"""Тесты для extraction plan_image_url из страницы квартиры DOM.РФ (issue #2440). Покрывает: - __NEXT_DATA__ pageProps (primary): URL плана под plan-hint ключом. - bare fallback: plan-hint в атрибутах / proximity к «Планировка». - нормализация URL (относительный → absolute; data:-inline / шум отбрасываются). - интеграция с parse_catalog_flat: ключ plan_image_url в результате. - _TextCollector НЕ ломает text-extraction при наличии (регрессия #1608). Фикстуры инлайновые (минимальный реалистичный HTML), без live-fetch — DOM.РФ каталог-квартир это Next.js SSR (тот же стек что страница объекта в domrf_catalog_object.py, где план живёт в __NEXT_DATA__, а не в ). """ from __future__ import annotations import json import pytest from app.services.scrapers.domrf_catalog import ( _normalize_plan_url, _TextCollector, extract_plan_image_url, parse_catalog_flat, ) from app.services.scrapers.stealth import BASE_URL # Абсолютный file-API URL DOM.РФ (реальный паттерн, см. documents.py:17). PLAN_ABS_URL = f"{BASE_URL}/api/ext/file/plan-12345.png" # Относительный next/image URL (Next.js image optimizer). PLAN_REL_NEXT = "/_next/image?url=%2Fapi%2Fext%2Ffile%2Fplan-777.png&w=1200&q=75" def _html_with_next_data(page_props: dict) -> str: """SSR HTML с __NEXT_DATA__ блоком, содержащим pageProps.""" blob = json.dumps({"props": {"pageProps": page_props}}) return ( "" f'' "" "

Квартира 2-комнатная

" "
4 500 000 ₽
" "" ) def _collector_for(html: str) -> _TextCollector: c = _TextCollector() c.feed(html) return c # ── __NEXT_DATA__ primary path ──────────────────────────────────────────────── def test_plan_from_next_data_plan_image_key() -> None: """URL под ключом planImage в pageProps → извлекается (absolute).""" html = _html_with_next_data({"planImage": PLAN_ABS_URL, "buildingClass": "Комфорт"}) assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL def test_plan_from_next_data_layout_key_nested() -> None: """URL под вложенным ключом layoutImageUrl → извлекается (BFS обход).""" html = _html_with_next_data( {"flat": {"info": {"layoutImageUrl": PLAN_ABS_URL}}, "price": 4_500_000} ) assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL def test_plan_from_next_data_relative_url_normalized() -> None: """Относительный next/image URL → абсолютный через BASE_URL.""" html = _html_with_next_data({"floorPlanUrl": PLAN_REL_NEXT}) got = extract_plan_image_url(html, _collector_for(html)) assert got is not None assert got.startswith(BASE_URL) assert "plan-777" in got def test_plan_from_next_data_ignores_non_plan_keys() -> None: """Картинка под НЕ-plan ключом (developerLogo) не должна ловиться.""" html = _html_with_next_data( {"developerLogo": f"{BASE_URL}/api/ext/file/logo.png", "buildingClass": "Бизнес"} ) assert extract_plan_image_url(html, _collector_for(html)) is None def test_plan_from_next_data_ignores_noise_url() -> None: """plan-ключ, но URL содержит logo/thumb (шум) → отбрасывается.""" html = _html_with_next_data({"planThumb": f"{BASE_URL}/api/ext/file/plan-thumb.png"}) # 'thumb' в URL → _IMAGE_NOISE_RE → None assert extract_plan_image_url(html, _collector_for(html)) is None # ── bare fallback ─────────────────────────────────────────────────────── def test_plan_from_img_class_hint() -> None: """Нет __NEXT_DATA__: → извлекается по class.""" html = ( "" f'' "" ) assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL def test_plan_from_img_src_hint() -> None: """ без plan-класса, но src содержит 'plan' → извлекается.""" html = f'' assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL def test_plan_from_img_proximity_to_label() -> None: """ без plan-hint в атрибутах, но рядом с блоком «Планировка».""" plain = f"{BASE_URL}/api/ext/file/imgabc.png" html = ( "" "
Планировка
" f'' "" ) got = extract_plan_image_url(html, _collector_for(html)) assert got == plain def test_plan_from_img_skips_logo_and_captcha() -> None: """Лого/captcha игнорируются, даже если стоят первыми.""" html = ( "" f'' '' "
Планировка квартиры
" f'' "" ) assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL def test_no_plan_returns_none() -> None: """Нет ни __NEXT_DATA__ плана, ни plan-img → None.""" html = ( "" f'' "

Площадь 45 м²

" "" ) assert extract_plan_image_url(html, _collector_for(html)) is None # ── URL normalization ───────────────────────────────────────────────────────── @pytest.mark.parametrize( "raw,expected_prefix", [ (PLAN_ABS_URL, PLAN_ABS_URL), ("/api/ext/file/plan.png", f"{BASE_URL}/api/ext/file/plan.png"), ("//cdn.example/plan.png", "https://cdn.example/plan.png"), ], ) def test_normalize_plan_url_variants(raw: str, expected_prefix: str) -> None: assert _normalize_plan_url(raw) == expected_prefix @pytest.mark.parametrize("raw", ["", None, "data:image/png;base64,AAAA", " "]) def test_normalize_plan_url_rejects_junk(raw: str | None) -> None: assert _normalize_plan_url(raw) is None # ── integration: parse_catalog_flat ─────────────────────────────────────────── def test_parse_catalog_flat_includes_plan_image_url() -> None: """parse_catalog_flat кладёт plan_image_url в результат при наличии плана.""" html = _html_with_next_data({"planImage": PLAN_ABS_URL, "price": 4_500_000}) result = parse_catalog_flat(html) assert result.get("plan_image_url") == PLAN_ABS_URL def test_parse_catalog_flat_omits_plan_when_absent() -> None: """Нет плана → ключ plan_image_url отсутствует (а не None).""" html = "

Площадь 45 м²

" result = parse_catalog_flat(html) assert "plan_image_url" not in result # ── regression: capture must not break text-extraction (#1608) ────────── def test_img_capture_preserves_text_blocks() -> None: """Наличие (void-тег) не рассинхронизирует стек — текст блоков цел.""" html = ( "" '
4 500 000 ₽
' f'' '
Продана
' "" ) collector = _collector_for(html) texts = [t for _cls, t in collector.blocks] assert any("4 500 000" in t for t in texts) assert any("Продана" in t for t in texts) # и картинка захвачена в отдельный список assert len(collector.images) == 1 assert collector.images[0][0].get("src") == PLAN_ABS_URL # parse_catalog_flat всё ещё корректно извлекает status/price при наличии img result = parse_catalog_flat(html) assert result.get("status") == "sold" assert result.get("price_rub") == 4_500_000