Merge pull request 'fix(tradein/domclick): свип брал BFF навигацией браузера — теперь подзапросом (#3264)' (#3266) from fix/3264-sweep-subresource-fetch into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m14s
Deploy Trade-In / test (push) Successful in 4m3s
Deploy Trade-In / build-backend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 2m42s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s

Reviewed-on: #3266
This commit is contained in:
lekss361 2026-08-30 08:32:51 +00:00
commit 10b90cb85b
7 changed files with 375 additions and 6 deletions

View file

@ -239,7 +239,9 @@ 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, referer=None):
async def _fake_post(
url, origin=None, cookies=None, reset_context=False, referer=None, fetch_mode=None
):
seen.append(reset_context)
return "<html></html>"
@ -261,7 +263,9 @@ 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, referer=None):
async def _fake_post(
url, origin=None, cookies=None, reset_context=False, referer=None, fetch_mode=None
):
seen.append(reset_context)
if len(seen) == 1:
raise httpx.ConnectError("boom")

View file

@ -0,0 +1,95 @@
"""#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

View file

@ -138,6 +138,7 @@ Per-provider модель (#1793):
import asyncio
import base64
import gzip
import logging
import os
from collections.abc import Callable, Mapping
@ -1259,6 +1260,16 @@ async def fetch_handler(request: web.Request) -> web.Response:
reuse_context: bool = bool(body.get("reuse_context", False))
reset_context: bool = bool(body.get("reset_context", False))
# fetch_mode (#3264) — чем забирать ресурс: навигацией (дефолт, прежнее поведение),
# запросом контекста или fetch'ем из прогретой страницы. См. FETCH_MODES.
fetch_mode: str = str(body.get("fetch_mode") or "navigate")
if fetch_mode not in FETCH_MODES:
return web.json_response(
{"error": f"неизвестный fetch_mode={fetch_mode!r}, "
f"допустимы {sorted(FETCH_MODES)}"},
status=400,
)
provider = _resolve_provider(body, url)
proxy_override = _resolve_proxy_override(body, provider)
@ -1299,6 +1310,7 @@ async def fetch_handler(request: web.Request) -> web.Response:
cookies=cookies,
reuse_context=reuse_context,
reset_context=reset_context,
fetch_mode=fetch_mode,
)
# Читаем статус под тем же локом, что и сам фетч, — иначе следующий
# запрос этого провайдера успел бы его перезаписать.
@ -1464,6 +1476,41 @@ async def _pace_provider(provider: str) -> None:
_last_goto_at[provider] = asyncio.get_event_loop().time()
# ── Режимы получения тела (#3264) ────────────────────────────────────────────────
# Сайдкар всю жизнь умел ровно одно: navigate — page.goto(url). Для HTML-страницы это
# и есть то, что делает человек. Но два нужных нам ресурса Домклика страницами НЕ
# являются: BFF-ручка /api/offers/v1 отдаёт JSON, а карта офферов
# (sitemap-offers-1.xml.gz) — вообще gzip-файл. Живой сайт их так и не запрашивает:
# перехват сети на выдаче 30.08.2026 показал, что офферы приезжают в SSR-документе, а
# к BFF ходят XHR'ы за гео/районами/метро. Навигацией браузера на API-хост мы делаем
# то, чего настоящий клиент не делает никогда, — и через узлы пула это упирается в
# ChallengeTimeout, тогда как карточки через те же узлы в те же минуты идут.
#
# Отсюда два дополнительных режима, оба — ПОСЛЕ прогрева origin:
# subresource — context.request.get(url): запрос из browser-контекста, куки и прокси
# общие со страницей, но это не JS страницы;
# page_fetch — fetch(url, {credentials: 'include'}) ИЗ прогретой страницы: ровно то,
# что делает SPA, вместе с Origin/Referer/Sec-Fetch-* и cookie jar'ом.
# Какой из них проходит через пул — вопрос замера, а не рассуждения, поэтому оба.
FETCH_MODES: frozenset[str] = frozenset({"navigate", "subresource", "page_fetch"})
def _decode_body(raw: bytes) -> str:
"""Тело подзапроса строкой: gzip-файлы распаковываем, прочее декодируем как UTF-8.
Карта офферов приезжает с Content-Type: application/gzip это gzip-ФАЙЛ, а не
Content-Encoding, поэтому HTTP-стек его не распаковывает и вызывающий получил бы
бинарь в поле "html". Распаковываем здесь по магическим байтам, чтобы контракт
/fetch остался прежним «тело строкой».
"""
if raw[:2] == bytes((0x1F, 0x8B)): # магия gzip
try:
raw = gzip.decompress(raw)
except Exception: # noqa: BLE001 — битый gzip отдаём как есть, пусть решает caller
pass
return raw.decode("utf-8", "replace")
async def _do_fetch(
provider: str,
url: str,
@ -1473,6 +1520,7 @@ async def _do_fetch(
cookies: dict | None = None,
reuse_context: bool = False,
reset_context: bool = False,
fetch_mode: str = "navigate",
) -> str:
"""Одна попытка навигации; при краше браузера — relaunch и один retry.
@ -1495,6 +1543,7 @@ async def _do_fetch(
cookies=cookies,
reuse_context=reuse_context,
reset_context=reset_context,
fetch_mode=fetch_mode,
)
except Exception as exc:
if _is_browser_crash(exc):
@ -1515,6 +1564,7 @@ async def _do_fetch(
cookies=cookies,
reuse_context=reuse_context,
reset_context=reset_context,
fetch_mode=fetch_mode,
)
raise
@ -1834,6 +1884,45 @@ async def _get_or_create_context(
return context
async def _fetch_subresource(
page: object, url: str, fetch_mode: str, referer: str | None
) -> tuple[bytes, int]:
"""Забирает *url* подзапросом из прогретой страницы. Возвращает (тело, HTTP-статус).
Два режима, отличающихся тем, ЧЬИМ запросом идёт обращение:
``subresource`` ``context.request.get``: запрос браузерного контекста. Куки, прокси
и TLS общие со страницей, но это не JS страницы, поэтому заголовков ``Sec-Fetch-*`` и
``Origin`` у него нет.
``page_fetch`` ``fetch()``, выполненный ВНУТРИ страницы. Ровно то, что делает SPA:
с ``Origin``/``Referer``/``Sec-Fetch-*`` и общим cookie jar'ом. Тело возвращается
через base64, потому что ``page.evaluate`` умеет отдавать только JSON-совместимое,
а карта офферов двоичный gzip.
"""
if fetch_mode == "subresource":
headers = {"Referer": referer} if referer else None
resp = await page.context.request.get( # type: ignore[attr-defined]
url, timeout=BROWSER_NAV_TIMEOUT_MS, headers=headers
)
return await resp.body(), int(resp.status)
if fetch_mode == "page_fetch":
res = await page.evaluate( # type: ignore[attr-defined]
"""async (u) => {
const r = await fetch(u, {credentials: 'include'});
const buf = new Uint8Array(await r.arrayBuffer());
let s = '';
for (let i = 0; i < buf.length; i++) s += String.fromCharCode(buf[i]);
return {status: r.status, b64: btoa(s)};
}""",
url,
)
return base64.b64decode(res["b64"]), int(res["status"])
raise ValueError(f"неизвестный fetch_mode={fetch_mode!r}, допустимы {sorted(FETCH_MODES)}")
async def _fetch_once(
provider: str,
url: str,
@ -1843,6 +1932,7 @@ async def _fetch_once(
cookies: dict | None = None,
reuse_context: bool = False,
reset_context: bool = False,
fetch_mode: str = "navigate",
) -> str:
"""Открывает страницу, переходит по URL, ждёт JS, возвращает HTML.
@ -1929,6 +2019,31 @@ async def _fetch_once(
origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
)
await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined]
if fetch_mode != "navigate":
# Ресурс берём ПОДЗАПРОСОМ из уже прогретого контекста, а не навигацией
# (#3264). Прогрев origin выше обязателен: именно он проносит рукопожатие
# QRATOR в куки контекста, из которого пойдёт подзапрос.
body_bytes, status = await _fetch_subresource(page, url, fetch_mode, referer)
_last_response_status[provider] = status
text = _decode_body(body_bytes)
if _is_ban_page(text):
raise BanPageDetectedError(
f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}"
)
if provider == "domclick" and _is_domclick_refusal(text):
raise BanPageDetectedError(
f"tradein-browser[{provider}]: статический отказ площадки url={url!r}"
)
logger.info(
"tradein-browser[%s]: %s → HTTP %s, тело %d Б url=%r",
provider,
fetch_mode,
status,
len(text),
url,
)
return text
# Статус берём у ЦЕЛЕВОЙ навигации, а не у прогрева origin выше: интересует
# ответ площадки на запрошенный url (#3196).
goto_kwargs: dict[str, object] = {

View file

@ -182,6 +182,7 @@ class _OverlapProbe:
cookies: dict | None = None,
reuse_context: bool = False,
reset_context: bool = False,
fetch_mode: str = "navigate",
) -> str:
self.active[provider] = self.active.get(provider, 0) + 1
self.peak[provider] = max(self.peak.get(provider, 0), self.active[provider])

View file

@ -0,0 +1,112 @@
"""test_server_fetch_mode.py — режимы получения тела: навигация против подзапроса (#3264).
BFF-ручка Домклика и карта офферов страницами не являются. Навигацией браузера на
BFF через мобильные узлы пула приходит только ChallengeTimeout, подзапросом из
прогретого контекста HTTP 200 за 6 секунд (замер 30.08.2026, четыре узла, шесть
корзин). Здесь проверяется контракт режимов, а не поведение площадки.
Тестируется:
1. дефолт остаётся навигацией goto зовётся, подзапрос нет;
2. subresource НЕ навигирует на целевой url и берёт тело запросом контекста;
3. gzip-файл распаковывается (карта офферов приезжает application/gzip);
4. неизвестный режим отбивается 400, а не падает внутри.
camoufox НЕ запускается browser/context/page подделаны.
"""
from __future__ import annotations
import asyncio
import gzip
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)
class _Resp:
def __init__(self, raw: bytes, status: int = 200) -> None:
self._raw, self.status = raw, status
async def body(self) -> bytes:
return self._raw
class _Request:
def __init__(self, raw: bytes) -> None:
self._raw = raw
self.calls: list[tuple[str, Any]] = []
async def get(self, url: str, **kwargs: Any) -> _Resp:
self.calls.append((url, kwargs))
return _Resp(self._raw)
class _Context:
def __init__(self, raw: bytes) -> None:
self.request = _Request(raw)
class _Page:
def __init__(self, raw: bytes = b"{}") -> None:
self.context = _Context(raw)
self.goto_urls: list[str] = []
self.evaluated: list[str] = []
async def goto(self, url: str, **_kw: Any) -> None:
self.goto_urls.append(url)
async def evaluate(self, _script: str, url: str) -> dict:
self.evaluated.append(url)
return {"status": 200, "b64": ""}
def test_gzip_body_is_decompressed() -> None:
"""Карта офферов приезжает Content-Type: application/gzip — стек её не распакует."""
raw = gzip.compress("<urlset><loc>x</loc></urlset>".encode())
assert server._decode_body(raw) == "<urlset><loc>x</loc></urlset>"
def test_plain_body_passes_through() -> None:
assert server._decode_body(b'{"result":{"offersCount":2202}}') == (
'{"result":{"offersCount":2202}}'
)
def test_subresource_does_not_navigate_to_target() -> None:
"""Смысл режима: на целевой url НЕ ходим навигацией, берём его подзапросом."""
page = _Page(b'{"result":{"offersCount":2202}}')
raw, status = asyncio.run(
server._fetch_subresource(page, "https://bff-search-web.domclick.ru/api/x",
"subresource", "https://ekaterinburg.domclick.ru/serp")
)
assert page.goto_urls == []
assert status == 200
assert b"offersCount" in raw
url, kwargs = page.context.request.calls[0]
assert url == "https://bff-search-web.domclick.ru/api/x"
assert kwargs["headers"] == {"Referer": "https://ekaterinburg.domclick.ru/serp"}
def test_subresource_without_referer_sends_no_header() -> None:
page = _Page()
asyncio.run(server._fetch_subresource(page, "https://x.domclick.ru/api", "subresource", None))
assert page.context.request.calls[0][1]["headers"] is None
def test_unknown_mode_raises_named_error() -> None:
page = _Page()
with pytest.raises(ValueError, match="fetch_mode"):
asyncio.run(server._fetch_subresource(page, "https://x/api", "телепортация", None))
def test_navigate_is_the_default_and_stays_a_valid_mode() -> None:
assert "navigate" in server.FETCH_MODES
assert {"navigate", "subresource", "page_fetch"} == set(server.FETCH_MODES)

View file

@ -468,9 +468,19 @@ class BrowserFetcher:
referer: str | None = None,
cookies: dict[str, str] | None = None,
reset_context: bool = False,
fetch_mode: str | None = None,
) -> str:
"""Запрашивает HTML страницы через tradein-browser HTTP-сервис.
fetch_mode (#3264) — чем сайдкар заберёт ресурс: ``None``/``"navigate"`` —
обычная навигация браузера (дефолт, прежнее поведение), ``"subresource"``
запрос из прогретого browser-контекста. Второй режим нужен ресурсам, которые
страницами не являются: BFF-ручка Домклика отдаёт JSON, и навигацией браузера
на неё через мобильные прокси приходит только ChallengeTimeout, тогда как
подзапросом HTTP 200 за 6 секунд (замер 30.08.2026, все 4 узла пула, все 6
комнатных корзин). Требует непустого origin: рукопожатие QRATOR попадает в
куки контекста именно при прогреве.
origin same-site якорь, на который камуфокс зайдёт ПЕРЕД url (прогревает
cookies контекста ДО холодного goto на url), см. /fetch ``origin`` в
server.py. None (дефолт) поведение не меняется (ровно один goto(url), как
@ -519,7 +529,9 @@ class BrowserFetcher:
self._context_reset_pending = False
try:
return await self._post_fetch(url, origin, cookies, effective_reset, referer)
return await self._post_fetch(
url, origin, cookies, effective_reset, referer, fetch_mode=fetch_mode
)
except (httpx.HTTPError, httpx.TransportError) as exc:
logger.warning(
"BrowserFetcher: ошибка запроса (%s), retry через %.1fs: %s",
@ -528,7 +540,9 @@ class BrowserFetcher:
url,
)
await asyncio.sleep(_RETRY_SLEEP_S)
return await self._post_fetch(url, origin, cookies, effective_reset, referer)
return await self._post_fetch(
url, origin, cookies, effective_reset, referer, fetch_mode=fetch_mode
)
async def fetch_json(
self,
@ -798,6 +812,7 @@ class BrowserFetcher:
cookies: dict[str, str] | None = None,
reset_context: bool = False,
referer: str | None = None,
fetch_mode: str | None = None,
) -> str:
"""Один HTTP POST к /fetch эндпоинту сервиса.
@ -838,6 +853,11 @@ class BrowserFetcher:
payload["reset_context"] = reset_context
if referer:
payload["referer"] = referer
# fetch_mode кладём ТОЛЬКО когда задан и не дефолтный: сайдкар старой версии
# (без этого поля в схеме) не должен получать неожиданный ключ — тот же приём,
# что с referer выше.
if fetch_mode and fetch_mode != "navigate":
payload["fetch_mode"] = fetch_mode
if proxy_url:
payload["proxy"] = proxy_url
if proxy_kind:

View file

@ -158,6 +158,16 @@ def _build_offers_url(
return f"{_BFF_BASE}/api/offers/v1?{urlencode(params)}"
# Страница, на которой прогревается контекст перед запросом к BFF (#3264).
# BFF — не страница, а JSON-ручка SPA; навигацией браузера на неё через мобильные узлы
# пула приходит только ChallengeTimeout (прод, прогоны 5330 и 5351 — 9/9 и 6/6 запросов
# зависли), а подзапросом из прогретой выдачи — HTTP 200 за 6 секунд на всех четырёх
# узлах и всех шести комнатных корзинах (замер 30.08.2026, суммарно 6359 офферов против
# 6367, снятых напрямую с резидентного IP). Прогрев обязателен: рукопожатие QRATOR
# попадает в куки контекста именно при заходе на страницу.
_SWEEP_ORIGIN = "https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka"
def _build_count_url(
rooms: str,
price_gte: int | None,
@ -482,7 +492,13 @@ class DomClickScraper(BaseScraper):
"""
url = _build_count_url(rooms, price_gte, price_lte)
logger.debug("domklik: count url=%s", url)
html = await fetcher.fetch(url, cookies=self._cookies) # #3264: снимок Sber ID сессии, обходит QRATOR PoW
html = await fetcher.fetch(
url,
origin=_SWEEP_ORIGIN,
referer=_SWEEP_ORIGIN,
cookies=self._cookies,
fetch_mode="subresource",
)
try:
data = _extract_json(html)
except DomClickBlockedError:
@ -571,7 +587,13 @@ class DomClickScraper(BaseScraper):
break
url = _build_offers_url(rooms, price_gte, price_lte, offset)
logger.debug("domklik: offers url=%s", url)
html = await fetcher.fetch(url, cookies=self._cookies) # #3264: снимок Sber ID сессии, обходит QRATOR PoW
html = await fetcher.fetch(
url,
origin=_SWEEP_ORIGIN,
referer=_SWEEP_ORIGIN,
cookies=self._cookies,
fetch_mode="subresource",
)
try:
data = _extract_json(html)
except DomClickBlockedError: