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

Investigated with a real curl_cffi capture (HTTP 200, 46 cards): the Avito
SERP lazy-renders — data-marker="item-date" appears only on the top
above-the-fold cards (~10/46), so ~79% of cards have no date in the raw HTML
curl_cffi receives. The marker wasn't renamed; the field is just absent.

But every listing carries sortTimeStamp (epoch-ms) in the embedded per-item
JSON (56 occurrences/page) — the line-266 'state empty' note was stale. Map
item_id (= DOM data-item-id) -> sortTimeStamp -> date as the primary source,
keep the DOM item-date + _parse_relative_date as fallback for the top cards.

Verified on the real capture: 42/46 = 91% coverage (was ~21% DOM-only),
clearing the >80% DoD bar. Adds a distilled real-structure fixture
(avito_serp_sample.html) + unit test covering both the JSON (lazy card) and
DOM (top card) paths.

Refs #726
This commit is contained in:
bot-backend 2026-05-30 17:09:41 +03:00
parent 1f3008d59e
commit d6dca24ea2
3 changed files with 172 additions and 32 deletions

View file

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