gendesign/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py
bot-backend e03f4e4534 feat(tradein): BrowserFetcher camoufox wrapper + Docker/config infra (Phase 0, #884)
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
2026-05-31 15:41:56 +03:00

219 lines
9.9 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 — 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