"""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) )