diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index ab9e9f89..73a32c4a 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -18,6 +18,7 @@ concurrency: env: IMAGE_BACKEND: ghcr.io/lekss361/gendesign-tradein-backend IMAGE_FRONTEND: ghcr.io/lekss361/gendesign-tradein-frontend + IMAGE_BROWSER: ghcr.io/lekss361/gendesign-tradein-browser jobs: changes: @@ -25,6 +26,7 @@ jobs: outputs: backend: ${{ steps.filter.outputs.backend }} frontend: ${{ steps.filter.outputs.frontend }} + browser: ${{ steps.filter.outputs.browser }} infra: ${{ steps.filter.outputs.infra }} steps: - uses: actions/checkout@v4 @@ -36,6 +38,8 @@ jobs: - 'tradein-mvp/backend/**' frontend: - 'tradein-mvp/frontend/**' + browser: + - 'tradein-mvp/browser/**' infra: - 'tradein-mvp/docker-compose.prod.yml' - 'tradein-mvp/deploy/**' @@ -149,9 +153,41 @@ jobs: ${{ env.IMAGE_FRONTEND }}:latest ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} + build-browser: + runs-on: ubuntu-latest + needs: changes + # tradein-browser несёт camoufox + Firefox-build (#905). Триггерится на + # изменения browser/ или infra (compose ссылается на образ) или вручную. + if: | + needs.changes.outputs.browser == 'true' || + needs.changes.outputs.infra == 'true' || + github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + + - name: Login to GHCR (shell-based — docker/login-action@v3 unreliable под Forgejo Actions) + env: + GHCR_PAT: ${{ secrets.GHCR_PAT }} + run: | + echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build & push tradein-browser + uses: docker/build-push-action@v6 + with: + context: ./tradein-mvp/browser + push: true + cache-from: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache + cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max + tags: | + ${{ env.IMAGE_BROWSER }}:latest + ${{ env.IMAGE_BROWSER }}:${{ github.sha }} + deploy: runs-on: ubuntu-latest - needs: [changes, test, build-backend, build-frontend] + needs: [changes, test, build-backend, build-frontend, build-browser] # NB: a failed `test` skips build-backend (result='skipped', not 'failure'), # so we must block deploy on test failure explicitly (#666 quality gate). if: | @@ -159,7 +195,8 @@ jobs: !cancelled() && needs.test.result != 'failure' && needs.build-backend.result != 'failure' && - needs.build-frontend.result != 'failure' + needs.build-frontend.result != 'failure' && + needs.build-browser.result != 'failure' steps: - name: Deploy via SSH uses: appleboy/ssh-action@v1.0.3 diff --git a/tradein-mvp/backend/Dockerfile b/tradein-mvp/backend/Dockerfile index aa243114..032fa193 100644 --- a/tradein-mvp/backend/Dockerfile +++ b/tradein-mvp/backend/Dockerfile @@ -43,11 +43,6 @@ 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 @@ -58,16 +53,10 @@ COPY --from=builder --chown=app:app /app/scripts /app/scripts USER app -# HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а camoufox/platformdirs -# резолвит кэш Firefox как $HOME/.cache/camoufox. Без этого fetch (под root) клал браузер -# в /root/.cache, а рантайм-app искал в /home/app/.cache → "browser not found" в browser-mode. -# (подтверждено сборкой+прогоном образа на деплой-хосте 2026-05-31, баг из #884.) +# HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а некоторые +# платформенные библиотеки резолвят $HOME/.cache. Безвредно оставить. ENV HOME=/home/app -# Загружаем Firefox-сборку для camoufox (#884) — ПОД app-юзером, чтобы кэш совпал с рантаймом. -# Слой кешируется: пересобирается только при изменении .venv (обновление camoufox). ~960MB. -RUN python -m camoufox fetch - EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 7a398312..97f691c4 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -207,14 +207,17 @@ class Settings(BaseSettings): # ENV: YANDEX_COOKIES_FILE. yandex_cookies_file: str | None = None - # ── #884: BrowserFetcher (camoufox) — Phase 0 foundation ───────────────── + # ── #884/#905: BrowserFetcher — connect-mode к tradein-browser контейнеру ── # scraper_fetch_mode: "curl_cffi" (дефолт, текущее поведение) или "browser" - # (AsyncCamoufox headless Firefox). Переключение в "browser" — Phase 1+. + # (playwright.firefox.connect к tradein-browser WS-серверу). Phase 1+. # Пока дефолт "curl_cffi" — никаких изменений в рантайме. # ENV: SCRAPER_FETCH_MODE. scraper_fetch_mode: Literal["curl_cffi", "browser"] = "curl_cffi" - # Сколько страниц обработать в одном browser-процессе перед перезапуском - # (ограничение утечек памяти). ENV: BROWSER_RECYCLE_PAGES. + # WS-эндпоинт tradein-browser сервиса. В Docker-сети — имя сервиса из compose. + # ENV: BROWSER_WS_ENDPOINT. + browser_ws_endpoint: str = "ws://tradein-browser:3000/playwright" + # Сколько страниц обработать в одном browser-сеансе перед переподключением + # (ограничение утечек памяти на стороне браузер-контейнера). ENV: BROWSER_RECYCLE_PAGES. browser_recycle_pages: int = 15 # Таймаут навигации (page.goto) в мс. ENV: BROWSER_NAV_TIMEOUT_MS. browser_nav_timeout_ms: int = 60000 diff --git a/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py b/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py index 4c0ece06..f25eef05 100644 --- a/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py +++ b/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py @@ -1,29 +1,33 @@ -"""browser_fetcher.py — camoufox wrapper для browser-mode scraping (Phase 0, #884). +"""browser_fetcher.py — Playwright connect-mode клиент для browser-mode scraping (#884/#905). Провайдер-агностичная основа: не затрагивает существующую avito/cian-логику. -Активируется только когда settings.scraper_fetch_mode == "browser" (пока -дефолт "curl_cffi" — поведение идентично старому до явного переключения). +Активируется только когда 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-сервера; + этот модуль управляет только жизненным циклом соединения и страниц. Особенности: -- Ленивые импорты 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": ...}. +- Ленивые импорты playwright: не ломают app-импорт если не установлен. +- Page recycle: каждые settings.browser_recycle_pages страниц закрываем и + переподключаемся (ограничение утечек памяти на стороне браузер-контейнера). +- Crash-recovery: TargetClosedError / connection-closed → переподключение → один retry. """ from __future__ import annotations import logging -from urllib.parse import urlparse logger = logging.getLogger(__name__) class BrowserFetcher: - """Async context manager: camoufox headless Firefox с рециклингом страниц. + """Async context manager: Playwright-клиент подключения к tradein-browser WS-серверу. Использование:: @@ -33,11 +37,13 @@ class BrowserFetcher: 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 ────────────────────────────────────────────────────────────── @@ -47,19 +53,21 @@ class BrowserFetcher: 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() + self._ws_endpoint = settings.browser_ws_endpoint + await self._connect() return self async def __aexit__(self, *_: object) -> None: - await self._close_browser() + await self._close() # ── public API ───────────────────────────────────────────────────────────── async def fetch(self, url: str) -> str: """Открывает новую страницу, переходит по URL, ждёт JS, возвращает HTML. - При TargetClosedError (crash) перезапускает браузер и делает одну - повторную попытку. Остальные исключения всплывают к вызывающему коду. + При TargetClosedError или connection-closed (crash/перезапуск браузер-контейнера) + переподключается и делает одну повторную попытку. Остальные исключения + всплывают к вызывающему коду. Returns: Полный HTML-контент страницы (innerHTML documentElement). @@ -67,14 +75,14 @@ class BrowserFetcher: 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", + "BrowserFetcher: соединение с браузером потеряно (%s)," + " переподключение + retry: %s", type(exc).__name__, url, ) - await self._relaunch() + await self._reconnect() return await self._fetch_once(url) raise @@ -82,22 +90,13 @@ class BrowserFetcher: 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: нет активного соединения" - 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() @@ -105,94 +104,71 @@ class BrowserFetcher: await page.close() self._page_count += 1 - logger.debug("BrowserFetcher: fetch OK url=%r pages_since_launch=%d", url, self._page_count) + logger.debug( + "BrowserFetcher: fetch OK url=%r pages_since_connect=%d", url, self._page_count + ) - # Recycle: перезапускаем браузер каждые N страниц + # Recycle: переподключаемся каждые N страниц assert self._recycle_after is not None if self._page_count >= self._recycle_after: logger.info( - "BrowserFetcher: recycle threshold (%d) достигнут, перезапуск браузера", + "BrowserFetcher: recycle threshold (%d) достигнут, переподключение", self._recycle_after, ) - await self._relaunch() + await self._reconnect() return html - async def _launch(self) -> None: - """Запускает AsyncCamoufox браузер.""" + async def _connect(self) -> None: + """Запускает Playwright и подключается к WS-серверу tradein-browser.""" try: - from camoufox.async_api import AsyncCamoufox + from playwright.async_api import async_playwright except ImportError as exc: raise RuntimeError( - "camoufox не установлен. Добавь 'camoufox[geoip]>=0.4.0' в pyproject.toml " - "и выполни 'python -m camoufox fetch' для загрузки Firefox-сборки." + "playwright не установлен. Добавь 'playwright>=1.45' в pyproject.toml." ) 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 + assert self._ws_endpoint, "BrowserFetcher: browser_ws_endpoint не задан" - # AsyncCamoufox — async context manager; входим в него чтобы получить browser - ctx = AsyncCamoufox(**kwargs) # type: ignore[call-arg] - self._browser = await ctx.__aenter__() - self._browser_ctx = ctx # сохраняем для корректного __aexit__ + 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: браузер запущен (proxy=%s)", proxy is not None) + logger.info( + "BrowserFetcher: подключился к %s", + self._ws_endpoint, + ) - async def _close_browser(self) -> None: - """Закрывает браузер если запущен.""" - ctx = getattr(self, "_browser_ctx", None) - if ctx is not None: + async def _close(self) -> None: + """Закрывает соединение с браузером и останавливает Playwright.""" + browser = self._browser + if browser is not None: try: - await ctx.__aexit__(None, None, None) - logger.info("BrowserFetcher: браузер закрыт") + await browser.close() # type: ignore[attr-defined] + logger.info("BrowserFetcher: соединение закрыто") except Exception as exc: - logger.warning("BrowserFetcher: ошибка при закрытии браузера: %s", 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() + 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 - def _proxy_dict(self) -> dict[str, str] | None: - """Парсит settings.scraper_proxy_url → playwright proxy dict. + self._page_count = 0 - Формат входного 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 + async def _reconnect(self) -> None: + """Закрывает текущее соединение и устанавливает новое.""" + await self._close() + await self._connect() # ── helpers ──────────────────────────────────────────────────────────────────── @@ -201,19 +177,23 @@ class BrowserFetcher: def _is_target_closed(exc: BaseException) -> bool: """Проверяет является ли исключение playwright TargetClosedError. - Проверка по имени класса (не import) — playwright не установлен обязательно. + Проверка по имени класса (не import) — playwright может не быть в dev-окружении. """ - cls_name = type(exc).__name__ - return cls_name == "TargetClosedError" + return type(exc).__name__ == "TargetClosedError" def _is_crash_exception(exc: BaseException) -> bool: - """Проверяет является ли исключение признаком краша браузера. + """Проверяет является ли исключение признаком краша / разрыва соединения. - Ловим широкий набор playwright crash-сигналов по имени/сообщению. + Ловим широкий набор 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 + 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) + ) diff --git a/tradein-mvp/backend/pyproject.toml b/tradein-mvp/backend/pyproject.toml index 13d74360..a4ca8dbe 100644 --- a/tradein-mvp/backend/pyproject.toml +++ b/tradein-mvp/backend/pyproject.toml @@ -23,7 +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) + "playwright>=1.45", # Playwright client для connect к tradein-browser (#905) ] [dependency-groups] @@ -31,7 +31,6 @@ dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.24.0", "ruff>=0.5.0", - "playwright>=1.45", # address-mismatch audit fallback (issue #582 Phase 1) ] [tool.pytest.ini_options] diff --git a/tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py b/tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py index f0e5b95a..0036a3ca 100644 --- a/tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py +++ b/tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py @@ -1,11 +1,12 @@ -"""Tests for BrowserFetcher (#884, Phase 0). +"""Tests for BrowserFetcher (#884/#905, connect-mode). -Полностью замоканы: не требуют установленного Firefox или camoufox. -Покрывают: - - _proxy_dict(): парсинг proxy URL с credentials и без +Полностью замоканы: не требуют установленного Firefox, camoufox или реального +WS-сервера. Покрывают: - fetch(): new_page → goto → wait_for_timeout → content → close - - recycle: счётчик страниц → relaunch браузера при достижении порога - - crash-recovery: TargetClosedError → relaunch → retry + - recycle: счётчик страниц → reconnect при достижении порога + - crash-recovery: TargetClosedError → reconnect → retry + - page counter сбрасывается после reconnect + - non-crash exception пробрасывается без перехвата """ from __future__ import annotations @@ -27,12 +28,14 @@ def _mock_settings( recycle_pages: int = 15, nav_timeout_ms: int = 60000, wait_ms: int = 2500, + ws_endpoint: str = "ws://fake-browser:3000/playwright", ) -> 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, + browser_ws_endpoint=ws_endpoint, ) @@ -46,68 +49,36 @@ def _make_mock_page(html: str = "ok") -> MagicMock: return page -def _make_mock_browser(page: MagicMock | None = None) -> MagicMock: - """Создаёт мок browser-контекста camoufox.""" +def _make_playwright_stack( + page: MagicMock | None = None, + html: str = "ok", +) -> tuple[MagicMock, MagicMock, MagicMock]: + """Создаёт полную mock-цепочку playwright: pw_cm → pw → browser → page. + + Returns: + (pw_cm, pw_mock, browser_mock) + """ if page is None: - page = _make_mock_page() - browser = MagicMock() - browser.new_page = AsyncMock(return_value=page) - return browser + page = _make_mock_page(html=html) + browser_mock = MagicMock() + browser_mock.new_page = AsyncMock(return_value=page) + browser_mock.close = AsyncMock() -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 + firefox_mock = MagicMock() + firefox_mock.connect = AsyncMock(return_value=browser_mock) + pw_mock = MagicMock() + pw_mock.firefox = firefox_mock + pw_mock.stop = AsyncMock() -# ── _proxy_dict() ────────────────────────────────────────────────────────────── -# _proxy_dict() импортирует settings через `from app.core.config import settings` -# внутри метода, поэтому патчим app.core.config.settings (источник). + async def _start() -> MagicMock: + return pw_mock + pw_cm = MagicMock() + pw_cm.start = _start -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 + return pw_cm, pw_mock, browser_mock # ── fetch(): new_page → goto → content → close ──────────────────────────────── @@ -120,13 +91,11 @@ async def test_fetch_calls_page_lifecycle(): expected_html = "test" page = _make_mock_page(html=expected_html) - browser = _make_mock_browser(page=page) - ctx = _make_mock_camoufox_ctx(browser) - + pw_cm, _pw, browser = _make_playwright_stack(page=page) 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): + with patch("playwright.async_api.async_playwright", return_value=pw_cm): fetcher = BrowserFetcher() async with fetcher: result = await fetcher.fetch("https://example.com") @@ -147,14 +116,11 @@ async def test_fetch_returns_html_string(): 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) - + pw_cm, _pw, _browser = _make_playwright_stack(html=html) ms = _mock_settings() with patch("app.core.config.settings", ms): - with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx): + with patch("playwright.async_api.async_playwright", return_value=pw_cm): fetcher = BrowserFetcher() async with fetcher: result = await fetcher.fetch("https://avito.ru/test") @@ -163,59 +129,49 @@ async def test_fetch_returns_html_string(): assert isinstance(result, str) -# ── recycle: relaunch at threshold ──────────────────────────────────────────── +# ── recycle: reconnect at threshold ─────────────────────────────────────────── @pytest.mark.asyncio -async def test_recycle_triggers_relaunch_at_threshold(): - """После recycle_pages страниц браузер перезапускается (relaunch).""" +async def test_recycle_triggers_reconnect_at_threshold(): + """После recycle_pages страниц выполняется переподключение (_reconnect).""" 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) - + pw_cm, _pw, _browser = _make_playwright_stack() ms = _mock_settings(recycle_pages=recycle_after, wait_ms=0) - launch_count = 0 - original_launch = BrowserFetcher._launch + reconnect_count = 0 + original_reconnect = BrowserFetcher._reconnect - async def counting_launch(self: BrowserFetcher) -> None: - nonlocal launch_count - launch_count += 1 - await original_launch(self) + async def counting_reconnect(self: BrowserFetcher) -> None: + nonlocal reconnect_count + reconnect_count += 1 + await original_reconnect(self) with patch("app.core.config.settings", ms): - with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx): - with patch.object(BrowserFetcher, "_launch", counting_launch): + with patch("playwright.async_api.async_playwright", return_value=pw_cm): + with patch.object(BrowserFetcher, "_reconnect", counting_reconnect): fetcher = BrowserFetcher() async with fetcher: - # Первый запуск через __aenter__ - assert launch_count == 1 - # Отправляем recycle_after страниц — на последней сработает relaunch + assert reconnect_count == 0 for _ in range(recycle_after): await fetcher.fetch("https://example.com") - # После recycle браузер должен был перезапуститься ещё раз - assert launch_count == 2 + # Третья страница триггерит recycle + assert reconnect_count == 1 @pytest.mark.asyncio -async def test_page_counter_resets_after_relaunch(): - """После recycle page_count сбрасывается в 0.""" +async def test_page_counter_resets_after_reconnect(): + """После 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) - + pw_cm, _pw, _browser = _make_playwright_stack() 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): + with patch("playwright.async_api.async_playwright", return_value=pw_cm): fetcher = BrowserFetcher() async with fetcher: for _ in range(recycle_after): @@ -237,19 +193,20 @@ _FakeTargetClosedError.__qualname__ = "TargetClosedError" @pytest.mark.asyncio async def test_fetch_retries_once_on_target_closed_error(): - """fetch() при TargetClosedError перезапускает браузер и повторяет запрос.""" + """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() + browser_mock = MagicMock() + browser_mock.close = AsyncMock() call_num = 0 async def new_page_side_effect() -> MagicMock: @@ -257,20 +214,33 @@ async def test_fetch_retries_once_on_target_closed_error(): 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) + browser_mock.new_page = AsyncMock(side_effect=new_page_side_effect) + + firefox_mock = MagicMock() + firefox_mock.connect = AsyncMock(return_value=browser_mock) + + pw_mock = MagicMock() + pw_mock.firefox = firefox_mock + pw_mock.stop = AsyncMock() + + async def _start() -> MagicMock: + return pw_mock + + pw_cm = MagicMock() + pw_cm.start = _start ms = _mock_settings(wait_ms=0) with patch("app.core.config.settings", ms): - with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx): + with patch("playwright.async_api.async_playwright", return_value=pw_cm): 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 + # connect вызывался дважды: при __aenter__ + при reconnect + assert firefox_mock.connect.await_count == 2 @pytest.mark.asyncio @@ -282,13 +252,11 @@ async def test_fetch_propagates_non_crash_exception(): page.goto = AsyncMock(side_effect=ValueError("unexpected parse error")) page.close = AsyncMock() - browser = _make_mock_browser(page=page) - ctx = _make_mock_camoufox_ctx(browser) - + pw_cm, _pw, _browser = _make_playwright_stack(page=page) ms = _mock_settings(wait_ms=0) with patch("app.core.config.settings", ms): - with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx): + with patch("playwright.async_api.async_playwright", return_value=pw_cm): fetcher = BrowserFetcher() async with fetcher: with pytest.raises(ValueError, match="unexpected parse error"): diff --git a/tradein-mvp/browser/Dockerfile b/tradein-mvp/browser/Dockerfile new file mode 100644 index 00000000..22726c2b --- /dev/null +++ b/tradein-mvp/browser/Dockerfile @@ -0,0 +1,39 @@ +# tradein-browser — camoufox Playwright WS server (изолированный контейнер Firefox). +# Backend подключается к этому контейнеру через playwright.firefox.connect. + +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +# Firefox runtime libs + ca-certificates для TLS +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + libgtk-3-0 \ + libasound2 \ + libdbus-glib-1-2 \ + libx11-xcb1 \ + libxtst6 \ + ca-certificates \ + && useradd --create-home --uid 1000 app + +WORKDIR /app + +# camoufox[geoip] тянет playwright как зависимость +RUN pip install --no-cache-dir "camoufox[geoip]" + +COPY server.py ./server.py + +# CRITICAL (#899): USER + ENV HOME должны быть установлены ДО fetch, +# чтобы Firefox-сборка легла в /home/app/.cache — туда же, куда рантайм её ищет. +# Без этого fetch под root кладёт браузер в /root/.cache и app при старте +# не находит Firefox ("browser not found"). +USER app +ENV HOME=/home/app + +RUN python -m camoufox fetch + +EXPOSE 3000 + +CMD ["python", "server.py"] diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py new file mode 100644 index 00000000..40f42112 --- /dev/null +++ b/tradein-mvp/browser/server.py @@ -0,0 +1,82 @@ +"""server.py — tradein-browser service main process. + +Этот модуль запускается как точка входа контейнера ``tradein-browser``. +Он читает конфигурацию из переменных окружения и вызывает +``camoufox.server.launch_server`` для экспозиции Playwright WS-эндпоинта, +к которому подключается ``tradein-backend`` через ``playwright.firefox.connect``. + +Переменные окружения: + BROWSER_PORT — TCP-порт WS-сервера (default: 3000) + SCRAPER_PROXY_URL — прокси URL формата ``http://user:pass@host:port`` (опционально) + +WS-эндпоинт доступен внутри Docker-сети как +``ws://tradein-browser:/playwright``. +""" + +import logging +import os +from urllib.parse import urlparse + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +def _parse_proxy(proxy_url: str | None) -> dict[str, str] | None: + """Парсит proxy URL → playwright proxy dict. + + Формат входного URL: ``http://user:pass@host:port`` или ``http://host:port``. + Возвращает None если proxy_url пуст. + + Returns: + ``{"server": "http://host:port", "username": "...", "password": "..."}`` + или None. + """ + if not proxy_url: + return None + + parsed = urlparse(proxy_url) + 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 + + +def main() -> None: + port = int(os.environ.get("BROWSER_PORT", "3000")) + proxy_url = os.environ.get("SCRAPER_PROXY_URL") + proxy = _parse_proxy(proxy_url) + + logger.info( + "tradein-browser: запуск camoufox WS-сервера на порту %d (proxy=%s)", + port, + proxy is not None, + ) + + # Импортируем camoufox.server здесь — в контейнере Firefox уже установлен. + from camoufox.server import launch_server # noqa: PLC0415 + + kwargs: dict[str, object] = { + "headless": True, + "os": "windows", + "locale": "ru-RU", + "geoip": True, + "humanize": True, + "port": port, + # port передаётся через **launch_options в launch_options() → прокидывается + # в Playwright launchServer({ port: N }) — сервер слушает на заданном порту. + } + if proxy is not None: + kwargs["proxy"] = proxy + + # launch_server блокируется — это основной процесс контейнера. + launch_server(**kwargs) # type: ignore[arg-type] + + +if __name__ == "__main__": + main() diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index 5876b0e9..26a2ef8d 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -13,6 +13,22 @@ # - Frontend строится с basePath=/trade-in (см. next.config.ts) services: + browser: + image: ghcr.io/lekss361/gendesign-tradein-browser:${IMAGE_TAG:-latest} + container_name: tradein-browser + mem_limit: 2.5g # headless Firefox (camoufox) потребляет до ~1.5GB (#884/#905) + env_file: + - path: ./backend/.env.runtime + required: false + environment: + BROWSER_PORT: "3000" + # SCRAPER_PROXY_URL читается из .env.runtime (см. env_file выше) + expose: + - "3000" + restart: unless-stopped + networks: + - tradein-net + postgres: image: postgis/postgis:16-3.4 container_name: tradein-postgres @@ -36,7 +52,6 @@ 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 планировщик @@ -67,6 +82,8 @@ services: # cookies upload endpoint returns 503. Generate with `openssl rand -hex 32`. COOKIE_ENCRYPTION_KEY: "${COOKIE_ENCRYPTION_KEY:-}" depends_on: + browser: + condition: service_started postgres: condition: service_healthy volumes: