fix(tradein): decode Avito houses URL-encoded __preloadedState__ (#566) (#618)
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 44s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 35s

This commit is contained in:
Light1YT 2026-05-28 10:58:54 +00:00
commit 1058dc2430
3 changed files with 693 additions and 31 deletions

View file

@ -25,6 +25,7 @@ from __future__ import annotations
import json import json
import logging import logging
import re import re
import urllib.parse
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, date, datetime from datetime import UTC, date, datetime
from typing import Any from typing import Any
@ -38,8 +39,20 @@ logger = logging.getLogger(__name__)
AVITO_BASE = "https://www.avito.ru" AVITO_BASE = "https://www.avito.ru"
# Regex для извлечения __preloadedState__ из HTML # Regex для извлечения __preloadedState__ из HTML.
PRELOADED_RE = re.compile( #
# 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*;', r'window\.__preloadedState__\s*=\s*JSON\.parse\("(.*?)"\)\s*;',
re.DOTALL, re.DOTALL,
) )
@ -270,8 +283,28 @@ def _parse_expand_params(items: list[dict[str, Any]]) -> dict[str, Any]:
return result 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: def _get_widget(placeholders: list[dict[str, Any]], widget_type: str) -> dict[str, Any] | None:
"""Ищет виджет по type в массиве placeholders.""" """Ищет виджет по type в массиве placeholders (уже flat)."""
for w in placeholders: for w in placeholders:
if w.get("type") == widget_type: if w.get("type") == widget_type:
return w 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: def _parse_house_page(widget: dict[str, Any]) -> HouseInfo:
"""Парсит виджет housePage → 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", {}) props = widget.get("props", {})
dd = props.get("developmentData", {}) dd = props.get("housePage", {}).get("developmentData") or props.get("developmentData", {})
# expandParams → характеристики дома # expandParams → характеристики дома
about = dd.get("aboutDevelopment", {}) 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) parsed_params = _parse_expand_params(expand_params_items)
# Рейтинг # Рейтинг: новая форма — внутри ratingPreview, старая — прямо в developmentData
rating_badge = dd.get("ratingBadge", {}) rating_root = dd.get("ratingPreview") or dd
rating_badge = rating_root.get("ratingBadge", {})
rating_info = rating_badge.get("info", {}) 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", []) 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: def parse_houses_state(state: dict[str, Any], house_url: str) -> HouseCatalogEnrichment:
"""Парсит уже разобранный __preloadedState__ dict → HouseCatalogEnrichment. """Парсит уже разобранный __preloadedState__ dict → HouseCatalogEnrichment.
@ -520,7 +601,7 @@ def parse_houses_state(state: dict[str, Any], house_url: str) -> HouseCatalogEnr
ValueError если placeholders не массив или housePage виджет не найден. ValueError если placeholders не массив или housePage виджет не найден.
""" """
try: try:
placeholders: list[dict[str, Any]] = ( raw_placeholders: list[dict[str, Any]] = (
state["data"]["data"]["page"]["placeholders"] state["data"]["data"]["page"]["placeholders"]
) )
except (KeyError, TypeError) as exc: 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}" f"Не удалось найти placeholders в __preloadedState__: {exc}"
) from exc ) from exc
if not isinstance(placeholders, list): if not isinstance(raw_placeholders, list):
raise ValueError( raise ValueError(
f"placeholders должен быть массивом, получен: {type(placeholders).__name__}" f"placeholders должен быть массивом, получен: {type(raw_placeholders).__name__}"
) )
# Avito (≈2026) оборачивает виджеты в контейнер {id, widgets:[...]} —
# раскрываем в плоский список (поддержка старой и новой формы).
placeholders = _flatten_widgets(raw_placeholders)
# housePage — обязательный виджет # housePage — обязательный виджет
house_page_widget = _get_widget(placeholders, "housePage") house_page_widget = _get_widget(placeholders, "housePage")
if house_page_widget is None: if house_page_widget is None:
@ -630,29 +715,13 @@ async def fetch_house_catalog(
resp.raise_for_status() resp.raise_for_status()
html: str = resp.text html: str = resp.text
# Извлекаем экранированный JSON из window.__preloadedState__ = JSON.parse("...") # Извлекаем + декодируем window.__preloadedState__ (URL-encoded или JSON.parse).
match = PRELOADED_RE.search(html) state = _extract_preloaded_state(html)
if match is None: if state is None:
raise ValueError( 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") logger.info("Houses Catalog state extracted, parsing widgets")
return parse_houses_state(state, house_url) return parse_houses_state(state, house_url)

File diff suppressed because one or more lines are too long

View file

@ -5,6 +5,7 @@ Uses minimal JSON fixture mimicking Avito __preloadedState__ structure.
""" """
from datetime import date from datetime import date
from pathlib import Path
import pytest import pytest
@ -16,10 +17,13 @@ from app.services.scrapers.avito_houses import (
PlacementHistoryItem, PlacementHistoryItem,
RecommendationItem, RecommendationItem,
SellerInfo, SellerInfo,
_extract_preloaded_state,
_parse_ru_date, _parse_ru_date,
parse_houses_state, parse_houses_state,
) )
FIXTURES_DIR = Path(__file__).parent / "fixtures"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Minimal __preloadedState__ fixture (Python dict — не raw HTML) # 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 len(e.mini_serp) == 1
assert e.mini_serp[0].seller is None assert e.mini_serp[0].seller is None
assert e.mini_serp[0].price_rub == 3000000 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