From 1533baad9705580ebed451585bcb65f579173a2a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 18 Jun 2026 18:16:53 +0300 Subject: [PATCH] perf(browser): block image/media/font resources in camoufox fetch (~10x smaller pages, faster via proxy) --- tradein-mvp/browser/server.py | 65 +++++++ .../browser/test_server_resource_block.py | 158 ++++++++++++++++++ tradein-mvp/browser/test_server_smoke.py | 6 + 3 files changed, 229 insertions(+) create mode 100644 tradein-mvp/browser/test_server_resource_block.py diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index a71250df..8df8c923 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -21,6 +21,15 @@ WS-сервер) несовместим с современными версия BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска (default: 15) BROWSER_NAV_TIMEOUT_MS — таймаут page.goto в мс (default: 60000) BROWSER_WAIT_MS — ожидание гидрации listings после DOMContentLoaded, мс (default: 6000) + BROWSER_BLOCK_RESOURCES — блокировать тяжёлые ресурсы (картинки/медиа/шрифты) при + навигации, чтобы страница качалась быстрее через медленный + прокси (default: "true"; bandwidth — главный боттлнек). + Данные парсятся из page.content()/__initialData__, поэтому + картинки/шрифты не нужны. CSS/JS/XHR/document НЕ блокируем — + нужны для гидрации listings и не палят stealth. + BROWSER_BLOCKED_RESOURCE_TYPES — CSV типов ресурсов Playwright для блокировки + (default: "image,media,font"). Менять консервативно: + блокировка script/stylesheet/document/xhr сломает выдачу. AVITO_PROXY_URL — прокси ``http://user:pass@host:port`` (mobileproxy, #623); SCRAPER_PROXY_URL — generic-fallback. Без него — soft-block. @@ -76,6 +85,27 @@ BROWSER_NAV_TIMEOUT_MS: int = int(os.environ.get("BROWSER_NAV_TIMEOUT_MS", "6000 # на 2.5с в HTML генерик-шелл без объявлений (0 listings), на 5-6с — полная # выдача (~50 карточек, 3.2МБ). Подтверждено прод-дебагом 2026-05-31. BROWSER_WAIT_MS: int = int(os.environ.get("BROWSER_WAIT_MS", "6000")) + + +def _parse_bool(raw: str | None, default: bool) -> bool: + """Парсит булев env-флаг ("true"/"1"/"yes"/"on" → True), fallback на default.""" + if raw is None: + return default + return raw.strip().lower() in ("true", "1", "yes", "on") + + +# Блокировка тяжёлых ресурсов (картинки/медиа/шрифты) при навигации. avito SERP +# ~1.5-2 МБ, в основном медиа; нужные данные — в HTML/__initialData__ (парсятся из +# page.content()). Блокируя image/media/font, страница ~200 КБ (×10 меньше) → быстрее +# качается через медленный прокси (bandwidth — главный боттлнек). CSS/JS/XHR/document +# НЕ трогаем — нужны для гидрации listings, плюс «браузер без ресурсов» спалил бы stealth. +_BLOCK_RESOURCES: bool = _parse_bool(os.environ.get("BROWSER_BLOCK_RESOURCES"), True) +_BLOCKED_TYPES: frozenset[str] = frozenset( + t.strip().lower() + for t in os.environ.get("BROWSER_BLOCKED_RESOURCE_TYPES", "image,media,font").split(",") + if t.strip() +) + # Прокси: основной источник — AVITO_PROXY_URL (mobileproxy, #623, лежит в # backend/.env.runtime, тот же что у curl_cffi-avito). SCRAPER_PROXY_URL — # опциональный generic-fallback на будущее (cian/yandex). Без прокси camoufox @@ -498,6 +528,7 @@ async def _fetch_once_pool(url: str, proxy_url: str) -> str: page = await browser.new_page() # type: ignore[attr-defined] try: + await _apply_resource_block(page) 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] @@ -668,6 +699,39 @@ async def _fetch_via_pool(url: str, source: str | None) -> web.Response: return web.json_response({"html": html}) +async def _apply_resource_block(page: object) -> None: + """Навешивает route-interception, блокирующую тяжёлые ресурсы (image/media/font). + + No-op при BROWSER_BLOCK_RESOURCES=false. Route живёт на странице и снимается + автоматически при page.close(). Блокируем ТОЛЬКО _BLOCKED_TYPES (по умолчанию + картинки/медиа/шрифты) — document/script/stylesheet/xhr/fetch проходят, чтобы + гидрация listings работала и stealth не палился «браузером без ресурсов». + + Handler обёрнут в try/except: любой сбой abort/continue (например, route уже + обработан гонкой) деградирует в continue_(), чтобы один битый ресурс не ронял + навигацию. Сам continue_() тоже под try — если страница уже закрыта, молча выходим. + """ + if not _BLOCK_RESOURCES: + return + + async def _route_block(route: object) -> None: # type: ignore[no-untyped-def] + try: + if route.request.resource_type in _BLOCKED_TYPES: # type: ignore[attr-defined] + await route.abort() # type: ignore[attr-defined] + else: + await route.continue_() # type: ignore[attr-defined] + except Exception: + # Fallback: не блокируем спорный ресурс, пропускаем его — лучше лишний + # байт, чем сорванная навигация. continue_() под своим try на случай + # уже-закрытой страницы / повторно обработанного route. + try: + await route.continue_() # type: ignore[attr-defined] + except Exception: + pass + + await page.route("**/*", _route_block) # type: ignore[attr-defined] + + async def _do_fetch(url: str) -> str: """Одна попытка навигации; при краше браузера — перезапускает и повторяет один раз. @@ -704,6 +768,7 @@ async def _fetch_once(url: str) -> str: page = await browser.new_page() # type: ignore[attr-defined] try: + await _apply_resource_block(page) 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] diff --git a/tradein-mvp/browser/test_server_resource_block.py b/tradein-mvp/browser/test_server_resource_block.py new file mode 100644 index 00000000..3bae85b7 --- /dev/null +++ b/tradein-mvp/browser/test_server_resource_block.py @@ -0,0 +1,158 @@ +"""test_server_resource_block.py — юниты для блокировки тяжёлых ресурсов (perf). + +Проверяет: + 1. _parse_bool — разбор BROWSER_BLOCK_RESOURCES в bool; + 2. _apply_resource_block навешивает route когда флаг включён и НЕ навешивает когда + выключен (no-op); + 3. сам route-handler аборт'ит image/media/font и continue'ит document/script/xhr; + 4. сбой abort() деградирует в continue_() (fallback), не роняя навигацию. + +camoufox/Playwright НЕ поднимается: route + request мокаются заглушками. Хендлер +вызывается напрямую через захваченный _apply_resource_block-callback. + +Запуск (из tradein-mvp/browser/):: + + python -m pytest test_server_resource_block.py -q +""" + +from __future__ import annotations + +import asyncio +import importlib.util +from pathlib import Path +from typing import Any + +import pytest + +# 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) + + +# ── _parse_bool ────────────────────────────────────────────────────────────────── + + +def test_parse_bool_truthy() -> None: + for raw in ("true", "True", "1", "yes", "on", " TRUE "): + assert server._parse_bool(raw, False) is True, raw + + +def test_parse_bool_falsy() -> None: + for raw in ("false", "0", "no", "off", "garbage", ""): + assert server._parse_bool(raw, True) is False, raw + + +def test_parse_bool_none_uses_default() -> None: + assert server._parse_bool(None, True) is True + assert server._parse_bool(None, False) is False + + +def test_default_block_resources_on() -> None: + """Дефолт: блокировка включена, типы — image/media/font.""" + assert server._BLOCK_RESOURCES is True + assert server._BLOCKED_TYPES == frozenset({"image", "media", "font"}) + + +# ── route-interception заглушки ────────────────────────────────────────────────── + + +class _FakeRequest: + def __init__(self, resource_type: str) -> None: + self.resource_type = resource_type + + +class _FakeRoute: + """Поддельный Playwright Route: считает abort/continue, мокает request.""" + + def __init__(self, resource_type: str, fail_abort: bool = False) -> None: + self.request = _FakeRequest(resource_type) + self.aborted = 0 + self.continued = 0 + self._fail_abort = fail_abort + + async def abort(self) -> None: + if self._fail_abort: + raise RuntimeError("route already handled") + self.aborted += 1 + + async def continue_(self) -> None: + self.continued += 1 + + +class _FakePage: + """Поддельная page: захватывает callback из page.route(pattern, handler).""" + + def __init__(self) -> None: + self.route_pattern: str | None = None + self.route_handler: Any = None + + async def route(self, pattern: str, handler: Any) -> None: + self.route_pattern = pattern + self.route_handler = handler + + +def _capture_handler(monkeypatch: pytest.MonkeyPatch, *, enabled: bool, blocked: set[str]) -> Any: + """Включает флаг/типы, прогоняет _apply_resource_block, возвращает page (с handler).""" + monkeypatch.setattr(server, "_BLOCK_RESOURCES", enabled) + monkeypatch.setattr(server, "_BLOCKED_TYPES", frozenset(blocked)) + page = _FakePage() + asyncio.run(server._apply_resource_block(page)) + return page + + +def test_apply_resource_block_noop_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + """BROWSER_BLOCK_RESOURCES=false → route НЕ навешивается.""" + page = _capture_handler(monkeypatch, enabled=False, blocked={"image"}) + assert page.route_handler is None + assert page.route_pattern is None + + +def test_apply_resource_block_registers_route_when_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Флаг включён → route на '**/*' с хендлером.""" + page = _capture_handler(monkeypatch, enabled=True, blocked={"image", "media", "font"}) + assert page.route_pattern == "**/*" + assert callable(page.route_handler) + + +def test_handler_aborts_image(monkeypatch: pytest.MonkeyPatch) -> None: + """image (в _BLOCKED_TYPES) → abort, без continue.""" + page = _capture_handler(monkeypatch, enabled=True, blocked={"image", "media", "font"}) + route = _FakeRoute("image") + asyncio.run(page.route_handler(route)) + assert route.aborted == 1 + assert route.continued == 0 + + +def test_handler_aborts_media_and_font(monkeypatch: pytest.MonkeyPatch) -> None: + page = _capture_handler(monkeypatch, enabled=True, blocked={"image", "media", "font"}) + for rtype in ("media", "font"): + route = _FakeRoute(rtype) + asyncio.run(page.route_handler(route)) + assert route.aborted == 1, rtype + assert route.continued == 0, rtype + + +def test_handler_continues_document_and_script(monkeypatch: pytest.MonkeyPatch) -> None: + """document/script/stylesheet/xhr/fetch — НЕ блокируются (нужны для гидрации/stealth).""" + page = _capture_handler(monkeypatch, enabled=True, blocked={"image", "media", "font"}) + for rtype in ("document", "script", "stylesheet", "xhr", "fetch"): + route = _FakeRoute(rtype) + asyncio.run(page.route_handler(route)) + assert route.aborted == 0, rtype + assert route.continued == 1, rtype + + +def test_handler_falls_back_to_continue_on_abort_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """abort() кинул (route уже обработан) → fallback continue_(), навигация не падает.""" + page = _capture_handler(monkeypatch, enabled=True, blocked={"image"}) + route = _FakeRoute("image", fail_abort=True) + # Не должно бросить наружу. + asyncio.run(page.route_handler(route)) + assert route.continued == 1, "после сбоя abort должен быть fallback-continue" diff --git a/tradein-mvp/browser/test_server_smoke.py b/tradein-mvp/browser/test_server_smoke.py index 616d5149..40896b54 100644 --- a/tradein-mvp/browser/test_server_smoke.py +++ b/tradein-mvp/browser/test_server_smoke.py @@ -175,6 +175,12 @@ class _FakePage: def __init__(self, tracker: "_Tracker") -> None: self._tracker = tracker + async def route(self, pattern: str, handler: Any) -> None: + # _apply_resource_block навешивает route-interception перед goto (perf: + # блокировка image/media/font). Заглушка no-op — поведение route проверяется + # отдельно в test_server_resource_block.py. + return None + async def goto(self, url: str, **kwargs: Any) -> None: # Внутри goto держим «активную» страницу — здесь меряем параллелизм. self._tracker.active += 1 -- 2.45.3