Merge pull request 'feat(tradein): BrowserFetcher camoufox wrapper + Docker/config infra (Phase 0, #884)' (#892) from feat/884-browserfetcher-camoufox into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 4s
Deploy Trade-In / build-backend (push) Successful in 2m45s
Deploy Trade-In / build-frontend (push) Successful in 24s
Deploy Trade-In / test (push) Successful in 33s
Deploy Trade-In / deploy (push) Successful in 1m5s

Reviewed-on: #892
This commit is contained in:
lekss361 2026-05-31 12:47:49 +00:00
commit 2890199d07
6 changed files with 542 additions and 0 deletions

View file

@ -43,6 +43,11 @@ 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
@ -51,6 +56,11 @@ COPY --from=builder --chown=app:app /app/.venv /app/.venv
COPY --from=builder --chown=app:app /app/app /app/app
COPY --from=builder --chown=app:app /app/scripts /app/scripts
# Загружаем Firefox-сборку для camoufox (#884).
# Слой кешируется: пересобирается только при изменении .venv (обновление camoufox).
# ~960MB. CI/devops-verify required — не запускать локально без намерения скачать.
RUN python -m camoufox fetch
USER app
EXPOSE 8000

View file

@ -1,5 +1,7 @@
"""Минимальный settings для standalone trade-in MVP."""
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
@ -203,5 +205,19 @@ class Settings(BaseSettings):
# ENV: YANDEX_COOKIES_FILE.
yandex_cookies_file: str | None = None
# ── #884: BrowserFetcher (camoufox) — Phase 0 foundation ─────────────────
# scraper_fetch_mode: "curl_cffi" (дефолт, текущее поведение) или "browser"
# (AsyncCamoufox headless Firefox). Переключение в "browser" — Phase 1+.
# Пока дефолт "curl_cffi" — никаких изменений в рантайме.
# ENV: SCRAPER_FETCH_MODE.
scraper_fetch_mode: Literal["curl_cffi", "browser"] = "curl_cffi"
# Сколько страниц обработать в одном browser-процессе перед перезапуском
# (ограничение утечек памяти). ENV: BROWSER_RECYCLE_PAGES.
browser_recycle_pages: int = 15
# Таймаут навигации (page.goto) в мс. ENV: BROWSER_NAV_TIMEOUT_MS.
browser_nav_timeout_ms: int = 60000
# Ожидание после DOMContentLoaded для JS-гидрации в мс. ENV: BROWSER_WAIT_MS.
browser_wait_ms: int = 2500
settings = Settings()

View file

@ -0,0 +1,219 @@
"""browser_fetcher.py — camoufox wrapper для browser-mode scraping (Phase 0, #884).
Провайдер-агностичная основа: не затрагивает существующую avito/cian-логику.
Активируется только когда settings.scraper_fetch_mode == "browser" (пока
дефолт "curl_cffi" поведение идентично старому до явного переключения).
Особенности:
- Ленивые импорты 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": ...}.
"""
from __future__ import annotations
import logging
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
class BrowserFetcher:
"""Async context manager: camoufox headless Firefox с рециклингом страниц.
Использование::
async with BrowserFetcher() as fetcher:
html = await fetcher.fetch("https://example.com")
"""
def __init__(self) -> None:
self._browser: 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
# ── lifecycle ──────────────────────────────────────────────────────────────
async def __aenter__(self) -> BrowserFetcher:
from app.core.config import settings
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()
return self
async def __aexit__(self, *_: object) -> None:
await self._close_browser()
# ── public API ─────────────────────────────────────────────────────────────
async def fetch(self, url: str) -> str:
"""Открывает новую страницу, переходит по URL, ждёт JS, возвращает HTML.
При TargetClosedError (crash) перезапускает браузер и делает одну
повторную попытку. Остальные исключения всплывают к вызывающему коду.
Returns:
Полный HTML-контент страницы (innerHTML documentElement).
"""
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",
type(exc).__name__,
url,
)
await self._relaunch()
return await self._fetch_once(url)
raise
# ── internal ───────────────────────────────────────────────────────────────
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: браузер не запущен"
# Тип 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()
finally:
await page.close()
self._page_count += 1
logger.debug("BrowserFetcher: fetch OK url=%r pages_since_launch=%d", url, self._page_count)
# Recycle: перезапускаем браузер каждые N страниц
assert self._recycle_after is not None
if self._page_count >= self._recycle_after:
logger.info(
"BrowserFetcher: recycle threshold (%d) достигнут, перезапуск браузера",
self._recycle_after,
)
await self._relaunch()
return html
async def _launch(self) -> None:
"""Запускает AsyncCamoufox браузер."""
try:
from camoufox.async_api import AsyncCamoufox
except ImportError as exc:
raise RuntimeError(
"camoufox не установлен. Добавь 'camoufox[geoip]>=0.4.0' в pyproject.toml "
"и выполни 'python -m camoufox fetch' для загрузки Firefox-сборки."
) 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
# AsyncCamoufox — async context manager; входим в него чтобы получить browser
ctx = AsyncCamoufox(**kwargs) # type: ignore[call-arg]
self._browser = await ctx.__aenter__()
self._browser_ctx = ctx # сохраняем для корректного __aexit__
self._page_count = 0
logger.info("BrowserFetcher: браузер запущен (proxy=%s)", proxy is not None)
async def _close_browser(self) -> None:
"""Закрывает браузер если запущен."""
ctx = getattr(self, "_browser_ctx", None)
if ctx is not None:
try:
await ctx.__aexit__(None, None, None)
logger.info("BrowserFetcher: браузер закрыт")
except Exception as 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()
def _proxy_dict(self) -> dict[str, str] | None:
"""Парсит settings.scraper_proxy_url → playwright proxy dict.
Формат входного 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
# ── helpers ────────────────────────────────────────────────────────────────────
def _is_target_closed(exc: BaseException) -> bool:
"""Проверяет является ли исключение playwright TargetClosedError.
Проверка по имени класса (не import) playwright не установлен обязательно.
"""
cls_name = type(exc).__name__
return cls_name == "TargetClosedError"
def _is_crash_exception(exc: BaseException) -> bool:
"""Проверяет является ли исключение признаком краша браузера.
Ловим широкий набор playwright crash-сигналов по имени/сообщению.
"""
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

View file

@ -23,6 +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)
]
[dependency-groups]

View file

@ -0,0 +1,295 @@
"""Tests for BrowserFetcher (#884, Phase 0).
Полностью замоканы: не требуют установленного Firefox или camoufox.
Покрывают:
- _proxy_dict(): парсинг proxy URL с credentials и без
- fetch(): new_page goto wait_for_timeout content close
- recycle: счётчик страниц relaunch браузера при достижении порога
- crash-recovery: TargetClosedError relaunch retry
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# ── helpers ────────────────────────────────────────────────────────────────────
def _mock_settings(
proxy_url: str | None = None,
recycle_pages: int = 15,
nav_timeout_ms: int = 60000,
wait_ms: int = 2500,
) -> 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,
)
def _make_mock_page(html: str = "<html>ok</html>") -> MagicMock:
"""Создаёт мок страницы playwright с нужными async-методами."""
page = MagicMock()
page.goto = AsyncMock()
page.wait_for_timeout = AsyncMock()
page.content = AsyncMock(return_value=html)
page.close = AsyncMock()
return page
def _make_mock_browser(page: MagicMock | None = None) -> MagicMock:
"""Создаёт мок browser-контекста camoufox."""
if page is None:
page = _make_mock_page()
browser = MagicMock()
browser.new_page = AsyncMock(return_value=page)
return browser
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
# ── _proxy_dict() ──────────────────────────────────────────────────────────────
# _proxy_dict() импортирует settings через `from app.core.config import settings`
# внутри метода, поэтому патчим app.core.config.settings (источник).
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 ────────────────────────────────
@pytest.mark.asyncio
async def test_fetch_calls_page_lifecycle():
"""fetch() вызывает new_page, goto, wait_for_timeout, content, close."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
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)
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):
fetcher = BrowserFetcher()
async with fetcher:
result = await fetcher.fetch("https://example.com")
assert result == expected_html
browser.new_page.assert_called_once()
page.goto.assert_called_once_with(
"https://example.com", timeout=60000, wait_until="domcontentloaded"
)
page.wait_for_timeout.assert_called_once_with(2500)
page.content.assert_called_once()
page.close.assert_called_once()
@pytest.mark.asyncio
async def test_fetch_returns_html_string():
"""fetch() возвращает строку HTML от page.content()."""
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)
ms = _mock_settings()
with patch("app.core.config.settings", ms):
with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx):
fetcher = BrowserFetcher()
async with fetcher:
result = await fetcher.fetch("https://avito.ru/test")
assert result == html
assert isinstance(result, str)
# ── recycle: relaunch at threshold ────────────────────────────────────────────
@pytest.mark.asyncio
async def test_recycle_triggers_relaunch_at_threshold():
"""После recycle_pages страниц браузер перезапускается (relaunch)."""
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)
ms = _mock_settings(recycle_pages=recycle_after, wait_ms=0)
launch_count = 0
original_launch = BrowserFetcher._launch
async def counting_launch(self: BrowserFetcher) -> None:
nonlocal launch_count
launch_count += 1
await original_launch(self)
with patch("app.core.config.settings", ms):
with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx):
with patch.object(BrowserFetcher, "_launch", counting_launch):
fetcher = BrowserFetcher()
async with fetcher:
# Первый запуск через __aenter__
assert launch_count == 1
# Отправляем recycle_after страниц — на последней сработает relaunch
for _ in range(recycle_after):
await fetcher.fetch("https://example.com")
# После recycle браузер должен был перезапуститься ещё раз
assert launch_count == 2
@pytest.mark.asyncio
async def test_page_counter_resets_after_relaunch():
"""После 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)
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):
fetcher = BrowserFetcher()
async with fetcher:
for _ in range(recycle_after):
await fetcher.fetch("https://example.com")
# После recycle счётчик сброшен
assert fetcher._page_count == 0
# ── crash-recovery ─────────────────────────────────────────────────────────────
class _FakeTargetClosedError(Exception):
"""Имитирует playwright TargetClosedError (проверка по имени класса)."""
_FakeTargetClosedError.__name__ = "TargetClosedError"
_FakeTargetClosedError.__qualname__ = "TargetClosedError"
@pytest.mark.asyncio
async def test_fetch_retries_once_on_target_closed_error():
"""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()
call_num = 0
async def new_page_side_effect() -> MagicMock:
nonlocal call_num
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)
ms = _mock_settings(wait_ms=0)
with patch("app.core.config.settings", ms):
with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx):
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
@pytest.mark.asyncio
async def test_fetch_propagates_non_crash_exception():
"""fetch() не подавляет обычные (не-crash) исключения."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
page = MagicMock()
page.goto = AsyncMock(side_effect=ValueError("unexpected parse error"))
page.close = AsyncMock()
browser = _make_mock_browser(page=page)
ctx = _make_mock_camoufox_ctx(browser)
ms = _mock_settings(wait_ms=0)
with patch("app.core.config.settings", ms):
with patch("camoufox.async_api.AsyncCamoufox", return_value=ctx):
fetcher = BrowserFetcher()
async with fetcher:
with pytest.raises(ValueError, match="unexpected parse error"):
await fetcher.fetch("https://avito.ru/bad")

View file

@ -36,6 +36,7 @@ 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 планировщик