gendesign/tradein-mvp/backend/tests/test_3196_yandex_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

186 lines
7.8 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.

"""#3196: у yandex_detail_backfill не было счётчика blocked — бан был невозможен.
`mark_backfill_finished` переводит прогон в 'banned' только при
`counters["blocked"] > 0`. Яндексовый detail-backfill этот счётчик не вёл вовсе,
поэтому ветка (`scrape_runs.py`, `if blocked and (aborted_by_blocks or produced == 0)`)
была недостижима ПО ПОСТРОЕНИЮ: прод за 14 дней — 12 done, 1 failed, 1 zombie и
НОЛЬ банов, при том что 403/429 от площадки прогоны видели.
Диагноз теперь берётся из HTTP-статуса ответа, а не из текстовых маркеров
страницы (маркеры сняты с чужой площадки и на домкликовском отказе не сработали —
исходный симптом эпика): 403/429 → platform, 5xx → infra, статус, который о блоке
ничего не сообщает → 'unknown' (честное «не знаем», а не «блока не было»).
Тесты гоняют НАСТОЯЩИЙ `mark_backfill_finished` — иначе проверялась бы только
передача аргумента, а не итоговый `scrape_runs.ban_kind`.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import scrape_runs as real_runs
from app.tasks.yandex_detail_backfill import run_yandex_detail_backfill
_ASYNC_SESSION = "app.tasks.yandex_detail_backfill.AsyncSession"
_PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse"
_RUNS = "app.tasks.yandex_detail_backfill.runs_mod"
_SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep"
_RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url"
class _Recorder:
"""Подменяет runs_mod, но финализацию отдаёт настоящей mark_backfill_finished.
Так тест видит именно тот исход, который получит строка scrape_runs: статус и
ban_kind считает продовый код, а не сам тест.
"""
def __init__(self) -> None:
self.status: str | None = None
self.ban_kind: str | None = None
self.counters: dict[str, Any] = {}
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.status = "done"
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
self.status = "failed"
def mark_banned(
self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any
) -> None:
self.status = "banned"
self.ban_kind = kw.get("ban_kind")
def mark_backfill_finished(
self, db: Any, run_id: int, counters: dict[str, Any], **kw: Any
) -> None:
self.counters = counters
with (
patch.object(real_runs, "mark_done", self.mark_done),
patch.object(real_runs, "mark_failed", self.mark_failed),
patch.object(real_runs, "mark_banned", self.mark_banned),
):
real_runs.mark_backfill_finished(db, run_id, counters, **kw)
def _mock_db(n: int) -> MagicMock:
snapshot = [
{"id": i + 1, "source_url": f"https://realty.yandex.ru/offer/{i + 1}/"} for i in range(n)
]
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
sel.one.return_value = SimpleNamespace(url_from_offer_id=0, unenrichable_pending=0)
db.execute.return_value = sel
return db
def _resp(status: int) -> MagicMock:
resp = MagicMock()
resp.status_code = status
resp.text = "<html>отказ</html>"
return resp
def _session_cls(responses: list[MagicMock]) -> MagicMock:
session = AsyncMock()
session.get = AsyncMock(side_effect=responses)
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=session)
ctx.__aexit__ = AsyncMock(return_value=None)
return MagicMock(return_value=ctx)
async def _drive(status: int, *, count: int = 5, parse_result: Any = None) -> _Recorder:
"""Прогон из `count` одинаковых ответов со статусом `status`."""
recorder = _Recorder()
db = _mock_db(count)
with (
patch(_ASYNC_SESSION, _session_cls([_resp(status)] * count)),
patch(_PARSE, return_value=parse_result),
patch(_RUNS, recorder),
patch(_SLEEP, new_callable=AsyncMock),
patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://proxy:3128")),
):
await run_yandex_detail_backfill(
db,
run_id=3196,
params={"batch_size": count, "budget_sec": 3600, "max_consecutive_blocks": count},
)
return recorder
@pytest.mark.asyncio
async def test_http_403_marks_platform_ban() -> None:
"""403 — осознанный отказ площадки. До правки: 0 банов, blocked не считался."""
rec = await _drive(403)
assert rec.status == "banned", rec.status
assert rec.ban_kind == "platform"
assert rec.counters["blocked"] == 5
assert rec.counters["ban_kinds"] == {"platform": 5}
@pytest.mark.asyncio
async def test_http_429_marks_platform_ban() -> None:
"""429 (rate-limit) — та же природа, что 403: отказала площадка."""
rec = await _drive(429)
assert rec.status == "banned"
assert rec.ban_kind == "platform"
@pytest.mark.asyncio
async def test_http_5xx_marks_infra_ban() -> None:
"""5xx — сбой тракта/площадки, не адресный бан: 'infra', не 'platform'."""
rec = await _drive(503)
assert rec.status == "banned"
assert rec.ban_kind == "infra"
assert rec.counters["ban_kinds"] == {"infra": 5}
@pytest.mark.asyncio
async def test_unrecognized_refusal_marks_unknown_ban() -> None:
"""Отказ есть, а статус о его природе молчит → 'unknown', но прогон банится.
Ровно случай эпика: страница-отказ, которую наши маркеры не опознают. Молчание
статуса не должно превращаться в «блока не было».
"""
rec = await _drive(418)
assert rec.status == "banned"
assert rec.ban_kind == "unknown"
assert rec.counters["blocked"] == 5
@pytest.mark.asyncio
async def test_parse_none_at_200_is_not_a_ban() -> None:
"""Контроль: HTTP 200 + parse→None — по-прежнему 'failed', а не 'banned'.
При 200 отличить капчу от промаха НАШЕГО парсера нечем. Считать это блоком
значило бы называть внешней причиной каждый прогон, сломанный у нас, и потерять
сигнал 'failed' из #2674.
"""
rec = await _drive(200, parse_result=None)
assert rec.status == "failed", rec.status
assert rec.ban_kind is None
assert rec.counters["blocked"] == 0
@pytest.mark.asyncio
async def test_successful_run_stays_done() -> None:
"""Контроль: обогащение идёт — прогон 'done', банов нет."""
with patch("app.tasks.yandex_detail_backfill.save_detail_enrichment", return_value=True):
rec = await _drive(200, parse_result=MagicMock())
assert rec.status == "done", rec.status
assert rec.ban_kind is None
assert rec.counters["blocked"] == 0