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 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)

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 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