fix(tradein/domclick): органический заход через SERP перед card-fetch (anti-QRATOR) (#2430)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 52s
Deploy Trade-In / build-browser (push) Successful in 2m16s
Deploy Trade-In / build-backend (push) Successful in 1m45s
Deploy Trade-In / deploy (push) Successful in 8m15s

This commit is contained in:
lekss361 2026-07-04 16:39:47 +00:00
parent 752c3656a3
commit 102bdbda47
7 changed files with 172 additions and 15 deletions

View file

@ -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

View file

@ -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": "<ok>"})
bf = _fetcher(client, source="avito")
html = await bf.fetch("https://avito.ru/x")
assert html == "<ok>"
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": "<ok>"})
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"

View file

@ -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]

View file

@ -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

View file

@ -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 "<html>ok</html>"
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 == "<html>ok</html>"
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 == "<html>ok</html>"
assert page.goto_urls == ["https://www.avito.ru/card/1"]
async def _coro(value: Any) -> Any:
"""Хелпер: оборачивает значение в awaitable для подмены request.json()."""
return value

View file

@ -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:

View file

@ -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: