diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index ed630a90..3d6f47a9 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -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] diff --git a/tradein-mvp/browser/test_server_pow_challenge.py b/tradein-mvp/browser/test_server_pow_challenge.py new file mode 100644 index 00000000..863b7a55 --- /dev/null +++ b/tradein-mvp/browser/test_server_pow_challenge.py @@ -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 = ( + "
" +) +_BAN_HTML = "Доступ ограничен: проблема с IP" +_REAL_HTML = "listing card content" + + +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 = "Доступ ограничен: проверка безопасности" + 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"))