feat(browser): split camoufox into a separate tradein-browser container (Phase 0.5, #905) #907

Merged
lekss361 merged 2 commits from feat/884-browser-separate-container into main 2026-05-31 14:44:00 +00:00
9 changed files with 345 additions and 231 deletions

View file

@ -18,6 +18,7 @@ concurrency:
env: env:
IMAGE_BACKEND: ghcr.io/lekss361/gendesign-tradein-backend IMAGE_BACKEND: ghcr.io/lekss361/gendesign-tradein-backend
IMAGE_FRONTEND: ghcr.io/lekss361/gendesign-tradein-frontend IMAGE_FRONTEND: ghcr.io/lekss361/gendesign-tradein-frontend
IMAGE_BROWSER: ghcr.io/lekss361/gendesign-tradein-browser
jobs: jobs:
changes: changes:
@ -25,6 +26,7 @@ jobs:
outputs: outputs:
backend: ${{ steps.filter.outputs.backend }} backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }} frontend: ${{ steps.filter.outputs.frontend }}
browser: ${{ steps.filter.outputs.browser }}
infra: ${{ steps.filter.outputs.infra }} infra: ${{ steps.filter.outputs.infra }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -36,6 +38,8 @@ jobs:
- 'tradein-mvp/backend/**' - 'tradein-mvp/backend/**'
frontend: frontend:
- 'tradein-mvp/frontend/**' - 'tradein-mvp/frontend/**'
browser:
- 'tradein-mvp/browser/**'
infra: infra:
- 'tradein-mvp/docker-compose.prod.yml' - 'tradein-mvp/docker-compose.prod.yml'
- 'tradein-mvp/deploy/**' - 'tradein-mvp/deploy/**'
@ -149,9 +153,41 @@ jobs:
${{ env.IMAGE_FRONTEND }}:latest ${{ env.IMAGE_FRONTEND }}:latest
${{ env.IMAGE_FRONTEND }}:${{ github.sha }} ${{ 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: deploy:
runs-on: ubuntu-latest 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'), # NB: a failed `test` skips build-backend (result='skipped', not 'failure'),
# so we must block deploy on test failure explicitly (#666 quality gate). # so we must block deploy on test failure explicitly (#666 quality gate).
if: | if: |
@ -159,7 +195,8 @@ jobs:
!cancelled() && !cancelled() &&
needs.test.result != 'failure' && needs.test.result != 'failure' &&
needs.build-backend.result != 'failure' && needs.build-backend.result != 'failure' &&
needs.build-frontend.result != 'failure' needs.build-frontend.result != 'failure' &&
needs.build-browser.result != 'failure'
steps: steps:
- name: Deploy via SSH - name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3 uses: appleboy/ssh-action@v1.0.3

View file

@ -43,11 +43,6 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
libpangoft2-1.0-0 \ libpangoft2-1.0-0 \
fonts-dejavu-core \ fonts-dejavu-core \
curl \ curl \
libgtk-3-0 \
libasound2 \
libdbus-glib-1-2 \
libx11-xcb1 \
libxtst6 \
&& useradd --create-home --uid 1000 app && useradd --create-home --uid 1000 app
WORKDIR /app WORKDIR /app
@ -58,16 +53,10 @@ COPY --from=builder --chown=app:app /app/scripts /app/scripts
USER app USER app
# HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а camoufox/platformdirs # HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а некоторые
# резолвит кэш Firefox как $HOME/.cache/camoufox. Без этого fetch (под root) клал браузер # платформенные библиотеки резолвят $HOME/.cache. Безвредно оставить.
# в /root/.cache, а рантайм-app искал в /home/app/.cache → "browser not found" в browser-mode.
# (подтверждено сборкой+прогоном образа на деплой-хосте 2026-05-31, баг из #884.)
ENV HOME=/home/app ENV HOME=/home/app
# Загружаем Firefox-сборку для camoufox (#884) — ПОД app-юзером, чтобы кэш совпал с рантаймом.
# Слой кешируется: пересобирается только при изменении .venv (обновление camoufox). ~960MB.
RUN python -m camoufox fetch
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View file

@ -205,14 +205,17 @@ class Settings(BaseSettings):
# ENV: YANDEX_COOKIES_FILE. # ENV: YANDEX_COOKIES_FILE.
yandex_cookies_file: str | None = None 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" # scraper_fetch_mode: "curl_cffi" (дефолт, текущее поведение) или "browser"
# (AsyncCamoufox headless Firefox). Переключение в "browser" — Phase 1+. # (playwright.firefox.connect к tradein-browser WS-серверу). Phase 1+.
# Пока дефолт "curl_cffi" — никаких изменений в рантайме. # Пока дефолт "curl_cffi" — никаких изменений в рантайме.
# ENV: SCRAPER_FETCH_MODE. # ENV: SCRAPER_FETCH_MODE.
scraper_fetch_mode: Literal["curl_cffi", "browser"] = "curl_cffi" scraper_fetch_mode: Literal["curl_cffi", "browser"] = "curl_cffi"
# Сколько страниц обработать в одном browser-процессе перед перезапуском # WS-эндпоинт tradein-browser сервиса. В Docker-сети — имя сервиса из compose.
# (ограничение утечек памяти). ENV: BROWSER_RECYCLE_PAGES. # ENV: BROWSER_WS_ENDPOINT.
browser_ws_endpoint: str = "ws://tradein-browser:3000/playwright"
# Сколько страниц обработать в одном browser-сеансе перед переподключением
# (ограничение утечек памяти на стороне браузер-контейнера). ENV: BROWSER_RECYCLE_PAGES.
browser_recycle_pages: int = 15 browser_recycle_pages: int = 15
# Таймаут навигации (page.goto) в мс. ENV: BROWSER_NAV_TIMEOUT_MS. # Таймаут навигации (page.goto) в мс. ENV: BROWSER_NAV_TIMEOUT_MS.
browser_nav_timeout_ms: int = 60000 browser_nav_timeout_ms: int = 60000

View file

@ -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-логику. Провайдер-агностичная основа: не затрагивает существующую avito/cian-логику.
Активируется только когда settings.scraper_fetch_mode == "browser" (пока Активируется только когда settings.scraper_fetch_mode == "browser" (дефолт
дефолт "curl_cffi" поведение идентично старому до явного переключения). "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 не - Ленивые импорты playwright: не ломают app-импорт если не установлен.
установлен (аналогично curl_cffi в avito_imv.py). - Page recycle: каждые settings.browser_recycle_pages страниц закрываем и
- Page recycle: каждые settings.browser_recycle_pages страниц браузер переподключаемся (ограничение утечек памяти на стороне браузер-контейнера).
перезапускается (ограничение утечек памяти). - Crash-recovery: TargetClosedError / connection-closed переподключение один retry.
- Crash-recovery: TargetClosedError relaunch one retry.
- Proxy: парсит settings.scraper_proxy_url playwright dict формат
{"server": "http://host:port", "username": ..., "password": ...}.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from urllib.parse import urlparse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class BrowserFetcher: 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: def __init__(self) -> None:
self._browser: object | None = None self._browser: object | None = None
self._pw: object | None = None
self._page_count: int = 0 self._page_count: int = 0
# Lazy-loaded настройки чтобы не импортировать settings на верхнем уровне # Lazy-loaded настройки чтобы не импортировать settings на верхнем уровне
self._recycle_after: int | None = None self._recycle_after: int | None = None
self._nav_timeout_ms: int | None = None self._nav_timeout_ms: int | None = None
self._wait_ms: int | None = None self._wait_ms: int | None = None
self._ws_endpoint: str | None = None
# ── lifecycle ────────────────────────────────────────────────────────────── # ── lifecycle ──────────────────────────────────────────────────────────────
@ -47,19 +53,21 @@ class BrowserFetcher:
self._recycle_after = settings.browser_recycle_pages self._recycle_after = settings.browser_recycle_pages
self._nav_timeout_ms = settings.browser_nav_timeout_ms self._nav_timeout_ms = settings.browser_nav_timeout_ms
self._wait_ms = settings.browser_wait_ms self._wait_ms = settings.browser_wait_ms
await self._launch() self._ws_endpoint = settings.browser_ws_endpoint
await self._connect()
return self return self
async def __aexit__(self, *_: object) -> None: async def __aexit__(self, *_: object) -> None:
await self._close_browser() await self._close()
# ── public API ───────────────────────────────────────────────────────────── # ── public API ─────────────────────────────────────────────────────────────
async def fetch(self, url: str) -> str: async def fetch(self, url: str) -> str:
"""Открывает новую страницу, переходит по URL, ждёт JS, возвращает HTML. """Открывает новую страницу, переходит по URL, ждёт JS, возвращает HTML.
При TargetClosedError (crash) перезапускает браузер и делает одну При TargetClosedError или connection-closed (crash/перезапуск браузер-контейнера)
повторную попытку. Остальные исключения всплывают к вызывающему коду. переподключается и делает одну повторную попытку. Остальные исключения
всплывают к вызывающему коду.
Returns: Returns:
Полный HTML-контент страницы (innerHTML documentElement). Полный HTML-контент страницы (innerHTML documentElement).
@ -67,14 +75,14 @@ class BrowserFetcher:
try: try:
return await self._fetch_once(url) return await self._fetch_once(url)
except Exception as exc: except Exception as exc:
# Определяем crash vs обычная ошибка через имя класса (lazy import)
if _is_target_closed(exc) or _is_crash_exception(exc): if _is_target_closed(exc) or _is_crash_exception(exc):
logger.warning( logger.warning(
"BrowserFetcher: браузер упал (%s), перезапуск + retry: %s", "BrowserFetcher: соединение с браузером потеряно (%s),"
" переподключение + retry: %s",
type(exc).__name__, type(exc).__name__,
url, url,
) )
await self._relaunch() await self._reconnect()
return await self._fetch_once(url) return await self._fetch_once(url)
raise raise
@ -82,22 +90,13 @@ class BrowserFetcher:
async def _fetch_once(self, url: str) -> str: async def _fetch_once(self, url: str) -> str:
"""Одна попытка: new_page → goto → wait → content → close.""" """Одна попытка: new_page → goto → wait → content → close."""
# Ленивый импорт: не нужен при curl_cffi mode (не ломает app при assert self._browser is not None, "BrowserFetcher: нет активного соединения"
# отсутствии установленного 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] browser = self._browser # type: ignore[assignment]
page = await browser.new_page() # type: ignore[attr-defined] page = await browser.new_page() # type: ignore[attr-defined]
try: try:
await page.goto(url, timeout=self._nav_timeout_ms, wait_until="domcontentloaded") await page.goto(url, timeout=self._nav_timeout_ms, wait_until="domcontentloaded")
# Даём JS-гидрации отработать
if self._wait_ms and self._wait_ms > 0: if self._wait_ms and self._wait_ms > 0:
await page.wait_for_timeout(self._wait_ms) await page.wait_for_timeout(self._wait_ms)
html: str = await page.content() html: str = await page.content()
@ -105,94 +104,71 @@ class BrowserFetcher:
await page.close() await page.close()
self._page_count += 1 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 assert self._recycle_after is not None
if self._page_count >= self._recycle_after: if self._page_count >= self._recycle_after:
logger.info( logger.info(
"BrowserFetcher: recycle threshold (%d) достигнут, перезапуск браузера", "BrowserFetcher: recycle threshold (%d) достигнут, переподключение",
self._recycle_after, self._recycle_after,
) )
await self._relaunch() await self._reconnect()
return html return html
async def _launch(self) -> None: async def _connect(self) -> None:
"""Запускает AsyncCamoufox браузер.""" """Запускает Playwright и подключается к WS-серверу tradein-browser."""
try: try:
from camoufox.async_api import AsyncCamoufox from playwright.async_api import async_playwright
except ImportError as exc: except ImportError as exc:
raise RuntimeError( raise RuntimeError(
"camoufox не установлен. Добавь 'camoufox[geoip]>=0.4.0' в pyproject.toml " "playwright не установлен. Добавь 'playwright>=1.45' в pyproject.toml."
"и выполни 'python -m camoufox fetch' для загрузки Firefox-сборки."
) from exc ) from exc
proxy = self._proxy_dict() assert self._ws_endpoint, "BrowserFetcher: browser_ws_endpoint не задан"
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 self._pw = await async_playwright().start()
ctx = AsyncCamoufox(**kwargs) # type: ignore[call-arg] pw = self._pw # type: ignore[assignment]
self._browser = await ctx.__aenter__() self._browser = await pw.firefox.connect( # type: ignore[attr-defined]
self._browser_ctx = ctx # сохраняем для корректного __aexit__ self._ws_endpoint,
timeout=30000, # 30s на установку соединения
)
self._page_count = 0 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: async def _close(self) -> None:
"""Закрывает браузер если запущен.""" """Закрывает соединение с браузером и останавливает Playwright."""
ctx = getattr(self, "_browser_ctx", None) browser = self._browser
if ctx is not None: if browser is not None:
try: try:
await ctx.__aexit__(None, None, None) await browser.close() # type: ignore[attr-defined]
logger.info("BrowserFetcher: браузер закрыт") logger.info("BrowserFetcher: соединение закрыто")
except Exception as exc: except Exception as exc:
logger.warning("BrowserFetcher: ошибка при закрытии браузера: %s", exc) logger.warning("BrowserFetcher: ошибка при закрытии соединения: %s", exc)
finally: finally:
self._browser = None self._browser = None
self._browser_ctx = None
self._page_count = 0
async def _relaunch(self) -> None: pw = self._pw
"""Закрывает текущий браузер и запускает новый.""" if pw is not None:
await self._close_browser() try:
await self._launch() 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: self._page_count = 0
"""Парсит settings.scraper_proxy_url → playwright proxy dict.
Формат входного URL: ``http://user:pass@host:port`` или ``http://host:port``. async def _reconnect(self) -> None:
Возвращает None если proxy не настроен. """Закрывает текущее соединение и устанавливает новое."""
await self._close()
Returns: await self._connect()
``{"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 ──────────────────────────────────────────────────────────────────── # ── helpers ────────────────────────────────────────────────────────────────────
@ -201,19 +177,23 @@ class BrowserFetcher:
def _is_target_closed(exc: BaseException) -> bool: def _is_target_closed(exc: BaseException) -> bool:
"""Проверяет является ли исключение playwright TargetClosedError. """Проверяет является ли исключение playwright TargetClosedError.
Проверка по имени класса (не import) playwright не установлен обязательно. Проверка по имени класса (не import) playwright может не быть в dev-окружении.
""" """
cls_name = type(exc).__name__ return type(exc).__name__ == "TargetClosedError"
return cls_name == "TargetClosedError"
def _is_crash_exception(exc: BaseException) -> bool: def _is_crash_exception(exc: BaseException) -> bool:
"""Проверяет является ли исключение признаком краша браузера. """Проверяет является ли исключение признаком краша / разрыва соединения.
Ловим широкий набор playwright crash-сигналов по имени/сообщению. Ловим широкий набор playwright crash/disconnect сигналов по имени/сообщению.
""" """
cls_name = type(exc).__name__ cls_name = type(exc).__name__
if cls_name in ("BrowserClosedError", "ConnectionClosedError"): if cls_name in ("BrowserClosedError", "ConnectionClosedError"):
return True return True
msg = str(exc).lower() 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)
)

View file

@ -23,7 +23,7 @@ dependencies = [
"sentry-sdk>=2.0.0", # мониторинг ошибок → GlitchTip (#396) "sentry-sdk>=2.0.0", # мониторинг ошибок → GlitchTip (#396)
"redis>=5.0.0", # async hot cache для /api/v1/search (Phase 3.2) "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) "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] [dependency-groups]
@ -31,7 +31,6 @@ dev = [
"pytest>=8.0.0", "pytest>=8.0.0",
"pytest-asyncio>=0.24.0", "pytest-asyncio>=0.24.0",
"ruff>=0.5.0", "ruff>=0.5.0",
"playwright>=1.45", # address-mismatch audit fallback (issue #582 Phase 1)
] ]
[tool.pytest.ini_options] [tool.pytest.ini_options]

View file

@ -1,11 +1,12 @@
"""Tests for BrowserFetcher (#884, Phase 0). """Tests for BrowserFetcher (#884/#905, connect-mode).
Полностью замоканы: не требуют установленного Firefox или camoufox. Полностью замоканы: не требуют установленного Firefox, camoufox или реального
Покрывают: WS-сервера. Покрывают:
- _proxy_dict(): парсинг proxy URL с credentials и без
- fetch(): new_page goto wait_for_timeout content close - fetch(): new_page goto wait_for_timeout content close
- recycle: счётчик страниц relaunch браузера при достижении порога - recycle: счётчик страниц reconnect при достижении порога
- crash-recovery: TargetClosedError relaunch retry - crash-recovery: TargetClosedError reconnect retry
- page counter сбрасывается после reconnect
- non-crash exception пробрасывается без перехвата
""" """
from __future__ import annotations from __future__ import annotations
@ -27,12 +28,14 @@ def _mock_settings(
recycle_pages: int = 15, recycle_pages: int = 15,
nav_timeout_ms: int = 60000, nav_timeout_ms: int = 60000,
wait_ms: int = 2500, wait_ms: int = 2500,
ws_endpoint: str = "ws://fake-browser:3000/playwright",
) -> SimpleNamespace: ) -> SimpleNamespace:
return SimpleNamespace( return SimpleNamespace(
scraper_proxy_url=proxy_url, scraper_proxy_url=proxy_url,
browser_recycle_pages=recycle_pages, browser_recycle_pages=recycle_pages,
browser_nav_timeout_ms=nav_timeout_ms, browser_nav_timeout_ms=nav_timeout_ms,
browser_wait_ms=wait_ms, browser_wait_ms=wait_ms,
browser_ws_endpoint=ws_endpoint,
) )
@ -46,68 +49,36 @@ def _make_mock_page(html: str = "<html>ok</html>") -> MagicMock:
return page return page
def _make_mock_browser(page: MagicMock | None = None) -> MagicMock: def _make_playwright_stack(
"""Создаёт мок browser-контекста camoufox.""" page: MagicMock | None = None,
html: str = "<html>ok</html>",
) -> tuple[MagicMock, MagicMock, MagicMock]:
"""Создаёт полную mock-цепочку playwright: pw_cm → pw → browser → page.
Returns:
(pw_cm, pw_mock, browser_mock)
"""
if page is None: if page is None:
page = _make_mock_page() page = _make_mock_page(html=html)
browser = MagicMock()
browser.new_page = AsyncMock(return_value=page)
return browser
browser_mock = MagicMock()
browser_mock.new_page = AsyncMock(return_value=page)
browser_mock.close = AsyncMock()
def _make_mock_camoufox_ctx(browser: MagicMock) -> MagicMock: firefox_mock = MagicMock()
"""Мок AsyncCamoufox(...) context manager → возвращает browser из __aenter__.""" firefox_mock.connect = AsyncMock(return_value=browser_mock)
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=browser)
ctx.__aexit__ = AsyncMock(return_value=None)
return ctx
pw_mock = MagicMock()
pw_mock.firefox = firefox_mock
pw_mock.stop = AsyncMock()
# ── _proxy_dict() ────────────────────────────────────────────────────────────── async def _start() -> MagicMock:
# _proxy_dict() импортирует settings через `from app.core.config import settings` return pw_mock
# внутри метода, поэтому патчим app.core.config.settings (источник).
pw_cm = MagicMock()
pw_cm.start = _start
def test_proxy_dict_parses_full_url(): return pw_cm, pw_mock, browser_mock
"""_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 ──────────────────────────────── # ── fetch(): new_page → goto → content → close ────────────────────────────────
@ -120,13 +91,11 @@ async def test_fetch_calls_page_lifecycle():
expected_html = "<html><body>test</body></html>" expected_html = "<html><body>test</body></html>"
page = _make_mock_page(html=expected_html) page = _make_mock_page(html=expected_html)
browser = _make_mock_browser(page=page) pw_cm, _pw, browser = _make_playwright_stack(page=page)
ctx = _make_mock_camoufox_ctx(browser)
ms = _mock_settings(recycle_pages=15, nav_timeout_ms=60000, wait_ms=2500) ms = _mock_settings(recycle_pages=15, nav_timeout_ms=60000, wait_ms=2500)
with patch("app.core.config.settings", ms): 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() fetcher = BrowserFetcher()
async with fetcher: async with fetcher:
result = await fetcher.fetch("https://example.com") 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 from app.services.scrapers.browser_fetcher import BrowserFetcher
html = "<html><title>Avito</title></html>" html = "<html><title>Avito</title></html>"
page = _make_mock_page(html=html) pw_cm, _pw, _browser = _make_playwright_stack(html=html)
browser = _make_mock_browser(page=page)
ctx = _make_mock_camoufox_ctx(browser)
ms = _mock_settings() ms = _mock_settings()
with patch("app.core.config.settings", ms): 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() fetcher = BrowserFetcher()
async with fetcher: async with fetcher:
result = await fetcher.fetch("https://avito.ru/test") result = await fetcher.fetch("https://avito.ru/test")
@ -163,59 +129,49 @@ async def test_fetch_returns_html_string():
assert isinstance(result, str) assert isinstance(result, str)
# ── recycle: relaunch at threshold ──────────────────────────────────────────── # ── recycle: reconnect at threshold ───────────────────────────────────────────
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_recycle_triggers_relaunch_at_threshold(): async def test_recycle_triggers_reconnect_at_threshold():
"""После recycle_pages страниц браузер перезапускается (relaunch).""" """После recycle_pages страниц выполняется переподключение (_reconnect)."""
from app.services.scrapers.browser_fetcher import BrowserFetcher from app.services.scrapers.browser_fetcher import BrowserFetcher
recycle_after = 3 recycle_after = 3
pw_cm, _pw, _browser = _make_playwright_stack()
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) ms = _mock_settings(recycle_pages=recycle_after, wait_ms=0)
launch_count = 0 reconnect_count = 0
original_launch = BrowserFetcher._launch original_reconnect = BrowserFetcher._reconnect
async def counting_launch(self: BrowserFetcher) -> None: async def counting_reconnect(self: BrowserFetcher) -> None:
nonlocal launch_count nonlocal reconnect_count
launch_count += 1 reconnect_count += 1
await original_launch(self) await original_reconnect(self)
with patch("app.core.config.settings", ms): 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):
with patch.object(BrowserFetcher, "_launch", counting_launch): with patch.object(BrowserFetcher, "_reconnect", counting_reconnect):
fetcher = BrowserFetcher() fetcher = BrowserFetcher()
async with fetcher: async with fetcher:
# Первый запуск через __aenter__ assert reconnect_count == 0
assert launch_count == 1
# Отправляем recycle_after страниц — на последней сработает relaunch
for _ in range(recycle_after): for _ in range(recycle_after):
await fetcher.fetch("https://example.com") await fetcher.fetch("https://example.com")
# После recycle браузер должен был перезапуститься ещё раз # Третья страница триггерит recycle
assert launch_count == 2 assert reconnect_count == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_page_counter_resets_after_relaunch(): async def test_page_counter_resets_after_reconnect():
"""После recycle page_count сбрасывается в 0.""" """После recycle _page_count сбрасывается в 0."""
from app.services.scrapers.browser_fetcher import BrowserFetcher from app.services.scrapers.browser_fetcher import BrowserFetcher
recycle_after = 2 recycle_after = 2
pw_cm, _pw, _browser = _make_playwright_stack()
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) ms = _mock_settings(recycle_pages=recycle_after, wait_ms=0)
with patch("app.core.config.settings", ms): 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() fetcher = BrowserFetcher()
async with fetcher: async with fetcher:
for _ in range(recycle_after): for _ in range(recycle_after):
@ -237,19 +193,20 @@ _FakeTargetClosedError.__qualname__ = "TargetClosedError"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fetch_retries_once_on_target_closed_error(): async def test_fetch_retries_once_on_target_closed_error():
"""fetch() при TargetClosedError перезапускает браузер и повторяет запрос.""" """fetch() при TargetClosedError переподключается и повторяет запрос."""
from app.services.scrapers.browser_fetcher import BrowserFetcher from app.services.scrapers.browser_fetcher import BrowserFetcher
html = "<html>retry-ok</html>" html = "<html>retry-ok</html>"
# Первая страница бросает краш, вторая отвечает нормально # Первая страница кидает краш, вторая отвечает нормально
crash_page = MagicMock() crash_page = MagicMock()
crash_page.goto = AsyncMock(side_effect=_FakeTargetClosedError("target closed")) crash_page.goto = AsyncMock(side_effect=_FakeTargetClosedError("target closed"))
crash_page.close = AsyncMock() crash_page.close = AsyncMock()
ok_page = _make_mock_page(html=html) ok_page = _make_mock_page(html=html)
browser = _make_mock_browser()
browser_mock = MagicMock()
browser_mock.close = AsyncMock()
call_num = 0 call_num = 0
async def new_page_side_effect() -> MagicMock: async def new_page_side_effect() -> MagicMock:
@ -257,20 +214,33 @@ async def test_fetch_retries_once_on_target_closed_error():
call_num += 1 call_num += 1
return crash_page if call_num == 1 else ok_page return crash_page if call_num == 1 else ok_page
browser.new_page = AsyncMock(side_effect=new_page_side_effect) browser_mock.new_page = AsyncMock(side_effect=new_page_side_effect)
ctx = _make_mock_camoufox_ctx(browser)
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) ms = _mock_settings(wait_ms=0)
with patch("app.core.config.settings", ms): 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() fetcher = BrowserFetcher()
async with fetcher: async with fetcher:
result = await fetcher.fetch("https://avito.ru/page") result = await fetcher.fetch("https://avito.ru/page")
assert result == html assert result == html
# 2 обращения к new_page: первое (crash) + второе (retry после relaunch)
assert call_num == 2 assert call_num == 2
# connect вызывался дважды: при __aenter__ + при reconnect
assert firefox_mock.connect.await_count == 2
@pytest.mark.asyncio @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.goto = AsyncMock(side_effect=ValueError("unexpected parse error"))
page.close = AsyncMock() page.close = AsyncMock()
browser = _make_mock_browser(page=page) pw_cm, _pw, _browser = _make_playwright_stack(page=page)
ctx = _make_mock_camoufox_ctx(browser)
ms = _mock_settings(wait_ms=0) ms = _mock_settings(wait_ms=0)
with patch("app.core.config.settings", ms): 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() fetcher = BrowserFetcher()
async with fetcher: async with fetcher:
with pytest.raises(ValueError, match="unexpected parse error"): with pytest.raises(ValueError, match="unexpected parse error"):

View file

@ -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"]

View file

@ -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:<BROWSER_PORT>/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()

View file

@ -13,6 +13,22 @@
# - Frontend строится с basePath=/trade-in (см. next.config.ts) # - Frontend строится с basePath=/trade-in (см. next.config.ts)
services: 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: postgres:
image: postgis/postgis:16-3.4 image: postgis/postgis:16-3.4
container_name: tradein-postgres container_name: tradein-postgres
@ -36,7 +52,6 @@ services:
backend: backend:
image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest} image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest}
container_name: tradein-backend container_name: tradein-backend
mem_limit: 2.5g # headless Firefox (camoufox) потребляет до ~1.5GB (#884)
# Prod: uvicorn БЕЗ --reload (Dockerfile CMD несёт --reload только для dev hot-reload, # Prod: uvicorn БЕЗ --reload (Dockerfile CMD несёт --reload только для dev hot-reload,
# где app/ bind-mount'ится). В prod --reload = лишний WatchFiles-наблюдатель + риск # где app/ bind-mount'ится). В prod --reload = лишний WatchFiles-наблюдатель + риск
# reload в середине запроса. Single worker ОБЯЗАТЕЛЕН: in-app планировщик # reload в середине запроса. Single worker ОБЯЗАТЕЛЕН: in-app планировщик
@ -67,6 +82,8 @@ services:
# cookies upload endpoint returns 503. Generate with `openssl rand -hex 32`. # cookies upload endpoint returns 503. Generate with `openssl rand -hex 32`.
COOKIE_ENCRYPTION_KEY: "${COOKIE_ENCRYPTION_KEY:-}" COOKIE_ENCRYPTION_KEY: "${COOKIE_ENCRYPTION_KEY:-}"
depends_on: depends_on:
browser:
condition: service_started
postgres: postgres:
condition: service_healthy condition: service_healthy
volumes: volumes: