gendesign/tradein-mvp/browser/test_server_pow_challenge.py
bot-backend ff0a15d443
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
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 1m28s
CI Trade-In / backend-tests (pull_request) Successful in 5m1s
feat(tradein/browser): ходить по Домклику как человек — с Referer и с перезагрузкой зависшего рукопожатия
Добор карточек Домклика упирался в отказ на 11-й карточке: прогон 5298 дал
attempted=13, enriched=10, blocked=3. Ручные прогоны в живом браузере брали 91 и
40 карточек без единого отказа. Разбор нашёл два отличия, и оба оказались нашими,
а не площадки.

1. Referer не отправлялся НИКОГДА. playwright'овский goto() по умолчанию этот
   заголовок не шлёт, а параметр `referer`, который он принимает, мы не передавали.
   Площадка видела десяток появлений подряд прямо на URL карточки, без источника
   перехода — так не ходит ни один человек. Комментарии в коде при этом уверяли
   про «органическую навигацию с реальным Referer»; они врали, теперь исправлены.
   Сайдкар принимает `referer` в теле /fetch и ставит его ТОЛЬКО на целевую
   навигацию; на origin и якорную вкладку не ставит — туда приходят «сами».
   Добор Домклика передаёт страницу выдачи, чем переход и является по смыслу.

2. Зависшее рукопожатие не перезагружалось. _wait_out_pow_challenge построен на
   допущении «страница перезагрузит себя сама после решения PoW»; ручная сессия
   29.08 через узел 10 это опровергла — выдача осталась на 401, и пропуск
   qrator_jsid2 выдался только после ДВУХ перезагрузок, сделанных руками:
   102.7с GET → 401, 115.1с GET → 401, 118.4с GET → 200, следом кука-пропуск, и
   карточка за 6 секунд. Пока мы только опрашивали content(), такая страница жила
   до таймаута, а бэкфилл засчитывал это в блоки. Теперь после
   BROWSER_CHALLENGE_RELOAD_AFTER_MS (8с) сайдкар перезагружает сам, не больше
   BROWSER_CHALLENGE_MAX_RELOADS (2) раз за фетч.

Оба пути безопасны на откат: без поля `referer` в теле поведение прежнее,
BROWSER_CHALLENGE_RELOAD_AFTER_MS=0 возвращает прежний опрос без навигаций,
упавшая перезагрузка не роняет фетч — опрос продолжается в том же бюджете.

Тесты: 191 passed в сайдкаре (было 182) — 4 на Referer, 5 на перезагрузку, в том
числе «страница ожила сама → лишней навигации нет» и «висит вечно → не больше
лимита». Точечные backend-тесты домклика и scraper_kit — 144 passed.
2026-08-29 22:53:11 +03:00

283 lines
13 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.

"""test_server_pow_challenge.py — QRATOR PoW-челлендж Авито в /fetch (#3045).
Проблема, установленная замером на проде 2026-08-21: Авито за QRATOR отдаёт
proof-of-work челлендж (JS считает PoW, ставит куку pow_solved, через
setTimeout(3000) делает `window.location = location.href` — self-reload, URL не
меняется). Фиксированной паузы BROWSER_WAIT_MS (6с) на цепочку
«PoW → таймер 3с → перезагрузка → гидрация» хватало не всегда: живой замер дал
2 успеха из 6 карточек, 4 отказа — все «проверка безопасности», НЕ бан по IP.
Тестируется _fetch_once целиком (не только вынесенный polling-хелпер), чтобы
проверить весь путь: goto → content() → детект → (ожидание | ошибка) → return.
camoufox НЕ запускается: _browsers[provider] — поддельный browser/page,
возвращающие заранее заданную последовательность content(). wait_for_timeout
на фейковой page — no-op (без реального asyncio.sleep), поэтому тесты идут
мгновенно независимо от величины BROWSER_CHALLENGE_WAIT_MS/BROWSER_WAIT_MS.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_pow_challenge.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
from pathlib import Path
from typing import Any
import pytest
# 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 состояние на каждый тест (зеркалит test_server_smoke.py)."""
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, "_locks_guard", asyncio.Lock())
_CHALLENGE_HTML = (
"<html><body><script>"
"document.addEventListener('DOMContentLoaded', function() {"
" if (getCookie('pow_solved')) {"
" setTimeout(function() { window.location = location.href; }, 3000);"
" return;"
" }"
" startPow(1);"
"});"
"</script></body></html>"
)
_BAN_HTML = "<html><body>Доступ ограничен: проблема с IP</body></html>"
_REAL_HTML = "<html><body>listing card content</body></html>"
class _ChallengePage:
"""Поддельная page: отдаёт заданную последовательность content() по вызовам.
После исчерпания списка повторяет последний элемент (имитирует «страница
осталась в этом состоянии»). Фиксирует goto/wait_for_timeout-вызовы для
проверки, что бюджет ожидания не тратится там, где не должен.
Элемент последовательности может быть исключением — тогда content() его
поднимает. Это нужно, чтобы воспроизвести гонку с self-reload челленджа:
playwright кидает «Execution context was destroyed» ровно в момент той
перезагрузки, которую мы ждём, и на моках без этого дефект не виден.
"""
def __init__(self, html_sequence: list[str | Exception]) -> None:
self._html_sequence = html_sequence
self._call_count = 0
self.goto_urls: list[str] = []
self.reload_calls: list[dict[str, Any]] = []
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) -> None:
self.goto_urls.append(url)
async def reload(self, **kwargs: Any) -> None:
# Сайдкар сам перезагружает зависший челлендж (#3244-след), поэтому фейковая
# page обязана уметь reload — иначе тесты ловили бы AttributeError в ветке
# помощи и молча уходили в warning вместо проверки поведения.
self.reload_calls.append(kwargs)
async def wait_for_timeout(self, ms: int) -> None:
self.wait_for_timeout_calls.append(ms)
async def content(self) -> str:
idx = min(self._call_count, len(self._html_sequence) - 1)
item = self._html_sequence[idx]
self._call_count += 1
if isinstance(item, Exception):
raise item
return item
async def close(self) -> None:
self.closed += 1
class _ChallengeBrowser:
def __init__(self, page: _ChallengePage) -> None:
self._page = page
async def new_page(self) -> _ChallengePage:
return self._page
def _install(
monkeypatch: pytest.MonkeyPatch, page: _ChallengePage, provider: str = "avito"
) -> None:
server._browsers[provider] = _ChallengeBrowser(page)
monkeypatch.setattr(
server, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 10_000),
)
# ── детекторы: чистые функции ────────────────────────────────────────────────────
def test_is_pow_challenge_true_on_start_pow_marker() -> None:
assert server._is_pow_challenge(_CHALLENGE_HTML) is True
def test_is_pow_challenge_true_on_title_marker() -> None:
html = "<html><body>Доступ ограничен: проверка безопасности</body></html>"
assert server._is_pow_challenge(html) is True
def test_is_pow_challenge_false_on_ban_page() -> None:
"""Бан-страница НЕ должна ложно матчиться как челлендж — разные ветки."""
assert server._is_pow_challenge(_BAN_HTML) is False
def test_is_pow_challenge_false_on_real_content() -> None:
assert server._is_pow_challenge(_REAL_HTML) is False
def test_is_ban_page_true_on_ip_problem_marker() -> None:
assert server._is_ban_page(_BAN_HTML) is True
def test_is_ban_page_false_on_challenge_page() -> None:
assert server._is_ban_page(_CHALLENGE_HTML) is False
def test_is_ban_page_false_on_real_content() -> None:
assert server._is_ban_page(_REAL_HTML) is False
def test_challenge_wait_budget_defaults_to_30s(monkeypatch: pytest.MonkeyPatch) -> None:
"""Дефолт BROWSER_CHALLENGE_WAIT_MS=30000 без переопределения env."""
monkeypatch.delenv("BROWSER_CHALLENGE_WAIT_MS", raising=False)
import os
assert int(os.environ.get("BROWSER_CHALLENGE_WAIT_MS", "30000")) == 30000
# ── _fetch_once: сценарии ────────────────────────────────────────────────────────
def test_fetch_once_waits_out_challenge_then_returns_real_content(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Челлендж на первых двух опросах, третий — уже реальный контент → он и вернётся."""
page = _ChallengePage([_CHALLENGE_HTML, _CHALLENGE_HTML, _REAL_HTML])
_install(monkeypatch, page)
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
assert html == _REAL_HTML
assert page.closed == 1
# Обычная гидро-пауза + минимум одна пауза опроса челленджа + финальная
# догидрация тем же BROWSER_WAIT_MS (второй таймаут не изобретаем).
assert server.BROWSER_WAIT_MS in page.wait_for_timeout_calls
assert 1000 in page.wait_for_timeout_calls # шаг опроса
def test_fetch_once_raises_challenge_timeout_when_budget_exhausted(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Челлендж не снимается никогда → ChallengeTimeoutError, а не заглушка-контент."""
monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 2000)
page = _ChallengePage([_CHALLENGE_HTML]) # всегда челлендж (последний элемент повторяется)
_install(monkeypatch, page)
with pytest.raises(server.ChallengeTimeoutError):
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
# finally всё равно закрывает страницу, несмотря на исключение.
assert page.closed == 1
def test_fetch_once_raises_ban_error_immediately_without_spending_budget(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Бан-страница («проблема с IP») → своя ошибка сразу, БЕЗ цикла опроса."""
page = _ChallengePage([_BAN_HTML])
_install(monkeypatch, page)
with pytest.raises(server.BanPageDetectedError):
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
assert page.closed == 1
# Единственный wait_for_timeout — обычная гидро-пауза ДО детекта; опроса
# челленджа (доп. паузы по 1000мс) не было — бюджет не потрачен.
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
def test_fetch_once_normal_page_without_markers_unaffected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Страница без маркеров челленджа/бана → поведение как раньше, без polling-ветки."""
page = _ChallengePage([_REAL_HTML])
_install(monkeypatch, page)
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
assert html == _REAL_HTML
assert page.closed == 1
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
assert page.goto_urls == ["https://www.avito.ru/card/1"]
# ── гонка с self-reload челленджа (#3045, найдено при ревью ветки) ──────────────
#
# Челлендж перезагружает страницу САМ. Вызов page.content(), попавший ровно в этот
# момент, кидает «Execution context was destroyed» — то есть цикл ожидания падал бы
# именно на успешном исходе, ради которого написан. На моках без явной имитации
# это не воспроизводится, поэтому тесты ниже поднимают исключение из content().
_NAV_RACE = RuntimeError(
"Execution context was destroyed, most likely because of a navigation."
)
def test_navigation_race_during_reload_is_not_a_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""content() упал на перезагрузке → опрашиваем дальше, отдаём настоящий HTML."""
page = _ChallengePage([_CHALLENGE_HTML, _NAV_RACE, _REAL_HTML])
_install(monkeypatch, page)
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
assert html == _REAL_HTML
def test_permanent_navigation_race_raises_challenge_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Навигация не прекращается → своя ошибка, а не сырое исключение playwright."""
page = _ChallengePage([_CHALLENGE_HTML, _NAV_RACE])
_install(monkeypatch, page)
with pytest.raises(server.ChallengeTimeoutError):
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
def test_unrelated_content_error_still_propagates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Глушим ТОЛЬКО гонку навигации; упавший браузер должен всплыть как есть."""
boom = RuntimeError("Target page, context or browser has been closed")
page = _ChallengePage([_CHALLENGE_HTML, boom])
_install(monkeypatch, page)
with pytest.raises(RuntimeError, match="has been closed"):
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))