fix(scrapers): avito listing_date from sortTimeStamp JSON (#726) #729

Merged
bot-reviewer merged 1 commit from fix/726-avito-listing-date into main 2026-05-30 14:13:39 +00:00
3 changed files with 172 additions and 32 deletions

View file

@ -17,7 +17,7 @@ from __future__ import annotations
import logging
import re
from datetime import date, timedelta
from datetime import UTC, date, datetime, timedelta
from typing import Any
from urllib.parse import urlencode, urljoin
@ -44,8 +44,11 @@ _REL_DATE_RE = re.compile(
flags=re.I,
)
_UNIT_DAYS: dict[str, int] = {
"секунд": 0, "минут": 0, "час": 0,
"день": 1, "дн": 1,
"секунд": 0,
"минут": 0,
"час": 0,
"день": 1,
"дн": 1,
"недел": 7,
"месяц": 30,
"год": 365,
@ -53,9 +56,18 @@ _UNIT_DAYS: dict[str, int] = {
# Russian month names → number. Avito показывает абсолютные даты как «18 мая».
_RU_MONTHS: dict[str, int] = {
"январ": 1, "феврал": 2, "март": 3, "апрел": 4,
"ма": 5, "июн": 6, "июл": 7, "август": 8,
"сентябр": 9, "октябр": 10, "ноябр": 11, "декабр": 12,
"январ": 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(
@ -128,6 +140,34 @@ def _parse_relative_date(s: str | None) -> date | None:
return None
# #726: Avito SERP лениво рендерит DOM — `data-marker="item-date"` присутствует только
# у верхних (above-the-fold) карточек (~10 из ~46), у остальных его НЕТ в raw-HTML, что
# curl_cffi получает (JS-гидрация не выполняется). Но per-item JSON в той же HTML несёт
# `sortTimeStamp` (epoch-ms) почти у всех карточек — это drift-resistant источник даты.
# Маппим item_id (= DOM `data-item-id`) → дата. `{0,1500}` ограничивает поиск телом
# одного item-объекта (id и sortTimeStamp лежат в нём; внутри ещё есть короткий вложенный
# `addressDetailed{…}`, поэтому не `[^{}]`). re.S — sortTimeStamp может быть на новой строке.
_SORT_TS_RE = re.compile(r'"id":(?P<id>\d{8,12})\b.{0,1500}?"sortTimeStamp":(?P<ts>\d+)', re.S)
def _build_sort_timestamp_map(html: str) -> dict[str, date]:
"""item_id → дата публикации из embedded JSON (`sortTimeStamp`, epoch-ms).
Best-effort: при любой ошибке конкретной записи пропускаем её (не роняем парс).
Первое вхождение id выигрывает (item-объект идёт раньше recommendations-дублей).
"""
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=UTC).date()
except (ValueError, OSError, OverflowError):
continue
return out
class AvitoScraper(BaseScraper):
"""Avito vtorichka parser. Источник = 'avito'.
@ -201,16 +241,10 @@ class AvitoScraper(BaseScraper):
assert self._cffi is not None
response = await self._cffi.get(url)
if response.status_code == 403:
logger.error(
"avito SERP HTTP 403 (IP blocked) page=%d url=%s", page, url
)
raise AvitoBlockedError(
f"Avito SERP returned 403 — IP blocked at page={page}"
)
logger.error("avito SERP HTTP 403 (IP blocked) page=%d url=%s", page, url)
raise AvitoBlockedError(f"Avito SERP returned 403 — IP blocked at page={page}")
if response.status_code == 429:
logger.error(
"avito SERP HTTP 429 (rate limited) page=%d url=%s", page, url
)
logger.error("avito SERP HTTP 429 (rate limited) page=%d url=%s", page, url)
raise AvitoRateLimitedError(
f"Avito SERP returned 429 — rate limited at page={page}"
)
@ -232,7 +266,11 @@ class AvitoScraper(BaseScraper):
all_lots.extend(lots)
logger.info(
"avito page=%d: %d lots (total %d) around (%.4f, %.4f)",
page, len(lots), len(all_lots), lat, lon,
page,
len(lots),
len(all_lots),
lat,
lon,
)
if page < pages:
await self.sleep_between_requests()
@ -243,7 +281,10 @@ class AvitoScraper(BaseScraper):
elif pages > 1:
logger.info(
"avito fetch_around pages=%d total_lots=%d around (%.4f, %.4f)",
pages, len(all_lots), lat, lon,
pages,
len(all_lots),
lat,
lon,
)
return all_lots
@ -253,28 +294,31 @@ class AvitoScraper(BaseScraper):
params = {
"geoCoords": f"{lat},{lon}",
"radius": radius_km,
"s": 104, # sort by date
"s": 104, # sort by date
"p": page,
}
return (
f"{self.base_url}/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?{urlencode(params)}"
)
return f"{self.base_url}/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?{urlencode(params)}"
def _parse_html(self, html: str, source_url_base: str) -> list[ScrapedLot]:
"""Парсим карточки объявлений из HTML через DOM scrape.
Avito state давно пустой используем только DOM путь.
Поля карточки берём из DOM (`data-marker="item-*"`). Дату публикации из
embedded per-item JSON (`sortTimeStamp`), т.к. DOM `item-date` рендерится
лениво только у верхних карточек (#726); DOM-marker остаётся fallback.
"""
tree = HTMLParser(html)
ts_map = _build_sort_timestamp_map(html)
cards = tree.css('[data-marker="item"]')
lots: list[ScrapedLot] = []
for card in cards:
lot = self._dom_card_to_lot(card, source_url_base)
lot = self._dom_card_to_lot(card, source_url_base, ts_map)
if lot is not None:
lots.append(lot)
return lots
def _dom_card_to_lot(self, card: Any, source_url_base: str) -> ScrapedLot | None:
def _dom_card_to_lot(
self, card: Any, source_url_base: str, ts_map: dict[str, date] | None = None
) -> ScrapedLot | None:
"""Парсинг карточки объявления через DOM.
Avito state давно пустой единственный путь. Coords из карточки не отдаются,
@ -314,13 +358,19 @@ class AvitoScraper(BaseScraper):
if len(photo_urls) >= 5:
break
# Дата публикации: data-marker="item-date" / data-marker="item-date-info"
# Дата публикации (#726): primary — sortTimeStamp из per-item JSON по
# data-item-id (DOM item-date рендерится лениво только у верхних карточек),
# fallback — DOM marker item-date/item-date-info + relative-date parse.
listing_date: date | None = None
date_el = card.css_first('[data-marker="item-date"]')
if date_el is None:
date_el = card.css_first('[data-marker="item-date-info"]')
if date_el is not None:
listing_date = _parse_relative_date(date_el.text(strip=True))
item_id = card.attributes.get("data-item-id")
if ts_map and item_id:
listing_date = ts_map.get(item_id)
if listing_date is None:
date_el = card.css_first('[data-marker="item-date"]')
if date_el is None:
date_el = card.css_first('[data-marker="item-date-info"]')
if date_el is not None:
listing_date = _parse_relative_date(date_el.text(strip=True))
if listing_date is None:
# Fallback: ищем любой текст «N дней/недель/месяцев назад» в карточке
card_text = card.text(strip=True)
@ -353,8 +403,8 @@ class AvitoScraper(BaseScraper):
source_url=url,
source_id=card.attributes.get("data-item-id"),
address=address, # БЕЗ fallback "Екатеринбург (Avito)" — пусть None
lat=None, # C-5 fix: НЕ jitter
lon=None, # C-5 fix: НЕ jitter
lat=None, # C-5 fix: НЕ jitter
lon=None, # C-5 fix: НЕ jitter
rooms=rooms,
area_m2=area,
floor=floor,

View file

@ -0,0 +1,32 @@
<!doctype html>
<!--
#726 minimal Avito SERP fixture (apartment search), distilled from a real
captured page (curl_cffi chrome120, HTTP 200, 2026-05-30). Exercises both date
paths of avito.py::_parse_html:
- card A (8043936560): LAZY card — no DOM data-marker="item-date" (как у ~79%
карточек ниже первого экрана); дата берётся из per-item JSON sortTimeStamp.
- card B (8163084615): TOP card — несёт DOM data-marker="item-date" (fallback path).
Embedded JSON фрагмент повторяет реальную форму: "id":<id> ... "sortTimeStamp":<ms>.
-->
<html lang="ru"><head><meta charset="utf-8"><title>Avito SERP fixture</title></head>
<body>
<div class="items-items">
<div data-marker="item" data-item-id="8043936560" id="i8043936560">
<a data-marker="item-title" href="/ekaterinburg/kvartiry/2-k._kvartira_54m_59et._8043936560">2-к. квартира, 54&nbsp;м², 5/9&nbsp;эт.</a>
<meta itemprop="price" content="5500000">
<div data-marker="item-line"><div data-marker="item-address"><p>ул. Малышева, 30</p></div></div>
</div>
<div data-marker="item" data-item-id="8163084615" id="i8163084615">
<a data-marker="item-title" href="/ekaterinburg/kvartiry/1-k._kvartira_38m_316et._8163084615">1-к. квартира, 38&nbsp;м², 3/16&nbsp;эт.</a>
<meta itemprop="price" content="4200000">
<div data-marker="item-line"><div data-marker="item-address"><p>ул. Куйбышева, 21</p></div></div>
<p data-marker="item-date">1 час назад</p>
</div>
</div>
<script>
window.__avito_state_fragment = {"catalog":{"items":[
{"id":8043936560,"category":{"id":24,"slug":"kvartiry"},"addressDetailed":{"locationName":"Екатеринбург"},"sortTimeStamp":1700000000000,"allowTimeStamp":1700000000000},
{"id":8163084615,"category":{"id":24,"slug":"kvartiry"},"addressDetailed":{"locationName":"Екатеринбург"},"sortTimeStamp":1700086400000,"allowTimeStamp":1700086400000}
]}};
</script>
</body></html>

View file

@ -0,0 +1,58 @@
"""#726 — avito SERP listing_date extraction (sortTimeStamp JSON + DOM fallback).
Offline: парсит фикстуру через AvitoScraper._parse_html, без сети/curl_cffi/БД.
Регрессия на дрейф разметки: дата у lazy-карточек (без DOM item-date) берётся из
per-item JSON sortTimeStamp; у верхних карточек из DOM data-marker="item-date".
"""
import os
from datetime import UTC, date, datetime
from pathlib import Path
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from app.services.scrapers.avito import (
AvitoScraper,
_build_sort_timestamp_map,
)
FIXTURE = Path(__file__).parent / "fixtures" / "avito_serp_sample.html"
# Из фикстуры (sortTimeStamp, epoch-ms).
_TS_LAZY = 1700000000000
_TS_TOP = 1700086400000
def _expected(ms: int) -> date:
return datetime.fromtimestamp(ms / 1000, tz=UTC).date()
def test_sort_timestamp_map_built_from_json() -> None:
html = FIXTURE.read_text(encoding="utf-8")
ts_map = _build_sort_timestamp_map(html)
assert ts_map["8043936560"] == _expected(_TS_LAZY)
assert ts_map["8163084615"] == _expected(_TS_TOP)
def test_parse_html_sets_listing_date_for_all_cards() -> None:
"""Обе карточки получают listing_date != None (DoD #726)."""
html = FIXTURE.read_text(encoding="utf-8")
lots = AvitoScraper()._parse_html(html, "https://www.avito.ru/ekaterinburg/kvartiry")
assert len(lots) == 2
by_url = {lot.source_url: lot for lot in lots}
assert all(lot.listing_date is not None for lot in lots), "every card must carry a date"
# Lazy card (нет DOM item-date) → дата из JSON sortTimeStamp.
lazy = next(lot for lot in lots if "8043936560" in (lot.source_url or ""))
assert lazy.listing_date == _expected(_TS_LAZY)
assert by_url # touch to keep linter calm if structure changes
def test_lazy_card_date_comes_from_json_not_dom() -> None:
"""Карточка без data-marker='item-date' всё равно датируется (JSON primary)."""
html = FIXTURE.read_text(encoding="utf-8")
assert 'data-item-id="8043936560"' in html
# у этой карточки в фикстуре НЕТ DOM item-date — проверяем, что дата не потерялась
lots = AvitoScraper()._parse_html(html, "https://www.avito.ru/x")
lazy = next(lot for lot in lots if "8043936560" in (lot.source_url or ""))
assert lazy.listing_date == _expected(_TS_LAZY)