gendesign/tradein-mvp/backend/tests/test_yandex_newbuilding.py
bot-backend 278dafba76
All checks were successful
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / changes (pull_request) Successful in 6s
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
fix(tradein): yandex newbuilding rating drift — "4.3из 5" без пробела (#974)
Валидационный yandex_newbuilding_sweep (run 80) заполнял ratings_count /
text_reviews_count / developer_name / lat-lon, но rating оставался NULL для всех
ЖК. Live-диагностика (прод-браузер, ЖК Татлин/Рио 2026-06-15): рейтинг рендерится
как «4.3из 5» / «4,5из 5» — БЕЗ пробела перед «из» (nbsp внутри «из 5»), а
RE_RATING требовал \s+ перед «из» → match=None.

- RE_RATING: (\d[.,]\d)\s+из\s+5 → (\d[.,]\d)\s*из\s+5 (пробел перед «из» опционален).
  \s уже матчит nbsp, поэтому «из\xa05» обрабатывался; ломал именно отсутствующий
  разделитель перед «из».
- test_parse_rating_no_space_before_iz: регрессия на обе дрейфовые формы.

Существующие rating-тесты («4,3 из 5» с пробелом) остаются зелёными (\s* ⊇ \s+).

Refs #974
2026-06-15 21:29:43 +03:00

441 lines
16 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 yandex_newbuilding.py — ЖК landing parser (Worker B, Wave 4).
Reference target: ЖК Татлин (slug=tatlin, id=1592987) — comfort+, June 2023,
PRINZIP developer, rating 4.3, 1505 ratings, 353 text reviews,
coords (56.855312, 60.576668).
All tests are offline — no network calls. Parser receives hand-crafted fixture HTML.
"""
from __future__ import annotations
import pytest
from app.services.scrapers.yandex_newbuilding import (
JKMetroStation,
YandexNewbuildingInfo,
YandexNewbuildingScraper,
_extract_coords,
_find_section_text,
_parse_corpus_count,
_parse_metro,
)
# ── Fixture HTML ──────────────────────────────────────────────────────────────
TATLIN_FIXTURE_HTML = """<!DOCTYPE html>
<html>
<head><title>ЖК «Татлин»</title></head>
<body>
<h1>ЖК «Татлин»</h1>
<div>Екатеринбург, ул. Черепанова / ул. Готвальда</div>
<a data-test="CARD_DEV_BADGE_DEVELOPER_LINK" href="/developer/prinzip">PRINZIP</a>
<div data-test="CardDevSites">
<a href="/zhk/1">ЖК Кислород</a>
<a href="/zhk/2">ЖК Нова</a>
</div>
<div>56.855312, 60.576668</div>
<div>Уральская 11 мин. Динамо 16 мин.</div>
<div>4.3 из 5 1505 оценок Смотреть все 353 отзыва</div>
<h2>О комплексе</h2>
<p>ЖК «Татлин» — три 35-этажные башни в Заречном микрорайоне Екатеринбурга.
Комплекс класса комфорт+. Введён в эксплуатацию в июне 2023 года.
Расположен на участке 1,5 га. Монолитный тип дома.</p>
<h2>Расположение</h2>
<p>В 5 минутах от центра</p>
</body>
</html>"""
# Minimal fixture — missing optional sections
MINIMAL_FIXTURE_HTML = """<!DOCTYPE html>
<html><body>
<h1>ЖК Минимальный</h1>
</body></html>"""
# ── Full happy-path test ──────────────────────────────────────────────────────
def test_parse_jk_full_fixture():
"""Happy path: all major fields extracted from Татлин-like HTML."""
scraper = YandexNewbuildingScraper()
info = scraper.parse(
TATLIN_FIXTURE_HTML,
jk_slug="tatlin",
jk_id="1592987",
source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/tatlin-1592987/",
)
assert isinstance(info, YandexNewbuildingInfo)
assert info.ext_id == "1592987"
assert info.ext_slug == "tatlin"
assert "Татлин" in (info.name or "")
# coords
assert info.lat == pytest.approx(56.855312, abs=1e-5)
assert info.lon == pytest.approx(60.576668, abs=1e-5)
# class + commission
assert info.house_class == "comfort_plus"
assert info.commission_year == 2023
assert info.commission_month == "июне"
# footprint
assert info.total_floors == 35
assert info.corpus_count == 3
assert info.total_area_ha == pytest.approx(1.5, abs=0.01)
# developer
assert info.developer_name == "PRINZIP"
assert info.developer_url is not None and "prinzip" in info.developer_url
assert "ЖК Кислород" in info.developer_other_jk
assert "ЖК Нова" in info.developer_other_jk
# reviews
assert info.rating == pytest.approx(4.3, abs=0.01)
assert info.ratings_count == 1505
assert info.text_reviews_count == 353
# description present
assert info.description is not None
assert len(info.description) > 10
# metro
assert len(info.metro_stations) >= 1
station_names = [s.name for s in info.metro_stations]
assert any("Уральская" in n for n in station_names)
# house type from "монолитный"
assert info.house_type == "monolith"
# raw_payload
assert info.raw_payload is not None
assert "body_len" in info.raw_payload
# ── Coord extraction tests ────────────────────────────────────────────────────
def test_extract_coords_within_ekb_range():
html = "<div>56.855312, 60.576668</div>"
lat, lon = _extract_coords(html)
assert lat == pytest.approx(56.855312, abs=1e-5)
assert lon == pytest.approx(60.576668, abs=1e-5)
def test_extract_coords_outside_range_returns_none():
# Moscow coords — outside EKB range
html = "<div>55.7558, 37.6176</div>"
_lat, lon = _extract_coords(html)
# 55.75 is in LAT_RANGE but 37.61 is NOT in LON_RANGE
assert lon is None
def test_extract_coords_no_coords_returns_none():
html = "<div>Текст без координат</div>"
lat, lon = _extract_coords(html)
assert lat is None
assert lon is None
def test_extract_coords_multiple_takes_first():
"""When multiple valid coords present, first is returned."""
html = "<div>56.855312, 60.576668</div><div>56.900000, 60.600000</div>"
lat, lon = _extract_coords(html)
assert lat == pytest.approx(56.855312, abs=1e-5)
assert lon == pytest.approx(60.576668, abs=1e-5)
# ── Corpus count tests ────────────────────────────────────────────────────────
def test_parse_corpus_count_word_three():
text = "три 35-этажные башни в центре"
result = _parse_corpus_count(text)
assert result == 3
def test_parse_corpus_count_digit():
text = "5 35-этажных корпусов"
result = _parse_corpus_count(text)
assert result == 5
def test_parse_corpus_count_two():
text = "две 20-этажные башни"
result = _parse_corpus_count(text)
assert result == 2
def test_parse_corpus_count_none_when_no_match():
text = "Обычный текст без этажей"
result = _parse_corpus_count(text)
assert result is None
# ── House class test ──────────────────────────────────────────────────────────
def test_parse_house_class_comfort_plus_from_description():
from app.services.scrapers.yandex_helpers import parse_house_class
text = "Жилой комплекс класса комфорт+ в центре города"
result = parse_house_class(text)
assert result == "comfort_plus"
def test_parse_house_class_business():
from app.services.scrapers.yandex_helpers import parse_house_class
text = "ЖК класса бизнес"
result = parse_house_class(text)
assert result == "business"
# ── Commission year/month test ────────────────────────────────────────────────
def test_parse_commission_year_month():
"""Full pipeline: parse() correctly extracts commission_year and commission_month."""
scraper = YandexNewbuildingScraper()
info = scraper.parse(
TATLIN_FIXTURE_HTML,
jk_slug="tatlin",
jk_id="1592987",
source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/tatlin-1592987/",
)
assert info.commission_year == 2023
assert info.commission_month == "июне"
def test_parse_commission_sdan_variant():
"""Regex supports 'сдан в эксплуатацию' phrasing as well."""
html = """<html><body>
<h1>ЖК Тест</h1>
<h2>О комплексе</h2>
<p>Сдан в эксплуатацию в марте 2025 года.</p>
</body></html>"""
scraper = YandexNewbuildingScraper()
info = scraper.parse(html, jk_slug="test", jk_id="999", source_url="http://x")
assert info.commission_year == 2025
assert info.commission_month == "марте"
# ── Rating + counts test ──────────────────────────────────────────────────────
def test_parse_rating_and_counts():
scraper = YandexNewbuildingScraper()
info = scraper.parse(
TATLIN_FIXTURE_HTML,
jk_slug="tatlin",
jk_id="1592987",
source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/tatlin-1592987/",
)
assert info.rating == pytest.approx(4.3, abs=0.01)
assert info.ratings_count == 1505
assert info.text_reviews_count == 353
def test_parse_rating_comma_separator():
"""Rating '4,3 из 5' with comma handled correctly."""
html = "<html><body><div>4,3 из 5 200 оценок</div></body></html>"
scraper = YandexNewbuildingScraper()
info = scraper.parse(html, jk_slug="x", jk_id="1", source_url="http://x")
assert info.rating == pytest.approx(4.3, abs=0.01)
assert info.ratings_count == 200
def test_parse_rating_no_space_before_iz():
"""Yandex drift (#974): '4.3из 5' / '4,5из\xa05' — без пробела перед «из», nbsp внутри.
Регрессия 2026-06-15: live-страницы рендерили рейтинг как «4.3из 5» (без
разделителя), из-за чего \\s+ перед «из» не матчил → rating=NULL при
заполненных ratings_count. \\s* должен извлечь рейтинг в обеих формах.
"""
scraper = YandexNewbuildingScraper()
info = scraper.parse(
"<html><body><div>Отзывы4.3из 5Всего 1508 оценок</div></body></html>",
jk_slug="x",
jk_id="1",
source_url="http://x",
)
assert info.rating == pytest.approx(4.3, abs=0.01)
assert info.ratings_count == 1508
# nbsp-вариант (как в реальном body_text)
info2 = scraper.parse(
"<html><body><div>4,5из\xa05Всего 850 оценок</div></body></html>",
jk_slug="y",
jk_id="2",
source_url="http://y",
)
assert info2.rating == pytest.approx(4.5, abs=0.01)
# ── Developer link test ───────────────────────────────────────────────────────
def test_developer_name_extracted_from_data_test_link():
scraper = YandexNewbuildingScraper()
info = scraper.parse(
TATLIN_FIXTURE_HTML,
jk_slug="tatlin",
jk_id="1592987",
source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/tatlin-1592987/",
)
assert info.developer_name == "PRINZIP"
assert info.developer_url == "https://realty.yandex.ru/developer/prinzip"
def test_developer_absolute_url_kept_as_is():
html = """<html><body>
<a data-test="CARD_DEV_BADGE_DEVELOPER_LINK"
href="https://realty.yandex.ru/developer/abc">АБС Групп</a>
</body></html>"""
scraper = YandexNewbuildingScraper()
info = scraper.parse(html, jk_slug="x", jk_id="1", source_url="http://x")
assert info.developer_name == "АБС Групп"
assert info.developer_url == "https://realty.yandex.ru/developer/abc"
# ── Metro stations test ───────────────────────────────────────────────────────
def test_metro_stations_parsed():
scraper = YandexNewbuildingScraper()
info = scraper.parse(
TATLIN_FIXTURE_HTML,
jk_slug="tatlin",
jk_id="1592987",
source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/tatlin-1592987/",
)
assert len(info.metro_stations) >= 1
uralskaya = next((s for s in info.metro_stations if "Уральская" in s.name), None)
assert uralskaya is not None
assert uralskaya.walk_min == 11
def test_parse_metro_direct():
text = "Уральская 11 мин. Динамо 16 мин."
stations = _parse_metro(text)
assert len(stations) == 2
assert stations[0].name == "Уральская"
assert stations[0].walk_min == 11
assert stations[1].name == "Динамо"
assert stations[1].walk_min == 16
def test_parse_metro_caps_at_five():
text = "Альфа 5 мин. Бета 6 мин. Гамма 7 мин. " "Дельта 8 мин. Эпсилон 9 мин. Зета 10 мин."
stations = _parse_metro(text)
assert len(stations) == 5
# ── No description section test ───────────────────────────────────────────────
def test_no_description_section_returns_none():
"""When no О комплексе / Расположение section, description is None."""
scraper = YandexNewbuildingScraper()
info = scraper.parse(
MINIMAL_FIXTURE_HTML,
jk_slug="minimal",
jk_id="42",
source_url="http://example.com/",
)
assert info.description is None
assert info.house_class is None
assert info.commission_year is None
assert info.total_floors is None
# ── fetch_around raises NotImplementedError ───────────────────────────────────
@pytest.mark.asyncio
async def test_fetch_around_raises_not_implemented():
scraper = YandexNewbuildingScraper()
with pytest.raises(NotImplementedError, match="JK-slug-based"):
await scraper.fetch_around(56.855, 60.576)
# ── _find_section_text helper ─────────────────────────────────────────────────
def test_find_section_text_returns_content_after_heading():
from selectolax.parser import HTMLParser
html = """<html><body>
<h2>О комплексе</h2>
<p>Описание комплекса здесь.</p>
<p>Ещё абзац.</p>
<h2>Другой раздел</h2>
<p>Не должен попасть.</p>
</body></html>"""
tree = HTMLParser(html)
result = _find_section_text(tree, "О комплексе")
assert result is not None
assert "Описание комплекса" in result
assert "Не должен" not in result
def test_find_section_text_returns_none_when_missing():
from selectolax.parser import HTMLParser
html = "<html><body><p>Текст</p></body></html>"
tree = HTMLParser(html)
result = _find_section_text(tree, "О комплексе")
assert result is None
# ── Developer other JK dedup ──────────────────────────────────────────────────
def test_developer_other_jk_dedup():
"""Duplicate JK names are not added twice."""
html = """<html><body>
<div data-test="CardDevSites">
<a href="/1">ЖК Один</a>
<a href="/2">ЖК Один</a>
<a href="/3">ЖК Два</a>
</div>
</body></html>"""
scraper = YandexNewbuildingScraper()
info = scraper.parse(html, jk_slug="x", jk_id="1", source_url="http://x")
assert info.developer_other_jk.count("ЖК Один") == 1
assert "ЖК Два" in info.developer_other_jk
def test_developer_other_jk_capped_at_10():
"""Only first 10 other JK entries are kept."""
links = "\n".join(f'<a href="/{i}">ЖК {i}</a>' for i in range(15))
html = f"""<html><body>
<div data-test="CardDevSites">{links}</div>
</body></html>"""
scraper = YandexNewbuildingScraper()
info = scraper.parse(html, jk_slug="x", jk_id="1", source_url="http://x")
assert len(info.developer_other_jk) == 10
# ── model defaults ────────────────────────────────────────────────────────────
def test_yandex_newbuilding_info_defaults():
"""Model initialises with safe defaults for all optional fields."""
info = YandexNewbuildingInfo(ext_id="1", ext_slug="x", source_url="http://x")
assert info.name is None
assert info.lat is None
assert info.lon is None
assert info.house_class is None
assert info.developer_other_jk == []
assert info.metro_stations == []
assert info.raw_payload is None
def test_jk_metro_station_model():
station = JKMetroStation(name="Уральская", walk_min=11)
assert station.name == "Уральская"
assert station.walk_min == 11
station_no_time = JKMetroStation(name="Геологическая")
assert station_no_time.walk_min is None