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
Добор карточек Домклика упирался в отказ на 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.
283 lines
13 KiB
Python
283 lines
13 KiB
Python
"""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"))
|