fix(scrapers/avito): unify _unix_to_date timezone across serp/houses/imv (#2129)
Consolidate 3 divergent unix-epoch-to-date conversions (avito.py: MSK, documented #726; avito_houses.py: UTC; avito_imv.py: naive/system-local) plus 3 duplicated RUS_MONTHS dicts into a single app/services/scrapers/avito_shared.py module. MSK is the correct canonical representation: Avito displays listing dates (item-date, publishDate, price-history dates) in Europe/Moscow, and #726 already established this to avoid off-by-one dates for listings bumped 00:00-03:00 MSK. startPriceDate/lastPriceDate are always UTC-midnight in the source JSON so MSK vs UTC never shifts them, but removedDate/publishDate carry real time-of-day and DO shift for ~12.5% of events (21:00-23:59 UTC/local) once avito_imv.py/ avito_houses.py switch to MSK on the next scrape run.
This commit is contained in:
parent
da0b8f0374
commit
e0dcc220dd
6 changed files with 173 additions and 64 deletions
|
|
@ -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<id>\d{8,12})\b.{0,1500}?"sortTimeStamp":(?P<ts>\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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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+просмотр")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<rooms>\d+)-к[.\s].*?(?P<area>[\d,]+)\s*м².*?(?P<floor>\d+)/(?P<total>\d+)\s*эт",
|
||||
|
|
|
|||
79
tradein-mvp/backend/app/services/scrapers/avito_shared.py
Normal file
79
tradein-mvp/backend/app/services/scrapers/avito_shared.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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'<div>{{"id":8043936560,"sortTimeStamp":{_EPOCH_SECONDS * 1000},'
|
||||
f'"addressDetailed":{{"foo":"bar"}}}}</div>'
|
||||
)
|
||||
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
|
||||
Loading…
Add table
Reference in a new issue