gendesign/tradein-mvp/backend/tests/test_scraper_kit_bisection_parity.py
bot-backend 575c233e51
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m2s
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
refactor(tradein/scrapers): удалить 6 осиротевших legacy scraper-модулей + тест-хирургия (#2397 Part E2)
app.services.scrapers.{cian,cian_detail,yandex_realty,domclick,domclick_exceptions,
avito_houses} имели 0 runtime-импортёров после E1 (scrape_pipeline.py удалён,
#2405) — admin.py/cian_history_backfill.py уже на scraper_kit.providers.*.

Удаление:
- 6 модулей целиком (avito_houses.py, cian.py, cian_detail.py, domclick.py,
  domclick_exceptions.py, yandex_realty.py)
- 3 осиротевших dev-sweep скрипта (local-sweep-ekb{,-cian,-yandex}.py) —
  эксклюзивно драйвили удалённые легаси-скраперы, kit-путь их заменяет

Тесты (18 файлов DELETE — эксклюзивно тестировали удалённые модули или были
чистым legacy-vs-kit parity, чей легаси-эталон исчез):
test_cian_detail.py, test_cian_serp_scraper.py, tests/scrapers/test_cian_exhaustive.py,
test_yandex_realty_serp.py, tests/scrapers/test_domclick.py,
test_avito_houses_dedup.py, test_avito_houses_parse.py,
test_houses_rating_filter_entries_1789.py, test_scraper_kit_domclick_golden_parity.py,
tests/scrapers/test_admin_{avito,cian,yandex}_kit_parity.py.

CONVERT (файл покрывает СМЕШАННЫЙ live+legacy набор — вырезаны только
секции удалённых модулей, live-покрытие сохранено):
- test_scraper_kit_cian_golden_parity.py — SERP/`_format_address`/detail
  (cian.py/cian_detail.py) убраны; extract_state (cian_state_parser, live)/
  valuation (cian_valuation, live)/newbuilding (cian_newbuilding, live)/
  no-app-imports guard — остались
- test_scraper_kit_yandex_golden_parity.py — SERP `_entity_to_lot`/
  `_parse_gate_json`/`_build_url` (yandex_realty) убраны; normalize_house_type
  (live)/newbuilding parse (yandex_newbuilding, live)/no-app-imports guard —
  остались
- test_scraper_kit_bisection_parity.py — cian/domclick/yandex-ветки убраны;
  avito-ветка (live, единственная оставшаяся легаси-бисекция) — осталась
  полностью, включая full-seed-grid
- test_scraper_kit_group_c_backfill_kit_parity.py — cian fetch_detail
  browser_fetcher parity-тест убран; avito/house_imv_backfill/
  cian_history_backfill/ekb_geoportal тесты — остались
- test_scraper_kit_pricehistory_session_parity.py — fetch_detail
  config-injection/proxy-wiring тесты (cian_detail) убраны; extract_state
  (cian_state_parser, live)/ScrapedLot (base, live) parity — остались
- test_yandex_scrapers_delay_wiring.py — yandex_realty delay-тест убран
  (kit-эквивалент уже покрыт test_kit_serp_proxy_pool.py); yandex_newbuilding
  (live)/yandex_detail/yandex_valuation (kit) тесты — остались
- test_scraper_proxy.py — yandex_realty BrowserFetcher-тест убран (redundant
  с test_kit_serp_proxy_pool.py::test_yandex_serp_no_pool_by_default)

RETARGET на kit (символ живёт, только источник поменялся):
- test_domclick_sweep.py — `_map_item` → scraper_kit.providers.domclick.serp
- test_yandex_city_sweep.py — DEFAULT_PRICE_RANGES/ROOM_PATH →
  scraper_kit.providers.yandex.serp
- test_pipeline_browser_routing.py — fetch_house_catalog →
  scraper_kit.providers.avito.houses (+ KitAvitoBlockedError для firewall-теста)
- test_snapshot_writer.py — DetailEnrichment/save_detail_enrichment →
  scraper_kit.providers.cian.detail (byte-identical strangler-копия)
- test_backfill_wave2.py — DetailEnrichment → scraper_kit.providers.cian.detail
- tests/scrapers/test_avito_unix_date_tz_consistency.py — avito_houses →
  scraper_kit.providers.avito.houses (houses-ветка _unix_to_date/RUS_MONTHS
  consolidation-проверки)

Stale-комментарии поправлены в cian_price_history.py, tests/scrapers/
test_domclick_detail.py (описывали ещё-живой domclick.py/domclick_exceptions.py).

grep -rn scrapers\.\(cian\|cian_detail\|yandex_realty\|domclick\|domclick_exceptions\|
avito_houses\) app/ scripts/ packages/ — только historical footnotes
("strangler-копия app.services.scrapers.X", past-tense "удалён/deleted").

Backend suite: 2811 passed, 6 skipped, 1 failed (known flake test_search_cache_hit
#2208, не регрессия). ruff check чист на всех изменённых файлах.
2026-07-04 14:37:19 +03:00

225 lines
9.9 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.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": ""}
@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="",
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