fix(tradein): обход Домклика начинается со сдвигом — комнатности 2+ перестают быть слепой зоной (#2854) #2932

Merged
bot-backend merged 1 commit from fix/2854-domclick-bucket-rotation into main 2026-08-19 12:48:41 +00:00
3 changed files with 202 additions and 2 deletions

View file

@ -142,3 +142,154 @@ async def test_fetch_city_reports_ban_on_qrator_block(monkeypatch: pytest.Monkey
assert scraper.blocked is True
assert fake_fetcher.banned # report_ban был вызван на ЖИВОМ fetcher'е
assert "QRATOR" in fake_fetcher.banned[0]
# ── #2854: точка входа в ROOM_BUCKETS сдвигается от прогона к прогону ───────────
#
# Блок QRATOR прилетает ВНУТРИ первой корзины — на проде buckets_completed=0 во всех
# прогонах, включая тот, где собрано 389 лотов. Обход при этом всегда стартовал со
# студий, поэтому комнатности 2+ не собирались никогда. Замер 19.08 по активным
# объявлениям: у Домклика 575 студий, 408 однушек, 1 двушка, 0 трёшек; у Циана в тех
# же категориях 6 461 / 7 352 / 4 581.
class _OrderRecordingScraper:
"""Ничего не делает, только запоминает порядок корзин."""
async def _make_scraper_recording(monkeypatch: pytest.MonkeyPatch) -> tuple[DomClickScraper, list]:
seen: list[str] = []
def _fake_build_browser_fetcher(config: object, source: str, **_kw: object) -> _FakeFetcher:
return _FakeFetcher()
monkeypatch.setattr(
"scraper_kit.providers._base.build_browser_fetcher", _fake_build_browser_fetcher
)
async def _record(self: DomClickScraper, *, rooms: str, **_: object) -> None:
seen.append(rooms)
monkeypatch.setattr(DomClickScraper, "_sweep_bucket", _record)
config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000")
return DomClickScraper(config), seen
async def test_fetch_city_rotates_bucket_order(monkeypatch: pytest.MonkeyPatch) -> None:
"""Сдвиг меняет ПОРЯДОК корзин, а не их состав — ни одна не теряется."""
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
scraper, seen = await _make_scraper_recording(monkeypatch)
await scraper.fetch_city(city_id=1, start_bucket_index=2)
assert seen == list(ROOM_BUCKETS[2:]) + list(ROOM_BUCKETS[:2])
assert sorted(seen) == sorted(ROOM_BUCKETS), "состав корзин изменился — потеряли охват"
async def test_fetch_city_start_index_is_normalized(monkeypatch: pytest.MonkeyPatch) -> None:
"""Вызывающий передаёт остаток от run_id — он ничем не ограничен сверху."""
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
scraper, seen = await _make_scraper_recording(monkeypatch)
await scraper.fetch_city(city_id=1, start_bucket_index=len(ROOM_BUCKETS) + 1)
assert seen[0] == ROOM_BUCKETS[1]
assert scraper.bucket_start_index == 1
async def test_blocked_run_collects_shifted_bucket_not_studios(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Главный сценарий: блок в ПЕРВОЙ же корзине — собранной оказывается сдвинутая.
Ровно так ведёт себя прод: одна корзина за прогон и обрыв. До #2854 этой
единственной корзиной всегда были студии, из-за чего двушки и трёшки не
появлялись в listings вовсе.
"""
from scraper_kit.domclick_exceptions import DomClickBlockedError
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
attempted: list[str] = []
def _fake_build_browser_fetcher(config: object, source: str, **_kw: object) -> _FakeFetcher:
return _FakeFetcher()
monkeypatch.setattr(
"scraper_kit.providers._base.build_browser_fetcher", _fake_build_browser_fetcher
)
async def _block(self: DomClickScraper, *, rooms: str, **_: object) -> None:
attempted.append(rooms)
raise DomClickBlockedError("QRATOR block page")
monkeypatch.setattr(DomClickScraper, "_sweep_bucket", _block)
config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000")
scraper = DomClickScraper(config)
await scraper.fetch_city(city_id=1, start_bucket_index=ROOM_BUCKETS.index("2"))
assert attempted == ["2"], "обход всё ещё начинается не со сдвинутой корзины"
assert scraper.blocked is True
assert scraper.bucket_start_index == ROOM_BUCKETS.index("2")
async def test_default_start_index_keeps_previous_behaviour(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Контроль: без явного сдвига порядок прежний — правка не меняет вызовы без него."""
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
scraper, seen = await _make_scraper_recording(monkeypatch)
await scraper.fetch_city(city_id=1)
assert seen == list(ROOM_BUCKETS)
# ── проводка: pipeline обязан ПЕРЕДАВАТЬ сдвиг, а не просто уметь его принимать ──
async def test_pipeline_passes_rotating_start_index() -> None:
"""Без этого теста передачу сдвига можно снять, и проверки выше останутся зелёными:
скрейпер по-прежнему умеет сдвигать, просто его об этом никто не просит.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
pfx = "scraper_kit.orchestration.pipeline"
run_id = 17
scraper = MagicMock()
scraper.__aenter__ = AsyncMock(return_value=scraper)
scraper.__aexit__ = AsyncMock(return_value=None)
scraper.fetch_city = AsyncMock(return_value=[])
scraper.blocked = False
scraper.geo_filtered = 0
scraper.fetch_errors = 0
scraper.buckets_completed = 0
scraper.buckets_total = len(ROOM_BUCKETS)
scraper.bucket_start_index = run_id % len(ROOM_BUCKETS)
runs_stub = MagicMock()
runs_stub.is_cancelled.return_value = False
with (
patch(f"{pfx}.DomClickScraper", return_value=scraper),
patch(f"{pfx}.save_listings", MagicMock(return_value=(0, 0))),
patch(f"{pfx}.runs", runs_stub),
):
await run_domclick_city_sweep(
MagicMock(),
config=SimpleNamespace(browser_http_endpoint="http://x:9000"),
matcher=MagicMock(),
run_id=run_id,
city_id=4,
pages=1,
request_delay_sec=0.0,
)
passed = scraper.fetch_city.await_args.kwargs.get("start_bucket_index")
assert passed == run_id % len(ROOM_BUCKETS), (
f"pipeline не передал сдвиг корзины (получено {passed!r}) — "
"обход снова всегда начнётся со студий"
)

View file

@ -4016,6 +4016,9 @@ class DomClickCitySweepCounters:
# 0/0 = скрейпер не успел создаться — тогда судить об охвате нечем.
buckets_completed: int = 0
buckets_total: int = 0
# #2854: с какой корзины ROOM_BUCKETS начался обход. Без него прогон
# неатрибутируем: «двушек не собрали» и «до двушек не дошли» выглядят одинаково.
bucket_start_index: int = 0
def to_dict(self) -> dict[str, int]:
return {f.name: getattr(self, f.name) for f in fields(self)}
@ -4115,7 +4118,25 @@ async def run_domclick_city_sweep(
_scraper_ref.append(_scraper)
if request_delay_sec is not None:
_scraper.request_delay_sec = _resolved_delay
lots = await _scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
# #2854: точка входа в ROOM_BUCKETS сдвигается от прогона к прогону.
# Блок прилетает внутри ПЕРВОЙ корзины (buckets_completed=0 во всех
# прод-прогонах), а обход всегда стартовал со студий — поэтому в
# listings у Домклика 575 студий, 408 однушек и ровно 1 двушка при
# 7 352 двушках у Циана. Сдвиг не лечит блок, он раздаёт собранное по
# всем корзинам.
#
# Источник сдвига — run_id, а не дата и не случайность: детерминирован
# (тест воспроизводим), не зависит от часов и не требует нового
# состояния в БД. Строгого round-robin он не даёт — run_id общий на все
# источники и растёт неравномерно, — но за 30 суток каждая корзина
# получает порядка пяти стартов, чего достаточно для критерия приёмки
# «объявления с rooms >= 2 появились».
lots = await _scraper.fetch_city(
city_id=city_id,
rooms=rooms,
pages=pages,
start_bucket_index=run_id % len(ROOM_BUCKETS),
)
counters.lots_fetched += len(lots)
if lots:
# #2594: domclick oblast-rollout (B2) ещё не wired (нет city_id→slug
@ -4158,6 +4179,7 @@ async def run_domclick_city_sweep(
# скрейпер живая, а его счётчик показывает, докуда прогон дошёл.
counters.buckets_completed = _s.buckets_completed
counters.buckets_total = _s.buckets_total
counters.bucket_start_index = _s.bucket_start_index
# pages_fetched: worst-case число страниц (buckets × pages cap).
counters.pages_fetched = _num_fetches

View file

@ -280,6 +280,9 @@ class DomClickScraper(BaseScraper):
# Поэтому прогон, прошедший 3 бакета из 6, был неотличим от полного.
self.buckets_total: int = len(ROOM_BUCKETS)
self.buckets_completed: int = 0
# #2854: с какой корзины начался обход. Без этого прогон неатрибутируем —
# по данным нельзя отличить «корзина не собралась» от «до неё не дошли».
self.bucket_start_index: int = 0
async def __aenter__(self) -> DomClickScraper:
await super().__aenter__()
@ -300,6 +303,7 @@ class DomClickScraper(BaseScraper):
city_id: int,
rooms: list[int] | None = None,
pages: int = 100,
start_bucket_index: int = 0,
) -> list[ScrapedLot]:
"""Citywide sweep через BFF JSON API.
@ -312,6 +316,17 @@ class DomClickScraper(BaseScraper):
city_id: игнорируется (EKB захардкожен).
rooms: игнорируется (ROOM_BUCKETS перебирается всегда).
pages: максимальное число страниц на бакет (safety cap).
start_bucket_index: с какой позиции ROOM_BUCKETS начинать обход (#2854).
Порядок циклически сдвигается, состав не меняется. Нужен потому, что
блок QRATOR прилетает ВНУТРИ первой же корзины (buckets_completed=0 во
всех прод-прогонах), а обход всегда стартовал со студий поэтому
комнатности 2+ не собирались никогда. Замер 19.08 по активным
объявлениям: студий 575, однушек 408, двушек 1, трёшек 0; у соседних
площадок 2+ комнат около двух третей выдачи.
Сдвиг НЕ лечит блок, он распределяет то, что успевает собраться, по
всем корзинам вместо одной. Работает при единственном свободном узле
в отличие от ротации lease, которой сейчас упираться некуда: в пуле 3
включённых узла, 2 забанены Домкликом.
Returns:
Дедуплицированный по source_id список ScrapedLot.
@ -333,7 +348,19 @@ class DomClickScraper(BaseScraper):
async with build_browser_fetcher(
self._config, "domclick", proxy_provider=self._proxy_provider
) as fetcher:
for bucket in ROOM_BUCKETS:
# Циклический сдвиг: состав корзин прежний, меняется только точка входа.
# Отрицательный/большой индекс нормализуем — вызывающий передаёт остаток от
# run_id, а он ничем не ограничен.
offset = start_bucket_index % len(ROOM_BUCKETS)
buckets = ROOM_BUCKETS[offset:] + ROOM_BUCKETS[:offset]
self.bucket_start_index = offset
logger.info(
"domklik: обход начинается с корзины rooms=%r (сдвиг %d из %d, #2854)",
buckets[0],
offset,
len(ROOM_BUCKETS),
)
for bucket in buckets:
logger.info(
"domklik: BFF sweep rooms=%r city_id=%d pages_cap=%d",
bucket,