gendesign/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py
bot-backend 5fb367295a feat(browser): split camoufox into tradein-browser container, connect-mode (#905)
Phase 0.5 — revise #884's in-backend camoufox into a dedicated browser service.
camoufox.server.launch_server exposes a Playwright WS endpoint (fingerprint/geoip/
proxy configured server-side); the backend connects remotely via playwright, so it
no longer ships Firefox.

- NEW tradein-mvp/browser/{server.py,Dockerfile}: camoufox WS server on :3000;
  Firefox fetch under app-user + ENV HOME (carries the #899 cache-path fix)
- BrowserFetcher rewritten launch-in-process → playwright.firefox.connect(ws_endpoint);
  recycle (close+reconnect every N) + crash-recovery (TargetClosed → reconnect + 1 retry)
- backend Dockerfile slimmed: drop 5 Firefox libs + camoufox fetch layer
- pyproject: remove camoufox[geoip] from backend, promote playwright to main deps
- docker-compose.prod.yml: add browser service (mem_limit 2.5g moves here), remove
  mem_limit from backend, backend depends_on browser (service_started)
- config: browser_ws_endpoint default ws://tradein-browser:3000/playwright
- tests/scrapers/test_browser_fetcher.py rewritten for connect-mode (6 pass)

Dormant: scraper_fetch_mode stays curl_cffi → no prod behavior change.
launch_server signature verified (**kwargs→launch_options, port passes through).

Refs #905, #883, #884, #899
2026-05-31 17:24:35 +03:00

199 lines
9.1 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 — Playwright connect-mode клиент для browser-mode scraping (#884/#905).
Провайдер-агностичная основа: не затрагивает существующую avito/cian-логику.
Активируется только когда settings.scraper_fetch_mode == "browser" (дефолт
"curl_cffi" — поведение идентично старому до явного переключения).
Архитектура (Phase 0.5, #905):
- Camoufox Firefox запущен в отдельном контейнере ``tradein-browser`` как
Playwright WS-сервер (camoufox.server.launch_server).
- Этот модуль подключается к нему через ``playwright.async_api.firefox.connect``
используя эндпоинт из settings.browser_ws_endpoint.
- Proxy/fingerprint/locale/geoip настраиваются на стороне browser-сервера;
этот модуль управляет только жизненным циклом соединения и страниц.
Особенности:
- Ленивые импорты playwright: не ломают app-импорт если не установлен.
- Page recycle: каждые settings.browser_recycle_pages страниц закрываем и
переподключаемся (ограничение утечек памяти на стороне браузер-контейнера).
- Crash-recovery: TargetClosedError / connection-closed → переподключение → один retry.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
class BrowserFetcher:
"""Async context manager: Playwright-клиент подключения к tradein-browser WS-серверу.
Использование::
async with BrowserFetcher() as fetcher:
html = await fetcher.fetch("https://example.com")
"""
def __init__(self) -> None:
self._browser: object | None = None
self._pw: object | None = None
self._page_count: int = 0
# Lazy-loaded настройки чтобы не импортировать settings на верхнем уровне
self._recycle_after: int | None = None
self._nav_timeout_ms: int | None = None
self._wait_ms: int | None = None
self._ws_endpoint: str | None = None
# ── lifecycle ──────────────────────────────────────────────────────────────
async def __aenter__(self) -> BrowserFetcher:
from app.core.config import settings
self._recycle_after = settings.browser_recycle_pages
self._nav_timeout_ms = settings.browser_nav_timeout_ms
self._wait_ms = settings.browser_wait_ms
self._ws_endpoint = settings.browser_ws_endpoint
await self._connect()
return self
async def __aexit__(self, *_: object) -> None:
await self._close()
# ── public API ─────────────────────────────────────────────────────────────
async def fetch(self, url: str) -> str:
"""Открывает новую страницу, переходит по URL, ждёт JS, возвращает HTML.
При TargetClosedError или connection-closed (crash/перезапуск браузер-контейнера)
переподключается и делает одну повторную попытку. Остальные исключения
всплывают к вызывающему коду.
Returns:
Полный HTML-контент страницы (innerHTML documentElement).
"""
try:
return await self._fetch_once(url)
except Exception as exc:
if _is_target_closed(exc) or _is_crash_exception(exc):
logger.warning(
"BrowserFetcher: соединение с браузером потеряно (%s),"
" переподключение + retry: %s",
type(exc).__name__,
url,
)
await self._reconnect()
return await self._fetch_once(url)
raise
# ── internal ───────────────────────────────────────────────────────────────
async def _fetch_once(self, url: str) -> str:
"""Одна попытка: new_page → goto → wait → content → close."""
assert self._browser is not None, "BrowserFetcher: нет активного соединения"
browser = self._browser # type: ignore[assignment]
page = await browser.new_page() # type: ignore[attr-defined]
try:
await page.goto(url, timeout=self._nav_timeout_ms, wait_until="domcontentloaded")
if self._wait_ms and self._wait_ms > 0:
await page.wait_for_timeout(self._wait_ms)
html: str = await page.content()
finally:
await page.close()
self._page_count += 1
logger.debug(
"BrowserFetcher: fetch OK url=%r pages_since_connect=%d", url, self._page_count
)
# Recycle: переподключаемся каждые N страниц
assert self._recycle_after is not None
if self._page_count >= self._recycle_after:
logger.info(
"BrowserFetcher: recycle threshold (%d) достигнут, переподключение",
self._recycle_after,
)
await self._reconnect()
return html
async def _connect(self) -> None:
"""Запускает Playwright и подключается к WS-серверу tradein-browser."""
try:
from playwright.async_api import async_playwright
except ImportError as exc:
raise RuntimeError(
"playwright не установлен. Добавь 'playwright>=1.45' в pyproject.toml."
) from exc
assert self._ws_endpoint, "BrowserFetcher: browser_ws_endpoint не задан"
self._pw = await async_playwright().start()
pw = self._pw # type: ignore[assignment]
self._browser = await pw.firefox.connect( # type: ignore[attr-defined]
self._ws_endpoint,
timeout=30000, # 30s на установку соединения
)
self._page_count = 0
logger.info(
"BrowserFetcher: подключился к %s",
self._ws_endpoint,
)
async def _close(self) -> None:
"""Закрывает соединение с браузером и останавливает Playwright."""
browser = self._browser
if browser is not None:
try:
await browser.close() # type: ignore[attr-defined]
logger.info("BrowserFetcher: соединение закрыто")
except Exception as exc:
logger.warning("BrowserFetcher: ошибка при закрытии соединения: %s", exc)
finally:
self._browser = None
pw = self._pw
if pw is not None:
try:
await pw.stop() # type: ignore[attr-defined]
except Exception as exc:
logger.warning("BrowserFetcher: ошибка при остановке Playwright: %s", exc)
finally:
self._pw = None
self._page_count = 0
async def _reconnect(self) -> None:
"""Закрывает текущее соединение и устанавливает новое."""
await self._close()
await self._connect()
# ── helpers ────────────────────────────────────────────────────────────────────
def _is_target_closed(exc: BaseException) -> bool:
"""Проверяет является ли исключение playwright TargetClosedError.
Проверка по имени класса (не import) — playwright может не быть в dev-окружении.
"""
return type(exc).__name__ == "TargetClosedError"
def _is_crash_exception(exc: BaseException) -> bool:
"""Проверяет является ли исключение признаком краша / разрыва соединения.
Ловим широкий набор playwright crash/disconnect сигналов по имени/сообщению.
"""
cls_name = type(exc).__name__
if cls_name in ("BrowserClosedError", "ConnectionClosedError"):
return True
msg = str(exc).lower()
return (
"browser has been closed" in msg
or "target closed" in msg
or "connection closed" in msg
or ("websocket" in msg and "closed" in msg)
)