"""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, *, origin: str | None = None, cookies: dict[str, 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. cookies — dict cookie_name→value для инъекции в browser-контекст ПЕРЕД навигацией (обходит QRATOR-блок DomClick при валидной test-аккаунт сессии, empирически подтверждено вживую 2026-07-04, см. app.services. domclick_session). None (дефолт) → поведение не меняется, инъекции нет — таков путь всех providers кроме domclick detail-debug. При 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, origin, cookies) 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, origin, cookies) 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, origin: str | None = None, cookies: dict[str, str] | None = None, ) -> str: """Один HTTP POST к /fetch эндпоинту сервиса. origin/cookies всегда кладём в payload (даже None) — зеркалит _post_fetch_json, сервер (body.get("origin")/body.get("cookies")) корректно обрабатывает оба случая. """ 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, "origin": origin, "cookies": cookies, } 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