gendesign/tradein-mvp/backend/tests/test_scraper_kit_bisection_parity.py
lekss361 62fa1f732a
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / test (push) Has been cancelled
refactor(scraper-kit): unify price bisection engine across providers + dedup (#2134)
2026-07-02 16:41:04 +00:00

455 lines
19 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 бисекции: старый боевой обход `app.services.scrapers.<provider>`
≡ новый единый движок `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": ""}
_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="",
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