Spaces:
Sleeping
Sleeping
VladGeekPro commited on
Commit ·
3e13659
1
Parent(s): 2a93301
TestNatasha
Browse files- natasha_dates.py +493 -259
natasha_dates.py
CHANGED
|
@@ -1,178 +1,514 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
Использует
|
| 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 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
# Инициализация Natasha
|
| 16 |
-
_MORPH_VOCAB
|
| 17 |
-
_DATES_EXTRACTOR
|
| 18 |
|
| 19 |
|
| 20 |
-
def _get_extractor()
|
| 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 |
-
#
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
}
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
}
|
| 69 |
|
| 70 |
-
#
|
| 71 |
-
|
| 72 |
r'\b(оплата|оплатил[аи]?|заплатил[аи]?|купил[аи]?|заказал[аи]?|'
|
| 73 |
-
r'потратил[аи]?|был[аио]?|получил[аи]?|сделал[аи]?
|
|
|
|
| 74 |
re.IGNORECASE
|
| 75 |
)
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
-
def
|
| 79 |
-
"""П
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
text_lower = text.lower()
|
| 81 |
-
for
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 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
|
| 95 |
-
"""
|
| 96 |
text_lower = text.lower()
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
if match:
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
return None
|
| 120 |
|
| 121 |
|
| 122 |
-
def
|
| 123 |
-
"""
|
| 124 |
-
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
-
#
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
|
| 148 |
-
def
|
| 149 |
-
"""
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
def parse_date_natasha(
|
| 159 |
text: str,
|
| 160 |
reference_date: Optional[date] = None
|
| 161 |
) -> dict[str, Any]:
|
| 162 |
"""
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 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 |
-
|
| 257 |
-
|
| 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 |
-
|
| 327 |
-
if
|
| 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 |
-
|
|
|
|
|
|
|
| 353 |
for phrase in test_phrases:
|
| 354 |
-
result = parse_date_natasha(phrase)
|
| 355 |
-
print(f" '{phrase}' -> {result['date_iso']} ({result['
|
|
|
|
| 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']})")
|