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
Добор карточек Домклика упирался в отказ на 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.
153 lines
5.5 KiB
Python
153 lines
5.5 KiB
Python
"""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"
|