All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
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 5m1s
Гейт should_skip живёт в общем движке (walk_price_range, #3315), но предикат из done-леджера строил и передавал только avito. У cian и yandex та же бисекция и тот же чекпоинт — на резюме дерево деления спускалось ВНУТРЬ зачтённых полос живыми probe-запросами: ключи чекпоинта суть границы ДИНАМИЧЕСКОЙ бисекции, при сдвиге рынка новый лист старому не равен даже внутри собранной территории, поэтому сравнение строк ничего не ловит. Формат ключей у провайдеров разный, и ключи не трогаем (иначе протухнут живые чекпоинты): cian пишет room_label:lo:hi / :open — как avito, парсер подходит без изменений; yandex пишет _combo_label «rooms:lo-hi» с «None» вместо открытого потолка, поэтому в done_range_skipper параметризованы range_sep и open_token (дефолты = прежнее поведение avito/cian). Один леджер на два режима: у yandex incremental-ключи несут префикс сегмента (secondary/2:…) и отсев по label их не пропускает, а смешение режимов блокирует _pick_resume (params IS NOT DISTINCT FROM); у cian incremental-режима нет вовсе. Записано в докстринге предиката. Тесты по значению на обоих провайдерах: счётчик стоит на горлышке фетча (_fetch_page_html / _fetch_page_json), резюм готовой комнатности = 0 запросов, частично покрытая полоса по-прежнему пробивается. Closes #3359
119 lines
5.8 KiB
Python
119 lines
5.8 KiB
Python
"""Резюм exhaustive-обхода Cian не пробивает уже пройденную территорию (#3359).
|
||
|
||
Гейт `should_skip` живёт в общем движке (`pricing.walk_price_range`, #3315), но
|
||
предикат из done-леджера строил и передавал только avito: у cian та же бисекция и
|
||
тот же чекпоинт, поэтому на резюме дерево деления спускалось ВНУТРЬ зачтённых
|
||
полос живыми probe-запросами. Ключи чекпоинта — границы ДИНАМИЧЕСКОЙ бисекции:
|
||
рынок сдвинулся → totals другие → новый лист (`4062500:4124999`) ключом не равен
|
||
старому (`4000000:4999999`) даже внутри собранной территории, так что сравнение
|
||
строк на резюме бесполезно — нужны интервалы.
|
||
|
||
Формат ключей cian — `room_label:lo:hi` / `room_label:lo:open`, совпадает с avito
|
||
(`_paginate_leaf_bucket`), парсер по умолчанию подходит без адаптации.
|
||
|
||
Сеть не нужна: горлышко фетча `_fetch_page_html` подменяется счётчиком. Проверка
|
||
ПО ЗНАЧЕНИЮ — сколько раз обход сходил в сеть; на origin/main тесты покрытия
|
||
красные (обход делает N > 0 запросов по территории, которая уже в чекпоинте).
|
||
Парсинг HTML тут не при чём (он не под тестом) — счётчик стоит на ЕДИНСТВЕННОМ
|
||
сетевом вызове, а разбор страницы заглушен.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from scraper_kit.base import ScrapedLot
|
||
from scraper_kit.providers.cian.serp import CianScraper
|
||
|
||
from app.services.scraper_adapters import RealScraperConfig
|
||
|
||
_ROOMS = (1,)
|
||
_LABEL = "room1"
|
||
# Плотная выдача: total=2000 > cap=1400 → непокрытый диапазон обязан делиться дальше.
|
||
_DENSE_TOTAL = 2000
|
||
|
||
|
||
def _scraper() -> tuple[CianScraper, list[int]]:
|
||
"""Скрапер с заглушенной сетью; в списке — счётчик реальных фетчей."""
|
||
s = CianScraper(RealScraperConfig())
|
||
calls = [0]
|
||
|
||
async def fake_fetch(
|
||
rooms: tuple[int, ...] | None,
|
||
page: int,
|
||
min_price: int | None,
|
||
max_price: int | None,
|
||
) -> str:
|
||
calls[0] += 1
|
||
return "<html>dense</html>"
|
||
|
||
async def no_sleep() -> None:
|
||
return None
|
||
|
||
s._fetch_page_html = fake_fetch # type: ignore[method-assign]
|
||
s.sleep_between_requests = no_sleep # type: ignore[method-assign]
|
||
s.request_delay_sec = 0.0
|
||
s._extract_total_offers = lambda html: _DENSE_TOTAL # type: ignore[method-assign]
|
||
s._parse_serp_html = lambda html: [] # type: ignore[method-assign,return-value]
|
||
return s, calls
|
||
|
||
|
||
def _walk(lo: int, hi: int | None, skip_buckets: set[str] | None) -> int:
|
||
"""Прогнать бисекцию полосы [lo, hi] и вернуть ЧИСЛО сетевых запросов."""
|
||
s, calls = _scraper()
|
||
seen: dict[str, ScrapedLot] = {}
|
||
asyncio.run(
|
||
s._walk_price_range(
|
||
rooms=_ROOMS,
|
||
lo=lo,
|
||
hi=hi,
|
||
seen=seen,
|
||
price_cap_per_bucket=1400,
|
||
max_pages_per_bucket=1,
|
||
skip_buckets=skip_buckets,
|
||
)
|
||
)
|
||
return calls[0]
|
||
|
||
|
||
def test_subrange_of_done_bucket_costs_zero_requests() -> None:
|
||
"""Головной: полоса ВНУТРИ done-корзины не делает ни одного запроса."""
|
||
calls = _walk(4_062_500, 4_124_999, {f"{_LABEL}:4000000:4999999"})
|
||
assert calls == 0, (
|
||
f"полоса [4062500, 4124999] целиком внутри зачтённой [4000000, 4999999], "
|
||
f"а обход сходил в сеть {calls} раз(а) — бан-бюджет горит на готовой территории"
|
||
)
|
||
|
||
|
||
def test_partially_covered_range_still_probes() -> None:
|
||
"""Контроль честности skip'а: непокрытый остаток обязан пробиваться."""
|
||
calls = _walk(3_500_000, 4_200_000, {f"{_LABEL}:4000000:4999999"})
|
||
assert calls > 0, "частично покрытая полоса пропущена целиком — потеря инвентаря"
|
||
|
||
|
||
def test_empty_ledger_keeps_previous_behaviour() -> None:
|
||
"""Регресс-контроль: без леджера обход прежний — пробивает и делит."""
|
||
baseline = _walk(4_000_000, 4_999_999, None)
|
||
assert baseline > 1, f"обход без леджера деградировал: {baseline} запрос(ов)"
|
||
assert _walk(4_000_000, 4_999_999, set()) == baseline
|
||
# Чужая комнатность в леджере не покрывает нашу.
|
||
assert _walk(4_000_000, 4_999_999, {"room2:0:open"}) == baseline
|
||
|
||
|
||
def test_fully_done_room_resumes_with_zero_requests() -> None:
|
||
"""Приёмка issue: резюм по полностью готовой комнатности = НОЛЬ запросов.
|
||
|
||
Открытый верхний брекет в леджере записан как `label:lo:open` — это [lo, ∞).
|
||
"""
|
||
s, calls = _scraper()
|
||
asyncio.run(
|
||
s.fetch_all_secondary(
|
||
rooms_buckets=[_ROOMS],
|
||
max_pages_per_bucket=1,
|
||
skip_buckets={f"{_LABEL}:0:open"},
|
||
)
|
||
)
|
||
assert calls[0] == 0, f"резюм готовой комнатности сделал {calls[0]} запрос(ов) вместо нуля"
|