feat(tradein-browser): additive POST /fetch-json + BrowserFetcher.fetch_json (#915 Stage 2) (#1915)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m25s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / build-browser (push) Successful in 2m53s
Deploy Trade-In / deploy (push) Successful in 1m30s
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m25s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / build-browser (push) Successful in 2m53s
Deploy Trade-In / deploy (push) Successful in 1m30s
This commit is contained in:
parent
e0cd378d19
commit
d479edeb3f
4 changed files with 645 additions and 3 deletions
|
|
@ -94,6 +94,38 @@ class BrowserFetcher:
|
||||||
await asyncio.sleep(_RETRY_SLEEP_S)
|
await asyncio.sleep(_RETRY_SLEEP_S)
|
||||||
return await self._post_fetch(url)
|
return await self._post_fetch(url)
|
||||||
|
|
||||||
|
async def fetch_json(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
method: str = "GET",
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
body: str | None = None,
|
||||||
|
origin: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""In-page fetch() через sidecar /fetch-json. Возвращает {"status": int, "body": str}.
|
||||||
|
|
||||||
|
body — уже сериализованная строка (caller делает json.dumps для POST).
|
||||||
|
origin — same-origin страница, на которую перейдёт камуфокс перед fetch.
|
||||||
|
|
||||||
|
При HTTPError / TransportError делает одну повторную попытку после короткой
|
||||||
|
паузы. Остальные исключения всплывают к вызывающему коду.
|
||||||
|
"""
|
||||||
|
assert self._client is not None, "BrowserFetcher: используй как async context manager"
|
||||||
|
assert self._endpoint is not None, "BrowserFetcher: endpoint не задан"
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._post_fetch_json(url, method, headers, body, origin)
|
||||||
|
except (httpx.HTTPError, httpx.TransportError) as exc:
|
||||||
|
logger.warning(
|
||||||
|
"BrowserFetcher: ошибка fetch-json запроса (%s), retry через %.1fs: %s",
|
||||||
|
type(exc).__name__,
|
||||||
|
_RETRY_SLEEP_S,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(_RETRY_SLEEP_S)
|
||||||
|
return await self._post_fetch_json(url, method, headers, body, origin)
|
||||||
|
|
||||||
async def login(
|
async def login(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -159,6 +191,39 @@ class BrowserFetcher:
|
||||||
logger.debug("BrowserFetcher: fetch OK url=%r html_len=%d", url, len(html))
|
logger.debug("BrowserFetcher: fetch OK url=%r html_len=%d", url, len(html))
|
||||||
return html
|
return html
|
||||||
|
|
||||||
|
async def _post_fetch_json(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
method: str,
|
||||||
|
headers: dict[str, str] | None,
|
||||||
|
body: str | None,
|
||||||
|
origin: str | None,
|
||||||
|
) -> dict:
|
||||||
|
"""Один HTTP POST к /fetch-json эндпоинту сервиса."""
|
||||||
|
assert self._client is not None
|
||||||
|
assert self._endpoint is not None
|
||||||
|
|
||||||
|
resp = await self._client.post(
|
||||||
|
f"{self._endpoint}/fetch-json",
|
||||||
|
json={
|
||||||
|
"url": url,
|
||||||
|
"source": self._source,
|
||||||
|
"method": method,
|
||||||
|
"headers": headers or {},
|
||||||
|
"body": body,
|
||||||
|
"origin": origin,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data: dict = resp.json()
|
||||||
|
logger.debug(
|
||||||
|
"BrowserFetcher: fetch-json OK url=%r status=%s body_len=%d",
|
||||||
|
url,
|
||||||
|
data.get("status"),
|
||||||
|
len(data.get("body") or ""),
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
async def _post_login(self, body: dict[str, object]) -> dict[str, str]:
|
async def _post_login(self, body: dict[str, object]) -> dict[str, str]:
|
||||||
"""Один HTTP POST к /login эндпоинту сервиса."""
|
"""Один HTTP POST к /login эндпоинту сервиса."""
|
||||||
assert self._client is not None
|
assert self._client is not None
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,15 @@ def _make_ok_response(html: str = "<html>ok</html>") -> MagicMock:
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ok_json_response(status: int = 200, body: str = "{}") -> MagicMock:
|
||||||
|
"""Создаёт мок httpx.Response с JSON {"status": ..., "body": ...} (для /fetch-json)."""
|
||||||
|
resp = MagicMock(spec=httpx.Response)
|
||||||
|
resp.status_code = 200
|
||||||
|
resp.json = MagicMock(return_value={"status": status, "body": body})
|
||||||
|
resp.raise_for_status = MagicMock() # нет исключения
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
def _make_error_response(status: int = 500) -> MagicMock:
|
def _make_error_response(status: int = 500) -> MagicMock:
|
||||||
"""Создаёт мок httpx.Response который кидает HTTPStatusError при raise_for_status."""
|
"""Создаёт мок httpx.Response который кидает HTTPStatusError при raise_for_status."""
|
||||||
resp = MagicMock(spec=httpx.Response)
|
resp = MagicMock(spec=httpx.Response)
|
||||||
|
|
@ -135,6 +144,139 @@ async def test_fetch_posts_source_field_when_set() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── fetch_json(): in-page fetch через /fetch-json (#915 Stage 2) ───────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_json_returns_dict_from_json_response() -> None:
|
||||||
|
"""fetch_json() возвращает {"status","body"} dict из 200-ответа /fetch-json."""
|
||||||
|
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
||||||
|
|
||||||
|
ok_resp = _make_ok_json_response(status=200, body='{"items": []}')
|
||||||
|
ms = _mock_settings()
|
||||||
|
|
||||||
|
with patch("app.core.config.settings", ms):
|
||||||
|
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_json("https://avito.ru/api/x")
|
||||||
|
|
||||||
|
assert result == {"status": 200, "body": '{"items": []}'}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_json_posts_full_payload() -> None:
|
||||||
|
"""fetch_json() POST'ит url/source/method/headers/body/origin к /fetch-json."""
|
||||||
|
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
||||||
|
|
||||||
|
endpoint = "http://fake-browser:3000"
|
||||||
|
ok_resp = _make_ok_json_response()
|
||||||
|
ms = _mock_settings(browser_http_endpoint=endpoint)
|
||||||
|
|
||||||
|
with patch("app.core.config.settings", ms):
|
||||||
|
fetcher = BrowserFetcher(source="avito")
|
||||||
|
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_json(
|
||||||
|
"https://avito.ru/api/listings",
|
||||||
|
method="POST",
|
||||||
|
headers={"content-type": "application/json"},
|
||||||
|
body='{"q": 1}',
|
||||||
|
origin="https://avito.ru/moskva/kvartiry",
|
||||||
|
)
|
||||||
|
|
||||||
|
post_mock.assert_called_once_with(
|
||||||
|
f"{endpoint}/fetch-json",
|
||||||
|
json={
|
||||||
|
"url": "https://avito.ru/api/listings",
|
||||||
|
"source": "avito",
|
||||||
|
"method": "POST",
|
||||||
|
"headers": {"content-type": "application/json"},
|
||||||
|
"body": '{"q": 1}',
|
||||||
|
"origin": "https://avito.ru/moskva/kvartiry",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_json_defaults_get_empty_headers_none_body() -> None:
|
||||||
|
"""Дефолты: method=GET, headers={}, body=None, origin=None."""
|
||||||
|
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
||||||
|
|
||||||
|
endpoint = "http://fake-browser:3000"
|
||||||
|
ok_resp = _make_ok_json_response()
|
||||||
|
ms = _mock_settings(browser_http_endpoint=endpoint)
|
||||||
|
|
||||||
|
with patch("app.core.config.settings", ms):
|
||||||
|
fetcher = BrowserFetcher(source="cian")
|
||||||
|
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_json("https://cian.ru/api/x")
|
||||||
|
|
||||||
|
post_mock.assert_called_once_with(
|
||||||
|
f"{endpoint}/fetch-json",
|
||||||
|
json={
|
||||||
|
"url": "https://cian.ru/api/x",
|
||||||
|
"source": "cian",
|
||||||
|
"method": "GET",
|
||||||
|
"headers": {},
|
||||||
|
"body": None,
|
||||||
|
"origin": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_json_retries_once_on_transport_error() -> None:
|
||||||
|
"""TransportError первый раз → один retry → успешный второй вызов."""
|
||||||
|
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
||||||
|
|
||||||
|
ok_resp = _make_ok_json_response(status=200, body="retry-ok")
|
||||||
|
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_json("https://avito.ru/api/retry")
|
||||||
|
|
||||||
|
assert result == {"status": 200, "body": "retry-ok"}
|
||||||
|
assert post_mock.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_json_raises_after_two_http_errors() -> None:
|
||||||
|
"""Оба вызова /fetch-json кидают HTTPError → исключение пробрасывается."""
|
||||||
|
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
||||||
|
|
||||||
|
error_resp_1 = _make_error_response(500)
|
||||||
|
error_resp_2 = _make_error_response(500)
|
||||||
|
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=[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_json("https://avito.ru/api/fail")
|
||||||
|
|
||||||
|
assert post_mock.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
# ── retry при HTTPError ────────────────────────────────────────────────────────
|
# ── retry при HTTPError ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,10 @@
|
||||||
Он запускает camoufox **локально** внутри контейнера (AsyncCamoufox) и
|
Он запускает camoufox **локально** внутри контейнера (AsyncCamoufox) и
|
||||||
экспонирует простой HTTP API на базе aiohttp:
|
экспонирует простой HTTP API на базе aiohttp:
|
||||||
|
|
||||||
GET /health → {"status": "ok", "browsers": {"avito": bool, ...}}
|
GET /health → {"status": "ok", "browsers": {"avito": bool, ...}}
|
||||||
POST /fetch → {"url": "..."} → {"html": "..."}
|
POST /fetch → {"url": "..."} → {"html": "..."}
|
||||||
POST /login → {"url": "...", "email": "...", "password": "...", ...} → {"cookies": [...]}
|
POST /fetch-json → {"url","method","headers","body","origin"} → {"status","body"}
|
||||||
|
POST /login → {"url": "...", "email": "...", "password": "...", ...} → {"cookies": [...]}
|
||||||
|
|
||||||
Такой подход выбран потому, что ``camoufox.server.launch_server`` (Playwright
|
Такой подход выбран потому, что ``camoufox.server.launch_server`` (Playwright
|
||||||
WS-сервер) несовместим с современными версиями playwright (1.45–1.60):
|
WS-сервер) несовместим с современными версиями playwright (1.45–1.60):
|
||||||
|
|
@ -564,6 +565,70 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
||||||
return web.json_response({"html": html})
|
return web.json_response({"html": html})
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_json_handler(request: web.Request) -> web.Response:
|
||||||
|
"""POST /fetch-json {"url","method","headers","body","origin","source"} → {"status","body"}
|
||||||
|
|
||||||
|
Выполняет in-page ``fetch()`` ИЗ ТЁПЛОЙ страницы браузера поставщика: камуфокс
|
||||||
|
сначала переходит на ``origin`` (same-origin якорь), затем дёргает ``fetch(url)``
|
||||||
|
с реальным фингерпринтом + контекст-cookies этого инстанса + per-provider прокси.
|
||||||
|
Возвращает JSON/текст ответа как ``{"status": int, "body": str}``.
|
||||||
|
|
||||||
|
Чисто аддитивный путь (#915 Stage 2) — никто из прод-флоу пока не вызывает.
|
||||||
|
Делит тот же per-provider браузер/лок что и /fetch (поставщик по host URL или
|
||||||
|
явному body["provider"]/["source"]). Берётся ТОЛЬКО лок этого поставщика →
|
||||||
|
разные поставщики работают параллельно, внутри поставщика — строго ≤1 операция.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
method: str = body.get("method") or "GET"
|
||||||
|
headers: dict = body.get("headers") or {}
|
||||||
|
req_body = body.get("body") # None | str (caller сериализует через json.dumps)
|
||||||
|
# origin — same-origin страница, на которую перейдёт камуфокс перед fetch'ем.
|
||||||
|
# Явный body["origin"] имеет приоритет; иначе выводим f"{scheme}://{host}/" из url.
|
||||||
|
origin: str | None = body.get("origin")
|
||||||
|
if not origin:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
origin = f"{parsed.scheme}://{parsed.netloc}/"
|
||||||
|
|
||||||
|
provider = _resolve_provider(body, url)
|
||||||
|
|
||||||
|
lock = await _lock_for(provider)
|
||||||
|
async with lock:
|
||||||
|
# Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал).
|
||||||
|
# Lazy-попытка, иначе 503 — без прокси/браузера не фетчим.
|
||||||
|
if not await _ensure_browser(provider):
|
||||||
|
logger.warning(
|
||||||
|
"tradein-browser[%s]: /fetch-json 503 — браузер недоступен (proxy may be down)",
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "browser unavailable (proxy may be down)"}, status=503
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await _do_fetch_json(
|
||||||
|
provider, url, method=method, headers=headers, body=req_body, origin=origin
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"tradein-browser[%s]: fetch-json error url=%r: %s: %s",
|
||||||
|
provider,
|
||||||
|
url,
|
||||||
|
type(exc).__name__,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
|
||||||
|
|
||||||
|
return web.json_response({"status": result["status"], "body": result["body"]})
|
||||||
|
|
||||||
|
|
||||||
async def _apply_resource_block(page: object) -> None:
|
async def _apply_resource_block(page: object) -> None:
|
||||||
"""Навешивает route-interception, блокирующую тяжёлые под-ресурсы (font/media).
|
"""Навешивает route-interception, блокирующую тяжёлые под-ресурсы (font/media).
|
||||||
|
|
||||||
|
|
@ -685,6 +750,107 @@ async def _fetch_once(provider: str, url: str) -> str:
|
||||||
return html
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
async def _do_fetch_json(
|
||||||
|
provider: str,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
headers: dict,
|
||||||
|
body: object,
|
||||||
|
origin: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Одна попытка in-page fetch'а; при краше браузера — relaunch и один retry.
|
||||||
|
|
||||||
|
Зеркалит _do_fetch: caller держит _locks[provider] (нет параллельных страниц на
|
||||||
|
этом инстансе), поэтому relaunch безопасен — никого не рвёт из-под живых страниц.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await _fetch_json_once(
|
||||||
|
provider, url, method=method, headers=headers, body=body, origin=origin
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if _is_browser_crash(exc):
|
||||||
|
logger.warning(
|
||||||
|
"tradein-browser[%s]: краш браузера (%s), перезапуск + retry fetch-json: %s",
|
||||||
|
provider,
|
||||||
|
type(exc).__name__,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
await _relaunch_browser(provider)
|
||||||
|
if _browsers.get(provider) is None:
|
||||||
|
raise
|
||||||
|
return await _fetch_json_once(
|
||||||
|
provider, url, method=method, headers=headers, body=body, origin=origin
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_json_once(
|
||||||
|
provider: str,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
headers: dict,
|
||||||
|
body: object,
|
||||||
|
origin: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Переходит на origin (same-origin якорь) и выполняет in-page fetch(url).
|
||||||
|
|
||||||
|
Зеркалит _fetch_once по жизненному циклу страницы. Навигация идёт на ``origin``
|
||||||
|
(а не на ``url``), чтобы in-page fetch был same-origin и нёс контекст-cookies +
|
||||||
|
реальный фингерпринт инстанса. Возвращает {"status": int, "body": str}.
|
||||||
|
|
||||||
|
Caller держит _locks[provider], поэтому страницы на этом инстансе не
|
||||||
|
параллелятся — recycle через _relaunch_browser безопасен прямо здесь.
|
||||||
|
"""
|
||||||
|
browser = _browsers.get(provider)
|
||||||
|
assert browser is not None, "browser not launched"
|
||||||
|
|
||||||
|
page = await browser.new_page() # type: ignore[attr-defined]
|
||||||
|
try:
|
||||||
|
await _apply_resource_block(page)
|
||||||
|
await _pace_provider(provider)
|
||||||
|
await page.goto(origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded") # type: ignore[attr-defined]
|
||||||
|
# БЕЗ полного BROWSER_WAIT_MS: нам нужен лишь origin-контекст (cookies +
|
||||||
|
# same-origin scope для fetch), а не отрендеренные listings. Короткой паузы
|
||||||
|
# хватает, чтобы страница инициализировалась перед in-page fetch'ем.
|
||||||
|
await page.wait_for_timeout(500) # type: ignore[attr-defined]
|
||||||
|
result: dict = await page.evaluate( # type: ignore[attr-defined]
|
||||||
|
"""async ({url, method, headers, body}) => {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: method || 'GET',
|
||||||
|
headers: headers || {},
|
||||||
|
body: (body !== null && body !== undefined) ? body : undefined,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
const text = await resp.text();
|
||||||
|
return { status: resp.status, body: text };
|
||||||
|
}""",
|
||||||
|
{"url": url, "method": method, "headers": headers or {}, "body": body},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await page.close() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
_page_counters[provider] = _page_counters.get(provider, 0) + 1
|
||||||
|
logger.debug(
|
||||||
|
"tradein-browser[%s]: fetch-json OK url=%r status=%s pages_since_launch=%d",
|
||||||
|
provider,
|
||||||
|
url,
|
||||||
|
result.get("status"),
|
||||||
|
_page_counters[provider],
|
||||||
|
)
|
||||||
|
|
||||||
|
if _page_counters[provider] >= BROWSER_RECYCLE_PAGES:
|
||||||
|
logger.info(
|
||||||
|
"tradein-browser[%s]: recycle threshold (%d) достигнут, перезапуск браузера",
|
||||||
|
provider,
|
||||||
|
BROWSER_RECYCLE_PAGES,
|
||||||
|
)
|
||||||
|
await _relaunch_browser(provider)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _is_browser_crash(exc: BaseException) -> bool:
|
def _is_browser_crash(exc: BaseException) -> bool:
|
||||||
"""Проверяет является ли исключение признаком краша / разрыва браузера."""
|
"""Проверяет является ли исключение признаком краша / разрыва браузера."""
|
||||||
cls_name = type(exc).__name__
|
cls_name = type(exc).__name__
|
||||||
|
|
@ -975,6 +1141,7 @@ def build_app() -> web.Application:
|
||||||
app.on_cleanup.append(_on_cleanup)
|
app.on_cleanup.append(_on_cleanup)
|
||||||
app.router.add_get("/health", health_handler)
|
app.router.add_get("/health", health_handler)
|
||||||
app.router.add_post("/fetch", fetch_handler)
|
app.router.add_post("/fetch", fetch_handler)
|
||||||
|
app.router.add_post("/fetch-json", fetch_json_handler)
|
||||||
app.router.add_post("/login", login_handler)
|
app.router.add_post("/login", login_handler)
|
||||||
app.router.add_get("/pacing", pacing_get_handler)
|
app.router.add_get("/pacing", pacing_get_handler)
|
||||||
app.router.add_put("/pacing", pacing_put_handler)
|
app.router.add_put("/pacing", pacing_put_handler)
|
||||||
|
|
|
||||||
268
tradein-mvp/browser/test_server_fetch_json.py
Normal file
268
tradein-mvp/browser/test_server_fetch_json.py
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
"""test_server_fetch_json.py — юниты для аддитивного /fetch-json (#915 Stage 2).
|
||||||
|
|
||||||
|
Проверяет новый in-page fetch путь, НЕ трогая существующий /fetch:
|
||||||
|
1. fetch_json_handler возвращает {"status","body"} из page.evaluate;
|
||||||
|
2. 400 при отсутствии url;
|
||||||
|
3. 503 когда _ensure_browser отдаёт False (прокси/браузер недоступны);
|
||||||
|
4. навигация идёт на origin (same-origin якорь), не на url;
|
||||||
|
5. page закрывается после in-page fetch (finally).
|
||||||
|
|
||||||
|
camoufox НЕ запускается: _ensure_browser мокается / провайдер-браузер подделан
|
||||||
|
фейковой page с AsyncMock на evaluate. Хендлер вызывается напрямую
|
||||||
|
(make_mocked_request не поднимает сокет) — как в test_server_smoke.py.
|
||||||
|
|
||||||
|
Запуск (из tradein-mvp/browser/)::
|
||||||
|
|
||||||
|
python -m pytest test_server_fetch_json.py -q
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aiohttp.test_utils import make_mocked_request
|
||||||
|
|
||||||
|
# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
|
||||||
|
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
|
||||||
|
assert _spec is not None and _spec.loader is not None
|
||||||
|
server = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(server)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_body(response: Any) -> dict[str, Any]:
|
||||||
|
"""Достаёт JSON-тело из aiohttp Response (body — bytes после json_response)."""
|
||||||
|
return json.loads(response.body.decode())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Чистое per-provider состояние + инициализированный _locks_guard на каждый тест."""
|
||||||
|
monkeypatch.setattr(server, "_browsers", {})
|
||||||
|
monkeypatch.setattr(server, "_browser_cms", {})
|
||||||
|
monkeypatch.setattr(server, "_page_counters", {})
|
||||||
|
monkeypatch.setattr(server, "_locks", {})
|
||||||
|
monkeypatch.setattr(server, "_retry_tasks", {})
|
||||||
|
monkeypatch.setattr(server, "_last_goto_at", {})
|
||||||
|
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
||||||
|
|
||||||
|
|
||||||
|
async def _coro(value: Any) -> Any:
|
||||||
|
"""Хелпер: оборачивает значение в awaitable для подмены request.json()."""
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# ── фейковые page/browser с AsyncMock на evaluate ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePage:
|
||||||
|
"""Поддельная page: фиксирует goto-цель, отдаёт фиктивный результат evaluate."""
|
||||||
|
|
||||||
|
def __init__(self, evaluate_result: dict[str, Any]) -> None:
|
||||||
|
self.goto_urls: list[str] = []
|
||||||
|
self.closed = 0
|
||||||
|
# evaluate — AsyncMock, чтобы проверять как сам результат, так и аргументы.
|
||||||
|
self.evaluate = AsyncMock(return_value=evaluate_result)
|
||||||
|
|
||||||
|
async def route(self, pattern: str, handler: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def goto(self, url: str, **kwargs: Any) -> None:
|
||||||
|
self.goto_urls.append(url)
|
||||||
|
|
||||||
|
async def wait_for_timeout(self, ms: int) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.closed += 1
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBrowser:
|
||||||
|
def __init__(self, page: _FakePage) -> None:
|
||||||
|
self._page = page
|
||||||
|
self.opened = 0
|
||||||
|
|
||||||
|
async def new_page(self) -> _FakePage:
|
||||||
|
self.opened += 1
|
||||||
|
return self._page
|
||||||
|
|
||||||
|
|
||||||
|
def _make_request(body: dict[str, Any]) -> Any:
|
||||||
|
request = make_mocked_request("POST", "/fetch-json")
|
||||||
|
request.json = lambda: _coro(body) # type: ignore[method-assign]
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
# ── happy path ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_json_returns_evaluate_result(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""fetch_json_handler возвращает {"status","body"} ровно как отдал page.evaluate."""
|
||||||
|
page = _FakePage({"status": 200, "body": '{"ok": true}'})
|
||||||
|
server._browsers["avito"] = _FakeBrowser(page)
|
||||||
|
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||||||
|
|
||||||
|
async def _ensure(provider: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||||||
|
|
||||||
|
response = asyncio.run(
|
||||||
|
server.fetch_json_handler(
|
||||||
|
_make_request({"url": "https://www.avito.ru/api/x", "source": "avito"})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert response.status == 200
|
||||||
|
body = _json_body(response)
|
||||||
|
assert body == {"status": 200, "body": '{"ok": true}'}
|
||||||
|
# page.evaluate был вызван ровно один раз с url+method+headers+body.
|
||||||
|
page.evaluate.assert_awaited_once()
|
||||||
|
call_args = page.evaluate.await_args
|
||||||
|
assert call_args.args[1]["url"] == "https://www.avito.ru/api/x"
|
||||||
|
assert call_args.args[1]["method"] == "GET"
|
||||||
|
# Страница открылась и закрылась.
|
||||||
|
assert page.closed == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_json_navigates_to_origin_not_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Навигация идёт на origin (same-origin якорь), а fetch — уже на url."""
|
||||||
|
page = _FakePage({"status": 200, "body": "ok"})
|
||||||
|
server._browsers["avito"] = _FakeBrowser(page)
|
||||||
|
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||||||
|
|
||||||
|
async def _ensure(provider: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
server.fetch_json_handler(
|
||||||
|
_make_request({"url": "https://www.avito.ru/api/listings?q=1", "source": "avito"})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Дефолтный origin выводится из url: scheme://netloc/.
|
||||||
|
assert page.goto_urls == ["https://www.avito.ru/"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_json_uses_explicit_origin(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Явный body["origin"] перебивает выведенный из url."""
|
||||||
|
page = _FakePage({"status": 200, "body": "ok"})
|
||||||
|
server._browsers["avito"] = _FakeBrowser(page)
|
||||||
|
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||||||
|
|
||||||
|
async def _ensure(provider: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
server.fetch_json_handler(
|
||||||
|
_make_request(
|
||||||
|
{
|
||||||
|
"url": "https://www.avito.ru/api/x",
|
||||||
|
"origin": "https://www.avito.ru/moskva/kvartiry",
|
||||||
|
"source": "avito",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert page.goto_urls == ["https://www.avito.ru/moskva/kvartiry"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_json_passes_post_method_and_body(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""method/headers/body прокидываются в page.evaluate без искажений."""
|
||||||
|
page = _FakePage({"status": 201, "body": "{}"})
|
||||||
|
server._browsers["avito"] = _FakeBrowser(page)
|
||||||
|
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||||||
|
|
||||||
|
async def _ensure(provider: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
server.fetch_json_handler(
|
||||||
|
_make_request(
|
||||||
|
{
|
||||||
|
"url": "https://www.avito.ru/api/x",
|
||||||
|
"method": "POST",
|
||||||
|
"headers": {"content-type": "application/json"},
|
||||||
|
"body": '{"a": 1}',
|
||||||
|
"source": "avito",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
payload = page.evaluate.await_args.args[1]
|
||||||
|
assert payload["method"] == "POST"
|
||||||
|
assert payload["headers"] == {"content-type": "application/json"}
|
||||||
|
assert payload["body"] == '{"a": 1}'
|
||||||
|
|
||||||
|
|
||||||
|
# ── валидация / resilience ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_json_400_on_missing_url() -> None:
|
||||||
|
"""Отсутствие url → 400 (без обращения к браузеру)."""
|
||||||
|
response = asyncio.run(server.fetch_json_handler(_make_request({"source": "avito"})))
|
||||||
|
assert response.status == 400
|
||||||
|
assert "missing 'url'" in _json_body(response)["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_json_400_on_invalid_json() -> None:
|
||||||
|
"""Невалидное JSON-тело → 400."""
|
||||||
|
request = make_mocked_request("POST", "/fetch-json")
|
||||||
|
|
||||||
|
async def _raise() -> Any:
|
||||||
|
raise ValueError("bad json")
|
||||||
|
|
||||||
|
request.json = _raise # type: ignore[method-assign]
|
||||||
|
response = asyncio.run(server.fetch_json_handler(request))
|
||||||
|
assert response.status == 400
|
||||||
|
assert "invalid JSON body" in _json_body(response)["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_json_503_when_browser_unavailable(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""/fetch-json отдаёт 503 (не 500/краш) когда инстанс поставщика не поднят."""
|
||||||
|
|
||||||
|
async def _no_ensure(provider: str) -> bool:
|
||||||
|
return False # прокси всё ещё недоступен → браузер остаётся None
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_ensure_browser", _no_ensure)
|
||||||
|
|
||||||
|
response = asyncio.run(
|
||||||
|
server.fetch_json_handler(
|
||||||
|
_make_request({"url": "https://www.avito.ru/api/x", "source": "avito"})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert response.status == 503
|
||||||
|
assert "browser unavailable" in _json_body(response)["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_json_500_on_evaluate_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Ошибка page.evaluate (не краш браузера) → 500 с типом исключения."""
|
||||||
|
page = _FakePage({"status": 200, "body": "ok"})
|
||||||
|
page.evaluate = AsyncMock(side_effect=RuntimeError("eval boom"))
|
||||||
|
server._browsers["avito"] = _FakeBrowser(page)
|
||||||
|
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||||||
|
|
||||||
|
async def _ensure(provider: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||||||
|
|
||||||
|
response = asyncio.run(
|
||||||
|
server.fetch_json_handler(
|
||||||
|
_make_request({"url": "https://www.avito.ru/api/x", "source": "avito"})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert response.status == 500
|
||||||
|
assert "RuntimeError" in _json_body(response)["error"]
|
||||||
|
# Даже при ошибке evaluate страница должна закрыться (finally).
|
||||||
|
assert page.closed == 1
|
||||||
Loading…
Add table
Reference in a new issue