From d6dca24ea2d6e4591e1073113f241f22d197d5cf Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 30 May 2026 17:09:41 +0300 Subject: [PATCH] fix(scrapers): avito listing_date from sortTimeStamp JSON (#726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../backend/app/services/scrapers/avito.py | 114 +++++++++++++----- .../tests/fixtures/avito_serp_sample.html | 32 +++++ .../backend/tests/test_avito_serp_date.py | 58 +++++++++ 3 files changed, 172 insertions(+), 32 deletions(-) create mode 100644 tradein-mvp/backend/tests/fixtures/avito_serp_sample.html create mode 100644 tradein-mvp/backend/tests/test_avito_serp_date.py diff --git a/tradein-mvp/backend/app/services/scrapers/avito.py b/tradein-mvp/backend/app/services/scrapers/avito.py index 7ad64c01..6aaa8c59 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito.py +++ b/tradein-mvp/backend/app/services/scrapers/avito.py @@ -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\d{8,12})\b.{0,1500}?"sortTimeStamp":(?P\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, diff --git a/tradein-mvp/backend/tests/fixtures/avito_serp_sample.html b/tradein-mvp/backend/tests/fixtures/avito_serp_sample.html new file mode 100644 index 00000000..aa7601a6 --- /dev/null +++ b/tradein-mvp/backend/tests/fixtures/avito_serp_sample.html @@ -0,0 +1,32 @@ + + +Avito SERP fixture + +
+
+ 2-к. квартира, 54 м², 5/9 эт. + +

ул. Малышева, 30

+
+
+ 1-к. квартира, 38 м², 3/16 эт. + +

ул. Куйбышева, 21

+

1 час назад

+
+
+ + diff --git a/tradein-mvp/backend/tests/test_avito_serp_date.py b/tradein-mvp/backend/tests/test_avito_serp_date.py new file mode 100644 index 00000000..b370814b --- /dev/null +++ b/tradein-mvp/backend/tests/test_avito_serp_date.py @@ -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)