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:
|
env:
|
||||||
IMAGE_BACKEND: ghcr.io/lekss361/gendesign-tradein-backend
|
IMAGE_BACKEND: ghcr.io/lekss361/gendesign-tradein-backend
|
||||||
IMAGE_FRONTEND: ghcr.io/lekss361/gendesign-tradein-frontend
|
IMAGE_FRONTEND: ghcr.io/lekss361/gendesign-tradein-frontend
|
||||||
|
IMAGE_BROWSER: ghcr.io/lekss361/gendesign-tradein-browser
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
changes:
|
||||||
|
|
@ -25,6 +26,7 @@ jobs:
|
||||||
outputs:
|
outputs:
|
||||||
backend: ${{ steps.filter.outputs.backend }}
|
backend: ${{ steps.filter.outputs.backend }}
|
||||||
frontend: ${{ steps.filter.outputs.frontend }}
|
frontend: ${{ steps.filter.outputs.frontend }}
|
||||||
|
browser: ${{ steps.filter.outputs.browser }}
|
||||||
infra: ${{ steps.filter.outputs.infra }}
|
infra: ${{ steps.filter.outputs.infra }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
@ -36,6 +38,8 @@ jobs:
|
||||||
- 'tradein-mvp/backend/**'
|
- 'tradein-mvp/backend/**'
|
||||||
frontend:
|
frontend:
|
||||||
- 'tradein-mvp/frontend/**'
|
- 'tradein-mvp/frontend/**'
|
||||||
|
browser:
|
||||||
|
- 'tradein-mvp/browser/**'
|
||||||
infra:
|
infra:
|
||||||
- 'tradein-mvp/docker-compose.prod.yml'
|
- 'tradein-mvp/docker-compose.prod.yml'
|
||||||
- 'tradein-mvp/deploy/**'
|
- 'tradein-mvp/deploy/**'
|
||||||
|
|
@ -149,9 +153,41 @@ jobs:
|
||||||
${{ env.IMAGE_FRONTEND }}:latest
|
${{ env.IMAGE_FRONTEND }}:latest
|
||||||
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
|
${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
|
||||||
|
|
||||||
|
build-browser:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
|
# tradein-browser несёт camoufox + Firefox-build (#905). Триггерится на
|
||||||
|
# изменения browser/ или infra (compose ссылается на образ) или вручную.
|
||||||
|
if: |
|
||||||
|
needs.changes.outputs.browser == 'true' ||
|
||||||
|
needs.changes.outputs.infra == 'true' ||
|
||||||
|
github.event_name == 'workflow_dispatch'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to GHCR (shell-based — docker/login-action@v3 unreliable под Forgejo Actions)
|
||||||
|
env:
|
||||||
|
GHCR_PAT: ${{ secrets.GHCR_PAT }}
|
||||||
|
run: |
|
||||||
|
echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build & push tradein-browser
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./tradein-mvp/browser
|
||||||
|
push: true
|
||||||
|
cache-from: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE_BROWSER }}:latest
|
||||||
|
${{ env.IMAGE_BROWSER }}:${{ github.sha }}
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [changes, test, build-backend, build-frontend]
|
needs: [changes, test, build-backend, build-frontend, build-browser]
|
||||||
# NB: a failed `test` skips build-backend (result='skipped', not 'failure'),
|
# NB: a failed `test` skips build-backend (result='skipped', not 'failure'),
|
||||||
# so we must block deploy on test failure explicitly (#666 quality gate).
|
# so we must block deploy on test failure explicitly (#666 quality gate).
|
||||||
if: |
|
if: |
|
||||||
|
|
@ -159,7 +195,8 @@ jobs:
|
||||||
!cancelled() &&
|
!cancelled() &&
|
||||||
needs.test.result != 'failure' &&
|
needs.test.result != 'failure' &&
|
||||||
needs.build-backend.result != 'failure' &&
|
needs.build-backend.result != 'failure' &&
|
||||||
needs.build-frontend.result != 'failure'
|
needs.build-frontend.result != 'failure' &&
|
||||||
|
needs.build-browser.result != 'failure'
|
||||||
steps:
|
steps:
|
||||||
- name: Deploy via SSH
|
- name: Deploy via SSH
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,6 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
libpangoft2-1.0-0 \
|
libpangoft2-1.0-0 \
|
||||||
fonts-dejavu-core \
|
fonts-dejavu-core \
|
||||||
curl \
|
curl \
|
||||||
libgtk-3-0 \
|
|
||||||
libasound2 \
|
|
||||||
libdbus-glib-1-2 \
|
|
||||||
libx11-xcb1 \
|
|
||||||
libxtst6 \
|
|
||||||
&& useradd --create-home --uid 1000 app
|
&& useradd --create-home --uid 1000 app
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
@ -58,16 +53,10 @@ COPY --from=builder --chown=app:app /app/scripts /app/scripts
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
|
|
||||||
# HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а camoufox/platformdirs
|
# HOME должен быть явным: Docker НЕ выставляет $HOME по USER, а некоторые
|
||||||
# резолвит кэш Firefox как $HOME/.cache/camoufox. Без этого fetch (под root) клал браузер
|
# платформенные библиотеки резолвят $HOME/.cache. Безвредно оставить.
|
||||||
# в /root/.cache, а рантайм-app искал в /home/app/.cache → "browser not found" в browser-mode.
|
|
||||||
# (подтверждено сборкой+прогоном образа на деплой-хосте 2026-05-31, баг из #884.)
|
|
||||||
ENV HOME=/home/app
|
ENV HOME=/home/app
|
||||||
|
|
||||||
# Загружаем Firefox-сборку для camoufox (#884) — ПОД app-юзером, чтобы кэш совпал с рантаймом.
|
|
||||||
# Слой кешируется: пересобирается только при изменении .venv (обновление camoufox). ~960MB.
|
|
||||||
RUN python -m camoufox fetch
|
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
|
|
||||||
|
|
@ -205,14 +205,17 @@ class Settings(BaseSettings):
|
||||||
# ENV: YANDEX_COOKIES_FILE.
|
# ENV: YANDEX_COOKIES_FILE.
|
||||||
yandex_cookies_file: str | None = None
|
yandex_cookies_file: str | None = None
|
||||||
|
|
||||||
# ── #884: BrowserFetcher (camoufox) — Phase 0 foundation ─────────────────
|
# ── #884/#905: BrowserFetcher — connect-mode к tradein-browser контейнеру ──
|
||||||
# scraper_fetch_mode: "curl_cffi" (дефолт, текущее поведение) или "browser"
|
# scraper_fetch_mode: "curl_cffi" (дефолт, текущее поведение) или "browser"
|
||||||
# (AsyncCamoufox headless Firefox). Переключение в "browser" — Phase 1+.
|
# (playwright.firefox.connect к tradein-browser WS-серверу). Phase 1+.
|
||||||
# Пока дефолт "curl_cffi" — никаких изменений в рантайме.
|
# Пока дефолт "curl_cffi" — никаких изменений в рантайме.
|
||||||
# ENV: SCRAPER_FETCH_MODE.
|
# ENV: SCRAPER_FETCH_MODE.
|
||||||
scraper_fetch_mode: Literal["curl_cffi", "browser"] = "curl_cffi"
|
scraper_fetch_mode: Literal["curl_cffi", "browser"] = "curl_cffi"
|
||||||
# Сколько страниц обработать в одном browser-процессе перед перезапуском
|
# WS-эндпоинт tradein-browser сервиса. В Docker-сети — имя сервиса из compose.
|
||||||
# (ограничение утечек памяти). ENV: BROWSER_RECYCLE_PAGES.
|
# ENV: BROWSER_WS_ENDPOINT.
|
||||||
|
browser_ws_endpoint: str = "ws://tradein-browser:3000/playwright"
|
||||||
|
# Сколько страниц обработать в одном browser-сеансе перед переподключением
|
||||||
|
# (ограничение утечек памяти на стороне браузер-контейнера). ENV: BROWSER_RECYCLE_PAGES.
|
||||||
browser_recycle_pages: int = 15
|
browser_recycle_pages: int = 15
|
||||||
# Таймаут навигации (page.goto) в мс. ENV: BROWSER_NAV_TIMEOUT_MS.
|
# Таймаут навигации (page.goto) в мс. ENV: BROWSER_NAV_TIMEOUT_MS.
|
||||||
browser_nav_timeout_ms: int = 60000
|
browser_nav_timeout_ms: int = 60000
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,33 @@
|
||||||
"""browser_fetcher.py — camoufox wrapper для browser-mode scraping (Phase 0, #884).
|
"""browser_fetcher.py — Playwright connect-mode клиент для browser-mode scraping (#884/#905).
|
||||||
|
|
||||||
Провайдер-агностичная основа: не затрагивает существующую avito/cian-логику.
|
Провайдер-агностичная основа: не затрагивает существующую avito/cian-логику.
|
||||||
Активируется только когда settings.scraper_fetch_mode == "browser" (пока
|
Активируется только когда settings.scraper_fetch_mode == "browser" (дефолт
|
||||||
дефолт "curl_cffi" — поведение идентично старому до явного переключения).
|
"curl_cffi" — поведение идентично старому до явного переключения).
|
||||||
|
|
||||||
|
Архитектура (Phase 0.5, #905):
|
||||||
|
- Camoufox Firefox запущен в отдельном контейнере ``tradein-browser`` как
|
||||||
|
Playwright WS-сервер (camoufox.server.launch_server).
|
||||||
|
- Этот модуль подключается к нему через ``playwright.async_api.firefox.connect``
|
||||||
|
используя эндпоинт из settings.browser_ws_endpoint.
|
||||||
|
- Proxy/fingerprint/locale/geoip настраиваются на стороне browser-сервера;
|
||||||
|
этот модуль управляет только жизненным циклом соединения и страниц.
|
||||||
|
|
||||||
Особенности:
|
Особенности:
|
||||||
- Ленивые импорты camoufox/playwright: не ломают app-импорт если Firefox не
|
- Ленивые импорты playwright: не ломают app-импорт если не установлен.
|
||||||
установлен (аналогично curl_cffi в avito_imv.py).
|
- Page recycle: каждые settings.browser_recycle_pages страниц закрываем и
|
||||||
- Page recycle: каждые settings.browser_recycle_pages страниц браузер
|
переподключаемся (ограничение утечек памяти на стороне браузер-контейнера).
|
||||||
перезапускается (ограничение утечек памяти).
|
- Crash-recovery: TargetClosedError / connection-closed → переподключение → один retry.
|
||||||
- Crash-recovery: TargetClosedError → relaunch → one retry.
|
|
||||||
- Proxy: парсит settings.scraper_proxy_url → playwright dict формат
|
|
||||||
{"server": "http://host:port", "username": ..., "password": ...}.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BrowserFetcher:
|
class BrowserFetcher:
|
||||||
"""Async context manager: camoufox headless Firefox с рециклингом страниц.
|
"""Async context manager: Playwright-клиент подключения к tradein-browser WS-серверу.
|
||||||
|
|
||||||
Использование::
|
Использование::
|
||||||
|
|
||||||
|
|
@ -33,11 +37,13 @@ class BrowserFetcher:
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._browser: object | None = None
|
self._browser: object | None = None
|
||||||
|
self._pw: object | None = None
|
||||||
self._page_count: int = 0
|
self._page_count: int = 0
|
||||||
# Lazy-loaded настройки чтобы не импортировать settings на верхнем уровне
|
# Lazy-loaded настройки чтобы не импортировать settings на верхнем уровне
|
||||||
self._recycle_after: int | None = None
|
self._recycle_after: int | None = None
|
||||||
self._nav_timeout_ms: int | None = None
|
self._nav_timeout_ms: int | None = None
|
||||||
self._wait_ms: int | None = None
|
self._wait_ms: int | None = None
|
||||||
|
self._ws_endpoint: str | None = None
|
||||||
|
|
||||||
# ── lifecycle ──────────────────────────────────────────────────────────────
|
# ── lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -47,19 +53,21 @@ class BrowserFetcher:
|
||||||
self._recycle_after = settings.browser_recycle_pages
|
self._recycle_after = settings.browser_recycle_pages
|
||||||
self._nav_timeout_ms = settings.browser_nav_timeout_ms
|
self._nav_timeout_ms = settings.browser_nav_timeout_ms
|
||||||
self._wait_ms = settings.browser_wait_ms
|
self._wait_ms = settings.browser_wait_ms
|
||||||
await self._launch()
|
self._ws_endpoint = settings.browser_ws_endpoint
|
||||||
|
await self._connect()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, *_: object) -> None:
|
async def __aexit__(self, *_: object) -> None:
|
||||||
await self._close_browser()
|
await self._close()
|
||||||
|
|
||||||
# ── public API ─────────────────────────────────────────────────────────────
|
# ── public API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def fetch(self, url: str) -> str:
|
async def fetch(self, url: str) -> str:
|
||||||
"""Открывает новую страницу, переходит по URL, ждёт JS, возвращает HTML.
|
"""Открывает новую страницу, переходит по URL, ждёт JS, возвращает HTML.
|
||||||
|
|
||||||
При TargetClosedError (crash) перезапускает браузер и делает одну
|
При TargetClosedError или connection-closed (crash/перезапуск браузер-контейнера)
|
||||||
повторную попытку. Остальные исключения всплывают к вызывающему коду.
|
переподключается и делает одну повторную попытку. Остальные исключения
|
||||||
|
всплывают к вызывающему коду.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Полный HTML-контент страницы (innerHTML documentElement).
|
Полный HTML-контент страницы (innerHTML documentElement).
|
||||||
|
|
@ -67,14 +75,14 @@ class BrowserFetcher:
|
||||||
try:
|
try:
|
||||||
return await self._fetch_once(url)
|
return await self._fetch_once(url)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# Определяем crash vs обычная ошибка через имя класса (lazy import)
|
|
||||||
if _is_target_closed(exc) or _is_crash_exception(exc):
|
if _is_target_closed(exc) or _is_crash_exception(exc):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"BrowserFetcher: браузер упал (%s), перезапуск + retry: %s",
|
"BrowserFetcher: соединение с браузером потеряно (%s),"
|
||||||
|
" переподключение + retry: %s",
|
||||||
type(exc).__name__,
|
type(exc).__name__,
|
||||||
url,
|
url,
|
||||||
)
|
)
|
||||||
await self._relaunch()
|
await self._reconnect()
|
||||||
return await self._fetch_once(url)
|
return await self._fetch_once(url)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
@ -82,22 +90,13 @@ class BrowserFetcher:
|
||||||
|
|
||||||
async def _fetch_once(self, url: str) -> str:
|
async def _fetch_once(self, url: str) -> str:
|
||||||
"""Одна попытка: new_page → goto → wait → content → close."""
|
"""Одна попытка: new_page → goto → wait → content → close."""
|
||||||
# Ленивый импорт: не нужен при curl_cffi mode (не ломает app при
|
assert self._browser is not None, "BrowserFetcher: нет активного соединения"
|
||||||
# отсутствии установленного Firefox)
|
|
||||||
try:
|
|
||||||
from camoufox.async_api import AsyncCamoufox # noqa: F401 (type check)
|
|
||||||
except ImportError:
|
|
||||||
pass # импорт нужен только для type-check; browser уже запущен через _launch
|
|
||||||
|
|
||||||
assert self._browser is not None, "BrowserFetcher: браузер не запущен"
|
|
||||||
|
|
||||||
# Тип browser — AsyncCamoufox-инстанс (playwright.async_api.Browser-like)
|
|
||||||
browser = self._browser # type: ignore[assignment]
|
browser = self._browser # type: ignore[assignment]
|
||||||
|
|
||||||
page = await browser.new_page() # type: ignore[attr-defined]
|
page = await browser.new_page() # type: ignore[attr-defined]
|
||||||
try:
|
try:
|
||||||
await page.goto(url, timeout=self._nav_timeout_ms, wait_until="domcontentloaded")
|
await page.goto(url, timeout=self._nav_timeout_ms, wait_until="domcontentloaded")
|
||||||
# Даём JS-гидрации отработать
|
|
||||||
if self._wait_ms and self._wait_ms > 0:
|
if self._wait_ms and self._wait_ms > 0:
|
||||||
await page.wait_for_timeout(self._wait_ms)
|
await page.wait_for_timeout(self._wait_ms)
|
||||||
html: str = await page.content()
|
html: str = await page.content()
|
||||||
|
|
@ -105,94 +104,71 @@ class BrowserFetcher:
|
||||||
await page.close()
|
await page.close()
|
||||||
|
|
||||||
self._page_count += 1
|
self._page_count += 1
|
||||||
logger.debug("BrowserFetcher: fetch OK url=%r pages_since_launch=%d", url, self._page_count)
|
logger.debug(
|
||||||
|
"BrowserFetcher: fetch OK url=%r pages_since_connect=%d", url, self._page_count
|
||||||
|
)
|
||||||
|
|
||||||
# Recycle: перезапускаем браузер каждые N страниц
|
# Recycle: переподключаемся каждые N страниц
|
||||||
assert self._recycle_after is not None
|
assert self._recycle_after is not None
|
||||||
if self._page_count >= self._recycle_after:
|
if self._page_count >= self._recycle_after:
|
||||||
logger.info(
|
logger.info(
|
||||||
"BrowserFetcher: recycle threshold (%d) достигнут, перезапуск браузера",
|
"BrowserFetcher: recycle threshold (%d) достигнут, переподключение",
|
||||||
self._recycle_after,
|
self._recycle_after,
|
||||||
)
|
)
|
||||||
await self._relaunch()
|
await self._reconnect()
|
||||||
|
|
||||||
return html
|
return html
|
||||||
|
|
||||||
async def _launch(self) -> None:
|
async def _connect(self) -> None:
|
||||||
"""Запускает AsyncCamoufox браузер."""
|
"""Запускает Playwright и подключается к WS-серверу tradein-browser."""
|
||||||
try:
|
try:
|
||||||
from camoufox.async_api import AsyncCamoufox
|
from playwright.async_api import async_playwright
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"camoufox не установлен. Добавь 'camoufox[geoip]>=0.4.0' в pyproject.toml "
|
"playwright не установлен. Добавь 'playwright>=1.45' в pyproject.toml."
|
||||||
"и выполни 'python -m camoufox fetch' для загрузки Firefox-сборки."
|
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
proxy = self._proxy_dict()
|
assert self._ws_endpoint, "BrowserFetcher: browser_ws_endpoint не задан"
|
||||||
kwargs: dict[str, object] = {
|
|
||||||
"headless": True,
|
|
||||||
"os": "windows",
|
|
||||||
"locale": "ru-RU",
|
|
||||||
"geoip": True,
|
|
||||||
"humanize": True,
|
|
||||||
}
|
|
||||||
if proxy is not None:
|
|
||||||
kwargs["proxy"] = proxy
|
|
||||||
|
|
||||||
# AsyncCamoufox — async context manager; входим в него чтобы получить browser
|
self._pw = await async_playwright().start()
|
||||||
ctx = AsyncCamoufox(**kwargs) # type: ignore[call-arg]
|
pw = self._pw # type: ignore[assignment]
|
||||||
self._browser = await ctx.__aenter__()
|
self._browser = await pw.firefox.connect( # type: ignore[attr-defined]
|
||||||
self._browser_ctx = ctx # сохраняем для корректного __aexit__
|
self._ws_endpoint,
|
||||||
|
timeout=30000, # 30s на установку соединения
|
||||||
|
)
|
||||||
self._page_count = 0
|
self._page_count = 0
|
||||||
logger.info("BrowserFetcher: браузер запущен (proxy=%s)", proxy is not None)
|
logger.info(
|
||||||
|
"BrowserFetcher: подключился к %s",
|
||||||
|
self._ws_endpoint,
|
||||||
|
)
|
||||||
|
|
||||||
async def _close_browser(self) -> None:
|
async def _close(self) -> None:
|
||||||
"""Закрывает браузер если запущен."""
|
"""Закрывает соединение с браузером и останавливает Playwright."""
|
||||||
ctx = getattr(self, "_browser_ctx", None)
|
browser = self._browser
|
||||||
if ctx is not None:
|
if browser is not None:
|
||||||
try:
|
try:
|
||||||
await ctx.__aexit__(None, None, None)
|
await browser.close() # type: ignore[attr-defined]
|
||||||
logger.info("BrowserFetcher: браузер закрыт")
|
logger.info("BrowserFetcher: соединение закрыто")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("BrowserFetcher: ошибка при закрытии браузера: %s", exc)
|
logger.warning("BrowserFetcher: ошибка при закрытии соединения: %s", exc)
|
||||||
finally:
|
finally:
|
||||||
self._browser = None
|
self._browser = None
|
||||||
self._browser_ctx = None
|
|
||||||
self._page_count = 0
|
|
||||||
|
|
||||||
async def _relaunch(self) -> None:
|
pw = self._pw
|
||||||
"""Закрывает текущий браузер и запускает новый."""
|
if pw is not None:
|
||||||
await self._close_browser()
|
try:
|
||||||
await self._launch()
|
await pw.stop() # type: ignore[attr-defined]
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("BrowserFetcher: ошибка при остановке Playwright: %s", exc)
|
||||||
|
finally:
|
||||||
|
self._pw = None
|
||||||
|
|
||||||
def _proxy_dict(self) -> dict[str, str] | None:
|
self._page_count = 0
|
||||||
"""Парсит settings.scraper_proxy_url → playwright proxy dict.
|
|
||||||
|
|
||||||
Формат входного URL: ``http://user:pass@host:port`` или ``http://host:port``.
|
async def _reconnect(self) -> None:
|
||||||
Возвращает None если proxy не настроен.
|
"""Закрывает текущее соединение и устанавливает новое."""
|
||||||
|
await self._close()
|
||||||
Returns:
|
await self._connect()
|
||||||
``{"server": "http://host:port", "username": "...", "password": "..."}``
|
|
||||||
или None.
|
|
||||||
"""
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
proxy_url = settings.scraper_proxy_url
|
|
||||||
if not proxy_url:
|
|
||||||
return None
|
|
||||||
|
|
||||||
parsed = urlparse(proxy_url)
|
|
||||||
# Собираем server без credentials (playwright требует отдельных полей)
|
|
||||||
port_part = f":{parsed.port}" if parsed.port else ""
|
|
||||||
server = f"{parsed.scheme}://{parsed.hostname}{port_part}"
|
|
||||||
|
|
||||||
result: dict[str, str] = {"server": server}
|
|
||||||
if parsed.username:
|
|
||||||
result["username"] = parsed.username
|
|
||||||
if parsed.password:
|
|
||||||
result["password"] = parsed.password
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ── helpers ────────────────────────────────────────────────────────────────────
|
# ── helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -201,19 +177,23 @@ class BrowserFetcher:
|
||||||
def _is_target_closed(exc: BaseException) -> bool:
|
def _is_target_closed(exc: BaseException) -> bool:
|
||||||
"""Проверяет является ли исключение playwright TargetClosedError.
|
"""Проверяет является ли исключение playwright TargetClosedError.
|
||||||
|
|
||||||
Проверка по имени класса (не import) — playwright не установлен обязательно.
|
Проверка по имени класса (не import) — playwright может не быть в dev-окружении.
|
||||||
"""
|
"""
|
||||||
cls_name = type(exc).__name__
|
return type(exc).__name__ == "TargetClosedError"
|
||||||
return cls_name == "TargetClosedError"
|
|
||||||
|
|
||||||
|
|
||||||
def _is_crash_exception(exc: BaseException) -> bool:
|
def _is_crash_exception(exc: BaseException) -> bool:
|
||||||
"""Проверяет является ли исключение признаком краша браузера.
|
"""Проверяет является ли исключение признаком краша / разрыва соединения.
|
||||||
|
|
||||||
Ловим широкий набор playwright crash-сигналов по имени/сообщению.
|
Ловим широкий набор playwright crash/disconnect сигналов по имени/сообщению.
|
||||||
"""
|
"""
|
||||||
cls_name = type(exc).__name__
|
cls_name = type(exc).__name__
|
||||||
if cls_name in ("BrowserClosedError", "ConnectionClosedError"):
|
if cls_name in ("BrowserClosedError", "ConnectionClosedError"):
|
||||||
return True
|
return True
|
||||||
msg = str(exc).lower()
|
msg = str(exc).lower()
|
||||||
return "browser has been closed" in msg or "target closed" in msg
|
return (
|
||||||
|
"browser has been closed" in msg
|
||||||
|
or "target closed" in msg
|
||||||
|
or "connection closed" in msg
|
||||||
|
or ("websocket" in msg and "closed" in msg)
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ dependencies = [
|
||||||
"sentry-sdk>=2.0.0", # мониторинг ошибок → GlitchTip (#396)
|
"sentry-sdk>=2.0.0", # мониторинг ошибок → GlitchTip (#396)
|
||||||
"redis>=5.0.0", # async hot cache для /api/v1/search (Phase 3.2)
|
"redis>=5.0.0", # async hot cache для /api/v1/search (Phase 3.2)
|
||||||
"pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py)
|
"pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py)
|
||||||
"camoufox[geoip]>=0.4.0", # headless Firefox wrapper для browser-mode scraping (#884)
|
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905)
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|
@ -31,7 +31,6 @@ dev = [
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
"pytest-asyncio>=0.24.0",
|
"pytest-asyncio>=0.24.0",
|
||||||
"ruff>=0.5.0",
|
"ruff>=0.5.0",
|
||||||
"playwright>=1.45", # address-mismatch audit fallback (issue #582 Phase 1)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
"""Tests for BrowserFetcher (#884, Phase 0).
|
"""Tests for BrowserFetcher (#884/#905, connect-mode).
|
||||||
|
|
||||||
Полностью замоканы: не требуют установленного Firefox или camoufox.
|
Полностью замоканы: не требуют установленного Firefox, camoufox или реального
|
||||||
Покрывают:
|
WS-сервера. Покрывают:
|
||||||
- _proxy_dict(): парсинг proxy URL с credentials и без
|
|
||||||
- fetch(): new_page → goto → wait_for_timeout → content → close
|
- fetch(): new_page → goto → wait_for_timeout → content → close
|
||||||
- recycle: счётчик страниц → relaunch браузера при достижении порога
|
- recycle: счётчик страниц → reconnect при достижении порога
|
||||||
- crash-recovery: TargetClosedError → relaunch → retry
|
- crash-recovery: TargetClosedError → reconnect → retry
|
||||||
|
- page counter сбрасывается после reconnect
|
||||||
|
- non-crash exception пробрасывается без перехвата
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -27,12 +28,14 @@ def _mock_settings(
|
||||||
recycle_pages: int = 15,
|
recycle_pages: int = 15,
|
||||||
nav_timeout_ms: int = 60000,
|
nav_timeout_ms: int = 60000,
|
||||||
wait_ms: int = 2500,
|
wait_ms: int = 2500,
|
||||||
|
ws_endpoint: str = "ws://fake-browser:3000/playwright",
|
||||||
) -> SimpleNamespace:
|
) -> SimpleNamespace:
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
scraper_proxy_url=proxy_url,
|
scraper_proxy_url=proxy_url,
|
||||||
browser_recycle_pages=recycle_pages,
|
browser_recycle_pages=recycle_pages,
|
||||||
browser_nav_timeout_ms=nav_timeout_ms,
|
browser_nav_timeout_ms=nav_timeout_ms,
|
||||||
browser_wait_ms=wait_ms,
|
browser_wait_ms=wait_ms,
|
||||||
|
browser_ws_endpoint=ws_endpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -46,68 +49,36 @@ def _make_mock_page(html: str = "<html>ok</html>") -> MagicMock:
|
||||||
return page
|
return page
|
||||||
|
|
||||||
|
|
||||||
def _make_mock_browser(page: MagicMock | None = None) -> MagicMock:
|
def _make_playwright_stack(
|
||||||
"""Создаёт мок browser-контекста camoufox."""
|
page: MagicMock | None = None,
|
||||||
|
html: str = "<html>ok</html>",
|
||||||
|
) -> tuple[MagicMock, MagicMock, MagicMock]:
|
||||||
|
"""Создаёт полную mock-цепочку playwright: pw_cm → pw → browser → page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(pw_cm, pw_mock, browser_mock)
|
||||||
|
"""
|
||||||
if page is None:
|
if page is None:
|
||||||
page = _make_mock_page()
|
page = _make_mock_page(html=html)
|
||||||
browser = MagicMock()
|
|
||||||
browser.new_page = AsyncMock(return_value=page)
|
|
||||||
return browser
|
|
||||||
|
|
||||||
|
browser_mock = MagicMock()
|
||||||
|
browser_mock.new_page = AsyncMock(return_value=page)
|
||||||
|
browser_mock.close = AsyncMock()
|
||||||
|
|
||||||
def _make_mock_camoufox_ctx(browser: MagicMock) -> MagicMock:
|
firefox_mock = MagicMock()
|
||||||
"""Мок AsyncCamoufox(...) context manager → возвращает browser из __aenter__."""
|
firefox_mock.connect = AsyncMock(return_value=browser_mock)
|
||||||
ctx = MagicMock()
|
|
||||||
ctx.__aenter__ = AsyncMock(return_value=browser)
|
|
||||||
ctx.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
|
pw_mock = MagicMock()
|
||||||
|
pw_mock.firefox = firefox_mock
|
||||||
|
pw_mock.stop = AsyncMock()
|
||||||
|
|
||||||
# ── _proxy_dict() ──────────────────────────────────────────────────────────────
|
async def _start() -> MagicMock:
|
||||||
# _proxy_dict() импортирует settings через `from app.core.config import settings`
|
return pw_mock
|
||||||
# внутри метода, поэтому патчим app.core.config.settings (источник).
|
|
||||||
|
|
||||||
|
pw_cm = MagicMock()
|
||||||
|
pw_cm.start = _start
|
||||||
|
|
||||||
def test_proxy_dict_parses_full_url():
|
return pw_cm, pw_mock, browser_mock
|
||||||
"""_proxy_dict() разбирает URL с username/password → playwright dict."""
|
|
||||||
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
|
||||||
|
|
||||||
fetcher = BrowserFetcher()
|
|
||||||
ms = _mock_settings(proxy_url="http://user123:pass456@proxy.example.com:8080")
|
|
||||||
with patch("app.core.config.settings", ms):
|
|
||||||
result = fetcher._proxy_dict()
|
|
||||||
|
|
||||||
assert result == {
|
|
||||||
"server": "http://proxy.example.com:8080",
|
|
||||||
"username": "user123",
|
|
||||||
"password": "pass456",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_proxy_dict_no_credentials():
|
|
||||||
"""_proxy_dict() для URL без credentials возвращает только server."""
|
|
||||||
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
|
||||||
|
|
||||||
fetcher = BrowserFetcher()
|
|
||||||
ms = _mock_settings(proxy_url="http://proxy.example.com:3128")
|
|
||||||
with patch("app.core.config.settings", ms):
|
|
||||||
result = fetcher._proxy_dict()
|
|
||||||
|
|
||||||
assert result == {"server": "http://proxy.example.com:3128"}
|
|
||||||
assert "username" not in result
|
|
||||||
assert "password" not in result
|
|
||||||
|
|
||||||
|
|
||||||
def test_proxy_dict_returns_none_when_no_proxy():
|
|
||||||
"""_proxy_dict() → None когда scraper_proxy_url не задан."""
|
|
||||||
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
|
||||||
|
|
||||||
fetcher = BrowserFetcher()
|
|
||||||
ms = _mock_settings(proxy_url=None)
|
|
||||||
with patch("app.core.config.settings", ms):
|
|
||||||
result = fetcher._proxy_dict()
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
# ── fetch(): new_page → goto → content → close ────────────────────────────────
|
# ── fetch(): new_page → goto → content → close ────────────────────────────────
|
||||||
|
|
@ -120,13 +91,11 @@ async def test_fetch_calls_page_lifecycle():
|
||||||
|
|
||||||
expected_html = "<html><body>test</body></html>"
|
expected_html = "<html><body>test</body></html>"
|
||||||
page = _make_mock_page(html=expected_html)
|
page = _make_mock_page(html=expected_html)
|
||||||
browser = _make_mock_browser(page=page)
|
pw_cm, _pw, browser = _make_playwright_stack(page=page)
|
||||||
ctx = _make_mock_camoufox_ctx(browser)
|
|
||||||
|
|
||||||
ms = _mock_settings(recycle_pages=15, nav_timeout_ms=60000, wait_ms=2500)
|
ms = _mock_settings(recycle_pages=15, nav_timeout_ms=60000, wait_ms=2500)
|
||||||
|
|
||||||
with patch("app.core.config.settings", ms):
|
with patch("app.core.config.settings", ms):
|
||||||
with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx):
|
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
|
||||||
fetcher = BrowserFetcher()
|
fetcher = BrowserFetcher()
|
||||||
async with fetcher:
|
async with fetcher:
|
||||||
result = await fetcher.fetch("https://example.com")
|
result = await fetcher.fetch("https://example.com")
|
||||||
|
|
@ -147,14 +116,11 @@ async def test_fetch_returns_html_string():
|
||||||
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
||||||
|
|
||||||
html = "<html><title>Avito</title></html>"
|
html = "<html><title>Avito</title></html>"
|
||||||
page = _make_mock_page(html=html)
|
pw_cm, _pw, _browser = _make_playwright_stack(html=html)
|
||||||
browser = _make_mock_browser(page=page)
|
|
||||||
ctx = _make_mock_camoufox_ctx(browser)
|
|
||||||
|
|
||||||
ms = _mock_settings()
|
ms = _mock_settings()
|
||||||
|
|
||||||
with patch("app.core.config.settings", ms):
|
with patch("app.core.config.settings", ms):
|
||||||
with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx):
|
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
|
||||||
fetcher = BrowserFetcher()
|
fetcher = BrowserFetcher()
|
||||||
async with fetcher:
|
async with fetcher:
|
||||||
result = await fetcher.fetch("https://avito.ru/test")
|
result = await fetcher.fetch("https://avito.ru/test")
|
||||||
|
|
@ -163,59 +129,49 @@ async def test_fetch_returns_html_string():
|
||||||
assert isinstance(result, str)
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
|
||||||
# ── recycle: relaunch at threshold ────────────────────────────────────────────
|
# ── recycle: reconnect at threshold ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_recycle_triggers_relaunch_at_threshold():
|
async def test_recycle_triggers_reconnect_at_threshold():
|
||||||
"""После recycle_pages страниц браузер перезапускается (relaunch)."""
|
"""После recycle_pages страниц выполняется переподключение (_reconnect)."""
|
||||||
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
||||||
|
|
||||||
recycle_after = 3
|
recycle_after = 3
|
||||||
|
pw_cm, _pw, _browser = _make_playwright_stack()
|
||||||
page = _make_mock_page()
|
|
||||||
browser = _make_mock_browser(page=page)
|
|
||||||
ctx = _make_mock_camoufox_ctx(browser)
|
|
||||||
|
|
||||||
ms = _mock_settings(recycle_pages=recycle_after, wait_ms=0)
|
ms = _mock_settings(recycle_pages=recycle_after, wait_ms=0)
|
||||||
|
|
||||||
launch_count = 0
|
reconnect_count = 0
|
||||||
original_launch = BrowserFetcher._launch
|
original_reconnect = BrowserFetcher._reconnect
|
||||||
|
|
||||||
async def counting_launch(self: BrowserFetcher) -> None:
|
async def counting_reconnect(self: BrowserFetcher) -> None:
|
||||||
nonlocal launch_count
|
nonlocal reconnect_count
|
||||||
launch_count += 1
|
reconnect_count += 1
|
||||||
await original_launch(self)
|
await original_reconnect(self)
|
||||||
|
|
||||||
with patch("app.core.config.settings", ms):
|
with patch("app.core.config.settings", ms):
|
||||||
with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx):
|
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
|
||||||
with patch.object(BrowserFetcher, "_launch", counting_launch):
|
with patch.object(BrowserFetcher, "_reconnect", counting_reconnect):
|
||||||
fetcher = BrowserFetcher()
|
fetcher = BrowserFetcher()
|
||||||
async with fetcher:
|
async with fetcher:
|
||||||
# Первый запуск через __aenter__
|
assert reconnect_count == 0
|
||||||
assert launch_count == 1
|
|
||||||
# Отправляем recycle_after страниц — на последней сработает relaunch
|
|
||||||
for _ in range(recycle_after):
|
for _ in range(recycle_after):
|
||||||
await fetcher.fetch("https://example.com")
|
await fetcher.fetch("https://example.com")
|
||||||
# После recycle браузер должен был перезапуститься ещё раз
|
# Третья страница триггерит recycle
|
||||||
assert launch_count == 2
|
assert reconnect_count == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_page_counter_resets_after_relaunch():
|
async def test_page_counter_resets_after_reconnect():
|
||||||
"""После recycle page_count сбрасывается в 0."""
|
"""После recycle _page_count сбрасывается в 0."""
|
||||||
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
||||||
|
|
||||||
recycle_after = 2
|
recycle_after = 2
|
||||||
|
pw_cm, _pw, _browser = _make_playwright_stack()
|
||||||
page = _make_mock_page()
|
|
||||||
browser = _make_mock_browser(page=page)
|
|
||||||
ctx = _make_mock_camoufox_ctx(browser)
|
|
||||||
|
|
||||||
ms = _mock_settings(recycle_pages=recycle_after, wait_ms=0)
|
ms = _mock_settings(recycle_pages=recycle_after, wait_ms=0)
|
||||||
|
|
||||||
with patch("app.core.config.settings", ms):
|
with patch("app.core.config.settings", ms):
|
||||||
with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx):
|
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
|
||||||
fetcher = BrowserFetcher()
|
fetcher = BrowserFetcher()
|
||||||
async with fetcher:
|
async with fetcher:
|
||||||
for _ in range(recycle_after):
|
for _ in range(recycle_after):
|
||||||
|
|
@ -237,19 +193,20 @@ _FakeTargetClosedError.__qualname__ = "TargetClosedError"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fetch_retries_once_on_target_closed_error():
|
async def test_fetch_retries_once_on_target_closed_error():
|
||||||
"""fetch() при TargetClosedError перезапускает браузер и повторяет запрос."""
|
"""fetch() при TargetClosedError переподключается и повторяет запрос."""
|
||||||
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
||||||
|
|
||||||
html = "<html>retry-ok</html>"
|
html = "<html>retry-ok</html>"
|
||||||
|
|
||||||
# Первая страница бросает краш, вторая отвечает нормально
|
# Первая страница кидает краш, вторая отвечает нормально
|
||||||
crash_page = MagicMock()
|
crash_page = MagicMock()
|
||||||
crash_page.goto = AsyncMock(side_effect=_FakeTargetClosedError("target closed"))
|
crash_page.goto = AsyncMock(side_effect=_FakeTargetClosedError("target closed"))
|
||||||
crash_page.close = AsyncMock()
|
crash_page.close = AsyncMock()
|
||||||
|
|
||||||
ok_page = _make_mock_page(html=html)
|
ok_page = _make_mock_page(html=html)
|
||||||
browser = _make_mock_browser()
|
|
||||||
|
|
||||||
|
browser_mock = MagicMock()
|
||||||
|
browser_mock.close = AsyncMock()
|
||||||
call_num = 0
|
call_num = 0
|
||||||
|
|
||||||
async def new_page_side_effect() -> MagicMock:
|
async def new_page_side_effect() -> MagicMock:
|
||||||
|
|
@ -257,20 +214,33 @@ async def test_fetch_retries_once_on_target_closed_error():
|
||||||
call_num += 1
|
call_num += 1
|
||||||
return crash_page if call_num == 1 else ok_page
|
return crash_page if call_num == 1 else ok_page
|
||||||
|
|
||||||
browser.new_page = AsyncMock(side_effect=new_page_side_effect)
|
browser_mock.new_page = AsyncMock(side_effect=new_page_side_effect)
|
||||||
ctx = _make_mock_camoufox_ctx(browser)
|
|
||||||
|
firefox_mock = MagicMock()
|
||||||
|
firefox_mock.connect = AsyncMock(return_value=browser_mock)
|
||||||
|
|
||||||
|
pw_mock = MagicMock()
|
||||||
|
pw_mock.firefox = firefox_mock
|
||||||
|
pw_mock.stop = AsyncMock()
|
||||||
|
|
||||||
|
async def _start() -> MagicMock:
|
||||||
|
return pw_mock
|
||||||
|
|
||||||
|
pw_cm = MagicMock()
|
||||||
|
pw_cm.start = _start
|
||||||
|
|
||||||
ms = _mock_settings(wait_ms=0)
|
ms = _mock_settings(wait_ms=0)
|
||||||
|
|
||||||
with patch("app.core.config.settings", ms):
|
with patch("app.core.config.settings", ms):
|
||||||
with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx):
|
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
|
||||||
fetcher = BrowserFetcher()
|
fetcher = BrowserFetcher()
|
||||||
async with fetcher:
|
async with fetcher:
|
||||||
result = await fetcher.fetch("https://avito.ru/page")
|
result = await fetcher.fetch("https://avito.ru/page")
|
||||||
|
|
||||||
assert result == html
|
assert result == html
|
||||||
# 2 обращения к new_page: первое (crash) + второе (retry после relaunch)
|
|
||||||
assert call_num == 2
|
assert call_num == 2
|
||||||
|
# connect вызывался дважды: при __aenter__ + при reconnect
|
||||||
|
assert firefox_mock.connect.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -282,13 +252,11 @@ async def test_fetch_propagates_non_crash_exception():
|
||||||
page.goto = AsyncMock(side_effect=ValueError("unexpected parse error"))
|
page.goto = AsyncMock(side_effect=ValueError("unexpected parse error"))
|
||||||
page.close = AsyncMock()
|
page.close = AsyncMock()
|
||||||
|
|
||||||
browser = _make_mock_browser(page=page)
|
pw_cm, _pw, _browser = _make_playwright_stack(page=page)
|
||||||
ctx = _make_mock_camoufox_ctx(browser)
|
|
||||||
|
|
||||||
ms = _mock_settings(wait_ms=0)
|
ms = _mock_settings(wait_ms=0)
|
||||||
|
|
||||||
with patch("app.core.config.settings", ms):
|
with patch("app.core.config.settings", ms):
|
||||||
with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx):
|
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
|
||||||
fetcher = BrowserFetcher()
|
fetcher = BrowserFetcher()
|
||||||
async with fetcher:
|
async with fetcher:
|
||||||
with pytest.raises(ValueError, match="unexpected parse error"):
|
with pytest.raises(ValueError, match="unexpected parse error"):
|
||||||
|
|
|
||||||
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)
|
# - Frontend строится с basePath=/trade-in (см. next.config.ts)
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
browser:
|
||||||
|
image: ghcr.io/lekss361/gendesign-tradein-browser:${IMAGE_TAG:-latest}
|
||||||
|
container_name: tradein-browser
|
||||||
|
mem_limit: 2.5g # headless Firefox (camoufox) потребляет до ~1.5GB (#884/#905)
|
||||||
|
env_file:
|
||||||
|
- path: ./backend/.env.runtime
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
BROWSER_PORT: "3000"
|
||||||
|
# SCRAPER_PROXY_URL читается из .env.runtime (см. env_file выше)
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- tradein-net
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgis/postgis:16-3.4
|
image: postgis/postgis:16-3.4
|
||||||
container_name: tradein-postgres
|
container_name: tradein-postgres
|
||||||
|
|
@ -36,7 +52,6 @@ services:
|
||||||
backend:
|
backend:
|
||||||
image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest}
|
image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest}
|
||||||
container_name: tradein-backend
|
container_name: tradein-backend
|
||||||
mem_limit: 2.5g # headless Firefox (camoufox) потребляет до ~1.5GB (#884)
|
|
||||||
# Prod: uvicorn БЕЗ --reload (Dockerfile CMD несёт --reload только для dev hot-reload,
|
# Prod: uvicorn БЕЗ --reload (Dockerfile CMD несёт --reload только для dev hot-reload,
|
||||||
# где app/ bind-mount'ится). В prod --reload = лишний WatchFiles-наблюдатель + риск
|
# где app/ bind-mount'ится). В prod --reload = лишний WatchFiles-наблюдатель + риск
|
||||||
# reload в середине запроса. Single worker ОБЯЗАТЕЛЕН: in-app планировщик
|
# reload в середине запроса. Single worker ОБЯЗАТЕЛЕН: in-app планировщик
|
||||||
|
|
@ -67,6 +82,8 @@ services:
|
||||||
# cookies upload endpoint returns 503. Generate with `openssl rand -hex 32`.
|
# cookies upload endpoint returns 503. Generate with `openssl rand -hex 32`.
|
||||||
COOKIE_ENCRYPTION_KEY: "${COOKIE_ENCRYPTION_KEY:-}"
|
COOKIE_ENCRYPTION_KEY: "${COOKIE_ENCRYPTION_KEY:-}"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
browser:
|
||||||
|
condition: service_started
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue