fix(tradein/scraper): блок QRATOR у Домклика больше не помечает прогон успешным (#2657) #2667

Merged
bot-backend merged 1 commit from fix/2657-domclick-block-not-done into main 2026-08-05 18:49:21 +00:00
3 changed files with 77 additions and 16 deletions

View file

@ -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`

View file

@ -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 ───────────────────────────────────────────────────

View file

@ -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: