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