fix(tradein/domclick): недосчитанная QRATOR-страница уходила наверх как контент
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m15s

Домклик отдаёт рукопожатие без единого стабильного маркера (в отличие от
Авито), поэтому _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.
This commit is contained in:
bot-backend 2026-08-29 18:31:35 +03:00
parent ac2633011b
commit 5391a36880
3 changed files with 389 additions and 9 deletions

View file

@ -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, ...] = (
"похоже, ваш запрос выглядит необычно",
"<title>403 | домклик</title>",
)
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}"

View file

@ -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 = (
'<html><head><script src="/__qrator/qauth_utm_v2d_v9118.js"></script></head>'
"<body></body></html>"
)
_RENDERED_POW_HTML = (
"<html><body><script>window.location = location.href;</script>"
"<div>ожидание...</div></body></html>"
)
_REFUSAL_HTML = (
"<html><head><title>403 | Домклик</title></head>"
"<body>Похоже, ваш запрос выглядит необычно. Мы усилили защиту.</body></html>"
)
_SUCCESS_HTML = '<html><body><script>window.__SSR_STATE__ = {"card": {}};</script></body></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]

View file

@ -73,7 +73,12 @@ class _FakePage:
return None
async def content(self) -> str:
return "<html>ok</html>"
# Содержит __SSR_STATE__ (#3196): для provider="domclick" это единственный
# признак "успешной" страницы (см. server._is_domclick_success) — без него
# generic-контент теста уходил бы в ветку ожидания рукопожатия и падал по
# ChallengeTimeoutError. avito/cian игнорируют эту строку (их детекторы
# ищут другие маркеры), так что для них поведение не меняется.
return "<html>ok<script>window.__SSR_STATE__={}</script></html>"
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 == "<html>ok</html>"
assert html == "<html>ok<script>window.__SSR_STATE__={}</script></html>"
assert browser.new_page_calls == 1
assert browser.contexts_created == []