From 5baf07d6e67672946a6e4a4c69c0db9b213036a0 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 11:24:28 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/domclick):=20=D1=81=D0=B2=D0=B8?= =?UTF-8?q?=D0=BF=20=D0=B1=D1=80=D0=B0=D0=BB=20BFF=20=D0=BD=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D0=B5=D0=B9=20=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=83=D0=B7=D0=B5=D1=80=D0=B0=20=E2=80=94=20=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D1=8C=20=D0=BF=D0=BE=D0=B4=D0=B7=D0=B0=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D0=BE=D0=BC=20(#3264)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 знали старую сигнатуру — обновлены. --- .../tests/test_3118_domclick_warm_context.py | 8 +- .../tests/test_3264_sweep_subresource_mode.py | 95 +++++++++++++++ tradein-mvp/browser/server.py | 115 ++++++++++++++++++ tradein-mvp/browser/test_server.py | 1 + tradein-mvp/browser/test_server_fetch_mode.py | 112 +++++++++++++++++ .../src/scraper_kit/browser_fetcher.py | 24 +++- .../scraper_kit/providers/domclick/serp.py | 26 +++- 7 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3264_sweep_subresource_mode.py create mode 100644 tradein-mvp/browser/test_server_fetch_mode.py diff --git a/tradein-mvp/backend/tests/test_3118_domclick_warm_context.py b/tradein-mvp/backend/tests/test_3118_domclick_warm_context.py index fb96e3cc..09d77e7a 100644 --- a/tradein-mvp/backend/tests/test_3118_domclick_warm_context.py +++ b/tradein-mvp/backend/tests/test_3118_domclick_warm_context.py @@ -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 "" @@ -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") diff --git a/tradein-mvp/backend/tests/test_3264_sweep_subresource_mode.py b/tradein-mvp/backend/tests/test_3264_sweep_subresource_mode.py new file mode 100644 index 00000000..89fff284 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3264_sweep_subresource_mode.py @@ -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": "ok", "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 diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index d31dd8c5..c1fbf0ea 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -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] = { diff --git a/tradein-mvp/browser/test_server.py b/tradein-mvp/browser/test_server.py index 226d3da5..046e6a2d 100644 --- a/tradein-mvp/browser/test_server.py +++ b/tradein-mvp/browser/test_server.py @@ -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]) diff --git a/tradein-mvp/browser/test_server_fetch_mode.py b/tradein-mvp/browser/test_server_fetch_mode.py new file mode 100644 index 00000000..aec35c9a --- /dev/null +++ b/tradein-mvp/browser/test_server_fetch_mode.py @@ -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("x".encode()) + assert server._decode_body(raw) == "x" + + +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) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py index 9476e72f..1aa996e1 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py @@ -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: diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py index 6df94ed9..ac458a2a 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py @@ -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: