gendesign/tradein-mvp/browser/test_server_challenge_reload.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

143 lines
7 KiB
Python
Raw Permalink 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_challenge_reload.py — сайдкар сам перезагружает зависший PoW-челлендж.
Повод — ручная сессия 2026-08-29 через узел 10 (asocks-mobile-2, прод-прокси,
headful-камуфокс, путь «яндекс-поиск → выдача Домклика → карточка»). Запись сети:
102.7с GET /pokupka/kvartiry/vtorichka → 401
107.7с выдана кука qrator_jsr (PoW пошёл)
115.1с GET /pokupka/kvartiry/vtorichka → 401 ← перезагрузка человеком
118.4с GET /pokupka/kvartiry/vtorichka → 200 ← вторая перезагрузка
119.6с выдана кука qrator_jsid2 (ПРОПУСК)
124.6с GET /card/sale__flat__2078134349 → 200
То есть допущение «страница перезагрузит себя сама после решения PoW», на котором
был построен _wait_out_pow_challenge, выполняется НЕ всегда: здесь пропуск выдался
только после навигаций, сделанных руками. Пока сайдкар лишь опрашивал content(),
такая страница гарантированно доживала до BROWSER_CHALLENGE_WAIT_MS и уходила в
ChallengeTimeoutError, а бэкфилл засчитывал это в блоки.
Здесь проверяется, что сайдкар делает то же, что сделал человек: подождав
BROWSER_CHALLENGE_RELOAD_AFTER_MS, перезагружает страницу сам, до
BROWSER_CHALLENGE_MAX_RELOADS раз, и что это не ломает прежние пути.
camoufox НЕ запускается: поддельная page с заданной последовательностью content().
wait_for_timeout — no-op, поэтому тесты идут мгновенно.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_challenge_reload.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
from pathlib import Path
from typing import Any
import pytest
_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)
PENDING = "<html><body>проверка безопасности</body></html>"
DONE = "<html><body>listing card content</body></html>"
class _Page:
"""Поддельная page: content() идёт по списку, reload() двигает курсор дальше.
`reload_unblocks_at` — индекс, начиная с которого content() отдаёт DONE, но
ТОЛЬКО если к этому моменту была хотя бы одна перезагрузка. Так моделируется
именно наблюдавшееся поведение: сама по себе страница не «дозревает», её
расколдовывает повторная навигация.
"""
def __init__(self, *, unblock_after_reloads: int | None = None) -> None:
self.reload_calls: list[dict[str, Any]] = []
self.wait_for_timeout_calls: list[int] = []
self._unblock_after_reloads = unblock_after_reloads
self.reload_error: Exception | None = None
async def wait_for_timeout(self, ms: int) -> None:
self.wait_for_timeout_calls.append(ms)
async def reload(self, **kwargs: Any) -> None:
if self.reload_error is not None:
raise self.reload_error
self.reload_calls.append(kwargs)
async def content(self) -> str:
if (
self._unblock_after_reloads is not None
and len(self.reload_calls) >= self._unblock_after_reloads
):
return DONE
return PENDING
@pytest.fixture(autouse=True)
def _fast_budget(monkeypatch: pytest.MonkeyPatch) -> None:
"""Короткий бюджет + отключённая догидрация: тест про перезагрузки, не про сон."""
monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 30_000)
monkeypatch.setattr(server, "BROWSER_CHALLENGE_RELOAD_AFTER_MS", 8_000)
monkeypatch.setattr(server, "BROWSER_CHALLENGE_MAX_RELOADS", 2)
monkeypatch.setattr(server, "BROWSER_WAIT_MS", 0)
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
def _wait(page: _Page) -> str:
return asyncio.run(
server._wait_out_pow_challenge(
page, "domclick", "https://example.test/card/1", is_pending=lambda h: h == PENDING
)
)
def test_hung_challenge_is_reloaded_and_resolves() -> None:
"""Главный сценарий: сама страница не оживает, оживает после перезагрузки."""
page = _Page(unblock_after_reloads=1)
assert _wait(page) == DONE
assert len(page.reload_calls) == 1
def test_reload_not_used_when_challenge_clears_on_its_own() -> None:
"""Страница, которая перезагружает себя сама, лишней навигации не получает."""
page = _Page(unblock_after_reloads=0) # DONE сразу, ещё до первого опроса
assert _wait(page) == DONE
assert page.reload_calls == []
def test_reload_count_is_capped() -> None:
"""Вечно висящий челлендж не превращается в долбёжку — не больше лимита."""
page = _Page(unblock_after_reloads=None) # не оживает никогда
with pytest.raises(server.ChallengeTimeoutError):
_wait(page)
assert len(page.reload_calls) == server.BROWSER_CHALLENGE_MAX_RELOADS
def test_reload_disabled_by_zero_threshold(monkeypatch: pytest.MonkeyPatch) -> None:
"""BROWSER_CHALLENGE_RELOAD_AFTER_MS=0 возвращает ровно прежнее поведение."""
monkeypatch.setattr(server, "BROWSER_CHALLENGE_RELOAD_AFTER_MS", 0)
page = _Page(unblock_after_reloads=None)
with pytest.raises(server.ChallengeTimeoutError):
_wait(page)
assert page.reload_calls == []
def test_failed_reload_does_not_abort_the_wait() -> None:
"""Перезагрузка — помощь, а не обязательный шаг: её падение не роняет фетч.
Ошибка навигации здесь означает лишь, что помочь не удалось; опрос обязан
продолжиться в прежнем бюджете и упереться в честный ChallengeTimeoutError,
а не вывалиться наружу чужим исключением.
"""
page = _Page(unblock_after_reloads=None)
page.reload_error = RuntimeError("Navigation timeout of 30000ms exceeded")
with pytest.raises(server.ChallengeTimeoutError):
_wait(page)
assert page.reload_calls == []