fix(tradein/domclick): мы считали блоком собственное недоделанное рукопожатие #3237
3 changed files with 389 additions and 9 deletions
|
|
@ -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}"
|
||||
|
|
|
|||
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
|
||||
|
||||
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 == []
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue