This commit is contained in:
commit
1058dc2430
3 changed files with 693 additions and 31 deletions
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
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 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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue