Compare commits

..

No commits in common. "d6479c13b96a078e081b1dc6638811a2ad0144ef" and "98b0632dae62ac874f7d8bca390745fb6c2f042f" have entirely different histories.

9 changed files with 231 additions and 345 deletions

View file

@ -18,7 +18,6 @@ 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:
@ -26,7 +25,6 @@ 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
@ -38,8 +36,6 @@ 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/**'
@ -153,41 +149,9 @@ 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, build-browser] needs: [changes, test, build-backend, build-frontend]
# 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: |
@ -195,8 +159,7 @@ 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,6 +43,11 @@ 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
@ -53,10 +58,16 @@ COPY --from=builder --chown=app:app /app/scripts /app/scripts
USER app USER app
# HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а некоторые # HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а camoufox/platformdirs
# платформенные библиотеки резолвят $HOME/.cache. Безвредно оставить. # резолвит кэш Firefox как $HOME/.cache/camoufox. Без этого fetch (под root) клал браузер
# в /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

@ -207,17 +207,14 @@ class Settings(BaseSettings):
# ENV: YANDEX_COOKIES_FILE. # ENV: YANDEX_COOKIES_FILE.
yandex_cookies_file: str | None = None yandex_cookies_file: str | None = None
# ── #884/#905: BrowserFetcher — connect-mode к tradein-browser контейнеру ── # ── #884: BrowserFetcher (camoufox) — Phase 0 foundation ─────────────────
# scraper_fetch_mode: "curl_cffi" (дефолт, текущее поведение) или "browser" # scraper_fetch_mode: "curl_cffi" (дефолт, текущее поведение) или "browser"
# (playwright.firefox.connect к tradein-browser WS-серверу). Phase 1+. # (AsyncCamoufox headless Firefox). Переключение в "browser" — 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"
# WS-эндпоинт tradein-browser сервиса. В Docker-сети — имя сервиса из compose. # Сколько страниц обработать в одном browser-процессе перед перезапуском
# ENV: BROWSER_WS_ENDPOINT. # (ограничение утечек памяти). ENV: BROWSER_RECYCLE_PAGES.
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,33 +1,29 @@
"""browser_fetcher.py — Playwright connect-mode клиент для browser-mode scraping (#884/#905). """browser_fetcher.py — camoufox wrapper для browser-mode scraping (Phase 0, #884).
Провайдер-агностичная основа: не затрагивает существующую 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-сервера;
этот модуль управляет только жизненным циклом соединения и страниц.
Особенности: Особенности:
- Ленивые импорты playwright: не ломают app-импорт если не установлен. - Ленивые импорты camoufox/playwright: не ломают app-импорт если Firefox не
- Page recycle: каждые settings.browser_recycle_pages страниц закрываем и установлен (аналогично curl_cffi в avito_imv.py).
переподключаемся (ограничение утечек памяти на стороне браузер-контейнера). - 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: Playwright-клиент подключения к tradein-browser WS-серверу. """Async context manager: camoufox headless Firefox с рециклингом страниц.
Использование:: Использование::
@ -37,13 +33,11 @@ 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 ──────────────────────────────────────────────────────────────
@ -53,21 +47,19 @@ 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
self._ws_endpoint = settings.browser_ws_endpoint await self._launch()
await self._connect()
return self return self
async def __aexit__(self, *_: object) -> None: async def __aexit__(self, *_: object) -> None:
await self._close() await self._close_browser()
# ── 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 или connection-closed (crash/перезапуск браузер-контейнера) При TargetClosedError (crash) перезапускает браузер и делает одну
переподключается и делает одну повторную попытку. Остальные исключения повторную попытку. Остальные исключения всплывают к вызывающему коду.
всплывают к вызывающему коду.
Returns: Returns:
Полный HTML-контент страницы (innerHTML documentElement). Полный HTML-контент страницы (innerHTML documentElement).
@ -75,14 +67,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)," "BrowserFetcher: браузер упал (%s), перезапуск + retry: %s",
" переподключение + retry: %s",
type(exc).__name__, type(exc).__name__,
url, url,
) )
await self._reconnect() await self._relaunch()
return await self._fetch_once(url) return await self._fetch_once(url)
raise raise
@ -90,13 +82,22 @@ 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."""
assert self._browser is not None, "BrowserFetcher: нет активного соединения" # Ленивый импорт: не нужен при curl_cffi mode (не ломает app при
# отсутствии установленного Firefox)
try:
from camoufox.async_api import AsyncCamoufox # noqa: F401 (type check)
except ImportError:
pass # импорт нужен только для type-check; browser уже запущен через _launch
assert self._browser is not None, "BrowserFetcher: браузер не запущен"
# Тип browser — AsyncCamoufox-инстанс (playwright.async_api.Browser-like)
browser = self._browser # type: ignore[assignment] 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()
@ -104,71 +105,94 @@ class BrowserFetcher:
await page.close() await page.close()
self._page_count += 1 self._page_count += 1
logger.debug( logger.debug("BrowserFetcher: fetch OK url=%r pages_since_launch=%d", url, self._page_count)
"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._reconnect() await self._relaunch()
return html return html
async def _connect(self) -> None: async def _launch(self) -> None:
"""Запускает Playwright и подключается к WS-серверу tradein-browser.""" """Запускает AsyncCamoufox браузер."""
try: try:
from playwright.async_api import async_playwright from camoufox.async_api import AsyncCamoufox
except ImportError as exc: except ImportError as exc:
raise RuntimeError( raise RuntimeError(
"playwright не установлен. Добавь 'playwright>=1.45' в pyproject.toml." "camoufox не установлен. Добавь 'camoufox[geoip]>=0.4.0' в pyproject.toml "
"и выполни 'python -m camoufox fetch' для загрузки Firefox-сборки."
) from exc ) from exc
assert self._ws_endpoint, "BrowserFetcher: browser_ws_endpoint не задан" 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
self._pw = await async_playwright().start() # AsyncCamoufox — async context manager; входим в него чтобы получить browser
pw = self._pw # type: ignore[assignment] ctx = AsyncCamoufox(**kwargs) # type: ignore[call-arg]
self._browser = await pw.firefox.connect( # type: ignore[attr-defined] self._browser = await ctx.__aenter__()
self._ws_endpoint, self._browser_ctx = ctx # сохраняем для корректного __aexit__
timeout=30000, # 30s на установку соединения
)
self._page_count = 0 self._page_count = 0
logger.info( logger.info("BrowserFetcher: браузер запущен (proxy=%s)", proxy is not None)
"BrowserFetcher: подключился к %s",
self._ws_endpoint,
)
async def _close(self) -> None: async def _close_browser(self) -> None:
"""Закрывает соединение с браузером и останавливает Playwright.""" """Закрывает браузер если запущен."""
browser = self._browser ctx = getattr(self, "_browser_ctx", None)
if browser is not None: if ctx is not None:
try: try:
await browser.close() # type: ignore[attr-defined] await ctx.__aexit__(None, None, None)
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
pw = self._pw async def _relaunch(self) -> None:
if pw is not None: """Закрывает текущий браузер и запускает новый."""
try: await self._close_browser()
await pw.stop() # type: ignore[attr-defined] await self._launch()
except Exception as exc:
logger.warning("BrowserFetcher: ошибка при остановке Playwright: %s", exc)
finally:
self._pw = None
self._page_count = 0 def _proxy_dict(self) -> dict[str, str] | None:
"""Парсит settings.scraper_proxy_url → playwright proxy dict.
async def _reconnect(self) -> None: Формат входного URL: ``http://user:pass@host:port`` или ``http://host:port``.
"""Закрывает текущее соединение и устанавливает новое.""" Возвращает None если proxy не настроен.
await self._close()
await self._connect() Returns:
``{"server": "http://host:port", "username": "...", "password": "..."}``
или None.
"""
from app.core.config import settings
proxy_url = settings.scraper_proxy_url
if not proxy_url:
return None
parsed = urlparse(proxy_url)
# Собираем server без credentials (playwright требует отдельных полей)
port_part = f":{parsed.port}" if parsed.port else ""
server = f"{parsed.scheme}://{parsed.hostname}{port_part}"
result: dict[str, str] = {"server": server}
if parsed.username:
result["username"] = parsed.username
if parsed.password:
result["password"] = parsed.password
return result
# ── helpers ──────────────────────────────────────────────────────────────────── # ── helpers ────────────────────────────────────────────────────────────────────
@ -177,23 +201,19 @@ class BrowserFetcher:
def _is_target_closed(exc: BaseException) -> bool: def _is_target_closed(exc: BaseException) -> bool:
"""Проверяет является ли исключение playwright TargetClosedError. """Проверяет является ли исключение playwright TargetClosedError.
Проверка по имени класса (не import) playwright может не быть в dev-окружении. Проверка по имени класса (не import) playwright не установлен обязательно.
""" """
return type(exc).__name__ == "TargetClosedError" cls_name = type(exc).__name__
return cls_name == "TargetClosedError"
def _is_crash_exception(exc: BaseException) -> bool: def _is_crash_exception(exc: BaseException) -> bool:
"""Проверяет является ли исключение признаком краша / разрыва соединения. """Проверяет является ли исключение признаком краша браузера.
Ловим широкий набор playwright crash/disconnect сигналов по имени/сообщению. Ловим широкий набор playwright crash-сигналов по имени/сообщению.
""" """
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 ( return "browser has been closed" in msg or "target closed" in msg
"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)
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905) "camoufox[geoip]>=0.4.0", # headless Firefox wrapper для browser-mode scraping (#884)
] ]
[dependency-groups] [dependency-groups]
@ -31,6 +31,7 @@ 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,12 +1,11 @@
"""Tests for BrowserFetcher (#884/#905, connect-mode). """Tests for BrowserFetcher (#884, Phase 0).
Полностью замоканы: не требуют установленного 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: счётчик страниц reconnect при достижении порога - recycle: счётчик страниц relaunch браузера при достижении порога
- crash-recovery: TargetClosedError reconnect retry - crash-recovery: TargetClosedError relaunch retry
- page counter сбрасывается после reconnect
- non-crash exception пробрасывается без перехвата
""" """
from __future__ import annotations from __future__ import annotations
@ -28,14 +27,12 @@ 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,
) )
@ -49,36 +46,68 @@ def _make_mock_page(html: str = "<html>ok</html>") -> MagicMock:
return page return page
def _make_playwright_stack( def _make_mock_browser(page: MagicMock | None = None) -> MagicMock:
page: MagicMock | None = None, """Создаёт мок browser-контекста camoufox."""
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(html=html) page = _make_mock_page()
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()
firefox_mock = MagicMock() def _make_mock_camoufox_ctx(browser: MagicMock) -> MagicMock:
firefox_mock.connect = AsyncMock(return_value=browser_mock) """Мок AsyncCamoufox(...) context manager → возвращает browser из __aenter__."""
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()
async def _start() -> MagicMock: # ── _proxy_dict() ──────────────────────────────────────────────────────────────
return pw_mock # _proxy_dict() импортирует settings через `from app.core.config import settings`
# внутри метода, поэтому патчим app.core.config.settings (источник).
pw_cm = MagicMock()
pw_cm.start = _start
return pw_cm, pw_mock, browser_mock def test_proxy_dict_parses_full_url():
"""_proxy_dict() разбирает URL с username/password → playwright dict."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
fetcher = BrowserFetcher()
ms = _mock_settings(proxy_url="http://user123:pass456@proxy.example.com:8080")
with patch("app.core.config.settings", ms):
result = fetcher._proxy_dict()
assert result == {
"server": "http://proxy.example.com:8080",
"username": "user123",
"password": "pass456",
}
def test_proxy_dict_no_credentials():
"""_proxy_dict() для URL без credentials возвращает только server."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
fetcher = BrowserFetcher()
ms = _mock_settings(proxy_url="http://proxy.example.com:3128")
with patch("app.core.config.settings", ms):
result = fetcher._proxy_dict()
assert result == {"server": "http://proxy.example.com:3128"}
assert "username" not in result
assert "password" not in result
def test_proxy_dict_returns_none_when_no_proxy():
"""_proxy_dict() → None когда scraper_proxy_url не задан."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
fetcher = BrowserFetcher()
ms = _mock_settings(proxy_url=None)
with patch("app.core.config.settings", ms):
result = fetcher._proxy_dict()
assert result is None
# ── fetch(): new_page → goto → content → close ──────────────────────────────── # ── fetch(): new_page → goto → content → close ────────────────────────────────
@ -91,11 +120,13 @@ 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)
pw_cm, _pw, browser = _make_playwright_stack(page=page) browser = _make_mock_browser(page=page)
ctx = _make_mock_camoufox_ctx(browser)
ms = _mock_settings(recycle_pages=15, nav_timeout_ms=60000, wait_ms=2500) 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("playwright.async_api.async_playwright", return_value=pw_cm): with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx):
fetcher = BrowserFetcher() fetcher = BrowserFetcher()
async with fetcher: async with fetcher:
result = await fetcher.fetch("https://example.com") result = await fetcher.fetch("https://example.com")
@ -116,11 +147,14 @@ 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>"
pw_cm, _pw, _browser = _make_playwright_stack(html=html) page = _make_mock_page(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("playwright.async_api.async_playwright", return_value=pw_cm): with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx):
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")
@ -129,49 +163,59 @@ async def test_fetch_returns_html_string():
assert isinstance(result, str) assert isinstance(result, str)
# ── recycle: reconnect at threshold ─────────────────────────────────────────── # ── recycle: relaunch at threshold ────────────────────────────────────────────
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_recycle_triggers_reconnect_at_threshold(): async def test_recycle_triggers_relaunch_at_threshold():
"""После recycle_pages страниц выполняется переподключение (_reconnect).""" """После recycle_pages страниц браузер перезапускается (relaunch)."""
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)
reconnect_count = 0 launch_count = 0
original_reconnect = BrowserFetcher._reconnect original_launch = BrowserFetcher._launch
async def counting_reconnect(self: BrowserFetcher) -> None: async def counting_launch(self: BrowserFetcher) -> None:
nonlocal reconnect_count nonlocal launch_count
reconnect_count += 1 launch_count += 1
await original_reconnect(self) await original_launch(self)
with patch("app.core.config.settings", ms): with patch("app.core.config.settings", ms):
with patch("playwright.async_api.async_playwright", return_value=pw_cm): with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx):
with patch.object(BrowserFetcher, "_reconnect", counting_reconnect): with patch.object(BrowserFetcher, "_launch", counting_launch):
fetcher = BrowserFetcher() fetcher = BrowserFetcher()
async with fetcher: async with fetcher:
assert reconnect_count == 0 # Первый запуск через __aenter__
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 reconnect_count == 1 assert launch_count == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_page_counter_resets_after_reconnect(): async def test_page_counter_resets_after_relaunch():
"""После 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("playwright.async_api.async_playwright", return_value=pw_cm): with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx):
fetcher = BrowserFetcher() fetcher = BrowserFetcher()
async with fetcher: async with fetcher:
for _ in range(recycle_after): for _ in range(recycle_after):
@ -193,20 +237,19 @@ _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:
@ -214,33 +257,20 @@ 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_mock.new_page = AsyncMock(side_effect=new_page_side_effect) browser.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("playwright.async_api.async_playwright", return_value=pw_cm): with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx):
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
@ -252,11 +282,13 @@ 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()
pw_cm, _pw, _browser = _make_playwright_stack(page=page) browser = _make_mock_browser(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("playwright.async_api.async_playwright", return_value=pw_cm): with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx):
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

@ -1,39 +0,0 @@
# 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

@ -1,82 +0,0 @@
"""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,22 +13,6 @@
# - 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
@ -52,6 +36,7 @@ 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 планировщик
@ -82,8 +67,6 @@ 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: