Skip to content

Fix incorrect date parsing for multi-word number representations #1280

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 56 additions & 23 deletions dateparser/data/date_translation_data/ru.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,73 +340,91 @@
],
"simplifications": [
{
"од(на|ну|ни|ной|ин)": "1"
"од(ин|на|ну|ни|ной|ною|но|ного|ному|ним|нем)": "1"
},
{
"дв(а|е|ое|ух)": "2"
"перв(ой|ого|ому|ым|ом|ая|ую|ое|ые|ых|ыми)": "1"
},
{
"пар[ауы]": "2"
"дв(а|е|ух|ум|умя|ое)": "2"
},
{
"три": "3"
"пар(а|ы|е|у|ой|ою|ам|ами|ах)": "2"
},
{
"четыре": "4"
"втор(ой|ого|ому|ым|ом|ая|ую|ое|ые|ых|ыми)": "2"
},
{
"пять": "5"
"тр(и|ёх|ем|ём|емя|етье)": "3"
},
{
"шесть": "6"
"трети(й|его|ему|им|ем|я|ей|ею|ую|е|и|их|ими)": "3"
},
{
"семь": "7"
"четыр(е|ёх|ем|ём|ьмя)": "4"
},
{
"восемь": "8"
"четвёрт(ый|ого|ому|ым|ом|ая|ой|ою|ую|ое|ые|ых|ыми)": "4"
},
{
"девять": "9"
"четверт(ый|ого|ому|ым|ом|ая|ой|ою|ую|ое|ые|ых|ыми)": "4"
},
{
"десять": "10"
"пят(ь|и|ью)": "5"
},
{
"одиннадцать": "11"
"пят(ый|ого|ому|ым|ом|ая|ой|ою|ую|ое|ые|ых|ыми)": "5"
},
{
"двенадцать": "12"
"шест(ь|и|ью)": "6"
},
{
"пятнадцать": "15"
"шест(ый|ого|ому|ым|ом|ая|ой|ою|ую|ое|ые|ых|ыми)": "6"
},
{
"двадцать": "20"
"сем(ь|и|ью)": "7"
},
{
"тридцать": "30"
"седьм(ой|ого|ому|ым|ом|ая|ой|ою|ую|ое|ые|ых|ыми)": "7"
},
{
"сорок": "40"
"восьм(и|ью|ьею)|восем(ь|ью)": "8"
},
{
"пятьдесят": "50"
"восьм(ой|ого|ому|ым|ом|ая|ой|ою|ую|ое|ые|ых|ыми)": "8"
},
{
"несколько секунд": "44 секунды"
"девят(ь|и|ью)": "9"
},
{
"полчаса": "30 минут"
"девят(ый|ого|ому|ым|ом|ая|ой|ою|ую|ое|ые|ых|ыми)": "9"
},
{
"полгода": "6 месяцев"
"десять": "10"
},
{
"полтора часа": "90 минут"
"одиннадцать": "11"
},
{
"полтора года": "18 месяцев"
"двенадцать": "12"
},
{
"пятнадцать": "15"
},
{
"двадцат(ь|ое)": "20"
},
{
"тридцат(ь|ое)": "30"
},
{
"соро(к|ка|ковое)": "40"
},
{
"пятьдесят": "50"
},
{
"пятидесятое": "50"
},
{
"((?<=(через|спустя|в течение)\\s+)секунд[уы]|(?<=[^\\d]\\s+|^)секунду(?=(\\s+назад)))": "1 секунду"
Expand All @@ -426,12 +444,27 @@
{
"((?<=(через|спустя|в течение)\\s+)недел[юи]|(?<=[^\\d]\\s+|^)неделю(?=(\\s+назад)))": "1 неделю"
},
{
"полгода": "6 месяцев"
},
{
"((?<=(через|спустя|в течение)\\s+)месяца?|(?<=[^\\d]\\s+|^)месяц(?=(\\s+назад)))": "1 месяц"
},
{
"((?<=(через|спустя|в течение)\\s+)года?|(?<=[^\\d]\\s+|^)год(?=(\\s+назад)))": "1 год"
},
{
"полтора года": "18 месяцев"
},
{
"полчаса": "30 минут"
},
{
"несколько секунд": "44 секунды"
},
{
"полтора часа": "90 минут"
},
{
"(\\d{3,}1)\\s*год\\s*$": "\\1"
},
Expand Down
28 changes: 28 additions & 0 deletions dateparser/languages/locale.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,11 +426,39 @@ def _token_with_digits_is_ok(self, token):
def _simplify(self, date_string, settings=None):
date_string = date_string.lower()
simplifications = self._get_simplifications(settings=settings)

if self.info.get("name") == "ru":
date_string = self._process_russian_compound_ordinals(
date_string, simplifications
)
else:
date_string = self._apply_simplifications(date_string, simplifications)

return date_string

def _apply_simplifications(self, date_string, simplifications):
for simplification in simplifications:
pattern, replacement = list(simplification.items())[0]
date_string = pattern.sub(replacement, date_string).lower()
return date_string

def _process_russian_compound_ordinals(self, date_string, simplifications):
"""Process Russian compound ordinals mathematically (двадцать + первое = 21)."""
date_string = self._apply_simplifications(date_string, simplifications)

def replace_number_pairs(match):
first_num = int(match.group(1))
second_num = int(match.group(2))
result = first_num + second_num
if 1 <= result <= 31 and first_num in [20, 30] and 1 <= second_num <= 9:
return str(result)
return match.group(0)

number_pair_pattern = r"\b(\d+)\s+(\d+)\b"
date_string = re.sub(number_pair_pattern, replace_number_pairs, date_string)
Comment on lines +457 to +458
Copy link
Preview

Copilot AI Jul 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern should be compiled as a module-level constant since it's used repeatedly, which would improve performance and maintainability.

Suggested change
number_pair_pattern = r"\b(\d+)\s+(\d+)\b"
date_string = re.sub(number_pair_pattern, replace_number_pairs, date_string)
date_string = re.sub(NUMBER_PAIR_PATTERN, replace_number_pairs, date_string)

Copilot uses AI. Check for mistakes.


return date_string

def _get_simplifications(self, settings=None):
no_word_spacing = eval(self.info.get("no_word_spacing", "False"))
if settings.NORMALIZE:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,36 +72,47 @@ relative-type:
- послепослезавтра

simplifications:
- од(на|ну|ни|ной|ин): '1'
- дв(а|е|ое|ух): '2'
- пар[ауы]: '2'
- три: '3'
- четыре: '4'
- пять: '5'
- шесть: '6'
- семь: '7'
- восемь: '8'
- девять: '9'
- од(ин|на|ну|ни|ной|ною|но|ного|ному|ним|нем): '1'
- перв(ой|ого|ому|ым|ом|ая|ую|ое|ые|ых|ыми): '1'
- дв(а|е|ух|ум|умя|ое): '2'
- пар(а|ы|е|у|ой|ою|ам|ами|ах): '2'
- втор(ой|ого|ому|ым|ом|ая|ую|ое|ые|ых|ыми): '2'
- тр(и|ёх|ем|ём|емя|етье): '3'
- трети(й|его|ему|им|ем|я|ей|ею|ую|е|и|их|ими): '3'
- четыр(е|ёх|ем|ём|ьмя): '4'
- четвёрт(ый|ого|ому|ым|ом|ая|ой|ою|ую|ое|ые|ых|ыми): '4'
- четверт(ый|ого|ому|ым|ом|ая|ой|ою|ую|ое|ые|ых|ыми): '4'
- пят(ь|и|ью): '5'
- пят(ый|ого|ому|ым|ом|ая|ой|ою|ую|ое|ые|ых|ыми): '5'
- шест(ь|и|ью): '6'
- шест(ый|ого|ому|ым|ом|ая|ой|ою|ую|ое|ые|ых|ыми): '6'
- сем(ь|и|ью): '7'
- седьм(ой|ого|ому|ым|ом|ая|ой|ою|ую|ое|ые|ых|ыми): '7'
- восьм(и|ью|ьею)|восем(ь|ью): '8'
- восьм(ой|ого|ому|ым|ом|ая|ой|ою|ую|ое|ые|ых|ыми): '8'
- девят(ь|и|ью): '9'
- девят(ый|ого|ому|ым|ом|ая|ой|ою|ую|ое|ые|ых|ыми): '9'
- десять: '10'
- одиннадцать: '11'
- двенадцать: '12'
- пятнадцать: '15'
- двадцать: '20'
- тридцать: '30'
- сорок: '40'
- двадцат(ь|ое): '20'
- тридцат(ь|ое): '30'
- соро(к|ка|ковое): '40'
- пятьдесят: '50'
- несколько секунд: 44 секунды
- полчаса: 30 минут
- полгода: 6 месяцев
- полтора часа: 90 минут
- полтора года: 18 месяцев
- пятидесятое: '50'
- ((?<=(через|спустя|в течение)\s+)секунд[уы]|(?<=[^\d]\s+|^)секунду(?=(\s+назад))): 1 секунду
- ((?<=(через|спустя|в течение)\s+)минут[уы]|(?<=[^\d]\s+|^)минуту(?=(\s+назад))): 1 минуту
- ((?<=(через|спустя|в течение)\s+)часа?|(?<=[^\d]\s+|^)час(?=(\s+назад))): 1 час
- ((?<=(через|спустя|в течение)\s+)(день|дня)|(?<=[^\d]\s+|^)день(?=(\s+назад))): 1 день
- ((?<=(через|спустя|в течение)\s+)сут(ки|ок)|(?<=[^\d]\s+|^)сутки(?=(\s+назад))): 1 сутки
- ((?<=(через|спустя|в течение)\s+)недел[юи]|(?<=[^\d]\s+|^)неделю(?=(\s+назад))): 1 неделю
- полгода: 6 месяцев
- ((?<=(через|спустя|в течение)\s+)месяца?|(?<=[^\d]\s+|^)месяц(?=(\s+назад))): 1 месяц
- ((?<=(через|спустя|в течение)\s+)года?|(?<=[^\d]\s+|^)год(?=(\s+назад))): 1 год
- полтора года: 18 месяцев
- полчаса: 30 минут
- несколько секунд: 44 секунды
- полтора часа: 90 минут
- (\d{3,}1)\s*год\s*$: \1
- (\d*[02-9])\s*год\b: \1
110 changes: 110 additions & 0 deletions tests/test_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -1103,3 +1103,113 @@ def test_search_dates_with_prepositions(self):
("30 апреля", datetime.datetime(2025, 4, 30, 0, 0), "ru"),
]
assert result == expected

@parameterized.expand(
[
param(
text="Ужасное событие произошло в тот день. Двадцатое февраля. Вспоминаю тот день с ужасом.",
expected_text="Двадцатое февраля",
expected_day=20,
expected_month=2,
description="20th February",
),
param(
text="Ужасное событие произошло в тот день. Двадцать первое февраля. Вспоминаю тот день с ужасом.",
expected_text="Двадцать первое февраля",
expected_day=21,
expected_month=2,
description="21st February",
),
param(
text="Ужасное событие произошло в тот день. Двадцать второе февраля. Вспоминаю тот день с ужасом.",
expected_text="Двадцать второе февраля",
expected_day=22,
expected_month=2,
description="22nd February",
),
param(
text="Ужасное событие произошло в тот день. Двадцать третье февраля. Вспоминаю тот день с ужасом.",
expected_text="Двадцать третье февраля",
expected_day=23,
expected_month=2,
description="23rd February",
),
param(
text="Ужасное событие произошло в тот день. Двадцать четвёртое февраля. Вспоминаю тот день с ужасом.",
expected_text="Двадцать четвёртое февраля",
expected_day=24,
expected_month=2,
description="24th February (with ё)",
),
param(
text="Ужасное событие произошло в тот день. Двадцать четвертое февраля. Вспоминаю тот день с ужасом.",
expected_text="Двадцать четвертое февраля",
expected_day=24,
expected_month=2,
description="24th February (without ё)",
),
param(
text="Ужасное событие произошло в тот день. Двадцать пятое марта. Вспоминаю тот день с ужасом.",
expected_text="Двадцать пятое марта",
expected_day=25,
expected_month=3,
description="25th March",
),
param(
text="Ужасное событие произошло в тот день. Двадцать шестое марта. Вспоминаю тот день с ужасом.",
expected_text="Двадцать шестое марта",
expected_day=26,
expected_month=3,
description="26th March",
),
param(
text="Ужасное событие произошло в тот день. Двадцать седьмое марта. Вспоминаю тот день с ужасом.",
expected_text="Двадцать седьмое марта",
expected_day=27,
expected_month=3,
description="27th March",
),
param(
text="Ужасное событие произошло в тот день. Двадцать восьмое марта. Вспоминаю тот день с ужасом.",
expected_text="Двадцать восьмое марта",
expected_day=28,
expected_month=3,
description="28th March",
),
param(
text="Ужасное событие произошло в тот день. Двадцать девятое марта. Вспоминаю тот день с ужасом.",
expected_text="Двадцать девятое марта",
expected_day=29,
expected_month=3,
description="29th March",
),
param(
text="Ужасное событие произошло в тот день. Тридцатое марта. Вспоминаю тот день с ужасом.",
expected_text="Тридцатое марта",
expected_day=30,
expected_month=3,
description="30th March",
),
param(
text="Ужасное событие произошло в тот день. Тридцать первое марта. Вспоминаю тот день с ужасом.",
expected_text="Тридцать первое марта",
expected_day=31,
expected_month=3,
description="31st March",
),
]
)
def test_search_dates_multi_word_expression(
self, text, expected_text, expected_day, expected_month, description
):
"""Test parsing of multi-word date expressions in Russian."""
result = search_dates(text, languages=["ru"])
expected = [
(
expected_text,
datetime.datetime(
datetime.datetime.now().year, expected_month, expected_day, 0, 0
Copy link
Preview

Copilot AI Jul 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using datetime.datetime.now().year in tests can make them non-deterministic and fail when run across year boundaries. Consider using a fixed year value instead.

Suggested change
datetime.datetime.now().year, expected_month, expected_day, 0, 0
2025, expected_month, expected_day, 0, 0

Copilot uses AI. Check for mistakes.

),
)
]
self.assertEqual(result, expected)
Loading