All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 27s
Deploy Trade-In / test (push) Successful in 1m23s
Deploy Trade-In / build-backend (push) Successful in 51s
Deploy Trade-In / deploy (push) Successful in 46s
262 lines
12 KiB
Python
262 lines
12 KiB
Python
"""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
|
||
|
||
import httpx
|
||
|
||
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) -> 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.
|
||
self._source = source
|
||
self._fetch_timeout_s = fetch_timeout_s
|
||
self._client: httpx.AsyncClient | None = None
|
||
self._endpoint: str | None = None
|
||
|
||
# ── lifecycle ──────────────────────────────────────────────────────────────
|
||
|
||
async def __aenter__(self) -> BrowserFetcher:
|
||
from app.core.config import settings
|
||
|
||
self._endpoint = settings.browser_http_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 ───────────────────────────────────────────────────────────────
|
||
|
||
async def _post_fetch(self, url: str) -> str:
|
||
"""Один HTTP POST к /fetch эндпоинту сервиса."""
|
||
assert self._client is not None
|
||
assert self._endpoint is not None
|
||
|
||
resp = await self._client.post(
|
||
f"{self._endpoint}/fetch",
|
||
json={"url": url, "source": self._source},
|
||
)
|
||
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
|
||
|
||
resp = await self._client.post(
|
||
f"{self._endpoint}/fetch-json",
|
||
json={
|
||
"url": url,
|
||
"source": self._source,
|
||
"method": method,
|
||
"headers": headers or {},
|
||
"body": body,
|
||
"origin": origin,
|
||
},
|
||
)
|
||
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
|