fix(tradein/browser): дожидаться QRATOR PoW-челленджа Авито вместо тихой заглушки

Живой замер 2026-08-21 (#3045): фиксированной паузы BROWSER_WAIT_MS (6с) не
хватает на цепочку «PoW-расчёт в JS → таймер 3с → self-reload → гидрация» —
4 из 6 карточек с органической навигацией отдавали 7891-байтную challenge-
страницу вместо контента (не бан, "проверка безопасности"). caller считал её
валидным HTML — парсер либо падал, либо молча ничего не находил.

_fetch_once теперь опрашивает page.content() (шаг ~1с, бюджет
BROWSER_CHALLENGE_WAIT_MS=30000) пока маркеры челленджа (startPow / "проверка
безопасности") не исчезнут, затем догидрируется тем же BROWSER_WAIT_MS. По
истечении бюджета — ChallengeTimeoutError вместо заглушки. wait_for_url не
годится: страница перезагружает саму себя, URL не меняется.

Бан-страница ("проблема с IP") распознаётся отдельно и падает сразу
(BanPageDetectedError), без траты бюджета ожидания — это не то же самое, что
челлендж, и ждать там нечего.

Провайдер-агностично по форме: включается только по факту маркеров в HTML,
cian/yandex/generic их никогда не отдают.
This commit is contained in:
bot-backend 2026-08-21 19:52:00 +03:00
parent bebeb0c8f0
commit 0f6523f852
2 changed files with 340 additions and 0 deletions

View file

@ -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,95 @@ 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)
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 = await page.content() # type: ignore[attr-defined]
while _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 page.content() # type: ignore[attr-defined]
if _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]
return await page.content() # type: ignore[attr-defined]
async def _fetch_once(
provider: str,
url: str,
@ -950,6 +1060,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]

View file

@ -0,0 +1,219 @@
"""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-вызовы для
проверки, что бюджет ожидания не тратится там, где не должен.
"""
def __init__(self, html_sequence: list[str]) -> 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)
html = self._html_sequence[idx]
self._call_count += 1
return html
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"]