feat(browser): split camoufox into a separate tradein-browser container (Phase 0.5, #905) #907
9 changed files with 345 additions and 231 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -205,14 +205,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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
39
tradein-mvp/browser/Dockerfile
Normal file
39
tradein-mvp/browser/Dockerfile
Normal 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"]
|
||||
82
tradein-mvp/browser/server.py
Normal file
82
tradein-mvp/browser/server.py
Normal 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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue