From a3f45a67222fe4b746975d26b6103d54721416e8 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Thu, 28 May 2026 15:58:19 +0500 Subject: [PATCH] fix(tradein): decode Avito houses URL-encoded __preloadedState__ (#566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avito switched the houses-catalog state blob from JSON.parse("") to a URL-encoded string literal, wrapped the 10 widgets in placeholders[0].widgets, и re-nested developmentData под props.housePage. JS-unescape path silently failed → houses rows перестали наполняться (starve'ит #6 same-building + #569 deal centroids). Extract decode в pure _extract_preloaded_state (urllib.parse.unquote, JSON.parse fallback для старой формы), add _flatten_widgets, и сделать _parse_house_page tolerant к обеим developmentData/ratingPreview формам. Real-HTML fixture (Yumasheva-6 EKB) + 4 regression-теста. code-reviewer: ✅ APPROVE no critical (regex anchoring verified, decode safe — no eval/unicode_escape, backward-compat реальный, delisted-house envelope degrade'ит gracefully, fixture без PII/secrets). Tests: 22 passed. Closes #566 --- .../app/services/scrapers/avito_houses.py | 131 ++++- .../tests/fixtures/avito_houses_sample.html | 530 ++++++++++++++++++ .../backend/tests/test_avito_houses_parse.py | 63 +++ 3 files changed, 693 insertions(+), 31 deletions(-) create mode 100644 tradein-mvp/backend/tests/fixtures/avito_houses_sample.html diff --git a/tradein-mvp/backend/app/services/scrapers/avito_houses.py b/tradein-mvp/backend/app/services/scrapers/avito_houses.py index 743e8619..45b8b11d 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito_houses.py +++ b/tradein-mvp/backend/app/services/scrapers/avito_houses.py @@ -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(""); +# теперь — 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) diff --git a/tradein-mvp/backend/tests/fixtures/avito_houses_sample.html b/tradein-mvp/backend/tests/fixtures/avito_houses_sample.html new file mode 100644 index 00000000..f060c790 --- /dev/null +++ b/tradein-mvp/backend/tests/fixtures/avito_houses_sample.html @@ -0,0 +1,530 @@ +Авито — Объявления на сайте Авито + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/tradein-mvp/backend/tests/test_avito_houses_parse.py b/tradein-mvp/backend/tests/test_avito_houses_parse.py index a684f6c8..d604bd8d 100644 --- a/tradein-mvp/backend/tests/test_avito_houses_parse.py +++ b/tradein-mvp/backend/tests/test_avito_houses_parse.py @@ -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'' + + 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("no state here") is None