Epic #883 foundation. Provider-agnostic BrowserFetcher (camoufox headless Firefox): async ctx mgr + page-recycle + crash-recovery + proxy. camoufox/playwright lazy imports (won't break app import). scraper_fetch_mode defaults curl_cffi -> DORMANT (no parser/estimator/avito/cian change). config: 4 browser_* settings. Dockerfile: +5 Firefox libs + camoufox fetch layer. compose: mem_limit 2.5g. (uv.lock gitignored -> re-resolved at build.) 9 mocked tests pass, ruff clean. WARN: Docker build + container fetch UNVERIFIED (no local Docker); ~960MB image; merge auto-deploys to prod. MERGE-GATE post-demo + CI-build-verify. Refs #884, #883
219 lines
9.9 KiB
Python
219 lines
9.9 KiB
Python
"""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
|