From e03f4e45340e1138aa03f34b63c4a3996c188a19 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 31 May 2026 15:41:56 +0300 Subject: [PATCH] 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 --- tradein-mvp/backend/Dockerfile | 10 + tradein-mvp/backend/app/core/config.py | 16 + .../app/services/scrapers/browser_fetcher.py | 219 +++++++++++++ tradein-mvp/backend/pyproject.toml | 1 + .../tests/scrapers/test_browser_fetcher.py | 295 ++++++++++++++++++ tradein-mvp/docker-compose.prod.yml | 1 + 6 files changed, 542 insertions(+) create mode 100644 tradein-mvp/backend/app/services/scrapers/browser_fetcher.py create mode 100644 tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py diff --git a/tradein-mvp/backend/Dockerfile b/tradein-mvp/backend/Dockerfile index bd277646..38876e26 100644 --- a/tradein-mvp/backend/Dockerfile +++ b/tradein-mvp/backend/Dockerfile @@ -43,6 +43,11 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ libpangoft2-1.0-0 \ fonts-dejavu-core \ curl \ + libgtk-3-0 \ + libasound2 \ + libdbus-glib-1-2 \ + libx11-xcb1 \ + libxtst6 \ && useradd --create-home --uid 1000 app WORKDIR /app @@ -51,6 +56,11 @@ COPY --from=builder --chown=app:app /app/.venv /app/.venv COPY --from=builder --chown=app:app /app/app /app/app COPY --from=builder --chown=app:app /app/scripts /app/scripts +# Загружаем Firefox-сборку для camoufox (#884). +# Слой кешируется: пересобирается только при изменении .venv (обновление camoufox). +# ~960MB. CI/devops-verify required — не запускать локально без намерения скачать. +RUN python -m camoufox fetch + USER app EXPOSE 8000 diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 070a1bbc..1f939efb 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -1,5 +1,7 @@ """Минимальный settings для standalone trade-in MVP.""" +from typing import Literal + from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -203,5 +205,19 @@ class Settings(BaseSettings): # ENV: YANDEX_COOKIES_FILE. yandex_cookies_file: str | None = None + # ── #884: BrowserFetcher (camoufox) — Phase 0 foundation ───────────────── + # scraper_fetch_mode: "curl_cffi" (дефолт, текущее поведение) или "browser" + # (AsyncCamoufox headless Firefox). Переключение в "browser" — Phase 1+. + # Пока дефолт "curl_cffi" — никаких изменений в рантайме. + # ENV: SCRAPER_FETCH_MODE. + scraper_fetch_mode: Literal["curl_cffi", "browser"] = "curl_cffi" + # Сколько страниц обработать в одном browser-процессе перед перезапуском + # (ограничение утечек памяти). ENV: BROWSER_RECYCLE_PAGES. + browser_recycle_pages: int = 15 + # Таймаут навигации (page.goto) в мс. ENV: BROWSER_NAV_TIMEOUT_MS. + browser_nav_timeout_ms: int = 60000 + # Ожидание после DOMContentLoaded для JS-гидрации в мс. ENV: BROWSER_WAIT_MS. + browser_wait_ms: int = 2500 + settings = Settings() diff --git a/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py b/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py new file mode 100644 index 00000000..4c0ece06 --- /dev/null +++ b/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py @@ -0,0 +1,219 @@ +"""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 diff --git a/tradein-mvp/backend/pyproject.toml b/tradein-mvp/backend/pyproject.toml index f540d91e..13d74360 100644 --- a/tradein-mvp/backend/pyproject.toml +++ b/tradein-mvp/backend/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "sentry-sdk>=2.0.0", # мониторинг ошибок → GlitchTip (#396) "redis>=5.0.0", # async hot cache для /api/v1/search (Phase 3.2) "pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py) + "camoufox[geoip]>=0.4.0", # headless Firefox wrapper для browser-mode scraping (#884) ] [dependency-groups] diff --git a/tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py b/tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py new file mode 100644 index 00000000..f0e5b95a --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py @@ -0,0 +1,295 @@ +"""Tests for BrowserFetcher (#884, Phase 0). + +Полностью замоканы: не требуют установленного Firefox или camoufox. +Покрывают: + - _proxy_dict(): парсинг proxy URL с credentials и без + - fetch(): new_page → goto → wait_for_timeout → content → close + - recycle: счётчик страниц → relaunch браузера при достижении порога + - crash-recovery: TargetClosedError → relaunch → retry +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + + +# ── helpers ──────────────────────────────────────────────────────────────────── + + +def _mock_settings( + proxy_url: str | None = None, + recycle_pages: int = 15, + nav_timeout_ms: int = 60000, + wait_ms: int = 2500, +) -> SimpleNamespace: + return SimpleNamespace( + scraper_proxy_url=proxy_url, + browser_recycle_pages=recycle_pages, + browser_nav_timeout_ms=nav_timeout_ms, + browser_wait_ms=wait_ms, + ) + + +def _make_mock_page(html: str = "ok") -> MagicMock: + """Создаёт мок страницы playwright с нужными async-методами.""" + page = MagicMock() + page.goto = AsyncMock() + page.wait_for_timeout = AsyncMock() + page.content = AsyncMock(return_value=html) + page.close = AsyncMock() + return page + + +def _make_mock_browser(page: MagicMock | None = None) -> MagicMock: + """Создаёт мок browser-контекста camoufox.""" + if page is None: + page = _make_mock_page() + browser = MagicMock() + browser.new_page = AsyncMock(return_value=page) + return browser + + +def _make_mock_camoufox_ctx(browser: MagicMock) -> MagicMock: + """Мок AsyncCamoufox(...) context manager → возвращает browser из __aenter__.""" + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=browser) + ctx.__aexit__ = AsyncMock(return_value=None) + return ctx + + +# ── _proxy_dict() ────────────────────────────────────────────────────────────── +# _proxy_dict() импортирует settings через `from app.core.config import settings` +# внутри метода, поэтому патчим app.core.config.settings (источник). + + +def test_proxy_dict_parses_full_url(): + """_proxy_dict() разбирает URL с username/password → playwright dict.""" + from app.services.scrapers.browser_fetcher import BrowserFetcher + + fetcher = BrowserFetcher() + ms = _mock_settings(proxy_url="http://user123:pass456@proxy.example.com:8080") + with patch("app.core.config.settings", ms): + result = fetcher._proxy_dict() + + assert result == { + "server": "http://proxy.example.com:8080", + "username": "user123", + "password": "pass456", + } + + +def test_proxy_dict_no_credentials(): + """_proxy_dict() для URL без credentials возвращает только server.""" + from app.services.scrapers.browser_fetcher import BrowserFetcher + + fetcher = BrowserFetcher() + ms = _mock_settings(proxy_url="http://proxy.example.com:3128") + with patch("app.core.config.settings", ms): + result = fetcher._proxy_dict() + + assert result == {"server": "http://proxy.example.com:3128"} + assert "username" not in result + assert "password" not in result + + +def test_proxy_dict_returns_none_when_no_proxy(): + """_proxy_dict() → None когда scraper_proxy_url не задан.""" + from app.services.scrapers.browser_fetcher import BrowserFetcher + + fetcher = BrowserFetcher() + ms = _mock_settings(proxy_url=None) + with patch("app.core.config.settings", ms): + result = fetcher._proxy_dict() + + assert result is None + + +# ── fetch(): new_page → goto → content → close ──────────────────────────────── + + +@pytest.mark.asyncio +async def test_fetch_calls_page_lifecycle(): + """fetch() вызывает new_page, goto, wait_for_timeout, content, close.""" + from app.services.scrapers.browser_fetcher import BrowserFetcher + + expected_html = "test" + page = _make_mock_page(html=expected_html) + browser = _make_mock_browser(page=page) + ctx = _make_mock_camoufox_ctx(browser) + + ms = _mock_settings(recycle_pages=15, nav_timeout_ms=60000, wait_ms=2500) + + with patch("app.core.config.settings", ms): + with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx): + fetcher = BrowserFetcher() + async with fetcher: + result = await fetcher.fetch("https://example.com") + + assert result == expected_html + browser.new_page.assert_called_once() + page.goto.assert_called_once_with( + "https://example.com", timeout=60000, wait_until="domcontentloaded" + ) + page.wait_for_timeout.assert_called_once_with(2500) + page.content.assert_called_once() + page.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_fetch_returns_html_string(): + """fetch() возвращает строку HTML от page.content().""" + from app.services.scrapers.browser_fetcher import BrowserFetcher + + html = "Avito" + page = _make_mock_page(html=html) + browser = _make_mock_browser(page=page) + ctx = _make_mock_camoufox_ctx(browser) + + ms = _mock_settings() + + with patch("app.core.config.settings", ms): + with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx): + fetcher = BrowserFetcher() + async with fetcher: + result = await fetcher.fetch("https://avito.ru/test") + + assert result == html + assert isinstance(result, str) + + +# ── recycle: relaunch at threshold ──────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_recycle_triggers_relaunch_at_threshold(): + """После recycle_pages страниц браузер перезапускается (relaunch).""" + from app.services.scrapers.browser_fetcher import BrowserFetcher + + recycle_after = 3 + + page = _make_mock_page() + browser = _make_mock_browser(page=page) + ctx = _make_mock_camoufox_ctx(browser) + + ms = _mock_settings(recycle_pages=recycle_after, wait_ms=0) + + launch_count = 0 + original_launch = BrowserFetcher._launch + + async def counting_launch(self: BrowserFetcher) -> None: + nonlocal launch_count + launch_count += 1 + await original_launch(self) + + with patch("app.core.config.settings", ms): + with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx): + with patch.object(BrowserFetcher, "_launch", counting_launch): + fetcher = BrowserFetcher() + async with fetcher: + # Первый запуск через __aenter__ + assert launch_count == 1 + # Отправляем recycle_after страниц — на последней сработает relaunch + for _ in range(recycle_after): + await fetcher.fetch("https://example.com") + # После recycle браузер должен был перезапуститься ещё раз + assert launch_count == 2 + + +@pytest.mark.asyncio +async def test_page_counter_resets_after_relaunch(): + """После recycle page_count сбрасывается в 0.""" + from app.services.scrapers.browser_fetcher import BrowserFetcher + + recycle_after = 2 + + page = _make_mock_page() + browser = _make_mock_browser(page=page) + ctx = _make_mock_camoufox_ctx(browser) + + ms = _mock_settings(recycle_pages=recycle_after, wait_ms=0) + + with patch("app.core.config.settings", ms): + with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx): + fetcher = BrowserFetcher() + async with fetcher: + for _ in range(recycle_after): + await fetcher.fetch("https://example.com") + # После recycle счётчик сброшен + assert fetcher._page_count == 0 + + +# ── crash-recovery ───────────────────────────────────────────────────────────── + + +class _FakeTargetClosedError(Exception): + """Имитирует playwright TargetClosedError (проверка по имени класса).""" + + +_FakeTargetClosedError.__name__ = "TargetClosedError" +_FakeTargetClosedError.__qualname__ = "TargetClosedError" + + +@pytest.mark.asyncio +async def test_fetch_retries_once_on_target_closed_error(): + """fetch() при TargetClosedError перезапускает браузер и повторяет запрос.""" + from app.services.scrapers.browser_fetcher import BrowserFetcher + + html = "retry-ok" + + # Первая страница бросает краш, вторая отвечает нормально + crash_page = MagicMock() + crash_page.goto = AsyncMock(side_effect=_FakeTargetClosedError("target closed")) + crash_page.close = AsyncMock() + + ok_page = _make_mock_page(html=html) + browser = _make_mock_browser() + + call_num = 0 + + async def new_page_side_effect() -> MagicMock: + nonlocal call_num + call_num += 1 + return crash_page if call_num == 1 else ok_page + + browser.new_page = AsyncMock(side_effect=new_page_side_effect) + ctx = _make_mock_camoufox_ctx(browser) + + ms = _mock_settings(wait_ms=0) + + with patch("app.core.config.settings", ms): + with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx): + fetcher = BrowserFetcher() + async with fetcher: + result = await fetcher.fetch("https://avito.ru/page") + + assert result == html + # 2 обращения к new_page: первое (crash) + второе (retry после relaunch) + assert call_num == 2 + + +@pytest.mark.asyncio +async def test_fetch_propagates_non_crash_exception(): + """fetch() не подавляет обычные (не-crash) исключения.""" + from app.services.scrapers.browser_fetcher import BrowserFetcher + + page = MagicMock() + page.goto = AsyncMock(side_effect=ValueError("unexpected parse error")) + page.close = AsyncMock() + + browser = _make_mock_browser(page=page) + ctx = _make_mock_camoufox_ctx(browser) + + ms = _mock_settings(wait_ms=0) + + with patch("app.core.config.settings", ms): + with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx): + fetcher = BrowserFetcher() + async with fetcher: + with pytest.raises(ValueError, match="unexpected parse error"): + await fetcher.fetch("https://avito.ru/bad") diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index 17f3c23a..5876b0e9 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -36,6 +36,7 @@ services: backend: image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest} container_name: tradein-backend + mem_limit: 2.5g # headless Firefox (camoufox) потребляет до ~1.5GB (#884) # Prod: uvicorn БЕЗ --reload (Dockerfile CMD несёт --reload только для dev hot-reload, # где app/ bind-mount'ится). В prod --reload = лишний WatchFiles-наблюдатель + риск # reload в середине запроса. Single worker ОБЯЗАТЕЛЕН: in-app планировщик