All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 14m49s
domrf_kn_flat_plans было пусто (0 строк) — блокер для #299 (Potrace production-wiring) и #300 (CubiCasa). domrf_catalog.py уже фетчит SSR- страницы каталога квартир, где лежит планировка, но _TextCollector сознательно дропал <img> как void-tag (#1608) — картинка никогда не извлекалась. parse_catalog_flat теперь возвращает plan_image_url: primary-стратегия парсит __NEXT_DATA__ pageProps JSON (Next.js SSR-паттерн), fallback — голый <img> по class-hint/соседству с текстом "Планировка". _TextCollector получает аддитивный self.images-сбор ДО void-tag early-return — не трогает существующий stack/buffer, регрессия по #1608 исключена (тест test_img_capture_preserves_text_blocks). download_plan_image_stub (NotImplementedError) заменён на рабочую async-загрузку через существующий BrowserSession.download_binary (те же cookies/TLS/throttle), Pillow для width/height, идемпотентна (skip при уже существующем файле). Двухфазный upsert: URL пишется первым, затем метаданные после скачивания — частичный прогресс переживает падение загрузки. Не заводит новый Celery beat — не подключено ни в один task (домрана предпосылка issue #2440: scrape_one_flat/scrape_catalog_batch сейчас нигде не вызываются). Ships inert до отдельного follow-up по wiring. Refs #2440, #299, #300
211 lines
9 KiB
Python
211 lines
9 KiB
Python
"""Тесты для extraction plan_image_url из страницы квартиры DOM.РФ (issue #2440).
|
||
|
||
Покрывает:
|
||
- __NEXT_DATA__ pageProps (primary): URL плана под plan-hint ключом.
|
||
- bare <img> fallback: plan-hint в атрибутах / proximity к «Планировка».
|
||
- нормализация URL (относительный → absolute; data:-inline / шум отбрасываются).
|
||
- интеграция с parse_catalog_flat: ключ plan_image_url в результате.
|
||
- _TextCollector НЕ ломает text-extraction при наличии <img> (регрессия #1608).
|
||
|
||
Фикстуры инлайновые (минимальный реалистичный HTML), без live-fetch — DOM.РФ
|
||
каталог-квартир это Next.js SSR (тот же стек что страница объекта в
|
||
domrf_catalog_object.py, где план живёт в __NEXT_DATA__, а не в <img src>).
|
||
"""
|
||
|
||
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 (
|
||
"<!doctype html><html><head>"
|
||
f'<script id="__NEXT_DATA__" type="application/json">{blob}</script>'
|
||
"</head><body>"
|
||
"<h1>Квартира 2-комнатная</h1>"
|
||
"<div>4 500 000 ₽</div>"
|
||
"</body></html>"
|
||
)
|
||
|
||
|
||
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 <img> fallback ───────────────────────────────────────────────────────
|
||
|
||
|
||
def test_plan_from_img_class_hint() -> None:
|
||
"""Нет __NEXT_DATA__: <img class='flat-plan__image'> → извлекается по class."""
|
||
html = (
|
||
"<!doctype html><html><body>"
|
||
f'<img class="flat-plan__image" alt="" src="{PLAN_ABS_URL}">'
|
||
"</body></html>"
|
||
)
|
||
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
|
||
|
||
|
||
def test_plan_from_img_src_hint() -> None:
|
||
"""<img> без plan-класса, но src содержит 'plan' → извлекается."""
|
||
html = f'<!doctype html><html><body><img src="{PLAN_ABS_URL}"></body></html>'
|
||
assert extract_plan_image_url(html, _collector_for(html)) == PLAN_ABS_URL
|
||
|
||
|
||
def test_plan_from_img_proximity_to_label() -> None:
|
||
"""<img> без plan-hint в атрибутах, но рядом с блоком «Планировка»."""
|
||
plain = f"{BASE_URL}/api/ext/file/imgabc.png"
|
||
html = (
|
||
"<!doctype html><html><body>"
|
||
"<div>Планировка</div>"
|
||
f'<img alt="" src="{plain}">'
|
||
"</body></html>"
|
||
)
|
||
got = extract_plan_image_url(html, _collector_for(html))
|
||
assert got == plain
|
||
|
||
|
||
def test_plan_from_img_skips_logo_and_captcha() -> None:
|
||
"""Лого/captcha <img> игнорируются, даже если стоят первыми."""
|
||
html = (
|
||
"<!doctype html><html><body>"
|
||
f'<img class="header-logo" src="{BASE_URL}/api/ext/file/logo.png">'
|
||
'<img class="robot" src="data:image/jpeg;base64,AAAA">'
|
||
"<div>Планировка квартиры</div>"
|
||
f'<img class="plan-view" src="{PLAN_ABS_URL}">'
|
||
"</body></html>"
|
||
)
|
||
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 = (
|
||
"<!doctype html><html><body>"
|
||
f'<img class="header-logo" src="{BASE_URL}/api/ext/file/logo.png">'
|
||
"<p>Площадь 45 м²</p>"
|
||
"</body></html>"
|
||
)
|
||
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 = "<!doctype html><html><body><p>Площадь 45 м²</p></body></html>"
|
||
result = parse_catalog_flat(html)
|
||
assert "plan_image_url" not in result
|
||
|
||
|
||
# ── regression: <img> capture must not break text-extraction (#1608) ──────────
|
||
|
||
|
||
def test_img_capture_preserves_text_blocks() -> None:
|
||
"""Наличие <img> (void-тег) не рассинхронизирует стек — текст блоков цел."""
|
||
html = (
|
||
"<!doctype html><html><body>"
|
||
'<div class="price-block">4 500 000 ₽</div>'
|
||
f'<img src="{PLAN_ABS_URL}">'
|
||
'<div class="status-badge">Продана</div>'
|
||
"</body></html>"
|
||
)
|
||
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
|