From 32d1818fd42a53c527791f115fd565a0942a987e Mon Sep 17 00:00:00 2001 From: lekss361 Date: Thu, 2 Jul 2026 14:03:11 +0000 Subject: [PATCH] fix(scrapers/avito): unify _unix_to_date timezone across serp/houses/imv (#2129) --- .../backend/app/services/scrapers/avito.py | 18 ++--- .../app/services/scrapers/avito_detail.py | 17 +--- .../app/services/scrapers/avito_houses.py | 30 +------ .../app/services/scrapers/avito_imv.py | 14 +--- .../app/services/scrapers/avito_shared.py | 79 +++++++++++++++++++ .../test_avito_unix_date_tz_consistency.py | 79 +++++++++++++++++++ 6 files changed, 173 insertions(+), 64 deletions(-) create mode 100644 tradein-mvp/backend/app/services/scrapers/avito_shared.py create mode 100644 tradein-mvp/backend/tests/scrapers/test_avito_unix_date_tz_consistency.py diff --git a/tradein-mvp/backend/app/services/scrapers/avito.py b/tradein-mvp/backend/app/services/scrapers/avito.py index 97633110..7a3b673b 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito.py +++ b/tradein-mvp/backend/app/services/scrapers/avito.py @@ -30,7 +30,7 @@ import math import random import re from collections.abc import Callable -from datetime import date, datetime, timedelta, timezone +from datetime import date, timedelta from typing import Any from urllib.parse import urlencode, urljoin, urlparse, urlunparse @@ -45,6 +45,7 @@ from app.services.scrapers.avito_exceptions import ( AvitoContentBlockedError, AvitoRateLimitedError, ) +from app.services.scrapers.avito_shared import _unix_to_date from app.services.scrapers.base import BaseScraper, ScrapedLot from app.services.scrapers.browser_fetcher import BrowserFetcher from app.services.scrapers.price_brackets import get_price_seed_brackets @@ -192,10 +193,9 @@ def _parse_relative_date(s: str | None) -> date | None: # одного item-объекта (id и sortTimeStamp лежат в нём; внутри ещё есть короткий вложенный # `addressDetailed{…}`, поэтому не `[^{}]`). re.S — sortTimeStamp может быть на новой строке. # sortTimeStamp — UTC epoch-ms. Avito показывает дату в MSK (UTC+3, без DST с 2014). -# Берём .date() в MSK, чтобы совпадало с тем, что видно на сайте (иначе объявления, -# поднятые в 00:00–03:00 MSK, давали бы дату на день раньше). Fixed offset, без -# зависимости от системной tzdata в контейнере. #726. -_MSK = timezone(timedelta(hours=3)) +# Берём .date() в MSK через общий `_unix_to_date` (avito_shared.py, #2129), чтобы +# совпадало с тем, что видно на сайте (иначе объявления, поднятые в 00:00–03:00 MSK, +# давали бы дату на день раньше). #726. _SORT_TS_RE = re.compile(r'"id":(?P\d{8,12})\b.{0,1500}?"sortTimeStamp":(?P\d+)', re.S) @@ -204,16 +204,16 @@ def _build_sort_timestamp_map(html: str) -> dict[str, date]: Best-effort: при любой ошибке конкретной записи — пропускаем её (не роняем парс). Первое вхождение id выигрывает (item-объект идёт раньше recommendations-дублей). + `_unix_to_date` ожидает СЕКУНДЫ — sortTimeStamp в МИЛЛИСЕКУНДАХ, делим на 1000. """ out: dict[str, date] = {} for m in _SORT_TS_RE.finditer(html): item_id = m.group("id") if item_id in out: continue - try: - out[item_id] = datetime.fromtimestamp(int(m.group("ts")) / 1000, tz=_MSK).date() - except (ValueError, OSError, OverflowError): - continue + date_val = _unix_to_date(int(m.group("ts")) / 1000) + if date_val is not None: + out[item_id] = date_val return out diff --git a/tradein-mvp/backend/app/services/scrapers/avito_detail.py b/tradein-mvp/backend/app/services/scrapers/avito_detail.py index f9a8d451..b6fcffa6 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito_detail.py +++ b/tradein-mvp/backend/app/services/scrapers/avito_detail.py @@ -41,6 +41,7 @@ from app.services.scrapers.avito_exceptions import ( AvitoListingGoneError, AvitoRateLimitedError, ) +from app.services.scrapers.avito_shared import RUS_MONTHS from app.services.scrapers.repair_state_normalizer import infer_repair_state_from_text if TYPE_CHECKING: @@ -76,22 +77,6 @@ _AVITO_DETAIL_429_MAX_RETRIES = 8 _AVITO_DETAIL_429_BACKOFF_SEC = 1.5 _AVITO_DETAIL_429_RECONNECT_RETRIES = 3 -# ── Русские месяцы ───────────────────────────────────────────────────────── -RUS_MONTHS: dict[str, int] = { - "января": 1, - "февраля": 2, - "марта": 3, - "апреля": 4, - "мая": 5, - "июня": 6, - "июля": 7, - "августа": 8, - "сентября": 9, - "октября": 10, - "ноября": 11, - "декабря": 12, -} - # ── Regex ──────────────────────────────────────────────────────────────────── _ITEM_ID_RE = re.compile(r"№\s*(\d+)") _VIEWS_TOTAL_RE = re.compile(r"(\d+)\s+просмотр") diff --git a/tradein-mvp/backend/app/services/scrapers/avito_houses.py b/tradein-mvp/backend/app/services/scrapers/avito_houses.py index 1a7374ee..5bde7b0f 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito_houses.py +++ b/tradein-mvp/backend/app/services/scrapers/avito_houses.py @@ -32,7 +32,7 @@ import logging import re import urllib.parse from dataclasses import dataclass, field -from datetime import UTC, date, datetime +from datetime import date from typing import TYPE_CHECKING, Any from sqlalchemy import text @@ -41,6 +41,7 @@ from sqlalchemy.orm import Session from app.services.matching.houses import match_or_create_house from app.services.scrapers.avito import _is_firewall_page from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError +from app.services.scrapers.avito_shared import RUS_MONTHS, _unix_to_date if TYPE_CHECKING: from app.services.scrapers.browser_fetcher import BrowserFetcher @@ -67,22 +68,6 @@ PRELOADED_JSPARSE_RE = re.compile( re.DOTALL, ) -# Русские месяцы для парсинга дат отзывов -RUS_MONTHS = { - "января": 1, - "февраля": 2, - "марта": 3, - "апреля": 4, - "мая": 5, - "июня": 6, - "июля": 7, - "августа": 8, - "сентября": 9, - "октября": 10, - "ноября": 11, - "декабря": 12, -} - # Маппинг expandParams type → (field_name, cast_type) EXPAND_PARAMS_MAP: dict[str, tuple[str, str]] = { "Год постройки": ("year_built", "int"), @@ -244,17 +229,6 @@ def _parse_ru_date(text_val: str | None) -> date | None: return date(year, month, day) if month else None -def _unix_to_date(ts: int | None) -> date | None: - """Конвертирует unix timestamp (секунды) → date.""" - if ts is None: - return None - try: - return datetime.fromtimestamp(ts, tz=UTC).date() - except (OSError, OverflowError, ValueError): - logger.warning("Не удалось конвертировать unix timestamp: %r", ts) - return None - - def _strip_price(price_str: str | None) -> int | None: """Извлекает целое число из строки "11 990 000 ₽" → 11990000.""" if not price_str: diff --git a/tradein-mvp/backend/app/services/scrapers/avito_imv.py b/tradein-mvp/backend/app/services/scrapers/avito_imv.py index f7102e06..bb8bb481 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito_imv.py +++ b/tradein-mvp/backend/app/services/scrapers/avito_imv.py @@ -24,7 +24,7 @@ import json import logging import re from dataclasses import dataclass, field -from datetime import date, datetime +from datetime import date from typing import TYPE_CHECKING, Any from urllib.parse import urlencode from uuid import UUID @@ -32,6 +32,8 @@ from uuid import UUID from sqlalchemy import text from sqlalchemy.orm import Session +from app.services.scrapers.avito_shared import _unix_to_date + if TYPE_CHECKING: # Импорт под TYPE_CHECKING (как в avito_detail.py/avito_houses.py) — избегаем # hard import cycle browser_fetcher → config → ... и лишней зависимости в @@ -200,16 +202,6 @@ def compute_imv_cache_key( # --------------------------------------------------------------------------- -def _unix_to_date(ts: int | float | None) -> date | None: - """Конвертирует unix timestamp в date. Возвращает None для 0 и None.""" - if not ts: - return None - try: - return datetime.fromtimestamp(int(ts)).date() - except (ValueError, OSError): - return None - - # Паттерн для заголовка вида "2-к. квартира, 42 м², 4/5 эт." _TITLE_RE = re.compile( r"^(?P\d+)-к[.\s].*?(?P[\d,]+)\s*м².*?(?P\d+)/(?P\d+)\s*эт", diff --git a/tradein-mvp/backend/app/services/scrapers/avito_shared.py b/tradein-mvp/backend/app/services/scrapers/avito_shared.py new file mode 100644 index 00000000..c499178d --- /dev/null +++ b/tradein-mvp/backend/app/services/scrapers/avito_shared.py @@ -0,0 +1,79 @@ +"""avito_shared.py — общие константы/хелперы для avito*.py модулей. + +Единая точка правды для: + - `RUS_MONTHS` — рус. месяц (родительный падеж) → номер, для парсинга дат вида + "9 октября 2025" (было продублировано в avito.py/avito_detail.py/avito_houses.py). + - `_unix_to_date` — unix-epoch (в СЕКУНДАХ) → `date`, в таймзоне Europe/Moscow. + +TZ-обоснование (issue #2129): было 3 независимые копии конвертации unix→date с +РАЗНЫМИ timezone — MSK (avito.py, документировано issue #726), UTC (avito_houses.py), +naive/system-local (avito_imv.py). Один и тот же epoch давал разные `date` в +SERP / houses / IMV путях для одного и того же объявления. + +Почему MSK — канонический выбор (не UTC, не local): + - Avito — российская площадка, отображает даты объявлений (`item-date` на SERP, + `publishDate`, история цен `startPriceDate`/`lastPriceDate`/`removedDate`) в + Europe/Moscow. Объявление, поднятое в 00:00–03:00 MSK, при наивной UTC-конвертации + получило бы дату на день раньше, чем видно на самом Avito (обоснование #726). + - avito_houses.py и avito_imv.py используются на одних и тех же данных + (`housePlacementHistory` widget / IMV `placementHistory` — идентичные поля + startPriceDate/lastPriceDate/removedDate, см. fixtures/avito_imv_getdata.json) — + единственный смысл держать их согласованными с уже принятым MSK-решением из #726. + - Проверено на живой fixture: startPriceDate/lastPriceDate — всегда полночь UTC + (00:00:00), поэтому MSK vs UTC не меняет date для них. `removedDate`/`publishDate` + несут реальное время суток — там UTC/naive-local и MSK ДЕЙСТВИТЕЛЬНО расходятся + (пример из fixture: removedDate=1756077885 → UTC 2025-08-24, MSK 2025-08-25). + Это единственный реальный сдвиг данных от этого фикса: `avito_imv_evaluations` / + `house_placement_history`, поля `removed_date`/`publish_date`, у ~12.5% строк + (события в 21:00–23:59 UTC/local) дата сдвинется на +1 день при следующем прогоне + скрейпера. Схему/уже сохранённые строки не трогаем — это единственно правильное + представление на будущее. + - Fixed-offset `timezone(timedelta(hours=3))`, а не `zoneinfo("Europe/Moscow")` — + не зависит от системной tzdata в контейнере; Москва не переходит на DST с 2014, + так что fixed offset корректен всегда. +""" + +from __future__ import annotations + +import logging +from datetime import date, datetime, timedelta, timezone + +logger = logging.getLogger(__name__) + +# Europe/Moscow, UTC+3, без перехода на летнее время (с 2014). +MSK = timezone(timedelta(hours=3)) + +# Русские месяцы (родительный падеж) для парсинга дат вида "9 октября 2025". +RUS_MONTHS: dict[str, int] = { + "января": 1, + "февраля": 2, + "марта": 3, + "апреля": 4, + "мая": 5, + "июня": 6, + "июля": 7, + "августа": 8, + "сентября": 9, + "октября": 10, + "ноября": 11, + "декабря": 12, +} + + +def _unix_to_date(ts: int | float | None) -> date | None: + """Конвертирует unix-epoch (в СЕКУНДАХ) в `date`, в таймзоне Europe/Moscow (MSK). + + Возвращает `None` для `None`/`0` (Avito использует 0 как sentinel «нет даты») и + для значений, которые не конвертируются в валидную дату. + + ВАЖНО: ожидает epoch в СЕКУНДАХ. Avito SERP-поле `sortTimeStamp` отдаёт epoch в + МИЛЛИСЕКУНДАХ — вызывающий код обязан поделить на 1000 до вызова (см. + `avito.py::_build_sort_timestamp_map`). + """ + if not ts: + return None + try: + return datetime.fromtimestamp(float(ts), tz=MSK).date() + except (OSError, OverflowError, ValueError): + logger.warning("Не удалось конвертировать unix timestamp: %r", ts) + return None diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_unix_date_tz_consistency.py b/tradein-mvp/backend/tests/scrapers/test_avito_unix_date_tz_consistency.py new file mode 100644 index 00000000..e5225014 --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_avito_unix_date_tz_consistency.py @@ -0,0 +1,79 @@ +"""#2129 — `_unix_to_date` consolidation: SERP / houses / IMV должны давать ОДНУ дату +для одного и того же epoch. + +До фикса было 3 независимые копии с разными timezone: + - avito.py (inline в `_build_sort_timestamp_map`) — MSK (документировано #726) + - avito_houses.py — UTC + - avito_imv.py — naive/system-local (`datetime.fromtimestamp(ts)` без tz) + +Один и тот же epoch мог давать РАЗНЫЕ `date` в разных путях (пример из живой fixture +avito_imv_getdata.json: removedDate=1756077885 → UTC 2025-08-24, MSK 2025-08-25). + +Офлайн: без сети/curl_cffi/БД, только импорт + чистые функции. +""" + +from __future__ import annotations + +import os +from datetime import date + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from app.services.scrapers import avito, avito_houses, avito_imv, avito_shared + +# Живой пример из fixtures/avito_imv_getdata.json (placementHistory.items[0].removedDate) — +# именно тот timestamp, на котором UTC и MSK расходились в date() ДО фикса +# (UTC → 2025-08-24, MSK → 2025-08-25). +_EPOCH_SECONDS = 1756077885 +_EXPECTED_MSK_DATE = date(2025, 8, 25) + + +def test_shared_unix_to_date_uses_msk() -> None: + """Канонический источник правды — MSK (обоснование в avito_shared.py).""" + assert avito_shared._unix_to_date(_EPOCH_SECONDS) == _EXPECTED_MSK_DATE + + +def test_avito_houses_reexports_shared_unix_to_date() -> None: + """avito_houses.py больше не держит свою копию — импортирует общую функцию.""" + assert avito_houses._unix_to_date is avito_shared._unix_to_date + assert avito_houses._unix_to_date(_EPOCH_SECONDS) == _EXPECTED_MSK_DATE + + +def test_avito_imv_reexports_shared_unix_to_date() -> None: + """avito_imv.py больше не держит свою копию (была naive/system-local) — общая функция.""" + assert avito_imv._unix_to_date is avito_shared._unix_to_date + assert avito_imv._unix_to_date(_EPOCH_SECONDS) == _EXPECTED_MSK_DATE + + +def test_avito_serp_sort_timestamp_matches_shared_date() -> None: + """avito.py путь (SERP `sortTimeStamp`, epoch-МС) для того же момента времени + даёт ТУ ЖЕ дату, что и houses/IMV путь (epoch-секунды) — единственная разница + в единицах измерения epoch, TZ теперь общая (MSK) во всех трёх путях.""" + html = ( + f'
{{"id":8043936560,"sortTimeStamp":{_EPOCH_SECONDS * 1000},' + f'"addressDetailed":{{"foo":"bar"}}}}
' + ) + ts_map = avito._build_sort_timestamp_map(html) + assert ts_map["8043936560"] == _EXPECTED_MSK_DATE + # Все три пути согласованы на одном моменте времени: + assert ( + ts_map["8043936560"] + == avito_houses._unix_to_date(_EPOCH_SECONDS) + == avito_imv._unix_to_date(_EPOCH_SECONDS) + == avito_shared._unix_to_date(_EPOCH_SECONDS) + ) + + +def test_rus_months_single_source() -> None: + """RUS_MONTHS (тоже была продублирована ×3) — общий словарь, не 3 копии.""" + from app.services.scrapers import avito_detail + + assert avito_detail.RUS_MONTHS is avito_shared.RUS_MONTHS + assert avito_houses.RUS_MONTHS is avito_shared.RUS_MONTHS + assert avito_shared.RUS_MONTHS["декабря"] == 12 + + +def test_unix_to_date_none_and_zero() -> None: + """None и 0 — sentinel «нет даты» → None (сохранено поведение старых копий).""" + assert avito_shared._unix_to_date(None) is None + assert avito_shared._unix_to_date(0) is None