"""browser_fetcher.py — camoufox wrapper для browser-mode scraping (Phase 0, #884). Провайдер-агностичная основа: не затрагивает существующую avito/cian-логику. Активируется только когда settings.scraper_fetch_mode == "browser" (пока дефолт "curl_cffi" — поведение идентично старому до явного переключения). Особенности: - Ленивые импорты camoufox/playwright: не ломают app-импорт если Firefox не установлен (аналогично curl_cffi в avito_imv.py). - Page recycle: каждые settings.browser_recycle_pages страниц браузер перезапускается (ограничение утечек памяти). - Crash-recovery: TargetClosedError → relaunch → one retry. - Proxy: парсит settings.scraper_proxy_url → playwright dict формат {"server": "http://host:port", "username": ..., "password": ...}. """ from __future__ import annotations import logging from urllib.parse import urlparse logger = logging.getLogger(__name__) class BrowserFetcher: """Async context manager: camoufox headless Firefox с рециклингом страниц. Использование:: async with BrowserFetcher() as fetcher: html = await fetcher.fetch("https://example.com") """ def __init__(self) -> None: self._browser: 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 # ── 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 await self._launch() return self async def __aexit__(self, *_: object) -> None: await self._close_browser() # ── public API ───────────────────────────────────────────────────────────── async def fetch(self, url: str) -> str: """Открывает новую страницу, переходит по URL, ждёт JS, возвращает HTML. При TargetClosedError (crash) перезапускает браузер и делает одну повторную попытку. Остальные исключения всплывают к вызывающему коду. Returns: Полный HTML-контент страницы (innerHTML documentElement). """ try: return await self._fetch_once(url) except Exception as exc: # Определяем crash vs обычная ошибка через имя класса (lazy import) if _is_target_closed(exc) or _is_crash_exception(exc): logger.warning( "BrowserFetcher: браузер упал (%s), перезапуск + retry: %s", type(exc).__name__, url, ) await self._relaunch() return await self._fetch_once(url) raise # ── internal ─────────────────────────────────────────────────────────────── async def _fetch_once(self, url: str) -> str: """Одна попытка: new_page → goto → wait → content → close.""" # Ленивый импорт: не нужен при curl_cffi mode (не ломает app при # отсутствии установленного Firefox) try: from camoufox.async_api import AsyncCamoufox # noqa: F401 (type check) except ImportError: pass # импорт нужен только для type-check; browser уже запущен через _launch assert self._browser is not None, "BrowserFetcher: браузер не запущен" # Тип browser — AsyncCamoufox-инстанс (playwright.async_api.Browser-like) 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") # Даём JS-гидрации отработать 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_launch=%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._relaunch() return html async def _launch(self) -> None: """Запускает AsyncCamoufox браузер.""" try: from camoufox.async_api import AsyncCamoufox except ImportError as exc: raise RuntimeError( "camoufox не установлен. Добавь 'camoufox[geoip]>=0.4.0' в pyproject.toml " "и выполни 'python -m camoufox fetch' для загрузки Firefox-сборки." ) from exc proxy = self._proxy_dict() kwargs: dict[str, object] = { "headless": True, "os": "windows", "locale": "ru-RU", "geoip": True, "humanize": True, } if proxy is not None: kwargs["proxy"] = proxy # AsyncCamoufox — async context manager; входим в него чтобы получить browser ctx = AsyncCamoufox(**kwargs) # type: ignore[call-arg] self._browser = await ctx.__aenter__() self._browser_ctx = ctx # сохраняем для корректного __aexit__ self._page_count = 0 logger.info("BrowserFetcher: браузер запущен (proxy=%s)", proxy is not None) async def _close_browser(self) -> None: """Закрывает браузер если запущен.""" ctx = getattr(self, "_browser_ctx", None) if ctx is not None: try: await ctx.__aexit__(None, None, None) logger.info("BrowserFetcher: браузер закрыт") except Exception as exc: logger.warning("BrowserFetcher: ошибка при закрытии браузера: %s", exc) finally: self._browser = None self._browser_ctx = None self._page_count = 0 async def _relaunch(self) -> None: """Закрывает текущий браузер и запускает новый.""" await self._close_browser() await self._launch() def _proxy_dict(self) -> dict[str, str] | None: """Парсит settings.scraper_proxy_url → playwright proxy dict. Формат входного URL: ``http://user:pass@host:port`` или ``http://host:port``. Возвращает None если proxy не настроен. Returns: ``{"server": "http://host:port", "username": "...", "password": "..."}`` или None. """ from app.core.config import settings proxy_url = settings.scraper_proxy_url if not proxy_url: return None parsed = urlparse(proxy_url) # Собираем server без credentials (playwright требует отдельных полей) port_part = f":{parsed.port}" if parsed.port else "" server = f"{parsed.scheme}://{parsed.hostname}{port_part}" result: dict[str, str] = {"server": server} if parsed.username: result["username"] = parsed.username if parsed.password: result["password"] = parsed.password return result # ── helpers ──────────────────────────────────────────────────────────────────── def _is_target_closed(exc: BaseException) -> bool: """Проверяет является ли исключение playwright TargetClosedError. Проверка по имени класса (не import) — playwright не установлен обязательно. """ cls_name = type(exc).__name__ return cls_name == "TargetClosedError" def _is_crash_exception(exc: BaseException) -> bool: """Проверяет является ли исключение признаком краша браузера. Ловим широкий набор playwright crash-сигналов по имени/сообщению. """ 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