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 не исполняют.
186 lines
7.8 KiB
Python
186 lines
7.8 KiB
Python
"""#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
|