fix(scrapers): avito_houses читает MFE props-вложенность виджетов — отзывы/история/тип ЖК теперь долетают (#1789)
All checks were successful
CI / changes (pull_request) Successful in 5s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

This commit is contained in:
bot-backend 2026-06-19 19:21:49 +03:00
parent 4755f8bd13
commit 464e9faf7b
3 changed files with 302 additions and 148 deletions

View file

@ -4,6 +4,11 @@
и разбирает 10 виджетов: housePage, reviews, miniSerp, housePlacementHistory, и разбирает 10 виджетов: housePage, reviews, miniSerp, housePlacementHistory,
recommendations. recommendations.
Avito (2026, MFE) перенёс полезную нагрузку виджетов на уровень глубже
под ключ с именем самого виджета (props['reviews']['entries'],
props['miniSerp']['items'], props['housePlacementHistory']['items'] и т.д.).
Разворачивание делает _widget_payload с fallback на старую форму (props напрямую).
Flow: Flow:
fetch_house_catalog(house_url) fetch_house_catalog(house_url)
HTTP GET (curl_cffi chrome120) HTTP GET (curl_cffi chrome120)
@ -316,6 +321,26 @@ def _get_widget(placeholders: list[dict[str, Any]], widget_type: str) -> dict[st
return None return None
def _widget_payload(widget: dict[str, Any], widget_type: str) -> dict[str, Any]:
"""Возвращает полезную нагрузку виджета, разворачивая type-вложенность.
Avito (2026, MFE) переместил данные виджета на уровень глубже
под ключ с именем самого виджета:
новый: widget['props'][widget_type] = {... entries / items ...}
старый: widget['props'] = {... entries / items ...}
Хелпер пробует новую форму (props[widget_type]), fallback на старую
(props напрямую) поддержка обеих форм.
"""
props = widget.get("props", {})
if not isinstance(props, dict):
return {}
nested = props.get(widget_type)
if isinstance(nested, dict):
return nested
return props
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Widget parsers # Widget parsers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -427,7 +452,8 @@ def _parse_reviews(widget: dict[str, Any]) -> tuple[list[HouseReview], str | Non
reviews: list[HouseReview] = [] reviews: list[HouseReview] = []
next_page_url: str | None = None next_page_url: str | None = None
entries = widget.get("props", {}).get("entries", []) payload = _widget_payload(widget, "reviews")
entries = payload.get("entries", [])
for entry in entries: for entry in entries:
entry_type = entry.get("type") entry_type = entry.get("type")
value = entry.get("value", {}) value = entry.get("value", {})
@ -471,7 +497,7 @@ def _parse_mini_serp(widget: dict[str, Any]) -> list[MiniSerpListing]:
"""Парсит виджет miniSerp → список активных объявлений в доме.""" """Парсит виджет miniSerp → список активных объявлений в доме."""
listings: list[MiniSerpListing] = [] listings: list[MiniSerpListing] = []
items = widget.get("props", {}).get("items", []) items = _widget_payload(widget, "miniSerp").get("items", [])
for item in items: for item in items:
geo = item.get("geo", {}) geo = item.get("geo", {})
colors: list[str] = geo.get("colors", []) colors: list[str] = geo.get("colors", [])
@ -506,7 +532,7 @@ def _parse_placement_history(widget: dict[str, Any]) -> list[PlacementHistoryIte
""" """
result: list[PlacementHistoryItem] = [] result: list[PlacementHistoryItem] = []
items = widget.get("props", {}).get("items", []) items = _widget_payload(widget, "housePlacementHistory").get("items", [])
for item in items: for item in items:
item_copy = {k: v for k, v in item.items() if k != "itemImage"} item_copy = {k: v for k, v in item.items() if k != "itemImage"}
result.append( result.append(
@ -530,7 +556,7 @@ def _parse_recommendations(widget: dict[str, Any]) -> list[RecommendationItem]:
"""Парсит виджет recommendations → lightweight список похожих объявлений.""" """Парсит виджет recommendations → lightweight список похожих объявлений."""
result: list[RecommendationItem] = [] result: list[RecommendationItem] = []
items = widget.get("props", {}).get("items", []) items = _widget_payload(widget, "recommendations").get("items", [])
for item in items: for item in items:
additional_info: list[str] = item.get("additionalInfo", []) additional_info: list[str] = item.get("additionalInfo", [])
address = additional_info[0] if additional_info else None address = additional_info[0] if additional_info else None
@ -636,6 +662,8 @@ def parse_houses_state(state: dict[str, Any], house_url: str) -> HouseCatalogEnr
reviews_widget = _get_widget(placeholders, "reviews") reviews_widget = _get_widget(placeholders, "reviews")
if reviews_widget is not None: if reviews_widget is not None:
reviews, next_page_url = _parse_reviews(reviews_widget) reviews, next_page_url = _parse_reviews(reviews_widget)
if not reviews:
logger.warning("Виджет 'reviews' найден, но 0 отзывов распарсено — проверь шейп props")
else: else:
logger.debug("Виджет 'reviews' не найден — пропускаем") logger.debug("Виджет 'reviews' не найден — пропускаем")
@ -652,6 +680,10 @@ def parse_houses_state(state: dict[str, Any], house_url: str) -> HouseCatalogEnr
history_widget = _get_widget(placeholders, "housePlacementHistory") history_widget = _get_widget(placeholders, "housePlacementHistory")
if history_widget is not None: if history_widget is not None:
placement_history = _parse_placement_history(history_widget) placement_history = _parse_placement_history(history_widget)
if not placement_history:
logger.warning(
"Виджет 'housePlacementHistory' найден, но 0 items распарсено — проверь шейп props"
)
else: else:
logger.debug("Виджет 'housePlacementHistory' не найден — пропускаем") logger.debug("Виджет 'housePlacementHistory' не найден — пропускаем")
@ -842,7 +874,8 @@ def _persist_house(db: Session, h: HouseInfo, house_url: str) -> int:
lat = COALESCE(houses.lat, CAST(:lat AS double precision)), lat = COALESCE(houses.lat, CAST(:lat AS double precision)),
lon = COALESCE(houses.lon, CAST(:lon AS double precision)), lon = COALESCE(houses.lon, CAST(:lon AS double precision)),
year_built = COALESCE(houses.year_built, CAST(:year_built AS int)), year_built = COALESCE(houses.year_built, CAST(:year_built AS int)),
last_scraped_at = NOW() last_scraped_at = NOW(),
avito_validated_at = NOW()
WHERE id = CAST(:house_id AS bigint) WHERE id = CAST(:house_id AS bigint)
"""), """),
{ {

File diff suppressed because one or more lines are too long

View file

@ -30,21 +30,31 @@ FIXTURES_DIR = Path(__file__).parent / "fixtures"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
MINIMAL_STATE: dict = { MINIMAL_STATE: dict = {
"data": {"data": {"page": {"placeholders": [ "data": {
"data": {
"page": {
"placeholders": [
{"type": "breadcrumbs"}, {"type": "breadcrumbs"},
{"type": "title"}, {"type": "title"},
{"type": "gallery", "props": {}}, {"type": "gallery", "props": {}},
{ {
"type": "housePage", "type": "housePage",
"props": {"developmentData": { "props": {
"developmentData": {
"avitoId": 3171365, "avitoId": 3171365,
"id": "MTE3LjM3MDg5MA", "id": "MTE3LjM3MDg5MA",
"title": "Akademika Postovskogo 17a", "title": "Akademika Postovskogo 17a",
"address": "ул. Постовского, 17а", "address": "ул. Постовского, 17а",
"fullAddress": "Свердловская обл., Екатеринбург, ул. Постовского, 17а", "fullAddress": (
"Свердловская обл., Екатеринбург, ул. Постовского, 17а"
),
"coords": {"lat": 56.790699, "lng": 60.580191}, "coords": {"lat": 56.790699, "lng": 60.580191},
"aboutDevelopment": {"expandParams": {"items": [ "aboutDevelopment": {
{"title": "Параметры дома", "params": [ "expandParams": {
"items": [
{
"title": "Параметры дома",
"params": [
{"type": "Год постройки", "value": "2019"}, {"type": "Год постройки", "value": "2019"},
{"type": "Этажей", "value": "25"}, {"type": "Этажей", "value": "25"},
{"type": "Тип дома", "value": "Монолитный"}, {"type": "Тип дома", "value": "Монолитный"},
@ -52,8 +62,11 @@ MINIMAL_STATE: dict = {
{"type": "Консьерж", "value": "Да"}, {"type": "Консьерж", "value": "Да"},
{"type": "Закрытый двор", "value": "Да"}, {"type": "Закрытый двор", "value": "Да"},
{"type": "Детская площадка", "value": "Да"}, {"type": "Детская площадка", "value": "Да"},
]}, ],
]}}, },
]
}
},
"developer": {"name": "Атомстройкомплекс", "key": "atomstroy"}, "developer": {"name": "Атомстройкомплекс", "key": "atomstroy"},
"mapPreview": { "mapPreview": {
"distance": "в 5 минутах ходьбы", "distance": "в 5 минутах ходьбы",
@ -62,15 +75,22 @@ MINIMAL_STATE: dict = {
}, },
"ratingBadge": {"info": {"score": 4.666, "scoreString": "4,7"}}, "ratingBadge": {"info": {"score": 4.666, "scoreString": "4,7"}},
"ratingSummaryStat": { "ratingSummaryStat": {
"ratingStat": [{"score": 5, "count": 4}, {"score": 4, "count": 2}], "ratingStat": [
{"score": 5, "count": 4},
{"score": 4, "count": 2},
],
"reviewCount": 6, "reviewCount": 6,
}, },
}}, }
},
}, },
{ {
"type": "reviews", "type": "reviews",
"props": {"entries": [ "props": {
{"type": "rating", "value": { "entries": [
{
"type": "rating",
"value": {
"id": 12345, "id": 12345,
"author": {"title": "Иван"}, "author": {"title": "Иван"},
"reviewTitle": "Хорошо", "reviewTitle": "Хорошо",
@ -82,13 +102,16 @@ MINIMAL_STATE: dict = {
{"title": "Преимущества", "text": "Парк рядом"}, {"title": "Преимущества", "text": "Парк рядом"},
{"title": "Недостатки", "text": "Парковки мало"}, {"title": "Недостатки", "text": "Парковки мало"},
], ],
}}, },
},
{"type": "pages", "value": {"nextPageUrl": "/reviews?page=2"}}, {"type": "pages", "value": {"nextPageUrl": "/reviews?page=2"}},
]}, ]
},
}, },
{ {
"type": "miniSerp", "type": "miniSerp",
"props": {"items": [ "props": {
"items": [
{ {
"id": 7986882804, "id": 7986882804,
"title": "3-к. квартира", "title": "3-к. квартира",
@ -104,11 +127,13 @@ MINIMAL_STATE: dict = {
"logos": [], "logos": [],
}, },
}, },
]}, ]
},
}, },
{ {
"type": "housePlacementHistory", "type": "housePlacementHistory",
"props": {"items": [ "props": {
"items": [
{ {
"id": 7000000001, "id": 7000000001,
"title": "1-к. квартира", "title": "1-к. квартира",
@ -119,12 +144,14 @@ MINIMAL_STATE: dict = {
"exposure": 210, "exposure": 210,
"itemImage": {}, "itemImage": {},
}, },
]}, ]
},
}, },
{"type": "uxFeedback"}, {"type": "uxFeedback"},
{ {
"type": "recommendations", "type": "recommendations",
"props": {"items": [ "props": {
"items": [
{ {
"title": "Похожая", "title": "Похожая",
"priceRange": "от 6 млн", "priceRange": "от 6 млн",
@ -132,10 +159,14 @@ MINIMAL_STATE: dict = {
"url": "/rec1", "url": "/rec1",
"images": [{"208x156": "https://avito.ru/img1.jpg"}], "images": [{"208x156": "https://avito.ru/img1.jpg"}],
}, },
]}, ]
},
}, },
{"type": "padding"}, {"type": "padding"},
]}}} ]
}
}
}
} }
@ -305,10 +336,14 @@ def test_recommendations() -> None:
def test_missing_optional_widgets() -> None: def test_missing_optional_widgets() -> None:
"""Парсер не падает если опциональные виджеты отсутствуют.""" """Парсер не падает если опциональные виджеты отсутствуют."""
state_minimal: dict = { state_minimal: dict = {
"data": {"data": {"page": {"placeholders": [ "data": {
"data": {
"page": {
"placeholders": [
{ {
"type": "housePage", "type": "housePage",
"props": {"developmentData": { "props": {
"developmentData": {
"avitoId": 9999, "avitoId": 9999,
"coords": {"lat": 56.0, "lng": 60.0}, "coords": {"lat": 56.0, "lng": 60.0},
"aboutDevelopment": {"expandParams": {"items": []}}, "aboutDevelopment": {"expandParams": {"items": []}},
@ -316,9 +351,13 @@ def test_missing_optional_widgets() -> None:
"mapPreview": {}, "mapPreview": {},
"ratingBadge": {"info": {}}, "ratingBadge": {"info": {}},
"ratingSummaryStat": {}, "ratingSummaryStat": {},
}}, }
}, },
]}}} },
]
}
}
}
} }
e = parse_houses_state(state_minimal, "/catalog/houses/test/9999") e = parse_houses_state(state_minimal, "/catalog/houses/test/9999")
assert e.house.ext_id == 9999 assert e.house.ext_id == 9999
@ -338,9 +377,15 @@ def test_invalid_state_raises() -> None:
def test_missing_house_page_raises() -> None: def test_missing_house_page_raises() -> None:
"""ValueError если housePage виджет отсутствует.""" """ValueError если housePage виджет отсутствует."""
state: dict = { state: dict = {
"data": {"data": {"page": {"placeholders": [ "data": {
"data": {
"page": {
"placeholders": [
{"type": "breadcrumbs"}, {"type": "breadcrumbs"},
]}}} ]
}
}
}
} }
with pytest.raises(ValueError, match="housePage"): with pytest.raises(ValueError, match="housePage"):
parse_houses_state(state, "/catalog/houses/test/1") parse_houses_state(state, "/catalog/houses/test/1")
@ -373,10 +418,14 @@ def test_strip_price() -> None:
def test_mini_serp_no_seller() -> None: def test_mini_serp_no_seller() -> None:
"""Объявление без продавца не вызывает ошибку.""" """Объявление без продавца не вызывает ошибку."""
state: dict = { state: dict = {
"data": {"data": {"page": {"placeholders": [ "data": {
"data": {
"page": {
"placeholders": [
{ {
"type": "housePage", "type": "housePage",
"props": {"developmentData": { "props": {
"developmentData": {
"avitoId": 111, "avitoId": 111,
"coords": {"lat": 56.0, "lng": 60.0}, "coords": {"lat": 56.0, "lng": 60.0},
"aboutDevelopment": {"expandParams": {"items": []}}, "aboutDevelopment": {"expandParams": {"items": []}},
@ -384,16 +433,27 @@ def test_mini_serp_no_seller() -> None:
"mapPreview": {}, "mapPreview": {},
"ratingBadge": {"info": {}}, "ratingBadge": {"info": {}},
"ratingSummaryStat": {}, "ratingSummaryStat": {},
}}, }
},
}, },
{ {
"type": "miniSerp", "type": "miniSerp",
"props": {"items": [ "props": {
{"id": 12345, "title": "1-к.", "url": "/test", "price": "3 000 000 ₽", "items": [
"geo": {"content": "Центр", "colors": []}}, {
]}, "id": 12345,
"title": "1-к.",
"url": "/test",
"price": "3 000 000 ₽",
"geo": {"content": "Центр", "colors": []},
}, },
]}}} ]
},
},
]
}
}
}
} }
e = parse_houses_state(state, "/catalog/houses/test/111") e = parse_houses_state(state, "/catalog/houses/test/111")
assert len(e.mini_serp) == 1 assert len(e.mini_serp) == 1
@ -458,3 +518,56 @@ def test_extract_preloaded_state_jsparse_fallback() -> None:
def test_extract_preloaded_state_missing_returns_none() -> None: def test_extract_preloaded_state_missing_returns_none() -> None:
"""Нет маркера → None (а не исключение).""" """Нет маркера → None (а не исключение)."""
assert _extract_preloaded_state("<html><body>no state here</body></html>") is None assert _extract_preloaded_state("<html><body>no state here</body></html>") is None
# ---------------------------------------------------------------------------
# Real-HTML fixture (regression for issue #1789 — MFE props nesting)
# ---------------------------------------------------------------------------
#
# Fixture: реальная страница /catalog/houses/ekaterinburg/ul_soni_morozovoy_190/572538,
# захвачена браузер-сервисом (2026-06-19). Avito перенёс данные виджетов на уровень
# глубже — под ключ с именем виджета:
# reviews: props['reviews']['entries'] (было props['entries'])
# miniSerp: props['miniSerp']['items'] (было props['items'])
# housePlacementHistory: props['housePlacementHistory']['items'] (было props['items'])
# recommendations: props['recommendations']['items'] (было props['items'])
# Из-за этого reviews/placement/seller-обогащение НЕ долетало в БД (0 строк).
# Этот тест ловит регрессию шейпа props.
def test_parse_mfe_fixture_widgets_reach_db() -> None:
"""Фикстура MFE: reviews/placement/miniSerp/house_type/year_built распарсены непусто."""
html = (FIXTURES_DIR / "avito_house_572538.html").read_text(encoding="utf-8")
state = _extract_preloaded_state(html)
assert state is not None, "URL-encoded __preloadedState__ должен распарситься"
e = parse_houses_state(state, "/catalog/houses/ekaterinburg/ul_soni_morozovoy_190/572538")
# housePage — тип дома и год постройки долетают
assert e.house.ext_id == 572538
assert e.house.house_type is not None, "house_type должен парситься из expandParams"
assert e.house.house_type == "brick"
assert e.house.year_built == 1994
assert e.house.reviews_count == 3
# reviews — >=1 отзыв с текстом, автором, оценкой
assert len(e.reviews) >= 1, "reviews должны распарситься из props['reviews']['entries']"
r = e.reviews[0]
assert r.ext_review_id, "у отзыва должен быть id"
assert r.author_name, "у отзыва должен быть автор"
assert r.score is not None, "у отзыва должна быть оценка"
assert r.text_main, "у отзыва должен быть основной текст (textSections)"
# placement — >=1 item из props['housePlacementHistory']['items']
assert len(e.placement_history) >= 1, "placement должен распарситься"
p = e.placement_history[0]
assert p.ext_item_id, "у placement item должен быть id"
assert p.start_price is not None
assert p.last_price is not None
assert isinstance(p.last_price_date, date)
# miniSerp — >=1 объявление из props['miniSerp']['items']
assert len(e.mini_serp) >= 1, "miniSerp должен распарситься"
m = e.mini_serp[0]
assert m.ext_item_id
assert m.price_rub is not None