From 5391a36880944ff3cb454f6146e4c252f7be8beb Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 18:31:35 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/domclick):=20=D0=BD=D0=B5=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D1=87=D0=B8=D1=82=D0=B0=D0=BD=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?QRATOR-=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20?= =?UTF-8?q?=D1=83=D1=85=D0=BE=D0=B4=D0=B8=D0=BB=D0=B0=20=D0=BD=D0=B0=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=85=20=D0=BA=D0=B0=D0=BA=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Домклик отдаёт рукопожатие без единого стабильного маркера (в отличие от Авито), поэтому _CHALLENGE_MARKERS (сняты с Авито, #3045) на нём никогда не матчились и ветка ожидания не включалась — недосчитанная страница уезжала наверх, парсер не находил __SSR_STATE__ и поднимал ложный блок. Это и был двухнедельный attempted=3, blocked=3, enriched=0 у domclick_detail_backfill. Для provider=="domclick" логика инвертирована: положительно опознаём только два крайних состояния — успех (__SSR_STATE__) и статический отказ площадки («403 | Домклик» / «похоже, ваш запрос выглядит необычно»); всё остальное (загрузчик рукопожатия, нерендеренная PoW-страница без каких-либо маркеров) трактуется как «рукопожатие ещё идёт» и уходит в существующий _wait_out_pow_challenge с кастомным is_pending. HTTP-статус для DomClick не используется как сигнал (401 приходит и у отказа, и у успеха, и у здорового рукопожатия) — решает только тело. Avito и прочие провайдеры идут по старой elif-ветке без изменений. _wait_out_pow_challenge получил опциональный параметр is_pending (дефолт _is_pow_challenge) — golden-parity для всех, кроме domclick. --- tradein-mvp/browser/server.py | 105 ++++++- .../browser/test_server_domclick_challenge.py | 284 ++++++++++++++++++ .../browser/test_server_reuse_context.py | 9 +- 3 files changed, 389 insertions(+), 9 deletions(-) create mode 100644 tradein-mvp/browser/test_server_domclick_challenge.py diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 56b20e29..a7e55c98 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -122,7 +122,7 @@ import asyncio import base64 import logging import os -from collections.abc import Mapping +from collections.abc import Callable, Mapping from urllib.parse import urlparse from aiohttp import web @@ -1230,6 +1230,55 @@ def _is_ban_page(html: str) -> bool: return any(marker in lower for marker in _BAN_MARKERS) +# ── QRATOR-рукопожатие DomClick (#3196, замер прода 29.08.2026) ───────────────── +# У DomClick, в отличие от Авито, у промежуточной (нерендеренной) PoW-страницы НЕТ +# ни одного стабильного маркера вообще — ни __qrator, ни startpow, ни текста отказа +# (замер: 3 запроса подряд на здоровом узле — 1-й вернул 401/6898 байт без единого +# опознаваемого признака, 2-й и 3-й — уже 200/готовая карточка). Строить детектор +# «это челлендж» тут не на чем: любой угаданный маркер протухнет на следующей смене +# вёрстки, а до тех пор будет ловить призрак несуществующего состояния. +# +# Поэтому логика инвертирована: опознаём положительно два КРАЙНИХ состояния — +# «точно готово» и «точно отказ», а всё остальное (загрузчик рукопожатия на 279 +# байт, нерендеренная PoW-страница на ~6.9-7.1 КБ) считаем «рукопожатие ещё идёт» +# и уходим в _wait_out_pow_challenge вместо того, чтобы гадать по размеру — +# отказ у DomClick тоже статическая страница фиксированного размера (~26.6 КБ), +# см. _DOMCLICK_REFUSAL_MARKERS, поэтому размер как единственный признак ненадёжен. +# +# Признак успеха — JS-глобаль __SSR_STATE__, литеральная строка, которую кладёт +# в разметку SSR-рендер готовой карточки (та же строка, из-за отсутствия которой +# парсер бэкфилла поднимал ложный «блок», см. #3196). Сайдкар в остальном +# провайдер-агностичен и не обязан знать про внутренний контракт парсера, но +# альтернатива — «отсутствие маркеров отказа + размер» — здесь СЛАБЕЕ, а не чище: +# и загрузчик (279 байт), и промежуточная PoW-страница (~7 КБ), и будущая +# перекроенная вёрстка отказа тоже прошли бы такую проверку как «успех». Прямой +# маркер успеха — единственный вариант, который не размывается на промежуточных +# состояниях. +_DOMCLICK_SUCCESS_MARKER = "__SSR_STATE__" + +# Признак ОТКАЗА площадки (не рукопожатия) — статическая страница «403 | Домклик» +# с текстом про подозрительный запрос, ~26.6 КБ, приходит с HTTP 401 (см. +# _REFUSAL_STATUSES ниже — статус НЕ используется как сигнал для DomClick, тело +# приходит с тем же кодом, что и у здорового рукопожатия и у одной из успешных +# страниц в замере). Ждать тут нечего — статическая страница сама себя не +# перезагрузит. +_DOMCLICK_REFUSAL_MARKERS: tuple[str, ...] = ( + "похоже, ваш запрос выглядит необычно", + "403 | домклик", +) + + +def _is_domclick_success(html: str) -> bool: + """True, если HTML — отрендеренная карточка DomClick (см. _DOMCLICK_SUCCESS_MARKER).""" + return _DOMCLICK_SUCCESS_MARKER in html + + +def _is_domclick_refusal(html: str) -> bool: + """True, если HTML — статический отказ площадки DomClick (см. _DOMCLICK_REFUSAL_MARKERS).""" + lower = html.lower() + return any(marker in lower for marker in _DOMCLICK_REFUSAL_MARKERS) + + # Маркеры исключения playwright «страница прямо сейчас перезагружается». Ловим по # тексту, а не по типу: сервис не импортирует playwright напрямую (page приходит # уже готовым), а Error/TimeoutError у него не образуют отдельной иерархии для @@ -1261,8 +1310,14 @@ async def _content_during_navigation(page: object) -> str | None: raise -async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str: - """Опрашивает page.content() пока не исчезнут маркеры PoW-челленджа. +async def _wait_out_pow_challenge( + page: object, + provider: str, + url: str, + *, + is_pending: Callable[[str], bool] = _is_pow_challenge, +) -> str: + """Опрашивает page.content() пока `is_pending(html)` не станет False. Страница перезагружает СЕБЯ САМА (`window.location = location.href`) после решения PoW — URL не меняется, поэтому page.wait_for_url тут не годится; @@ -1270,18 +1325,24 @@ async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str: BROWSER_CHALLENGE_WAIT_MS — ChallengeTimeoutError, а не тихая отдача challenge-страницы как будто это валидный контент. + `is_pending` (дефолт `_is_pow_challenge`, поведение Авито и прочих + провайдеров не меняется) — предикат «страница ещё не устоялась, опроси + снова». DomClick передаёт свой (см. _fetch_once) — там нет отдельного + маркера самого челленджа, есть только «точно успех» / «точно отказ» + (#3196), поэтому pending = «ни то, ни другое». + После снятия челленджа даём странице догидрироваться тем же 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: + while (html is None or is_pending(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): + if html is None or is_pending(html): raise ChallengeTimeoutError( f"tradein-browser[{provider}]: PoW-челлендж не снялся за " f"{BROWSER_CHALLENGE_WAIT_MS}мс url={url!r}" @@ -1432,9 +1493,37 @@ async def _fetch_once( raise BanPageDetectedError( f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}" ) + if provider == "domclick": + # DomClick — своя ветка (#3196): нет отдельного маркера самого + # рукопожатия (см. комментарий у _DOMCLICK_SUCCESS_MARKER), поэтому + # статус НЕ смотрим вовсе (в отличие от общей ветки ниже) — 401 + # приходит и у здорового рукопожатия, и у отказа, и у успешной + # страницы в замере 29.08.2026, различать нужно ТОЛЬКО по телу. + if _is_domclick_refusal(html): + raise BanPageDetectedError( + f"tradein-browser[{provider}]: статический отказ площадки " + f"url={url!r}" + ) + if not _is_domclick_success(html): + html = await _wait_out_pow_challenge( + page, + provider, + url, + is_pending=lambda h: not _is_domclick_success(h) + and not _is_domclick_refusal(h), + ) + if _is_domclick_refusal(html): + # Рукопожатие домоталось до отказа уже ПОСЛЕ начала опроса — + # is_pending вернул False (страница устоялась), но устоялась + # она в состояние «отказ», а не «успех». Наверх идёт то же + # исключение, что и для мгновенного отказа выше. + raise BanPageDetectedError( + f"tradein-browser[{provider}]: отказ площадки после " + f"ожидания рукопожатия url={url!r}" + ) # PoW-челлендж (QRATOR) — в отличие от бана снимается сам по себе; ждём его # прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент. - if _is_pow_challenge(html): + elif _is_pow_challenge(html): status = _last_response_status.get(provider) if status in _REFUSAL_STATUSES: # Маркеры челленджа при 403/429 — это отказ, а не проверка: @@ -1443,7 +1532,9 @@ async def _fetch_once( # контент, мы бы воскресили регрессию #3045 — авитовская бан-страница # приходит ровно с 403/429 (см. _BAN_MARKERS выше), и парсер получил # бы 7891-байтный челлендж вместо карточки. Клиент уже трактует - # BanPageDetectedError как блок. + # BanPageDetectedError как блок. Статус НЕ добавляем 401 сюда — + # у DomClick (единственного, кто отдаёт 401) отказ распознаётся + # выше по телу, а не по коду, см. ветку provider == "domclick". raise BanPageDetectedError( f"tradein-browser[{provider}]: HTTP {status} + маркеры челленджа " f"— отказ площадки, ждать нечего url={url!r}" diff --git a/tradein-mvp/browser/test_server_domclick_challenge.py b/tradein-mvp/browser/test_server_domclick_challenge.py new file mode 100644 index 00000000..cb4c4fd9 --- /dev/null +++ b/tradein-mvp/browser/test_server_domclick_challenge.py @@ -0,0 +1,284 @@ +"""test_server_domclick_challenge.py — QRATOR-рукопожатие DomClick в /fetch (#3196). + +Проблема, установленная замером прода 29.08.2026: у DomClick, в отличие от +Авито, промежуточная (нерендеренная) PoW-страница НЕ содержит ни одного +стабильного маркера вообще — ни `__qrator`, ни `startpow`, ни текста отказа. +`_CHALLENGE_MARKERS` (сняты живьём с Авито, #3045) на DomClick никогда не +матчатся, поэтому ветка `_wait_out_pow_challenge` для него не включалась — +недосчитанная страница уезжала наверх как валидный контент, парсер не находил +`__SSR_STATE__` и поднимал ложный "блок" (двухнедельный +`attempted=3, blocked=3, enriched=0` у `domclick_detail_backfill`). + +Логика для DomClick инвертирована: опознаём положительно ДВА крайних +состояния — «точно успех» (`_DOMCLICK_SUCCESS_MARKER`, JS-глобаль +`__SSR_STATE__`) и «точно отказ» (`_DOMCLICK_REFUSAL_MARKERS`, статическая +страница «403 | Домклик» ~26.6 КБ) — а всё остальное (загрузчик рукопожатия +279 байт, нерендеренная PoW-страница ~7 КБ) трактуется как «рукопожатие ещё +идёт» и уходит в существующий `_wait_out_pow_challenge` с кастомным +`is_pending`. + +Тестируется _fetch_once целиком (зеркалит test_server_pow_challenge.py), +camoufox не запускается — фейковые browser/page возвращают заранее заданную +последовательность content(). wait_for_timeout на фейковой page — no-op. + +Запуск (из tradein-mvp/browser/):: + + python -m pytest test_server_domclick_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()) + + +# Приблизительные образцы трёх состояний, снятых живьём 29.08.2026 (см. докстринг +# задачи) — точный размер в байтах для теста не важен, важно отсутствие/наличие +# опознаваемых строк. +_HANDSHAKE_LOADER_HTML = ( + '' + "" +) +_RENDERED_POW_HTML = ( + "" + "
ожидание...
" +) +_REFUSAL_HTML = ( + "403 | Домклик" + "Похоже, ваш запрос выглядит необычно. Мы усилили защиту." +) +_SUCCESS_HTML = '' + + +class _DomclickPage: + """Поддельная page: отдаёт заданную последовательность content() по вызовам. + + После исчерпания списка повторяет последний элемент (имитирует «страница + осталась в этом состоянии»). Зеркалит _ChallengePage из + test_server_pow_challenge.py. + """ + + 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) + item = self._html_sequence[idx] + self._call_count += 1 + return item + + async def close(self) -> None: + self.closed += 1 + + +class _DomclickBrowser: + def __init__(self, page: _DomclickPage) -> None: + self._page = page + + async def new_page(self) -> _DomclickPage: + return self._page + + +def _install( + monkeypatch: pytest.MonkeyPatch, page: _DomclickPage, provider: str = "domclick" +) -> None: + server._browsers[provider] = _DomclickBrowser(page) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", + dict.fromkeys(server.PROVIDERS, 10_000), + ) + + +# ── детекторы: чистые функции ──────────────────────────────────────────────────── + + +def test_is_domclick_success_true_on_ssr_state_marker() -> None: + assert server._is_domclick_success(_SUCCESS_HTML) is True + + +def test_is_domclick_success_false_on_handshake_loader() -> None: + assert server._is_domclick_success(_HANDSHAKE_LOADER_HTML) is False + + +def test_is_domclick_success_false_on_rendered_pow_page() -> None: + """Ключевой случай задачи: у рендеренной PoW-страницы НЕТ стабильных маркеров + вообще — детектор успеха не должен ложно сработать на ней.""" + assert server._is_domclick_success(_RENDERED_POW_HTML) is False + + +def test_is_domclick_success_false_on_refusal_page() -> None: + assert server._is_domclick_success(_REFUSAL_HTML) is False + + +def test_is_domclick_refusal_true_on_title_and_text_markers() -> None: + assert server._is_domclick_refusal(_REFUSAL_HTML) is True + + +def test_is_domclick_refusal_false_on_handshake_loader() -> None: + assert server._is_domclick_refusal(_HANDSHAKE_LOADER_HTML) is False + + +def test_is_domclick_refusal_false_on_rendered_pow_page() -> None: + assert server._is_domclick_refusal(_RENDERED_POW_HTML) is False + + +def test_is_domclick_refusal_false_on_success_page() -> None: + assert server._is_domclick_refusal(_SUCCESS_HTML) is False + + +# ── _fetch_once: сценарии DomClick ─────────────────────────────────────────────── + + +def test_fetch_once_refusal_page_raises_immediately_without_waiting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Страница отказа → BanPageDetectedError СРАЗУ, без ухода в опрос (пункт 1).""" + page = _DomclickPage([_REFUSAL_HTML]) + _install(monkeypatch, page) + + with pytest.raises(server.BanPageDetectedError): + asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")) + + assert page.closed == 1 + # Единственная пауза — обычная гидро-пауза ДО детекта; цикла опроса (доп. + # паузы по 1000мс) не было — бюджет ожидания не потрачен. + assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS] + + +def test_fetch_once_page_without_markers_waits_then_returns_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Ни успеха, ни отказа (загрузчик/рендеренная PoW-страница) → уходит в + ожидание, а не отдаёт недосчитанную страницу как контент (пункт 2).""" + page = _DomclickPage([_HANDSHAKE_LOADER_HTML, _RENDERED_POW_HTML, _SUCCESS_HTML]) + _install(monkeypatch, page) + + html = asyncio.run( + server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1") + ) + + assert html == _SUCCESS_HTML + assert page.closed == 1 + assert server.BROWSER_WAIT_MS in page.wait_for_timeout_calls + assert 1000 in page.wait_for_timeout_calls # шаг опроса — бюджет реально тратился + + +def test_fetch_once_success_page_returned_immediately_without_waiting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Успех с первого раза → отдаётся сразу, БЕЗ ухода в polling-ветку (пункт 3).""" + page = _DomclickPage([_SUCCESS_HTML]) + _install(monkeypatch, page) + + html = asyncio.run( + server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1") + ) + + assert html == _SUCCESS_HTML + assert page.closed == 1 + assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS] + + +def test_fetch_once_settles_into_refusal_during_wait_raises_ban_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Рукопожатие домоталось до отказа уже ПОСЛЕ начала опроса → тоже BanPageDetectedError, + а не отдача отказной страницы как валидного контента.""" + page = _DomclickPage([_HANDSHAKE_LOADER_HTML, _REFUSAL_HTML]) + _install(monkeypatch, page) + + with pytest.raises(server.BanPageDetectedError): + asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")) + + assert page.closed == 1 + + +def test_fetch_once_raises_challenge_timeout_when_budget_exhausted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Ни успех, ни отказ так и не наступают → ChallengeTimeoutError по истечении бюджета.""" + monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 2000) + page = _DomclickPage([_HANDSHAKE_LOADER_HTML]) # повторяется бесконечно + _install(monkeypatch, page) + + with pytest.raises(server.ChallengeTimeoutError): + asyncio.run(server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1")) + + assert page.closed == 1 + + +def test_fetch_once_401_status_is_not_used_as_refusal_signal_for_domclick( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """HTTP 401 — статус здорового рукопожатия DomClick (замер 29.08.2026), НЕ должен + трактоваться как отказ: только тело страницы решает.""" + + class _Response: + status = 401 + + class _StatusPage(_DomclickPage): + async def goto(self, url: str, **kwargs: Any) -> _Response | None: + self.goto_urls.append(url) + return _Response() + + page = _StatusPage([_HANDSHAKE_LOADER_HTML, _SUCCESS_HTML]) + _install(monkeypatch, page) + + html = asyncio.run( + server._fetch_once("domclick", "https://ekaterinburg.domclick.ru/card/1") + ) + + assert html == _SUCCESS_HTML + + +# ── не-домклик провайдеры: поведение не изменилось (пункт 4) ──────────────────── + + +def test_fetch_once_non_domclick_provider_ignores_domclick_markers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """avito с HTML, который матчил бы _is_domclick_success/_is_domclick_refusal, + не должен уходить ни в какую специальную ветку — DomClick-логика провайдер- + скопирована строго под `provider == "domclick"`.""" + page = _DomclickPage([_REFUSAL_HTML]) # содержит "похоже, ваш запрос..." — не avito-маркер + _install(monkeypatch, page, provider="avito") + + html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1")) + + assert html == _REFUSAL_HTML # отдан как обычный контент — avito его не распознаёт + assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS] diff --git a/tradein-mvp/browser/test_server_reuse_context.py b/tradein-mvp/browser/test_server_reuse_context.py index 53c7f05a..debc185d 100644 --- a/tradein-mvp/browser/test_server_reuse_context.py +++ b/tradein-mvp/browser/test_server_reuse_context.py @@ -73,7 +73,12 @@ class _FakePage: return None async def content(self) -> str: - return "ok" + # Содержит __SSR_STATE__ (#3196): для provider="domclick" это единственный + # признак "успешной" страницы (см. server._is_domclick_success) — без него + # generic-контент теста уходил бы в ветку ожидания рукопожатия и падал по + # ChallengeTimeoutError. avito/cian игнорируют эту строку (их детекторы + # ищут другие маркеры), так что для них поведение не меняется. + return "ok" async def close(self) -> None: self.closed += 1 @@ -205,7 +210,7 @@ def test_reset_context_without_reuse_is_noop_for_new_page_path() -> None: server._fetch_once("avito", "https://www.avito.ru/1", reset_context=True) ) - assert html == "ok" + assert html == "ok" assert browser.new_page_calls == 1 assert browser.contexts_created == [] -- 2.45.3