"""Golden-parity бисекции: старый боевой обход `app.services.scrapers.` ≡ новый единый движок `scraper_kit.pricing.walk_price_range` (#2134). Четыре независимые копии адаптивной бисекции «комнатность × цена» были консолидированы в один движок (`scraper_kit/pricing/bisection.py`). Провайдеры (avito/cian/yandex/domclick) теперь передают в движок свои probe/paginate + свой `BisectionConfig`. Этот тест доказывает, что unification НЕ изменил ОБХОД: для каждого провайдера при одинаковом входе (детерминированный мок счётчика по диапазону) старая боевая реализация и новая (движок) посещают ИДЕНТИЧНУЮ последовательность бакетов — probe-запросы, leaf-пагинации, деления, ранние возвраты на нуле, probe-fail-ветки. Механика: на ОБЕ реализации (старую и новую) ставятся ОДИНАКОВЫЕ моки на низкоуровневые методы (probe-fetch + leaf-пагинатор), которые (а) возвращают счётчик из общего оракула `count(lo, hi)` и (б) пишут событие в общий список. Совпадение списков событий = идентичный обход. """ from __future__ import annotations import os from collections.abc import Callable from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from scraper_kit.price_brackets import get_price_seed_brackets from scraper_kit.providers.avito.serp import AvitoScraper as NewAvito from scraper_kit.providers.cian.serp import CianScraper as NewCian from scraper_kit.providers.domclick.serp import DomClickScraper as NewDomClick from scraper_kit.providers.yandex.serp import YandexRealtyScraper as NewYandex from app.services.scrapers.avito import AvitoScraper as OldAvito from app.services.scrapers.cian import CianScraper as OldCian from app.services.scrapers.domclick import DomClickScraper as OldDomClick from app.services.scrapers.yandex_realty import YandexRealtyScraper as OldYandex # ── Оракул счётчиков ───────────────────────────────────────────────────────── Oracle = Callable[[Any, Any], int | None] def make_oracle( *, divisor: int, open_ceiling: int, zeros: frozenset[tuple[Any, Any]] = frozenset(), nones: frozenset[tuple[Any, Any]] = frozenset(), ) -> Oracle: """Детерминированный счётчик от (lo, hi): count = span // divisor. span считается по клампнутым границам (None-lo → 0, None-hi → open_ceiling), чтобы широкие брекеты делились, а узкие уходили в leaf. `zeros` форсит 0 (ранний возврат / leaf-на-нуле у yandex), `nones` форсит None (probe-fail). """ def oracle(lo: Any, hi: Any) -> int | None: if (lo, hi) in nones: return None if (lo, hi) in zeros: return 0 eff_lo = lo if lo is not None else 0 eff_hi = hi if hi is not None else open_ceiling span = eff_hi - eff_lo return max(0, span // divisor) return oracle # ── Провайдер-специфичные «сборщики событий» ───────────────────────────────── # # Каждый ставит одинаковые моки на старый/новый экземпляр и возвращает список # событий обхода. `("probe", lo, hi)` — запрос счётчика бакета; `("leaf", ...)` — # пагинация leaf-бакета. def _patch_avito(scraper: Any, oracle: Oracle, events: list[Any]) -> None: def probe(room_slug: str, min_price: Any, max_price: Any) -> tuple[str | None, int | None]: events.append(("probe", min_price, max_price)) return "H", oracle(min_price, max_price) def leaf(**kw: Any) -> None: events.append(("leaf", kw["lo"], kw["hi"], kw["max_pages"])) scraper._probe_total = AsyncMock(side_effect=probe) scraper._paginate_leaf_bucket = AsyncMock(side_effect=leaf) scraper._rotate_ip = AsyncMock(return_value=False) def _patch_cian(scraper: Any, oracle: Oracle, events: list[Any]) -> None: holder: dict[str, tuple[Any, Any]] = {} def fph(rooms: Any, page: int, min_price: Any, max_price: Any) -> str: events.append(("probe", min_price, max_price)) holder["b"] = (min_price, max_price) return "H" def eto(html: str) -> int | None: return oracle(*holder["b"]) def leaf(**kw: Any) -> None: events.append(("leaf", kw["lo"], kw["hi"], kw["total"])) scraper._fetch_page_html = AsyncMock(side_effect=fph) scraper._extract_total_offers = MagicMock(side_effect=eto) scraper._paginate_leaf_bucket = AsyncMock(side_effect=leaf) scraper._rotate_ip = AsyncMock(return_value=False) scraper.sleep_between_requests = AsyncMock() def _patch_domclick(scraper: Any, oracle: Oracle, events: list[Any]) -> None: def count(fetcher: Any, rooms: Any, gte: Any, lte: Any) -> int: events.append(("probe", gte, lte)) val = oracle(gte, lte) return val if val is not None else 0 def paginate( fetcher: Any, rooms: Any, gte: Any, lte: Any, seen_ids: Any, out_lots: Any, pages: Any ) -> None: events.append(("leaf", gte, lte)) scraper._count = AsyncMock(side_effect=count) scraper._paginate = AsyncMock(side_effect=paginate) def _patch_yandex( scraper: Any, oracle: Oracle, events: list[Any], *, record_pages: bool = False ) -> None: """Yandex: leaf-пагинация встроена в walk (нет отдельного метода-хука). Стабим `_fetch_page_json` (page=1 отдаёт payload с totalItems из оракула; page>1 — пустой payload) и передаём `on_bucket`, который пишет завершение leaf/degraded бакета. probe = page-1-fetch; leaf-страницы 2..N идут через asyncio.gather (порядок недетерминирован) — их НЕ пишем в норм-режиме; факт leaf-обхода фиксируется через on_bucket. Для degraded-теста (пагинация последовательная) `record_pages=True` пишет все страницы. """ def gate(total: int | None, entities: list[Any] | None = None) -> dict[str, Any]: pager: dict[str, Any] = {} if total is not None: pager["totalItems"] = total return {"response": {"search": {"offers": {"entities": entities or [], "pager": pager}}}} def fpj(rooms: Any, page: int, price_min: Any, price_max: Any, new_flat: str = "NO") -> Any: if page == 1: events.append(("probe", price_min, price_max)) return gate(oracle(price_min, price_max)) if record_pages: events.append(("page", price_min, price_max, page)) return gate(0, entities=[]) scraper._fetch_page_json = AsyncMock(side_effect=fpj) scraper._rotate_ip = AsyncMock(return_value=False) scraper.request_delay_sec = 0.0 def _yandex_on_bucket(events: list[Any]) -> Callable[[str, int], None]: def on_bucket(bucket_key: str, count: int) -> None: events.append(("bucket", bucket_key)) return on_bucket # ── Конструкторы старых/новых экземпляров (offline, без DB) ─────────────────── _KIT_CONFIG = SimpleNamespace( scraper_proxy_url=None, yandex_proxy_rotate_url=None, avito_proxy_rotate_url=None, avito_proxy_max_rotations=0, scraper_fetch_mode="browser", browser_http_endpoint="http://tradein-browser:9000", glitchtip_dsn=None, avito_serp_ekb_only=False, ) def _old_avito() -> Any: with patch("app.services.scrapers.avito.get_scraper_delay", return_value=7.0): return OldAvito() def _old_cian() -> Any: with patch("app.services.scrapers.cian.get_scraper_delay", return_value=5.0): return OldCian() def _old_yandex() -> Any: with patch("app.services.scrapers.yandex_realty.get_scraper_delay", return_value=5.0): return OldYandex() def _old_domclick() -> Any: with patch("app.services.scrapers.domclick.get_scraper_delay", return_value=8.0): return OldDomClick() # ── avito / cian: одинаковый скелет (probe → split → leaf) ──────────────────── # Провайдер-специфичные kwargs для _walk_price_range (avito идентифицирует бакет # room_slug/room_label; cian — кортежем rooms). _AVITO_WALK_EXTRA = {"room_slug": "room1", "room_label": "1к"} _CIAN_WALK_EXTRA = {"rooms": (1,)} @pytest.mark.asyncio @pytest.mark.parametrize( "old_factory, new_factory, patch_fn, walk_extra", [ (_old_avito, lambda: NewAvito(config=_KIT_CONFIG), _patch_avito, _AVITO_WALK_EXTRA), (_old_cian, lambda: NewCian(config=_KIT_CONFIG), _patch_cian, _CIAN_WALK_EXTRA), ], ) async def test_bisection_parity_avito_cian( old_factory: Any, new_factory: Any, patch_fn: Any, walk_extra: dict[str, Any] ) -> None: """avito/cian: split-дерево + leaf + ранний возврат на нуле — обход идентичен. Оракул: divisor=1000, cap=1400 → брекет 8М делится 3 уровня (8М→4М→2М→1М-leaf); один под-брекет форсим в 0 (ранний возврат без leaf). """ # После первого split (0,7_999_999): mid=3_999_999 → дети (None,3_999_999) и # (4_000_000, 7_999_999). Форсим 0 на (4_000_000, 5_999_999) — под-брекет после # второго split правой ветки. oracle = make_oracle( divisor=1000, open_ceiling=30_000_000, zeros=frozenset({(4_000_000, 5_999_999)}), ) brackets: list[tuple[int, int | None]] = [(0, 7_999_999), (25_000_000, None)] async def run(scraper: Any) -> list[Any]: events: list[Any] = [] patch_fn(scraper, oracle, events) for lo, hi in brackets: await scraper._walk_price_range( lo=lo, hi=hi, seen={}, price_cap_per_bucket=1400, max_pages_per_bucket=100, concurrency=5, secondary_only=True, on_bucket=None, skip_buckets=None, **walk_extra, ) return events old_events = await run(old_factory()) new_events = await run(new_factory()) assert old_events == new_events # Санити: обход реально нетривиален (несколько probe + несколько leaf). assert sum(1 for e in old_events if e[0] == "leaf") >= 4 assert sum(1 for e in old_events if e[0] == "probe") >= 6 @pytest.mark.asyncio async def test_bisection_parity_avito_probe_fail() -> None: """avito probe-fail (total=None): SPLIT_OR_SKIP — принудительный split делимого закрытого брекета, best-effort leaf открытого — идентичен старому.""" # (0, 7_999_999): probe None → делим (bracket>=MIN). Дети (None,4M),(4000001,...): # им дадим реальные счётчики. Открытый (25M, None) probe None → best-effort leaf. oracle = make_oracle( divisor=1000, open_ceiling=30_000_000, nones=frozenset({(None, 7_999_999), (25_000_000, None)}), ) brackets: list[tuple[int, int | None]] = [(0, 7_999_999), (25_000_000, None)] async def run(scraper: Any) -> list[Any]: events: list[Any] = [] _patch_avito(scraper, oracle, events) for lo, hi in brackets: await scraper._walk_price_range( room_slug="room1", room_label="1к", lo=lo, hi=hi, seen={}, price_cap_per_bucket=1400, max_pages_per_bucket=100, concurrency=5, secondary_only=True, on_bucket=None, skip_buckets=None, ) return events old_events = await run(_old_avito()) new_events = await run(NewAvito(config=_KIT_CONFIG)) assert old_events == new_events # Открытый брекет с probe-fail → ровно один best-effort leaf на (25M, None). assert ("leaf", 25_000_000, None, 100) in old_events # ── domclick: count-endpoint + None-both-ends + split открытого корня ───────── @pytest.mark.asyncio async def test_bisection_parity_domclick() -> None: """domclick: unbounded-корень (None,None) клампится к [0,1e9] и делится; count-endpoint; span<=MIN_PRICE_SPAN строго → leaf. Обход идентичен старому.""" oracle = make_oracle(divisor=100_000, open_ceiling=1_000_000_000) async def run(scraper: Any) -> list[Any]: events: list[Any] = [] _patch_domclick(scraper, oracle, events) await scraper._sweep_bucket( fetcher=None, rooms="1", price_gte=None, price_lte=None, seen_ids=set(), out_lots=[], pages=100, ) return events old_events = await run(_old_domclick()) new_events = await run(NewDomClick(_make_domclick_config())) assert old_events == new_events assert sum(1 for e in old_events if e[0] == "leaf") >= 4 # Корень (None,None) обязательно пробится первым. assert old_events[0] == ("probe", None, None) def _make_domclick_config() -> Any: return SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000") # ── yandex: max_depth=8, min_bracket=500k, count-based on_bucket ────────────── @pytest.mark.asyncio async def test_bisection_parity_yandex_normal() -> None: """yandex: split-дерево (порог 500k, cap 500) + leaf-on-bucket + zero-as-leaf. Оракул divisor=1000, cap=500 → 4М→2М→1М→500k-leaf. Один под-брекет=0 (yandex вызывает on_bucket даже на нуле — treat_zero_as_leaf). """ oracle = make_oracle( divisor=1000, open_ceiling=30_000_000, zeros=frozenset({(2_000_000, 2_999_999)}), ) brackets: list[tuple[int, int | None]] = [(0, 3_999_999), (25_000_000, None)] async def run(scraper: Any) -> list[Any]: events: list[Any] = [] _patch_yandex(scraper, oracle, events) for lo, hi in brackets: await scraper._walk_price_range( rooms="1", lo=lo, hi=hi, seen={}, price_cap_per_bucket=500, max_pages_per_bucket=50, concurrency=4, on_bucket=_yandex_on_bucket(events), skip_buckets=None, ) return events old_events = await run(_old_yandex()) new_events = await run(NewYandex(config=_KIT_CONFIG)) assert old_events == new_events assert sum(1 for e in old_events if e[0] == "bucket") >= 4 assert sum(1 for e in old_events if e[0] == "probe") >= 6 @pytest.mark.asyncio async def test_bisection_parity_yandex_degraded() -> None: """yandex probe-fail (totalItems неизвестен) → degraded paginate-until-empty. Оракул форсит None на бакете → probe page1 payload без totalItems → total=None → degraded последовательно пагинирует. record_pages=True фиксирует все страницы. """ oracle = make_oracle( divisor=1000, open_ceiling=30_000_000, nones=frozenset({(None, 3_999_999)}), ) async def run(scraper: Any) -> list[Any]: events: list[Any] = [] _patch_yandex(scraper, oracle, events, record_pages=True) await scraper._walk_price_range( rooms="1", lo=0, hi=3_999_999, seen={}, price_cap_per_bucket=500, max_pages_per_bucket=50, concurrency=4, on_bucket=_yandex_on_bucket(events), skip_buckets=None, ) return events old_events = await run(_old_yandex()) new_events = await run(NewYandex(config=_KIT_CONFIG)) assert old_events == new_events # degraded: probe page1 + затем последовательная пагинация страниц 1..K до пустой. assert ("probe", None, 3_999_999) in old_events assert any(e[0] == "bucket" for e in old_events) # ── Full seed-grid обход (все seed-брекеты одной комнатности) ───────────────── @pytest.mark.asyncio @pytest.mark.parametrize( "old_factory, new_factory, patch_fn, walk_extra", [ (_old_avito, lambda: NewAvito(config=_KIT_CONFIG), _patch_avito, _AVITO_WALK_EXTRA), (_old_cian, lambda: NewCian(config=_KIT_CONFIG), _patch_cian, _CIAN_WALK_EXTRA), ], ) async def test_bisection_parity_full_seed_grid( old_factory: Any, new_factory: Any, patch_fn: Any, walk_extra: dict[str, Any] ) -> None: """Полный обход всех seed-брекетов (как в fetch_all_secondary) — включая открытый catch-all — идентичен между старым и новым для avito/cian.""" oracle = make_oracle(divisor=1500, open_ceiling=300_000_000) async def run(scraper: Any) -> list[Any]: events: list[Any] = [] patch_fn(scraper, oracle, events) for br_lo, br_hi in get_price_seed_brackets(): walk_hi = br_hi - 1 if br_hi is not None else None await scraper._walk_price_range( lo=br_lo, hi=walk_hi, seen={}, price_cap_per_bucket=1400, max_pages_per_bucket=100, concurrency=5, secondary_only=True, on_bucket=None, skip_buckets=None, **walk_extra, ) return events old_events = await run(old_factory()) new_events = await run(new_factory()) assert old_events == new_events