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(
_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,
)

View file

@ -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")

View file

@ -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_namevalue для инъекции ДО любой навигации
(обход 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]

View file

@ -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,

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._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)

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,
*,
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_namevalue для инъекции в 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:

View file

@ -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)