feat(tradein/browser): ходить по Домклику как человек — с Referer и с перезагрузкой зависшего рукопожатия #3250

Merged
lekss361 merged 1 commit from feat/browser-referer-on-card-nav into main 2026-08-29 19:59:13 +00:00
9 changed files with 421 additions and 24 deletions

View file

@ -340,6 +340,7 @@ async def test_fetch_detail_parses_via_browser() -> None:
bf.fetch.assert_called_once_with( bf.fetch.assert_called_once_with(
_CARD_URL, _CARD_URL,
origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka", origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
referer="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
cookies=None, cookies=None,
) )
@ -361,6 +362,7 @@ async def test_fetch_detail_passes_derived_vtorichka_origin() -> None:
bf.fetch.assert_called_once_with( bf.fetch.assert_called_once_with(
other_card_url, other_card_url,
origin="https://spb.domclick.ru/pokupka/kvartiry/vtorichka", origin="https://spb.domclick.ru/pokupka/kvartiry/vtorichka",
referer="https://spb.domclick.ru/pokupka/kvartiry/vtorichka",
cookies=None, cookies=None,
) )
@ -382,6 +384,7 @@ async def test_fetch_detail_passes_cookies_through_to_browser_fetcher() -> None:
bf.fetch.assert_called_once_with( bf.fetch.assert_called_once_with(
_CARD_URL, _CARD_URL,
origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka", origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
referer="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
cookies=session_cookies, cookies=session_cookies,
) )

View file

@ -239,7 +239,7 @@ async def test_3118_pending_reset_consumed_exactly_once():
bf = _fetcher(reuse_context=True) bf = _fetcher(reuse_context=True)
seen: list[bool] = [] 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) seen.append(reset_context)
return "<html></html>" return "<html></html>"
@ -261,7 +261,7 @@ async def test_3118_pending_reset_survives_transport_retry():
bf = _fetcher(reuse_context=True) bf = _fetcher(reuse_context=True)
seen: list[bool] = [] 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) seen.append(reset_context)
if len(seen) == 1: if len(seen) == 1:
raise httpx.ConnectError("boom") raise httpx.ConnectError("boom")

View file

@ -5,7 +5,7 @@
экспонирует простой HTTP API на базе aiohttp: экспонирует простой HTTP API на базе aiohttp:
GET /health {"status": "ok", "browsers": {"avito": bool, ...}} 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-код целевой навигации "status": <int|null>} # status — HTTP-код целевой навигации
POST /fetch-json {"url","method","headers","body","origin"} {"status","body"} POST /fetch-json {"url","method","headers","body","origin"} {"status","body"}
POST /login {"url": "...", "email": "...", "password": "...", ...} {"cookies": [...]} 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")) 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мс мало: # /fetch-json settle после goto(origin) перед in-page fetch (#1917). 500мс мало:
# первый XHR иногда ловит `NetworkError when attempting to fetch resource` (anti-bot/ # первый XHR иногда ловит `NetworkError when attempting to fetch resource` (anti-bot/
# сетевой стек страницы ещё не готов). Лечился внешним retry (re-navigation ~30-45с/дом). # сетевой стек страницы ещё не готов). Лечился внешним 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) return web.json_response({"error": "missing 'url' field"}, status=400)
# origin (опционально) — same-site якорь, на который камуфокс зайдёт ПЕРЕД url, # origin (опционально) — same-site якорь, на который камуфокс зайдёт ПЕРЕД url,
# чтобы получить пропуск QRATOR в куки контекста (Referer тут ни при чём: goto() # чтобы получить пропуск QRATOR в куки контекста (зеркалит /fetch-json #1917).
# его не шлёт; зеркалит /fetch-json #1917). Явного auto-derive из url здесь НЕТ (в отличие # Явного auto-derive из url здесь НЕТ (в отличие от fetch-json) — caller не передал
# от fetch-json) — caller не передал → остаётся None, origin-goto не выполняется, # → остаётся None, origin-goto не выполняется, поведение идентично прежнему
# поведение идентично прежнему (avito/cian/yandex его никогда не передают). # (avito/cian/yandex его никогда не передают).
origin: str | None = body.get("origin") 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 для инъекции в контекст страницы ПЕРЕД # cookies (опционально) — dict name→value для инъекции в контекст страницы ПЕРЕД
# навигацией (обход QRATOR-блока DomClick при валидной test-аккаунт сессии, # навигацией (обход QRATOR-блока DomClick при валидной test-аккаунт сессии,
# эмпирически подтверждено вживую 2026-07-04). Явного auto-derive нет — caller не # эмпирически подтверждено вживую 2026-07-04). Явного auto-derive нет — caller не
@ -1012,6 +1032,7 @@ async def fetch_handler(request: web.Request) -> web.Response:
provider, provider,
url, url,
origin=origin, origin=origin,
referer=referer,
cookies=cookies, cookies=cookies,
reuse_context=reuse_context, reuse_context=reuse_context,
reset_context=reset_context, reset_context=reset_context,
@ -1185,6 +1206,7 @@ async def _do_fetch(
url: str, url: str,
*, *,
origin: str | None = None, origin: str | None = None,
referer: str | None = None,
cookies: dict | None = None, cookies: dict | None = None,
reuse_context: bool = False, reuse_context: bool = False,
reset_context: bool = False, reset_context: bool = False,
@ -1198,14 +1220,15 @@ async def _do_fetch(
провайдера (#3118) — на retry reuse_context=True создаст его заново с чистого листа, провайдера (#3118) — на retry reuse_context=True создаст его заново с чистого листа,
а не обратится к context'у уже мёртвого браузера. а не обратится к context'у уже мёртвого браузера.
origin/cookies/reuse_context/reset_context см. _fetch_once. Дефолты не меняют origin/referer/cookies/reuse_context/reset_context см. _fetch_once. Дефолты не
поведение. меняют поведение.
""" """
try: try:
return await _fetch_once( return await _fetch_once(
provider, provider,
url, url,
origin=origin, origin=origin,
referer=referer,
cookies=cookies, cookies=cookies,
reuse_context=reuse_context, reuse_context=reuse_context,
reset_context=reset_context, reset_context=reset_context,
@ -1225,6 +1248,7 @@ async def _do_fetch(
provider, provider,
url, url,
origin=origin, origin=origin,
referer=referer,
cookies=cookies, cookies=cookies,
reuse_context=reuse_context, reuse_context=reuse_context,
reset_context=reset_context, reset_context=reset_context,
@ -1395,12 +1419,20 @@ async def _wait_out_pow_challenge(
) -> str: ) -> str:
"""Опрашивает page.content() пока `is_pending(html)` не станет False. """Опрашивает 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 тут не годится;
опрашиваем контент с шагом ~1с вместо этого. По истечении опрашиваем контент с шагом ~1с вместо этого. По истечении
BROWSER_CHALLENGE_WAIT_MS ChallengeTimeoutError, а не тихая отдача BROWSER_CHALLENGE_WAIT_MS ChallengeTimeoutError, а не тихая отдача
challenge-страницы как будто это валидный контент. challenge-страницы как будто это валидный контент.
«Обычно» не «всегда», и на этом мы горели: ручная сессия 2026-08-29 показала
выдачу Домклика, которая после решения PoW осталась на 401 и отдала контент лишь
после двух перезагрузок, сделанных человеком руками. Пока мы только опрашивали
content(), такая страница гарантированно доживала до таймаута. Поэтому после
BROWSER_CHALLENGE_RELOAD_AFTER_MS безрезультатного опроса перезагружаем сами, до
BROWSER_CHALLENGE_MAX_RELOADS раз ровно то действие, которым человек и вышел из
этого состояния. Reload не отменяет ожидания: опрос продолжается в том же бюджете.
`is_pending` (дефолт `_is_pow_challenge`, поведение Авито и прочих `is_pending` (дефолт `_is_pow_challenge`, поведение Авито и прочих
провайдеров не меняется) предикат «страница ещё не устоялась, опроси провайдеров не меняется) предикат «страница ещё не устоялась, опроси
снова». DomClick передаёт свой (см. _fetch_once) там нет отдельного снова». DomClick передаёт свой (см. _fetch_once) там нет отдельного
@ -1412,10 +1444,43 @@ async def _wait_out_pow_challenge(
""" """
poll_interval_ms = 1000 poll_interval_ms = 1000
elapsed_ms = 0 elapsed_ms = 0
reloads = 0
next_reload_at_ms = BROWSER_CHALLENGE_RELOAD_AFTER_MS
html: str | None = await _content_during_navigation(page) html: str | None = await _content_during_navigation(page)
while (html is None or is_pending(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
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) html = await _content_during_navigation(page)
if html is None or is_pending(html): if html is None or is_pending(html):
@ -1482,6 +1547,7 @@ async def _fetch_once(
url: str, url: str,
*, *,
origin: str | None = None, origin: str | None = None,
referer: str | None = None,
cookies: dict | None = None, cookies: dict | None = None,
reuse_context: bool = False, reuse_context: bool = False,
reset_context: bool = False, reset_context: bool = False,
@ -1494,14 +1560,18 @@ async def _fetch_once(
origin (опционально) same-site якорь (например SERP), на который камуфокс origin (опционально) same-site якорь (например SERP), на который камуфокс
заходит ПЕРЕД целевым url, чтобы получить пропуск QRATOR в контексте (зеркалит заходит ПЕРЕД целевым url, чтобы получить пропуск QRATOR в контексте (зеркалит
_fetch_json_once, #1917 — DomClick card-fetch, эмпирически подтверждено вживую _fetch_json_once, #1917 — DomClick card-fetch, эмпирически подтверждено вживую
2026-07-04). Работает это через КУКИ контекста, а не через Referer: goto() 2026-07-04). Работает это через КУКИ контекста, поэтому при reuse_context origin
заголовок Referer не шлёт вовсе, так что «органической навигации» тут никогда не
было ценность ровно в прогретом контексте. Поэтому при reuse_context origin
поднимается ОДИН раз в якорной вкладке (_ensure_anchor_page), а не переоткрывается поднимается ОДИН раз в якорной вкладке (_ensure_anchor_page), а не переоткрывается
перед каждой карточкой: контекст уже прогрет, повторный заход трата рукопожатия. перед каждой карточкой: контекст уже прогрет, повторный заход трата рукопожатия.
None (дефолт) поведение не меняется, ровно один goto(url) как раньше None (дефолт) поведение не меняется, ровно один goto(url) как раньше
(avito/cian/yandex не передают origin). (avito/cian/yandex не передают origin).
referer (опционально) HTTP-заголовок Referer, передаётся playwright'у ТОЛЬКО
для целевой навигации ``goto(url, referer=...)`` площадка видит переход "пришёл
из выдачи", а не голый заход прямо на URL карточки (#3247). На origin/якорную
вкладку referer НЕ передаётся: туда камуфокс приходит "сам", без источника.
None (дефолт) page.goto(url) без referer, поведение идентично прежнему.
cookies (опционально) dict cookie_namevalue для инъекции ДО любой навигации cookies (опционально) dict cookie_namevalue для инъекции ДО любой навигации
(обход QRATOR-блока DomClick при валидной test-аккаунт сессии, эмпирически (обход QRATOR-блока DomClick при валидной test-аккаунт сессии, эмпирически
подтверждено вживую 2026-07-04). Провайдер-агностично: домен НЕ захардкожен, а подтверждено вживую 2026-07-04). Провайдер-агностично: домен НЕ захардкожен, а
@ -1569,9 +1639,13 @@ async def _fetch_once(
await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined] await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined]
# Статус берём у ЦЕЛЕВОЙ навигации, а не у прогрева origin выше: интересует # Статус берём у ЦЕЛЕВОЙ навигации, а не у прогрева origin выше: интересует
# ответ площадки на запрошенный url (#3196). # ответ площадки на запрошенный url (#3196).
response = await page.goto( # type: ignore[attr-defined] goto_kwargs: dict[str, object] = {
url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded" "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) _last_response_status[provider] = _status_of(response)
if BROWSER_WAIT_MS > 0: if BROWSER_WAIT_MS > 0:
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined] await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]

View file

@ -178,6 +178,7 @@ class _OverlapProbe:
url: str, url: str,
*, *,
origin: str | None = None, origin: str | None = None,
referer: str | None = None,
cookies: dict | None = None, cookies: dict | None = None,
reuse_context: bool = False, reuse_context: bool = False,
reset_context: bool = False, reset_context: bool = False,

View 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 == []

View file

@ -80,6 +80,7 @@ class _ChallengePage:
self._html_sequence = html_sequence self._html_sequence = html_sequence
self._call_count = 0 self._call_count = 0
self.goto_urls: list[str] = [] self.goto_urls: list[str] = []
self.reload_calls: list[dict[str, Any]] = []
self.wait_for_timeout_calls: list[int] = [] self.wait_for_timeout_calls: list[int] = []
self.closed = 0 self.closed = 0
@ -89,6 +90,12 @@ class _ChallengePage:
async def goto(self, url: str, **kwargs: Any) -> None: async def goto(self, url: str, **kwargs: Any) -> None:
self.goto_urls.append(url) 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: async def wait_for_timeout(self, ms: int) -> None:
self.wait_for_timeout_calls.append(ms) self.wait_for_timeout_calls.append(ms)

View 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"

View file

@ -465,15 +465,21 @@ class BrowserFetcher:
url: str, url: str,
*, *,
origin: str | None = None, origin: str | None = None,
referer: str | None = None,
cookies: dict[str, str] | None = None, cookies: dict[str, str] | None = None,
reset_context: bool = False, reset_context: bool = False,
) -> str: ) -> str:
"""Запрашивает HTML страницы через tradein-browser HTTP-сервис. """Запрашивает HTML страницы через tradein-browser HTTP-сервис.
origin same-site якорь, на который камуфокс зайдёт ПЕРЕД url (органическая origin same-site якорь, на который камуфокс зайдёт ПЕРЕД url (прогревает
навигация: реальные cookies/Referer вместо холодного goto), см. /fetch cookies контекста ДО холодного goto на url), см. /fetch ``origin`` в
``origin`` в server.py. None (дефолт) поведение не меняется (ровно один server.py. None (дефолт) поведение не меняется (ровно один goto(url), как
goto(url), как раньше) таков путь всех providers кроме domclick detail. раньше) таков путь всех providers кроме domclick detail.
referer (#3247) — HTTP-заголовок Referer для целевой навигации ``goto(url,
referer=...)`` на сайдкаре: без него голый ``page.goto()`` Referer не шлёт
вовсе, и площадка видит переход прямо на URL карточки без источника. None
(дефолт) поле не кладётся в payload, поведение не меняется.
cookies dict cookie_namevalue для инъекции в browser-контекст ПЕРЕД cookies dict cookie_namevalue для инъекции в browser-контекст ПЕРЕД
навигацией (обходит QRATOR-блок DomClick при валидной test-аккаунт навигацией (обходит QRATOR-блок DomClick при валидной test-аккаунт
@ -513,7 +519,7 @@ class BrowserFetcher:
self._context_reset_pending = False self._context_reset_pending = False
try: 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: except (httpx.HTTPError, httpx.TransportError) as exc:
logger.warning( logger.warning(
"BrowserFetcher: ошибка запроса (%s), retry через %.1fs: %s", "BrowserFetcher: ошибка запроса (%s), retry через %.1fs: %s",
@ -522,7 +528,7 @@ class BrowserFetcher:
url, url,
) )
await asyncio.sleep(_RETRY_SLEEP_S) 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( async def fetch_json(
self, self,
@ -791,6 +797,7 @@ class BrowserFetcher:
origin: str | None = None, origin: str | None = None,
cookies: dict[str, str] | None = None, cookies: dict[str, str] | None = None,
reset_context: bool = False, reset_context: bool = False,
referer: str | None = None,
) -> str: ) -> str:
"""Один HTTP POST к /fetch эндпоинту сервиса. """Один HTTP POST к /fetch эндпоинту сервиса.
@ -798,6 +805,10 @@ class BrowserFetcher:
_post_fetch_json, сервер (body.get("origin")/body.get("cookies")) _post_fetch_json, сервер (body.get("origin")/body.get("cookies"))
корректно обрабатывает оба случая. корректно обрабатывает оба случая.
referer (#3247) кладётся в payload ТОЛЬКО когда задан — старый сайдкар без
этого поля в схеме не должен получать неожиданный ключ, а поведение при
referer=None (дефолт) остаётся байт-в-байт прежним.
reuse_context/reset_context (#3118) кладутся в payload ТОЛЬКО когда сессионный reuse_context/reset_context (#3118) кладутся в payload ТОЛЬКО когда сессионный
self._reuse_context включён при выключенном флаге (дефолт) payload остаётся self._reuse_context включён при выключенном флаге (дефолт) payload остаётся
БАЙТ-В-БАЙТ таким же, как раньше: сайдкар старой версии (без этих полей в БАЙТ-В-БАЙТ таким же, как раньше: сайдкар старой версии (без этих полей в
@ -825,6 +836,8 @@ class BrowserFetcher:
if self._reuse_context: if self._reuse_context:
payload["reuse_context"] = True payload["reuse_context"] = True
payload["reset_context"] = reset_context payload["reset_context"] = reset_context
if referer:
payload["referer"] = referer
if proxy_url: if proxy_url:
payload["proxy"] = proxy_url payload["proxy"] = proxy_url
if proxy_kind: if proxy_kind:

View file

@ -535,7 +535,10 @@ async def fetch_detail(
try: try:
# Заход на same-site vtorichka-SERP ПЕРЕД карточкой снижает подозрительность # Заход на same-site vtorichka-SERP ПЕРЕД карточкой снижает подозрительность
# холодной навигации для QRATOR (эмпирически подтверждено вживую 2026-07-04). # холодной навигации для 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() его перезапишет. # #3196: статус читаем СРАЗУ после await — следующий fetch() его перезапишет.
# getattr — сайдкар/фетчер старой сборки атрибута не имеет, это не повод падать. # getattr — сайдкар/фетчер старой сборки атрибута не имеет, это не повод падать.
status = getattr(browser_fetcher, "last_response_status", None) status = getattr(browser_fetcher, "last_response_status", None)