fix(scrapers): avito listing_date from sortTimeStamp JSON (#726) #729
3 changed files with 172 additions and 32 deletions
|
|
@ -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,
|
||||
|
|
|
|||
32
tradein-mvp/backend/tests/fixtures/avito_serp_sample.html
vendored
Normal file
32
tradein-mvp/backend/tests/fixtures/avito_serp_sample.html
vendored
Normal 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 м², 5/9 эт.</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 м², 3/16 эт.</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>
|
||||
58
tradein-mvp/backend/tests/test_avito_serp_date.py
Normal file
58
tradein-mvp/backend/tests/test_avito_serp_date.py
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue