gendesign/tradein-mvp/backend/tests/test_scraper_kit_domclick_golden_parity.py
lekss361 b7abaeb569
Some checks failed
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m28s
Deploy Trade-In / build-backend (push) Successful in 1m24s
Deploy Trade-In / deploy (push) Has been cancelled
feat(scraper-kit): copy domclick provider with protocol injection, strangler (#2133 domclick)
2026-07-02 16:08:09 +00:00

183 lines
8.8 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.

"""Golden-parity: `scraper_kit.providers.domclick.serp` ≡ `app.services.scrapers.domclick`.
Strangler-инвариант (#2133): новая scraper_kit-копия domclick-провайдера должна
давать БАЙТ-ИДЕНТИЧНЫЙ результат парсинга старому боевому коду на одинаковом входе.
Развязка (get_scraper_delay→delay_provider, base/exceptions/repair-normalizer→
scraper_kit.*) НЕ меняет распарсенные данные.
Гоняем ОБА модуля на одной BFF-JSON-фикстуре и сравниваем:
- `_extract_json` (JSON из HTML-обёртки + QRATOR-block detection);
- `_map_item` (offer → ScrapedLot): студия rooms=0, sentinel 0→None, agency;
- `_is_geo_ok` (bbox + offerRegionName guard);
- url-билдеры и чистые хелперы (_build_offers_url/_build_count_url/
_parse_publish_date/_extract_agency_name).
Идентичность результата = kit-парсер верно скопирован (развязка не задела логику).
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import patch
# Старый app.services.scrapers.domclick тянет app.core.config.settings=Settings(),
# которому нужен DATABASE_URL. Офлайн-парсинг БД не трогает — фиктивный DSN достаточен.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
# НОВЫЙ (scraper_kit) — ruff сортирует scraper_kit (first-party) выше app-импортов.
from scraper_kit.providers.domclick.serp import DomClickScraper as NewDomClickScraper
from scraper_kit.providers.domclick.serp import _build_count_url as new_build_count_url
from scraper_kit.providers.domclick.serp import _build_offers_url as new_build_offers_url
from scraper_kit.providers.domclick.serp import _extract_agency_name as new_extract_agency
from scraper_kit.providers.domclick.serp import _extract_json as new_extract_json
from scraper_kit.providers.domclick.serp import _parse_publish_date as new_parse_publish_date
from app.services.scrapers.domclick import DomClickScraper as OldDomClickScraper
from app.services.scrapers.domclick import _build_count_url as old_build_count_url
from app.services.scrapers.domclick import _build_offers_url as old_build_offers_url
from app.services.scrapers.domclick import _extract_agency_name as old_extract_agency
from app.services.scrapers.domclick import _extract_json as old_extract_json
from app.services.scrapers.domclick import _parse_publish_date as old_parse_publish_date
_FIXTURES = Path(__file__).parent / "fixtures"
_BFF_JSON = (_FIXTURES / "domclick_bff_offers_sample.json").read_text(encoding="utf-8")
_BFF_ITEMS: list[dict[str, Any]] = json.loads(_BFF_JSON)["result"]["items"]
def _make_old() -> OldDomClickScraper:
"""Старый DomClickScraper без DB: patch get_scraper_delay."""
with patch("app.services.scrapers.domclick.get_scraper_delay", return_value=8.0):
return OldDomClickScraper()
def _make_new() -> NewDomClickScraper:
"""Новый DomClickScraper с инжектируемым ScraperConfig + delay_provider.
Парсинг-тесты не дёргают fetch_city → browser_http_endpoint не используется,
но конструктор требует config (duck-typed namespace достаточен).
"""
config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000")
return NewDomClickScraper(config, delay_provider=lambda _name: 8.0)
# ── _extract_json parity ──────────────────────────────────────────────────────
def test_extract_json_parity_bare() -> None:
"""_extract_json на bare JSON-теле идентичен."""
assert old_extract_json(_BFF_JSON) == new_extract_json(_BFF_JSON)
def test_extract_json_parity_pre_wrapped() -> None:
"""_extract_json на <pre>-обёрнутом JSON идентичен (реальная форма ответа)."""
wrapped = f"<html><body><pre>{_BFF_JSON}</pre></body></html>"
assert old_extract_json(wrapped) == new_extract_json(wrapped)
def test_extract_json_parity_qrator_block() -> None:
"""QRATOR block-страница → DomClickBlockedError в обоих (идентичное поведение)."""
from scraper_kit.domclick_exceptions import DomClickBlockedError as NewBlocked
from app.services.scrapers.domclick_exceptions import DomClickBlockedError as OldBlocked
block_html = "<html><body>Система защиты QRATOR: captcha</body></html>"
old_raised = new_raised = False
try:
old_extract_json(block_html)
except OldBlocked:
old_raised = True
try:
new_extract_json(block_html)
except NewBlocked:
new_raised = True
assert old_raised and new_raised
# ── _map_item parity (offer → ScrapedLot) ─────────────────────────────────────
def test_map_item_parity_all_items() -> None:
"""Каждый offer-item → ScrapedLot идентичен (студия rooms=0, sentinel 0→None)."""
old = _make_old()
new = _make_new()
for item in _BFF_ITEMS:
# force_rooms=0 для студий (st-бакет) — проверяем обе ветки маппинга.
for force_rooms in (None, 0):
old_lot = old._map_item(item, force_rooms=force_rooms)
new_lot = new._map_item(item, force_rooms=force_rooms)
if old_lot is None or new_lot is None:
assert old_lot is None and new_lot is None
continue
assert old_lot.model_dump() == new_lot.model_dump()
# Счётчик parse-ошибок эволюционирует одинаково.
assert old.parse_failures == new.parse_failures
# ── _is_geo_ok parity (bbox + region guard) ───────────────────────────────────
def test_is_geo_ok_parity_all_items() -> None:
"""Гео-гард (offerRegionName + bbox) идентичен на всех items."""
old = _make_old()
new = _make_new()
for item in _BFF_ITEMS:
assert old._is_geo_ok(item) == new._is_geo_ok(item)
# ── url-билдеры + чистые хелперы ──────────────────────────────────────────────
def test_url_builders_parity() -> None:
"""_build_offers_url / _build_count_url идентичны (вкл. '5+''5%2B')."""
cases: list[tuple[str, int | None, int | None]] = [
("st", None, None),
("2", 3_000_000, 8_000_000),
("5+", 15_000_000, None),
("1", None, 5_000_000),
]
for rooms, gte, lte in cases:
assert old_build_count_url(rooms, gte, lte) == new_build_count_url(rooms, gte, lte)
for offset in (0, 20, 1980):
assert old_build_offers_url(rooms, gte, lte, offset) == new_build_offers_url(
rooms, gte, lte, offset
)
def test_parse_publish_date_parity() -> None:
"""_parse_publish_date идентичен на валидных/битых ISO-строках."""
for iso in [None, "", "2026-06-28T09:15:00+05:00", "2026-06-27", "not-a-date", "2026-13-40"]:
assert old_parse_publish_date(iso) == new_parse_publish_date(iso)
def test_extract_agency_name_parity() -> None:
"""_extract_agency_name идентичен на строковых/dict/пустых seller-блоках."""
sellers: list[dict[str, Any]] = [
{},
{"company": "АН Этажи", "agent": "Иванов"},
{"company": " ", "agent": {"name": "Резерв"}},
{"agent": {"fullName": "Петров П."}},
{"company": {"title": "СберРиелт"}},
{"agent": 12345},
]
for seller in sellers:
assert old_extract_agency(seller) == new_extract_agency(seller)
# ── strangler-guard: kit-провайдер не импортирует app.* ───────────────────────
def test_kit_domclick_has_no_app_imports() -> None:
"""Ни один модуль scraper_kit.providers.domclick.* не импортирует app.* (развязка)."""
import inspect
from scraper_kit.providers.domclick import serp
source = inspect.getsource(serp)
for line in source.splitlines():
stripped = line.strip()
assert not stripped.startswith("from app"), f"serp: {line!r}"
assert not stripped.startswith("import app"), f"serp: {line!r}"