fix(scrapers/avito): unify _unix_to_date timezone across serp/houses/imv (#2129)
All checks were successful
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

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:
bot-backend 2026-07-02 16:57:25 +03:00
parent da0b8f0374
commit e0dcc220dd
6 changed files with 173 additions and 64 deletions

View file

@ -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:0003:00 MSK, давали бы дату на день раньше). Fixed offset, без
# зависимости от системной tzdata в контейнере. #726.
_MSK = timezone(timedelta(hours=3))
# Берём .date() в MSK через общий `_unix_to_date` (avito_shared.py, #2129), чтобы
# совпадало с тем, что видно на сайте (иначе объявления, поднятые в 00:0003: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

View file

@ -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+просмотр")

View file

@ -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:

View file

@ -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*эт",

View 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:0003: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:0023: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

View file

@ -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