fix(tradein): decode Avito houses URL-encoded __preloadedState__ (#566) #618
3 changed files with 693 additions and 31 deletions
|
|
@ -25,6 +25,7 @@ from __future__ import annotations
|
|||
import json
|
||||
import logging
|
||||
import re
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, date, datetime
|
||||
from typing import Any
|
||||
|
|
@ -38,8 +39,20 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
AVITO_BASE = "https://www.avito.ru"
|
||||
|
||||
# Regex для извлечения __preloadedState__ из HTML
|
||||
PRELOADED_RE = re.compile(
|
||||
# Regex для извлечения __preloadedState__ из HTML.
|
||||
#
|
||||
# Avito (≈2026) сменил формат state-блоба: раньше был
|
||||
# window.__preloadedState__ = JSON.parse("<js-escaped json>");
|
||||
# теперь — URL-encoded строковый литерал:
|
||||
# window.__preloadedState__ = "%7B%22abCentral%22%3A..."; (%7B='{', %22='"')
|
||||
#
|
||||
# PRELOADED_URLENC_RE — текущая форма (декод через urllib.parse.unquote).
|
||||
# PRELOADED_JSPARSE_RE — старая форма (оставлена как fallback).
|
||||
PRELOADED_URLENC_RE = re.compile(
|
||||
r'window\.__preloadedState__\s*=\s*"((?:[^"\\]|\\.)*)"\s*;',
|
||||
re.DOTALL,
|
||||
)
|
||||
PRELOADED_JSPARSE_RE = re.compile(
|
||||
r'window\.__preloadedState__\s*=\s*JSON\.parse\("(.*?)"\)\s*;',
|
||||
re.DOTALL,
|
||||
)
|
||||
|
|
@ -270,8 +283,28 @@ def _parse_expand_params(items: list[dict[str, Any]]) -> dict[str, Any]:
|
|||
return result
|
||||
|
||||
|
||||
def _flatten_widgets(placeholders: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Возвращает плоский список виджетов из placeholders.
|
||||
|
||||
Avito (≈2026) обернул 10 виджетов в контейнер: placeholders теперь
|
||||
[{"id": ..., "widgets": [{"type": "housePage", ...}, ...]}]
|
||||
раньше виджеты лежали в placeholders напрямую:
|
||||
[{"type": "housePage", ...}, {"type": "reviews", ...}, ...]
|
||||
|
||||
Хелпер раскрывает любой элемент с ключом "widgets" (список) в его виджеты,
|
||||
остальные элементы оставляет как есть → поддержка обеих форм.
|
||||
"""
|
||||
flat: list[dict[str, Any]] = []
|
||||
for entry in placeholders:
|
||||
if isinstance(entry, dict) and isinstance(entry.get("widgets"), list):
|
||||
flat.extend(w for w in entry["widgets"] if isinstance(w, dict))
|
||||
elif isinstance(entry, dict):
|
||||
flat.append(entry)
|
||||
return flat
|
||||
|
||||
|
||||
def _get_widget(placeholders: list[dict[str, Any]], widget_type: str) -> dict[str, Any] | None:
|
||||
"""Ищет виджет по type в массиве placeholders."""
|
||||
"""Ищет виджет по type в массиве placeholders (уже flat)."""
|
||||
for w in placeholders:
|
||||
if w.get("type") == widget_type:
|
||||
return w
|
||||
|
|
@ -286,10 +319,15 @@ def _get_widget(placeholders: list[dict[str, Any]], widget_type: str) -> dict[st
|
|||
def _parse_house_page(widget: dict[str, Any]) -> HouseInfo:
|
||||
"""Парсит виджет housePage → HouseInfo.
|
||||
|
||||
Путь: widget['props']['developmentData'] — основной объект с данными дома.
|
||||
developmentData — основной объект с данными дома. Avito (≈2026) изменил путь:
|
||||
новый: widget['props']['housePage']['developmentData']
|
||||
старый: widget['props']['developmentData']
|
||||
Аналогично рейтинг переехал в developmentData['ratingPreview']
|
||||
(раньше ratingBadge / ratingSummaryStat лежали прямо в developmentData).
|
||||
Хелпер пробует новые пути с fallback на старые → поддержка обеих форм.
|
||||
"""
|
||||
props = widget.get("props", {})
|
||||
dd = props.get("developmentData", {})
|
||||
dd = props.get("housePage", {}).get("developmentData") or props.get("developmentData", {})
|
||||
|
||||
# expandParams → характеристики дома
|
||||
about = dd.get("aboutDevelopment", {})
|
||||
|
|
@ -298,11 +336,12 @@ def _parse_house_page(widget: dict[str, Any]) -> HouseInfo:
|
|||
)
|
||||
parsed_params = _parse_expand_params(expand_params_items)
|
||||
|
||||
# Рейтинг
|
||||
rating_badge = dd.get("ratingBadge", {})
|
||||
# Рейтинг: новая форма — внутри ratingPreview, старая — прямо в developmentData
|
||||
rating_root = dd.get("ratingPreview") or dd
|
||||
rating_badge = rating_root.get("ratingBadge", {})
|
||||
rating_info = rating_badge.get("info", {})
|
||||
|
||||
rating_summary = dd.get("ratingSummaryStat", {})
|
||||
rating_summary = rating_root.get("ratingSummaryStat", {})
|
||||
rating_stat: list[dict[str, Any]] = rating_summary.get("ratingStat", [])
|
||||
|
||||
# Карта/инфраструктура
|
||||
|
|
@ -510,6 +549,48 @@ def _parse_recommendations(widget: dict[str, Any]) -> list[RecommendationItem]:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_preloaded_state(html: str) -> dict[str, Any] | None:
|
||||
"""Извлекает window.__preloadedState__ из HTML → распарсенный dict.
|
||||
|
||||
Pure функция (нет I/O) — тестируется на фикстуре, переиспользуется из
|
||||
fetch_house_catalog.
|
||||
|
||||
Две формы (см. PRELOADED_*_RE):
|
||||
1. URL-encoded строковый литерал (текущая, ≈2026):
|
||||
window.__preloadedState__ = "%7B%22...%22%7D";
|
||||
→ urllib.parse.unquote + json.loads
|
||||
2. JSON.parse с js-экранированием (старая, fallback):
|
||||
window.__preloadedState__ = JSON.parse("{\\"...\\"}");
|
||||
→ ручной unescape + json.loads
|
||||
|
||||
Возвращает dict или None, если маркер не найден / не распарсился.
|
||||
"""
|
||||
# Форма 1 — URL-encoded литерал (приоритет)
|
||||
m = PRELOADED_URLENC_RE.search(html)
|
||||
if m is not None:
|
||||
try:
|
||||
return json.loads(urllib.parse.unquote(m.group(1)))
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
logger.warning("URL-encoded __preloadedState__ не распарсился: %s", exc)
|
||||
|
||||
# Форма 2 — старый JSON.parse("...") (fallback)
|
||||
m = PRELOADED_JSPARSE_RE.search(html)
|
||||
if m is not None:
|
||||
# JS-стиль unescaping: \\" → " , \\\\ → \ , \\/ → /
|
||||
# НЕ .decode("unicode_escape") — ломает многобайтный UTF-8 (кириллица).
|
||||
raw = m.group(1).replace('\\"', '"').replace("\\\\", "\\").replace("\\/", "/")
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("JSON.parse-форма с replace-unescape не удалась, пробуем unicode_escape")
|
||||
try:
|
||||
return json.loads(bytes(raw, "utf-8").decode("unicode_escape"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc2:
|
||||
logger.warning("JSON.parse-форма __preloadedState__ не распарсилась: %s", exc2)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_houses_state(state: dict[str, Any], house_url: str) -> HouseCatalogEnrichment:
|
||||
"""Парсит уже разобранный __preloadedState__ dict → HouseCatalogEnrichment.
|
||||
|
||||
|
|
@ -520,7 +601,7 @@ def parse_houses_state(state: dict[str, Any], house_url: str) -> HouseCatalogEnr
|
|||
ValueError если placeholders не массив или housePage виджет не найден.
|
||||
"""
|
||||
try:
|
||||
placeholders: list[dict[str, Any]] = (
|
||||
raw_placeholders: list[dict[str, Any]] = (
|
||||
state["data"]["data"]["page"]["placeholders"]
|
||||
)
|
||||
except (KeyError, TypeError) as exc:
|
||||
|
|
@ -528,11 +609,15 @@ def parse_houses_state(state: dict[str, Any], house_url: str) -> HouseCatalogEnr
|
|||
f"Не удалось найти placeholders в __preloadedState__: {exc}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(placeholders, list):
|
||||
if not isinstance(raw_placeholders, list):
|
||||
raise ValueError(
|
||||
f"placeholders должен быть массивом, получен: {type(placeholders).__name__}"
|
||||
f"placeholders должен быть массивом, получен: {type(raw_placeholders).__name__}"
|
||||
)
|
||||
|
||||
# Avito (≈2026) оборачивает виджеты в контейнер {id, widgets:[...]} —
|
||||
# раскрываем в плоский список (поддержка старой и новой формы).
|
||||
placeholders = _flatten_widgets(raw_placeholders)
|
||||
|
||||
# housePage — обязательный виджет
|
||||
house_page_widget = _get_widget(placeholders, "housePage")
|
||||
if house_page_widget is None:
|
||||
|
|
@ -630,29 +715,13 @@ async def fetch_house_catalog(
|
|||
resp.raise_for_status()
|
||||
html: str = resp.text
|
||||
|
||||
# Извлекаем экранированный JSON из window.__preloadedState__ = JSON.parse("...")
|
||||
match = PRELOADED_RE.search(html)
|
||||
if match is None:
|
||||
# Извлекаем + декодируем window.__preloadedState__ (URL-encoded или JSON.parse).
|
||||
state = _extract_preloaded_state(html)
|
||||
if state is None:
|
||||
raise ValueError(
|
||||
f"window.__preloadedState__ не найден в HTML страницы: {url}"
|
||||
f"window.__preloadedState__ не найден / не распарсился в HTML страницы: {url}"
|
||||
)
|
||||
|
||||
raw = match.group(1)
|
||||
# JS-стиль unescaping: \\" → " , \\\\ → \ , \\/ → /
|
||||
# НЕ используем .decode("unicode_escape") — ломает многобайтный UTF-8 (кириллица).
|
||||
raw = raw.replace('\\"', '"').replace("\\\\", "\\").replace("\\/", "/")
|
||||
|
||||
try:
|
||||
state: dict[str, Any] = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("json.loads с replace-unescape не удался, пробуем unicode_escape")
|
||||
try:
|
||||
state = json.loads(bytes(raw, "utf-8").decode("unicode_escape"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc2:
|
||||
raise ValueError(
|
||||
f"Не удалось распарсить __preloadedState__ JSON: {exc2}"
|
||||
) from exc2
|
||||
|
||||
logger.info("Houses Catalog state extracted, parsing widgets")
|
||||
return parse_houses_state(state, house_url)
|
||||
|
||||
|
|
|
|||
530
tradein-mvp/backend/tests/fixtures/avito_houses_sample.html
vendored
Normal file
530
tradein-mvp/backend/tests/fixtures/avito_houses_sample.html
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -5,6 +5,7 @@ Uses minimal JSON fixture mimicking Avito __preloadedState__ structure.
|
|||
"""
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -16,10 +17,13 @@ from app.services.scrapers.avito_houses import (
|
|||
PlacementHistoryItem,
|
||||
RecommendationItem,
|
||||
SellerInfo,
|
||||
_extract_preloaded_state,
|
||||
_parse_ru_date,
|
||||
parse_houses_state,
|
||||
)
|
||||
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal __preloadedState__ fixture (Python dict — не raw HTML)
|
||||
|
|
@ -395,3 +399,62 @@ def test_mini_serp_no_seller() -> None:
|
|||
assert len(e.mini_serp) == 1
|
||||
assert e.mini_serp[0].seller is None
|
||||
assert e.mini_serp[0].price_rub == 3000000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real-HTML fixture (regression for issue #566 — state-blob encoding/shape change)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Fixture: реальная страница /catalog/houses/ekaterinburg/ul_yumasheva_6/115188,
|
||||
# захвачена curl_cffi chrome120 (2026-05-28). Avito сменил формат __preloadedState__
|
||||
# на URL-encoded литерал + обернул виджеты в placeholders[0]['widgets'] +
|
||||
# вложил developmentData под props['housePage']. Этот тест ловит регрессию декода/shape.
|
||||
|
||||
|
||||
def test_extract_preloaded_state_from_real_html() -> None:
|
||||
"""_extract_preloaded_state декодирует текущий URL-encoded __preloadedState__."""
|
||||
html = (FIXTURES_DIR / "avito_houses_sample.html").read_text(encoding="utf-8")
|
||||
|
||||
state = _extract_preloaded_state(html)
|
||||
|
||||
assert state is not None, "URL-encoded __preloadedState__ должен распарситься"
|
||||
# Avito SSR обёртка — успешный ответ
|
||||
assert state["data"]["status"] == 200
|
||||
# Inner path до placeholders сохранился
|
||||
placeholders = state["data"]["data"]["page"]["placeholders"]
|
||||
assert isinstance(placeholders, list) and placeholders, "placeholders непустой массив"
|
||||
|
||||
|
||||
def test_parse_real_fixture_end_to_end() -> None:
|
||||
"""Фикстура HTML → _extract_preloaded_state → parse_houses_state → HouseInfo.ext_id."""
|
||||
html = (FIXTURES_DIR / "avito_houses_sample.html").read_text(encoding="utf-8")
|
||||
state = _extract_preloaded_state(html)
|
||||
assert state is not None
|
||||
|
||||
e = parse_houses_state(state, "/catalog/houses/ekaterinburg/ul_yumasheva_6/115188")
|
||||
|
||||
assert isinstance(e, HouseCatalogEnrichment)
|
||||
# housePage распарсился — ext_id (avitoId) присутствует и совпадает с URL
|
||||
assert e.house.ext_id == 115188
|
||||
assert e.house.ext_id_hash # base64 internal id присутствует
|
||||
# expandParams распарсились (год постройки реальной фикстуры — 2016)
|
||||
assert e.house.year_built == 2016
|
||||
assert e.house.total_floors == 17
|
||||
assert e.house.house_type == "monolith"
|
||||
assert e.house.lat is not None and e.house.lon is not None
|
||||
|
||||
|
||||
def test_extract_preloaded_state_jsparse_fallback() -> None:
|
||||
"""Старая форма JSON.parse("...") всё ещё декодируется (fallback-ветка)."""
|
||||
inner = '{\\"data\\":{\\"data\\":{\\"page\\":{\\"placeholders\\":[]}}}}'
|
||||
html = f'<script>window.__preloadedState__ = JSON.parse("{inner}");</script>'
|
||||
|
||||
state = _extract_preloaded_state(html)
|
||||
|
||||
assert state is not None
|
||||
assert state["data"]["data"]["page"]["placeholders"] == []
|
||||
|
||||
|
||||
def test_extract_preloaded_state_missing_returns_none() -> None:
|
||||
"""Нет маркера → None (а не исключение)."""
|
||||
assert _extract_preloaded_state("<html><body>no state here</body></html>") is None
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue