Repository: okfn-brasil/querido-diario
Pull request #1389: Adiciona, em utils, função para reconhecimento de datas em texto 
Author: login: trevineju
Created: 2025-04-15T19:48:48Z | Merged: 2025-04-28T03:00:39Z

Esta PR parte do trabalho começado por @slfabio em #1333, que adiciona um utilitário para extrair um objeto date de um texto. Como um módulo de utils precisa atender muitos casos, o método foi incrementado a partir dos casos reais que o repositório tem -- por isso, a PR já migra diversos raspadores para utilizar o novo módulo. E, para dar robustez a nova utils, testes unitários para ela foram adicionados também.

Execução de todos os raspadores migrados: [validacoes_raspadores.zip](https://github.com/user-attachments/files/19912021/validacoes_raspadores.zip). Executei antes e depois da modificação, para validar que os casos que já estavam funcionando seguem funcionando, e eles batem com sucesso.

Os melhores casos de teste são: os que começam com `aratext_`, df_brasilia e rs_porto alegre. Eles tinham strings mais sujas ou que o formato varia. Os outros casos são bem padronizados.


Diff:
diff --git a/data_collection/gazette/spiders/al/al_maragogi.py b/data_collection/gazette/spiders/al/al_maragogi.py
index 3cc5ddd03..b86dd57b3 100644
--- a/data_collection/gazette/spiders/al/al_maragogi.py
+++ b/data_collection/gazette/spiders/al/al_maragogi.py
@@ -2,11 +2,11 @@
 from datetime import date
 from urllib.parse import parse_qs
 
-import dateparser
 from scrapy import FormRequest
 
 from gazette.items import Gazette
 from gazette.spiders.base import BaseGazetteSpider
+from gazette.utils.extraction import get_date_from_text
 
 
 class AlMaragogiSpider(BaseGazetteSpider):
@@ -31,7 +31,7 @@ def parse(self, response, current_page=1):
             edition_number = title_element.re_first(r"nº (\d+)/", re.IGNORECASE)
 
             date_raw = publication.css("div div div::text").getall()[1].strip()
-            item_date = dateparser.parse(date_raw, languages=["pt"]).date()
+            item_date = get_date_from_text(date_raw)
 
             file_id = publication.css("h4 > a::attr(href)").get().strip().split("/")[-1]
             url = f"{self.BASE_URL}/diario-oficial/versao-pdf/{file_id}"
diff --git a/data_collection/gazette/spiders/ba/ba_feira_de_santana.py b/data_collection/gazette/spiders/ba/ba_feira_de_santana.py
index d4cc80f31..737b63446 100644
--- a/data_collection/gazette/spiders/ba/ba_feira_de_santana.py
+++ b/data_collection/gazette/spiders/ba/ba_feira_de_santana.py
@@ -1,10 +1,10 @@
 from datetime import date, datetime
 
-from dateparser import parse
 from scrapy.http import Request
 
 from gazette.items import Gazette
 from gazette.spiders.base import BaseGazetteSpider
+from gazette.utils.extraction import get_date_from_text
 
 
 class BaFeiraDeSantanaSpider(BaseGazetteSpider):
@@ -42,7 +42,7 @@ def parse(self, response):
                 is_extra_edition = bool(is_extra_edition.css('[alt$="EXTRA"]').getall())
 
                 gazette = Gazette(
-                    date=parse(gazette_date, languages=["pt"]).date(),
+                    date=get_date_from_text(gazette_date),
                     is_extra_edition=is_extra_edition,
                     power=power,
                     edition_number=edition,
diff --git a/data_collection/gazette/spiders/base/aratext.py b/data_collection/gazette/spiders/base/aratext.py
index 50d08d589..ab055ffa1 100644
--- a/data_collection/gazette/spiders/base/aratext.py
+++ b/data_collection/gazette/spiders/base/aratext.py
@@ -1,11 +1,11 @@
 import re
 from urllib.parse import urlparse
 
-import dateparser
 from scrapy import Request
 
 from gazette.items import Gazette
 from gazette.spiders.base import BaseGazetteSpider
+from gazette.utils.extraction import get_date_from_text
 
 
 class BaseAratextSpider(BaseGazetteSpider):
@@ -14,16 +14,15 @@ def parse(self, response, page=1):
             raw_edition_date = (
                 item.css("td")[2].css("::text").get().split(",")[1].strip()
             )
-            edition_date = dateparser.parse(raw_edition_date, languages=["pt"]).date()
+            edition_date = get_date_from_text(raw_edition_date)
 
             raw_edition_number = item.css("a::text").get().strip()
-            edition_number = re.search(r"(\d+)/", raw_edition_number).group(1)
+            edition_number = re.search(r"N.? (.*)/", raw_edition_number).group(1)
 
             path = item.css("a").attrib["href"]
             intermediary_page = (
                 urlparse(self.start_urls[0])._replace(path=path).geturl()
             )
-
             if self.start_date <= edition_date <= self.end_date:
                 gazette = {
                     "date": edition_date,
diff --git a/data_collection/gazette/spiders/base/atende_v2.py b/data_collection/gazette/spiders/base/atende_v2.py
index dfd61502b..6523ef027 100644
--- a/data_collection/gazette/spiders/base/atende_v2.py
+++ b/data_collection/gazette/spiders/base/atende_v2.py
@@ -1,10 +1,10 @@
 import re
 
-import dateparser
 from scrapy import FormRequest
 
 from gazette.items import Gazette
 from gazette.spiders.base import BaseGazetteSpider
+from gazette.utils.extraction import get_date_from_text
 
 
 class BaseAtendeV2Spider(BaseGazetteSpider):
@@ -34,7 +34,7 @@ def start_requests(self):
     def parse(self, response, page):
         for item in response.css("div.nova_listagem div.linha"):
             date_raw = item.css("div.data::text").get()
-            date = dateparser.parse(date_raw, languages=["pt"]).date()
+            date = get_date_from_text(date_raw)
 
             if date > self.end_date:
                 continue
diff --git a/data_collection/gazette/spiders/base/diariooficialbr.py b/data_collection/gazette/spiders/base/diariooficialbr.py
index f9a2a4aa9..d7bf4dc1c 100644
--- a/data_collection/gazette/spiders/base/diariooficialbr.py
+++ b/data_collection/gazette/spiders/base/diariooficialbr.py
@@ -1,8 +1,8 @@
-import dateparser
 import scrapy
 
 from gazette.items import Gazette
 from gazette.spiders.base import BaseGazetteSpider
+from gazette.utils.extraction import get_date_from_text
 
 
 class BaseDiarioOficialBRSpider(BaseGazetteSpider):
@@ -16,9 +16,7 @@ def parse(self, response):
             edition_date_selector = edition.xpath(
                 './/div[contains(text(), "Publicado")]/text()'
             ).get()
-            edition_date = dateparser.parse(
-                edition_date_selector.split("dia")[-1], languages=["pt"]
-            ).date()
+            edition_date = get_date_from_text(edition_date_selector.split("dia")[-1])
 
             edition_number_raw = edition.xpath(
                 './/span[contains(text(), "Edição")]/text()'
diff --git a/data_collection/gazette/spiders/base/doem.py b/data_collection/gazette/spiders/base/doem.py
index a66d25a15..045c950a5 100644
--- a/data_collection/gazette/spiders/base/doem.py
+++ b/data_collection/gazette/spiders/base/doem.py
@@ -1,9 +1,9 @@
-import dateparser
 import scrapy
 
 from gazette.items import Gazette
 from gazette.spiders.base import BaseGazetteSpider
 from gazette.utils.dates import monthly_sequence
+from gazette.utils.extraction import get_date_from_text
 
 
 class BaseDoemSpider(BaseGazetteSpider):
@@ -63,7 +63,7 @@ def get_gazette_date(self, response_item):
         Get the date for the gazette inside one of the 'box-diario' divs
         """
         date = response_item.css("span.data-diario::text").get().strip()
-        return dateparser.parse(date, languages=["pt"]).date()
+        return get_date_from_text(date)
 
     def get_edition_number(self, response_item):
         """
diff --git a/data_collection/gazette/spiders/base/ptio.py b/data_collection/gazette/spiders/base/ptio.py
index ee0e77d63..a2efeb014 100644
--- a/data_collection/gazette/spiders/base/ptio.py
+++ b/data_collection/gazette/spiders/base/ptio.py
@@ -1,8 +1,8 @@
-import dateparser
 import scrapy
 
 from gazette.items import Gazette
 from gazette.spiders.base import BaseGazetteSpider
+from gazette.utils.extraction import get_date_from_text
 
 
 class BasePtioSpider(BaseGazetteSpider):
@@ -14,7 +14,7 @@ def parse(self, response):
             raw_gazete_date = gazette_div.xpath(
                 ".//div[@class='data-caderno hidden-phone']/text()"
             ).get()
-            gazette_date = dateparser.parse(raw_gazete_date).date()
+            gazette_date = get_date_from_text(raw_gazete_date)
 
             if gazette_date > self.end_date:
                 continue
diff --git a/data_collection/gazette/spiders/df/df_brasilia.py b/data_collection/gazette/spiders/df/df_brasilia.py
index ba8c5a3c9..93eaf5d38 100644
--- a/data_collection/gazette/spiders/df/df_brasilia.py
+++ b/data_collection/gazette/spiders/df/df_brasilia.py
@@ -1,12 +1,12 @@
 import datetime
 from urllib.parse import parse_qs, urlparse
 
-from dateparser import parse
 from scrapy import Request
 
 from gazette.items import Gazette
 from gazette.spiders.base import BaseGazetteSpider
 from gazette.utils.dates import monthly_sequence
+from gazette.utils.extraction import get_date_from_text
 
 MONTH_MAP = {
     idx + 1: value
@@ -51,7 +51,7 @@ def parse_month(self, response):
 
         for url in gazette_days_url:
             date = url.split("/")[-1]
-            date = parse(date, settings={"DATE_ORDER": "DMY"}).date()
+            date = get_date_from_text(date)
 
             if self.start_date <= date <= self.end_date:
                 yield Request(
diff --git a/data_collection/gazette/spiders/go/go_goiania.py b/data_collection/gazette/spiders/go/go_goiania.py
index cf44a990d..f0ce17cdf 100644
--- a/data_collection/gazette/spiders/go/go_goiania.py
+++ b/data_collection/gazette/spiders/go/go_goiania.py
@@ -2,10 +2,10 @@
 import re
 
 import scrapy
-from dateparser import parse
 
 from gazette.items import Gazette
 from gazette.spiders.base import BaseGazetteSpider
+from gazette.utils.extraction import get_date_from_text
 
 
 class GoGoianiaSpider(BaseGazetteSpider):
@@ -36,7 +36,7 @@ def parse(self, response):
                 continue
 
             edition_number, day, month, year = gazette_info
-            gazette_date = parse(f"{day} de {month} de {year}", languages=["pt"]).date()
+            gazette_date = get_date_from_text(f"{day} de {month} de {year}")
             if gazette_date < self.start_date:
                 continue
 
diff --git a/data_collection/gazette/spiders/ma/ma_sao_luis.py b/data_collection/gazette/spiders/ma/ma_sao_luis.py
index 4c6b43694..d1b66ac9c 100644
--- a/data_collection/gazette/spiders/ma/ma_sao_luis.py
+++ b/data_collection/gazette/spiders/ma/ma_sao_luis.py
@@ -2,11 +2,11 @@
 from datetime import date
 from urllib.parse import urlparse
 
-import dateparser
 from scrapy import Request
 
 from gazette.items import Gazette
 from gazette.spiders.base import BaseGazetteSpider
+from gazette.utils.extraction import get_date_from_text
 
 
 class MASaoLuisSpider(BaseGazetteSpider):
@@ -21,7 +21,7 @@ def parse(self, response, page=1):
             raw_infos = "".join(item.css("::text").getall()).strip()
 
             raw_edition_date = re.search(r",(.+)\s", raw_infos).group(1).strip()
-            edition_date = dateparser.parse(raw_edition_date, languages=["pt"]).date()
+            edition_date = get_date_from_text(raw_edition_date)
 
             if self.start_date <= edition_date <= self.end_date:
                 edition_number = re.search(r"(\d+)/", raw_infos).group(1)
diff --git a/data_collection/gazette/spiders/mg/mg_uberlandia.py b/data_collection/gazette/spiders/mg/mg_uberlandia.py
index 20e9cdb9e..0f03b923e 100644
--- a/data_collection/gazette/spiders/mg/mg_uberlandia.py
+++ b/data_collection/gazette/spiders/mg/mg_uberlandia.py
@@ -1,13 +1,13 @@
 import datetime
 import re
 
-import dateparser
 import scrapy
 import w3lib
 
 from gazette.items import Gazette
 from gazette.spiders.base import BaseGazetteSpider
 from gazette.utils.dates import monthly_sequence
+from gazette.utils.extraction import get_date_from_text
 
 
 class MgUberlandiaSpider(BaseGazetteSpider):
@@ -42,11 +42,11 @@ def on_error(self, failure):
     def parse(self, response):
         gazettes = response.css("article.elementor-post")
         for gazette in gazettes:
-            gazette_date = dateparser.parse(
+            gazette_date = get_date_from_text(
                 gazette.css(
                     ".elementor-post-date::text, .ee-post__metas__date::text"
                 ).get()
-            ).date()
+            )
             if gazette_date < self.start_date or gazette_date > self.end_date:
                 continue
 
diff --git a/data_collection/gazette/spiders/pe/pe_jaboatao_dos_guararapes.py b/data_collection/gazette/spiders/pe/pe_jaboatao_dos_guararapes.py
index 6b40b3ccd..7f323c3b5 100644
--- a/data_collection/gazette/spiders/pe/pe_jaboatao_dos_guararapes.py
+++ b/data_collection/gazette/spiders/pe/pe_jaboatao_dos_guararapes.py
@@ -1,10 +1,10 @@
 from datetime import date
 
-from dateparser import parse
 from scrapy import Request
 
 from gazette.items import Gazette
 from gazette.spiders.base import BaseGazetteSpider
+from gazette.utils.extraction import get_date_from_text
 
 
 class PeJaboataoDosGuararapesSpider(BaseGazetteSpider):
@@ -13,13 +13,11 @@ class PeJaboataoDosGuararapesSpider(BaseGazetteSpider):
     allowed_domains = ["diariooficial.jaboatao.pe.gov.br"]
     start_urls = ["https://diariooficial.jaboatao.pe.gov.br/"]
     start_date = date(2015, 10, 3)
-    end_date = date.today()
 
     def parse(self, response):
         for gazette_card in response.css(".elementor-post__card"):
-            gazette_date = parse(
-                gazette_card.css(".elementor-post-date::text").get(),
-            ).date()
+            raw_date = gazette_card.css(".elementor-post-date::text").get().strip()
+            gazette_date = get_date_from_text(raw_date)
 
             if gazette_date < self.start_date:
                 return
diff --git a/data_collection/gazette/spiders/pr/pr_maringa.py b/data_collection/gazette/spiders/pr/pr_maringa.py
index 59c1269ac..654998abf 100644
--- a/data_collection/gazette/spiders/pr/pr_maringa.py
+++ b/data_collection/gazette/spiders/pr/pr_maringa.py
@@ -1,14 +1,14 @@
 from datetime import date
 
 import scrapy
-from dateparser import parse
 
 from gazette.items import Gazette
 from gazette.spiders.base import BaseGazetteSpider
+from gazette.utils.extraction import get_date_from_text
 
 
 class PrMaringaSpider(BaseGazetteSpider):
-    zyte_smartproxy_enabled = True
+    # zyte_smartproxy_enabled = True
     TERRITORY_ID = "4115200"
     name = "pr_maringa"
     allowed_domains = ["maringa.pr.gov.br"]
@@ -35,7 +35,7 @@ def parse_year(self, response):
         for row in rows:
             gazette_file_link = row.css("td:nth-child(1) a::attr(href)").get()
             gazette_date = row.css("td:nth-child(2) font > font::text").get()
-            data = parse(gazette_date, languages=["pt"]).date()
+            data = get_date_from_text(gazette_date)
             edition = row.css("td:nth-child(1) a::text").re_first(r"\d+")
             if self.start_date <= data <= self.end_date:
                 yield Gazette(
diff --git a/data_collection/gazette/spiders/rr/rr_boa_vista.py b/data_collection/gazette/spiders/rr/rr_boa_vista.py
index 75f69eee4..ce502178d 100644
--- a/data_collection/gazette/spiders/rr/rr_boa_vista.py
+++ b/data_collection/gazette/spiders/rr/rr_boa_vista.py
@@ -1,10 +1,10 @@
 from datetime import date
 
-import dateparser
 from scrapy import Request
 
 from gazette.items import Gazette
 from gazette.spiders.base import BaseGazetteSpider
+from gazette.utils.extraction import get_date_from_text
 
 
 class RrBoaVistaSpider(BaseGazetteSpider):
@@ -18,7 +18,7 @@ class RrBoaVistaSpider(BaseGazetteSpider):
     def parse(self, response, page=1):
         for entry in response.json()["data"]:
             raw_date = entry["data"].split(", ")[-1]
-            date = dateparser.parse(raw_date, languages=["pt"]).date()
+            date = get_date_from_text(raw_date)
 
             if date < self.start_date:
                 return
diff --git a/data_collection/gazette/spiders/rs/rs_porto_alegre.py b/data_collection/gazette/spiders/rs/rs_porto_alegre.py
index 372362cda..534ed894c 100644
--- a/data_collection/gazette/spiders/rs/rs_porto_alegre.py
+++ b/data_collection/gazette/spiders/rs/rs_porto_alegre.py
@@ -1,10 +1,9 @@
 import datetime as dt
 
-import dateparser
-
 from gazette.items import Gazette
 from gazette.spiders.base import BaseGazetteSpider
 from gazette.utils.dates import YearMonthDate, monthly_sequence
+from gazette.utils.extraction import get_date_from_text
 
 
 class RsPortoAlegreSpider(BaseGazetteSpider):
@@ -57,18 +56,11 @@ def _filter_months_of_interest(self, month_elements):
                 yield href
 
     def _extract_date(self, text):
-        common_pattern = text.re_first(r"\d+/\d+/\d+")
-        full_written_pattern = text.re_first(r"\d{1,2}º?\s+de[\w\s]+\d{4}")
-        marco_2010_pattern = text.re_first(r"marco2010[_\s]+(\d{2})marco10")
-
-        if common_pattern:
-            return dt.datetime.strptime(common_pattern, "%d/%m/%Y").date()
-        elif full_written_pattern:
-            full_written_pattern = full_written_pattern.replace("º", "")
-            return dateparser.parse(full_written_pattern, languages=["pt"]).date()
-        elif marco_2010_pattern:
+        marco_2010_pattern = text.re_first(r"marco2010[_\s]+(\d{2})")
+        if marco_2010_pattern:
             day = int(marco_2010_pattern)
             return dt.date(2010, 3, day)
+        return get_date_from_text(text.get())
 
     def _get_power_from_url(self, url):
         if "executivo" in url.lower():
diff --git a/data_collection/gazette/spiders/sc/sc_joinville.py b/data_collection/gazette/spiders/sc/sc_joinville.py
index 7632b3266..96c38b296 100644
--- a/data_collection/gazette/spiders/sc/sc_joinville.py
+++ b/data_collection/gazette/spiders/sc/sc_joinville.py
@@ -1,10 +1,10 @@
 import datetime
 
-import dateparser
 from scrapy import Request
 
 from gazette.items import Gazette
 from gazette.spiders.base import BaseGazetteSpider
+from gazette.utils.extraction import get_date_from_text
 
 
 class ScJoinvilleSpider(BaseGazetteSpider):
@@ -47,7 +47,7 @@ def parse(self, response):
     def extract_date(self, element):
         date = element.css(self.DATE_CSS).re(self.DATE_REGEX)
         date = "/".join(date)
-        return dateparser.parse(date, languages=["pt"]).date()
+        return get_date_from_text(date)
 
     def extract_url(self, element):
         path = element.css("a::attr(href)").get()
diff --git a/data_collection/gazette/spiders/sp/sp_jundiai.py b/data_collection/gazette/spiders/sp/sp_jundiai.py
index 856f8ef13..ddb73832f 100644
--- a/data_collection/gazette/spiders/sp/sp_jundiai.py
+++ b/data_collection/gazette/spiders/sp/sp_jundiai.py
@@ -1,11 +1,11 @@
 import datetime as dt
 
-import dateparser
 import scrapy
 
 from gazette.items import Gazette
 from gazette.spiders.base import BaseGazetteSpider
 from gazette.utils.dates import monthly_sequence
+from gazette.utils.extraction import get_date_from_text
 
 
 class SpJundiaiSpider(BaseGazetteSpider):
@@ -24,7 +24,7 @@ def parse(self, response, current_page=1):
         editions = response.css("#lista-edicoes li.edicao-atual")
         for edition in editions:
             raw_date = edition.css(".data-lista div::text")[1].get()
-            date = dateparser.parse(raw_date, languages=["pt"]).date()
+            date = get_date_from_text(raw_date)
 
             if date > self.end_date:
                 continue
diff --git a/data_collection/gazette/utils/extraction.py b/data_collection/gazette/utils/extraction.py
new file mode 100644
index 000000000..9db0ccd99
--- /dev/null
+++ b/data_collection/gazette/utils/extraction.py
@@ -0,0 +1,203 @@
+import re
+from datetime import date
+
+from dateparser import parse
+from text_unidecode import unidecode
+from thefuzz import process
+
+
+def get_date_from_text(text):
+    """Return date identified in text
+
+    This func exists to extend date recognition cases that dateparser lib doesn't
+    cover. The basic logic is: first try to get date using dateparser, if it
+    doesn't work, try to get it using regex.
+
+    So, this works with known dateparser cases in pt-br, with commonly used separators
+    as '-', '/', 'de', etc. And adds ways to deal with inconsistent situations,
+    such as when there's only one separator, ordenation symbol (ªº) present
+    or mistyped month. Check test_dates.py in tests/ directory to see examples.
+
+    By default, it's set to do stric_parsing, meaning it doesn't try to parse
+    incomplete data, and only handles date in day month year order (DMY) for now,
+    because its enough for all using cases (but an improvement can be implemented
+    in the future, on demand)
+
+    Args:
+        text: string with date text in it
+
+    Returns:
+        Date object, if able to parse. Otherwise, returns None.
+    """
+    if not isinstance(text, str) or len(text) == 0:
+        return None
+
+    settings = {"DATE_ORDER": "DMY", "STRICT_PARSING": True}
+
+    # normalize text
+    text = unidecode(re.sub("[°ºª]", "", text)).lower().strip()
+
+    # Try to apply dateparser as always ...
+    parsed_date = parse(text, languages=["pt"], settings=settings)
+    if parsed_date:
+        return parsed_date.date()
+
+    # ... it didnt work, so try to parse manually
+
+    # note: in general, day and year are numbers, only month vary between being
+    # written or numbered. At the same time, a full numbered date text depend more
+    # of separator presence and number length logic to date be identified. But when
+    # month is written, month text already works as separator between numbers
+
+    if re.sub("[ -/_de]+", "", text).isdigit():
+        return get_date_from_numbered_text(text)
+    return get_date_from_month_written_text(text)
+
+
+def get_date_from_numbered_text(text, separator=r"[ /_-]"):
+    """Returns date identified in a numbered text
+
+    Date is identified through four cases, related to separator presence:
+    (a) 2 separatos: separates all parts
+    format: (D/DD) (M/MM) (YYYY)
+    examples: 1 1 1111 | 1 11 1111 | 11 1 1111 | 11 11 1111
+    possible to parse in any day, month, year length since its properly separated
+
+    (b) 1 separator, the first one: separates day from month+year part
+    format: (D/DD) (M/MM)(YYYY)
+    examples: 1 11111 | 1 111111 | 11 11111 | 11 111111
+    possible to parse if we assume year with 4 char length
+
+    (c) 1 separator, the second one: separates year from day+month part
+    format: (DD)(MM) (YYYY)
+    example: 1111 1111
+    possible to parse only when day and month have 2 char length
+
+    (d) no separator:
+    format: (DD)(MM)(YYYY)
+    example: 11111111
+    possible to parse only if 2 char day, 2 char month and 4 char year
+    """
+    if not isinstance(text, str) or len(text) == 0:
+        return None
+
+    regex_list = [
+        rf"(\d+){separator}(\d+){separator}(\d+)",  # case (a)
+        rf"(\d+){separator}(\d{{1,2}})(\d{{4}})",  # case (b)
+        rf"(\d{{2}})(\d{{2}}){separator}(\d+)",  # case (c)
+        r"(\d{2})(\d{2})(\d{4})",  # case (d)
+    ]
+
+    text = re.sub(" +", " ", text.replace("de", ""))
+    for regex_case in regex_list:
+        date = _apply_regex(regex_case, text, 1, 2, 3)
+        if date:
+            return date
+    return None
+
+
+def get_date_from_month_written_text(text, separator=r"[/_-]"):
+    """Returns date identified in text where month name is written in full
+
+    To avoid dirty strings, this func works by concatenating all text to apply
+    regex as parsing strategy. Also, replaces 'de' occurences to make 'de' separated
+    cases behave as if hyphen separated avoiding conflict with 'de' of 'dezembro'
+    """
+    if not isinstance(text, str) or len(text) == 0:
+        return None
+
+    concatenated_text = re.sub(" ", "", text)
+    text = concatenated_text.replace("de", "-").replace("-z", "dez")
+
+    # (D or DD)separators(month)separators(YYYY)
+    regex = rf"(\d{{1,2}}){separator}?([a-z0-9]+){separator}?(\d{{4}})"
+    return _apply_regex(regex, text, 1, 2, 3)
+
+
+def get_month_value(month_text):
+    """Identify month number from a text
+
+    Uses Levenshtein Distance algorithm from thefuzz lib to recognize text proximity
+    as strategy to fix mistyped month
+
+    Args:
+        month_text (str): text written or numbered about a month
+
+    Returns:
+        An int of month value, if match. Otherwise, returns None.
+    """
+    if not isinstance(month_text, str) or len(month_text) == 0:
+        return None
+
+    MONTHS = {
+        "janeiro": 1,
+        "fevereiro": 2,
+        "marco": 3,
+        "abril": 4,
+        "maio": 5,
+        "junho": 6,
+        "julho": 7,
+        "agosto": 8,
+        "setembro": 9,
+        "outubro": 10,
+        "novembro": 11,
+        "dezembro": 12,
+    }
+
+    conflicting_cases = ["juho"]
+
+    month_text = unidecode(month_text.strip().lower())
+
+    if month_text.isdigit() and int(month_text) in MONTHS.values():
+        return int(month_text)
+
+    if len(month_text) < 3 or month_text in conflicting_cases:
+        return None
+
+    if month_text not in MONTHS:
+        result = process.extractOne(month_text, MONTHS.keys(), score_cutoff=80)
+        return MONTHS[result[0]] if result else None
+
+    return MONTHS[month_text]
+
+
+def _apply_regex(regex, text, day_index, month_index, year_index):
+    """Returns date by applying regex to text, in day, month and year order, fixing
+    month writing in pt-br if necessary
+
+    Note:
+        By now, only DMY order is used when evoking this func, but the intention
+        is to allow more orders in future, on demand.
+
+    Examples:
+        >>> _apply_regex("...", "10 maio 2000", 1, 2, 3)       # DMY order
+        date(2000, 5, 10)
+        >>> _apply_regex("...", "2000-05-10", 3, 2, 1)         # YMD order
+        date(2000, 5, 10)
+        >>> _apply_regex("...", "May 10 2000", 2, 1, 3)        # MDY order
+        date(2000, 5, 10)
+
+    Args:
+        regex (str): a string representing a regex pattern
+        text (str): text to apply regex to
+        day_index (int): group index where day value is expected
+        month_index (int): group index where month is expected
+        year_index (int): group index where year is expected
+
+    Returns:
+        date: object representing date regex-founded in text
+        None: if regex didn't match or date doesn't exist
+    """
+    match = re.search(regex, text)
+
+    if match:
+        day = match.group(day_index)
+        month = get_month_value(match.group(month_index))
+        year = match.group(year_index)
+
+        try:
+            parsed_date = date(int(year), month, int(day))
+            return parsed_date
+        except Exception:
+            return None
+    return None
diff --git a/data_collection/requirements-dev.txt b/data_collection/requirements-dev.txt
index 40218adec..1311c6635 100644
--- a/data_collection/requirements-dev.txt
+++ b/data_collection/requirements-dev.txt
@@ -655,6 +655,9 @@ python-dateutil==2.8.2 \
 python-decouple==3.8 \
     --hash=sha256:ba6e2657d4f376ecc46f77a3a615e058d93ba5e465c01bbe57289bfb7cce680f \
     --hash=sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66
+python-levenshtein==0.26.1 \
+    --hash=sha256:24ba578e28058ebb4afa2700057e1678d7adf27e43cd1f17700c09a9009d5d3a \
+    --hash=sha256:8ef5e529dd640fb00f05ee62d998d2ee862f19566b641ace775d5ae16167b2ef
 python-slugify==8.0.1 \
     --hash=sha256:70ca6ea68fe63ecc8fa4fcf00ae651fc8a5d02d93dcd12ae6d4fc7ca46c4d395 \
     --hash=sha256:ce0d46ddb668b3be82f4ed5e503dbc33dd815d83e2eb6824211310d3fb172a27
@@ -678,6 +681,101 @@ pyyaml==5.3.1 \
 queuelib==1.6.2 \
     --hash=sha256:4b207267f2642a8699a1f806045c56eb7ad1a85a10c0e249884580d139c2fcd2 \
     --hash=sha256:4b96d48f650a814c6fb2fd11b968f9c46178b683aad96d68f930fe13a8574d19
+rapidfuzz==3.12.2 \
+    --hash=sha256:02e6466caa0222d5233b1f05640873671cd99549a5c5ba4c29151634a1e56080 \
+    --hash=sha256:09227bd402caa4397ba1d6e239deea635703b042dd266a4092548661fb22b9c6 \
+    --hash=sha256:095776b11bb45daf7c2973dd61cc472d7ea7f2eecfa454aef940b4675659b92f \
+    --hash=sha256:0b9a75e0385a861178adf59e86d6616cbd0d5adca7228dc9eeabf6f62cf5b0b1 \
+    --hash=sha256:0f05b7b95f9f87254b53fa92048367a8232c26cee7fc8665e4337268c3919def \
+    --hash=sha256:17e1a3a8b4b5125cfb63a6990459b25b87ea769bdaf90d05bb143f8febef076a \
+    --hash=sha256:195adbb384d89d6c55e2fd71e7fb262010f3196e459aa2f3f45f31dd7185fe72 \
+    --hash=sha256:19fa5bc4301a1ee55400d4a38a8ecf9522b0391fc31e6da5f4d68513fe5c0026 \
+    --hash=sha256:1a314d170ee272ac87579f25a6cf8d16a031e1f7a7b07663434b41a1473bc501 \
+    --hash=sha256:1ae9ded463f2ca4ba1eb762913c5f14c23d2e120739a62b7f4cc102eab32dc90 \
+    --hash=sha256:1d982a651253ffe8434d9934ff0c1089111d60502228464721a2a4587435e159 \
+    --hash=sha256:21d5b3793c6f5aecca595cd24164bf9d3c559e315ec684f912146fc4e769e367 \
+    --hash=sha256:24081077b571ec4ee6d5d7ea0e49bc6830bf05b50c1005028523b9cd356209f3 \
+    --hash=sha256:296bf0fd4f678488670e262c87a3e4f91900b942d73ae38caa42a417e53643b1 \
+    --hash=sha256:29ca445e320e5a8df3bd1d75b4fa4ecfa7c681942b9ac65b55168070a1a1960e \
+    --hash=sha256:2b9e43cf2213e524f3309d329f1ad8dbf658db004ed44f6ae1cd2919aa997da5 \
+    --hash=sha256:2dee7d740a2d5418d4f964f39ab8d89923e6b945850db833e798a1969b19542a \
+    --hash=sha256:2f9b0adc3d894beb51f5022f64717b6114a6fabaca83d77e93ac7675911c8cc5 \
+    --hash=sha256:30bf15c1ecec2798b713d551df17f23401a3e3653ad9ed4e83ad1c2b06e86100 \
+    --hash=sha256:32691aa59577f42864d5535cb6225d0f47e2c7bff59cf4556e5171e96af68cc1 \
+    --hash=sha256:36d3ef4f047ed1bc96fa29289f9e67a637ddca5e4f4d3dc7cb7f50eb33ec1664 \
+    --hash=sha256:39b343b6cb4b2c3dbc8d2d4c5ee915b6088e3b144ddf8305a57eaab16cf9fc74 \
+    --hash=sha256:3b3c4df0321df6f8f0b61afbaa2ced9622750ee1e619128db57a18533d139820 \
+    --hash=sha256:3f2ddd5b99b254039a8c82be5749d4d75943f62eb2c2918acf6ffd586852834f \
+    --hash=sha256:40c0f16d62d6553527de3dab2fb69709c4383430ea44bce8fb4711ed4cbc6ae3 \
+    --hash=sha256:42e7f747b55529a6d0d1588695d71025e884ab48664dca54b840413dea4588d8 \
+    --hash=sha256:46a616c0e13cff2de1761b011e0b14bb73b110182f009223f1453d505c9a975c \
+    --hash=sha256:5032cbffa245b4beba0067f8ed17392ef2501b346ae3c1f1d14b950edf4b6115 \
+    --hash=sha256:50a9c54c0147b468363119132d514c5024fbad1ed8af12bd8bd411b0119f9208 \
+    --hash=sha256:5385398d390c6571f0f2a7837e6ddde0c8b912dac096dc8c87208ce9aaaa7570 \
+    --hash=sha256:544a47190a0d25971658a9365dba7095397b4ce3e897f7dd0a77ca2cf6fa984e \
+    --hash=sha256:54bb69ebe5ca0bd7527357e348f16a4c0c52fe0c2fcc8a041010467dcb8385f7 \
+    --hash=sha256:54e53662d71ed660c83c5109127c8e30b9e607884b7c45d2aff7929bbbd00589 \
+    --hash=sha256:55a43be0e0fa956a919043c19d19bd988991d15c59f179d413fe5145ed9deb43 \
+    --hash=sha256:55bcc003541f5f16ec0a73bf6de758161973f9e8d75161954380738dd147f9f2 \
+    --hash=sha256:58d9ae5cf9246d102db2a2558b67fe7e73c533e5d769099747921232d88b9be2 \
+    --hash=sha256:5df0cecc2852fcb078ed1b4482fac4fc2c2e7787f3edda8920d9a4c0f51b1c95 \
+    --hash=sha256:648adc2dd2cf873efc23befcc6e75754e204a409dfa77efd0fea30d08f22ef9d \
+    --hash=sha256:6906a7eb458731e3dd2495af1d0410e23a21a2a2b7ced535e6d5cd15cb69afc5 \
+    --hash=sha256:6938738e00d9eb6e04097b3f565097e20b0c398f9c58959a2bc64f7f6be3d9da \
+    --hash=sha256:69f6ecdf1452139f2b947d0c169a605de578efdb72cbb2373cb0a94edca1fd34 \
+    --hash=sha256:6c506bdc2f304051592c0d3b0e82eed309248ec10cdf802f13220251358375ea \
+    --hash=sha256:6c7152d77b2eb6bfac7baa11f2a9c45fd5a2d848dbb310acd0953b3b789d95c9 \
+    --hash=sha256:71cf1ea16acdebe9e2fb62ee7a77f8f70e877bebcbb33b34e660af2eb6d341d9 \
+    --hash=sha256:758b10380ad34c1f51753a070d7bb278001b5e6fcf544121c6df93170952d705 \
+    --hash=sha256:7635fe34246cd241c8e35eb83084e978b01b83d5ef7e5bf72a704c637f270017 \
+    --hash=sha256:780b4469ee21cf62b1b2e8ada042941fd2525e45d5fb6a6901a9798a0e41153c \
+    --hash=sha256:78fafaf7f5a48ee35ccd7928339080a0136e27cf97396de45259eca1d331b714 \
+    --hash=sha256:7957f5d768de14f6b2715303ccdf224b78416738ee95a028a2965c95f73afbfb \
+    --hash=sha256:7a6eaec2ef658dd650c6eb9b36dff7a361ebd7d8bea990ce9d639b911673b2cb \
+    --hash=sha256:7e2574cf4aa86065600b664a1ac7b8b8499107d102ecde836aaaa403fc4f1784 \
+    --hash=sha256:8117dab9b26a1aaffab59b4e30f80ac4d55e61ad4139a637c149365960933bee \
+    --hash=sha256:83eb7ef732c2f8533c6b5fbe69858a722c218acc3e1fc190ab6924a8af7e7e0e \
+    --hash=sha256:85a54ce30345cff2c79cbcffa063f270ad1daedd0d0c3ff6e541d3c3ba4288cf \
+    --hash=sha256:941f31038dba5d3dedcfcceba81d61570ad457c873a24ceb13f4f44fcb574260 \
+    --hash=sha256:96fa00bc105caa34b6cd93dca14a29243a3a7f0c336e4dcd36348d38511e15ac \
+    --hash=sha256:97fb05e1ddb7b71a054040af588b0634214ee87cea87900d309fafc16fd272a4 \
+    --hash=sha256:9a11e1d036170bbafa43a9e63d8c309273564ec5bdfc5439062f439d1a16965a \
+    --hash=sha256:9b1e6f48e1ffa0749261ee23a1c6462bdd0be5eac83093f4711de17a42ae78ad \
+    --hash=sha256:9f9132c55d330f0a1d34ce6730a76805323a6250d97468a1ca766a883d6a9a25 \
+    --hash=sha256:a057cdb0401e42c84b6516c9b1635f7aedd5e430c6e388bd5f6bcd1d6a0686bb \
+    --hash=sha256:a3692d4ab36d44685f61326dca539975a4eda49b2a76f0a3df177d8a2c0de9d2 \
+    --hash=sha256:a749fd2690f24ef256b264a781487746bbb95344364fe8fe356f0eef7ef206ba \
+    --hash=sha256:acb63c5072c08058f8995404201a52fc4e1ecac105548a4d03c6c6934bda45a3 \
+    --hash=sha256:b0ba1ccc22fff782e7152a3d3d0caca44ec4e32dc48ba01c560b8593965b5aa3 \
+    --hash=sha256:b3745c6443890265513a3c8777f2de4cb897aeb906a406f97741019be8ad5bcc \
+    --hash=sha256:b4c5a0413589aef936892fbfa94b7ff6f7dd09edf19b5a7b83896cc9d4e8c184 \
+    --hash=sha256:b63170d9db00629b5b3f2862114d8d6ee19127eaba0eee43762d62a25817dbe0 \
+    --hash=sha256:b9f8177b24ccc0a843e85932b1088c5e467a7dd7a181c13f84c684b796bea815 \
+    --hash=sha256:bccfb30c668620c5bc3490f2dc7d7da1cca0ead5a9da8b755e2e02e2ef0dff14 \
+    --hash=sha256:bd9a67cfc83e8453ef17ddd1c2c4ce4a74d448a197764efb54c29f29fb41f611 \
+    --hash=sha256:c4c852cd8bed1516a64fd6e2d4c6f270d4356196ee03fda2af1e5a9e13c34643 \
+    --hash=sha256:c988a4fc91856260355773bf9d32bebab2083d4c6df33fafeddf4330e5ae9139 \
+    --hash=sha256:cf165a76870cd875567941cf861dfd361a0a6e6a56b936c5d30042ddc9def090 \
+    --hash=sha256:d41e8231326e94fd07c4d8f424f6bed08fead6f5e6688d1e6e787f1443ae7631 \
+    --hash=sha256:d5a3425a6c50fd8fbd991d8f085ddb504791dae6ef9cc3ab299fea2cb5374bef \
+    --hash=sha256:d7701769f110332cde45c41759cb2a497de8d2dca55e4c519a46aed5fbb19d1a \
+    --hash=sha256:dccf8d4fb5b86d39c581a59463c596b1d09df976da26ff04ae219604223d502f \
+    --hash=sha256:dda45f47b559be72ecbce45c7f71dc7c97b9772630ab0f3286d97d2c3025ab71 \
+    --hash=sha256:dfb337f1832c1231e3d5621bd0ebebb854e46036aedae3e6a49c1fc08f16f249 \
+    --hash=sha256:e0c7989ff32c077bb8fd53253fd6ca569d1bfebc80b17557e60750e6909ba4fe \
+    --hash=sha256:e3ceb87c11d2d0fbe8559bb795b0c0604b84cfc8bb7b8720b5c16e9e31e00f41 \
+    --hash=sha256:e5fd3ce849b27d063755829cda27a9dab6dbd63be3801f2a40c60ec563a4c90f \
+    --hash=sha256:e88c6e68fca301722fa3ab7fd3ca46998012c14ada577bc1e2c2fc04f2067ca6 \
+    --hash=sha256:e956b3f053e474abae69ac693a52742109d860ac2375fe88e9387d3277f4c96c \
+    --hash=sha256:e9c4d984621ae17404c58f8d06ed8b025e167e52c0e6a511dfec83c37e9220cd \
+    --hash=sha256:edd84b0a323885493c893bad16098c5e3b3005d7caa995ae653da07373665d97 \
+    --hash=sha256:efa22059c765b3d8778083805b199deaaf643db070f65426f87d274565ddf36a \
+    --hash=sha256:f177e1eb6e4f5261a89c475e21bce7a99064a8f217d2336fb897408f46f0ceaf \
+    --hash=sha256:f21af27c5e001f0ba1b88c36a0936437dfe034c452548d998891c21125eb640f \
+    --hash=sha256:f43b773a4d4950606fb25568ecde5f25280daf8f97b87eb323e16ecd8177b328 \
+    --hash=sha256:f4b3334a8958b689f292d5ce8a928140ac98919b51e084f04bf0c14276e4c6ba \
+    --hash=sha256:f7c9a003002434889255ff5676ca0f8934a478065ab5e702f75dc42639505bba \
+    --hash=sha256:fe2dfc454ee51ba168a67b1e92b72aad251e45a074972cef13340bbad2fd9438
 referencing==0.30.2 \
     --hash=sha256:449b6669b6121a9e96a7f9e410b245d471e8d48964c67113ce9afe50c8dd7bdf \
     --hash=sha256:794ad8003c65938edcdbc027f1933215e0d0ccc0291e3ce20a4d87432b59efc0
@@ -946,6 +1044,9 @@ sqlalchemy==2.0.21 \
 text-unidecode==1.3 \
     --hash=sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8 \
     --hash=sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93
+thefuzz==0.22.1 \
+    --hash=sha256:59729b33556850b90e1093c4cf9e618af6f2e4c985df193fdf3c5b5cf02ca481 \
+    --hash=sha256:7138039a7ecf540da323792d8592ef9902b1d79eb78c147d4f20664de79f3680
 tldextract==3.6.0 \
     --hash=sha256:30a492de80f4de215aa998588ba5c2e625ee74ace3a2705cfb52b0021053bcbe \
     --hash=sha256:a5d8b6583791daca268a7592ebcf764152fa49617983c49916ee9de99b366222
diff --git a/data_collection/requirements.in b/data_collection/requirements.in
index 899f4d4ba..6e590dc72 100644
--- a/data_collection/requirements.in
+++ b/data_collection/requirements.in
@@ -4,12 +4,14 @@ click
 chompjs
 dateparser
 filetype
+thefuzz
 itemadapter
 jinja2
 psycopg2-binary
 pyyaml==5.3.1
 python-dateutil
 python-decouple
+python-Levenshtein
 scrapy
 scrapy-zyte-smartproxy
 SQLAlchemy
diff --git a/data_collection/requirements.txt b/data_collection/requirements.txt
index 41bb67513..2d02e21b1 100644
--- a/data_collection/requirements.txt
+++ b/data_collection/requirements.txt
@@ -572,6 +572,9 @@ python-dateutil==2.8.2 \
 python-decouple==3.8 \
     --hash=sha256:ba6e2657d4f376ecc46f77a3a615e058d93ba5e465c01bbe57289bfb7cce680f \
     --hash=sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66
+python-levenshtein==0.26.1 \
+    --hash=sha256:24ba578e28058ebb4afa2700057e1678d7adf27e43cd1f17700c09a9009d5d3a \
+    --hash=sha256:8ef5e529dd640fb00f05ee62d998d2ee862f19566b641ace775d5ae16167b2ef
 python-slugify==8.0.1 \
     --hash=sha256:70ca6ea68fe63ecc8fa4fcf00ae651fc8a5d02d93dcd12ae6d4fc7ca46c4d395 \
     --hash=sha256:ce0d46ddb668b3be82f4ed5e503dbc33dd815d83e2eb6824211310d3fb172a27
@@ -595,6 +598,101 @@ pyyaml==5.3.1 \
 queuelib==1.6.2 \
     --hash=sha256:4b207267f2642a8699a1f806045c56eb7ad1a85a10c0e249884580d139c2fcd2 \
     --hash=sha256:4b96d48f650a814c6fb2fd11b968f9c46178b683aad96d68f930fe13a8574d19
+rapidfuzz==3.12.2 \
+    --hash=sha256:02e6466caa0222d5233b1f05640873671cd99549a5c5ba4c29151634a1e56080 \
+    --hash=sha256:09227bd402caa4397ba1d6e239deea635703b042dd266a4092548661fb22b9c6 \
+    --hash=sha256:095776b11bb45daf7c2973dd61cc472d7ea7f2eecfa454aef940b4675659b92f \
+    --hash=sha256:0b9a75e0385a861178adf59e86d6616cbd0d5adca7228dc9eeabf6f62cf5b0b1 \
+    --hash=sha256:0f05b7b95f9f87254b53fa92048367a8232c26cee7fc8665e4337268c3919def \
+    --hash=sha256:17e1a3a8b4b5125cfb63a6990459b25b87ea769bdaf90d05bb143f8febef076a \
+    --hash=sha256:195adbb384d89d6c55e2fd71e7fb262010f3196e459aa2f3f45f31dd7185fe72 \
+    --hash=sha256:19fa5bc4301a1ee55400d4a38a8ecf9522b0391fc31e6da5f4d68513fe5c0026 \
+    --hash=sha256:1a314d170ee272ac87579f25a6cf8d16a031e1f7a7b07663434b41a1473bc501 \
+    --hash=sha256:1ae9ded463f2ca4ba1eb762913c5f14c23d2e120739a62b7f4cc102eab32dc90 \
+    --hash=sha256:1d982a651253ffe8434d9934ff0c1089111d60502228464721a2a4587435e159 \
+    --hash=sha256:21d5b3793c6f5aecca595cd24164bf9d3c559e315ec684f912146fc4e769e367 \
+    --hash=sha256:24081077b571ec4ee6d5d7ea0e49bc6830bf05b50c1005028523b9cd356209f3 \
+    --hash=sha256:296bf0fd4f678488670e262c87a3e4f91900b942d73ae38caa42a417e53643b1 \
+    --hash=sha256:29ca445e320e5a8df3bd1d75b4fa4ecfa7c681942b9ac65b55168070a1a1960e \
+    --hash=sha256:2b9e43cf2213e524f3309d329f1ad8dbf658db004ed44f6ae1cd2919aa997da5 \
+    --hash=sha256:2dee7d740a2d5418d4f964f39ab8d89923e6b945850db833e798a1969b19542a \
+    --hash=sha256:2f9b0adc3d894beb51f5022f64717b6114a6fabaca83d77e93ac7675911c8cc5 \
+    --hash=sha256:30bf15c1ecec2798b713d551df17f23401a3e3653ad9ed4e83ad1c2b06e86100 \
+    --hash=sha256:32691aa59577f42864d5535cb6225d0f47e2c7bff59cf4556e5171e96af68cc1 \
+    --hash=sha256:36d3ef4f047ed1bc96fa29289f9e67a637ddca5e4f4d3dc7cb7f50eb33ec1664 \
+    --hash=sha256:39b343b6cb4b2c3dbc8d2d4c5ee915b6088e3b144ddf8305a57eaab16cf9fc74 \
+    --hash=sha256:3b3c4df0321df6f8f0b61afbaa2ced9622750ee1e619128db57a18533d139820 \
+    --hash=sha256:3f2ddd5b99b254039a8c82be5749d4d75943f62eb2c2918acf6ffd586852834f \
+    --hash=sha256:40c0f16d62d6553527de3dab2fb69709c4383430ea44bce8fb4711ed4cbc6ae3 \
+    --hash=sha256:42e7f747b55529a6d0d1588695d71025e884ab48664dca54b840413dea4588d8 \
+    --hash=sha256:46a616c0e13cff2de1761b011e0b14bb73b110182f009223f1453d505c9a975c \
+    --hash=sha256:5032cbffa245b4beba0067f8ed17392ef2501b346ae3c1f1d14b950edf4b6115 \
+    --hash=sha256:50a9c54c0147b468363119132d514c5024fbad1ed8af12bd8bd411b0119f9208 \
+    --hash=sha256:5385398d390c6571f0f2a7837e6ddde0c8b912dac096dc8c87208ce9aaaa7570 \
+    --hash=sha256:544a47190a0d25971658a9365dba7095397b4ce3e897f7dd0a77ca2cf6fa984e \
+    --hash=sha256:54bb69ebe5ca0bd7527357e348f16a4c0c52fe0c2fcc8a041010467dcb8385f7 \
+    --hash=sha256:54e53662d71ed660c83c5109127c8e30b9e607884b7c45d2aff7929bbbd00589 \
+    --hash=sha256:55a43be0e0fa956a919043c19d19bd988991d15c59f179d413fe5145ed9deb43 \
+    --hash=sha256:55bcc003541f5f16ec0a73bf6de758161973f9e8d75161954380738dd147f9f2 \
+    --hash=sha256:58d9ae5cf9246d102db2a2558b67fe7e73c533e5d769099747921232d88b9be2 \
+    --hash=sha256:5df0cecc2852fcb078ed1b4482fac4fc2c2e7787f3edda8920d9a4c0f51b1c95 \
+    --hash=sha256:648adc2dd2cf873efc23befcc6e75754e204a409dfa77efd0fea30d08f22ef9d \
+    --hash=sha256:6906a7eb458731e3dd2495af1d0410e23a21a2a2b7ced535e6d5cd15cb69afc5 \
+    --hash=sha256:6938738e00d9eb6e04097b3f565097e20b0c398f9c58959a2bc64f7f6be3d9da \
+    --hash=sha256:69f6ecdf1452139f2b947d0c169a605de578efdb72cbb2373cb0a94edca1fd34 \
+    --hash=sha256:6c506bdc2f304051592c0d3b0e82eed309248ec10cdf802f13220251358375ea \
+    --hash=sha256:6c7152d77b2eb6bfac7baa11f2a9c45fd5a2d848dbb310acd0953b3b789d95c9 \
+    --hash=sha256:71cf1ea16acdebe9e2fb62ee7a77f8f70e877bebcbb33b34e660af2eb6d341d9 \
+    --hash=sha256:758b10380ad34c1f51753a070d7bb278001b5e6fcf544121c6df93170952d705 \
+    --hash=sha256:7635fe34246cd241c8e35eb83084e978b01b83d5ef7e5bf72a704c637f270017 \
+    --hash=sha256:780b4469ee21cf62b1b2e8ada042941fd2525e45d5fb6a6901a9798a0e41153c \
+    --hash=sha256:78fafaf7f5a48ee35ccd7928339080a0136e27cf97396de45259eca1d331b714 \
+    --hash=sha256:7957f5d768de14f6b2715303ccdf224b78416738ee95a028a2965c95f73afbfb \
+    --hash=sha256:7a6eaec2ef658dd650c6eb9b36dff7a361ebd7d8bea990ce9d639b911673b2cb \
+    --hash=sha256:7e2574cf4aa86065600b664a1ac7b8b8499107d102ecde836aaaa403fc4f1784 \
+    --hash=sha256:8117dab9b26a1aaffab59b4e30f80ac4d55e61ad4139a637c149365960933bee \
+    --hash=sha256:83eb7ef732c2f8533c6b5fbe69858a722c218acc3e1fc190ab6924a8af7e7e0e \
+    --hash=sha256:85a54ce30345cff2c79cbcffa063f270ad1daedd0d0c3ff6e541d3c3ba4288cf \
+    --hash=sha256:941f31038dba5d3dedcfcceba81d61570ad457c873a24ceb13f4f44fcb574260 \
+    --hash=sha256:96fa00bc105caa34b6cd93dca14a29243a3a7f0c336e4dcd36348d38511e15ac \
+    --hash=sha256:97fb05e1ddb7b71a054040af588b0634214ee87cea87900d309fafc16fd272a4 \
+    --hash=sha256:9a11e1d036170bbafa43a9e63d8c309273564ec5bdfc5439062f439d1a16965a \
+    --hash=sha256:9b1e6f48e1ffa0749261ee23a1c6462bdd0be5eac83093f4711de17a42ae78ad \
+    --hash=sha256:9f9132c55d330f0a1d34ce6730a76805323a6250d97468a1ca766a883d6a9a25 \
+    --hash=sha256:a057cdb0401e42c84b6516c9b1635f7aedd5e430c6e388bd5f6bcd1d6a0686bb \
+    --hash=sha256:a3692d4ab36d44685f61326dca539975a4eda49b2a76f0a3df177d8a2c0de9d2 \
+    --hash=sha256:a749fd2690f24ef256b264a781487746bbb95344364fe8fe356f0eef7ef206ba \
+    --hash=sha256:acb63c5072c08058f8995404201a52fc4e1ecac105548a4d03c6c6934bda45a3 \
+    --hash=sha256:b0ba1ccc22fff782e7152a3d3d0caca44ec4e32dc48ba01c560b8593965b5aa3 \
+    --hash=sha256:b3745c6443890265513a3c8777f2de4cb897aeb906a406f97741019be8ad5bcc \
+    --hash=sha256:b4c5a0413589aef936892fbfa94b7ff6f7dd09edf19b5a7b83896cc9d4e8c184 \
+    --hash=sha256:b63170d9db00629b5b3f2862114d8d6ee19127eaba0eee43762d62a25817dbe0 \
+    --hash=sha256:b9f8177b24ccc0a843e85932b1088c5e467a7dd7a181c13f84c684b796bea815 \
+    --hash=sha256:bccfb30c668620c5bc3490f2dc7d7da1cca0ead5a9da8b755e2e02e2ef0dff14 \
+    --hash=sha256:bd9a67cfc83e8453ef17ddd1c2c4ce4a74d448a197764efb54c29f29fb41f611 \
+    --hash=sha256:c4c852cd8bed1516a64fd6e2d4c6f270d4356196ee03fda2af1e5a9e13c34643 \
+    --hash=sha256:c988a4fc91856260355773bf9d32bebab2083d4c6df33fafeddf4330e5ae9139 \
+    --hash=sha256:cf165a76870cd875567941cf861dfd361a0a6e6a56b936c5d30042ddc9def090 \
+    --hash=sha256:d41e8231326e94fd07c4d8f424f6bed08fead6f5e6688d1e6e787f1443ae7631 \
+    --hash=sha256:d5a3425a6c50fd8fbd991d8f085ddb504791dae6ef9cc3ab299fea2cb5374bef \
+    --hash=sha256:d7701769f110332cde45c41759cb2a497de8d2dca55e4c519a46aed5fbb19d1a \
+    --hash=sha256:dccf8d4fb5b86d39c581a59463c596b1d09df976da26ff04ae219604223d502f \
+    --hash=sha256:dda45f47b559be72ecbce45c7f71dc7c97b9772630ab0f3286d97d2c3025ab71 \
+    --hash=sha256:dfb337f1832c1231e3d5621bd0ebebb854e46036aedae3e6a49c1fc08f16f249 \
+    --hash=sha256:e0c7989ff32c077bb8fd53253fd6ca569d1bfebc80b17557e60750e6909ba4fe \
+    --hash=sha256:e3ceb87c11d2d0fbe8559bb795b0c0604b84cfc8bb7b8720b5c16e9e31e00f41 \
+    --hash=sha256:e5fd3ce849b27d063755829cda27a9dab6dbd63be3801f2a40c60ec563a4c90f \
+    --hash=sha256:e88c6e68fca301722fa3ab7fd3ca46998012c14ada577bc1e2c2fc04f2067ca6 \
+    --hash=sha256:e956b3f053e474abae69ac693a52742109d860ac2375fe88e9387d3277f4c96c \
+    --hash=sha256:e9c4d984621ae17404c58f8d06ed8b025e167e52c0e6a511dfec83c37e9220cd \
+    --hash=sha256:edd84b0a323885493c893bad16098c5e3b3005d7caa995ae653da07373665d97 \
+    --hash=sha256:efa22059c765b3d8778083805b199deaaf643db070f65426f87d274565ddf36a \
+    --hash=sha256:f177e1eb6e4f5261a89c475e21bce7a99064a8f217d2336fb897408f46f0ceaf \
+    --hash=sha256:f21af27c5e001f0ba1b88c36a0936437dfe034c452548d998891c21125eb640f \
+    --hash=sha256:f43b773a4d4950606fb25568ecde5f25280daf8f97b87eb323e16ecd8177b328 \
+    --hash=sha256:f4b3334a8958b689f292d5ce8a928140ac98919b51e084f04bf0c14276e4c6ba \
+    --hash=sha256:f7c9a003002434889255ff5676ca0f8934a478065ab5e702f75dc42639505bba \
+    --hash=sha256:fe2dfc454ee51ba168a67b1e92b72aad251e45a074972cef13340bbad2fd9438
 referencing==0.30.2 \
     --hash=sha256:449b6669b6121a9e96a7f9e410b245d471e8d48964c67113ce9afe50c8dd7bdf \
     --hash=sha256:794ad8003c65938edcdbc027f1933215e0d0ccc0291e3ce20a4d87432b59efc0
@@ -863,6 +961,9 @@ sqlalchemy==2.0.21 \
 text-unidecode==1.3 \
     --hash=sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8 \
     --hash=sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93
+thefuzz==0.22.1 \
+    --hash=sha256:59729b33556850b90e1093c4cf9e618af6f2e4c985df193fdf3c5b5cf02ca481 \
+    --hash=sha256:7138039a7ecf540da323792d8592ef9902b1d79eb78c147d4f20664de79f3680
 tldextract==3.6.0 \
     --hash=sha256:30a492de80f4de215aa998588ba5c2e625ee74ace3a2705cfb52b0021053bcbe \
     --hash=sha256:a5d8b6583791daca268a7592ebcf764152fa49617983c49916ee9de99b366222
diff --git a/data_collection/tests/test_extraction.py b/data_collection/tests/test_extraction.py
new file mode 100644
index 000000000..8481e1e57
--- /dev/null
+++ b/data_collection/tests/test_extraction.py
@@ -0,0 +1,494 @@
+from datetime import date
+
+from gazette.utils.extraction import (
+    get_date_from_month_written_text,
+    get_date_from_numbered_text,
+    get_date_from_text,
+    get_month_value,
+)
+
+
+class TestExtractDateFromText:
+    # TEXT ARG
+    def test_if_text_should_be_string(self):
+        assert get_date_from_text(None) is None
+        assert get_date_from_text("") is None
+        assert get_date_from_text([]) is None
+
+    def test_if_text_is_empty_string_should_return_none(self):
+        assert get_date_from_text("") is None
+
+    # CONTENT
+    def test_presence_of_ordenation_symbol_should_behave_as_if_without_it(self):
+        assert get_date_from_text("15º março 2021") == date(2021, 3, 15)
+        assert get_date_from_text("27 10° 1999") == date(1999, 10, 27)
+        assert get_date_from_text("01ª 08 1879") == date(1879, 8, 1)
+
+    def test_if_text_with_multiple_whitespaces_is_parsed(self):
+        # space in separator
+        assert get_date_from_text("01       02        2023") == date(2023, 2, 1)
+        assert get_date_from_text("01    fevereiro    2023") == date(2023, 2, 1)
+
+    def test_upper_cased_words_should_be_parsed(self):
+        assert get_date_from_text("02 JaNeIrO 2023") == date(2023, 1, 2)
+        assert get_date_from_text("02 jAnEiRo 2023") == date(2023, 1, 2)
+
+    def test_if_text_with_diacritics_should_be_parsed_as_if_without_it(self):
+        assert get_date_from_text("4 março 2028") == date(2028, 3, 4)
+        assert get_date_from_text("4 máíó 2028") == date(2028, 5, 4)
+        assert get_date_from_text("4 mâîô 2028") == date(2028, 5, 4)
+        assert get_date_from_text("4 mãĩõ 2028") == date(2028, 5, 4)
+
+    def test_if_two_digit_year_is_parsed(self):
+        # parses as if 19 century
+        assert get_date_from_text("1 1 70") == date(1970, 1, 1)
+        assert get_date_from_text("1 1 80") == date(1980, 1, 1)
+        assert get_date_from_text("1 1 90") == date(1990, 1, 1)
+        # parses as if 20 century
+        assert get_date_from_text("1 1 00") == date(2000, 1, 1)
+        assert get_date_from_text("1 1 10") == date(2010, 1, 1)
+        assert get_date_from_text("1 1 20") == date(2020, 1, 1)
+        assert get_date_from_text("1 1 30") == date(2030, 1, 1)
+        assert get_date_from_text("1 1 40") == date(2040, 1, 1)
+        assert get_date_from_text("1 1 50") == date(2050, 1, 1)
+        assert get_date_from_text("1 1 60") == date(2060, 1, 1)
+
+    # INCONSISTENCY
+    # STRICT_PARSING = TRUE
+    def test_if_text_with_missing_information_shouldnt_be_parsed(self):
+        # no year
+        assert get_date_from_text("10 de Março") is None
+        assert get_date_from_text("10 de 03") is None
+        assert get_date_from_text("10 03") is None
+        assert get_date_from_text("1003") is None
+        # no month
+        assert get_date_from_text("10 2009") is None
+        assert get_date_from_text("102009") is None
+        # no day
+        assert get_date_from_text("Março de 2009") is None
+        assert get_date_from_text("03 de 2009") is None
+        assert get_date_from_text("03 2009") is None
+        assert get_date_from_text("032009") is None
+
+    # REAL CASES FROM SITES
+    def test_if_text_contains_a_interval_return_first_complete_date_possible(self):
+        assert get_date_from_text("03/06/2022 a 09/06/2022") == date(2022, 6, 3)
+        assert get_date_from_text("de 27/05/2022 à 02/06/2022") == date(2022, 5, 27)
+        assert get_date_from_text("17 a 23 de outubro de 2008") == date(2008, 10, 23)
+        assert get_date_from_text("de 13/06/2007 à 19/06/2007") == date(2007, 6, 13)
+        assert get_date_from_text("de 27 de julho a 02 de agosto de 2007") == date(
+            2007, 8, 2
+        )
+
+    def test_if_date_is_captured_when_around_more_text(self):
+        assert get_date_from_text("Publicado dia 28 de março 2025") == date(2025, 3, 28)
+        assert get_date_from_text("Terça-feira, 9 de julho de 2024") == date(2024, 7, 9)
+        assert get_date_from_text(
+            "26 de dezembro de 2005 Encarte da Lei 9.881 parte 1"
+        ) == date(2005, 12, 26)
+        assert get_date_from_text("1ª edição extra de 30 de dezembro de 2005") == date(
+            2005, 12, 30
+        )
+        assert get_date_from_text(
+            "2ª Edição de 29 de dezembro de 2006 - pág 52 e 53"
+        ) == date(2006, 12, 29)
+        assert get_date_from_text("04 de Abril0 de 2024") == date(2024, 4, 4)
+        assert get_date_from_text("04 Abril0 2024") == date(2024, 4, 4)
+        assert get_date_from_text("04Abril02024") == date(2024, 4, 4)
+
+
+class TestGetDateFromNumberedText:
+    # INPUT / ARG
+    def test_if_text_isnt_string_should_return_none(self):
+        assert get_date_from_numbered_text(None) is None
+        assert get_date_from_numbered_text(1.2) is None
+        assert get_date_from_numbered_text([]) is None
+        assert get_date_from_numbered_text((1, 2)) is None
+
+    def test_if_text_is_empty_string_should_return_none(self):
+        assert get_date_from_numbered_text("") is None
+
+    def test_if_text_has_alphabetic_char_should_return_none(self):
+        assert get_date_from_numbered_text("30 de novembro de1917") is None
+
+    # CONTENT
+    # 2 separators
+    def test_date_should_be_recognized_when_two_separators_presence(self):
+        # whitespace
+        assert get_date_from_numbered_text("1 1 1968") == date(1968, 1, 1)
+        assert get_date_from_numbered_text("1 10 1968") == date(1968, 10, 1)
+        assert get_date_from_numbered_text("10 1 1968") == date(1968, 1, 10)
+        assert get_date_from_numbered_text("10 10 1968") == date(1968, 10, 10)
+        # slash
+        assert get_date_from_numbered_text("1/1/1968") == date(1968, 1, 1)
+        assert get_date_from_numbered_text("1/10/1968") == date(1968, 10, 1)
+        assert get_date_from_numbered_text("10/1/1968") == date(1968, 1, 10)
+        assert get_date_from_numbered_text("10/10/1968") == date(1968, 10, 10)
+        # hyphen
+        assert get_date_from_numbered_text("1-1-1968") == date(1968, 1, 1)
+        assert get_date_from_numbered_text("1-10-1968") == date(1968, 10, 1)
+        assert get_date_from_numbered_text("10-1-1968") == date(1968, 1, 10)
+        assert get_date_from_numbered_text("10-10-1968") == date(1968, 10, 10)
+        # underscore
+        assert get_date_from_numbered_text("1_1_1968") == date(1968, 1, 1)
+        assert get_date_from_numbered_text("1_10_1968") == date(1968, 10, 1)
+        assert get_date_from_numbered_text("10_1_1968") == date(1968, 1, 10)
+        assert get_date_from_numbered_text("10_10_1968") == date(1968, 10, 10)
+        # de
+        assert get_date_from_numbered_text("1 de 1 de 1968") == date(1968, 1, 1)
+        assert get_date_from_numbered_text("1 de 10 de 1968") == date(1968, 10, 1)
+        assert get_date_from_numbered_text("10 de 1 de 1968") == date(1968, 1, 10)
+        assert get_date_from_numbered_text("10 de 10 de 1968") == date(1968, 10, 10)
+
+    # only first separator
+    def test_date_should_be_recognized_with_only_first_separator(self):
+        # whitespace
+        assert get_date_from_numbered_text("1 11968") == date(1968, 1, 1)
+        assert get_date_from_numbered_text("1 101968") == date(1968, 10, 1)
+        assert get_date_from_numbered_text("10 11968") == date(1968, 1, 10)
+        assert get_date_from_numbered_text("10 101968") == date(1968, 10, 10)
+        # slash
+        assert get_date_from_numbered_text("1/11968") == date(1968, 1, 1)
+        assert get_date_from_numbered_text("1/101968") == date(1968, 10, 1)
+        assert get_date_from_numbered_text("10/11968") == date(1968, 1, 10)
+        assert get_date_from_numbered_text("10/101968") == date(1968, 10, 10)
+        # hyphen
+        assert get_date_from_numbered_text("1-11968") == date(1968, 1, 1)
+        assert get_date_from_numbered_text("1-101968") == date(1968, 10, 1)
+        assert get_date_from_numbered_text("10-11968") == date(1968, 1, 10)
+        assert get_date_from_numbered_text("10-101968") == date(1968, 10, 10)
+        # underscore
+        assert get_date_from_numbered_text("1_11968") == date(1968, 1, 1)
+        assert get_date_from_numbered_text("1_101968") == date(1968, 10, 1)
+        assert get_date_from_numbered_text("10_11968") == date(1968, 1, 10)
+        assert get_date_from_numbered_text("10_101968") == date(1968, 10, 10)
+        # de
+        assert get_date_from_numbered_text("1 de 11968") == date(1968, 1, 1)
+        assert get_date_from_numbered_text("1 de 101968") == date(1968, 10, 1)
+        assert get_date_from_numbered_text("10 de 11968") == date(1968, 1, 10)
+        assert get_date_from_numbered_text("10 de 101968") == date(1968, 10, 10)
+
+    # only second separator
+    def test_date_should_be_recognized_with_second_separator_only_when_four_char_parts(
+        self,
+    ):
+        # whitespace
+        assert get_date_from_numbered_text("11 1968") is None
+        assert get_date_from_numbered_text("110 1968") is None
+        assert get_date_from_numbered_text("101 1968") is None
+        assert get_date_from_numbered_text("1010 1968") == date(1968, 10, 10)
+        # slash
+        assert get_date_from_numbered_text("11/1968") is None
+        assert get_date_from_numbered_text("110/1968") is None
+        assert get_date_from_numbered_text("101/1968") is None
+        assert get_date_from_numbered_text("1010/1968") == date(1968, 10, 10)
+        # hyphen
+        assert get_date_from_numbered_text("11-1968") is None
+        assert get_date_from_numbered_text("110-1968") is None
+        assert get_date_from_numbered_text("101-1968") is None
+        assert get_date_from_numbered_text("1010-1968") == date(1968, 10, 10)
+        # underscore
+        assert get_date_from_numbered_text("11_1968") is None
+        assert get_date_from_numbered_text("110_1968") is None
+        assert get_date_from_numbered_text("101_1968") is None
+        assert get_date_from_numbered_text("1010_1968") == date(1968, 10, 10)
+        # de
+        assert get_date_from_numbered_text("11 de 1968") is None
+        assert get_date_from_numbered_text("110 de 1968") is None
+        assert get_date_from_numbered_text("101 de 1968") is None
+        assert get_date_from_numbered_text("1010 de 1968") == date(1968, 10, 10)
+
+    # no separator
+    def test_date_should_be_recognized_without_separator_only_when_eight_char_length(
+        self,
+    ):
+        assert get_date_from_numbered_text("0010022025") is None  # 001 de 002
+        assert get_date_from_numbered_text("001022025") is None  # 001 de 02
+        assert get_date_from_numbered_text("010022025") is None  # 01 de 002
+        assert get_date_from_numbered_text("01022025") == date(2025, 2, 1)  # 01 de 02
+        assert get_date_from_numbered_text("1022025") is None  # 1 de 02
+        assert get_date_from_numbered_text("0122025") is None  # 01 de 2
+        assert get_date_from_numbered_text("122025") is None  # 1 de 2
+
+    # INCONSISTENCY
+    def test_inexistent_date_text_should_return_none(self):
+        # ano bissexto
+        assert get_date_from_numbered_text("29 02 2025") is None
+        assert get_date_from_numbered_text("30 02 2025") is None
+        # 31th day month
+        assert get_date_from_numbered_text("31 02 2025") is None
+        assert get_date_from_numbered_text("31 04 2025") is None
+        assert get_date_from_numbered_text("31 06 2025") is None
+        assert get_date_from_numbered_text("31 09 2025") is None
+        assert get_date_from_numbered_text("31 11 2025") is None
+        # day > 31
+        assert get_date_from_numbered_text("40 01 2025") is None
+        assert get_date_from_numbered_text("50 01 2025") is None
+        assert get_date_from_numbered_text("600 04 2025") is None
+        assert get_date_from_numbered_text("7000 05 2025") is None
+        # month > 12
+        assert get_date_from_numbered_text("01 13 2025") is None
+        assert get_date_from_numbered_text("01 14 2025") is None
+        assert get_date_from_numbered_text("01 150 2025") is None
+        assert get_date_from_numbered_text("01 1600 2025") is None
+
+
+class TestGetDateFromMonthWrittenText:
+    # INPUT / ARG
+    def test_if_text_isnt_string_should_return_none(self):
+        assert get_date_from_month_written_text(None) is None
+        assert get_date_from_month_written_text(1.2) is None
+        assert get_date_from_month_written_text([]) is None
+        assert get_date_from_month_written_text((1, 2)) is None
+
+    def test_if_text_is_empty_string_should_return_none(self):
+        assert get_date_from_month_written_text("") is None
+
+    # CONTENT
+    # 2 separators
+    def test_date_should_be_recognized_when_two_separators(self):
+        assert get_date_from_month_written_text("23 novembro 2028") == date(
+            2028, 11, 23
+        )
+        assert get_date_from_month_written_text("23/novembro/2028") == date(
+            2028, 11, 23
+        )
+        assert get_date_from_month_written_text("23-novembro-2028") == date(
+            2028, 11, 23
+        )
+        assert get_date_from_month_written_text("23_novembro_2028") == date(
+            2028, 11, 23
+        )
+        assert get_date_from_month_written_text("23 de novembro de 2028") == date(
+            2028, 11, 23
+        )
+
+    # only first separator
+    def test_date_should_be_recognized_when_first_separator_only(self):
+        assert get_date_from_month_written_text("03 outubro2000") == date(2000, 10, 3)
+        assert get_date_from_month_written_text("03/outubro2000") == date(2000, 10, 3)
+        assert get_date_from_month_written_text("03-outubro2000") == date(2000, 10, 3)
+        assert get_date_from_month_written_text("03_outubro2000") == date(2000, 10, 3)
+        assert get_date_from_month_written_text("03 de outubro2000") == date(
+            2000, 10, 3
+        )
+
+    # only second separator
+    def test_date_should_be_recognized_when_second_separator_only(self):
+        assert get_date_from_month_written_text("23novembro 2028") == date(2028, 11, 23)
+        assert get_date_from_month_written_text("23novembro/2028") == date(2028, 11, 23)
+        assert get_date_from_month_written_text("23novembro-2028") == date(2028, 11, 23)
+        assert get_date_from_month_written_text("23novembro_2028") == date(2028, 11, 23)
+        assert get_date_from_month_written_text("23novembro de 2028") == date(
+            2028, 11, 23
+        )
+
+    # no separator
+    def test_date_should_be_recognized_without_separators(self):
+        assert get_date_from_month_written_text("03fevereiro2025") == date(2025, 2, 3)
+
+    ## de separator vs de dezembro
+    def test_de_separator_shouldnt_conflict_with_dezembro(self):
+        assert get_date_from_month_written_text("15 de dezembro de 1995") == date(
+            1995, 12, 15
+        )
+
+        assert get_date_from_month_written_text("15de dezembro de 1995") == date(
+            1995, 12, 15
+        )
+        assert get_date_from_month_written_text("15 dedezembro de 1995") == date(
+            1995, 12, 15
+        )
+        assert get_date_from_month_written_text("15dedezembro de 1995") == date(
+            1995, 12, 15
+        )
+
+        assert get_date_from_month_written_text("15 de dezembrode 1995") == date(
+            1995, 12, 15
+        )
+        assert get_date_from_month_written_text("15 de dezembro de1995") == date(
+            1995, 12, 15
+        )
+        assert get_date_from_month_written_text("15 de dezembrode1995") == date(
+            1995, 12, 15
+        )
+
+        assert get_date_from_month_written_text("15dedezembrode1995") == date(
+            1995, 12, 15
+        )
+
+    # INCONSISTENCY
+    def test_inexistent_date_text_should_return_none(self):
+        # ano bissexto
+        assert get_date_from_month_written_text("29 fevereiro 2025") is None
+        assert get_date_from_month_written_text("30 fevereiro 2025") is None
+        # 31th day month
+        assert get_date_from_month_written_text("31 fevereiro 2025") is None
+        assert get_date_from_month_written_text("31 abril 2025") is None
+        assert get_date_from_month_written_text("31 junho 2025") is None
+        assert get_date_from_month_written_text("31 setembro 2025") is None
+        assert get_date_from_month_written_text("31 novembro 2025") is None
+        # day > 31
+        assert get_date_from_month_written_text("40 janeiro 2025") is None
+        assert get_date_from_month_written_text("50 janeiro 2025") is None
+        assert get_date_from_month_written_text("600 janeiro 2025") is None
+        assert get_date_from_month_written_text("7000 janeiro 2025") is None
+
+    def test_if_recognizes_date_from_month_with_typo(self):
+        assert get_date_from_month_written_text("8 de janiro de 2030") == date(
+            2030, 1, 8
+        )
+        assert get_date_from_month_written_text("8 de feverero de 2030") == date(
+            2030, 2, 8
+        )
+        assert get_date_from_month_written_text("8 de maro de 2030") == date(2030, 3, 8)
+        assert get_date_from_month_written_text("8 de abriu de 2030") == date(
+            2030, 4, 8
+        )
+        assert get_date_from_month_written_text("8 de maiio de 2030") == date(
+            2030, 5, 8
+        )
+        assert get_date_from_month_written_text("8 de juno de 2030") == date(2030, 6, 8)
+        assert get_date_from_month_written_text("8 de julo de 2030") == date(2030, 7, 8)
+        assert get_date_from_month_written_text("8 de agosti de 2030") == date(
+            2030, 8, 8
+        )
+        assert get_date_from_month_written_text("8 de setembu de 2030") == date(
+            2030, 9, 8
+        )
+        assert get_date_from_month_written_text("8 de oubro de 2030") == date(
+            2030, 10, 8
+        )
+        assert get_date_from_month_written_text("8 de novem de 2030") == date(
+            2030, 11, 8
+        )
+        assert get_date_from_month_written_text("8 de zembro de 2030") == date(
+            2030, 12, 8
+        )
+
+
+class TestGetMonthValue:
+    # TEXT ARG
+    def test_if_text_isnt_string_should_return_none(self):
+        assert get_month_value(None) is None
+        assert get_month_value("") is None
+        assert get_month_value([]) is None
+
+    def test_if_text_is_empty_string_should_return_none(self):
+        assert get_month_value("") is None
+
+    # CONTENT
+    def test_if_whitespace_in_extremities_is_parsed_as_if_without_it(self):
+        assert get_month_value("   janeiro    ") == 1
+
+    def test_if_upper_case_is_ignored(self):
+        assert get_month_value("JunHo") == 6
+        assert get_month_value("jUlhO") == 7
+
+    def test_if_text_with_diacritics_should_be_parsed_as_if_without_it(self):
+        assert get_month_value("março") == 3
+        assert get_month_value("máíó") == 5
+        assert get_month_value("mâîô") == 5
+        assert get_month_value("mãĩõ") == 5
+
+    def test_if_world_length_less_than_3_char_returns_none(self):
+        assert get_month_value("ja") is None
+        assert get_month_value("fe") is None
+        assert get_month_value("ma") is None
+        assert get_month_value("ab") is None
+        assert get_month_value("ma") is None
+        assert get_month_value("ju") is None
+        assert get_month_value("ag") is None
+        assert get_month_value("se") is None
+        assert get_month_value("ou") is None
+        assert get_month_value("no") is None
+        assert get_month_value("de") is None
+
+    def test_if_ambiguous_cases_return_none(self):
+        assert get_month_value("juho") is None  # could be junho or julho
+
+    def test_if_numbered_text_returns_appropriate_int_only_when_month_value_exists(
+        self,
+    ):
+        assert get_month_value("01") == 1
+        assert get_month_value("02") == 2
+        assert get_month_value("03") == 3
+        assert get_month_value("04") == 4
+        assert get_month_value("05") == 5
+        assert get_month_value("06") == 6
+        assert get_month_value("07") == 7
+        assert get_month_value("08") == 8
+        assert get_month_value("09") == 9
+        assert get_month_value("10") == 10
+        assert get_month_value("11") == 11
+        assert get_month_value("12") == 12
+
+        # month 13, 14, 15, etc, doesnt exist
+        assert get_month_value("13") is None
+        assert get_month_value("14") is None
+        assert get_month_value("15") is None
+
+    def test_if_common_abbreviation_is_recognized_correctly(self):
+        assert get_month_value("jan") == 1
+        assert get_month_value("fev") == 2
+        assert get_month_value("mar") == 3
+        assert get_month_value("abr") == 4
+        assert get_month_value("mai") == 5
+        assert get_month_value("jun") == 6
+        assert get_month_value("jul") == 7
+        assert get_month_value("ago") == 8
+        assert get_month_value("set") == 9
+        assert get_month_value("out") == 10
+        assert get_month_value("nov") == 11
+        assert get_month_value("dez") == 12
+
+    def test_janeiro_recognition(self):
+        # 1 letter is missing
+        assert get_month_value("aneiro") == 1
+        assert get_month_value("jneiro") == 1
+        assert get_month_value("jaeiro") == 1
+        assert get_month_value("janiro") == 1
+        assert get_month_value("janero") == 1
+        assert get_month_value("janeio") == 1
+        # progressive missing letters
+        assert get_month_value("janeir") == 1
+        assert get_month_value("janei") == 1
+        assert get_month_value("jane") == 1
+        # letters position changed
+        assert get_month_value("janiero") == 1
+        assert get_month_value("janeior") == 1
+        assert get_month_value("jnaeiro") == 1
+
+    def test_junho_recognition(self):
+        # 1 letter is missing
+        assert get_month_value("unho") == 6
+        assert get_month_value("jnho") == 6
+        assert get_month_value("juno") == 6
+        # progressive missing letters
+        assert get_month_value("junh") == 6
+        # letters position changed
+        assert get_month_value("juhno") == 6
+        assert get_month_value("jnuho") == 6
+        assert get_month_value("junoh") == 6
+
+    def test_julho_recognition(self):
+        # 1 letter is missing
+        assert get_month_value("ulho") == 7
+        assert get_month_value("jlho") == 7
+        assert get_month_value("julo") == 7
+        # progressive missing letters
+        assert get_month_value("julh") == 7
+        # letters position changed
+        # assert get_month_value("juhlo") == 7   # não passa
+        # assert get_month_value("jluho") == 7   # não passa
+        assert get_month_value("juloh") == 7
+        # assert get_month_value("jolhu") == 7   # não passa
+
+    def test_if_known_case_typos_are_fixed(self):  # real cases from spiders
+        assert get_month_value("edicao") is None
+        assert get_month_value("abril0") == 4
+        assert get_month_value("junhoe") == 6
+        assert get_month_value("agosoto") == 8
+        assert get_month_value("dezembrbo") == 12
+        assert get_month_value("setembo") == 9
+        assert get_month_value("novembo") == 11


Reviews:
user: login: firefueled
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-22T19:50:42Z
user: login: trevineju
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T15:52:12Z
user: login: trevineju
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T16:36:22Z
user: login: trevineju
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T16:39:18Z
user: login: trevineju
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T02:48:46Z

Review comments:
diff_hunk: @@ -0,0 +1,138 @@
+import re
+from datetime import date
+
+from dateparser import parse
+from text_unidecode import unidecode
+from thefuzz import process
+
+
+def get_date_from_text(text):
+    """Return date identified in text
+
+    This func exists to extend date recognition cases that dateparser lib doesn't
+    cover. The basic logic is: first try to get date using dateparser, if it
+    doesn't work, try to get it using regex.
+
+    So, this works with known dateparser cases (formats like: strictly numbered
+    string, month in full writing in pt-br, commonly used separators as '-', '/',
+    'de', ' ' or without it). Adds ways to deal with inconsistent situations,
+    such as when there's only one separator in text, ordenation symbol (ªº) present
+    or mistyped month. Check test_dates.py in tests/ directory to see examples.
+
+    By default, it's set to do stric_parsing, meaning it doesn't try to parse
+    incomplete data, and only handles date in day month year order (DMY) for now,
+    because its enough for all using cases (but an improvement can be implemented
+    in the future, on demand)
+
+    Args:
+        text: string with date text in it
+    Returns:
+        Date object, if able to parse. Otherwise, returns None.
+    """
+    if not isinstance(text, str):
+        return None
+
+    order = "DMY"
+    settings = {"DATE_ORDER": order, "STRICT_PARSING": True}
+
+    # normalize input
+    text = re.sub("[°ºª]", "", text)
+    normalized_text = unidecode(re.sub(" +", " ", text).strip().lower())
+
+    # To avoid issues with dirty strings, this function uses full concatenated text preferably
+    # 1nov2025 > month behaves as separator, allowing numbers identification
+    # 1112025 > numbered str might has ambiguity (it is 1/11 or 11/1 or 11/12/025?)
path: data_collection/gazette/utils/text_extraction.py
user: login: firefueled
body: typo:
```suggestion
    # 1112025 > numbered str might have ambiguity (it is 1/11 or 11/1 or 11/12/025?)
```

Não sabia que escrevíamos comentários em inglês :heavy_check_mark: 
created_at: 2025-04-22T19:00:16Z
author_association: NONE
diff_hunk: @@ -0,0 +1,138 @@
+import re
+from datetime import date
+
+from dateparser import parse
+from text_unidecode import unidecode
+from thefuzz import process
+
+
+def get_date_from_text(text):
+    """Return date identified in text
+
+    This func exists to extend date recognition cases that dateparser lib doesn't
+    cover. The basic logic is: first try to get date using dateparser, if it
+    doesn't work, try to get it using regex.
+
+    So, this works with known dateparser cases (formats like: strictly numbered
+    string, month in full writing in pt-br, commonly used separators as '-', '/',
+    'de', ' ' or without it). Adds ways to deal with inconsistent situations,
+    such as when there's only one separator in text, ordenation symbol (ªº) present
+    or mistyped month. Check test_dates.py in tests/ directory to see examples.
+
+    By default, it's set to do stric_parsing, meaning it doesn't try to parse
+    incomplete data, and only handles date in day month year order (DMY) for now,
+    because its enough for all using cases (but an improvement can be implemented
+    in the future, on demand)
+
+    Args:
+        text: string with date text in it
+    Returns:
+        Date object, if able to parse. Otherwise, returns None.
+    """
+    if not isinstance(text, str):
+        return None
+
+    order = "DMY"
+    settings = {"DATE_ORDER": order, "STRICT_PARSING": True}
+
+    # normalize input
+    text = re.sub("[°ºª]", "", text)
+    normalized_text = unidecode(re.sub(" +", " ", text).strip().lower())
+
+    # To avoid issues with dirty strings, this function uses full concatenated text preferably
+    # 1nov2025 > month behaves as separator, allowing numbers identification
+    # 1112025 > numbered str might has ambiguity (it is 1/11 or 11/1 or 11/12/025?)
+    concatenated_text = re.sub(" ", "", normalized_text)
+
+    # HANDLES FULL NUMBERED LIMITATIONS...
+    is_full_numbered = concatenated_text.isdigit()
+    has_space_separators = sum(char == " " for char in normalized_text) == 2
+    if is_full_numbered:
+        # 1- space is a separator. Concatenation will ruin its parse
+        if has_space_separators:  # D M YYYY | D MM YYYY | DD M YYYY | DD MM YYYY
+            parsed_date = parse(normalized_text, languages=["pt"], settings=settings)
+            if parsed_date:
+                return parsed_date.date()
+        # 2- there is no separator. Only possible to parse when it has 8 digits
+        if len(concatenated_text) != 8:  # exactly DDMMYYYY
+            return None
+
+    # ... FOLLOWS DEFAULT FLOW (with full concatenated string)
+
+    # replace 'de' separator, without conflict with 'de' of 'dezembro'
+    # note: makes 'de' cases behave as if DD - MM - AAAA
+    text = concatenated_text.replace("de", "-").replace("-z", "dez")
+
+    # TRY TO APPLY DATEPARSER PARSING
+    parsed_date = parse(text, languages=["pt"], settings=settings)
+    if parsed_date:
+        return parsed_date.date()
+
+    # TRY TO APPLY REGEX PARSING
+
+    # create order sensibility.
+    regex_date_order = ["", "", ""]
+
+    day_index = order.index("D")
+    month_index = order.index("M")
+    year_index = order.index("Y")
+
+    regex_date_order[day_index] = r"(\d{1,2})"
+    regex_date_order[month_index] = r"(\d{1,2}|[a-z]+)"
+    regex_date_order[year_index] = r"(\d{4})"
+
+    # builds a complete search regex, like
+    # (D or M or Y) separators (D or M or Y) separators (D or M or Y)
path: data_collection/gazette/utils/text_extraction.py
user: login: firefueled
body: Esse comentário indica que o regex final encontra datas com partes (D or M or Y) em qualquer ordem, mas não é o caso, certo?
É esperado uma ordem fixa definida em `order`

Talvez seja melhor se ele dissesse algo como `# builds a complete search regex with different separators and ways of writing the date parts, in the order defined by 'order'`
created_at: 2025-04-22T19:10:35Z
author_association: NONE
diff_hunk: @@ -0,0 +1,138 @@
+import re
+from datetime import date
+
+from dateparser import parse
+from text_unidecode import unidecode
+from thefuzz import process
+
+
+def get_date_from_text(text):
+    """Return date identified in text
+
+    This func exists to extend date recognition cases that dateparser lib doesn't
+    cover. The basic logic is: first try to get date using dateparser, if it
+    doesn't work, try to get it using regex.
+
+    So, this works with known dateparser cases (formats like: strictly numbered
+    string, month in full writing in pt-br, commonly used separators as '-', '/',
+    'de', ' ' or without it). Adds ways to deal with inconsistent situations,
+    such as when there's only one separator in text, ordenation symbol (ªº) present
+    or mistyped month. Check test_dates.py in tests/ directory to see examples.
+
+    By default, it's set to do stric_parsing, meaning it doesn't try to parse
+    incomplete data, and only handles date in day month year order (DMY) for now,
+    because its enough for all using cases (but an improvement can be implemented
+    in the future, on demand)
+
+    Args:
+        text: string with date text in it
+    Returns:
+        Date object, if able to parse. Otherwise, returns None.
+    """
+    if not isinstance(text, str):
+        return None
+
+    order = "DMY"
+    settings = {"DATE_ORDER": order, "STRICT_PARSING": True}
+
+    # normalize input
+    text = re.sub("[°ºª]", "", text)
+    normalized_text = unidecode(re.sub(" +", " ", text).strip().lower())
+
+    # To avoid issues with dirty strings, this function uses full concatenated text preferably
+    # 1nov2025 > month behaves as separator, allowing numbers identification
+    # 1112025 > numbered str might has ambiguity (it is 1/11 or 11/1 or 11/12/025?)
+    concatenated_text = re.sub(" ", "", normalized_text)
+
+    # HANDLES FULL NUMBERED LIMITATIONS...
+    is_full_numbered = concatenated_text.isdigit()
+    has_space_separators = sum(char == " " for char in normalized_text) == 2
+    if is_full_numbered:
+        # 1- space is a separator. Concatenation will ruin its parse
+        if has_space_separators:  # D M YYYY | D MM YYYY | DD M YYYY | DD MM YYYY
+            parsed_date = parse(normalized_text, languages=["pt"], settings=settings)
+            if parsed_date:
+                return parsed_date.date()
+        # 2- there is no separator. Only possible to parse when it has 8 digits
+        if len(concatenated_text) != 8:  # exactly DDMMYYYY
+            return None
+
+    # ... FOLLOWS DEFAULT FLOW (with full concatenated string)
+
+    # replace 'de' separator, without conflict with 'de' of 'dezembro'
+    # note: makes 'de' cases behave as if DD - MM - AAAA
+    text = concatenated_text.replace("de", "-").replace("-z", "dez")
+
+    # TRY TO APPLY DATEPARSER PARSING
+    parsed_date = parse(text, languages=["pt"], settings=settings)
+    if parsed_date:
+        return parsed_date.date()
+
+    # TRY TO APPLY REGEX PARSING
+
+    # create order sensibility.
+    regex_date_order = ["", "", ""]
+
+    day_index = order.index("D")
+    month_index = order.index("M")
+    year_index = order.index("Y")
+
+    regex_date_order[day_index] = r"(\d{1,2})"
+    regex_date_order[month_index] = r"(\d{1,2}|[a-z]+)"
+    regex_date_order[year_index] = r"(\d{4})"
path: data_collection/gazette/utils/text_extraction.py
user: login: firefueled
body: Se tivermos exemplos de anos com dois números em vez de quatro, mas acho que nunca vi
```suggestion
    regex_date_order[year_index] = r"(\d{2,4})"
```
created_at: 2025-04-22T19:19:35Z
author_association: NONE
path: data_collection/gazette/utils/dates.py
user: login: firefueled
body: A ideia seria não usar o `fuzzywuzzy`, certo? Deve ter deixado a mudança por engano :p
created_at: 2025-04-22T19:37:32Z
author_association: NONE
path: data_collection/gazette/utils/dates.py
user: login: trevineju
body: é, eu migrei o código pro outro arquivo e esqueci de tirar de `dates.py`. Foi removido. Obrigada.
created_at: 2025-04-25T15:52:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,138 @@
+import re
+from datetime import date
+
+from dateparser import parse
+from text_unidecode import unidecode
+from thefuzz import process
+
+
+def get_date_from_text(text):
+    """Return date identified in text
+
+    This func exists to extend date recognition cases that dateparser lib doesn't
+    cover. The basic logic is: first try to get date using dateparser, if it
+    doesn't work, try to get it using regex.
+
+    So, this works with known dateparser cases (formats like: strictly numbered
+    string, month in full writing in pt-br, commonly used separators as '-', '/',
+    'de', ' ' or without it). Adds ways to deal with inconsistent situations,
+    such as when there's only one separator in text, ordenation symbol (ªº) present
+    or mistyped month. Check test_dates.py in tests/ directory to see examples.
+
+    By default, it's set to do stric_parsing, meaning it doesn't try to parse
+    incomplete data, and only handles date in day month year order (DMY) for now,
+    because its enough for all using cases (but an improvement can be implemented
+    in the future, on demand)
+
+    Args:
+        text: string with date text in it
+    Returns:
+        Date object, if able to parse. Otherwise, returns None.
+    """
+    if not isinstance(text, str):
+        return None
+
+    order = "DMY"
+    settings = {"DATE_ORDER": order, "STRICT_PARSING": True}
+
+    # normalize input
+    text = re.sub("[°ºª]", "", text)
+    normalized_text = unidecode(re.sub(" +", " ", text).strip().lower())
+
+    # To avoid issues with dirty strings, this function uses full concatenated text preferably
+    # 1nov2025 > month behaves as separator, allowing numbers identification
+    # 1112025 > numbered str might has ambiguity (it is 1/11 or 11/1 or 11/12/025?)
+    concatenated_text = re.sub(" ", "", normalized_text)
+
+    # HANDLES FULL NUMBERED LIMITATIONS...
+    is_full_numbered = concatenated_text.isdigit()
+    has_space_separators = sum(char == " " for char in normalized_text) == 2
+    if is_full_numbered:
+        # 1- space is a separator. Concatenation will ruin its parse
+        if has_space_separators:  # D M YYYY | D MM YYYY | DD M YYYY | DD MM YYYY
+            parsed_date = parse(normalized_text, languages=["pt"], settings=settings)
+            if parsed_date:
+                return parsed_date.date()
+        # 2- there is no separator. Only possible to parse when it has 8 digits
+        if len(concatenated_text) != 8:  # exactly DDMMYYYY
+            return None
+
+    # ... FOLLOWS DEFAULT FLOW (with full concatenated string)
+
+    # replace 'de' separator, without conflict with 'de' of 'dezembro'
+    # note: makes 'de' cases behave as if DD - MM - AAAA
+    text = concatenated_text.replace("de", "-").replace("-z", "dez")
+
+    # TRY TO APPLY DATEPARSER PARSING
+    parsed_date = parse(text, languages=["pt"], settings=settings)
+    if parsed_date:
+        return parsed_date.date()
+
+    # TRY TO APPLY REGEX PARSING
+
+    # create order sensibility.
+    regex_date_order = ["", "", ""]
+
+    day_index = order.index("D")
+    month_index = order.index("M")
+    year_index = order.index("Y")
+
+    regex_date_order[day_index] = r"(\d{1,2})"
+    regex_date_order[month_index] = r"(\d{1,2}|[a-z]+)"
+    regex_date_order[year_index] = r"(\d{4})"
+
+    # builds a complete search regex, like
+    # (D or M or Y) separators (D or M or Y) separators (D or M or Y)
path: data_collection/gazette/utils/text_extraction.py
user: login: trevineju
body: Isso. Melhorei isso abstraindo para _apply_regex() essa questão de flexibilidade. Por enquanto, só DMY foi necessário, mas ja vi casos que a ordem é outra. O dateparser tem um recurso nativo de indicar a ordem, mas o regex é muito "rigido", vai ser a ordem da string do padrão. Queria deixar o recurso (de poder indicar qual a ordem) meio pré-pensado para ir incrementando. 
created_at: 2025-04-25T16:36:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,138 @@
+import re
+from datetime import date
+
+from dateparser import parse
+from text_unidecode import unidecode
+from thefuzz import process
+
+
+def get_date_from_text(text):
+    """Return date identified in text
+
+    This func exists to extend date recognition cases that dateparser lib doesn't
+    cover. The basic logic is: first try to get date using dateparser, if it
+    doesn't work, try to get it using regex.
+
+    So, this works with known dateparser cases (formats like: strictly numbered
+    string, month in full writing in pt-br, commonly used separators as '-', '/',
+    'de', ' ' or without it). Adds ways to deal with inconsistent situations,
+    such as when there's only one separator in text, ordenation symbol (ªº) present
+    or mistyped month. Check test_dates.py in tests/ directory to see examples.
+
+    By default, it's set to do stric_parsing, meaning it doesn't try to parse
+    incomplete data, and only handles date in day month year order (DMY) for now,
+    because its enough for all using cases (but an improvement can be implemented
+    in the future, on demand)
+
+    Args:
+        text: string with date text in it
+    Returns:
+        Date object, if able to parse. Otherwise, returns None.
+    """
+    if not isinstance(text, str):
+        return None
+
+    order = "DMY"
+    settings = {"DATE_ORDER": order, "STRICT_PARSING": True}
+
+    # normalize input
+    text = re.sub("[°ºª]", "", text)
+    normalized_text = unidecode(re.sub(" +", " ", text).strip().lower())
+
+    # To avoid issues with dirty strings, this function uses full concatenated text preferably
+    # 1nov2025 > month behaves as separator, allowing numbers identification
+    # 1112025 > numbered str might has ambiguity (it is 1/11 or 11/1 or 11/12/025?)
+    concatenated_text = re.sub(" ", "", normalized_text)
+
+    # HANDLES FULL NUMBERED LIMITATIONS...
+    is_full_numbered = concatenated_text.isdigit()
+    has_space_separators = sum(char == " " for char in normalized_text) == 2
+    if is_full_numbered:
+        # 1- space is a separator. Concatenation will ruin its parse
+        if has_space_separators:  # D M YYYY | D MM YYYY | DD M YYYY | DD MM YYYY
+            parsed_date = parse(normalized_text, languages=["pt"], settings=settings)
+            if parsed_date:
+                return parsed_date.date()
+        # 2- there is no separator. Only possible to parse when it has 8 digits
+        if len(concatenated_text) != 8:  # exactly DDMMYYYY
+            return None
+
+    # ... FOLLOWS DEFAULT FLOW (with full concatenated string)
+
+    # replace 'de' separator, without conflict with 'de' of 'dezembro'
+    # note: makes 'de' cases behave as if DD - MM - AAAA
+    text = concatenated_text.replace("de", "-").replace("-z", "dez")
+
+    # TRY TO APPLY DATEPARSER PARSING
+    parsed_date = parse(text, languages=["pt"], settings=settings)
+    if parsed_date:
+        return parsed_date.date()
+
+    # TRY TO APPLY REGEX PARSING
+
+    # create order sensibility.
+    regex_date_order = ["", "", ""]
+
+    day_index = order.index("D")
+    month_index = order.index("M")
+    year_index = order.index("Y")
+
+    regex_date_order[day_index] = r"(\d{1,2})"
+    regex_date_order[month_index] = r"(\d{1,2}|[a-z]+)"
+    regex_date_order[year_index] = r"(\d{4})"
path: data_collection/gazette/utils/text_extraction.py
user: login: trevineju
body: É, pelo menos nos casos dessa PR não tem. Mas tb fiquei pensando muito nisso. Deixei 4 pq com 2 corre o risco do ano sendo criado errado. 

Digamos: um texto com `12 de jan de 03`. Com dateparser talvez dê certo, não testei, mas com regex com certeza vai dar errado, pq "03" equivale o inteiro 3. Vai ser considerado ano 3 e não 2003. 
created_at: 2025-04-25T16:39:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,138 @@
+import re
+from datetime import date
+
+from dateparser import parse
+from text_unidecode import unidecode
+from thefuzz import process
+
+
+def get_date_from_text(text):
+    """Return date identified in text
+
+    This func exists to extend date recognition cases that dateparser lib doesn't
+    cover. The basic logic is: first try to get date using dateparser, if it
+    doesn't work, try to get it using regex.
+
+    So, this works with known dateparser cases (formats like: strictly numbered
+    string, month in full writing in pt-br, commonly used separators as '-', '/',
+    'de', ' ' or without it). Adds ways to deal with inconsistent situations,
+    such as when there's only one separator in text, ordenation symbol (ªº) present
+    or mistyped month. Check test_dates.py in tests/ directory to see examples.
+
+    By default, it's set to do stric_parsing, meaning it doesn't try to parse
+    incomplete data, and only handles date in day month year order (DMY) for now,
+    because its enough for all using cases (but an improvement can be implemented
+    in the future, on demand)
+
+    Args:
+        text: string with date text in it
+    Returns:
+        Date object, if able to parse. Otherwise, returns None.
+    """
+    if not isinstance(text, str):
+        return None
+
+    order = "DMY"
+    settings = {"DATE_ORDER": order, "STRICT_PARSING": True}
+
+    # normalize input
+    text = re.sub("[°ºª]", "", text)
+    normalized_text = unidecode(re.sub(" +", " ", text).strip().lower())
+
+    # To avoid issues with dirty strings, this function uses full concatenated text preferably
+    # 1nov2025 > month behaves as separator, allowing numbers identification
+    # 1112025 > numbered str might has ambiguity (it is 1/11 or 11/1 or 11/12/025?)
+    concatenated_text = re.sub(" ", "", normalized_text)
+
+    # HANDLES FULL NUMBERED LIMITATIONS...
+    is_full_numbered = concatenated_text.isdigit()
+    has_space_separators = sum(char == " " for char in normalized_text) == 2
+    if is_full_numbered:
+        # 1- space is a separator. Concatenation will ruin its parse
+        if has_space_separators:  # D M YYYY | D MM YYYY | DD M YYYY | DD MM YYYY
+            parsed_date = parse(normalized_text, languages=["pt"], settings=settings)
+            if parsed_date:
+                return parsed_date.date()
+        # 2- there is no separator. Only possible to parse when it has 8 digits
+        if len(concatenated_text) != 8:  # exactly DDMMYYYY
+            return None
+
+    # ... FOLLOWS DEFAULT FLOW (with full concatenated string)
+
+    # replace 'de' separator, without conflict with 'de' of 'dezembro'
+    # note: makes 'de' cases behave as if DD - MM - AAAA
+    text = concatenated_text.replace("de", "-").replace("-z", "dez")
+
+    # TRY TO APPLY DATEPARSER PARSING
+    parsed_date = parse(text, languages=["pt"], settings=settings)
+    if parsed_date:
+        return parsed_date.date()
+
+    # TRY TO APPLY REGEX PARSING
+
+    # create order sensibility.
+    regex_date_order = ["", "", ""]
+
+    day_index = order.index("D")
+    month_index = order.index("M")
+    year_index = order.index("Y")
+
+    regex_date_order[day_index] = r"(\d{1,2})"
+    regex_date_order[month_index] = r"(\d{1,2}|[a-z]+)"
+    regex_date_order[year_index] = r"(\d{4})"
path: data_collection/gazette/utils/text_extraction.py
user: login: trevineju
body: Testei com dateparser e adicionei o comportamento no commit https://github.com/okfn-brasil/querido-diario/pull/1389/commits/2aeadf7694d7a54ea62d656760e42e183f4f710a

Em resumo, ano com 2 digitos funciona assim: de 0 a 60 assume-se que é no atual século (2000, 2010 ... até 2060) e de 70 até 99 assume-se que é no século passado (1970, 1980 ... até 1999). 

É comportamento nativo de dateparser em portugues msm, não mudei nada nas configurações. 
created_at: 2025-04-28T02:48:46Z
author_association: MEMBER

PR comments:
user: login: trevineju
created_at: 2025-04-18T18:56:05Z
body: Todos os testes unitários de `get_text_from_date` passam. Entretanto, `get_month_value_from_writing` tem alguns casos que não. Deixei comentários. 
author_association: MEMBER
user: login: trevineju
created_at: 2025-04-18T19:11:03Z
body: Fábio identificou que Duque de Caxias/RJ, Barra Mansa/RJ e Campos/RJ são casos críticos também. Vou adicionar execuções testes em seguida pra esses casos. 
author_association: MEMBER
user: login: slfabio
created_at: 2025-04-19T21:57:18Z
body: Muito obrigado, @trevineju ! Ficou muito melhor.
author_association: COLLABORATOR
user: login: trevineju
created_at: 2025-04-25T16:20:42Z
body: > Fábio identificou que Duque de Caxias/RJ, Barra Mansa/RJ e Campos/RJ são casos críticos também. Vou adicionar execuções testes em seguida pra esses casos.

Continuei fazendo o TDD pois ainda havia esses 3 municipios para completar e tive complicações neles e Jaú também, que publica alguns documentos referente a datas variadas, e o texto no site são intervalos. Como o TDD continuou, mudou bastante o código. 

## RJ

| Datas analisadas nos seletores | Passa? | Casos de Typo | Passa? |
| --- | --- | --- | --- |
| [rj_barra_mansa_texto_dos_seletores.csv](https://github.com/user-attachments/files/19912264/rj_barra_mansa_texto_dos_seletores.csv) |  ✅  | [rj_barra_mansa_texto_dos_typos.csv](https://github.com/user-attachments/files/19912265/rj_barra_mansa_texto_dos_typos.csv) |  ✅  |
| [rj_campos_dos_goytacazes_texto_dos_seletores.csv](https://github.com/user-attachments/files/19912266/rj_campos_dos_goytacazes_texto_dos_seletores.csv) |  ✅  | [rj_campos_dos_goytacazes_texto_dos_typos.csv](https://github.com/user-attachments/files/19912268/rj_campos_dos_goytacazes_texto_dos_typos.csv) |  ✅  |
| [rj_duque_de_caxias_texto_dos_seletores.csv](https://github.com/user-attachments/files/19912269/rj_duque_de_caxias_texto_dos_seletores.csv) |  ✅  | [rj_duque_de_caxias_texto_dos_typos.csv](https://github.com/user-attachments/files/19912270/rj_duque_de_caxias_texto_dos_typos.csv) |  ✅  |

PRs usadas para testar os municípios do RJ:  #1333 #563 #1323   


## Jau-SP
Não estamos usando em produção a coleta do site https://www.jau.sp.gov.br/jornal-oficial (o que usamos é https://imprensaoficialmunicipal.com.br/jau, apenas a partir de 2023). Entretanto, já inclui os casos de teste referente a situação de Jaú, com datas anteriores à 2023. Poderemos coletar o resto da série histórica no futuro, em outra PR, já com segurança que funciona

~~~
def test_if_text_contains_a_interval_return_first_complete_date_possible(self):
   assert get_date_from_text("03/06/2022 a 09/06/2022") == date(2022, 6, 3)
   assert get_date_from_text("de 27/05/2022 à 02/06/2022") == date(2022, 5, 27)
   assert get_date_from_text("17 a 23 de outubro de 2008") == date(2008, 10, 23)
   assert get_date_from_text("de 13/06/2007 à 19/06/2007") == date(2007, 6, 13)
   assert get_date_from_text("de 27 de julho a 02 de agosto de 2007") == date(2007, 8, 2)
~~~
author_association: MEMBER