feat(tradein/browser): ходить по Домклику как человек — с Referer и с перезагрузкой зависшего рукопожатия #3250
9 changed files with 421 additions and 24 deletions
|
|
@ -340,6 +340,7 @@ async def test_fetch_detail_parses_via_browser() -> None:
|
|||
bf.fetch.assert_called_once_with(
|
||||
_CARD_URL,
|
||||
origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
|
||||
referer="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
|
||||
cookies=None,
|
||||
)
|
||||
|
||||
|
|
@ -361,6 +362,7 @@ async def test_fetch_detail_passes_derived_vtorichka_origin() -> None:
|
|||
bf.fetch.assert_called_once_with(
|
||||
other_card_url,
|
||||
origin="https://spb.domclick.ru/pokupka/kvartiry/vtorichka",
|
||||
referer="https://spb.domclick.ru/pokupka/kvartiry/vtorichka",
|
||||
cookies=None,
|
||||
)
|
||||
|
||||
|
|
@ -382,6 +384,7 @@ async def test_fetch_detail_passes_cookies_through_to_browser_fetcher() -> None:
|
|||
bf.fetch.assert_called_once_with(
|
||||
_CARD_URL,
|
||||
origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
|
||||
referer="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
|
||||
cookies=session_cookies,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ async def test_3118_pending_reset_consumed_exactly_once():
|
|||
bf = _fetcher(reuse_context=True)
|
||||
seen: list[bool] = []
|
||||
|
||||
async def _fake_post(url, origin=None, cookies=None, reset_context=False):
|
||||
async def _fake_post(url, origin=None, cookies=None, reset_context=False, referer=None):
|
||||
seen.append(reset_context)
|
||||
return "<html></html>"
|
||||
|
||||
|
|
@ -261,7 +261,7 @@ async def test_3118_pending_reset_survives_transport_retry():
|
|||
bf = _fetcher(reuse_context=True)
|
||||
seen: list[bool] = []
|
||||
|
||||
async def _fake_post(url, origin=None, cookies=None, reset_context=False):
|
||||
async def _fake_post(url, origin=None, cookies=None, reset_context=False, referer=None):
|
||||
seen.append(reset_context)
|
||||
if len(seen) == 1:
|
||||
raise httpx.ConnectError("boom")
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
экспонирует простой HTTP API на базе aiohttp:
|
||||
|
||||
GET /health → {"status": "ok", "browsers": {"avito": bool, ...}}
|
||||
POST /fetch → {"url","origin"(опц.),"cookies"(опц.)} → {"html": "...",
|
||||
POST /fetch → {"url","origin"(опц.),"referer"(опц.),"cookies"(опц.)} → {"html": "...",
|
||||
"status": <int|null>} # status — HTTP-код целевой навигации
|
||||
POST /fetch-json → {"url","method","headers","body","origin"} → {"status","body"}
|
||||
POST /login → {"url": "...", "email": "...", "password": "...", ...} → {"cookies": [...]}
|
||||
|
|
@ -162,6 +162,22 @@ BROWSER_WAIT_MS: int = int(os.environ.get("BROWSER_WAIT_MS", "6000"))
|
|||
# не превращающий единичный фетч в минуту ожидания при реальном бане/сетевой пробе.
|
||||
BROWSER_CHALLENGE_WAIT_MS: int = int(os.environ.get("BROWSER_CHALLENGE_WAIT_MS", "30000"))
|
||||
|
||||
# Через сколько мс безрезультатного опроса перезагрузить страницу САМИМ, не дожидаясь
|
||||
# её самопроизвольного reload'а. Допущение «страница перезагрузит себя сама» (см.
|
||||
# _content_during_navigation) верно НЕ всегда: ручная сессия 2026-08-29 через узел 10
|
||||
# показала, что после решения PoW выдача Домклика так и осталась на 401, и пропуск
|
||||
# qrator_jsid2 выдался только после ДВУХ перезагрузок, сделанных человеком руками —
|
||||
# 102.7с GET → 401, 115.1с GET → 401, 118.4с GET → 200, и сразу за ним кука-пропуск.
|
||||
# Пока мы только опрашивали content(), такая страница висела до самого таймаута.
|
||||
# Перезагрузка повторяет ровно то, что делает человек, и стоит одну навигацию.
|
||||
BROWSER_CHALLENGE_RELOAD_AFTER_MS: int = int(
|
||||
os.environ.get("BROWSER_CHALLENGE_RELOAD_AFTER_MS", "8000")
|
||||
)
|
||||
# Сколько таких перезагрузок допустимо за один фетч. Ноль = прежнее поведение (только
|
||||
# опрос). Два — столько понадобилось человеку; больше похоже уже на долбёжку, которая
|
||||
# сама по себе повод отказать.
|
||||
BROWSER_CHALLENGE_MAX_RELOADS: int = int(os.environ.get("BROWSER_CHALLENGE_MAX_RELOADS", "2"))
|
||||
|
||||
# /fetch-json settle после goto(origin) перед in-page fetch (#1917). 500мс мало:
|
||||
# первый XHR иногда ловит `NetworkError when attempting to fetch resource` (anti-bot/
|
||||
# сетевой стек страницы ещё не готов). Лечился внешним retry (re-navigation ~30-45с/дом).
|
||||
|
|
@ -951,12 +967,16 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
|||
return web.json_response({"error": "missing 'url' field"}, status=400)
|
||||
|
||||
# origin (опционально) — same-site якорь, на который камуфокс зайдёт ПЕРЕД url,
|
||||
# чтобы получить пропуск QRATOR в куки контекста (Referer тут ни при чём: goto()
|
||||
# его не шлёт; зеркалит /fetch-json #1917). Явного auto-derive из url здесь НЕТ (в отличие
|
||||
# от fetch-json) — caller не передал → остаётся None, origin-goto не выполняется,
|
||||
# поведение идентично прежнему (avito/cian/yandex его никогда не передают).
|
||||
# чтобы получить пропуск QRATOR в куки контекста (зеркалит /fetch-json #1917).
|
||||
# Явного auto-derive из url здесь НЕТ (в отличие от fetch-json) — caller не передал
|
||||
# → остаётся None, origin-goto не выполняется, поведение идентично прежнему
|
||||
# (avito/cian/yandex его никогда не передают).
|
||||
origin: str | None = body.get("origin")
|
||||
|
||||
# referer (опционально, #3247) — HTTP Referer, передаётся ТОЛЬКО в goto() целевого
|
||||
# url (см. _fetch_once). Поле отсутствует → None → поведение идентично прежнему.
|
||||
referer: str | None = body.get("referer")
|
||||
|
||||
# cookies (опционально) — dict name→value для инъекции в контекст страницы ПЕРЕД
|
||||
# навигацией (обход QRATOR-блока DomClick при валидной test-аккаунт сессии,
|
||||
# эмпирически подтверждено вживую 2026-07-04). Явного auto-derive нет — caller не
|
||||
|
|
@ -1012,6 +1032,7 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
|||
provider,
|
||||
url,
|
||||
origin=origin,
|
||||
referer=referer,
|
||||
cookies=cookies,
|
||||
reuse_context=reuse_context,
|
||||
reset_context=reset_context,
|
||||
|
|
@ -1185,6 +1206,7 @@ async def _do_fetch(
|
|||
url: str,
|
||||
*,
|
||||
origin: str | None = None,
|
||||
referer: str | None = None,
|
||||
cookies: dict | None = None,
|
||||
reuse_context: bool = False,
|
||||
reset_context: bool = False,
|
||||
|
|
@ -1198,14 +1220,15 @@ async def _do_fetch(
|
|||
провайдера (#3118) — на retry reuse_context=True создаст его заново с чистого листа,
|
||||
а не обратится к context'у уже мёртвого браузера.
|
||||
|
||||
origin/cookies/reuse_context/reset_context — см. _fetch_once. Дефолты не меняют
|
||||
поведение.
|
||||
origin/referer/cookies/reuse_context/reset_context — см. _fetch_once. Дефолты не
|
||||
меняют поведение.
|
||||
"""
|
||||
try:
|
||||
return await _fetch_once(
|
||||
provider,
|
||||
url,
|
||||
origin=origin,
|
||||
referer=referer,
|
||||
cookies=cookies,
|
||||
reuse_context=reuse_context,
|
||||
reset_context=reset_context,
|
||||
|
|
@ -1225,6 +1248,7 @@ async def _do_fetch(
|
|||
provider,
|
||||
url,
|
||||
origin=origin,
|
||||
referer=referer,
|
||||
cookies=cookies,
|
||||
reuse_context=reuse_context,
|
||||
reset_context=reset_context,
|
||||
|
|
@ -1395,12 +1419,20 @@ async def _wait_out_pow_challenge(
|
|||
) -> str:
|
||||
"""Опрашивает page.content() пока `is_pending(html)` не станет False.
|
||||
|
||||
Страница перезагружает СЕБЯ САМА (`window.location = location.href`) после
|
||||
решения PoW — URL не меняется, поэтому page.wait_for_url тут не годится;
|
||||
Страница ОБЫЧНО перезагружает себя сама (`window.location = location.href`)
|
||||
после решения PoW — URL не меняется, поэтому page.wait_for_url тут не годится;
|
||||
опрашиваем контент с шагом ~1с вместо этого. По истечении
|
||||
BROWSER_CHALLENGE_WAIT_MS — ChallengeTimeoutError, а не тихая отдача
|
||||
challenge-страницы как будто это валидный контент.
|
||||
|
||||
«Обычно» — не «всегда», и на этом мы горели: ручная сессия 2026-08-29 показала
|
||||
выдачу Домклика, которая после решения PoW осталась на 401 и отдала контент лишь
|
||||
после двух перезагрузок, сделанных человеком руками. Пока мы только опрашивали
|
||||
content(), такая страница гарантированно доживала до таймаута. Поэтому после
|
||||
BROWSER_CHALLENGE_RELOAD_AFTER_MS безрезультатного опроса перезагружаем сами, до
|
||||
BROWSER_CHALLENGE_MAX_RELOADS раз — ровно то действие, которым человек и вышел из
|
||||
этого состояния. Reload не отменяет ожидания: опрос продолжается в том же бюджете.
|
||||
|
||||
`is_pending` (дефолт `_is_pow_challenge`, поведение Авито и прочих
|
||||
провайдеров не меняется) — предикат «страница ещё не устоялась, опроси
|
||||
снова». DomClick передаёт свой (см. _fetch_once) — там нет отдельного
|
||||
|
|
@ -1412,10 +1444,43 @@ async def _wait_out_pow_challenge(
|
|||
"""
|
||||
poll_interval_ms = 1000
|
||||
elapsed_ms = 0
|
||||
reloads = 0
|
||||
next_reload_at_ms = BROWSER_CHALLENGE_RELOAD_AFTER_MS
|
||||
html: str | None = await _content_during_navigation(page)
|
||||
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
|
||||
if (
|
||||
reloads < BROWSER_CHALLENGE_MAX_RELOADS
|
||||
and BROWSER_CHALLENGE_RELOAD_AFTER_MS > 0
|
||||
and elapsed_ms >= next_reload_at_ms
|
||||
):
|
||||
reloads += 1
|
||||
next_reload_at_ms = elapsed_ms + BROWSER_CHALLENGE_RELOAD_AFTER_MS
|
||||
logger.info(
|
||||
"tradein-browser[%s]: челлендж висит %dмс — перезагружаю страницу "
|
||||
"(%d/%d) url=%r",
|
||||
provider,
|
||||
elapsed_ms,
|
||||
reloads,
|
||||
BROWSER_CHALLENGE_MAX_RELOADS,
|
||||
url,
|
||||
)
|
||||
try:
|
||||
await page.reload( # type: ignore[attr-defined]
|
||||
timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
|
||||
)
|
||||
except Exception as exc:
|
||||
# Перезагрузка — попытка помочь, а не обязательный шаг. Упала (гонка
|
||||
# с собственным reload'ом страницы, таймаут навигации) — продолжаем
|
||||
# опрос в прежнем бюджете, а не роняем фетч, который мог бы устояться.
|
||||
logger.warning(
|
||||
"tradein-browser[%s]: перезагрузка челленджа не удалась (%s: %s) — "
|
||||
"продолжаю опрос",
|
||||
provider,
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
html = await _content_during_navigation(page)
|
||||
|
||||
if html is None or is_pending(html):
|
||||
|
|
@ -1482,6 +1547,7 @@ async def _fetch_once(
|
|||
url: str,
|
||||
*,
|
||||
origin: str | None = None,
|
||||
referer: str | None = None,
|
||||
cookies: dict | None = None,
|
||||
reuse_context: bool = False,
|
||||
reset_context: bool = False,
|
||||
|
|
@ -1494,14 +1560,18 @@ async def _fetch_once(
|
|||
origin (опционально) — same-site якорь (например SERP), на который камуфокс
|
||||
заходит ПЕРЕД целевым url, чтобы получить пропуск QRATOR в контексте (зеркалит
|
||||
_fetch_json_once, #1917 — DomClick card-fetch, эмпирически подтверждено вживую
|
||||
2026-07-04). Работает это через КУКИ контекста, а не через Referer: goto()
|
||||
заголовок Referer не шлёт вовсе, так что «органической навигации» тут никогда не
|
||||
было — ценность ровно в прогретом контексте. Поэтому при reuse_context origin
|
||||
2026-07-04). Работает это через КУКИ контекста, поэтому при reuse_context origin
|
||||
поднимается ОДИН раз в якорной вкладке (_ensure_anchor_page), а не переоткрывается
|
||||
перед каждой карточкой: контекст уже прогрет, повторный заход — трата рукопожатия.
|
||||
None (дефолт) → поведение не меняется, ровно один goto(url) как раньше
|
||||
(avito/cian/yandex не передают origin).
|
||||
|
||||
referer (опционально) — HTTP-заголовок Referer, передаётся playwright'у ТОЛЬКО
|
||||
для целевой навигации ``goto(url, referer=...)`` — площадка видит переход "пришёл
|
||||
из выдачи", а не голый заход прямо на URL карточки (#3247). На origin/якорную
|
||||
вкладку referer НЕ передаётся: туда камуфокс приходит "сам", без источника.
|
||||
None (дефолт) → page.goto(url) без referer, поведение идентично прежнему.
|
||||
|
||||
cookies (опционально) — dict cookie_name→value для инъекции ДО любой навигации
|
||||
(обход QRATOR-блока DomClick при валидной test-аккаунт сессии, эмпирически
|
||||
подтверждено вживую 2026-07-04). Провайдер-агностично: домен НЕ захардкожен, а
|
||||
|
|
@ -1569,9 +1639,13 @@ async def _fetch_once(
|
|||
await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined]
|
||||
# Статус берём у ЦЕЛЕВОЙ навигации, а не у прогрева origin выше: интересует
|
||||
# ответ площадки на запрошенный url (#3196).
|
||||
response = await page.goto( # type: ignore[attr-defined]
|
||||
url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
|
||||
)
|
||||
goto_kwargs: dict[str, object] = {
|
||||
"timeout": BROWSER_NAV_TIMEOUT_MS,
|
||||
"wait_until": "domcontentloaded",
|
||||
}
|
||||
if referer:
|
||||
goto_kwargs["referer"] = referer
|
||||
response = await page.goto(url, **goto_kwargs) # type: ignore[attr-defined]
|
||||
_last_response_status[provider] = _status_of(response)
|
||||
if BROWSER_WAIT_MS > 0:
|
||||
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ class _OverlapProbe:
|
|||
url: str,
|
||||
*,
|
||||
origin: str | None = None,
|
||||
referer: str | None = None,
|
||||
cookies: dict | None = None,
|
||||
reuse_context: bool = False,
|
||||
reset_context: bool = False,
|
||||
|
|
|
|||
143
tradein-mvp/browser/test_server_challenge_reload.py
Normal file
143
tradein-mvp/browser/test_server_challenge_reload.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""test_server_challenge_reload.py — сайдкар сам перезагружает зависший PoW-челлендж.
|
||||
|
||||
Повод — ручная сессия 2026-08-29 через узел 10 (asocks-mobile-2, прод-прокси,
|
||||
headful-камуфокс, путь «яндекс-поиск → выдача Домклика → карточка»). Запись сети:
|
||||
|
||||
102.7с GET /pokupka/kvartiry/vtorichka → 401
|
||||
107.7с выдана кука qrator_jsr (PoW пошёл)
|
||||
115.1с GET /pokupka/kvartiry/vtorichka → 401 ← перезагрузка человеком
|
||||
118.4с GET /pokupka/kvartiry/vtorichka → 200 ← вторая перезагрузка
|
||||
119.6с выдана кука qrator_jsid2 (ПРОПУСК)
|
||||
124.6с GET /card/sale__flat__2078134349 → 200
|
||||
|
||||
То есть допущение «страница перезагрузит себя сама после решения PoW», на котором
|
||||
был построен _wait_out_pow_challenge, выполняется НЕ всегда: здесь пропуск выдался
|
||||
только после навигаций, сделанных руками. Пока сайдкар лишь опрашивал content(),
|
||||
такая страница гарантированно доживала до BROWSER_CHALLENGE_WAIT_MS и уходила в
|
||||
ChallengeTimeoutError, а бэкфилл засчитывал это в блоки.
|
||||
|
||||
Здесь проверяется, что сайдкар делает то же, что сделал человек: подождав
|
||||
BROWSER_CHALLENGE_RELOAD_AFTER_MS, перезагружает страницу сам, до
|
||||
BROWSER_CHALLENGE_MAX_RELOADS раз, и что это не ломает прежние пути.
|
||||
|
||||
camoufox НЕ запускается: поддельная page с заданной последовательностью content().
|
||||
wait_for_timeout — no-op, поэтому тесты идут мгновенно.
|
||||
|
||||
Запуск (из tradein-mvp/browser/)::
|
||||
|
||||
python -m pytest test_server_challenge_reload.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
_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)
|
||||
|
||||
|
||||
PENDING = "<html><body>проверка безопасности</body></html>"
|
||||
DONE = "<html><body>listing card content</body></html>"
|
||||
|
||||
|
||||
class _Page:
|
||||
"""Поддельная page: content() идёт по списку, reload() двигает курсор дальше.
|
||||
|
||||
`reload_unblocks_at` — индекс, начиная с которого content() отдаёт DONE, но
|
||||
ТОЛЬКО если к этому моменту была хотя бы одна перезагрузка. Так моделируется
|
||||
именно наблюдавшееся поведение: сама по себе страница не «дозревает», её
|
||||
расколдовывает повторная навигация.
|
||||
"""
|
||||
|
||||
def __init__(self, *, unblock_after_reloads: int | None = None) -> None:
|
||||
self.reload_calls: list[dict[str, Any]] = []
|
||||
self.wait_for_timeout_calls: list[int] = []
|
||||
self._unblock_after_reloads = unblock_after_reloads
|
||||
self.reload_error: Exception | None = None
|
||||
|
||||
async def wait_for_timeout(self, ms: int) -> None:
|
||||
self.wait_for_timeout_calls.append(ms)
|
||||
|
||||
async def reload(self, **kwargs: Any) -> None:
|
||||
if self.reload_error is not None:
|
||||
raise self.reload_error
|
||||
self.reload_calls.append(kwargs)
|
||||
|
||||
async def content(self) -> str:
|
||||
if (
|
||||
self._unblock_after_reloads is not None
|
||||
and len(self.reload_calls) >= self._unblock_after_reloads
|
||||
):
|
||||
return DONE
|
||||
return PENDING
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fast_budget(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Короткий бюджет + отключённая догидрация: тест про перезагрузки, не про сон."""
|
||||
monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 30_000)
|
||||
monkeypatch.setattr(server, "BROWSER_CHALLENGE_RELOAD_AFTER_MS", 8_000)
|
||||
monkeypatch.setattr(server, "BROWSER_CHALLENGE_MAX_RELOADS", 2)
|
||||
monkeypatch.setattr(server, "BROWSER_WAIT_MS", 0)
|
||||
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
||||
|
||||
|
||||
def _wait(page: _Page) -> str:
|
||||
return asyncio.run(
|
||||
server._wait_out_pow_challenge(
|
||||
page, "domclick", "https://example.test/card/1", is_pending=lambda h: h == PENDING
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_hung_challenge_is_reloaded_and_resolves() -> None:
|
||||
"""Главный сценарий: сама страница не оживает, оживает после перезагрузки."""
|
||||
page = _Page(unblock_after_reloads=1)
|
||||
assert _wait(page) == DONE
|
||||
assert len(page.reload_calls) == 1
|
||||
|
||||
|
||||
def test_reload_not_used_when_challenge_clears_on_its_own() -> None:
|
||||
"""Страница, которая перезагружает себя сама, лишней навигации не получает."""
|
||||
page = _Page(unblock_after_reloads=0) # DONE сразу, ещё до первого опроса
|
||||
assert _wait(page) == DONE
|
||||
assert page.reload_calls == []
|
||||
|
||||
|
||||
def test_reload_count_is_capped() -> None:
|
||||
"""Вечно висящий челлендж не превращается в долбёжку — не больше лимита."""
|
||||
page = _Page(unblock_after_reloads=None) # не оживает никогда
|
||||
with pytest.raises(server.ChallengeTimeoutError):
|
||||
_wait(page)
|
||||
assert len(page.reload_calls) == server.BROWSER_CHALLENGE_MAX_RELOADS
|
||||
|
||||
|
||||
def test_reload_disabled_by_zero_threshold(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""BROWSER_CHALLENGE_RELOAD_AFTER_MS=0 возвращает ровно прежнее поведение."""
|
||||
monkeypatch.setattr(server, "BROWSER_CHALLENGE_RELOAD_AFTER_MS", 0)
|
||||
page = _Page(unblock_after_reloads=None)
|
||||
with pytest.raises(server.ChallengeTimeoutError):
|
||||
_wait(page)
|
||||
assert page.reload_calls == []
|
||||
|
||||
|
||||
def test_failed_reload_does_not_abort_the_wait() -> None:
|
||||
"""Перезагрузка — помощь, а не обязательный шаг: её падение не роняет фетч.
|
||||
|
||||
Ошибка навигации здесь означает лишь, что помочь не удалось; опрос обязан
|
||||
продолжиться в прежнем бюджете и упереться в честный ChallengeTimeoutError,
|
||||
а не вывалиться наружу чужим исключением.
|
||||
"""
|
||||
page = _Page(unblock_after_reloads=None)
|
||||
page.reload_error = RuntimeError("Navigation timeout of 30000ms exceeded")
|
||||
with pytest.raises(server.ChallengeTimeoutError):
|
||||
_wait(page)
|
||||
assert page.reload_calls == []
|
||||
|
|
@ -80,6 +80,7 @@ class _ChallengePage:
|
|||
self._html_sequence = html_sequence
|
||||
self._call_count = 0
|
||||
self.goto_urls: list[str] = []
|
||||
self.reload_calls: list[dict[str, Any]] = []
|
||||
self.wait_for_timeout_calls: list[int] = []
|
||||
self.closed = 0
|
||||
|
||||
|
|
@ -89,6 +90,12 @@ class _ChallengePage:
|
|||
async def goto(self, url: str, **kwargs: Any) -> None:
|
||||
self.goto_urls.append(url)
|
||||
|
||||
async def reload(self, **kwargs: Any) -> None:
|
||||
# Сайдкар сам перезагружает зависший челлендж (#3244-след), поэтому фейковая
|
||||
# page обязана уметь reload — иначе тесты ловили бы AttributeError в ветке
|
||||
# помощи и молча уходили в warning вместо проверки поведения.
|
||||
self.reload_calls.append(kwargs)
|
||||
|
||||
async def wait_for_timeout(self, ms: int) -> None:
|
||||
self.wait_for_timeout_calls.append(ms)
|
||||
|
||||
|
|
|
|||
153
tradein-mvp/browser/test_server_referer.py
Normal file
153
tradein-mvp/browser/test_server_referer.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""test_server_referer.py — HTTP Referer при навигации на целевой url (#3247).
|
||||
|
||||
Проблема: ``page.goto(url)`` playwright по умолчанию НЕ отправляет заголовок
|
||||
Referer. В проде каждая карточка DomClick запрашивается: сайдкар грузит origin
|
||||
(выдачу), затем ``goto(card_url)`` — площадка видит переход прямо на URL карточки
|
||||
без источника. Ручные прогоны с кликами (Referer уходил естественно) брали 91 и 40
|
||||
карточек без отказов; прод-добор без Referer упирался в отказ на 11-й.
|
||||
|
||||
Тестируется:
|
||||
1. referer передаётся В page.goto() ТОЛЬКО для ЦЕЛЕВОГО url (не для origin/якоря);
|
||||
2. без referer в вызове — page.goto(url) без kwarg'а referer (прежнее поведение);
|
||||
3. _do_fetch пробрасывает referer сквозь retry-путь один-в-один с _fetch_once.
|
||||
|
||||
camoufox НЕ запускается — browser/page подделаны.
|
||||
|
||||
Запуск (из tradein-mvp/browser/)::
|
||||
|
||||
python -m pytest test_server_referer.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
_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:
|
||||
monkeypatch.setattr(server, "_browsers", {})
|
||||
monkeypatch.setattr(server, "_browser_cms", {})
|
||||
monkeypatch.setattr(server, "_contexts", {})
|
||||
monkeypatch.setattr(server, "_anchor_pages", {})
|
||||
monkeypatch.setattr(server, "_page_counters", {})
|
||||
monkeypatch.setattr(server, "_locks", {})
|
||||
monkeypatch.setattr(server, "_last_goto_at", {})
|
||||
monkeypatch.setattr(
|
||||
server, "_RECYCLE_PAGES_BY_PROVIDER", {p: 10_000 for p in server.PROVIDERS}
|
||||
)
|
||||
|
||||
|
||||
class _FakePage:
|
||||
"""Поддельная page: фиксирует (url, kwargs) каждого goto()."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.goto_calls: list[tuple[str, dict[str, Any]]] = []
|
||||
self.closed = 0
|
||||
|
||||
def is_closed(self) -> bool:
|
||||
return 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_calls.append((url, kwargs))
|
||||
|
||||
async def wait_for_timeout(self, ms: int) -> None:
|
||||
return None
|
||||
|
||||
async def content(self) -> str:
|
||||
return "<html>ok<script>window.__SSR_STATE__={}</script></html>"
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed += 1
|
||||
|
||||
|
||||
class _FakeBrowser:
|
||||
def __init__(self) -> None:
|
||||
self.pages: list[_FakePage] = []
|
||||
|
||||
async def new_page(self) -> _FakePage:
|
||||
page = _FakePage()
|
||||
self.pages.append(page)
|
||||
return page
|
||||
|
||||
|
||||
def test_referer_passed_only_to_target_goto() -> None:
|
||||
browser = _FakeBrowser()
|
||||
server._browsers["domclick"] = browser
|
||||
|
||||
html = asyncio.run(
|
||||
server._fetch_once(
|
||||
"domclick",
|
||||
"https://domclick.ru/card/1",
|
||||
origin="https://domclick.ru/serp",
|
||||
referer="https://domclick.ru/serp",
|
||||
)
|
||||
)
|
||||
|
||||
assert "__SSR_STATE__" in html
|
||||
assert len(browser.pages) == 1
|
||||
page = browser.pages[0]
|
||||
# Два goto: origin (без referer), затем target (с referer).
|
||||
assert len(page.goto_calls) == 2
|
||||
origin_url, origin_kwargs = page.goto_calls[0]
|
||||
target_url, target_kwargs = page.goto_calls[1]
|
||||
assert origin_url == "https://domclick.ru/serp"
|
||||
assert "referer" not in origin_kwargs, "origin-навигация НЕ должна получать referer"
|
||||
assert target_url == "https://domclick.ru/card/1"
|
||||
assert target_kwargs.get("referer") == "https://domclick.ru/serp"
|
||||
|
||||
|
||||
def test_referer_absent_by_default_no_kwarg() -> None:
|
||||
"""Без referer в вызове — goto(url) без kwarg'а referer вовсе (прежнее поведение)."""
|
||||
browser = _FakeBrowser()
|
||||
server._browsers["avito"] = browser
|
||||
|
||||
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
|
||||
|
||||
page = browser.pages[0]
|
||||
assert len(page.goto_calls) == 1
|
||||
_, kwargs = page.goto_calls[0]
|
||||
assert "referer" not in kwargs
|
||||
|
||||
|
||||
def test_referer_none_explicit_no_kwarg() -> None:
|
||||
"""referer=None явно — то же самое: без ключа в kwargs (falsy-check в _fetch_once)."""
|
||||
browser = _FakeBrowser()
|
||||
server._browsers["avito"] = browser
|
||||
|
||||
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/2", referer=None))
|
||||
|
||||
page = browser.pages[0]
|
||||
_, kwargs = page.goto_calls[0]
|
||||
assert "referer" not in kwargs
|
||||
|
||||
|
||||
def test_do_fetch_passes_referer_through() -> None:
|
||||
"""_do_fetch (обёртка с retry) пробрасывает referer в _fetch_once один-в-один."""
|
||||
browser = _FakeBrowser()
|
||||
server._browsers["domclick"] = browser
|
||||
|
||||
asyncio.run(
|
||||
server._do_fetch(
|
||||
"domclick",
|
||||
"https://domclick.ru/card/9",
|
||||
referer="https://domclick.ru/serp",
|
||||
)
|
||||
)
|
||||
|
||||
page = browser.pages[0]
|
||||
_, kwargs = page.goto_calls[0]
|
||||
assert kwargs.get("referer") == "https://domclick.ru/serp"
|
||||
|
|
@ -465,15 +465,21 @@ class BrowserFetcher:
|
|||
url: str,
|
||||
*,
|
||||
origin: str | None = None,
|
||||
referer: str | None = None,
|
||||
cookies: dict[str, str] | None = None,
|
||||
reset_context: bool = False,
|
||||
) -> str:
|
||||
"""Запрашивает HTML страницы через tradein-browser HTTP-сервис.
|
||||
|
||||
origin — same-site якорь, на который камуфокс зайдёт ПЕРЕД url (органическая
|
||||
навигация: реальные cookies/Referer вместо холодного goto), см. /fetch
|
||||
``origin`` в server.py. None (дефолт) → поведение не меняется (ровно один
|
||||
goto(url), как раньше) — таков путь всех providers кроме domclick detail.
|
||||
origin — same-site якорь, на который камуфокс зайдёт ПЕРЕД url (прогревает
|
||||
cookies контекста ДО холодного goto на url), см. /fetch ``origin`` в
|
||||
server.py. None (дефолт) → поведение не меняется (ровно один goto(url), как
|
||||
раньше) — таков путь всех providers кроме domclick detail.
|
||||
|
||||
referer (#3247) — HTTP-заголовок Referer для целевой навигации ``goto(url,
|
||||
referer=...)`` на сайдкаре: без него голый ``page.goto()`` Referer не шлёт
|
||||
вовсе, и площадка видит переход прямо на URL карточки без источника. None
|
||||
(дефолт) → поле не кладётся в payload, поведение не меняется.
|
||||
|
||||
cookies — dict cookie_name→value для инъекции в browser-контекст ПЕРЕД
|
||||
навигацией (обходит QRATOR-блок DomClick при валидной test-аккаунт
|
||||
|
|
@ -513,7 +519,7 @@ class BrowserFetcher:
|
|||
self._context_reset_pending = False
|
||||
|
||||
try:
|
||||
return await self._post_fetch(url, origin, cookies, effective_reset)
|
||||
return await self._post_fetch(url, origin, cookies, effective_reset, referer)
|
||||
except (httpx.HTTPError, httpx.TransportError) as exc:
|
||||
logger.warning(
|
||||
"BrowserFetcher: ошибка запроса (%s), retry через %.1fs: %s",
|
||||
|
|
@ -522,7 +528,7 @@ class BrowserFetcher:
|
|||
url,
|
||||
)
|
||||
await asyncio.sleep(_RETRY_SLEEP_S)
|
||||
return await self._post_fetch(url, origin, cookies, effective_reset)
|
||||
return await self._post_fetch(url, origin, cookies, effective_reset, referer)
|
||||
|
||||
async def fetch_json(
|
||||
self,
|
||||
|
|
@ -791,6 +797,7 @@ class BrowserFetcher:
|
|||
origin: str | None = None,
|
||||
cookies: dict[str, str] | None = None,
|
||||
reset_context: bool = False,
|
||||
referer: str | None = None,
|
||||
) -> str:
|
||||
"""Один HTTP POST к /fetch эндпоинту сервиса.
|
||||
|
||||
|
|
@ -798,6 +805,10 @@ class BrowserFetcher:
|
|||
_post_fetch_json, сервер (body.get("origin")/body.get("cookies"))
|
||||
корректно обрабатывает оба случая.
|
||||
|
||||
referer (#3247) кладётся в payload ТОЛЬКО когда задан — старый сайдкар без
|
||||
этого поля в схеме не должен получать неожиданный ключ, а поведение при
|
||||
referer=None (дефолт) остаётся байт-в-байт прежним.
|
||||
|
||||
reuse_context/reset_context (#3118) кладутся в payload ТОЛЬКО когда сессионный
|
||||
self._reuse_context включён — при выключенном флаге (дефолт) payload остаётся
|
||||
БАЙТ-В-БАЙТ таким же, как раньше: сайдкар старой версии (без этих полей в
|
||||
|
|
@ -825,6 +836,8 @@ class BrowserFetcher:
|
|||
if self._reuse_context:
|
||||
payload["reuse_context"] = True
|
||||
payload["reset_context"] = reset_context
|
||||
if referer:
|
||||
payload["referer"] = referer
|
||||
if proxy_url:
|
||||
payload["proxy"] = proxy_url
|
||||
if proxy_kind:
|
||||
|
|
|
|||
|
|
@ -535,7 +535,10 @@ async def fetch_detail(
|
|||
try:
|
||||
# Заход на same-site vtorichka-SERP ПЕРЕД карточкой снижает подозрительность
|
||||
# холодной навигации для QRATOR (эмпирически подтверждено вживую 2026-07-04).
|
||||
html = await browser_fetcher.fetch(card_url, origin=origin, cookies=cookies)
|
||||
# referer=origin (#3247): переход на карточку логически и есть переход из
|
||||
# выдачи — сайдкар пробрасывает его в page.goto(card_url, referer=origin),
|
||||
# чего page.goto() без явного referer не делает никогда.
|
||||
html = await browser_fetcher.fetch(card_url, origin=origin, referer=origin, cookies=cookies)
|
||||
# #3196: статус читаем СРАЗУ после await — следующий fetch() его перезапишет.
|
||||
# getattr — сайдкар/фетчер старой сборки атрибута не имеет, это не повод падать.
|
||||
status = getattr(browser_fetcher, "last_response_status", None)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue