gendesign/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py
bot-backend 15361e6662
All checks were successful
CI / changes (pull_request) Successful in 9s
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
feat(proxy-pool): P4 — browser dynamic pool-proxy (acquire→/fetch→relaunch) + orchestration wiring, ship-dark (#2164)
2026-07-02 22:18:51 +03:00

349 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""browser_fetcher.py — HTTP-клиент к tradein-browser сервису (#884/#905).
Тонкий клиент над tradein-browser контейнером, который запускает AsyncCamoufox
локально и экспонирует HTTP API (POST /fetch).
Публичный интерфейс не изменился:
async with BrowserFetcher() as fetcher:
html = await fetcher.fetch("https://example.com")
Внутреннее устройство: httpx.AsyncClient + POST к settings.browser_http_endpoint.
Recycle, crash-recovery и управление браузером живут на стороне сервера (server.py).
При HTTPError / ConnectError делает одну повторную попытку после короткой паузы,
затем пробрасывает исключение.
Архитектура выбрана потому, что playwright WS-сервер (launch_server) несовместим
с playwright >=1.45: ``browserServerImpl.js`` отсутствует → MODULE_NOT_FOUND.
Локальный AsyncCamoufox + HTTP — работающая альтернатива.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from typing import TYPE_CHECKING
import httpx
if TYPE_CHECKING:
from scraper_kit.contracts import ProxyProvider
logger = logging.getLogger(__name__)
_RETRY_SLEEP_S: float = 1.0
_HTTP_TIMEOUT_S: float = 120.0 # навигация медленная → щедрый таймаут
class BrowserFetcher:
"""Async context manager: HTTP-клиент к tradein-browser HTTP-сервису.
Использование::
async with BrowserFetcher() as fetcher:
html = await fetcher.fetch("https://example.com")
"""
def __init__(
self,
source: str = "avito",
fetch_timeout_s: float = _HTTP_TIMEOUT_S,
*,
endpoint: str,
proxy_provider: ProxyProvider | None = None,
use_pool: bool = False,
) -> None:
# source — логический источник ("avito"/"cian"/"yandex"/"domclick"). Сервер
# роутит /fetch по нему на отдельный браузер+прокси, когда включён
# FEATURE_BROWSER_POOL_ENABLED (Phase 1). При выключенном флаге source
# игнорируется — поведение не меняется.
# fetch_timeout_s — таймаут httpx-клиента для POST /fetch. Yandex-путь передаёт
# 30s чтобы один проблемный combo занимал ≤30s×retries вместо 120s×retries.
# endpoint — HTTP-эндпоинт tradein-browser сервиса. Инжектируется вызывающей
# стороной (продуктовый код передаёт settings.browser_http_endpoint /
# ScraperConfig.browser_http_endpoint) — kit НЕ импортирует app.core.config.
#
# #2164 P4 (ship-dark за use_pool=config.use_proxy_pool_browser):
# proxy_provider + use_pool → на каждый /fetch scraper-сторона делает
# acquire(source), кладёт lease.url в тело запроса ({"proxy": ...}), а
# tradein-browser релончит camoufox с этим прокси (только при реальной смене).
# После fetch — mark_health + release (finally, lease не течёт). use_pool=False
# (дефолт) ИЛИ пустой пул → proxy в теле не шлём, браузер юзает свой env-прокси
# (BROWSER_PROXY_*), поведение не меняется.
self._source = source
self._fetch_timeout_s = fetch_timeout_s
self._client: httpx.AsyncClient | None = None
self._endpoint: str | None = endpoint
self._proxy_provider = proxy_provider
self._use_pool = use_pool
# ── lifecycle ──────────────────────────────────────────────────────────────
async def __aenter__(self) -> BrowserFetcher:
assert self._endpoint is not None, "BrowserFetcher: endpoint не задан"
self._client = httpx.AsyncClient(timeout=self._fetch_timeout_s)
logger.info("BrowserFetcher: клиент создан, endpoint=%s", self._endpoint)
return self
async def __aexit__(self, *_: object) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
logger.debug("BrowserFetcher: клиент закрыт")
# ── public API ─────────────────────────────────────────────────────────────
async def fetch(self, url: str) -> str:
"""Запрашивает HTML страницы через tradein-browser HTTP-сервис.
При HTTPError или ConnectError делает одну повторную попытку после
короткой паузы. Остальные исключения всплывают к вызывающему коду.
Returns:
Полный HTML-контент страницы.
"""
assert self._client is not None, "BrowserFetcher: используй как async context manager"
assert self._endpoint is not None, "BrowserFetcher: endpoint не задан"
try:
return await self._post_fetch(url)
except (httpx.HTTPError, httpx.TransportError) as exc:
logger.warning(
"BrowserFetcher: ошибка запроса (%s), retry через %.1fs: %s",
type(exc).__name__,
_RETRY_SLEEP_S,
url,
)
await asyncio.sleep(_RETRY_SLEEP_S)
return await self._post_fetch(url)
async def fetch_json(
self,
url: str,
*,
method: str = "GET",
headers: dict[str, str] | None = None,
body: str | None = None,
origin: str | None = None,
) -> dict:
"""In-page fetch() через sidecar /fetch-json. Возвращает {"status": int, "body": str}.
body — уже сериализованная строка (caller делает json.dumps для POST).
origin — same-origin страница, на которую перейдёт камуфокс перед fetch.
При HTTPError / TransportError делает одну повторную попытку после короткой
паузы. Остальные исключения всплывают к вызывающему коду.
"""
assert self._client is not None, "BrowserFetcher: используй как async context manager"
assert self._endpoint is not None, "BrowserFetcher: endpoint не задан"
try:
return await self._post_fetch_json(url, method, headers, body, origin)
except (httpx.HTTPError, httpx.TransportError) as exc:
logger.warning(
"BrowserFetcher: ошибка fetch-json запроса (%s), retry через %.1fs: %s",
type(exc).__name__,
_RETRY_SLEEP_S,
url,
)
await asyncio.sleep(_RETRY_SLEEP_S)
return await self._post_fetch_json(url, method, headers, body, origin)
async def login(
self,
*,
url: str,
email: str,
password: str,
email_selector: str,
password_selector: str,
submit_selector: str,
success_cookie: str,
pre_click_selectors: list[str] | None = None,
wait_ms: int | None = None,
) -> dict[str, str]:
"""Логинится через tradein-browser /login и возвращает cookies как dict name→value.
Использует увеличенный таймаут (60s) — логин медленнее обычного fetch.
При HTTPError / TransportError делает одну повторную попытку.
Returns:
Плоский словарь {cookie_name: cookie_value}.
"""
assert self._client is not None, "BrowserFetcher: используй как async context manager"
assert self._endpoint is not None, "BrowserFetcher: endpoint не задан"
body: dict[str, object] = {
"url": url,
"email": email,
"password": password,
"email_selector": email_selector,
"password_selector": password_selector,
"submit_selector": submit_selector,
"success_cookie": success_cookie,
"pre_click_selectors": pre_click_selectors or [],
}
if wait_ms is not None:
body["wait_ms"] = wait_ms
try:
return await self._post_login(body)
except (httpx.HTTPError, httpx.TransportError) as exc:
logger.warning(
"BrowserFetcher: ошибка login-запроса (%s), retry через %.1fs",
type(exc).__name__,
_RETRY_SLEEP_S,
)
await asyncio.sleep(_RETRY_SLEEP_S)
return await self._post_login(body)
# ── internal ───────────────────────────────────────────────────────────────
@contextmanager
def _pool_proxy(self) -> Iterator[tuple[str | None, str | None]]:
"""Взять прокси из пула на один /fetch (#2164 P4, ship-dark + fallback).
use_pool=False (дефолт) ИЛИ proxy_provider=None → yield (None, None): proxy в тело
/fetch не кладётся, tradein-browser юзает свой env-прокси (BROWSER_PROXY_*),
поведение не меняется. Флаг on + пул выдал lease → yield (lease.url, lease.kind);
на выходе mark_health(ok) + release (в finally — lease не течёт). Пул пуст/ошибка
acquire → fallback (None, None), НЕ падаем (сбор цел). ok=False если внутри блока
поднялось исключение (бан/сетевая ошибка) → mark_health(False).
"""
use_pool = self._use_pool and self._proxy_provider is not None
lease = None
if use_pool:
assert self._proxy_provider is not None # type-narrowing (use_pool гарантирует)
try:
lease = self._proxy_provider.acquire(self._source)
except Exception:
logger.warning(
"BrowserFetcher: proxy_pool acquire(%s) failed — fallback to env proxy",
self._source,
exc_info=True,
)
lease = None
if lease is None:
# off / пул пуст / ошибка acquire → без proxy в теле (browser юзает env).
yield None, None
return
assert self._proxy_provider is not None
ok = True
try:
yield lease.url, lease.kind
except Exception:
ok = False
raise
finally:
# mark_health + release — best-effort: проблема пула не должна ронять сбор.
try:
self._proxy_provider.mark_health(lease, ok)
except Exception:
logger.warning(
"BrowserFetcher: proxy_pool mark_health failed for %s",
self._source,
exc_info=True,
)
try:
self._proxy_provider.release(lease) # ОБЯЗАТЕЛЬНО — lease не течёт
except Exception:
logger.warning(
"BrowserFetcher: proxy_pool release failed for %s",
self._source,
exc_info=True,
)
async def _post_fetch(self, url: str) -> str:
"""Один HTTP POST к /fetch эндпоинту сервиса."""
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}
if proxy_url:
payload["proxy"] = proxy_url
if proxy_kind:
payload["proxy_kind"] = proxy_kind
resp = await self._client.post(f"{self._endpoint}/fetch", json=payload)
resp.raise_for_status()
data: dict[str, str] = resp.json()
html = data["html"]
logger.debug("BrowserFetcher: fetch OK url=%r html_len=%d", url, len(html))
return html
async def _post_fetch_json(
self,
url: str,
method: str,
headers: dict[str, str] | None,
body: str | None,
origin: str | None,
) -> dict:
"""Один HTTP POST к /fetch-json эндпоинту сервиса."""
assert self._client is not None
assert self._endpoint is not None
with self._pool_proxy() as (proxy_url, proxy_kind):
payload: dict = {
"url": url,
"source": self._source,
"method": method,
"headers": headers or {},
"body": body,
"origin": origin,
}
if proxy_url:
payload["proxy"] = proxy_url
if proxy_kind:
payload["proxy_kind"] = proxy_kind
resp = await self._client.post(f"{self._endpoint}/fetch-json", json=payload)
resp.raise_for_status()
data: dict = resp.json()
# Defensive: контракт сервера — {"status": int, "body": str} (зеркалит как
# _post_fetch читает data["html"]). Если ключи пропали (несовместимый сервер /
# прокинутый error-payload) — падаем с понятной ошибкой, а не KeyError ниже по
# стеку у адаптера, который ждёт r["status"]/r["body"].
if "status" not in data or "body" not in data:
raise RuntimeError(
f"BrowserFetcher: /fetch-json вернул некорректный ответ "
f"(нет 'status'/'body'): keys={sorted(data)} url={url!r}"
)
logger.debug(
"BrowserFetcher: fetch-json OK url=%r status=%s body_len=%d",
url,
data.get("status"),
len(data.get("body") or ""),
)
return data
async def _post_login(self, body: dict[str, object]) -> dict[str, str]:
"""Один HTTP POST к /login эндпоинту сервиса."""
assert self._client is not None
assert self._endpoint is not None
resp = await self._client.post(
f"{self._endpoint}/login",
json=body,
timeout=httpx.Timeout(60.0),
)
if resp.status_code == 502:
data = resp.json()
has_screenshot = bool(data.get("screenshot_b64"))
logger.debug(
"BrowserFetcher: login 502 — has_screenshot=%s page_url=%r",
has_screenshot,
data.get("page_url"),
)
raise RuntimeError(
f"browser login failed: {data.get('error')} url={data.get('page_url')}"
)
resp.raise_for_status()
data = resp.json()
raw: list[dict[str, object]] = data["cookies"]
result = {str(c["name"]): str(c["value"]) for c in raw}
logger.info("BrowserFetcher: login OK cookie_count=%d", len(result))
return result