gendesign/tradein-mvp/backend/tests/test_yandex_realty_serp.py
bot-backend d03c96a79a fix(scrapers): yandex area из embedded-state JSON (Refs #775)
Часть A (#24): yandex_realty.py парсил площадь из RAW-HTML (regex), пустого до
JS-гидрации → 82% NULL area. _parse_html теперь извлекает {offer_id: area_m2}
из <script id=initial_state_script> (или application/json) раз на страницу и
использует preferentially в _card_to_lot; DOM-regex остаётся fallback'ом.
4 offer-path + 5 area-полей (incl nested {value}). 6 тестов.

Часть B (#23, cian buildingCadastralNumber): УЖЕ реализовано — cian.py:291
читает offer.buildingCadastralNumber, :456 пишет в ScrapedLot, поле base.py:109
существует, тест test_cian_serp_cadastral_numbers зелёный. Изменений не нужно
(премиса аудита устарела). cian.py НЕ трогался.

ruff clean; pytest -k 'yandex or cian' 365 pass (+1 pre-existing unrelated fail
test_cian_valuation low_price mock, не из этого PR).
2026-05-30 19:35:13 +03:00

341 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Unit tests for YandexRealtyScraper DOM-based SERP parser."""
import json
from datetime import date
import pytest
from app.services.scrapers.yandex_realty import (
DEFAULT_CITY,
MAX_PAGES,
YandexRealtyScraper,
_extract_offer_areas_from_state,
)
# Realistic single-card fixture (trimmed but selector-faithful).
# Note: selectolax.text(strip=True) concatenates all text nodes without
# added separators — so floor/price data lives in a single line div with
# middot (·) separators, matching real Yandex SERP card layout.
#
# The address container mirrors the live DOM shape (2026-05):
# <div class="AddressWithGeoLinks__addressContainer--XXXX">
# <a href=".../kupit/kvartira/st-...">улица Малышева</a>, 125
# </div>
# selectolax sees the trailing text node ", 125" as a sibling of <a>, so
# text(strip=False) yields "улица Малышева, 125" while text(strip=True)
# collapses whitespace to "улица Малышева,125".
SINGLE_CARD_HTML = """
<html><body>
<div data-test="OffersSerpItem">
<a href="/offer/7567094292504417257/">Открыть</a>
<div class="AddressWithGeoLinks__addressContainer--abc12">
<a href="/ekaterinburg/kupit/kvartira/st-frezerovshchikov-12345/">улица Фрезеровщиков</a>, 84
</div>
<a href="/ekaterinburg/kupit/novostrojka/tatlin-1592987/">ЖК Татлин</a>
<img src="https://avatars.mds.yandex.net/get-realty/12345/app_snippet_small/test.jpg">
<img src="https://avatars.mds.yandex.net/get-realty/67890/app_snippet_small/test2.jpg">
<img src="https://other-domain.example/photo.jpg">
<div>36,8 м² · 1-комнатная квартира · 8 этаж из 9 · 4 399 000 ₽ · 119 566 ₽ за м²</div>
<div>торг возможен · опубликовано 17 февраля 2026</div>
</div>
</body></html>
"""
STUDIO_CARD_HTML = """
<div data-test="OffersSerpItem">
<a href="/offer/9999999/">x</a>
<div>Студия · 28 м² · 2 этаж из 9 · 3 200 000 ₽</div>
</div>
"""
NO_PRICE_CARD_HTML = """
<div data-test="OffersSerpItem">
<a href="/offer/1111/">x</a>
<div>40 м² · 1-комнатная · 3 этаж из 5</div>
</div>
"""
# House number with letter suffix (e.g. "2В") — real-world variant
LETTER_HOUSE_CARD_HTML = """
<div data-test="OffersSerpItem">
<a href="/offer/2222/">x</a>
<div class="AddressWithGeoLinks__addressContainer--xyz">
<a href="/ekaterinburg/kupit/kvartira/st-ulica-pekhotintsev-1/">улица Пехотинцев</a>, 2В
</div>
<div>40 м² · 1-комнатная · 3 этаж из 5 · 5 000 000 ₽</div>
</div>
"""
# House number with slash (e.g. "14/3") — corner building
SLASH_HOUSE_CARD_HTML = """
<div data-test="OffersSerpItem">
<a href="/offer/3333/">x</a>
<div class="AddressWithGeoLinks__addressContainer--xyz">
<a href="/ekaterinburg/kupit/kvartira/st-akademika-landau-2/">улица Академика Ландау</a>, 14/3
</div>
<div>50 м² · 2-комнатная · 4 этаж из 9 · 7 000 000 ₽</div>
</div>
"""
# District / city prefix before the street link (cards in suburbs or
# named micro-districts) — must keep the full prefixed address.
DISTRICT_PREFIX_CARD_HTML = """
<div data-test="OffersSerpItem">
<a href="/offer/4444/">x</a>
<div class="AddressWithGeoLinks__addressContainer--xyz">
<a href="/berezovskiy/kupit/kvartira/">Берёзовский</a>,
<a href="/berezovskiy/kupit/kvartira/st-aprospekt-1/">Александровский проспект</a>, 5А
</div>
<div>35 м² · 1-комнатная · 2 этаж из 12 · 4 500 000 ₽</div>
</div>
"""
# Legacy fallback case: no AddressWithGeoLinks container — must still
# capture street name via the aggregator link (street-only) to avoid NULL.
NO_ADDR_CONTAINER_CARD_HTML = """
<div data-test="OffersSerpItem">
<a href="/offer/5555/">x</a>
<a href="/ekaterinburg/kupit/kvartira/st-ulica-malysheva-3/">улица Малышева</a>
<div>30 м² · 1-комнатная · 1 этаж из 5 · 3 500 000 ₽</div>
</div>
"""
EMPTY_PAGE_HTML = "<html><body><div>Нет объявлений</div></body></html>"
def test_max_pages_default():
assert MAX_PAGES == 3
def test_default_city():
assert DEFAULT_CITY == "ekaterinburg"
def test_build_url_first_page():
s = YandexRealtyScraper()
assert s._build_url(page=0) == (
"https://realty.yandex.ru/ekaterinburg/kupit/kvartira/vtorichniy-rynok/"
)
def test_build_url_paginated():
s = YandexRealtyScraper()
assert s._build_url(page=2) == (
"https://realty.yandex.ru/ekaterinburg/kupit/kvartira/vtorichniy-rynok/?page=2"
)
def test_build_url_other_city():
s = YandexRealtyScraper(city="moscow")
assert s._build_url(page=0) == (
"https://realty.yandex.ru/moscow/kupit/kvartira/vtorichniy-rynok/"
)
def test_parse_html_extracts_card():
s = YandexRealtyScraper()
lots = s._parse_html(SINGLE_CARD_HTML, page=0)
assert len(lots) == 1
lot = lots[0]
assert lot.source == "yandex"
assert lot.source_id == "7567094292504417257"
assert lot.source_url == "https://realty.yandex.ru/offer/7567094292504417257/"
# Address must include the house number (was "улица Фрезеровщиков" only — buggy)
assert lot.address == "улица Фрезеровщиков, 84"
assert any(ch.isdigit() for ch in lot.address)
assert lot.area_m2 == pytest.approx(36.8)
assert lot.rooms == 1
assert lot.floor == 8
assert lot.total_floors == 9
assert lot.price_rub == 4_399_000
assert lot.price_per_m2 == 119_566
assert lot.bargain_allowed is True
assert lot.listing_date == date(2026, 2, 17)
assert lot.house_source == "yandex_realty_nb"
assert lot.house_ext_id == "1592987"
assert lot.house_url == (
"https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/tatlin-1592987/"
)
assert lot.listing_segment == "vtorichka"
assert lot.lat is None and lot.lon is None # SERP has no coords
# Photos: 2 from yandex CDN, the third (other-domain) skipped
assert len(lot.photo_urls) == 2
assert all("avatars.mds.yandex" in u for u in lot.photo_urls)
# Size upgrade applied: app_snippet_small → main
assert all("app_snippet_small" not in u for u in lot.photo_urls)
assert all("main" in u for u in lot.photo_urls)
def test_parse_html_studio_rooms_zero():
s = YandexRealtyScraper()
lots = s._parse_html(STUDIO_CARD_HTML, page=0)
assert len(lots) == 1
assert lots[0].rooms == 0 # studio convention
assert lots[0].area_m2 == pytest.approx(28.0)
assert lots[0].floor == 2 and lots[0].total_floors == 9
def test_parse_html_no_price_skipped():
s = YandexRealtyScraper()
lots = s._parse_html(NO_PRICE_CARD_HTML, page=0)
assert lots == []
def test_parse_html_empty_page():
s = YandexRealtyScraper()
lots = s._parse_html(EMPTY_PAGE_HTML, page=0)
assert lots == []
def test_parse_html_multi_card():
multi = SINGLE_CARD_HTML + STUDIO_CARD_HTML
s = YandexRealtyScraper()
lots = s._parse_html(multi, page=1)
assert len(lots) == 2
assert {lot.source_id for lot in lots} == {"7567094292504417257", "9999999"}
assert all(lot.raw_payload["page"] == 1 for lot in lots)
def test_address_with_letter_house_number():
"""House numbers with letter suffix (e.g. '2В') must round-trip intact."""
s = YandexRealtyScraper()
lots = s._parse_html(LETTER_HOUSE_CARD_HTML, page=0)
assert len(lots) == 1
assert lots[0].address == "улица Пехотинцев, 2В"
def test_address_with_slash_house_number():
"""Corner-building numbers like '14/3' must be preserved verbatim."""
s = YandexRealtyScraper()
lots = s._parse_html(SLASH_HOUSE_CARD_HTML, page=0)
assert len(lots) == 1
assert lots[0].address == "улица Академика Ландау, 14/3"
def test_address_keeps_district_prefix():
"""Cards with district / city prefix must keep the full prefixed
address — the geocoder benefits from disambiguation."""
s = YandexRealtyScraper()
lots = s._parse_html(DISTRICT_PREFIX_CARD_HTML, page=0)
assert len(lots) == 1
assert lots[0].address == "Берёзовский, Александровский проспект, 5А"
def test_address_fallback_to_street_link():
"""When the AddressWithGeoLinks container is missing, fall back to the
street aggregator link (street-only) — better than NULL."""
s = YandexRealtyScraper()
lots = s._parse_html(NO_ADDR_CONTAINER_CARD_HTML, page=0)
assert len(lots) == 1
assert lots[0].address == "улица Малышева"
# ── PART A: area from embedded JSON state ────────────────────────────────────
def _make_yandex_state_html(offer_id: str, area: float, price_rub: int = 5_000_000) -> str:
"""Build minimal Yandex SERP HTML with:
- <script id="initial_state_script"> containing offer area in JSON state
- A DOM card where the text div has NO area (simulating pre-JS-hydration).
"""
state = {
"offers": [
{
"offerId": offer_id,
"totalArea": area,
}
]
}
state_json = json.dumps(state, ensure_ascii=False)
price_fmt = f"{price_rub:,}".replace(",", " ")
return f"""<html><head>
<script id="initial_state_script">{state_json}</script>
</head><body>
<div data-test="OffersSerpItem">
<a href="/offer/{offer_id}/">Открыть</a>
<div class="AddressWithGeoLinks__addressContainer--xyz">
<a href="/ekaterinburg/kupit/kvartira/st-test-1/">улица Тестовая</a>, 10
</div>
<div>{price_fmt} ₽</div>
</div>
</body></html>"""
def test_area_from_state_json_no_dom_text():
"""area_m2 extracted from embedded JSON state when DOM text has no area."""
html = _make_yandex_state_html(offer_id="1234567890", area=63.5)
s = YandexRealtyScraper()
lots = s._parse_html(html, page=0)
assert len(lots) == 1
assert lots[0].area_m2 == pytest.approx(63.5)
def test_area_from_state_json_multiple_offers():
"""State with multiple offers — each card gets its own area from state."""
state = {
"offers": [
{"offerId": "111", "totalArea": 42.0},
{"offerId": "222", "totalArea": 78.3},
]
}
state_json = json.dumps(state)
html = f"""<html><head>
<script id="initial_state_script">{state_json}</script>
</head><body>
<div data-test="OffersSerpItem">
<a href="/offer/111/">x</a>
<div>3 000 000 ₽</div>
</div>
<div data-test="OffersSerpItem">
<a href="/offer/222/">x</a>
<div>5 000 000 ₽</div>
</div>
</body></html>"""
s = YandexRealtyScraper()
lots = s._parse_html(html, page=0)
assert len(lots) == 2
areas = {lot.source_id: lot.area_m2 for lot in lots}
assert areas["111"] == pytest.approx(42.0)
assert areas["222"] == pytest.approx(78.3)
def test_area_dom_fallback_when_no_state():
"""When state script absent, area falls back to DOM regex (existing behavior)."""
# SINGLE_CARD_HTML has area in DOM text — state script absent
s = YandexRealtyScraper()
lots = s._parse_html(SINGLE_CARD_HTML, page=0)
assert len(lots) == 1
assert lots[0].area_m2 == pytest.approx(36.8)
def test_extract_offer_areas_from_state_nested_path():
"""State via pageData.offers path (SERP v2 shape)."""
state = {
"pageData": {
"offers": [
{"offerId": "999", "spaceTotal": 55.0},
]
}
}
html = '<script id="initial_state_script">' + json.dumps(state) + "</script>"
areas = _extract_offer_areas_from_state(html)
assert areas == {"999": 55.0}
def test_extract_offer_areas_area_dict_value():
"""area field as nested dict {'value': 48.0, 'unit': 'SQM'} (some Yandex responses)."""
state = {
"offers": [
{"offerId": "777", "area": {"value": 48.0, "unit": "SQM"}},
]
}
html = '<script id="initial_state_script">' + json.dumps(state) + "</script>"
areas = _extract_offer_areas_from_state(html)
assert areas == {"777": 48.0}
def test_extract_offer_areas_empty_when_no_script():
"""No state script → empty dict returned, no crash."""
html = "<html><body><p>no state here</p></body></html>"
areas = _extract_offer_areas_from_state(html)
assert areas == {}