gendesign/tradein-mvp/backend/tests/test_3196_domclick_ban_kind.py
bot-backend bf3214b9e4
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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 / browser-tests (pull_request) Successful in 1m7s
CI Trade-In / backend-tests (pull_request) Successful in 4m57s
fix(tradein/scrapers): диагноз блока брался из текстовых маркеров чужой площадки, а не из HTTP-статуса (#3196)
Сайдкар вообще не читал код ответа page.goto: страница классифицировалась
только по маркерам, снятым с Авито. Домклик отдаёт статическую `403 | Домклик`
на 26 624 байта, где нет ни одного такого маркера (замер прода 28.08.2026) —
она уезжала наверх как валидный HTML, парсер не находил состояние, и прогон
получал блок неизвестной природы. За 14 дней все 14 прогонов домклика легли с
ban_kind='unknown'; у Яндекса счётчика blocked не было вовсе, поэтому ветка
перевода прогона в 'banned' была недостижима по построению — ноль банов.

- browser/server.py: статус целевой навигации сохраняется per-provider и
  доезжает в тело /fetch аддитивным ключом "status" (ключ "html" не тронут);
  403/429 с маркерами челленджа больше не ждут PoW — ждать нечего, статическая
  страница сама себя не перезагрузит. Наверх идёт BanPageDetectedError, а не
  заглушка: вернув её контентом, воскресили бы #3045.
- scraper_kit/browser_fetcher.py: BrowserFetcher.last_response_status +
  ban_kind_from_status (403/429 → platform, 5xx → infra, прочее → None).
  Поток управления не менялся: fetch() по-прежнему отдаёт str.
- domclick: DomClickBlockedError несёт .status — один тип исключения на
  маркер-детект и на сбой фетча разводится без размножения типов; прогон
  передаёт перепись диагнозов в mark_backfill_finished.
- yandex: появился счётчик blocked, оживляющий ветку бана. Серии блоков и
  промахов парсера считаются РАЗДЕЛЬНО: иначе четыре промаха плюс один 403
  пятым давали 'banned' с переписью {platform: 1}.
- cian: ban_kinds наполняется только диагностируемым статусом. HTTP 200 с
  пустым разбором — дрейф разметки на нашей стороне, а не отказ площадки;
  записав его блоком, мы бы штамповали фиктивные баны у здорового источника
  (13 done против 1 banned за 14 дней).

Инвариант: непустой ban_kinds ⟺ виден ответ 403/429/5xx. Значения остаются в
пределах CHECK scrape_runs.ban_kind.

Известный пробел: шов providers/domclick/detail.py `blocked.status = status`
тестами не покрыт — существующие домкликовые тесты подают исключение готовым
моком и боевой fetch_detail не исполняют.
2026-08-28 23:21:54 +03:00

216 lines
8.9 KiB
Python
Raw 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.

"""Честный ban_kind у domclick_detail_backfill по HTTP-статусу ответа (#3196).
Проблема (прод, 14 дней): domclick_detail_backfill дал 14 банов и ВСЕ с
ban_kind='unknown'. Причин две и обе механические:
1. задача вообще не передавала `ban_kinds` в mark_backfill_finished — диагноз не
доезжал до финализатора, каким бы он ни был;
2. DomClickBlockedError поднимается и на распознанном маркере, и на любом сбое
браузерного фетча, поэтому по типу исключения их не развести.
Фикс: исключение несёт `.status` (HTTP-код целевой навигации, см.
BrowserFetcher.last_response_status), задача раскладывает его через
ban_kind_from_status и копит перепись kind -> count — ровно как это делает
avito_detail_backfill (ban_kind_of_exception → Counter → mark_backfill_finished).
403/429 → 'platform' (замер 28.08: статическая страница `403 | Домклик` на
26 624 байта, без PoW и капчи — отказ, а не челлендж), 5xx → 'infra', статуса нет
'unknown' (не назначаем причину, которую не установили, #2764).
Зеркалит конвенции tests/test_3118_domclick_warm_context.py (module-level
patch-target строки, _mock_db/_mock_session_svc helpers).
"""
from __future__ import annotations
import os
import sys
from collections import Counter
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from scraper_kit.domclick_exceptions import DomClickBlockedError # noqa: E402
from app.tasks.domclick_detail_backfill import ( # noqa: E402
_ban_kind_of_block,
run_domclick_detail_backfill,
)
_FETCH = "app.tasks.domclick_detail_backfill.fetch_detail"
_SAVE = "app.tasks.domclick_detail_backfill.save_detail_enrichment"
_RUNS = "app.tasks.domclick_detail_backfill.runs_mod"
_SLEEP = "app.tasks.domclick_detail_backfill.asyncio.sleep"
_SETTINGS = "app.tasks.domclick_detail_backfill.settings"
_BROWSER_FETCHER = "app.tasks.domclick_detail_backfill.BrowserFetcher"
_SESSION_SVC = "app.tasks.domclick_detail_backfill.domclick_session_svc"
def _make_snapshot(n: int) -> list[dict]:
return [
{
"id": i + 1,
"source_url": f"https://ekaterinburg.domclick.ru/card/sale__flat__{i + 1}",
}
for i in range(n)
]
def _mock_db(snapshot: list[dict]) -> MagicMock:
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
db.execute.return_value = sel
return db
def _mock_session_svc() -> MagicMock:
svc = MagicMock()
svc.load_session.return_value = {"CAS_ID": "123"}
svc.COOKIE_EXPIRY_WARN_DAYS = 5
svc.session_expires_at.return_value = datetime.now(tz=UTC) + timedelta(days=30)
return svc
def _mock_browser_fetcher_cls() -> MagicMock:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
# синхронный метод — иначе остаётся неawaited coroutine
instance.request_context_reset = MagicMock()
return MagicMock(return_value=instance)
async def _run_all_blocked(status: int | None, *, blocks: int = 3) -> MagicMock:
"""Прогон, где КАЖДЫЙ fetch — блок с данным HTTP-статусом. Возвращает runs-мок."""
snapshot = _make_snapshot(blocks)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(side_effect=DomClickBlockedError("domclick blocked", status=status))
with (
patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")),
patch(_SESSION_SVC, _mock_session_svc()),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
await run_domclick_detail_backfill(
db,
run_id=3196,
params={"batch_size": blocks, "budget_sec": 3600, "max_consecutive_blocks": blocks},
)
return runs
def _ban_kinds_of(runs: MagicMock) -> Counter:
runs.mark_backfill_finished.assert_called_once()
return runs.mark_backfill_finished.call_args.kwargs["ban_kinds"]
# ── диагноз одного блока ─────────────────────────────────────────────────────
@pytest.mark.parametrize(
("status", "expected"),
[
(403, "platform"), # ровно тот случай, что намеряли вживую: `403 | Домклик`
(429, "platform"),
(500, "infra"),
(502, "infra"),
(503, "infra"),
(200, "unknown"), # маркер поймали, но статус про блок ничего не говорит
(404, "unknown"),
(None, "unknown"), # сайдкар статус не отдал / фетч не состоялся
],
)
def test_ban_kind_of_block(status: int | None, expected: str) -> None:
assert _ban_kind_of_block(DomClickBlockedError("boom", status=status)) == expected
def test_ban_kind_of_block_without_status_attr() -> None:
"""Исключение без явного статуса → 'unknown', не падение."""
assert _ban_kind_of_block(DomClickBlockedError("legacy")) == "unknown"
# ── прогон целиком: диагноз доезжает до финализатора ─────────────────────────
@pytest.mark.asyncio
async def test_run_403_gets_platform() -> None:
"""403 (отказ площадки) → ban_kinds={'platform': 3} в mark_backfill_finished."""
runs = await _run_all_blocked(403)
assert _ban_kinds_of(runs) == Counter({"platform": 3})
assert runs.mark_backfill_finished.call_args.kwargs["aborted_by_blocks"] is True
@pytest.mark.asyncio
async def test_run_5xx_gets_infra() -> None:
"""5xx (сбой площадки/прокси, не адресный бан) → 'infra'."""
runs = await _run_all_blocked(503)
assert _ban_kinds_of(runs) == Counter({"infra": 3})
@pytest.mark.asyncio
async def test_run_unrecognized_refusal_stays_unknown() -> None:
"""Статуса нет → по-прежнему 'unknown': причину не выдумываем (#2764)."""
runs = await _run_all_blocked(None)
assert _ban_kinds_of(runs) == Counter({"unknown": 3})
@pytest.mark.asyncio
async def test_run_mixed_statuses_keep_multiplicity() -> None:
"""Перепись несёт КРАТНОСТИ — их разбирает _dominant_ban_kind, а не set (#3178)."""
snapshot = _make_snapshot(3)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(
side_effect=[
DomClickBlockedError("refused", status=403),
DomClickBlockedError("refused", status=429),
DomClickBlockedError("upstream", status=502),
]
)
with (
patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")),
patch(_SESSION_SVC, _mock_session_svc()),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
await run_domclick_detail_backfill(
db,
run_id=3196,
params={"batch_size": 3, "budget_sec": 3600, "max_consecutive_blocks": 3},
)
assert _ban_kinds_of(runs) == Counter({"platform": 2, "infra": 1})
@pytest.mark.asyncio
async def test_clean_run_passes_empty_ban_kinds() -> None:
"""Без блоков перепись пуста — финализатор не должен видеть выдуманный диагноз."""
snapshot = _make_snapshot(2)
db = _mock_db(snapshot)
runs = MagicMock()
with (
patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")),
patch(_SESSION_SVC, _mock_session_svc()),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()),
patch(_FETCH, AsyncMock(return_value=MagicMock())),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
await run_domclick_detail_backfill(
db, run_id=3196, params={"batch_size": 2, "budget_sec": 3600}
)
assert _ban_kinds_of(runs) == Counter()