fix(tradein/domclick): свип брал BFF навигацией браузера — теперь подзапросом (#3264) #3266
7 changed files with 375 additions and 6 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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] = {
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
112
tradein-mvp/browser/test_server_fetch_mode.py
Normal file
112
tradein-mvp/browser/test_server_fetch_mode.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue