gendesign/tradein-mvp/backend/tests/test_3359_cian_exhaustive_containment_skip.py
bot-backend 35c7ea8492
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
fix(cian,yandex): skip fully-covered price ranges before probing on resume
Гейт 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
2026-09-05 23:56:46 +05:00

119 lines
5.8 KiB
Python
Raw Permalink 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.

"""Резюм 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]} запрос(ов) вместо нуля"