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
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 чист на всех изменённых файлах.
225 lines
9.9 KiB
Python
225 lines
9.9 KiB
Python
"""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
|