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
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 знали старую сигнатуру — обновлены.
95 lines
4.2 KiB
Python
95 lines
4.2 KiB
Python
"""#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
|