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 == []