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.
143 lines
7 KiB
Python
143 lines
7 KiB
Python
"""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 == []
|