diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py
index 97f691c4..1eef67ff 100644
--- a/tradein-mvp/backend/app/core/config.py
+++ b/tradein-mvp/backend/app/core/config.py
@@ -207,21 +207,24 @@ class Settings(BaseSettings):
# ENV: YANDEX_COOKIES_FILE.
yandex_cookies_file: str | None = None
- # ── #884/#905: BrowserFetcher — connect-mode к tradein-browser контейнеру ──
+ # ── #884/#905: BrowserFetcher — HTTP-клиент к tradein-browser контейнеру ────
# scraper_fetch_mode: "curl_cffi" (дефолт, текущее поведение) или "browser"
- # (playwright.firefox.connect к tradein-browser WS-серверу). Phase 1+.
+ # (HTTP POST к tradein-browser /fetch эндпоинту). Phase 1+.
# Пока дефолт "curl_cffi" — никаких изменений в рантайме.
# ENV: SCRAPER_FETCH_MODE.
scraper_fetch_mode: Literal["curl_cffi", "browser"] = "curl_cffi"
- # WS-эндпоинт tradein-browser сервиса. В Docker-сети — имя сервиса из compose.
- # ENV: BROWSER_WS_ENDPOINT.
- browser_ws_endpoint: str = "ws://tradein-browser:3000/playwright"
- # Сколько страниц обработать в одном browser-сеансе перед переподключением
- # (ограничение утечек памяти на стороне браузер-контейнера). ENV: BROWSER_RECYCLE_PAGES.
+ # HTTP-эндпоинт tradein-browser сервиса. В Docker-сети — имя сервиса из compose.
+ # ENV: BROWSER_HTTP_ENDPOINT.
+ browser_http_endpoint: str = "http://tradein-browser:3000"
+ # Сколько страниц обработать в одном browser-сеансе перед перезапуском браузера
+ # (ограничение утечек памяти). Читается сервером из env BROWSER_RECYCLE_PAGES.
+ # Оставлено для справки / compat. ENV: BROWSER_RECYCLE_PAGES.
browser_recycle_pages: int = 15
- # Таймаут навигации (page.goto) в мс. ENV: BROWSER_NAV_TIMEOUT_MS.
+ # Таймаут навигации (page.goto) в мс. Читается сервером из env BROWSER_NAV_TIMEOUT_MS.
+ # ENV: BROWSER_NAV_TIMEOUT_MS.
browser_nav_timeout_ms: int = 60000
- # Ожидание после DOMContentLoaded для JS-гидрации в мс. ENV: BROWSER_WAIT_MS.
+ # Ожидание после DOMContentLoaded для JS-гидрации в мс. Читается сервером.
+ # ENV: BROWSER_WAIT_MS.
browser_wait_ms: int = 2500
diff --git a/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py b/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py
index f25eef05..730673db 100644
--- a/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py
+++ b/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py
@@ -1,33 +1,38 @@
-"""browser_fetcher.py — Playwright connect-mode клиент для browser-mode scraping (#884/#905).
+"""browser_fetcher.py — HTTP-клиент к tradein-browser сервису (#884/#905).
-Провайдер-агностичная основа: не затрагивает существующую avito/cian-логику.
-Активируется только когда settings.scraper_fetch_mode == "browser" (дефолт
-"curl_cffi" — поведение идентично старому до явного переключения).
+Тонкий клиент над tradein-browser контейнером, который запускает AsyncCamoufox
+локально и экспонирует HTTP API (POST /fetch).
-Архитектура (Phase 0.5, #905):
-- Camoufox Firefox запущен в отдельном контейнере ``tradein-browser`` как
- Playwright WS-сервер (camoufox.server.launch_server).
-- Этот модуль подключается к нему через ``playwright.async_api.firefox.connect``
- используя эндпоинт из settings.browser_ws_endpoint.
-- Proxy/fingerprint/locale/geoip настраиваются на стороне browser-сервера;
- этот модуль управляет только жизненным циклом соединения и страниц.
+Публичный интерфейс не изменился:
-Особенности:
-- Ленивые импорты playwright: не ломают app-импорт если не установлен.
-- Page recycle: каждые settings.browser_recycle_pages страниц закрываем и
- переподключаемся (ограничение утечек памяти на стороне браузер-контейнера).
-- Crash-recovery: TargetClosedError / connection-closed → переподключение → один retry.
+ async with BrowserFetcher() as fetcher:
+ html = await fetcher.fetch("https://example.com")
+
+Внутреннее устройство: httpx.AsyncClient + POST к settings.browser_http_endpoint.
+Recycle, crash-recovery и управление браузером живут на стороне сервера (server.py).
+При HTTPError / ConnectError делает одну повторную попытку после короткой паузы,
+затем пробрасывает исключение.
+
+Архитектура выбрана потому, что playwright WS-сервер (launch_server) несовместим
+с playwright >=1.45: ``browserServerImpl.js`` отсутствует → MODULE_NOT_FOUND.
+Локальный AsyncCamoufox + HTTP — работающая альтернатива.
"""
from __future__ import annotations
+import asyncio
import logging
+import httpx
+
logger = logging.getLogger(__name__)
+_RETRY_SLEEP_S: float = 1.0
+_HTTP_TIMEOUT_S: float = 120.0 # навигация медленная → щедрый таймаут
+
class BrowserFetcher:
- """Async context manager: Playwright-клиент подключения к tradein-browser WS-серверу.
+ """Async context manager: HTTP-клиент к tradein-browser HTTP-сервису.
Использование::
@@ -36,164 +41,64 @@ class BrowserFetcher:
"""
def __init__(self) -> None:
- self._browser: object | None = None
- self._pw: 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
- self._ws_endpoint: str | None = None
+ self._client: httpx.AsyncClient | None = None
+ self._endpoint: str | 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
- self._ws_endpoint = settings.browser_ws_endpoint
- await self._connect()
+ self._endpoint = settings.browser_http_endpoint
+ self._client = httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S)
+ logger.info("BrowserFetcher: клиент создан, endpoint=%s", self._endpoint)
return self
async def __aexit__(self, *_: object) -> None:
- await self._close()
+ if self._client is not None:
+ await self._client.aclose()
+ self._client = None
+ logger.debug("BrowserFetcher: клиент закрыт")
# ── public API ─────────────────────────────────────────────────────────────
async def fetch(self, url: str) -> str:
- """Открывает новую страницу, переходит по URL, ждёт JS, возвращает HTML.
+ """Запрашивает HTML страницы через tradein-browser HTTP-сервис.
- При TargetClosedError или connection-closed (crash/перезапуск браузер-контейнера)
- переподключается и делает одну повторную попытку. Остальные исключения
- всплывают к вызывающему коду.
+ При HTTPError или ConnectError делает одну повторную попытку после
+ короткой паузы. Остальные исключения всплывают к вызывающему коду.
Returns:
- Полный HTML-контент страницы (innerHTML documentElement).
+ Полный HTML-контент страницы.
"""
+ assert self._client is not None, "BrowserFetcher: используй как async context manager"
+ assert self._endpoint is not None, "BrowserFetcher: endpoint не задан"
+
try:
- return await self._fetch_once(url)
- except Exception as exc:
- if _is_target_closed(exc) or _is_crash_exception(exc):
- logger.warning(
- "BrowserFetcher: соединение с браузером потеряно (%s),"
- " переподключение + retry: %s",
- type(exc).__name__,
- url,
- )
- await self._reconnect()
- return await self._fetch_once(url)
- raise
+ return await self._post_fetch(url)
+ except (httpx.HTTPError, httpx.TransportError) as exc:
+ logger.warning(
+ "BrowserFetcher: ошибка запроса (%s), retry через %.1fs: %s",
+ type(exc).__name__,
+ _RETRY_SLEEP_S,
+ url,
+ )
+ await asyncio.sleep(_RETRY_SLEEP_S)
+ return await self._post_fetch(url)
# ── internal ───────────────────────────────────────────────────────────────
- async def _fetch_once(self, url: str) -> str:
- """Одна попытка: new_page → goto → wait → content → close."""
- assert self._browser is not None, "BrowserFetcher: нет активного соединения"
+ async def _post_fetch(self, url: str) -> str:
+ """Один HTTP POST к /fetch эндпоинту сервиса."""
+ assert self._client is not None
+ assert self._endpoint is not None
- 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")
- 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_connect=%d", url, self._page_count
+ resp = await self._client.post(
+ f"{self._endpoint}/fetch",
+ json={"url": url},
)
-
- # 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._reconnect()
-
+ resp.raise_for_status()
+ data: dict[str, str] = resp.json()
+ html = data["html"]
+ logger.debug("BrowserFetcher: fetch OK url=%r html_len=%d", url, len(html))
return html
-
- async def _connect(self) -> None:
- """Запускает Playwright и подключается к WS-серверу tradein-browser."""
- try:
- from playwright.async_api import async_playwright
- except ImportError as exc:
- raise RuntimeError(
- "playwright не установлен. Добавь 'playwright>=1.45' в pyproject.toml."
- ) from exc
-
- assert self._ws_endpoint, "BrowserFetcher: browser_ws_endpoint не задан"
-
- self._pw = await async_playwright().start()
- pw = self._pw # type: ignore[assignment]
- self._browser = await pw.firefox.connect( # type: ignore[attr-defined]
- self._ws_endpoint,
- timeout=30000, # 30s на установку соединения
- )
- self._page_count = 0
- logger.info(
- "BrowserFetcher: подключился к %s",
- self._ws_endpoint,
- )
-
- async def _close(self) -> None:
- """Закрывает соединение с браузером и останавливает Playwright."""
- browser = self._browser
- if browser is not None:
- try:
- await browser.close() # type: ignore[attr-defined]
- logger.info("BrowserFetcher: соединение закрыто")
- except Exception as exc:
- logger.warning("BrowserFetcher: ошибка при закрытии соединения: %s", exc)
- finally:
- self._browser = None
-
- pw = self._pw
- if pw is not None:
- try:
- await pw.stop() # type: ignore[attr-defined]
- except Exception as exc:
- logger.warning("BrowserFetcher: ошибка при остановке Playwright: %s", exc)
- finally:
- self._pw = None
-
- self._page_count = 0
-
- async def _reconnect(self) -> None:
- """Закрывает текущее соединение и устанавливает новое."""
- await self._close()
- await self._connect()
-
-
-# ── helpers ────────────────────────────────────────────────────────────────────
-
-
-def _is_target_closed(exc: BaseException) -> bool:
- """Проверяет является ли исключение playwright TargetClosedError.
-
- Проверка по имени класса (не import) — playwright может не быть в dev-окружении.
- """
- return type(exc).__name__ == "TargetClosedError"
-
-
-def _is_crash_exception(exc: BaseException) -> bool:
- """Проверяет является ли исключение признаком краша / разрыва соединения.
-
- Ловим широкий набор playwright crash/disconnect сигналов по имени/сообщению.
- """
- 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
- or "connection closed" in msg
- or ("websocket" in msg and "closed" in msg)
- )
diff --git a/tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py b/tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py
index 0036a3ca..749abeef 100644
--- a/tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py
+++ b/tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py
@@ -1,12 +1,13 @@
-"""Tests for BrowserFetcher (#884/#905, connect-mode).
+"""Tests for BrowserFetcher (#884/#905, HTTP-клиент).
-Полностью замоканы: не требуют установленного Firefox, camoufox или реального
-WS-сервера. Покрывают:
- - fetch(): new_page → goto → wait_for_timeout → content → close
- - recycle: счётчик страниц → reconnect при достижении порога
- - crash-recovery: TargetClosedError → reconnect → retry
- - page counter сбрасывается после reconnect
- - non-crash exception пробрасывается без перехвата
+Полностью замоканы через monkeypatch на httpx.AsyncClient.post:
+не требуют установленного Firefox, camoufox или реального HTTP-сервера.
+
+Покрывают:
+ - fetch(): POST к правильному endpoint + разбор {"html": "..."} из ответа
+ - HTTPError триггерит один retry, затем поднимает исключение
+ - URL строится из settings.browser_http_endpoint
+ - импорт модуля не ломается без playwright/camoufox
"""
from __future__ import annotations
@@ -15,6 +16,7 @@ import os
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
+import httpx
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
@@ -24,240 +26,181 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
def _mock_settings(
- proxy_url: str | None = None,
- recycle_pages: int = 15,
- nav_timeout_ms: int = 60000,
- wait_ms: int = 2500,
- ws_endpoint: str = "ws://fake-browser:3000/playwright",
+ browser_http_endpoint: str = "http://fake-browser:3000",
) -> 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,
- browser_ws_endpoint=ws_endpoint,
+ return SimpleNamespace(browser_http_endpoint=browser_http_endpoint)
+
+
+def _make_ok_response(html: str = "ok") -> MagicMock:
+ """Создаёт мок httpx.Response с JSON {"html": html} и статусом 200."""
+ resp = MagicMock(spec=httpx.Response)
+ resp.status_code = 200
+ resp.json = MagicMock(return_value={"html": html})
+ resp.raise_for_status = MagicMock() # нет исключения
+ return resp
+
+
+def _make_error_response(status: int = 500) -> MagicMock:
+ """Создаёт мок httpx.Response который кидает HTTPStatusError при raise_for_status."""
+ resp = MagicMock(spec=httpx.Response)
+ resp.status_code = status
+ resp.raise_for_status = MagicMock(
+ side_effect=httpx.HTTPStatusError(
+ f"Server error {status}",
+ request=MagicMock(),
+ response=resp,
+ )
)
+ return resp
-def _make_mock_page(html: str = "ok") -> 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_playwright_stack(
- page: MagicMock | None = None,
- html: str = "ok",
-) -> tuple[MagicMock, MagicMock, MagicMock]:
- """Создаёт полную mock-цепочку playwright: pw_cm → pw → browser → page.
-
- Returns:
- (pw_cm, pw_mock, browser_mock)
- """
- if page is None:
- page = _make_mock_page(html=html)
-
- browser_mock = MagicMock()
- browser_mock.new_page = AsyncMock(return_value=page)
- browser_mock.close = AsyncMock()
-
- 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
-
- return pw_cm, pw_mock, browser_mock
+def test_import_browser_fetcher() -> None:
+ """import app.services.scrapers.browser_fetcher не поднимает исключений."""
+ import app.services.scrapers.browser_fetcher # noqa: F401
-# ── fetch(): new_page → goto → content → close ────────────────────────────────
+# ── fetch(): возвращает HTML из JSON-ответа ────────────────────────────────────
@pytest.mark.asyncio
-async def test_fetch_calls_page_lifecycle():
- """fetch() вызывает new_page, goto, wait_for_timeout, content, close."""
+async def test_fetch_returns_html_from_json_response() -> None:
+ """fetch() разбирает {'html': ...} из 200-ответа и возвращает строку."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
- expected_html = "
test"
- page = _make_mock_page(html=expected_html)
- pw_cm, _pw, browser = _make_playwright_stack(page=page)
- ms = _mock_settings(recycle_pages=15, nav_timeout_ms=60000, wait_ms=2500)
-
- with patch("app.core.config.settings", ms):
- with patch("playwright.async_api.async_playwright", return_value=pw_cm):
- 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 = "Avito"
- pw_cm, _pw, _browser = _make_playwright_stack(html=html)
+ expected_html = "avito"
+ ok_resp = _make_ok_response(html=expected_html)
ms = _mock_settings()
with patch("app.core.config.settings", ms):
- with patch("playwright.async_api.async_playwright", return_value=pw_cm):
- fetcher = BrowserFetcher()
- async with fetcher:
- result = await fetcher.fetch("https://avito.ru/test")
+ fetcher = BrowserFetcher()
+ async with fetcher:
+ assert fetcher._client is not None
+ fetcher._client.post = AsyncMock(return_value=ok_resp) # type: ignore[method-assign]
+ result = await fetcher.fetch("https://avito.ru/test")
- assert result == html
+ assert result == expected_html
assert isinstance(result, str)
-# ── recycle: reconnect at threshold ───────────────────────────────────────────
-
-
@pytest.mark.asyncio
-async def test_recycle_triggers_reconnect_at_threshold():
- """После recycle_pages страниц выполняется переподключение (_reconnect)."""
+async def test_fetch_posts_to_correct_endpoint() -> None:
+ """fetch() делает POST к {browser_http_endpoint}/fetch с {"url": ...}."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
- recycle_after = 3
- pw_cm, _pw, _browser = _make_playwright_stack()
- ms = _mock_settings(recycle_pages=recycle_after, wait_ms=0)
-
- reconnect_count = 0
- original_reconnect = BrowserFetcher._reconnect
-
- async def counting_reconnect(self: BrowserFetcher) -> None:
- nonlocal reconnect_count
- reconnect_count += 1
- await original_reconnect(self)
+ endpoint = "http://fake-browser:3000"
+ ok_resp = _make_ok_response()
+ ms = _mock_settings(browser_http_endpoint=endpoint)
with patch("app.core.config.settings", ms):
- with patch("playwright.async_api.async_playwright", return_value=pw_cm):
- with patch.object(BrowserFetcher, "_reconnect", counting_reconnect):
- fetcher = BrowserFetcher()
- async with fetcher:
- assert reconnect_count == 0
- for _ in range(recycle_after):
- await fetcher.fetch("https://example.com")
- # Третья страница триггерит recycle
- assert reconnect_count == 1
+ fetcher = BrowserFetcher()
+ async with fetcher:
+ assert fetcher._client is not None
+ post_mock = AsyncMock(return_value=ok_resp)
+ fetcher._client.post = post_mock # type: ignore[method-assign]
+ await fetcher.fetch("https://example.com/page")
+
+ post_mock.assert_called_once_with(
+ f"{endpoint}/fetch",
+ json={"url": "https://example.com/page"},
+ )
+
+
+# ── retry при HTTPError ────────────────────────────────────────────────────────
@pytest.mark.asyncio
-async def test_page_counter_resets_after_reconnect():
- """После recycle _page_count сбрасывается в 0."""
- from app.services.scrapers.browser_fetcher import BrowserFetcher
-
- recycle_after = 2
- pw_cm, _pw, _browser = _make_playwright_stack()
- ms = _mock_settings(recycle_pages=recycle_after, wait_ms=0)
-
- with patch("app.core.config.settings", ms):
- with patch("playwright.async_api.async_playwright", return_value=pw_cm):
- 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 переподключается и повторяет запрос."""
+async def test_fetch_retries_once_on_http_error() -> None:
+ """При HTTPError первый вызов → retry → успешный второй вызов."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
html = "retry-ok"
-
- # Первая страница кидает краш, вторая отвечает нормально
- crash_page = MagicMock()
- crash_page.goto = AsyncMock(side_effect=_FakeTargetClosedError("target closed"))
- crash_page.close = AsyncMock()
-
- ok_page = _make_mock_page(html=html)
-
- browser_mock = MagicMock()
- browser_mock.close = AsyncMock()
- 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_mock.new_page = AsyncMock(side_effect=new_page_side_effect)
-
- 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)
+ ok_resp = _make_ok_response(html=html)
+ error_resp = _make_error_response(500)
+ ms = _mock_settings()
with patch("app.core.config.settings", ms):
- with patch("playwright.async_api.async_playwright", return_value=pw_cm):
- fetcher = BrowserFetcher()
- async with fetcher:
- result = await fetcher.fetch("https://avito.ru/page")
+ fetcher = BrowserFetcher()
+ async with fetcher:
+ assert fetcher._client is not None
+ # Первый вызов кидает HTTPStatusError, второй возвращает OK
+ post_mock = AsyncMock(side_effect=[error_resp, ok_resp])
+ fetcher._client.post = post_mock # type: ignore[method-assign]
+
+ with patch("app.services.scrapers.browser_fetcher.asyncio.sleep", AsyncMock()):
+ result = await fetcher.fetch("https://avito.ru/retry")
assert result == html
- assert call_num == 2
- # connect вызывался дважды: при __aenter__ + при reconnect
- assert firefox_mock.connect.await_count == 2
+ assert post_mock.await_count == 2
@pytest.mark.asyncio
-async def test_fetch_propagates_non_crash_exception():
- """fetch() не подавляет обычные (не-crash) исключения."""
+async def test_fetch_raises_after_two_http_errors() -> None:
+ """Если оба вызова кидают HTTPError — исключение пробрасывается."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
- page = MagicMock()
- page.goto = AsyncMock(side_effect=ValueError("unexpected parse error"))
- page.close = AsyncMock()
-
- pw_cm, _pw, _browser = _make_playwright_stack(page=page)
- ms = _mock_settings(wait_ms=0)
+ error_resp_1 = _make_error_response(500)
+ error_resp_2 = _make_error_response(500)
+ ms = _mock_settings()
with patch("app.core.config.settings", ms):
- with patch("playwright.async_api.async_playwright", return_value=pw_cm):
- fetcher = BrowserFetcher()
- async with fetcher:
- with pytest.raises(ValueError, match="unexpected parse error"):
- await fetcher.fetch("https://avito.ru/bad")
+ fetcher = BrowserFetcher()
+ async with fetcher:
+ assert fetcher._client is not None
+ post_mock = AsyncMock(side_effect=[error_resp_1, error_resp_2])
+ fetcher._client.post = post_mock # type: ignore[method-assign]
+
+ with patch("app.services.scrapers.browser_fetcher.asyncio.sleep", AsyncMock()):
+ with pytest.raises(httpx.HTTPStatusError):
+ await fetcher.fetch("https://avito.ru/fail")
+
+ assert post_mock.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_fetch_retries_on_connect_error() -> None:
+ """ConnectError (подкласс TransportError) тоже триггерит retry."""
+ from app.services.scrapers.browser_fetcher import BrowserFetcher
+
+ html = "connect-retry-ok"
+ ok_resp = _make_ok_response(html=html)
+ connect_err = httpx.ConnectError("Connection refused")
+ ms = _mock_settings()
+
+ with patch("app.core.config.settings", ms):
+ fetcher = BrowserFetcher()
+ async with fetcher:
+ assert fetcher._client is not None
+ post_mock = AsyncMock(side_effect=[connect_err, ok_resp])
+ fetcher._client.post = post_mock # type: ignore[method-assign]
+
+ with patch("app.services.scrapers.browser_fetcher.asyncio.sleep", AsyncMock()):
+ result = await fetcher.fetch("https://avito.ru/connect")
+
+ assert result == html
+ assert post_mock.await_count == 2
+
+
+# ── non-http exception пробрасывается без перехвата ───────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_fetch_propagates_unexpected_exception() -> None:
+ """Не-HTTP исключение (ValueError и т.п.) не подавляется и не вызывает retry."""
+ from app.services.scrapers.browser_fetcher import BrowserFetcher
+
+ ms = _mock_settings()
+
+ with patch("app.core.config.settings", ms):
+ fetcher = BrowserFetcher()
+ async with fetcher:
+ assert fetcher._client is not None
+ post_mock = AsyncMock(side_effect=ValueError("unexpected"))
+ fetcher._client.post = post_mock # type: ignore[method-assign]
+
+ with pytest.raises(ValueError, match="unexpected"):
+ await fetcher.fetch("https://avito.ru/bad")
+
+ assert post_mock.await_count == 1
diff --git a/tradein-mvp/browser/Dockerfile b/tradein-mvp/browser/Dockerfile
index 891e47b4..689db9f8 100644
--- a/tradein-mvp/browser/Dockerfile
+++ b/tradein-mvp/browser/Dockerfile
@@ -21,7 +21,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
WORKDIR /app
# camoufox[geoip] тянет playwright как зависимость
-RUN pip install --no-cache-dir "camoufox[geoip]"
+RUN pip install --no-cache-dir "camoufox[geoip]" aiohttp
COPY server.py ./server.py
diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py
index 40f42112..c268ec7c 100644
--- a/tradein-mvp/browser/server.py
+++ b/tradein-mvp/browser/server.py
@@ -1,28 +1,49 @@
"""server.py — tradein-browser service main process.
Этот модуль запускается как точка входа контейнера ``tradein-browser``.
-Он читает конфигурацию из переменных окружения и вызывает
-``camoufox.server.launch_server`` для экспозиции Playwright WS-эндпоинта,
-к которому подключается ``tradein-backend`` через ``playwright.firefox.connect``.
+Он запускает camoufox **локально** внутри контейнера (AsyncCamoufox) и
+экспонирует простой HTTP API на базе aiohttp:
+
+ GET /health → {"status": "ok"}
+ POST /fetch → {"url": "..."} → {"html": "..."}
+
+Такой подход выбран потому, что ``camoufox.server.launch_server`` (Playwright
+WS-сервер) несовместим с современными версиями playwright (1.45–1.60):
+``browserServerImpl.js`` отсутствует в дистрибутиве → контейнер краш-лупится
+с MODULE_NOT_FOUND / RuntimeError при запуске через ``launch_server``.
+Локальный запуск ``AsyncCamoufox`` работает стабильно — проверено.
Переменные окружения:
- BROWSER_PORT — TCP-порт WS-сервера (default: 3000)
- SCRAPER_PROXY_URL — прокси URL формата ``http://user:pass@host:port`` (опционально)
+ BROWSER_PORT — TCP-порт HTTP-сервера (default: 3000)
+ BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска (default: 15)
+ BROWSER_NAV_TIMEOUT_MS — таймаут page.goto в мс (default: 60000)
+ BROWSER_WAIT_MS — ожидание после DOMContentLoaded в мс (default: 2500)
+ SCRAPER_PROXY_URL — прокси URL формата ``http://user:pass@host:port`` (опционально)
-WS-эндпоинт доступен внутри Docker-сети как
-``ws://tradein-browser:/playwright``.
+Эндпоинт /fetch доступен из Docker-сети как ``http://tradein-browser:3000/fetch``.
"""
+import asyncio
import logging
import os
from urllib.parse import urlparse
+from aiohttp import web
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
+# ── конфигурация из env ────────────────────────────────────────────────────────
+
+BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000"))
+BROWSER_RECYCLE_PAGES: int = int(os.environ.get("BROWSER_RECYCLE_PAGES", "15"))
+BROWSER_NAV_TIMEOUT_MS: int = int(os.environ.get("BROWSER_NAV_TIMEOUT_MS", "60000"))
+BROWSER_WAIT_MS: int = int(os.environ.get("BROWSER_WAIT_MS", "2500"))
+SCRAPER_PROXY_URL: str | None = os.environ.get("SCRAPER_PROXY_URL")
+
def _parse_proxy(proxy_url: str | None) -> dict[str, str] | None:
- """Парсит proxy URL → playwright proxy dict.
+ """Парсит proxy URL → camoufox proxy dict.
Формат входного URL: ``http://user:pass@host:port`` или ``http://host:port``.
Возвращает None если proxy_url пуст.
@@ -47,36 +68,190 @@ def _parse_proxy(proxy_url: str | None) -> dict[str, str] | None:
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,
- )
+_browser: object | None = None # текущий экземпляр Browser
+_browser_cm: object | None = None # AsyncCamoufox context manager (для __aexit__)
+_page_counter: int = 0 # страниц с момента последнего (пере)запуска
+_fetch_lock: asyncio.Lock | None = None # сериализация fetch-запросов
- # Импортируем camoufox.server здесь — в контейнере Firefox уже установлен.
- from camoufox.server import launch_server # noqa: PLC0415
+async def _launch_browser() -> None:
+ """Запускает AsyncCamoufox и сохраняет browser + CM в модульных переменных."""
+ global _browser, _browser_cm, _page_counter
+
+ from camoufox.async_api import AsyncCamoufox # noqa: PLC0415
+
+ proxy = _parse_proxy(SCRAPER_PROXY_URL)
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]
+ logger.info(
+ "tradein-browser: запуск AsyncCamoufox (proxy=%s, recycle_pages=%d)",
+ proxy is not None,
+ BROWSER_RECYCLE_PAGES,
+ )
+ cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type]
+ browser = await cm.__aenter__()
+ _browser_cm = cm
+ _browser = browser
+ _page_counter = 0
+ logger.info("tradein-browser: браузер запущен")
+
+
+async def _close_browser() -> None:
+ """Закрывает текущий экземпляр браузера."""
+ global _browser, _browser_cm, _page_counter
+
+ cm = _browser_cm
+ if cm is not None:
+ try:
+ await cm.__aexit__(None, None, None) # type: ignore[attr-defined]
+ logger.info("tradein-browser: браузер закрыт")
+ except Exception as exc:
+ logger.warning("tradein-browser: ошибка при закрытии браузера: %s", exc)
+ finally:
+ _browser = None
+ _browser_cm = None
+ _page_counter = 0
+
+
+async def _relaunch_browser() -> None:
+ """Закрывает текущий браузер и запускает новый (recycle / crash-recovery)."""
+ logger.info("tradein-browser: перезапуск браузера")
+ await _close_browser()
+ await _launch_browser()
+
+
+# ── aiohttp lifecycle hooks ────────────────────────────────────────────────────
+
+
+async def _on_startup(app: web.Application) -> None:
+ global _fetch_lock
+ _fetch_lock = asyncio.Lock()
+ await _launch_browser()
+
+
+async def _on_cleanup(app: web.Application) -> None:
+ await _close_browser()
+
+
+# ── handlers ───────────────────────────────────────────────────────────────────
+
+
+async def health_handler(request: web.Request) -> web.Response:
+ return web.json_response({"status": "ok"})
+
+
+async def fetch_handler(request: web.Request) -> web.Response:
+ """POST /fetch {"url": "..."} → {"html": "..."}
+
+ Выполняет навигацию в браузере и возвращает полный HTML страницы.
+ Запросы сериализованы через lock (один за раз).
+ """
+ global _page_counter
+
+ try:
+ body = await request.json()
+ except Exception:
+ return web.json_response({"error": "invalid JSON body"}, status=400)
+
+ url: str | None = body.get("url")
+ if not url:
+ return web.json_response({"error": "missing 'url' field"}, status=400)
+
+ assert _fetch_lock is not None, "_fetch_lock not initialised"
+
+ async with _fetch_lock:
+ try:
+ html = await _do_fetch(url)
+ except Exception as exc:
+ logger.error("tradein-browser: fetch error url=%r: %s: %s", url, type(exc).__name__, exc)
+ return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
+
+ return web.json_response({"html": html})
+
+
+async def _do_fetch(url: str) -> str:
+ """Одна попытка навигации; при краше браузера — перезапускает и повторяет один раз."""
+ global _page_counter
+
+ try:
+ return await _fetch_once(url)
+ except Exception as exc:
+ if _is_browser_crash(exc):
+ logger.warning(
+ "tradein-browser: краш браузера (%s), перезапуск + retry: %s",
+ type(exc).__name__,
+ url,
+ )
+ await _relaunch_browser()
+ return await _fetch_once(url)
+ raise
+
+
+async def _fetch_once(url: str) -> str:
+ """Открывает страницу, переходит по URL, ждёт JS, возвращает HTML."""
+ global _page_counter
+
+ browser = _browser
+ assert browser is not None, "browser not launched"
+
+ page = await browser.new_page() # type: ignore[attr-defined]
+ try:
+ await page.goto(url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded") # type: ignore[attr-defined]
+ if BROWSER_WAIT_MS > 0:
+ await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
+ html: str = await page.content() # type: ignore[attr-defined]
+ finally:
+ await page.close() # type: ignore[attr-defined]
+
+ _page_counter += 1
+ logger.debug("tradein-browser: fetch OK url=%r pages_since_launch=%d", url, _page_counter)
+
+ if _page_counter >= BROWSER_RECYCLE_PAGES:
+ logger.info(
+ "tradein-browser: recycle threshold (%d) достигнут, перезапуск браузера",
+ BROWSER_RECYCLE_PAGES,
+ )
+ await _relaunch_browser()
+
+ return html
+
+
+def _is_browser_crash(exc: BaseException) -> bool:
+ """Проверяет является ли исключение признаком краша / разрыва браузера."""
+ cls_name = type(exc).__name__
+ if cls_name in ("TargetClosedError", "BrowserClosedError", "ConnectionClosedError"):
+ return True
+ msg = str(exc).lower()
+ return (
+ "browser has been closed" in msg
+ or "target closed" in msg
+ or "connection closed" in msg
+ or "browser disconnected" in msg
+ )
+
+
+# ── entrypoint ─────────────────────────────────────────────────────────────────
+
+
+def build_app() -> web.Application:
+ app = web.Application()
+ app.on_startup.append(_on_startup)
+ app.on_cleanup.append(_on_cleanup)
+ app.router.add_get("/health", health_handler)
+ app.router.add_post("/fetch", fetch_handler)
+ return app
if __name__ == "__main__":
- main()
+ logger.info("tradein-browser: старт HTTP-сервера на порту %d", BROWSER_PORT)
+ web.run_app(build_app(), host="0.0.0.0", port=BROWSER_PORT)