fix(tradein/browser): дожидаться QRATOR PoW-челленджа Авито вместо тихой заглушки #3046
2 changed files with 437 additions and 0 deletions
|
|
@ -36,6 +36,16 @@ Per-provider модель (#1793):
|
|||
BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска (default: 15)
|
||||
BROWSER_NAV_TIMEOUT_MS — таймаут page.goto в мс (default: 60000)
|
||||
BROWSER_WAIT_MS — ожидание гидрации listings после DOMContentLoaded, мс (default: 6000)
|
||||
BROWSER_CHALLENGE_WAIT_MS — бюджет ожидания QRATOR PoW-челленджа Авито (#3045),
|
||||
мс (default: 30000). Челлендж-страница сама считает
|
||||
proof-of-work в JS, ставит куку pow_solved и через
|
||||
setTimeout(3000) делает window.location = location.href
|
||||
(self-reload, URL не меняется). /fetch опрашивает
|
||||
page.content() пока маркеры челленджа не исчезнут; по
|
||||
истечении бюджета — ChallengeTimeoutError вместо тихой
|
||||
отдачи заглушки. Действует ТОЛЬКО при обнаружении
|
||||
маркеров челленджа в разметке — прочие провайдеры этот
|
||||
путь никогда не задевают.
|
||||
BROWSER_BLOCK_RESOURCE_TYPES — CSV типов ресурсов Playwright, которые abort'ить
|
||||
через page.route при навигации (default: "font,media").
|
||||
Снижает число одновременных под-коннектов на страницу →
|
||||
|
|
@ -113,6 +123,17 @@ BROWSER_NAV_TIMEOUT_MS: int = int(os.environ.get("BROWSER_NAV_TIMEOUT_MS", "6000
|
|||
# выдача (~50 карточек, 3.2МБ). Подтверждено прод-дебагом 2026-05-31.
|
||||
BROWSER_WAIT_MS: int = int(os.environ.get("BROWSER_WAIT_MS", "6000"))
|
||||
|
||||
# 30000: цепочка PoW-челленджа Авито — startPow() в JS, затем setTimeout(3000) на
|
||||
# self-reload, затем повторная гидрация страницы. 3с таймера самой площадки — это
|
||||
# ТОЛЬКО задержка перед reload, не бюджет на сам расчёт PoW: под headless-браузером
|
||||
# и egress-прокси решение может занять заметно дольше, чем в обычном браузере
|
||||
# пользователя. Живой замер 2026-08-21 (#3045): без ожидания челленджа 4 из 6
|
||||
# карточек с органической навигацией отдавали 7891-байтную челлендж-страницу вместо
|
||||
# контента — не бан (403/429 не было), просто уходили раньше, чем страница себя
|
||||
# перезагрузила. 30с — запас с кратным резервом на решение + reload + догидрацию,
|
||||
# не превращающий единичный фетч в минуту ожидания при реальном бане/сетевой пробе.
|
||||
BROWSER_CHALLENGE_WAIT_MS: int = int(os.environ.get("BROWSER_CHALLENGE_WAIT_MS", "30000"))
|
||||
|
||||
# /fetch-json settle после goto(origin) перед in-page fetch (#1917). 500мс мало:
|
||||
# первый XHR иногда ловит `NetworkError when attempting to fetch resource` (anti-bot/
|
||||
# сетевой стек страницы ещё не готов). Лечился внешним retry (re-navigation ~30-45с/дом).
|
||||
|
|
@ -898,6 +919,138 @@ async def _do_fetch(
|
|||
raise
|
||||
|
||||
|
||||
# ── QRATOR PoW-челлендж Авито (#3045) ────────────────────────────────────────────
|
||||
# Маркеры сняты живьём с challenge-страницы Авито 2026-08-21 (замер: 6 карточек,
|
||||
# органическая навигация из выдачи, 4/6 ушли с челленджа раньше времени). Разметка
|
||||
# площадки может поменяться со временем — при протухании маркеров переснять их
|
||||
# заново вживую, а не гадать по памяти. Форма провайдер-агностична: детектор просто
|
||||
# ищет строки в HTML, другие площадки (cian/yandex/generic) их никогда не отдают,
|
||||
# поэтому ветка ожидания для них не включается.
|
||||
|
||||
# Признак самого челленджа: JS-функция startPow(), которую страница вызывает в
|
||||
# DOMContentLoaded (см. хвост challenge-скрипта в #3045), либо заголовок блока
|
||||
# «Доступ ограничен: проверка безопасности» — оба встречались на снятых страницах.
|
||||
_CHALLENGE_MARKERS: tuple[str, ...] = (
|
||||
"startpow",
|
||||
"доступ ограничен: проверка безопасности",
|
||||
)
|
||||
|
||||
# Признак БАН-страницы (не челлендж): «Доступ ограничен: проблема с IP» — статика
|
||||
# без PoW-скрипта, приходит с 403/429 и заметно меньше challenge-страницы весом.
|
||||
# Ждать тут бессмысленно — адрес заблокирован, а не временно проверяется.
|
||||
_BAN_MARKERS: tuple[str, ...] = ("доступ ограничен: проблема с ip",)
|
||||
|
||||
|
||||
class ChallengeTimeoutError(Exception):
|
||||
"""PoW-челлендж не снялся за BROWSER_CHALLENGE_WAIT_MS.
|
||||
|
||||
Caller должен трактовать как временный отказ (retry/backoff), НЕ как валидный
|
||||
контент — раньше caller получал 7891-байтную challenge-страницу и парсер либо
|
||||
падал на ней, либо молча ничего не находил (#3045).
|
||||
"""
|
||||
|
||||
|
||||
class BanPageDetectedError(Exception):
|
||||
"""Площадка отдала бан-страницу («проблема с IP») вместо контента/челленджа.
|
||||
|
||||
В отличие от ChallengeTimeoutError ждать здесь бессмысленно: адрес забанен, а
|
||||
не проходит временную проверку — поднимается сразу, без траты
|
||||
BROWSER_CHALLENGE_WAIT_MS.
|
||||
"""
|
||||
|
||||
|
||||
def _is_pow_challenge(html: str) -> bool:
|
||||
"""True, если HTML — QRATOR PoW-челлендж Авито (см. _CHALLENGE_MARKERS)."""
|
||||
lower = html.lower()
|
||||
return any(marker in lower for marker in _CHALLENGE_MARKERS)
|
||||
|
||||
|
||||
def _is_ban_page(html: str) -> bool:
|
||||
"""True, если HTML — бан-страница «проблема с IP» (см. _BAN_MARKERS)."""
|
||||
lower = html.lower()
|
||||
return any(marker in lower for marker in _BAN_MARKERS)
|
||||
|
||||
|
||||
# Маркеры исключения playwright «страница прямо сейчас перезагружается». Ловим по
|
||||
# тексту, а не по типу: сервис не импортирует playwright напрямую (page приходит
|
||||
# уже готовым), а Error/TimeoutError у него не образуют отдельной иерархии для
|
||||
# этого случая.
|
||||
_NAVIGATION_RACE_MARKERS: tuple[str, ...] = (
|
||||
"execution context was destroyed",
|
||||
"most likely because of a navigation",
|
||||
"page is navigating",
|
||||
)
|
||||
|
||||
|
||||
async def _content_during_navigation(page: object) -> str | None:
|
||||
"""`page.content()`, устойчивый к перезагрузке страницы под руками.
|
||||
|
||||
PoW-челлендж перезагружает себя сам (`window.location = location.href`), и
|
||||
вызов content(), попавший ровно в этот момент, кидает «Execution context was
|
||||
destroyed». Для нас это НЕ ошибка, а признак того, что перезагрузка — та
|
||||
самая, которую мы ждём, — идёт прямо сейчас. Возвращаем None = «ещё не
|
||||
устоялось, опроси снова», а не роняем фетч на самом успешном исходе.
|
||||
|
||||
Всё остальное (закрытая страница, упавший браузер) пробрасываем как есть.
|
||||
"""
|
||||
try:
|
||||
return await page.content() # type: ignore[attr-defined]
|
||||
except Exception as exc: # noqa: BLE001 — тип не импортируем, различаем по тексту
|
||||
text = str(exc).lower()
|
||||
if any(marker in text for marker in _NAVIGATION_RACE_MARKERS):
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str:
|
||||
"""Опрашивает page.content() пока не исчезнут маркеры PoW-челленджа.
|
||||
|
||||
Страница перезагружает СЕБЯ САМА (`window.location = location.href`) после
|
||||
решения PoW — URL не меняется, поэтому page.wait_for_url тут не годится;
|
||||
опрашиваем контент с шагом ~1с вместо этого. По истечении
|
||||
BROWSER_CHALLENGE_WAIT_MS — ChallengeTimeoutError, а не тихая отдача
|
||||
challenge-страницы как будто это валидный контент.
|
||||
|
||||
После снятия челленджа даём странице догидрироваться тем же BROWSER_WAIT_MS,
|
||||
каким ждём обычную навигацию (второй таймаут не изобретаем).
|
||||
"""
|
||||
poll_interval_ms = 1000
|
||||
elapsed_ms = 0
|
||||
html: str | None = await _content_during_navigation(page)
|
||||
while (html is None or _is_pow_challenge(html)) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS:
|
||||
await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined]
|
||||
elapsed_ms += poll_interval_ms
|
||||
html = await _content_during_navigation(page)
|
||||
|
||||
if html is None or _is_pow_challenge(html):
|
||||
raise ChallengeTimeoutError(
|
||||
f"tradein-browser[{provider}]: PoW-челлендж не снялся за "
|
||||
f"{BROWSER_CHALLENGE_WAIT_MS}мс url={url!r}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"tradein-browser[%s]: PoW-челлендж снят за ~%dмс, догидрация url=%r",
|
||||
provider,
|
||||
elapsed_ms,
|
||||
url,
|
||||
)
|
||||
if BROWSER_WAIT_MS > 0:
|
||||
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
|
||||
settled = await _content_during_navigation(page)
|
||||
if settled is None:
|
||||
# Догидрация совпала с ещё одной навигацией — даём один короткий добор
|
||||
# вместо того, чтобы падать: контент уже не challenge, гонка чисто
|
||||
# техническая.
|
||||
await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined]
|
||||
settled = await _content_during_navigation(page)
|
||||
if settled is None:
|
||||
raise ChallengeTimeoutError(
|
||||
f"tradein-browser[{provider}]: челлендж снят, но страница не устоялась "
|
||||
f"(навигация не прекращается) url={url!r}"
|
||||
)
|
||||
return settled
|
||||
|
||||
|
||||
async def _fetch_once(
|
||||
provider: str,
|
||||
url: str,
|
||||
|
|
@ -950,6 +1103,17 @@ async def _fetch_once(
|
|||
if BROWSER_WAIT_MS > 0:
|
||||
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
|
||||
html: str = await page.content() # type: ignore[attr-defined]
|
||||
|
||||
# Бан-страница («проблема с IP») распознаётся и падает СРАЗУ, без траты
|
||||
# BROWSER_CHALLENGE_WAIT_MS — ждать там нечего, адрес заблокирован (#3045).
|
||||
if _is_ban_page(html):
|
||||
raise BanPageDetectedError(
|
||||
f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}"
|
||||
)
|
||||
# PoW-челлендж (QRATOR) — в отличие от бана снимается сам по себе; ждём его
|
||||
# прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент.
|
||||
if _is_pow_challenge(html):
|
||||
html = await _wait_out_pow_challenge(page, provider, url)
|
||||
finally:
|
||||
await page.close() # type: ignore[attr-defined]
|
||||
|
||||
|
|
|
|||
273
tradein-mvp/browser/test_server_pow_challenge.py
Normal file
273
tradein-mvp/browser/test_server_pow_challenge.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
"""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.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 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, "BROWSER_RECYCLE_PAGES", 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"))
|
||||
Loading…
Add table
Reference in a new issue