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
199 lines
9.1 KiB
Python
199 lines
9.1 KiB
Python
"""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)
|
||
)
|