feat(tradein): BrowserFetcher camoufox wrapper + Docker/config infra (Phase 0, #884) #892
6 changed files with 542 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
@ -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/app /app/app
|
||||||
COPY --from=builder --chown=app:app /app/scripts /app/scripts
|
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
|
USER app
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"""Минимальный settings для standalone trade-in MVP."""
|
"""Минимальный settings для standalone trade-in MVP."""
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
@ -203,5 +205,19 @@ 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 ─────────────────
|
||||||
|
# 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()
|
settings = Settings()
|
||||||
|
|
|
||||||
219
tradein-mvp/backend/app/services/scrapers/browser_fetcher.py
Normal file
219
tradein-mvp/backend/app/services/scrapers/browser_fetcher.py
Normal 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
|
||||||
|
|
@ -23,6 +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)
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|
|
||||||
295
tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py
Normal file
295
tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py
Normal 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")
|
||||||
|
|
@ -36,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 планировщик
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue