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

160 lines
6.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.

"""HTTP-статус сайдкара наверх: `BrowserFetcher.last_response_status` (#3196).
Сайдкар (tradein-mvp/browser/server.py) теперь кладёт в тело /fetch HTTP-код целевой
навигации рядом с html: ``{"html": ..., "status": <int|null>}``. Kit выносит его на
инстанс фетчера — АТРИБУТОМ, а не возвратом ``fetch()``: поток управления менять
нельзя, ``fetch()`` по-прежнему отдаёт ``str`` и по-прежнему не бросает там, где не
бросал раньше.
Зачем: ДомКлик отдаёт статическую страницу «403 | Домклик» на 26 624 байта, где нет
ни startpow, ни qrator, ни капчи — ни один текстовый маркер сайдкара (все сняты с
Авито) на неё не срабатывает, и отказ уезжал наверх как валидный контент. 14 прогонов
domclick_detail_backfill подряд получили ban_kind=unknown ровно поэтому.
Инварианты:
- status из тела → last_response_status (int) на КАЖДЫЙ успешный fetch;
- ключа "status" нет (сайдкар старой версии) ИЛИ он null → None, БЕЗ исключения;
- status нечислового типа → None (мусор в теле не должен ронять фетч);
- fetch упал → last_response_status сброшен в None (не отдаём статус прошлого);
- ban_kind_from_status раскладывает код в значение, допустимое CHECK-ограничением
scrape_runs.ban_kind ("platform" | "infra" | "unknown" | NULL).
httpx полностью замокан (зеркалит test_kit_browser_fetcher_proxy_pool.py).
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
def _mock_client(json_payload: dict[str, Any], *, raise_exc: Exception | None = None) -> MagicMock:
"""httpx.AsyncClient-заглушка: .post → resp c raise_for_status/json."""
resp = MagicMock()
if raise_exc is not None:
resp.raise_for_status.side_effect = raise_exc
else:
resp.raise_for_status.return_value = None
resp.json.return_value = json_payload
client = MagicMock()
client.post = AsyncMock(return_value=resp)
client.aclose = AsyncMock(return_value=None)
return client
async def _fetcher(client: MagicMock, **kwargs: Any) -> BrowserFetcher:
"""Реально входит в `__aenter__`, потом подменяет httpx-клиент."""
bf = BrowserFetcher(endpoint="http://browser:3000", **kwargs)
await bf.__aenter__()
bf._client = client
return bf
# ── last_response_status ──────────────────────────────────────────────────────
async def test_status_starts_as_none() -> None:
"""До первого fetch статуса нет — атрибут существует и равен None."""
client = _mock_client({"html": "<ok>", "status": 200})
bf = await _fetcher(client, source="domclick")
assert bf.last_response_status is None
async def test_status_from_body_is_exposed() -> None:
client = _mock_client({"html": "<403 page>", "status": 403})
bf = await _fetcher(client, source="domclick")
html = await bf.fetch("https://domclick.ru/card/1")
assert html == "<403 page>" # поток управления не изменился — fetch отдаёт str
assert bf.last_response_status == 403
async def test_status_updated_on_every_fetch() -> None:
"""Атрибут обновляется КАЖДЫМ _post_fetch, а не только первым."""
client = _mock_client({"html": "<ok>", "status": 200})
bf = await _fetcher(client, source="domclick")
await bf.fetch("https://domclick.ru/1")
assert bf.last_response_status == 200
client.post.return_value.json.return_value = {"html": "<403>", "status": 403}
await bf.fetch("https://domclick.ru/2")
assert bf.last_response_status == 403
async def test_missing_status_key_is_none_and_does_not_raise() -> None:
"""Сайдкар старой версии (тело без "status") — фетч проходит, статуса просто нет."""
client = _mock_client({"html": "<ok>"})
bf = await _fetcher(client, source="avito")
html = await bf.fetch("https://avito.ru/x")
assert html == "<ok>"
assert bf.last_response_status is None
async def test_null_status_is_none() -> None:
"""goto вернул None (редирект/навигационная гонка) → сайдкар шлёт status=null."""
client = _mock_client({"html": "<ok>", "status": None})
bf = await _fetcher(client, source="avito")
await bf.fetch("https://avito.ru/x")
assert bf.last_response_status is None
async def test_non_int_status_is_ignored() -> None:
"""Мусор в поле status не должен ронять фетч — читается как «статуса нет»."""
client = _mock_client({"html": "<ok>", "status": "403"})
bf = await _fetcher(client, source="avito")
await bf.fetch("https://avito.ru/x")
assert bf.last_response_status is None
async def test_status_reset_on_failed_fetch() -> None:
"""Фетч упал — не отдаём статус ПРОШЛОГО запроса."""
client = _mock_client({"html": "<ok>", "status": 200})
bf = await _fetcher(client, source="avito")
await bf.fetch("https://avito.ru/1")
assert bf.last_response_status == 200
client.post.side_effect = RuntimeError("transport down")
with pytest.raises(RuntimeError):
await bf.fetch("https://avito.ru/2")
assert bf.last_response_status is None
# ── ban_kind_from_status ──────────────────────────────────────────────────────
@pytest.mark.parametrize(
("status", "expected"),
[
(403, "platform"),
(429, "platform"),
(500, "infra"),
(502, "infra"),
(599, "infra"),
(200, None),
(301, None),
(404, None),
(None, None),
],
)
def test_ban_kind_from_status(status: int | None, expected: str | None) -> None:
assert ban_kind_from_status(status) == expected
def test_ban_kind_values_fit_scrape_runs_check() -> None:
"""Возврат обязан быть пригоден для scrape_runs.ban_kind как есть."""
allowed = {"platform", "infra", "unknown", None}
for status in (None, 200, 301, 403, 404, 429, 499, 500, 503, 599, 600):
assert ban_kind_from_status(status) in allowed