"""Тесты для 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