From f5c87201f1532538105cfd5b5b983c92a7154105 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 27 May 2026 12:55:27 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20Avito=20listing=5Fdate=20extrac?= =?UTF-8?q?tion=20(3=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B0)=20(#601?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/scrapers/avito.py | 77 +++++++++++++++++-- .../backend/tests/test_avito_relative_date.py | 48 ++++++++++++ 2 files changed, 117 insertions(+), 8 deletions(-) diff --git a/tradein-mvp/backend/app/services/scrapers/avito.py b/tradein-mvp/backend/app/services/scrapers/avito.py index 4d849e82..7ad64c01 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito.py +++ b/tradein-mvp/backend/app/services/scrapers/avito.py @@ -51,19 +51,80 @@ _UNIT_DAYS: dict[str, int] = { "год": 365, } +# Russian month names → number. Avito показывает абсолютные даты как «18 мая». +_RU_MONTHS: dict[str, int] = { + "январ": 1, "феврал": 2, "март": 3, "апрел": 4, + "ма": 5, "июн": 6, "июл": 7, "август": 8, + "сентябр": 9, "октябр": 10, "ноябр": 11, "декабр": 12, +} +# «18 мая», «5 июня в 12:30» — day + month (year inferred current). +_ABS_DATE_RE = re.compile( + r"(?P\d{1,2})\s+(?Pянвар\w*|феврал\w*|март\w*|апрел\w*|" + r"ма[яй]|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)", + flags=re.I, +) + def _parse_relative_date(s: str | None) -> date | None: - """Avito показывает '5 дней назад' / 'час назад'. Конвертирует в date.""" + """Avito показывает дату публикации в трёх форматах: + * '5 дней назад' / 'час назад' / 'неделю назад' — relative numeric + * 'сегодня в 12:30' / 'вчера в 18:45' / 'позавчера' — relative keyword + * '18 мая' / '5 июня в 12:30' — absolute day + month (year inferred) + + PR M-followup 2026-05-27: расширено покрытие — раньше только relative numeric + matched (regex `\\d+ unit назад`), 100% Avito listings заходило с listing_date=None + т.к. Avito SERP для свежих cards использует keyword/absolute форматы. См. + тесты для каждого case. + """ if not s: return None + text_lower = s.lower() + + # Keyword shortcuts (no \d+). + if "позавчера" in text_lower: + return date.today() - timedelta(days=2) + if "вчера" in text_lower: + return date.today() - timedelta(days=1) + if "сегодня" in text_lower or "только что" in text_lower: + return date.today() + + # Relative numeric («5 дней назад»). m = _REL_DATE_RE.search(s) - if not m: - return None - n = int(m["n"]) - unit_raw = m["unit"].lower() - for prefix, days in _UNIT_DAYS.items(): - if unit_raw.startswith(prefix): - return date.today() - timedelta(days=n * days) + if m: + n = int(m["n"]) + unit_raw = m["unit"].lower() + for prefix, days in _UNIT_DAYS.items(): + if unit_raw.startswith(prefix): + return date.today() - timedelta(days=n * days) + + # Absolute («18 мая», «5 июня в 12:30»). Year — current; если месяц + # в будущем относительно сегодня (например сейчас декабрь, увидели + # «5 января») — откатываем к прошлому году. + m = _ABS_DATE_RE.search(s) + if m: + day = int(m["day"]) + month_raw = m["month"].lower() + month: int | None = None + for prefix, num in _RU_MONTHS.items(): + if month_raw.startswith(prefix): + month = num + break + if month is not None and 1 <= day <= 31: + today = date.today() + year = today.year + try: + parsed = date(year, month, day) + except ValueError: + return None + # Если parsed в будущем больше чем на 30 дней — Avito показывает + # прошлогоднюю дату («декабрь» в феврале значит прошлый декабрь). + if (parsed - today).days > 30: + try: + parsed = date(year - 1, month, day) + except ValueError: + return None + return parsed + return None diff --git a/tradein-mvp/backend/tests/test_avito_relative_date.py b/tradein-mvp/backend/tests/test_avito_relative_date.py index 00aca379..65d80596 100644 --- a/tradein-mvp/backend/tests/test_avito_relative_date.py +++ b/tradein-mvp/backend/tests/test_avito_relative_date.py @@ -19,3 +19,51 @@ def test_none_passthrough(): assert _parse_relative_date(None) is None assert _parse_relative_date("") is None assert _parse_relative_date("random text") is None + + +# Keyword shortcuts — без \d+ (Avito SERP формат для свежих cards) +def test_today_keyword(): + assert _parse_relative_date("сегодня в 12:30") == date.today() + + +def test_yesterday_keyword(): + assert _parse_relative_date("вчера в 18:45") == date.today() - timedelta(days=1) + + +def test_day_before_yesterday(): + assert _parse_relative_date("позавчера") == date.today() - timedelta(days=2) + + +def test_just_now(): + assert _parse_relative_date("только что") == date.today() + + +# Absolute dates — «18 мая», «5 июня в 12:30» +def test_absolute_date_recent(): + today = date.today() + # Take a day-month combo near current (last month) — gradening для CI stability + if today.month >= 2: + target = date(today.year, today.month - 1, 15) + else: + target = date(today.year - 1, 12, 15) + ru_months = { + 1: "января", 2: "февраля", 3: "марта", 4: "апреля", + 5: "мая", 6: "июня", 7: "июля", 8: "августа", + 9: "сентября", 10: "октября", 11: "ноября", 12: "декабря", + } + text = f"{target.day} {ru_months[target.month]} в 12:30" + assert _parse_relative_date(text) == target + + +def test_absolute_date_future_month_rolls_back(): + """Если today январь, а текст «5 декабря» — это прошлый год.""" + today = date.today() + if today.month == 1: + # Today already January, «декабря» → previous year December + result = _parse_relative_date("5 декабря") + assert result == date(today.year - 1, 12, 5) + else: + # Skip when not January — would change semantics + result = _parse_relative_date("5 января") + # Either matches current year January (если уже прошло) или previous + assert result is not None and result.month == 1 and result.day == 5