gendesign/tradein-mvp/browser/test_server_referer.py
bot-backend ff0a15d443
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m28s
CI Trade-In / backend-tests (pull_request) Successful in 5m1s
feat(tradein/browser): ходить по Домклику как человек — с Referer и с перезагрузкой зависшего рукопожатия
Добор карточек Домклика упирался в отказ на 11-й карточке: прогон 5298 дал
attempted=13, enriched=10, blocked=3. Ручные прогоны в живом браузере брали 91 и
40 карточек без единого отказа. Разбор нашёл два отличия, и оба оказались нашими,
а не площадки.

1. Referer не отправлялся НИКОГДА. playwright'овский goto() по умолчанию этот
   заголовок не шлёт, а параметр `referer`, который он принимает, мы не передавали.
   Площадка видела десяток появлений подряд прямо на URL карточки, без источника
   перехода — так не ходит ни один человек. Комментарии в коде при этом уверяли
   про «органическую навигацию с реальным Referer»; они врали, теперь исправлены.
   Сайдкар принимает `referer` в теле /fetch и ставит его ТОЛЬКО на целевую
   навигацию; на origin и якорную вкладку не ставит — туда приходят «сами».
   Добор Домклика передаёт страницу выдачи, чем переход и является по смыслу.

2. Зависшее рукопожатие не перезагружалось. _wait_out_pow_challenge построен на
   допущении «страница перезагрузит себя сама после решения PoW»; ручная сессия
   29.08 через узел 10 это опровергла — выдача осталась на 401, и пропуск
   qrator_jsid2 выдался только после ДВУХ перезагрузок, сделанных руками:
   102.7с GET → 401, 115.1с GET → 401, 118.4с GET → 200, следом кука-пропуск, и
   карточка за 6 секунд. Пока мы только опрашивали content(), такая страница жила
   до таймаута, а бэкфилл засчитывал это в блоки. Теперь после
   BROWSER_CHALLENGE_RELOAD_AFTER_MS (8с) сайдкар перезагружает сам, не больше
   BROWSER_CHALLENGE_MAX_RELOADS (2) раз за фетч.

Оба пути безопасны на откат: без поля `referer` в теле поведение прежнее,
BROWSER_CHALLENGE_RELOAD_AFTER_MS=0 возвращает прежний опрос без навигаций,
упавшая перезагрузка не роняет фетч — опрос продолжается в том же бюджете.

Тесты: 191 passed в сайдкаре (было 182) — 4 на Referer, 5 на перезагрузку, в том
числе «страница ожила сама → лишней навигации нет» и «висит вечно → не больше
лимита». Точечные backend-тесты домклика и scraper_kit — 144 passed.
2026-08-29 22:53:11 +03:00

153 lines
5.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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