"""Golden-parity: `scraper_kit.providers.avito.*` парсинг ≡ `app.services.scrapers.avito*`. Strangler-инвариант (#2133): новая scraper_kit-копия avito-провайдера должна давать БАЙТ-ИДЕНТИЧНЫЙ результат парсинга старому боевому коду на одинаковом входе. Развязка (settings→ScraperConfig, get_scraper_delay→delay_provider, geocoder→scraper_kit.geo, matcher→HouseMatcher) НЕ меняет распарсенные данные. Гоняем ОБА модуля на одних фикстурах и сравниваем результат: - SERP `_parse_html` (DOM-карточки → list[ScrapedLot]) на avito_serp_sample.html, при ekb_only=False и ekb_only=True (фильтр padding-карточек); - `_extract_total_count` (счётчик выдачи); - `_build_sort_timestamp_map` + `_unix_to_date` (sortTimeStamp → date, MSK); - detail `parse_detail_html` (item-view HTML → DetailEnrichment); - чистые title/address-хелперы (_clean_address, _extract_rooms/area/floor_from_title). Идентичность результата = kit-парсер верно скопирован (развязка не задела логику). """ from __future__ import annotations import dataclasses import os from pathlib import Path from types import SimpleNamespace from typing import Any from unittest.mock import patch # Старый app.services.scrapers.avito импортирует app.core.config.settings=Settings(), # которому нужен DATABASE_URL. Офлайн-парсинг БД не трогает — фиктивный DSN достаточен. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") # НОВЫЙ (scraper_kit) и СТАРЫЙ (app.services.scrapers) avito-парсеры — оба в одном # тесте; ruff сортирует scraper_kit (first-party) выше app-импортов. from scraper_kit.providers.avito.detail import parse_detail_html as new_parse_detail from scraper_kit.providers.avito.serp import AvitoScraper as NewAvitoScraper from scraper_kit.providers.avito.serp import ( _build_sort_timestamp_map as new_build_ts_map, ) from scraper_kit.providers.avito.serp import _clean_address as new_clean_address from scraper_kit.providers.avito.serp import _extract_area_from_title as new_area from scraper_kit.providers.avito.serp import _extract_floor_from_title as new_floor from scraper_kit.providers.avito.serp import _extract_rooms_from_title as new_rooms from scraper_kit.providers.avito.shared import _unix_to_date as new_unix_to_date from app.services.scrapers.avito import AvitoScraper as OldAvitoScraper from app.services.scrapers.avito import ( _build_sort_timestamp_map as old_build_ts_map, ) from app.services.scrapers.avito import _clean_address as old_clean_address from app.services.scrapers.avito import _extract_area_from_title as old_area from app.services.scrapers.avito import _extract_floor_from_title as old_floor from app.services.scrapers.avito import _extract_rooms_from_title as old_rooms from app.services.scrapers.avito import _unix_to_date as old_unix_to_date from app.services.scrapers.avito_detail import parse_detail_html as old_parse_detail _FIXTURES = Path(__file__).parent / "fixtures" _SERP_HTML = (_FIXTURES / "avito_serp_sample.html").read_text(encoding="utf-8") # Минимальный detail item-view HTML (зеркало test_avito_detail_parse.MINIMAL_HTML). _DETAIL_HTML = """
№ 7986882804 · 18 мая в 13:40 · 1899 просмотров (+ 12 сегодня)
11 990 000 ₽
Дом
Отличная квартира в новом ЖК.
""" # noqa: E501 _SOURCE_URL = "https://www.avito.ru/ekaterinburg/kvartiry/test_7986882804" def _make_old(ekb_only: bool) -> tuple[OldAvitoScraper, SimpleNamespace]: """Старый AvitoScraper без DB: patch get_scraper_delay + settings.avito_serp_ekb_only.""" with patch("app.services.scrapers.avito.get_scraper_delay", return_value=7.0): scraper = OldAvitoScraper() # _parse_html читает settings.avito_serp_ekb_only — подменяем на детерминированное. scraper_settings = SimpleNamespace(avito_serp_ekb_only=ekb_only) return scraper, scraper_settings def _make_new(ekb_only: bool) -> NewAvitoScraper: """Новый AvitoScraper с инжектируемым ScraperConfig (duck-typed namespace).""" config = SimpleNamespace(avito_serp_ekb_only=ekb_only) return NewAvitoScraper(config=config) def _dump_lots(lots: list[Any]) -> list[dict[str, Any]]: return [lot.model_dump() for lot in lots] # ── SERP _parse_html parity ────────────────────────────────────────────────── def _run_old_parse_html(html: str, base: str, ekb_only: bool) -> list[Any]: old, old_settings = _make_old(ekb_only) with patch("app.services.scrapers.avito.settings", old_settings): return old._parse_html(html, base) def test_serp_parse_html_parity_ekb_only_false() -> None: """DOM-карточки → ScrapedLot идентичны (фильтр padding выключен).""" base = "https://www.avito.ru/ekaterinburg/kvartiry" old_lots = _run_old_parse_html(_SERP_HTML, base, ekb_only=False) new_lots = _make_new(ekb_only=False)._parse_html(_SERP_HTML, base) assert len(old_lots) == len(new_lots) assert len(new_lots) > 0, "фикстура должна содержать хотя бы одну карточку" assert _dump_lots(old_lots) == _dump_lots(new_lots) def test_serp_parse_html_parity_ekb_only_true() -> None: """С включённым ekb_only-фильтром padding-дроп идентичен.""" base = "https://www.avito.ru/ekaterinburg/kvartiry" old_lots = _run_old_parse_html(_SERP_HTML, base, ekb_only=True) new_lots = _make_new(ekb_only=True)._parse_html(_SERP_HTML, base) assert len(old_lots) == len(new_lots) assert _dump_lots(old_lots) == _dump_lots(new_lots) def test_serp_extract_total_count_parity() -> None: """_extract_total_count на SERP-фикстуре идентичен.""" old, _ = _make_old(ekb_only=False) new = _make_new(ekb_only=False) assert old._extract_total_count(_SERP_HTML) == new._extract_total_count(_SERP_HTML) def test_build_sort_timestamp_map_parity() -> None: """_build_sort_timestamp_map (sortTimeStamp → date, MSK) идентичен.""" assert old_build_ts_map(_SERP_HTML) == new_build_ts_map(_SERP_HTML) def test_unix_to_date_parity() -> None: """_unix_to_date (MSK) идентичен на репрезентативных epoch-значениях.""" for ts in [None, 0, 1756077885, 1735689600, 1700000000.0, 1_600_000_000]: assert old_unix_to_date(ts) == new_unix_to_date(ts) # ── detail parse_detail_html parity ────────────────────────────────────────── def test_parse_detail_html_parity() -> None: """parse_detail_html → DetailEnrichment идентичен (все поля).""" old_res = old_parse_detail(_DETAIL_HTML, _SOURCE_URL) new_res = new_parse_detail(_DETAIL_HTML, _SOURCE_URL) assert dataclasses.asdict(old_res) == dataclasses.asdict(new_res) # ── чистые title/address-хелперы ────────────────────────────────────────────── def test_pure_title_helpers_parity() -> None: titles = [ "3-к. квартира, 75 м², 20/26 эт.", "Квартира-студия, 24,5 м², 12/25 эт.", "1-к. квартира, 38 м², 5/9 эт.", "2-к. квартира, 52,3 м², 7/16 эт.", "Комната, 18 м², 3/5 эт.", ] for t in titles: assert old_rooms(t) == new_rooms(t) assert old_area(t) == new_area(t) assert old_floor(t) == new_floor(t) def test_clean_address_parity() -> None: addrs = [ None, "Екатеринбург, ул. Постовского, 17А", "Свердловская область, Екатеринбург, Малышева, 51", " Екатеринбург, Ленина 1 ", ] for a in addrs: assert old_clean_address(a) == new_clean_address(a) # ── strangler-guard: kit-провайдер не импортирует app.* ────────────────────── def test_kit_avito_has_no_app_imports() -> None: """Ни один модуль scraper_kit.providers.avito.* не импортирует app.* (развязка).""" import inspect from scraper_kit.providers.avito import detail, houses, imv, serp, shared for mod in (serp, detail, houses, imv, shared): source = inspect.getsource(mod) for line in source.splitlines(): stripped = line.strip() assert not stripped.startswith("from app"), f"{mod.__name__}: {line!r}" assert not stripped.startswith("import app"), f"{mod.__name__}: {line!r}"