gendesign/tradein-mvp/backend/tests/test_3264_sweep_subresource_mode.py
bot-backend 5baf07d6e6
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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 4m53s
fix(tradein/domclick): свип брал BFF навигацией браузера — теперь подзапросом (#3264)
BFF-ручка Домклика не страница, а JSON-эндпоинт SPA. Сайдкар умел ровно
одно — page.goto(url), — и навигацией браузера на API-хост мы делали то,
чего настоящий клиент не делает никогда. Перехват сети на живой выдаче
30.08 это и показал: офферы приезжают в SSR-документе, а к BFF ходят
XHR'ы за гео, районами и метро.

Через мобильные узлы пула такая навигация упиралась в ChallengeTimeout:
прогоны 5330 и 5351 — 9 из 9 и 6 из 6 запросов зависли на челлендже, 0
лотов. Карточки через те же узлы в те же минуты шли.

Замер трёх режимов на одном узле, один и тот же ресурс:

  BFF navigate     ChallengeTimeout, 55с
  BFF subresource  HTTP 200, 52 байта, 6с
  BFF page_fetch   ошибка

Подзапрос проверен вширь: все шесть комнатных корзин HTTP 200, суммарно
6359 офферов против 6367, снятых напрямую с резидентного IP (расхождение
— дрейф фонда за пару часов); список офферов отдаётся на всех четырёх
узлах пула, по 20 штук, 120 КБ.

Сайдкар получил fetch_mode: navigate (дефолт, прежнее поведение),
subresource (context.request.get из прогретого контекста) и page_fetch
(fetch из страницы). Третий оставлен, потому что теоретически он ближе
всего к настоящему XHR, но в замере отказал — выбор сделан измерением, а
не рассуждением. Прогрев origin обязателен: рукопожатие QRATOR попадает
в куки контекста именно при заходе на страницу, поэтому свип теперь
передаёт origin и referer, которых раньше не передавал вовсе.

_decode_body распаковывает gzip по магическим байтам — карта офферов
приезжает Content-Type: application/gzip, и без этого вызывающий получил
бы бинарь в поле "html". Сама карта, к слову, подзапросом всё равно не
берётся (279 байт — загрузчик QRATOR), но BFF полнее: 100% фонда против
62% у карты.

fetch_mode кладётся в payload только когда он не дефолтный — сайдкар
прежней версии не должен получать незнакомый ключ (тот же приём, что с
referer в #3247).

Тесты: сайдкар 212 passed (новый test_server_fetch_mode.py — 6
проверок), backend 316 passed (новый test_3264_sweep_subresource_mode.py
— 5). Три тестовых дублёра _do_fetch/_post_fetch знали старую сигнатуру
— обновлены.
2026-08-30 11:24:28 +03:00

95 lines
4.2 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.

"""#3264: свип берёт BFF подзапросом из прогретой страницы, а не навигацией.
BFF-ручка Домклика — не страница, а JSON-эндпоинт SPA. Навигацией браузера на неё
через мобильные узлы пула приходит только ChallengeTimeout (прод, прогоны 5330 и 5351:
9/9 и 6/6 запросов зависли на челлендже), а подзапросом из прогретого контекста —
HTTP 200 за 6 секунд. Замер 30.08.2026: все четыре узла пула, все шесть комнатных
корзин, суммарно 6359 офферов против 6367, снятых напрямую с резидентного IP.
Тестируется контракт, а не обход защиты:
1. оба вызова свипа идут с fetch_mode="subresource" И с непустым origin —
без прогрева рукопожатие в куки контекста не попадёт, режим бесполезен;
2. BrowserFetcher кладёт fetch_mode в payload ТОЛЬКО когда он не дефолтный —
сайдкар прежней версии не должен получать незнакомый ключ.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from scraper_kit.providers.domclick.serp import _SWEEP_ORIGIN, DomClickScraper
def _scraper() -> DomClickScraper:
return DomClickScraper(
SimpleNamespace(scraper_fetch_mode="browser", browser_http_endpoint="http://b/fetch"),
cookies={"qrator_jsid2": "x"},
)
@pytest.mark.asyncio
async def test_count_probe_uses_subresource_with_warmed_origin() -> None:
s = _scraper()
fetcher = MagicMock()
fetcher.fetch = AsyncMock(return_value='{"result":{"offersCount":2202}}')
await s._count(fetcher, "2", None, None) # type: ignore[attr-defined]
kw = fetcher.fetch.await_args.kwargs
assert kw["fetch_mode"] == "subresource"
assert kw["origin"] == _SWEEP_ORIGIN and kw["origin"]
assert kw["cookies"] == {"qrator_jsid2": "x"}
@pytest.mark.asyncio
async def test_pagination_uses_subresource_with_warmed_origin() -> None:
"""Второй вызов — листалка; режим и прогрев должны совпадать с count-пробой."""
s = _scraper()
fetcher = MagicMock()
fetcher.fetch = AsyncMock(return_value='{"result":{"items":[]}}')
await s._paginate(fetcher, "2", None, None, set(), [], 1) # type: ignore[attr-defined]
assert fetcher.fetch.await_count >= 1
kw = fetcher.fetch.await_args.kwargs
assert kw["fetch_mode"] == "subresource"
assert kw["origin"] == _SWEEP_ORIGIN
@pytest.mark.asyncio
@pytest.mark.parametrize(
("mode", "ожидается_в_payload"),
[("subresource", True), ("navigate", False), (None, False)],
)
async def test_fetch_mode_reaches_payload_only_when_meaningful(
mode: str | None, ожидается_в_payload: bool
) -> None:
"""Дефолтный режим в payload не попадает: старый сайдкар не знает такого ключа."""
from scraper_kit.browser_fetcher import BrowserFetcher
bf = BrowserFetcher(source="domclick", endpoint="http://b/fetch")
bf._client = MagicMock()
captured: dict = {}
class _Resp:
status_code = 200
@staticmethod
def json() -> dict:
return {"html": "<html>ok</html>", "status": 200}
@staticmethod
def raise_for_status() -> None:
return None
async def _post(_url: str, json: dict, **_kw: object) -> _Resp:
captured.update(json)
return _Resp()
bf._client.post = _post
await bf._post_fetch("https://bff-search-web.domclick.ru/api/offers/v1",
_SWEEP_ORIGIN, {"c": "1"}, False, _SWEEP_ORIGIN, mode)
assert ("fetch_mode" in captured) is ожидается_в_payload
if ожидается_в_payload:
assert captured["fetch_mode"] == mode