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
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:
commit
d43d8b0c60
3 changed files with 389 additions and 9 deletions
|
|
@ -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}"
|
||||||
|
|
|
||||||
284
tradein-mvp/browser/test_server_domclick_challenge.py
Normal file
284
tradein-mvp/browser/test_server_domclick_challenge.py
Normal 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]
|
||||||
|
|
@ -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 == []
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue