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 random
|
||||||
import re
|
import re
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode, urljoin, urlparse, urlunparse
|
from urllib.parse import urlencode, urljoin, urlparse, urlunparse
|
||||||
|
|
||||||
|
|
@ -45,6 +45,7 @@ from app.services.scrapers.avito_exceptions import (
|
||||||
AvitoContentBlockedError,
|
AvitoContentBlockedError,
|
||||||
AvitoRateLimitedError,
|
AvitoRateLimitedError,
|
||||||
)
|
)
|
||||||
|
from app.services.scrapers.avito_shared import _unix_to_date
|
||||||
from app.services.scrapers.base import BaseScraper, ScrapedLot
|
from app.services.scrapers.base import BaseScraper, ScrapedLot
|
||||||
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
||||||
from app.services.scrapers.price_brackets import get_price_seed_brackets
|
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 лежат в нём; внутри ещё есть короткий вложенный
|
# одного item-объекта (id и sortTimeStamp лежат в нём; внутри ещё есть короткий вложенный
|
||||||
# `addressDetailed{…}`, поэтому не `[^{}]`). re.S — sortTimeStamp может быть на новой строке.
|
# `addressDetailed{…}`, поэтому не `[^{}]`). re.S — sortTimeStamp может быть на новой строке.
|
||||||
# sortTimeStamp — UTC epoch-ms. Avito показывает дату в MSK (UTC+3, без DST с 2014).
|
# sortTimeStamp — UTC epoch-ms. Avito показывает дату в MSK (UTC+3, без DST с 2014).
|
||||||
# Берём .date() в MSK, чтобы совпадало с тем, что видно на сайте (иначе объявления,
|
# Берём .date() в MSK через общий `_unix_to_date` (avito_shared.py, #2129), чтобы
|
||||||
# поднятые в 00:00–03:00 MSK, давали бы дату на день раньше). Fixed offset, без
|
# совпадало с тем, что видно на сайте (иначе объявления, поднятые в 00:00–03:00 MSK,
|
||||||
# зависимости от системной tzdata в контейнере. #726.
|
# давали бы дату на день раньше). #726.
|
||||||
_MSK = timezone(timedelta(hours=3))
|
|
||||||
_SORT_TS_RE = re.compile(r'"id":(?P<id>\d{8,12})\b.{0,1500}?"sortTimeStamp":(?P<ts>\d+)', re.S)
|
_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: при любой ошибке конкретной записи — пропускаем её (не роняем парс).
|
Best-effort: при любой ошибке конкретной записи — пропускаем её (не роняем парс).
|
||||||
Первое вхождение id выигрывает (item-объект идёт раньше recommendations-дублей).
|
Первое вхождение id выигрывает (item-объект идёт раньше recommendations-дублей).
|
||||||
|
`_unix_to_date` ожидает СЕКУНДЫ — sortTimeStamp в МИЛЛИСЕКУНДАХ, делим на 1000.
|
||||||
"""
|
"""
|
||||||
out: dict[str, date] = {}
|
out: dict[str, date] = {}
|
||||||
for m in _SORT_TS_RE.finditer(html):
|
for m in _SORT_TS_RE.finditer(html):
|
||||||
item_id = m.group("id")
|
item_id = m.group("id")
|
||||||
if item_id in out:
|
if item_id in out:
|
||||||
continue
|
continue
|
||||||
try:
|
date_val = _unix_to_date(int(m.group("ts")) / 1000)
|
||||||
out[item_id] = datetime.fromtimestamp(int(m.group("ts")) / 1000, tz=_MSK).date()
|
if date_val is not None:
|
||||||
except (ValueError, OSError, OverflowError):
|
out[item_id] = date_val
|
||||||
continue
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ from app.services.scrapers.avito_exceptions import (
|
||||||
AvitoListingGoneError,
|
AvitoListingGoneError,
|
||||||
AvitoRateLimitedError,
|
AvitoRateLimitedError,
|
||||||
)
|
)
|
||||||
|
from app.services.scrapers.avito_shared import RUS_MONTHS
|
||||||
from app.services.scrapers.repair_state_normalizer import infer_repair_state_from_text
|
from app.services.scrapers.repair_state_normalizer import infer_repair_state_from_text
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -76,22 +77,6 @@ _AVITO_DETAIL_429_MAX_RETRIES = 8
|
||||||
_AVITO_DETAIL_429_BACKOFF_SEC = 1.5
|
_AVITO_DETAIL_429_BACKOFF_SEC = 1.5
|
||||||
_AVITO_DETAIL_429_RECONNECT_RETRIES = 3
|
_AVITO_DETAIL_429_RECONNECT_RETRIES = 3
|
||||||
|
|
||||||
# ── Русские месяцы ─────────────────────────────────────────────────────────
|
|
||||||
RUS_MONTHS: dict[str, int] = {
|
|
||||||
"января": 1,
|
|
||||||
"февраля": 2,
|
|
||||||
"марта": 3,
|
|
||||||
"апреля": 4,
|
|
||||||
"мая": 5,
|
|
||||||
"июня": 6,
|
|
||||||
"июля": 7,
|
|
||||||
"августа": 8,
|
|
||||||
"сентября": 9,
|
|
||||||
"октября": 10,
|
|
||||||
"ноября": 11,
|
|
||||||
"декабря": 12,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Regex ────────────────────────────────────────────────────────────────────
|
# ── Regex ────────────────────────────────────────────────────────────────────
|
||||||
_ITEM_ID_RE = re.compile(r"№\s*(\d+)")
|
_ITEM_ID_RE = re.compile(r"№\s*(\d+)")
|
||||||
_VIEWS_TOTAL_RE = re.compile(r"(\d+)\s+просмотр")
|
_VIEWS_TOTAL_RE = re.compile(r"(\d+)\s+просмотр")
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import logging
|
||||||
import re
|
import re
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, date, datetime
|
from datetime import date
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from sqlalchemy import text
|
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.matching.houses import match_or_create_house
|
||||||
from app.services.scrapers.avito import _is_firewall_page
|
from app.services.scrapers.avito import _is_firewall_page
|
||||||
from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
|
from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
|
||||||
|
from app.services.scrapers.avito_shared import RUS_MONTHS, _unix_to_date
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
||||||
|
|
@ -67,22 +68,6 @@ PRELOADED_JSPARSE_RE = re.compile(
|
||||||
re.DOTALL,
|
re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Русские месяцы для парсинга дат отзывов
|
|
||||||
RUS_MONTHS = {
|
|
||||||
"января": 1,
|
|
||||||
"февраля": 2,
|
|
||||||
"марта": 3,
|
|
||||||
"апреля": 4,
|
|
||||||
"мая": 5,
|
|
||||||
"июня": 6,
|
|
||||||
"июля": 7,
|
|
||||||
"августа": 8,
|
|
||||||
"сентября": 9,
|
|
||||||
"октября": 10,
|
|
||||||
"ноября": 11,
|
|
||||||
"декабря": 12,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Маппинг expandParams type → (field_name, cast_type)
|
# Маппинг expandParams type → (field_name, cast_type)
|
||||||
EXPAND_PARAMS_MAP: dict[str, tuple[str, str]] = {
|
EXPAND_PARAMS_MAP: dict[str, tuple[str, str]] = {
|
||||||
"Год постройки": ("year_built", "int"),
|
"Год постройки": ("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
|
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:
|
def _strip_price(price_str: str | None) -> int | None:
|
||||||
"""Извлекает целое число из строки "11 990 000 ₽" → 11990000."""
|
"""Извлекает целое число из строки "11 990 000 ₽" → 11990000."""
|
||||||
if not price_str:
|
if not price_str:
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import date, datetime
|
from datetime import date
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
@ -32,6 +32,8 @@ from uuid import UUID
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.services.scrapers.avito_shared import _unix_to_date
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
# Импорт под TYPE_CHECKING (как в avito_detail.py/avito_houses.py) — избегаем
|
# Импорт под TYPE_CHECKING (как в avito_detail.py/avito_houses.py) — избегаем
|
||||||
# hard import cycle browser_fetcher → config → ... и лишней зависимости в
|
# 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 эт."
|
# Паттерн для заголовка вида "2-к. квартира, 42 м², 4/5 эт."
|
||||||
_TITLE_RE = re.compile(
|
_TITLE_RE = re.compile(
|
||||||
r"^(?P<rooms>\d+)-к[.\s].*?(?P<area>[\d,]+)\s*м².*?(?P<floor>\d+)/(?P<total>\d+)\s*эт",
|
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