From 9b9f299922a194491f63d271beb3b8fc9b5cc05e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 5 Aug 2026 23:37:48 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/scraper):=20=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=20QRATOR=20=D1=83=20=D0=94=D0=BE=D0=BC=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=BE=D0=BC=D0=B5=D1=87=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=20=D1=83=D1=81=D0=BF=D0=B5?= =?UTF-8?q?=D1=88=D0=BD=D1=8B=D0=BC=20(#2657)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Honest-status в run_domclick_city_sweep требовал ОДНОВРЕМЕННО блок И ноль лотов, поэтому распознанный QRATOR-блок после первых собранных лотов уходил в `done`. На проде это 13 из 13 прогонов с blocked=1 (39-464 лота вместо ~6300) — ни один распознанный блок ни разу не дал не-`done` статус. Домклик структурно отличается от cian/yandex (#2625/#2642): там независимые anchor'ы и провал одного среди успешных — не бан (анти-флап). Здесь anchor'ов нет, sweep линейный по ROOM_BUCKETS, и первый же блок делает break — оставшиеся бакеты не пробуются вовсе. Значит блок = прогон оборван, сколько бы лотов он ни успел взять до этого. Теперь: blocked → mark_banned (external constraint, не наш баг; тот же статус, что #2642 дал cian/yandex — доступен как триггер ротации IP #2611, сама ротация не вызывается). Ноль лотов с fetch-ошибками, но БЕЗ блока → по-прежнему failed. Честная пустота → по-прежнему done. Пометка прокси-пула (fetcher.report_ban, #2600 п.1) не тронута — живёт в providers/domclick/serp.py и срабатывает раньше и независимо от статуса прогона. Refs #2657 --- .../backend/tests/test_domclick_sweep.py | 6 +-- .../test_scraper_kit_pipeline_parity2.py | 45 ++++++++++++++++--- .../src/scraper_kit/orchestration/pipeline.py | 42 +++++++++++++---- 3 files changed, 77 insertions(+), 16 deletions(-) diff --git a/tradein-mvp/backend/tests/test_domclick_sweep.py b/tradein-mvp/backend/tests/test_domclick_sweep.py index 9c122cc4..e84f377e 100644 --- a/tradein-mvp/backend/tests/test_domclick_sweep.py +++ b/tradein-mvp/backend/tests/test_domclick_sweep.py @@ -1,10 +1,10 @@ """Offline unit tests for DomClickCitySweepCounters + DomClick parser. Legacy `run_domclick_city_sweep` orchestration behavior (sweep phases, cooperative -cancel, honest mark_done/mark_failed statuses) удалён вместе с +cancel, honest mark_done/mark_failed/mark_banned statuses) удалён вместе с `app.services.scrape_pipeline` (#2397 Part E1) — эквивалентная regression-coverage -теперь в `test_scraper_kit_pipeline_parity2.py::test_domclick_city_sweep_{done, -blocked_failed}` (kit `run_domclick_city_sweep`). +теперь в `test_scraper_kit_pipeline_parity2.py::test_domclick_city_sweep_*` +(kit `run_domclick_city_sweep`). `app.services.scrapers.domclick` удалён (#2397 Part E2, 0 runtime-импортёров) — `_map_item` ретаргетирован на kit `scraper_kit.providers.domclick.serp.DomClickScraper` diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py index 5dae8cb1..c38ffc0d 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py @@ -9,7 +9,7 @@ sweep). Здесь — остальные 7 sweep'ов (#2135 F2): City sweep'ы (приоритет — активны в проде): - run_yandex_city_sweep — combos SERP + save + price-history - run_cian_city_sweep — SERP + newbuilding_only-фильтр + save - - run_domclick_city_sweep — BFF citywide + честный статус (done / failed) + - run_domclick_city_sweep — BFF citywide + честный статус (done / failed / banned) Плюс: - run_avito_newbuilding_sweep — citywide novostroyka SERP + save Full load'ы (smoke — импорт + базовый прогон через on_bucket): @@ -388,7 +388,11 @@ async def test_cian_city_sweep_honest_empty_stays_done() -> None: async def _drive_domclick( - *, lots_n: int, blocked: bool, capture: dict[str, Any] | None = None + *, + lots_n: int, + blocked: bool, + fetch_errors: int = 0, + capture: dict[str, Any] | None = None, ) -> _DriveResult: """capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...).""" recorder = _RunsRecorder() @@ -398,7 +402,7 @@ async def _drive_domclick( fetch_city=AsyncMock(return_value=lots), blocked=blocked, geo_filtered=0, - fetch_errors=0, + fetch_errors=fetch_errors, ) save_mock = MagicMock(side_effect=[(lots_n, 0)] if lots_n else []) if capture is not None: @@ -425,14 +429,45 @@ async def test_domclick_city_sweep_done() -> None: @pytest.mark.asyncio -async def test_domclick_city_sweep_blocked_failed() -> None: - """QRATOR-блок + 0 lots → mark_failed (честный статус #1968).""" +async def test_domclick_city_sweep_blocked_banned() -> None: + """QRATOR-блок + 0 lots → mark_banned (честный статус #1968, #2657).""" counters, calls = await _drive_domclick(lots_n=0, blocked=True) assert counters["lots_fetched"] == 0 assert counters["blocked"] == 1 + assert calls[-1][0] == "mark_banned" + + +@pytest.mark.asyncio +async def test_domclick_city_sweep_blocked_with_lots_marks_banned() -> None: + """#2657: блок оборвал бакеты ПОСЛЕ части лотов → banned, не done. + + Прод-случай: 13 из 13 прогонов с blocked=1 уходили в done, потому что + honest-status требовал ещё и lots_fetched == 0. + """ + counters, calls = await _drive_domclick(lots_n=4, blocked=True) + assert counters["lots_fetched"] == 4 + assert counters["blocked"] == 1 + assert calls[-1][0] == "mark_banned" + + +@pytest.mark.asyncio +async def test_domclick_city_sweep_fetch_errors_without_block_stays_failed() -> None: + """#2657 анти-оверрич: 0 лотов + fetch-ошибки, но БЕЗ блока → failed, не banned.""" + counters, calls = await _drive_domclick(lots_n=0, blocked=False, fetch_errors=2) + assert counters["blocked"] == 0 + assert counters["errors_count"] == 2 assert calls[-1][0] == "mark_failed" +@pytest.mark.asyncio +async def test_domclick_city_sweep_honest_empty_stays_done() -> None: + """#2657 анти-оверрич: честная пустота (0 лотов, ни блока, ни ошибок) → done.""" + counters, calls = await _drive_domclick(lots_n=0, blocked=False) + assert counters["lots_fetched"] == 0 + assert counters["blocked"] == 0 + assert calls[-1][0] == "mark_done" + + # ── Avito newbuilding sweep ─────────────────────────────────────────────────── 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 3150a503..57a17fc6 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 @@ -3702,8 +3702,10 @@ async def run_domclick_city_sweep( Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо прямых импортов app.* (см. scraper_kit.contracts). - ЧЕСТНЫЙ СТАТУС (#1968): если scraper сообщил QRATOR-блок И lots == 0 → - mark_failed. Иначе mark_done. + ЧЕСТНЫЙ СТАТУС (#1968, ужесточён #2657): распознанный QRATOR-блок → mark_banned + (независимо от числа собранных лотов — блок обрывает ОСТАВШИЕСЯ комнатные бакеты, + прогон не доделал работу). Ноль лотов с fetch-ошибками, но без блока → mark_failed. + Иначе mark_done. Возвращает DomClickCitySweepCounters. """ @@ -3797,19 +3799,43 @@ async def run_domclick_city_sweep( counters.pages_fetched = _num_fetches runs.update_heartbeat(db, run_id, counters.to_dict()) - # ── ЧЕСТНЫЙ СТАТУС (#1968) ──────────────────────────────────────────── - if counters.lots_fetched == 0 and (counters.blocked or counters.errors_count > 0): + # ── ЧЕСТНЫЙ СТАТУС (#1968, ужесточён #2657) ─────────────────────────── + # Распознанный QRATOR-блок НИКОГДА не даёт done. Домклик тут структурно + # отличается от cian/yandex (#2625/#2642): там независимые anchor'ы, и + # провал одного среди успешных — не бан (анти-флап). Здесь anchor'ов нет, + # sweep линейный по ROOM_BUCKETS, и первый же блок делает `break` — + # оставшиеся бакеты не пробуются вовсе (fetch_city). То есть блок = прогон + # оборван, сколько бы лотов он ни успел взять до этого; на проде это дало + # 13 из 13 «успешных» прогонов с blocked=1 (39-464 лота вместо ~6300). + # banned (а не failed): это external constraint, не наш баг, и тот же + # статус, что #2642 дал cian/yandex — доступен как триггер ротации IP + # (#2611; сама ротация здесь не вызывается). Пометка прокси-пула живёт + # отдельно и раньше — fetcher.report_ban в serp.py (#2600 п.1). + if counters.blocked: logger.error( - "domclick-sweep run_id=%d: 0 listings with blocked=%d errors=%d " - "— marking failed", + "domclick-sweep run_id=%d: QRATOR block aborted remaining room buckets " + "(lots=%d, errors=%d) — marking banned (#2657)", + run_id, + counters.lots_fetched, + counters.errors_count, + ) + runs.mark_banned( + db, + run_id, + f"QRATOR block aborted sweep — {counters.lots_fetched} listings " + "collected before abort (#2657)", + counters.to_dict(), + ) + elif counters.lots_fetched == 0 and counters.errors_count > 0: + logger.error( + "domclick-sweep run_id=%d: 0 listings with errors=%d — marking failed", run_id, - counters.blocked, counters.errors_count, ) runs.mark_failed( db, run_id, - "QRATOR block or fetch errors — 0 listings", + "fetch errors — 0 listings", counters.to_dict(), ) else: -- 2.45.3