VladGeekPro commited on
Commit
3e13659
·
1 Parent(s): 2a93301

TestNatasha

Browse files
Files changed (1) hide show
  1. natasha_dates.py +493 -259
natasha_dates.py CHANGED
@@ -1,178 +1,514 @@
1
  """
2
- Профессиональный парсер дат для русского языка.
3
- Использует Natasha (DatesExtractor) - лучшее решение для русского.
4
- Поддерживает точные и относительные даты с морфологическим анализом.
5
  """
6
 
7
  import re
8
  from datetime import date, datetime, timedelta
9
- from typing import Any, Optional
10
  from dateutil.relativedelta import relativedelta
11
 
12
- from natasha import DatesExtractor, MorphVocab
13
- from natasha.extractors import Match
 
 
 
 
 
 
14
 
15
- # Инициализация Natasha
16
- _MORPH_VOCAB: Optional[MorphVocab] = None
17
- _DATES_EXTRACTOR: Optional[DatesExtractor] = None
18
 
19
 
20
- def _get_extractor() -> DatesExtractor:
21
- """Ленивая инициализация экстрактора."""
22
  global _MORPH_VOCAB, _DATES_EXTRACTOR
 
 
23
  if _DATES_EXTRACTOR is None:
24
  _MORPH_VOCAB = MorphVocab()
25
  _DATES_EXTRACTOR = DatesExtractor(_MORPH_VOCAB)
26
  return _DATES_EXTRACTOR
27
 
28
 
29
- # Паттерны для относительных дат (Natasha не всегда их распознаёт)
30
- RELATIVE_PATTERNS = {
31
- # Простые относительные
32
- r'\bсегодня\b': lambda ref: ref,
33
- r'\bзавтра\b': lambda ref: ref + timedelta(days=1),
34
- r'\bпослезавтра\b': lambda ref: ref + timedelta(days=2),
35
- r'\bвчера\b': lambda ref: ref - timedelta(days=1),
36
- r'\bпозавчера\b': lambda ref: ref - timedelta(days=2),
37
-
38
- # "через X дней/недель/месяцев"
39
- r'\bчерез\s+(\d+)\s+(?:день|дня|дней)\b': lambda ref, n: ref + timedelta(days=int(n)),
40
- r'\bчерез\s+(\d+)\s+(?:неделю|недели|недель)\b': lambda ref, n: ref + timedelta(weeks=int(n)),
41
- r'\bчерез\s+(\d+)\s+(?:месяц|месяца|месяцев)\b': lambda ref, n: ref + relativedelta(months=int(n)),
42
-
43
- # "X дней/недель/месяцев назад"
44
- r'\b(\d+)\s+(?:день|дня|дней)\s+назад\b': lambda ref, n: ref - timedelta(days=int(n)),
45
- r'\b(\d+)\s+(?:неделю|недели|недель)\s+назад\b': lambda ref, n: ref - timedelta(weeks=int(n)),
46
- r'\b(\d+)\s+(?:месяц|месяца|месяцев)\s+назад\b': lambda ref, n: ref - relativedelta(months=int(n)),
47
-
48
- # Дни недели
49
- r'\b(?:в\s+)?(?:прошлый|прошлую)\s+(понедельник|вторник|среду|четверг|пятницу|субботу|воскресенье)\b': 'past_weekday',
50
- r'\b(?:в\s+)?(?:следующий|следующую|этот|эту)\s+(понедельник|вторник|среду|четверг|пятницу|субботу|воскресенье)\b': 'next_weekday',
51
-
52
- # Недели
53
- r'\b(?:на\s+)?(?:прошлой|прошлую)\s+неделю?\b': lambda ref: ref - timedelta(weeks=1),
54
- r'\b(?:на\s+)?(?:следующей|следующую)\s+неделю?\b': lambda ref: ref + timedelta(weeks=1),
55
- r'\b(?:на\s+)?этой\s+неделе\b': lambda ref: ref,
56
-
57
- # Начало/конец периода
58
- r'\b(?:в\s+)?начал[еоа]\s+месяца\b': lambda ref: ref.replace(day=1),
59
- r'\b(?:в\s+)?конц[еа]\s+месяца\b': lambda ref: (ref.replace(day=1) + relativedelta(months=1) - timedelta(days=1)),
60
- r'\b(?:в\s+)?начал[еоа]\s+недели\b': lambda ref: ref - timedelta(days=ref.weekday()),
61
- r'\b(?:в\s+)?конц[еа]\s+недели\b': lambda ref: ref + timedelta(days=6-ref.weekday()),
 
 
 
 
 
 
62
  }
63
 
64
- WEEKDAY_MAP = {
65
- 'понедельник': 0, 'вторник': 1, 'среду': 2, 'среда': 2,
66
- етверг': 3, 'пятницу': 4, 'пятница': 4,
67
- 'субботу': 5, 'суббота': 5, 'воскресенье': 6,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  }
69
 
70
- # Индикаторы прошедшего времени для контекстной коррекции
71
- PAST_CONTEXT_WORDS = re.compile(
72
  r'\b(оплата|оплатил[аи]?|заплатил[аи]?|купил[аи]?|заказал[аи]?|'
73
- r'потратил[аи]?|был[аио]?|получил[аи]?|сделал[аи]?)\b',
 
74
  re.IGNORECASE
75
  )
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
- def _parse_weekday(text: str, reference: date, direction: str) -> Optional[date]:
79
- """Парсит день недели относительно reference."""
 
 
 
 
 
 
 
80
  text_lower = text.lower()
81
- for name, weekday_num in WEEKDAY_MAP.items():
82
- if name in text_lower:
83
- days_diff = weekday_num - reference.weekday()
84
- if direction == 'past_weekday':
85
- if days_diff >= 0:
86
- days_diff -= 7
87
- else: # next_weekday
88
- if days_diff <= 0:
89
- days_diff += 7
90
- return reference + timedelta(days=days_diff)
91
  return None
92
 
93
 
94
- def _parse_relative_date(text: str, reference: date) -> Optional[tuple[date, str]]:
95
- """Парсит относительные даты через регулярные выражения."""
96
  text_lower = text.lower()
97
 
98
- for pattern, handler in RELATIVE_PATTERNS.items():
99
- match = re.search(pattern, text_lower, re.IGNORECASE)
 
 
 
 
 
 
 
100
  if match:
101
- matched_text = match.group(0)
102
-
103
- if handler == 'past_weekday':
104
- result = _parse_weekday(matched_text, reference, 'past_weekday')
105
- elif handler == 'next_weekday':
106
- result = _parse_weekday(matched_text, reference, 'next_weekday')
107
- elif callable(handler):
108
- groups = match.groups()
109
- if groups:
110
- result = handler(reference, groups[0])
111
- else:
112
- result = handler(reference)
113
- else:
114
- continue
115
-
116
- if result:
117
- return result, matched_text
 
 
 
 
 
 
 
 
 
 
118
 
119
  return None
120
 
121
 
122
- def _natasha_match_to_date(match: Match, reference: date) -> Optional[date]:
123
- """Конвертирует результат Natasha в date."""
124
- fact = match.fact
125
 
126
- year = getattr(fact, 'year', None)
127
- month = getattr(fact, 'month', None)
128
- day = getattr(fact, 'day', None)
 
 
 
 
 
 
 
129
 
130
- # Если год не указан, берём из reference
131
- if year is None:
132
- year = reference.year
 
 
 
133
 
134
- # Если месяц не указан
135
- if month is None:
136
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
- # Если день не указан, берём 1-е число
139
- if day is None:
140
- day = 1
 
 
 
 
 
 
 
141
 
142
- try:
143
- return date(year, month, day)
144
- except ValueError:
145
- return None
 
 
 
 
 
 
 
146
 
147
 
148
- def _adjust_date_by_context(parsed_date: date, text: str, reference: date) -> date:
149
- """
150
- Корректирует дату по контексту.
151
- Если есть индикаторы прошлого и дата в будущем - сдвигаем на год назад.
152
- """
153
- if PAST_CONTEXT_WORDS.search(text) and parsed_date > reference:
154
- return parsed_date - relativedelta(years=1)
155
- return parsed_date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  def parse_date_natasha(
159
  text: str,
160
  reference_date: Optional[date] = None
161
  ) -> dict[str, Any]:
162
  """
163
- Извлекает дату из текста с использованием Natasha.
 
 
 
 
 
 
 
 
 
 
164
 
165
  Args:
166
  text: Текст для анализа
167
  reference_date: Опорная дата (по умолчанию - сегодня)
168
 
169
  Returns:
170
- {
171
- "date": "19.04.2026",
172
- "date_iso": "2026-04-19",
173
- "matched_date_phrase": "завтра",
174
- "parser": "natasha" # или "relative" или "fallback"
175
- }
176
  """
177
  if reference_date is None:
178
  reference_date = date.today()
@@ -181,164 +517,56 @@ def parse_date_natasha(
181
  "date": None,
182
  "date_iso": None,
183
  "matched_date_phrase": None,
184
- "parser": None
185
  }
186
 
187
- # 1. Сначала пробуем относительные паттерны (они надёжнее для "завтра", "через 2 дня")
188
- relative_result = _parse_relative_date(text, reference_date)
189
- if relative_result:
190
- parsed_date, matched = relative_result
191
- parsed_date = _adjust_date_by_context(parsed_date, text, reference_date)
192
- result["date"] = parsed_date.strftime("%d.%m.%Y")
193
- result["date_iso"] = parsed_date.isoformat()
194
- result["matched_date_phrase"] = matched
195
- result["parser"] = "relative"
196
- return result
197
-
198
- # 2. Затем пробуем Natasha для точных дат ("15 января 2025", "5 марта")
199
- try:
200
- extractor = _get_extractor()
201
- matches = list(extractor(text))
202
-
203
- if matches:
204
- # Берём первый результат
205
- match = matches[0]
206
- parsed_date = _natasha_match_to_date(match, reference_date)
207
-
208
- if parsed_date:
209
- parsed_date = _adjust_date_by_context(parsed_date, text, reference_date)
210
- result["date"] = parsed_date.strftime("%d.%m.%Y")
211
- result["date_iso"] = parsed_date.isoformat()
212
- result["matched_date_phrase"] = text[match.start:match.stop]
213
- result["parser"] = "natasha"
214
- return result
215
- except Exception as e:
216
- print(f"Natasha error: {e}")
217
-
218
- # 3. Fallback: ищем месяц без дня ("за март", "за апрель")
219
- month_pattern = re.compile(
220
- r'\b(?:за|в|на)\s+(январ[ья]|феврал[ья]|март[а]?|апрел[ья]|ма[йя]|июн[ья]|'
221
- r'июл[ья]|август[а]?|сентябр[ья]|октябр[ья]|ноябр[ья]|декабр[ья])\b',
222
- re.IGNORECASE
223
- )
224
- month_match = month_pattern.search(text)
225
- if month_match:
226
- month_names = {
227
- 'январ': 1, 'феврал': 2, 'март': 3, 'апрел': 4, 'ма': 5, 'июн': 6,
228
- 'июл': 7, 'август': 8, 'сентябр': 9, 'октябр': 10, 'ноябр': 11, 'декабр': 12
229
- }
230
- month_text = month_match.group(1).lower()
231
- for prefix, month_num in month_names.items():
232
- if month_text.startswith(prefix):
233
- # Определяем год по контексту
234
- year = reference_date.year
235
- # Если месяц > текущего и есть индикаторы прошлого - прошлый год
236
- if month_num > reference_date.month and PAST_CONTEXT_WORDS.search(text):
237
- year -= 1
238
- # Если месяц < текущего и нет индикаторов прошлого - этот год
239
- elif month_num < reference_date.month and not PAST_CONTEXT_WORDS.search(text):
240
- pass # оставляем текущий год
241
-
242
- try:
243
- parsed_date = date(year, month_num, 1)
244
- result["date"] = parsed_date.strftime("%d.%m.%Y")
245
- result["date_iso"] = parsed_date.isoformat()
246
- result["matched_date_phrase"] = month_match.group(0)
247
- result["parser"] = "fallback_month"
248
- return result
249
- except ValueError:
250
- pass
251
- break
252
 
253
  return result
254
 
255
 
256
- def parse_all_dates_natasha(
257
- text: str,
258
- reference_date: Optional[date] = None
259
- ) -> list[dict[str, Any]]:
260
- """Извлекает все даты из текста."""
261
- if reference_date is None:
262
- reference_date = date.today()
263
-
264
- results = []
265
-
266
- # Natasha matches
267
- try:
268
- extractor = _get_extractor()
269
- for match in extractor(text):
270
- parsed_date = _natasha_match_to_date(match, reference_date)
271
- if parsed_date:
272
- parsed_date = _adjust_date_by_context(parsed_date, text, reference_date)
273
- results.append({
274
- "date": parsed_date.strftime("%d.%m.%Y"),
275
- "date_iso": parsed_date.isoformat(),
276
- "matched_date_phrase": text[match.start:match.stop],
277
- "start": match.start,
278
- "end": match.stop,
279
- "parser": "natasha"
280
- })
281
- except Exception as e:
282
- print(f"Natasha error: {e}")
283
-
284
- # Relative dates
285
- relative_result = _parse_relative_date(text, reference_date)
286
- if relative_result:
287
- parsed_date, matched = relative_result
288
- parsed_date = _adjust_date_by_context(parsed_date, text, reference_date)
289
- # Проверяем что не дубликат
290
- if not any(r["date_iso"] == parsed_date.isoformat() for r in results):
291
- results.append({
292
- "date": parsed_date.strftime("%d.%m.%Y"),
293
- "date_iso": parsed_date.isoformat(),
294
- "matched_date_phrase": matched,
295
- "start": text.lower().find(matched.lower()),
296
- "end": text.lower().find(matched.lower()) + len(matched),
297
- "parser": "relative"
298
- })
299
-
300
- return results
301
-
302
-
303
- # Для обратной совместимости
304
  class NatashaDateExtractor:
305
- """
306
- Экстрактор дат на основе Natasha.
307
- Рекомендуемое решение для русск��го языка.
308
- """
309
 
310
  def extract(self, text: str, reference_date: Optional[date] = None) -> dict[str, Any]:
311
- """Извлекает первую дату из текста."""
312
  ref = reference_date or date.today()
313
  if isinstance(ref, str):
314
  ref = datetime.strptime(ref, "%Y-%m-%d").date()
315
-
316
- result = parse_date_natasha(text, ref)
317
- # Убираем parser из результата для совместимости
318
- return {
319
- "date": result["date"],
320
- "date_iso": result["date_iso"],
321
- "matched_date_phrase": result["matched_date_phrase"],
322
- }
323
 
324
  def extract_all(self, text: str, reference_date: Optional[date] = None) -> list[dict[str, Any]]:
325
- """Извлекает все даты из текста."""
326
- ref = reference_date or date.today()
327
- if isinstance(ref, str):
328
- ref = datetime.strptime(ref, "%Y-%m-%d").date()
329
-
330
- results = parse_all_dates_natasha(text, ref)
331
- return [{
332
- "date": r["date"],
333
- "date_iso": r["date_iso"],
334
- "matched_date_phrase": r["matched_date_phrase"],
335
- } for r in results]
336
 
337
 
338
  if __name__ == "__main__":
 
339
  test_phrases = [
340
  "завтра",
341
  "через 2 дня",
 
342
  "на следующей неделе",
343
  "15 января 2025",
344
  "позавчера",
@@ -347,9 +575,15 @@ if __name__ == "__main__":
347
  "5 марта",
348
  "купил вчера",
349
  "в конце месяца",
 
 
 
 
350
  ]
351
 
352
- print("Тестирование Natasha DateExtractor:\n")
 
 
353
  for phrase in test_phrases:
354
- result = parse_date_natasha(phrase)
355
- print(f" '{phrase}' -> {result['date_iso']} ({result['parser']})")
 
1
  """
2
+ Универсальный парсер дат для русского языка.
3
+ Использует собственные правила + опционально Natasha как fallback.
4
+ Поддерживает: точные даты, относительные, порядковые числительные, числа словами.
5
  """
6
 
7
  import re
8
  from datetime import date, datetime, timedelta
9
+ from typing import Any, Optional, Callable
10
  from dateutil.relativedelta import relativedelta
11
 
12
+ # Опциональный импорт Natasha
13
+ try:
14
+ from natasha import DatesExtractor, MorphVocab
15
+ NATASHA_AVAILABLE = True
16
+ except ImportError:
17
+ NATASHA_AVAILABLE = False
18
+ DatesExtractor = None
19
+ MorphVocab = None
20
 
21
+ # Инициализация Natasha (ленивая)
22
+ _MORPH_VOCAB = None
23
+ _DATES_EXTRACTOR = None
24
 
25
 
26
+ def _get_extractor():
27
+ """Ленивая инициализация экстрактора Natasha."""
28
  global _MORPH_VOCAB, _DATES_EXTRACTOR
29
+ if not NATASHA_AVAILABLE:
30
+ return None
31
  if _DATES_EXTRACTOR is None:
32
  _MORPH_VOCAB = MorphVocab()
33
  _DATES_EXTRACTOR = DatesExtractor(_MORPH_VOCAB)
34
  return _DATES_EXTRACTOR
35
 
36
 
37
+ # ============== СЛОВАРИ ==============
38
+
39
+ MONTHS = {
40
+ нварь": 1, "января": 1, "январе": 1,
41
+ "февраль": 2, "февраля": 2, "феврале": 2,
42
+ арт": 3, "марта": 3, "марте": 3,
43
+ "апрель": 4, "апреля": 4, "апреле": 4,
44
+ ай": 5, "мая": 5, "мае": 5,
45
+ "июнь": 6, "июня": 6, "июне": 6,
46
+ "июль": 7, "июля": 7, "июле": 7,
47
+ "август": 8, "августа": 8, "августе": 8,
48
+ ентябрь": 9, "сентября": 9, "сентябре": 9,
49
+ "октябрь": 10, "октября": 10, "октябре": 10,
50
+ "ноябрь": 11, "ноября": 11, "ноябре": 11,
51
+ "декабрь": 12, "декабря": 12, "декабре": 12,
52
+ }
53
+
54
+ WEEKDAYS = {
55
+ "понедельник": 0, "вторник": 1, "среда": 2, "среду": 2,
56
+ "четверг": 3, "пятница": 4, "пятницу": 4,
57
+ уббота": 5, "субботу": 5, "воскресенье": 6,
58
+ }
59
+
60
+ # Числа словами (кардинальные)
61
+ NUMBER_WORDS = {
62
+ "ноль": 0, "один": 1, "одну": 1, "одного": 1,
63
+ "два": 2, "две": 2, "двух": 2,
64
+ "три": 3, "трёх": 3, "трех": 3,
65
+ "четыре": 4, "четырёх": 4, "четырех": 4,
66
+ "пять": 5, "пяти": 5,
67
+ есть": 6, "шести": 6,
68
+ емь": 7, "семи": 7,
69
+ "восемь": 8, "восьми": 8,
70
+ "девять": 9, "девяти": 9,
71
+ "десять": 10, "десяти": 10,
72
+ "одиннадцать": 11, "двенадцать": 12, "тринадцать": 13,
73
+ "четырнадцать": 14, "пятнадцать": 15, "шестнадцать": 16,
74
+ "семнадцать": 17, "восемнадцать": 18, "девятнадцать": 19,
75
+ "двадцать": 20, "тридцать": 30,
76
  }
77
 
78
+ # Порядковые числительные для дней
79
+ ORDINAL_DAYS = {
80
+ "первое": 1, "первого": 1, "первом": 1,
81
+ торое": 2, торого": 2, "втором": 2,
82
+ "третье": 3, "третьего": 3, "третьем": 3,
83
+ "четвёртое": 4, "четвертое": 4, "четвёртого": 4, "четвертого": 4,
84
+ "пятое": 5, "пятого": 5,
85
+ "шестое": 6, "шестого": 6,
86
+ "седьмое": 7, "седьмого": 7,
87
+ "восьмое": 8, "восьмого": 8,
88
+ "девятое": 9, "девятого": 9,
89
+ "десятое": 10, "десятого": 10,
90
+ "одиннадцатое": 11, "одиннадцатого": 11,
91
+ "двенадцатое": 12, "двенадцатого": 12,
92
+ "тринадцатое": 13, "тринадцатого": 13,
93
+ "четырнадцатое": 14, "четырнадцатого": 14,
94
+ "пятнадцатое": 15, "пятнадцатого": 15,
95
+ "шестнадцатое": 16, "шестнадцатого": 16,
96
+ "семнадцатое": 17, "семнадцатого": 17,
97
+ "восемнадцатое": 18, "восемнадцатого": 18,
98
+ "девятнадцатое": 19, "девятнадцатого": 19,
99
+ "двадцатое": 20, "двадцатого": 20,
100
+ "двадцать первое": 21, "двадцать первого": 21,
101
+ "двадцать второе": 22, "двадцать второго": 22,
102
+ "двадцать третье": 23, "двадцать третьего": 23,
103
+ "двадцать четвёртое": 24, "двадцать четвертое": 24, "двадцать четвёртого": 24, "двадцать четвертого": 24,
104
+ "двадцать пятое": 25, "двадцать пятого": 25,
105
+ "двадцать шестое": 26, "двадцать шестого": 26,
106
+ "двадцать седьмое": 27, "двадцать седьмого": 27,
107
+ "двадцать восьмое": 28, "двадцать восьмого": 28,
108
+ "двадцать девятое": 29, "двадцать девятого": 29,
109
+ "тридцатое": 30, "тридцатого": 30,
110
+ "тридцать первое": 31, "тридцать первого": 31,
111
  }
112
 
113
+ # Контекст прошлого/будущего
114
+ PAST_INDICATORS = re.compile(
115
  r'\b(оплата|оплатил[аи]?|заплатил[аи]?|купил[аи]?|заказал[аи]?|'
116
+ r'потратил[аи]?|был[аио]?|получил[аи]?|сделал[аи]?|прошл[аоыйую]|'
117
+ r'предыдущ[аоыйую]|назад)\b',
118
  re.IGNORECASE
119
  )
120
 
121
+ FUTURE_INDICATORS = re.compile(
122
+ r'\b(завтра|послезавтра|через|следующ[аоыйую]|будущ[аоыйую]|'
123
+ r'заплатить|купить|заказать)\b',
124
+ re.IGNORECASE
125
+ )
126
+
127
+
128
+ # ============== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ==============
129
+
130
+ def _parse_number(text: str) -> Optional[int]:
131
+ """Парсит число из текста (цифры или словами)."""
132
+ text = text.strip().lower().replace('ё', 'е')
133
+
134
+ # Цифры
135
+ if text.isdigit():
136
+ return int(text)
137
+
138
+ # Одно слово
139
+ if text in NUMBER_WORDS:
140
+ return NUMBER_WORDS[text]
141
+
142
+ # Два слова (двадцать один)
143
+ parts = text.split()
144
+ if len(parts) == 2:
145
+ tens = NUMBER_WORDS.get(parts[0])
146
+ units = NUMBER_WORDS.get(parts[1])
147
+ if tens in (20, 30) and units and 1 <= units <= 9:
148
+ return tens + units
149
+
150
+ return None
151
+
152
+
153
+ def _parse_day(text: str) -> Optional[int]:
154
+ """Парсит день месяца (цифры или порядковые числительные)."""
155
+ text = text.strip().lower().replace('ё', 'е')
156
+
157
+ if text.isdigit():
158
+ val = int(text)
159
+ return val if 1 <= val <= 31 else None
160
+
161
+ # Порядковые числительные
162
+ if text in ORDINAL_DAYS:
163
+ return ORDINAL_DAYS[text]
164
+
165
+ # Составные порядковые (двадцать первого)
166
+ for phrase, day in ORDINAL_DAYS.items():
167
+ if ' ' in phrase and phrase in text:
168
+ return day
169
+
170
+ return None
171
+
172
+
173
+ def _week_start(ref: date) -> date:
174
+ """Понедельник текущей недели."""
175
+ return ref - timedelta(days=ref.weekday())
176
+
177
+
178
+ def _get_weekday_date(weekday: int, ref: date, direction: str) -> date:
179
+ """Находит дату дня недели относительно ref."""
180
+ days_diff = weekday - ref.weekday()
181
+
182
+ if direction == 'past':
183
+ if days_diff >= 0:
184
+ days_diff -= 7
185
+ elif direction == 'next':
186
+ if days_diff <= 0:
187
+ days_diff += 7
188
+ # 'this' - ближайший
189
+
190
+ return ref + timedelta(days=days_diff)
191
+
192
+
193
+ def _adjust_year_by_context(parsed_date: date, text: str, ref: date) -> date:
194
+ """Корректирует год по контексту (прошлое/будущее)."""
195
+ has_past = bool(PAST_INDICATORS.search(text))
196
+ has_future = bool(FUTURE_INDICATORS.search(text))
197
+
198
+ # Если явно прошлое и дата в будущем
199
+ if has_past and not has_future and parsed_date > ref:
200
+ return parsed_date - relativedelta(years=1)
201
+
202
+ # Если явно будущее и дата в прошлом
203
+ if has_future and not has_past and parsed_date < ref:
204
+ return parsed_date + relativedelta(years=1)
205
+
206
+ return parsed_date
207
+
208
+
209
+ # ============== ПАРСЕРЫ ==============
210
 
211
+ def _parse_direct_relative(text: str, ref: date) -> Optional[tuple[date, str]]:
212
+ """Прямые относительные: сегодня, завтра, вчера..."""
213
+ patterns = [
214
+ (r'\bпослезавтра\b', 2),
215
+ (r'\bпозавчера\b', -2),
216
+ (r'\bсегодня\b', 0),
217
+ (r'\bзавтра\b', 1),
218
+ (r'\bвчера\b', -1),
219
+ ]
220
  text_lower = text.lower()
221
+ for pattern, delta in patterns:
222
+ match = re.search(pattern, text_lower)
223
+ if match:
224
+ return ref + timedelta(days=delta), match.group(0)
 
 
 
 
 
 
225
  return None
226
 
227
 
228
+ def _parse_quantity_relative(text: str, ref: date) -> Optional[tuple[date, str]]:
229
+ """Количественные: через 2 дня, 3 недели назад..."""
230
  text_lower = text.lower()
231
 
232
+ # через X дней/недель/месяцев
233
+ patterns_forward = [
234
+ (r'\bчерез\s+(\d+|[а-яё]+(?:\s+[а-яё]+)?)\s+(день|дня|дней)\b', 'days'),
235
+ (r'\bчерез\s+(\d+|[а-яё]+(?:\s+[а-яё]+)?)\s+(неделю|недели|недель)\b', 'weeks'),
236
+ (r'\bчерез\s+(\d+|[а-яё]+(?:\s+[а-яё]+)?)\s+(месяц|месяца|месяцев)\b', 'months'),
237
+ ]
238
+
239
+ for pattern, unit in patterns_forward:
240
+ match = re.search(pattern, text_lower)
241
  if match:
242
+ num = _parse_number(match.group(1))
243
+ if num:
244
+ if unit == 'days':
245
+ return ref + timedelta(days=num), match.group(0)
246
+ elif unit == 'weeks':
247
+ return ref + timedelta(weeks=num), match.group(0)
248
+ elif unit == 'months':
249
+ return ref + relativedelta(months=num), match.group(0)
250
+
251
+ # X дней/недель/месяцев назад
252
+ patterns_back = [
253
+ (r'\b(\d+|[а-яё]+(?:\s+[а-яё]+)?)\s+(день|дня|дней)\s+назад\b', 'days'),
254
+ (r'\b(\d+|[а-яё]+(?:\s+[а-яё]+)?)\s+(неделю|недели|недель)\s+назад\b', 'weeks'),
255
+ (r'\b(\d+|[а-яё]+(?:\s+[а-яё]+)?)\s+(месяц|месяца|месяцев)\s+назад\b', 'months'),
256
+ ]
257
+
258
+ for pattern, unit in patterns_back:
259
+ match = re.search(pattern, text_lower)
260
+ if match:
261
+ num = _parse_number(match.group(1))
262
+ if num:
263
+ if unit == 'days':
264
+ return ref - timedelta(days=num), match.group(0)
265
+ elif unit == 'weeks':
266
+ return ref - timedelta(weeks=num), match.group(0)
267
+ elif unit == 'months':
268
+ return ref - relativedelta(months=num), match.group(0)
269
 
270
  return None
271
 
272
 
273
+ def _parse_week_relative(text: str, ref: date) -> Optional[tuple[date, str]]:
274
+ """Недельные: на следующей неделе, на прошлой неделе..."""
275
+ text_lower = text.lower()
276
 
277
+ # на следующей/прошлой/этой неделе
278
+ week_patterns = [
279
+ (r'\b(?:на\s+)?следующ(?:ей|ую)\s+недел[юеи]\b', 7),
280
+ (r'\b(?:на\s+)?прошл(?:ой|ую)\s+недел[юеи]\b', -7),
281
+ (r'\b(?:на\s+)?предыдущ(?:ей|ую)\s+недел[юеи]\b', -7),
282
+ (r'\b(?:на\s+)?этой\s+неделе\b', 0),
283
+ (r'\b(?:на\s+)?текущ(?:ей|ую)\s+недел[юеи]\b', 0),
284
+ (r'\bчерез\s+неделю\b', 7),
285
+ (r'\bнеделю\s+назад\b', -7),
286
+ ]
287
 
288
+ for pattern, delta in week_patterns:
289
+ match = re.search(pattern, text_lower)
290
+ if match:
291
+ # Понедельник целевой недели
292
+ target_monday = _week_start(ref) + timedelta(days=delta)
293
+ return target_monday, match.group(0)
294
 
295
+ return None
296
+
297
+
298
+ def _parse_weekday(text: str, ref: date) -> Optional[tuple[date, str]]:
299
+ """Дни недели: в прошлый понедельник, в следующую пятницу..."""
300
+ text_lower = text.lower()
301
+
302
+ # Прошлый день недели
303
+ match = re.search(
304
+ r'\b(?:в\s+)?прошл(?:ый|ую)\s+(понедельник|вторник|сред[ау]|четверг|пятниц[ау]|суббот[ау]|воскресенье)\b',
305
+ text_lower
306
+ )
307
+ if match:
308
+ weekday_text = match.group(1).replace('у', 'а') if match.group(1).endswith('у') else match.group(1)
309
+ weekday = WEEKDAYS.get(weekday_text) or WEEKDAYS.get(match.group(1))
310
+ if weekday is not None:
311
+ return _get_weekday_date(weekday, ref, 'past'), match.group(0)
312
 
313
+ # Следующий день недели
314
+ match = re.search(
315
+ r'\b(?:в\s+)?следующ(?:ий|ую)\s+(понедельник|вторник|сред[ау]|четверг|пятниц[ау]|суббот[ау]|воскресенье)\b',
316
+ text_lower
317
+ )
318
+ if match:
319
+ weekday_text = match.group(1)
320
+ weekday = WEEKDAYS.get(weekday_text)
321
+ if weekday is not None:
322
+ return _get_weekday_date(weekday, ref, 'next'), match.group(0)
323
 
324
+ # Этот день недели
325
+ match = re.search(
326
+ r'\b(?:в\s+)?(?:этот|эту)\s+(понедельник|вторник|сред[ау]|четверг|пятниц[ау]|суббот[ау]|воскресенье)\b',
327
+ text_lower
328
+ )
329
+ if match:
330
+ weekday = WEEKDAYS.get(match.group(1))
331
+ if weekday is not None:
332
+ return _get_weekday_date(weekday, ref, 'this'), match.group(0)
333
+
334
+ return None
335
 
336
 
337
+ def _parse_period_edge(text: str, ref: date) -> Optional[tuple[date, str]]:
338
+ """Границы периодов: в начале месяца, в конце недели..."""
339
+ text_lower = text.lower()
340
+
341
+ # Начало/конец месяца
342
+ match = re.search(r'\b(?:в\s+)?начал[еоа]\s+месяца\b', text_lower)
343
+ if match:
344
+ return ref.replace(day=1), match.group(0)
345
+
346
+ match = re.search(r'\b(?:в\s+)?конц[еа]\s+месяца\b', text_lower)
347
+ if match:
348
+ last_day = (ref.replace(day=1) + relativedelta(months=1) - timedelta(days=1)).day
349
+ return ref.replace(day=last_day), match.group(0)
350
+
351
+ # Начало/конец недели
352
+ match = re.search(r'\b(?:в\s+)?начал[еоа]\s+недели\b', text_lower)
353
+ if match:
354
+ return _week_start(ref), match.group(0)
355
+
356
+ match = re.search(r'\b(?:в\s+)?конц[еа]\s+недели\b', text_lower)
357
+ if match:
358
+ return _week_start(ref) + timedelta(days=6), match.group(0)
359
+
360
+ # Начало/конец следующего месяца
361
+ match = re.search(r'\b(?:в\s+)?начал[еоа]\s+следующего\s+месяца\b', text_lower)
362
+ if match:
363
+ return (ref.replace(day=1) + relativedelta(months=1)), match.group(0)
364
+
365
+ match = re.search(r'\b(?:в\s+)?конц[еа]\s+следующего\s+месяца\b', text_lower)
366
+ if match:
367
+ next_month = ref.replace(day=1) + relativedelta(months=2) - timedelta(days=1)
368
+ return next_month, match.group(0)
369
+
370
+ return None
371
+
372
+
373
+ def _parse_textual_date(text: str, ref: date) -> Optional[tuple[date, str]]:
374
+ """Текстовые даты: 15 января, пятого марта 2025..."""
375
+ text_lower = text.lower().replace('ё', 'е')
376
+
377
+ # Порядковые + месяц: пятого марта, двадцать первого января
378
+ for ordinal, day in sorted(ORDINAL_DAYS.items(), key=lambda x: -len(x[0])):
379
+ for month_name, month_num in MONTHS.items():
380
+ pattern = rf'\b{re.escape(ordinal)}\s+{re.escape(month_name)}(?:\s+(\d{{4}}))?\b'
381
+ match = re.search(pattern, text_lower)
382
+ if match:
383
+ year = int(match.group(1)) if match.group(1) else ref.year
384
+ try:
385
+ parsed = date(year, month_num, day)
386
+ parsed = _adjust_year_by_context(parsed, text, ref)
387
+ return parsed, match.group(0)
388
+ except ValueError:
389
+ continue
390
+
391
+ # Цифра + месяц: 15 января 2025
392
+ for month_name, month_num in MONTHS.items():
393
+ pattern = rf'\b(\d{{1,2}})\s+{re.escape(month_name)}(?:\s+(\d{{4}}))?\b'
394
+ match = re.search(pattern, text_lower)
395
+ if match:
396
+ day = int(match.group(1))
397
+ year = int(match.group(2)) if match.group(2) else ref.year
398
+ if 1 <= day <= 31:
399
+ try:
400
+ parsed = date(year, month_num, day)
401
+ parsed = _adjust_year_by_context(parsed, text, ref)
402
+ return parsed, match.group(0)
403
+ except ValueError:
404
+ continue
405
+
406
+ return None
407
+
408
+
409
+ def _parse_month_only(text: str, ref: date) -> Optional[tuple[date, str]]:
410
+ """Только месяц: за март, в апреле..."""
411
+ text_lower = text.lower()
412
+
413
+ for month_name, month_num in MONTHS.items():
414
+ pattern = rf'\b(?:за|в|на)\s+{re.escape(month_name)}\b'
415
+ match = re.search(pattern, text_lower)
416
+ if match:
417
+ year = ref.year
418
+ # Контекст определяет год
419
+ if PAST_INDICATORS.search(text) and month_num > ref.month:
420
+ year -= 1
421
+ elif not PAST_INDICATORS.search(text) and month_num < ref.month:
422
+ # Если месяц уже прошёл и нет индикаторов - следующий год?
423
+ # Нет, оставляем текущий год по умолчанию
424
+ pass
425
+
426
+ return date(year, month_num, 1), match.group(0)
427
+
428
+ return None
429
+
430
+
431
+ def _parse_numeric_date(text: str, ref: date) -> Optional[tuple[date, str]]:
432
+ """Числовые даты: 15.01.2025, 2025-01-15..."""
433
+ # DD.MM.YYYY или DD/MM/YYYY или DD-MM-YYYY
434
+ match = re.search(r'\b(\d{1,2})[./\-](\d{1,2})[./\-](\d{4})\b', text)
435
+ if match:
436
+ day, month, year = int(match.group(1)), int(match.group(2)), int(match.group(3))
437
+ try:
438
+ return date(year, month, day), match.group(0)
439
+ except ValueError:
440
+ pass
441
+
442
+ # YYYY-MM-DD
443
+ match = re.search(r'\b(\d{4})[./\-](\d{1,2})[./\-](\d{1,2})\b', text)
444
+ if match:
445
+ year, month, day = int(match.group(1)), int(match.group(2)), int(match.group(3))
446
+ try:
447
+ return date(year, month, day), match.group(0)
448
+ except ValueError:
449
+ pass
450
+
451
+ return None
452
 
453
 
454
+ def _parse_with_natasha(text: str, ref: date) -> Optional[tuple[date, str]]:
455
+ """Natasha как fallback для сложных случаев."""
456
+ if not NATASHA_AVAILABLE:
457
+ return None
458
+
459
+ try:
460
+ extractor = _get_extractor()
461
+ if extractor is None:
462
+ return None
463
+
464
+ matches = list(extractor(text))
465
+
466
+ if matches:
467
+ match = matches[0]
468
+ fact = match.fact
469
+
470
+ year = getattr(fact, 'year', None) or ref.year
471
+ month = getattr(fact, 'month', None)
472
+ day = getattr(fact, 'day', None) or 1
473
+
474
+ if month:
475
+ try:
476
+ parsed = date(year, month, day)
477
+ parsed = _adjust_year_by_context(parsed, text, ref)
478
+ return parsed, text[match.start:match.stop]
479
+ except ValueError:
480
+ pass
481
+ except Exception:
482
+ pass
483
+
484
+ return None
485
+
486
+
487
+ # ============== ГЛАВНАЯ ФУНКЦИЯ ==============
488
+
489
  def parse_date_natasha(
490
  text: str,
491
  reference_date: Optional[date] = None
492
  ) -> dict[str, Any]:
493
  """
494
+ Универсальный парсер дат для русского языка.
495
+
496
+ Поддерживает:
497
+ - Прямые относительные: сегодня, завтра, вчера, послезавтра, позавчера
498
+ - Количественные: через 2 дня, 3 недели назад, через два месяца
499
+ - Недельные: на следующей неделе, на прошлой неделе
500
+ - Дни недели: в прошлый понедельник, в следующую пятницу
501
+ - Границы периодов: в начале месяца, в конце недели
502
+ - Текстовые: 15 января 2025, пятого марта
503
+ - Месяцы: за март, в апреле
504
+ - Числовые: 15.01.2025, 2025-01-15
505
 
506
  Args:
507
  text: Текст для анализа
508
  reference_date: Опорная дата (по умолчанию - сегодня)
509
 
510
  Returns:
511
+ {"date": "19.04.2026", "date_iso": "2026-04-19", "matched_date_phrase": "..."}
 
 
 
 
 
512
  """
513
  if reference_date is None:
514
  reference_date = date.today()
 
517
  "date": None,
518
  "date_iso": None,
519
  "matched_date_phrase": None,
 
520
  }
521
 
522
+ # Порядок парсеров: от простых к сложным
523
+ parsers: list[Callable[[str, date], Optional[tuple[date, str]]]] = [
524
+ _parse_numeric_date, # 15.01.2025
525
+ _parse_direct_relative, # завтра, вчера
526
+ _parse_quantity_relative, # через 2 дня
527
+ _parse_week_relative, # на следующей неделе
528
+ _parse_weekday, # в прошлый понедельник
529
+ _parse_period_edge, # в конце месяца
530
+ _parse_textual_date, # 15 января, пятого марта
531
+ _parse_month_only, # за март
532
+ _parse_with_natasha, # Natasha fallback
533
+ ]
534
+
535
+ for parser in parsers:
536
+ parsed = parser(text, reference_date)
537
+ if parsed:
538
+ parsed_date, matched = parsed
539
+ result["date"] = parsed_date.strftime("%d.%m.%Y")
540
+ result["date_iso"] = parsed_date.isoformat()
541
+ result["matched_date_phrase"] = matched
542
+ return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
 
544
  return result
545
 
546
 
547
+ # ============== КЛАСС-ОБЁРТКА ==============
548
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
  class NatashaDateExtractor:
550
+ """Экстрактор дат для совместимости с ExpenseDateExtractor."""
 
 
 
551
 
552
  def extract(self, text: str, reference_date: Optional[date] = None) -> dict[str, Any]:
 
553
  ref = reference_date or date.today()
554
  if isinstance(ref, str):
555
  ref = datetime.strptime(ref, "%Y-%m-%d").date()
556
+ return parse_date_natasha(text, ref)
 
 
 
 
 
 
 
557
 
558
  def extract_all(self, text: str, reference_date: Optional[date] = None) -> list[dict[str, Any]]:
559
+ # Для простоты возвращаем первый результат
560
+ result = self.extract(text, reference_date)
561
+ return [result] if result["date"] else []
 
 
 
 
 
 
 
 
562
 
563
 
564
  if __name__ == "__main__":
565
+ # Тестирование
566
  test_phrases = [
567
  "завтра",
568
  "через 2 дня",
569
+ "через два дня",
570
  "на следующей неделе",
571
  "15 января 2025",
572
  "позавчера",
 
575
  "5 марта",
576
  "купил вчера",
577
  "в конце месяца",
578
+ "пятого марта",
579
+ "двадцать первого января",
580
+ "3 недели назад",
581
+ "через месяц",
582
  ]
583
 
584
+ ref = date(2026, 4, 19)
585
+ print(f"Reference: {ref}\n")
586
+
587
  for phrase in test_phrases:
588
+ result = parse_date_natasha(phrase, ref)
589
+ print(f" '{phrase}' -> {result['date_iso']} ({result['matched_date_phrase']})")