fix(tradein/domclick): органический заход через SERP перед card-fetch (anti-QRATOR)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 53s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 53s
DomClick card-фетч шёл холодным прямым goto(card_url) — эмпирически подтверждено, что QRATOR подозрительнее относится к такой навигации, чем к заходу через same-site SERP-страницу перед карточкой (как ведёт себя реальный пользователь). Расширяет уже проверенный паттерн /fetch-json (origin-goto + settle wait, #1917) на обычный /fetch: origin опционален и по умолчанию None — avito/cian/yandex поведение не меняется (byte-identical, origin — keyword-only, ни один из 12 существующих call-site'ов /fetch не передаёт позиционный аргумент на это место). Только domclick/detail.py::fetch_detail() передаёт origin, выведенный из card_url как {scheme}://{netloc}/pokupka/kvartiry/vtorichka.
This commit is contained in:
parent
752c3656a3
commit
7ab20977c3
7 changed files with 172 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue