All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 1m5s
Замер на проде 2026-08-29. Три независимых запуска camoufox через прод-прокси, идём по карточкам до первого отказа — каждый раз одно и то же: карточка #1 даёт 200 и ~1 МБ с SSR-стейтом, карточка #2 даёт 401 и страницу отказа на 26 625 байт. A/B на восьми карточках: свой браузер на каждую — 4 из 4 успеха, один браузер на четыре — 1 из 4. Прямой выход с сервера и выход через прокси неразличимы (1 из 8 в обоих условиях), то есть дело не в IP. Свежего КОНТЕКСТА не хватает: в контрольном замере каждая карточка бралась через browser.new_page(), в новом изолированном контексте, — и всё равно отказ со второй. Признак живёт на уровне процесса, camoufox генерирует отпечаток при запуске, а не при создании контекста. Отсюда: reset_context (#3118) эту задачу не решает в принципе. Цена перезапуска — 0.6 с (3.5 с только первый, холодный). - PROVIDERS: добавлен "domclick" (+ host-detect). Раньше он проваливался в generic и делил браузер со счётчиком страниц с прочим трафиком — при пороге перезапуска 1 это было бы неверно. - BROWSER_RECYCLE_PAGES стал поставщик-зависимым (_resolve_recycle_pages + BROWSER_RECYCLE_PAGES_{PROVIDER}), по образцу BROWSER_BLOCK_IMAGES_{PROVIDER} из #3185. Код-дефолт domclick=1, остальным прежние 15 — у Авито и Циана узор другой и своего замера под него нет. Отдельно починены ~24 холостых охранника в тестах. Они делали monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000), чтобы запретить перезапуск браузера; после перехода на словарь этот патч перестал на что-либо влиять, и набор оставался зелёным лишь потому, что ни один тест не делает 15 страниц подряд. Теперь патчится _RECYCLE_PAGES_BY_PROVIDER, а сама глобальная константа убрана, чтобы её не патчили снова. Проверено мутацией: при пороге 1 для всех провайдеров падают ровно три теста, которые этот дефолт и проверяют, остальные 155 удерживаются — значит охранники работают.
295 lines
12 KiB
Python
295 lines
12 KiB
Python
"""test_server_http_status.py — HTTP-статус целевой навигации в /fetch (#3196).
|
||
|
||
Проблема, установленная замером на проде 28.08.2026: ДомКлик отдаёт статическую
|
||
страницу «403 | Домклик» ровно на 26 624 байта, в которой НЕТ ни startpow, ни
|
||
qrator, ни капчи, ни PoW-скрипта. Ни один маркер из _CHALLENGE_MARKERS/_BAN_MARKERS
|
||
(оба сняты с авитовских страниц) в неё не попадает, поэтому отказ уезжал наверх как
|
||
валидный контент, парсер не находил состояние страницы, и прогон получал блок
|
||
неизвестной природы — 14 прогонов domclick_detail_backfill подряд с ban_kind=unknown.
|
||
|
||
_fetch_once игнорировал Response, который возвращает page.goto. Теперь статус
|
||
целевой навигации сохраняется в _last_response_status[provider] и отдаётся в теле
|
||
/fetch рядом с html — АДДИТИВНО, ключ "html" не меняется.
|
||
|
||
camoufox НЕ запускается: _browsers[provider] — поддельный browser/page (зеркалит
|
||
test_server_pow_challenge.py). wait_for_timeout на фейковой page — no-op.
|
||
|
||
Запуск (из tradein-mvp/browser/)::
|
||
|
||
python -m pytest test_server_http_status.py -q
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import importlib.util
|
||
import json
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from aiohttp.test_utils import make_mocked_request
|
||
|
||
# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
|
||
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
|
||
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
|
||
assert _spec is not None and _spec.loader is not None
|
||
server = importlib.util.module_from_spec(_spec)
|
||
_spec.loader.exec_module(server)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Чистое per-provider состояние на каждый тест (зеркалит соседние тесты)."""
|
||
monkeypatch.setattr(server, "_browsers", {})
|
||
monkeypatch.setattr(server, "_browser_cms", {})
|
||
monkeypatch.setattr(server, "_page_counters", {})
|
||
monkeypatch.setattr(server, "_locks", {})
|
||
monkeypatch.setattr(server, "_retry_tasks", {})
|
||
monkeypatch.setattr(server, "_last_goto_at", {})
|
||
monkeypatch.setattr(server, "_last_response_status", {})
|
||
monkeypatch.setattr(server, "_launched_proxy", {})
|
||
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
||
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
|
||
|
||
|
||
# Заглушка ДомКлика: 403 и НИ ОДНОГО маркера из _CHALLENGE_MARKERS/_BAN_MARKERS —
|
||
# ровно то, на чём ломался старый детектор.
|
||
_DOMCLICK_403_HTML = "<html><head><title>403 | Домклик</title></head><body></body></html>"
|
||
_REAL_HTML = "<html><body>listing card content</body></html>"
|
||
# Челлендж Авито: маркер startPow при HTTP 200 — путь, который трогать нельзя.
|
||
_CHALLENGE_HTML = "<html><body><script>startPow(1);</script></body></html>"
|
||
|
||
|
||
class _Response:
|
||
"""Поддельный playwright Response — интересует только .status."""
|
||
|
||
def __init__(self, status: int) -> None:
|
||
self.status = status
|
||
|
||
|
||
class _Page:
|
||
"""Поддельная page: goto отдаёт заданный Response, content() — заданный HTML.
|
||
|
||
goto_results — по одному элементу на вызов goto (None = «playwright вернул None»,
|
||
навигационная гонка/редирект). После исчерпания повторяет последний.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
html_sequence: list[str],
|
||
goto_results: list[_Response | None],
|
||
) -> None:
|
||
self._html_sequence = html_sequence
|
||
self._goto_results = goto_results
|
||
self._content_calls = 0
|
||
self.goto_urls: list[str] = []
|
||
self.wait_for_timeout_calls: list[int] = []
|
||
self.closed = 0
|
||
|
||
async def route(self, pattern: str, handler: Any) -> None:
|
||
return None
|
||
|
||
async def goto(self, url: str, **kwargs: Any) -> _Response | None:
|
||
idx = min(len(self.goto_urls), len(self._goto_results) - 1)
|
||
self.goto_urls.append(url)
|
||
return self._goto_results[idx]
|
||
|
||
async def wait_for_timeout(self, ms: int) -> None:
|
||
self.wait_for_timeout_calls.append(ms)
|
||
|
||
async def content(self) -> str:
|
||
idx = min(self._content_calls, len(self._html_sequence) - 1)
|
||
self._content_calls += 1
|
||
return self._html_sequence[idx]
|
||
|
||
async def close(self) -> None:
|
||
self.closed += 1
|
||
|
||
|
||
class _Browser:
|
||
def __init__(self, page: _Page) -> None:
|
||
self._page = page
|
||
|
||
async def new_page(self) -> _Page:
|
||
return self._page
|
||
|
||
|
||
def _install(monkeypatch: pytest.MonkeyPatch, page: _Page, provider: str = "avito") -> None:
|
||
server._browsers[provider] = _Browser(page)
|
||
monkeypatch.setattr(
|
||
server, "_RECYCLE_PAGES_BY_PROVIDER",
|
||
dict.fromkeys(server.PROVIDERS, 10_000),
|
||
)
|
||
monkeypatch.setattr(server, "BROWSER_WAIT_MS", 0)
|
||
monkeypatch.setattr(server, "_MIN_PAGE_INTERVAL_BY_PROVIDER", {})
|
||
monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0)
|
||
|
||
|
||
def _json_body(response: Any) -> dict[str, Any]:
|
||
return json.loads(response.body.decode())
|
||
|
||
|
||
async def _coro(value: Any) -> Any:
|
||
return value
|
||
|
||
|
||
def _make_request(body: dict[str, Any]) -> Any:
|
||
request = make_mocked_request("POST", "/fetch")
|
||
request.json = lambda: _coro(body) # type: ignore[method-assign]
|
||
return request
|
||
|
||
|
||
# ── _status_of: чтение статуса ────────────────────────────────────────────────
|
||
|
||
|
||
def test_status_of_reads_response_status() -> None:
|
||
assert server._status_of(_Response(403)) == 403
|
||
|
||
|
||
def test_status_of_none_response_is_none() -> None:
|
||
"""goto вернул None (редирект/навигационная гонка) — не падаем, статуса нет."""
|
||
assert server._status_of(None) is None
|
||
|
||
|
||
# ── _fetch_once: статус пишется в per-provider состояние ──────────────────────
|
||
|
||
|
||
def test_fetch_once_records_target_status(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
page = _Page([_DOMCLICK_403_HTML], [_Response(403)])
|
||
_install(monkeypatch, page)
|
||
|
||
html = asyncio.run(server._fetch_once("avito", "https://domclick.ru/card/1"))
|
||
|
||
assert html == _DOMCLICK_403_HTML # контент отдан как раньше — правка аддитивна
|
||
assert server._last_response_status["avito"] == 403
|
||
|
||
|
||
def test_fetch_once_status_none_when_goto_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
page = _Page([_REAL_HTML], [None])
|
||
_install(monkeypatch, page)
|
||
|
||
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
|
||
|
||
assert html == _REAL_HTML
|
||
assert server._last_response_status["avito"] is None
|
||
|
||
|
||
def test_fetch_once_status_taken_from_target_not_origin(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Прогрев origin отдал 200, целевая навигация — 403. Наверх идёт 403."""
|
||
page = _Page([_DOMCLICK_403_HTML], [_Response(200), _Response(403)])
|
||
_install(monkeypatch, page)
|
||
|
||
asyncio.run(
|
||
server._fetch_once("avito", "https://domclick.ru/card/1", origin="https://domclick.ru/")
|
||
)
|
||
|
||
assert page.goto_urls == ["https://domclick.ru/", "https://domclick.ru/card/1"]
|
||
assert server._last_response_status["avito"] == 403
|
||
|
||
|
||
def test_fetch_once_resets_stale_status_before_navigation(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Статус прошлого запроса не должен пережить следующую навигацию."""
|
||
server._last_response_status["avito"] = 403
|
||
page = _Page([_REAL_HTML], [None])
|
||
_install(monkeypatch, page)
|
||
|
||
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
|
||
|
||
assert server._last_response_status["avito"] is None
|
||
|
||
|
||
# ── статус в диагностике: 403/429 не ждут PoW ─────────────────────────────────
|
||
|
||
|
||
def test_fetch_once_skips_pow_wait_on_403(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""403 + маркеры челленджа — отказ, а не проверка: бюджет ожидания не тратим.
|
||
|
||
Заглушку наверх НЕ отдаём: вернув её как валидный HTML, воскресили бы #3045 —
|
||
парсер Авито получил бы страницу челленджа вместо карточки.
|
||
"""
|
||
page = _Page([_CHALLENGE_HTML], [_Response(403)])
|
||
_install(monkeypatch, page)
|
||
monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 30_000)
|
||
|
||
with pytest.raises(server.BanPageDetectedError):
|
||
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
|
||
|
||
assert page.wait_for_timeout_calls == [] # ни одного тика ожидания челленджа
|
||
|
||
|
||
def test_fetch_once_skips_pow_wait_on_429(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
page = _Page([_CHALLENGE_HTML], [_Response(429)])
|
||
_install(monkeypatch, page)
|
||
|
||
with pytest.raises(server.BanPageDetectedError):
|
||
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
|
||
|
||
assert page.wait_for_timeout_calls == []
|
||
|
||
|
||
def test_fetch_once_still_waits_out_challenge_on_200(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""HTTP 200 с PoW-заглушкой (путь Авито) не задет: ждём и получаем контент."""
|
||
page = _Page([_CHALLENGE_HTML, _CHALLENGE_HTML, _REAL_HTML], [_Response(200)])
|
||
_install(monkeypatch, page)
|
||
|
||
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
|
||
|
||
assert html == _REAL_HTML
|
||
assert page.wait_for_timeout_calls # ожидание челленджа реально шло
|
||
|
||
|
||
def test_fetch_once_ban_marker_still_raises_on_200(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Ветка _is_ban_page осталась: «проблема с IP» с кодом 200 — по-прежнему бан."""
|
||
page = _Page(["<html>Доступ ограничен: проблема с IP</html>"], [_Response(200)])
|
||
_install(monkeypatch, page)
|
||
|
||
with pytest.raises(server.BanPageDetectedError):
|
||
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
|
||
|
||
|
||
# ── /fetch: статус доезжает в тело ответа ─────────────────────────────────────
|
||
|
||
|
||
def test_fetch_handler_returns_status_next_to_html(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setattr(server, "IS_PROD", False)
|
||
|
||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||
return True
|
||
|
||
async def _fake_do_fetch(provider: str, url: str, **_kw: Any) -> str:
|
||
server._last_response_status[provider] = 403
|
||
return _DOMCLICK_403_HTML
|
||
|
||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||
monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch)
|
||
|
||
response = asyncio.run(server.fetch_handler(_make_request({"url": "https://domclick.ru/x"})))
|
||
|
||
assert response.status == 200
|
||
body = _json_body(response)
|
||
assert body["html"] == _DOMCLICK_403_HTML # старый ключ не тронут
|
||
assert body["status"] == 403
|
||
|
||
|
||
def test_fetch_handler_status_null_without_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Статуса нет (goto вернул None) — ответ не ломается, status=null."""
|
||
monkeypatch.setattr(server, "IS_PROD", False)
|
||
|
||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||
return True
|
||
|
||
async def _fake_do_fetch(provider: str, url: str, **_kw: Any) -> str:
|
||
server._last_response_status[provider] = None
|
||
return _REAL_HTML
|
||
|
||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||
monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch)
|
||
|
||
response = asyncio.run(server.fetch_handler(_make_request({"url": "https://www.avito.ru/x"})))
|
||
|
||
body = _json_body(response)
|
||
assert body["html"] == _REAL_HTML
|
||
assert body["status"] is None
|