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
455 lines
19 KiB
Python
455 lines
19 KiB
Python
"""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": "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
|