Merge pull request 'fix(tradein/domclick): мы считали блоком собственное недоделанное рукопожатие' (#3237) from fix/domclick-challenge-not-a-block into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Successful in 2m59s
Deploy Trade-In / build-frontend (push) Successful in 3m27s
Deploy Trade-In / test (push) Successful in 4m25s
Deploy Trade-In / build-backend (push) Successful in 33s
Deploy Trade-In / deploy (push) Successful in 2m35s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Failing after 10s

This commit is contained in:
bot-backend 2026-08-29 15:38:04 +00:00
commit d43d8b0c60
3 changed files with 389 additions and 9 deletions

View file

@ -122,7 +122,7 @@ import asyncio
import base64 import base64
import logging import logging
import os import os
from collections.abc import Mapping from collections.abc import Callable, Mapping
from urllib.parse import urlparse from urllib.parse import urlparse
from aiohttp import web 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) 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 «страница прямо сейчас перезагружается». Ловим по
# тексту, а не по типу: сервис не импортирует playwright напрямую (page приходит # тексту, а не по типу: сервис не импортирует playwright напрямую (page приходит
# уже готовым), а Error/TimeoutError у него не образуют отдельной иерархии для # уже готовым), а Error/TimeoutError у него не образуют отдельной иерархии для
@ -1261,8 +1310,14 @@ async def _content_during_navigation(page: object) -> str | None:
raise raise
async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str: async def _wait_out_pow_challenge(
"""Опрашивает page.content() пока не исчезнут маркеры PoW-челленджа. 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`) после Страница перезагружает СЕБЯ САМА (`window.location = location.href`) после
решения PoW URL не меняется, поэтому page.wait_for_url тут не годится; решения 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, а не тихая отдача BROWSER_CHALLENGE_WAIT_MS ChallengeTimeoutError, а не тихая отдача
challenge-страницы как будто это валидный контент. challenge-страницы как будто это валидный контент.
`is_pending` (дефолт `_is_pow_challenge`, поведение Авито и прочих
провайдеров не меняется) предикат «страница ещё не устоялась, опроси
снова». DomClick передаёт свой (см. _fetch_once) там нет отдельного
маркера самого челленджа, есть только «точно успех» / «точно отказ»
(#3196), поэтому pending = «ни то, ни другое».
После снятия челленджа даём странице догидрироваться тем же BROWSER_WAIT_MS, После снятия челленджа даём странице догидрироваться тем же BROWSER_WAIT_MS,
каким ждём обычную навигацию (второй таймаут не изобретаем). каким ждём обычную навигацию (второй таймаут не изобретаем).
""" """
poll_interval_ms = 1000 poll_interval_ms = 1000
elapsed_ms = 0 elapsed_ms = 0
html: str | None = await _content_during_navigation(page) 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] await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined]
elapsed_ms += poll_interval_ms elapsed_ms += poll_interval_ms
html = await _content_during_navigation(page) 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( raise ChallengeTimeoutError(
f"tradein-browser[{provider}]: PoW-челлендж не снялся за " f"tradein-browser[{provider}]: PoW-челлендж не снялся за "
f"{BROWSER_CHALLENGE_WAIT_MS}мс url={url!r}" f"{BROWSER_CHALLENGE_WAIT_MS}мс url={url!r}"
@ -1432,9 +1493,37 @@ async def _fetch_once(
raise BanPageDetectedError( raise BanPageDetectedError(
f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}" 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) — в отличие от бана снимается сам по себе; ждём его # PoW-челлендж (QRATOR) — в отличие от бана снимается сам по себе; ждём его
# прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент. # прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент.
if _is_pow_challenge(html): elif _is_pow_challenge(html):
status = _last_response_status.get(provider) status = _last_response_status.get(provider)
if status in _REFUSAL_STATUSES: if status in _REFUSAL_STATUSES:
# Маркеры челленджа при 403/429 — это отказ, а не проверка: # Маркеры челленджа при 403/429 — это отказ, а не проверка:
@ -1443,7 +1532,9 @@ async def _fetch_once(
# контент, мы бы воскресили регрессию #3045 — авитовская бан-страница # контент, мы бы воскресили регрессию #3045 — авитовская бан-страница
# приходит ровно с 403/429 (см. _BAN_MARKERS выше), и парсер получил # приходит ровно с 403/429 (см. _BAN_MARKERS выше), и парсер получил
# бы 7891-байтный челлендж вместо карточки. Клиент уже трактует # бы 7891-байтный челлендж вместо карточки. Клиент уже трактует
# BanPageDetectedError как блок. # BanPageDetectedError как блок. Статус НЕ добавляем 401 сюда —
# у DomClick (единственного, кто отдаёт 401) отказ распознаётся
# выше по телу, а не по коду, см. ветку provider == "domclick".
raise BanPageDetectedError( raise BanPageDetectedError(
f"tradein-browser[{provider}]: HTTP {status} + маркеры челленджа " f"tradein-browser[{provider}]: HTTP {status} + маркеры челленджа "
f"— отказ площадки, ждать нечего url={url!r}" 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 return None
async def content(self) -> str: 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: async def close(self) -> None:
self.closed += 1 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) 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.new_page_calls == 1
assert browser.contexts_created == [] assert browser.contexts_created == []