Compare commits

..

No commits in common. "059fa83589613124f237eebf9a27b5198f5e09e2" and "4755f8bd131b1b7e3778069ba5c0f44c820129b3" have entirely different histories.

3 changed files with 148 additions and 302 deletions

View file

@ -4,11 +4,6 @@
и разбирает 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)
@ -321,26 +316,6 @@ 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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -452,8 +427,7 @@ 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
payload = _widget_payload(widget, "reviews") entries = widget.get("props", {}).get("entries", [])
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", {})
@ -497,7 +471,7 @@ def _parse_mini_serp(widget: dict[str, Any]) -> list[MiniSerpListing]:
"""Парсит виджет miniSerp → список активных объявлений в доме.""" """Парсит виджет miniSerp → список активных объявлений в доме."""
listings: list[MiniSerpListing] = [] listings: list[MiniSerpListing] = []
items = _widget_payload(widget, "miniSerp").get("items", []) items = widget.get("props", {}).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", [])
@ -532,7 +506,7 @@ def _parse_placement_history(widget: dict[str, Any]) -> list[PlacementHistoryIte
""" """
result: list[PlacementHistoryItem] = [] result: list[PlacementHistoryItem] = []
items = _widget_payload(widget, "housePlacementHistory").get("items", []) items = widget.get("props", {}).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(
@ -556,7 +530,7 @@ def _parse_recommendations(widget: dict[str, Any]) -> list[RecommendationItem]:
"""Парсит виджет recommendations → lightweight список похожих объявлений.""" """Парсит виджет recommendations → lightweight список похожих объявлений."""
result: list[RecommendationItem] = [] result: list[RecommendationItem] = []
items = _widget_payload(widget, "recommendations").get("items", []) items = widget.get("props", {}).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
@ -662,8 +636,6 @@ 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' не найден — пропускаем")
@ -680,10 +652,6 @@ 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' не найден — пропускаем")
@ -874,8 +842,7 @@ 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,143 +30,112 @@ FIXTURES_DIR = Path(__file__).parent / "fixtures"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
MINIMAL_STATE: dict = { MINIMAL_STATE: dict = {
"data": { "data": {"data": {"page": {"placeholders": [
"data": { {"type": "breadcrumbs"},
"page": { {"type": "title"},
"placeholders": [ {"type": "gallery", "props": {}},
{"type": "breadcrumbs"}, {
{"type": "title"}, "type": "housePage",
{"type": "gallery", "props": {}}, "props": {"developmentData": {
{ "avitoId": 3171365,
"type": "housePage", "id": "MTE3LjM3MDg5MA",
"props": { "title": "Akademika Postovskogo 17a",
"developmentData": { "address": "ул. Постовского, 17а",
"avitoId": 3171365, "fullAddress": "Свердловская обл., Екатеринбург, ул. Постовского, 17а",
"id": "MTE3LjM3MDg5MA", "coords": {"lat": 56.790699, "lng": 60.580191},
"title": "Akademika Postovskogo 17a", "aboutDevelopment": {"expandParams": {"items": [
"address": "ул. Постовского, 17а", {"title": "Параметры дома", "params": [
"fullAddress": ( {"type": "Год постройки", "value": "2019"},
"Свердловская обл., Екатеринбург, ул. Постовского, 17а" {"type": "Этажей", "value": "25"},
), {"type": "Тип дома", "value": "Монолитный"},
"coords": {"lat": 56.790699, "lng": 60.580191}, {"type": "Пассажирский лифт", "value": "2"},
"aboutDevelopment": { {"type": "Консьерж", "value": "Да"},
"expandParams": { {"type": "Закрытый двор", "value": "Да"},
"items": [ {"type": "Детская площадка", "value": "Да"},
{ ]},
"title": "Параметры дома", ]}},
"params": [ "developer": {"name": "Атомстройкомплекс", "key": "atomstroy"},
{"type": "Год постройки", "value": "2019"}, "mapPreview": {
{"type": "Этажей", "value": "25"}, "distance": "в 5 минутах ходьбы",
{"type": "Тип дома", "value": "Монолитный"}, "objects": "школа, парк, аптека",
{"type": "Пассажирский лифт", "value": "2"}, "pins": [{"lat": 56.79, "lng": 60.58, "type": "school"}],
{"type": "Консьерж", "value": "Да"}, },
{"type": "Закрытый двор", "value": "Да"}, "ratingBadge": {"info": {"score": 4.666, "scoreString": "4,7"}},
{"type": "Детская площадка", "value": "Да"}, "ratingSummaryStat": {
], "ratingStat": [{"score": 5, "count": 4}, {"score": 4, "count": 2}],
}, "reviewCount": 6,
] },
} }},
}, },
"developer": {"name": "Атомстройкомплекс", "key": "atomstroy"}, {
"mapPreview": { "type": "reviews",
"distance": "в 5 минутах ходьбы", "props": {"entries": [
"objects": "школа, парк, аптека", {"type": "rating", "value": {
"pins": [{"lat": 56.79, "lng": 60.58, "type": "school"}], "id": 12345,
}, "author": {"title": "Иван"},
"ratingBadge": {"info": {"score": 4.666, "scoreString": "4,7"}}, "reviewTitle": "Хорошо",
"ratingSummaryStat": { "score": 5,
"ratingStat": [ "modelExperience": "Живу в своей квартире",
{"score": 5, "count": 4}, "rated": "9 октября 2025",
{"score": 4, "count": 2}, "textSections": [
], {"title": "", "text": "Общий отзыв"},
"reviewCount": 6, {"title": "Преимущества", "text": "Парк рядом"},
}, {"title": "Недостатки", "text": "Парковки мало"},
} ],
}, }},
{"type": "pages", "value": {"nextPageUrl": "/reviews?page=2"}},
]},
},
{
"type": "miniSerp",
"props": {"items": [
{
"id": 7986882804,
"title": "3-к. квартира",
"description": "Описание",
"url": "/ekaterinburg/kvartiry/test_7986882804",
"price": "11 990 000 ₽",
"geo": {"content": "Чкаловская, 2,7 км", "colors": ["#cf2734"]},
"seller": {
"name": "DOMRF66",
"type": "Компания",
"from": "На Авито с июля 2012",
"url": "/brands/domrf66",
"logos": [],
}, },
{ },
"type": "reviews", ]},
"props": { },
"entries": [ {
{ "type": "housePlacementHistory",
"type": "rating", "props": {"items": [
"value": { {
"id": 12345, "id": 7000000001,
"author": {"title": "Иван"}, "title": "1-к. квартира",
"reviewTitle": "Хорошо", "startPrice": 5000000,
"score": 5, "startPriceDate": 1697000000,
"modelExperience": "Живу в своей квартире", "lastPrice": 4900000,
"rated": "9 октября 2025", "lastPriceDate": 1715000000,
"textSections": [ "exposure": 210,
{"title": "", "text": "Общий отзыв"}, "itemImage": {},
{"title": "Преимущества", "text": "Парк рядом"}, },
{"title": "Недостатки", "text": "Парковки мало"}, ]},
], },
}, {"type": "uxFeedback"},
}, {
{"type": "pages", "value": {"nextPageUrl": "/reviews?page=2"}}, "type": "recommendations",
] "props": {"items": [
}, {
}, "title": "Похожая",
{ "priceRange": "от 6 млн",
"type": "miniSerp", "additionalInfo": ["Адрес 1"],
"props": { "url": "/rec1",
"items": [ "images": [{"208x156": "https://avito.ru/img1.jpg"}],
{ },
"id": 7986882804, ]},
"title": "3-к. квартира", },
"description": "Описание", {"type": "padding"},
"url": "/ekaterinburg/kvartiry/test_7986882804", ]}}}
"price": "11 990 000 ₽",
"geo": {"content": "Чкаловская, 2,7 км", "colors": ["#cf2734"]},
"seller": {
"name": "DOMRF66",
"type": "Компания",
"from": "На Авито с июля 2012",
"url": "/brands/domrf66",
"logos": [],
},
},
]
},
},
{
"type": "housePlacementHistory",
"props": {
"items": [
{
"id": 7000000001,
"title": "1-к. квартира",
"startPrice": 5000000,
"startPriceDate": 1697000000,
"lastPrice": 4900000,
"lastPriceDate": 1715000000,
"exposure": 210,
"itemImage": {},
},
]
},
},
{"type": "uxFeedback"},
{
"type": "recommendations",
"props": {
"items": [
{
"title": "Похожая",
"priceRange": "от 6 млн",
"additionalInfo": ["Адрес 1"],
"url": "/rec1",
"images": [{"208x156": "https://avito.ru/img1.jpg"}],
},
]
},
},
{"type": "padding"},
]
}
}
}
} }
@ -336,28 +305,20 @@ def test_recommendations() -> None:
def test_missing_optional_widgets() -> None: def test_missing_optional_widgets() -> None:
"""Парсер не падает если опциональные виджеты отсутствуют.""" """Парсер не падает если опциональные виджеты отсутствуют."""
state_minimal: dict = { state_minimal: dict = {
"data": { "data": {"data": {"page": {"placeholders": [
"data": { {
"page": { "type": "housePage",
"placeholders": [ "props": {"developmentData": {
{ "avitoId": 9999,
"type": "housePage", "coords": {"lat": 56.0, "lng": 60.0},
"props": { "aboutDevelopment": {"expandParams": {"items": []}},
"developmentData": { "developer": {},
"avitoId": 9999, "mapPreview": {},
"coords": {"lat": 56.0, "lng": 60.0}, "ratingBadge": {"info": {}},
"aboutDevelopment": {"expandParams": {"items": []}}, "ratingSummaryStat": {},
"developer": {}, }},
"mapPreview": {}, },
"ratingBadge": {"info": {}}, ]}}}
"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
@ -377,15 +338,9 @@ 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": {"data": {"page": {"placeholders": [
"data": { {"type": "breadcrumbs"},
"page": { ]}}}
"placeholders": [
{"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")
@ -418,42 +373,27 @@ 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": {"data": {"page": {"placeholders": [
"data": { {
"page": { "type": "housePage",
"placeholders": [ "props": {"developmentData": {
{ "avitoId": 111,
"type": "housePage", "coords": {"lat": 56.0, "lng": 60.0},
"props": { "aboutDevelopment": {"expandParams": {"items": []}},
"developmentData": { "developer": {},
"avitoId": 111, "mapPreview": {},
"coords": {"lat": 56.0, "lng": 60.0}, "ratingBadge": {"info": {}},
"aboutDevelopment": {"expandParams": {"items": []}}, "ratingSummaryStat": {},
"developer": {}, }},
"mapPreview": {}, },
"ratingBadge": {"info": {}}, {
"ratingSummaryStat": {}, "type": "miniSerp",
} "props": {"items": [
}, {"id": 12345, "title": "1-к.", "url": "/test", "price": "3 000 000 ₽",
}, "geo": {"content": "Центр", "colors": []}},
{ ]},
"type": "miniSerp", },
"props": { ]}}}
"items": [
{
"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
@ -518,56 +458,3 @@ 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