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
Сайдкар вообще не читал код ответа 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 не исполняют.
216 lines
8.9 KiB
Python
216 lines
8.9 KiB
Python
"""Честный 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()
|