Merge pull request 'feat(browser): split camoufox into a separate tradein-browser container (Phase 0.5, #905)' (#907) from feat/884-browser-separate-container into main
Some checks failed
Deploy Trade-In / build-frontend (push) Successful in 26s
Deploy Trade-In / test (push) Successful in 32s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-browser (push) Failing after 1m16s
Deploy Trade-In / build-backend (push) Successful in 1m14s
Deploy Trade-In / deploy (push) Has been skipped

Reviewed-on: #907
This commit is contained in:
lekss361 2026-05-31 14:44:00 +00:00
commit d6479c13b9
9 changed files with 345 additions and 231 deletions

View file

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

View file

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

View file

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

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-логику.
Активируется только когда 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)
)

View file

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

View file

@ -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 = "<html>ok</html>") -> MagicMock:
return page
def _make_mock_browser(page: MagicMock | None = None) -> MagicMock:
"""Создаёт мок browser-контекста camoufox."""
def _make_playwright_stack(
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:
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 = "<html><body>test</body></html>"
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 = "<html><title>Avito</title></html>"
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 = "<html>retry-ok</html>"
# Первая страница бросает краш, вторая отвечает нормально
# Первая страница кидает краш, вторая отвечает нормально
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"):

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)
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: