diff --git a/tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py b/tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py new file mode 100644 index 00000000..cef84cae --- /dev/null +++ b/tradein-mvp/backend/tests/test_2687_domclick_ban_kind.py @@ -0,0 +1,86 @@ +"""#2687: распознанный QRATOR-блок домкликового свипа — это 'platform', не 'unknown'. + +Ветка `if counters.blocked:` в `run_domclick_city_sweep` входится ТОЛЬКО когда +скрейпер поднял `DomClickBlockedError` (площадка отдала QRATOR block-страницу). Это +ровно определение `BAN_KIND_PLATFORM`. До правки свип звал `mark_banned` без +`ban_kind`, и все его баны падали в дефолт 'unknown' (#2764) — диагноз был +УСТАНОВЛЕН (блок распознан), но не передавался, и мониторинг не мог отличить +«площадка отбила» (platform) от «тракт/прокси» (infra). + +Прод 2026-08-12: domclick_city_sweep 3751/3675/3594 все `banned` с `blocked=1`, +`ban_kind='unknown'`. На старом коде тест ниже падает (ban_kind='unknown'). +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.orchestration.pipeline import run_domclick_city_sweep + +PFX = "scraper_kit.orchestration.pipeline" + + +class _BanKindRecorder: + """Ловит ban_kind, переданный в mark_banned (остальное — no-op).""" + + def __init__(self) -> None: + self.ban_kind: str | None = None + self.marked: list[str] = [] + + def is_cancelled(self, db: Any, run_id: int) -> bool: + return False + + def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + return None + + def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + self.marked.append("done") + + def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + self.marked.append("failed") + + def mark_banned( + self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any + ) -> None: + self.marked.append("banned") + self.ban_kind = kw.get("ban_kind") + + +async def _drive_blocked() -> _BanKindRecorder: + recorder = _BanKindRecorder() + scraper = MagicMock() + scraper.__aenter__ = AsyncMock(return_value=scraper) + scraper.__aexit__ = AsyncMock(return_value=None) + scraper.fetch_city = AsyncMock(return_value=[MagicMock() for _ in range(39)]) + scraper.blocked = True # распознанный QRATOR-блок + scraper.geo_filtered = 0 + scraper.fetch_errors = 1 + scraper.buckets_completed = 2 + scraper.buckets_total = 6 + with ( + patch(f"{PFX}.DomClickScraper", return_value=scraper), + patch(f"{PFX}.save_listings", MagicMock(return_value=(39, 0))), + patch(f"{PFX}.runs", recorder), + ): + await run_domclick_city_sweep( + MagicMock(), + config=SimpleNamespace(browser_http_endpoint="http://x:9000"), + matcher=MagicMock(), + run_id=1, + city_id=4, + pages=1, + request_delay_sec=0.0, + ) + return recorder + + +async def test_qrator_block_marks_platform_ban_kind() -> None: + """Блок распознан → ban_kind='platform'. На старом коде было 'unknown' (дефолт).""" + recorder = await _drive_blocked() + assert recorder.marked[-1] == "banned" + assert recorder.ban_kind == "platform" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index eb6852b3..888cf296 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -3891,6 +3891,13 @@ async def run_domclick_city_sweep( f"QRATOR block aborted sweep — {counters.lots_fetched} listings " "collected before abort (#2657)", counters.to_dict(), + # #2687: эта ветка входится ТОЛЬКО при распознанном QRATOR-блоке + # (counters.blocked == 1, DomClickBlockedError — площадка показала + # block-страницу). Это ровно определение BAN_KIND_PLATFORM + # («площадка нас заблокировала»), а не 'unknown'. Раньше домкликовый + # свип не передавал ban_kind и все его баны падали в 'unknown', хотя + # диагноз здесь установлен, а не «не установлен» (#2764). + ban_kind=BAN_KIND_PLATFORM, ) elif 0 < counters.buckets_completed < counters.buckets_total: # #2670: прогон оборван на середине — прошёл часть комнатных бакетов и