diff --git a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py index ff80ca8e..88c8c377 100644 --- a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py +++ b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py @@ -315,7 +315,28 @@ async def test_fetch_detail_parses_via_browser() -> None: e = await fetch_detail(_CARD_URL, browser_fetcher=bf) assert e.item_id == "2075729321" assert e.repair_state == "good" - bf.fetch.assert_called_once_with(_CARD_URL) + bf.fetch.assert_called_once_with( + _CARD_URL, origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka" + ) + + +@pytest.mark.asyncio +async def test_fetch_detail_passes_derived_vtorichka_origin() -> None: + """fetch_detail выводит origin из scheme+netloc card_url → vtorichka-SERP root. + + Организм-навигация (SERP до карточки) снижает подозрительность холодного + захода для QRATOR — см. комментарий в fetch_detail. Проверяем сам вывод origin + для другого хоста/пути, не завязываясь на _CARD_URL из happy-path теста выше. + """ + bf = MagicMock() + bf.fetch = AsyncMock(return_value=_HTML) + other_card_url = "https://spb.domclick.ru/card/sale__flat__999888777" + + await fetch_detail(other_card_url, browser_fetcher=bf) + + bf.fetch.assert_called_once_with( + other_card_url, origin="https://spb.domclick.ru/pokupka/kvartiry/vtorichka" + ) @pytest.mark.asyncio diff --git a/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py b/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py index cbf7ea4b..7a44782e 100644 --- a/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py +++ b/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py @@ -165,3 +165,36 @@ async def test_fetch_json_pool_off_no_proxy() -> None: body = client.post.call_args.kwargs["json"] assert "proxy" not in body + + +# ── fetch() origin passthrough (DomClick SERP-anchor, зеркалит fetch_json) ─────── + + +async def test_fetch_without_origin_sends_none() -> None: + """fetch(url) без origin → payload содержит "origin": None (parity avito/cian/yandex). + + Ни один провайдер кроме domclick не передаёт origin — сервер (body.get("origin")) + трактует None как «origin-goto не выполнять», поведение не меняется. + """ + client = _mock_client({"html": ""}) + bf = _fetcher(client, source="avito") + + html = await bf.fetch("https://avito.ru/x") + + assert html == "" + body = client.post.call_args.kwargs["json"] + assert body["origin"] is None + + +async def test_fetch_with_origin_sends_it_in_payload() -> None: + """fetch(url, origin=X) → payload несёт origin=X (domclick vtorichka-SERP anchor).""" + client = _mock_client({"html": ""}) + bf = _fetcher(client, source="cian") + + await bf.fetch( + "https://ekaterinburg.domclick.ru/card/sale__flat__1", + origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka", + ) + + body = client.post.call_args.kwargs["json"] + assert body["origin"] == "https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka" diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 7716f607..fffc973b 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -5,7 +5,7 @@ экспонирует простой HTTP API на базе aiohttp: GET /health → {"status": "ok", "browsers": {"avito": bool, ...}} - POST /fetch → {"url": "..."} → {"html": "..."} + POST /fetch → {"url","origin"(опц.)} → {"html": "..."} POST /fetch-json → {"url","method","headers","body","origin"} → {"status","body"} POST /login → {"url": "...", "email": "...", "password": "...", ...} → {"cookies": [...]} @@ -610,6 +610,13 @@ async def fetch_handler(request: web.Request) -> web.Response: if not url: return web.json_response({"error": "missing 'url' field"}, status=400) + # origin (опционально) — same-site якорь, на который камуфокс зайдёт ПЕРЕД url + # (органическая навигация: реальные cookies/Referer вместо холодного goto, + # зеркалит /fetch-json #1917). Явного auto-derive из url здесь НЕТ (в отличие + # от fetch-json) — caller не передал → остаётся None, origin-goto не выполняется, + # поведение идентично прежнему (avito/cian/yandex его никогда не передают). + origin: str | None = body.get("origin") + provider = _resolve_provider(body, url) proxy_override = _resolve_proxy_override(body, provider) @@ -630,7 +637,7 @@ async def fetch_handler(request: web.Request) -> web.Response: ) try: - html = await _do_fetch(provider, url) + html = await _do_fetch(provider, url, origin=origin) except Exception as exc: logger.error( "tradein-browser[%s]: fetch error url=%r: %s: %s", @@ -769,15 +776,17 @@ async def _pace_provider(provider: str) -> None: _last_goto_at[provider] = asyncio.get_event_loop().time() -async def _do_fetch(provider: str, url: str) -> str: +async def _do_fetch(provider: str, url: str, *, origin: str | None = None) -> str: """Одна попытка навигации; при краше браузера — relaunch и один retry. Caller держит _locks[provider] (нет параллельных страниц на этом инстансе), поэтому relaunch безопасен. Crash-relaunch сохраняет текущий прокси инстанса (_relaunch_browser reuse _launched_proxy) — динамический прокси пула не теряется. + + origin — см. _fetch_once. None (дефолт) не меняет поведение. """ try: - return await _fetch_once(provider, url) + return await _fetch_once(provider, url, origin=origin) except Exception as exc: if _is_browser_crash(exc): logger.warning( @@ -789,15 +798,21 @@ async def _do_fetch(provider: str, url: str) -> str: await _relaunch_browser(provider) if _browsers.get(provider) is None: raise - return await _fetch_once(provider, url) + return await _fetch_once(provider, url, origin=origin) raise -async def _fetch_once(provider: str, url: str) -> str: +async def _fetch_once(provider: str, url: str, *, origin: str | None = None) -> str: """Открывает СОБСТВЕННУЮ страницу, переходит по URL, ждёт JS, возвращает HTML. Caller держит _locks[provider], поэтому страницы на этом инстансе не параллелятся — recycle через _relaunch_browser безопасен прямо здесь. + + origin (опционально) — same-site якорь (например SERP), на который камуфокс + заходит ПЕРЕД целевым url: органическая навигация с реальными cookies/Referer + вместо холодного прямого захода (зеркалит _fetch_json_once, #1917 — DomClick + card-fetch, эмпирически подтверждено вживую 2026-07-04). None (дефолт) → поведение + не меняется, ровно один goto(url) как раньше (avito/cian/yandex не передают origin). """ browser = _browsers.get(provider) assert browser is not None, "browser not launched" @@ -806,6 +821,11 @@ async def _fetch_once(provider: str, url: str) -> str: try: await _apply_resource_block(page) await _pace_provider(provider) + if origin: + await page.goto( # type: ignore[attr-defined] + origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded" + ) + await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined] await page.goto(url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded") # type: ignore[attr-defined] if BROWSER_WAIT_MS > 0: await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined] diff --git a/tradein-mvp/browser/test_server.py b/tradein-mvp/browser/test_server.py index 474117f9..9e4f3f2d 100644 --- a/tradein-mvp/browser/test_server.py +++ b/tradein-mvp/browser/test_server.py @@ -202,7 +202,7 @@ class _OverlapProbe: self.global_active = 0 self.global_peak = 0 - async def __call__(self, provider: str, url: str) -> str: + async def __call__(self, provider: str, url: str, *, origin: str | None = None) -> str: self.active[provider] = self.active.get(provider, 0) + 1 self.peak[provider] = max(self.peak.get(provider, 0), self.active[provider]) self.global_active += 1 diff --git a/tradein-mvp/browser/test_server_smoke.py b/tradein-mvp/browser/test_server_smoke.py index f0a40345..069577bd 100644 --- a/tradein-mvp/browser/test_server_smoke.py +++ b/tradein-mvp/browser/test_server_smoke.py @@ -287,6 +287,75 @@ def test_login_closes_page_on_cancelled_error( assert tracker.closed == 1, "страница должна закрыться в finally при CancelledError" +# ── _fetch_once: origin-goto ТОЛЬКО когда передан (DomClick anti-bot, #2429-ish) ── +# Зеркалит _fetch_json_once (#1917): когда origin передан, камуфокс сначала заходит +# на origin (same-site якорь, напр. SERP), затем на целевой url — органическая +# навигация вместо холодного goto. origin=None (дефолт, все providers кроме +# domclick detail) должен остаться РОВНО одним goto(url), без регрессии. + + +class _OriginTrackingPage: + """Поддельная page: фиксирует ВСЕ goto-URL по порядку вызова.""" + + def __init__(self) -> None: + self.goto_urls: list[str] = [] + self.closed = 0 + + async def route(self, pattern: str, handler: Any) -> None: + return None + + async def goto(self, url: str, **kwargs: Any) -> None: + self.goto_urls.append(url) + + async def wait_for_timeout(self, ms: int) -> None: + return None + + async def content(self) -> str: + return "ok" + + async def close(self) -> None: + self.closed += 1 + + +class _OriginTrackingBrowser: + def __init__(self, page: _OriginTrackingPage) -> None: + self._page = page + + async def new_page(self) -> _OriginTrackingPage: + return self._page + + +def test_fetch_once_navigates_origin_then_url_when_provided( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """origin передан → ДВА goto по порядку: origin первым, затем url.""" + page = _OriginTrackingPage() + server._browsers["avito"] = _OriginTrackingBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + + html = asyncio.run( + server._fetch_once( + "avito", "https://www.avito.ru/card/1", origin="https://www.avito.ru/serp" + ) + ) + + assert html == "ok" + assert page.goto_urls == ["https://www.avito.ru/serp", "https://www.avito.ru/card/1"] + assert page.closed == 1 + + +def test_fetch_once_single_goto_when_origin_none(monkeypatch: pytest.MonkeyPatch) -> None: + """origin не передан (дефолт, avito/cian/yandex) → РОВНО один goto(url), без regressии.""" + page = _OriginTrackingPage() + server._browsers["avito"] = _OriginTrackingBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + + html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1")) + + assert html == "ok" + assert page.goto_urls == ["https://www.avito.ru/card/1"] + + async def _coro(value: Any) -> Any: """Хелпер: оборачивает значение в awaitable для подмены request.json().""" return value 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 023bc913..1892e397 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 @@ -95,9 +95,14 @@ class BrowserFetcher: # ── public API ───────────────────────────────────────────────────────────── - async def fetch(self, url: str) -> str: + async def fetch(self, url: str, *, origin: str | None = None) -> str: """Запрашивает HTML страницы через tradein-browser HTTP-сервис. + origin — same-site якорь, на который камуфокс зайдёт ПЕРЕД url (органическая + навигация: реальные cookies/Referer вместо холодного goto), см. /fetch + ``origin`` в server.py. None (дефолт) → поведение не меняется (ровно один + goto(url), как раньше) — таков путь всех providers кроме domclick detail. + При HTTPError или ConnectError делает одну повторную попытку после короткой паузы. Остальные исключения всплывают к вызывающему коду. @@ -108,7 +113,7 @@ class BrowserFetcher: assert self._endpoint is not None, "BrowserFetcher: endpoint не задан" try: - return await self._post_fetch(url) + return await self._post_fetch(url, origin) except (httpx.HTTPError, httpx.TransportError) as exc: logger.warning( "BrowserFetcher: ошибка запроса (%s), retry через %.1fs: %s", @@ -117,7 +122,7 @@ class BrowserFetcher: url, ) await asyncio.sleep(_RETRY_SLEEP_S) - return await self._post_fetch(url) + return await self._post_fetch(url, origin) async def fetch_json( self, @@ -257,13 +262,17 @@ class BrowserFetcher: exc_info=True, ) - async def _post_fetch(self, url: str) -> str: - """Один HTTP POST к /fetch эндпоинту сервиса.""" + async def _post_fetch(self, url: str, origin: str | None = None) -> str: + """Один HTTP POST к /fetch эндпоинту сервиса. + + origin всегда кладём в payload (даже None) — зеркалит _post_fetch_json, + сервер (body.get("origin")) корректно обрабатывает оба случая. + """ assert self._client is not None assert self._endpoint is not None with self._pool_proxy() as (proxy_url, proxy_kind): - payload: dict[str, str] = {"url": url, "source": self._source} + payload: dict = {"url": url, "source": self._source, "origin": origin} if proxy_url: payload["proxy"] = proxy_url if proxy_kind: diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py index a41803df..88ee030c 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py @@ -51,6 +51,7 @@ import re from dataclasses import dataclass, field from datetime import UTC, datetime from typing import TYPE_CHECKING, Any +from urllib.parse import urlsplit from sqlalchemy import text from sqlalchemy.orm import Session @@ -477,8 +478,12 @@ async def fetch_detail( это блоком и помечает listing failed. DomClickParseError: HTTP 200, но SSR-стейт не парсится (дрейф схемы). """ + parsed = urlsplit(card_url) + origin = f"{parsed.scheme}://{parsed.netloc}/pokupka/kvartiry/vtorichka" try: - html = await browser_fetcher.fetch(card_url) + # Заход на same-site vtorichka-SERP ПЕРЕД карточкой снижает подозрительность + # холодной навигации для QRATOR (эмпирически подтверждено вживую 2026-07-04). + html = await browser_fetcher.fetch(card_url, origin=origin) except DomClickBlockedError: raise except Exception as exc: