feat(tradein): BrowserFetcher camoufox wrapper + Docker/config infra (Phase 0, #884)
Epic #883 foundation. Provider-agnostic BrowserFetcher (camoufox headless Firefox): async ctx mgr + page-recycle + crash-recovery + proxy. camoufox/playwright lazy imports (won't break app import). scraper_fetch_mode defaults curl_cffi -> DORMANT (no parser/estimator/avito/cian change). config: 4 browser_* settings. Dockerfile: +5 Firefox libs + camoufox fetch layer. compose: mem_limit 2.5g. (uv.lock gitignored -> re-resolved at build.) 9 mocked tests pass, ruff clean. WARN: Docker build + container fetch UNVERIFIED (no local Docker); ~960MB image; merge auto-deploys to prod. MERGE-GATE post-demo + CI-build-verify. Refs #884, #883
This commit is contained in:
parent
3b38b3f004
commit
e03f4e4534
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 \
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
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)
|
||||
"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]
|
||||
|
|
|
|||
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:
|
||||
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 планировщик
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue