"""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 на
-обёрнутом JSON идентичен (реальная форма ответа)."""
    wrapped = f"
{_BFF_JSON}
" 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 = "Система защиты QRATOR: captcha" 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}"