"""Golden-parity бисекции: старый боевой обход `app.services.scrapers.avito` ≡ новый единый движок `scraper_kit.pricing.walk_price_range` (#2134). Четыре независимые копии адаптивной бисекции «комнатность × цена» были консолидированы в один движок (`scraper_kit/pricing/bisection.py`). Провайдеры cian/domclick/yandex_realty удалены (#2397 Part E2, 0 runtime-импортёров) — их legacy-vs-kit секции обхода убраны отсюда вместе с ними (легаси-эталон исчез). `avito` остаётся легаси (не мигрирован) — его parity-проверка ниже доказывает, что 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, 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 app.services.scrapers.avito import AvitoScraper as OldAvito # ── Оракул счётчиков ───────────────────────────────────────────────────────── 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 (ранний возврат), `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 # ── avito «сборщик событий» ────────────────────────────────────────────────── # # Ставит одинаковые моки на старый/новый экземпляр и возвращает список событий # обхода. `("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) # ── Конструкторы старых/новых экземпляров (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() # ── avito: probe → split → leaf ─────────────────────────────────────────────── _AVITO_WALK_EXTRA = {"room_slug": "room1", "room_label": "1к"} @pytest.mark.asyncio async def test_bisection_parity_avito() -> None: """avito: 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_avito(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, **_AVITO_WALK_EXTRA, ) return events old_events = await run(_old_avito()) new_events = await run(NewAvito(config=_KIT_CONFIG)) 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 # ── Full seed-grid обход (все seed-брекеты одной комнатности) ───────────────── @pytest.mark.asyncio async def test_bisection_parity_full_seed_grid() -> None: """Полный обход всех seed-брекетов (как в fetch_all_secondary) — включая открытый catch-all — идентичен между старым и новым для avito.""" oracle = make_oracle(divisor=1500, open_ceiling=300_000_000) async def run(scraper: Any) -> list[Any]: events: list[Any] = [] _patch_avito(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, **_AVITO_WALK_EXTRA, ) return events old_events = await run(_old_avito()) new_events = await run(NewAvito(config=_KIT_CONFIG)) assert old_events == new_events