perf(browser): block image/media/font resources in camoufox fetch (~10x smaller pages, faster via proxy) #1761

Merged
lekss361 merged 1 commit from perf/camoufox-block-resources into main 2026-06-18 15:18:29 +00:00
3 changed files with 229 additions and 0 deletions

View file

@ -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]

View file

@ -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"

View file

@ -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