feat(browser): block font/media + per-provider page pacing — снизить proxy fan-out (#1812)
This commit is contained in:
parent
ebdd70a38d
commit
59b5d15752
3 changed files with 340 additions and 59 deletions
|
|
@ -34,15 +34,22 @@ Per-provider модель (#1793):
|
|||
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 сломает выдачу.
|
||||
BROWSER_BLOCK_RESOURCE_TYPES — CSV типов ресурсов Playwright, которые abort'ить
|
||||
через page.route при навигации (default: "font,media").
|
||||
Снижает число одновременных под-коннектов на страницу →
|
||||
мобильный прокси не ловит NS_ERROR_PROXY_TOO_MANY_REQUESTS.
|
||||
image уже глушит camoufox block_images. Пустая строка =
|
||||
НЕ блокировать ничего. КРИТИЧНО: НЕ добавлять
|
||||
document/script/stylesheet/xhr/fetch — данные парсятся из
|
||||
HTML/JS-state (INITIAL_STATE/__preloadedState__), JS нужен
|
||||
для гидрации, CSS проверяется anti-bot fingerprint.
|
||||
(legacy alias BROWSER_BLOCKED_RESOURCE_TYPES всё ещё читается
|
||||
как fallback, если новая переменная не задана.)
|
||||
BROWSER_MIN_PAGE_INTERVAL_S — минимальный интервал (сек) между последовательными
|
||||
page.goto ОДНОГО провайдера (default: 2.0). Даёт под-
|
||||
коннектам предыдущей страницы дренироваться, прежде чем
|
||||
открыть новую → меньше пиковый параллелизм на прокси.
|
||||
0 = без пейсинга.
|
||||
BROWSER_PROXY_AVITO — http-прокси для avito (fallback AVITO_PROXY_URL).
|
||||
BROWSER_PROXY_CIAN — http-прокси для cian (fallback CIAN_PROXY_URL).
|
||||
BROWSER_PROXY_YANDEX — http-прокси для yandex (fallback YANDEX_PROXY_URL).
|
||||
|
|
@ -93,18 +100,37 @@ def _parse_bool(raw: str | None, default: bool) -> bool:
|
|||
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()
|
||||
def _parse_block_types(raw: str | None) -> frozenset[str]:
|
||||
"""Парсит CSV типов ресурсов для блокировки → frozenset (lower/strip).
|
||||
|
||||
None (env не задана) → дефолт "font,media". Пустая строка → пустой набор
|
||||
(ничего не блокируем). Не задаём document/script/stylesheet/xhr/fetch здесь —
|
||||
дефолт намеренно узкий (font+media), эти типы не нужны для extraction/fingerprint.
|
||||
"""
|
||||
if raw is None:
|
||||
raw = "font,media"
|
||||
return frozenset(t.strip().lower() for t in raw.split(",") if t.strip())
|
||||
|
||||
|
||||
# Типы под-ресурсов, которые abort'им через page.route на каждой странице. Каждый
|
||||
# page.goto тянет десятки sub-requests (js/css/xhr/font/media) через прокси; мобильный
|
||||
# прокси держит ~5 параллельных коннектов → 2-3 быстрых страницы подряд на одном
|
||||
# аккаунте упираются в NS_ERROR_PROXY_TOO_MANY_REQUESTS/500. Глуша font+media (тяжёлые,
|
||||
# не нужны ни для extraction, ни для fingerprint), снижаем пиковый fan-out. image уже
|
||||
# глушит camoufox block_images. КРИТИЧНО: document/script/stylesheet/xhr/fetch НЕ
|
||||
# блокируем — данные в HTML/JS-state (INITIAL_STATE/__preloadedState__), JS гидрирует
|
||||
# listings, CSS проверяет anti-bot fingerprint. Legacy alias BROWSER_BLOCKED_RESOURCE_TYPES
|
||||
# читается fallback'ом, если новая BROWSER_BLOCK_RESOURCE_TYPES не задана.
|
||||
_BLOCKED_TYPES: frozenset[str] = _parse_block_types(
|
||||
os.environ.get("BROWSER_BLOCK_RESOURCE_TYPES")
|
||||
or os.environ.get("BROWSER_BLOCKED_RESOURCE_TYPES")
|
||||
)
|
||||
|
||||
# Минимальный интервал (сек) между последовательными page.goto одного провайдера —
|
||||
# даёт под-коннектам предыдущей страницы дренироваться (прокси освобождает слоты),
|
||||
# прежде чем открыть новую страницу. 0 = без пейсинга.
|
||||
BROWSER_MIN_PAGE_INTERVAL_S: float = float(os.environ.get("BROWSER_MIN_PAGE_INTERVAL_S", "2.0"))
|
||||
|
||||
|
||||
def _provider_for_url(url: str) -> str:
|
||||
"""Определяет поставщика по host URL.
|
||||
|
|
@ -176,6 +202,7 @@ _browser_cms: dict[str, object] = {} # provider → AsyncCamoufox CM (для
|
|||
_page_counters: dict[str, int] = {} # provider → страниц с момента launch'а
|
||||
_locks: dict[str, asyncio.Lock] = {} # provider → Lock (весь доступ к инстансу)
|
||||
_retry_tasks: dict[str, asyncio.Task[None]] = {} # provider → фоновая retry-задача
|
||||
_last_goto_at: dict[str, float] = {} # provider → loop-time последнего page.goto (пейсинг)
|
||||
|
||||
# Guard на ленивое создание per-provider локов: setdefault на обычном dict из
|
||||
# разных корутин гонок не даёт (нет await между read-modify-write), но держим
|
||||
|
|
@ -265,6 +292,7 @@ async def _close_browser(provider: str) -> None:
|
|||
_browsers.pop(provider, None)
|
||||
_browser_cms.pop(provider, None)
|
||||
_page_counters[provider] = 0
|
||||
_last_goto_at.pop(provider, None)
|
||||
|
||||
|
||||
async def _relaunch_browser(provider: str) -> None:
|
||||
|
|
@ -487,18 +515,18 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
|||
|
||||
|
||||
async def _apply_resource_block(page: object) -> None:
|
||||
"""Навешивает route-interception, блокирующую тяжёлые ресурсы (image/media/font).
|
||||
"""Навешивает route-interception, блокирующую тяжёлые под-ресурсы (font/media).
|
||||
|
||||
No-op при BROWSER_BLOCK_RESOURCES=false. Route живёт на странице и снимается
|
||||
автоматически при page.close(). Блокируем ТОЛЬКО _BLOCKED_TYPES (по умолчанию
|
||||
картинки/медиа/шрифты) — document/script/stylesheet/xhr/fetch проходят, чтобы
|
||||
No-op при пустом _BLOCKED_TYPES (BROWSER_BLOCK_RESOURCE_TYPES=""). Route живёт на
|
||||
странице и снимается автоматически при page.close(). Блокируем ТОЛЬКО _BLOCKED_TYPES
|
||||
(по умолчанию font/media) — document/script/stylesheet/xhr/fetch проходят, чтобы
|
||||
гидрация listings работала и stealth не палился «браузером без ресурсов».
|
||||
|
||||
Handler обёрнут в try/except: любой сбой abort/continue (например, route уже
|
||||
обработан гонкой) деградирует в continue_(), чтобы один битый ресурс не ронял
|
||||
навигацию. Сам continue_() тоже под try — если страница уже закрыта, молча выходим.
|
||||
"""
|
||||
if not _BLOCK_RESOURCES:
|
||||
if not _BLOCKED_TYPES:
|
||||
return
|
||||
|
||||
async def _route_block(route: object) -> None: # type: ignore[no-untyped-def]
|
||||
|
|
@ -519,6 +547,31 @@ async def _apply_resource_block(page: object) -> None:
|
|||
await page.route("**/*", _route_block) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
async def _pace_provider(provider: str) -> None:
|
||||
"""Притормаживает перед page.goto, если с прошлого goto этого провайдера прошло
|
||||
меньше BROWSER_MIN_PAGE_INTERVAL_S — даёт под-коннектам прошлой страницы
|
||||
дренироваться, снижая пиковый параллелизм на прокси.
|
||||
|
||||
Caller держит _locks[provider] → timestamp per-provider читается/пишется без гонки.
|
||||
Записывает момент ПОСЛЕ возможного sleep (т.е. фактический момент следующего goto),
|
||||
чтобы интервал считался между реальными навигациями, а не между входами в функцию.
|
||||
No-op при BROWSER_MIN_PAGE_INTERVAL_S <= 0.
|
||||
"""
|
||||
if BROWSER_MIN_PAGE_INTERVAL_S <= 0:
|
||||
return
|
||||
now = asyncio.get_event_loop().time()
|
||||
last = _last_goto_at.get(provider)
|
||||
if last is not None:
|
||||
elapsed = now - last
|
||||
remaining = BROWSER_MIN_PAGE_INTERVAL_S - elapsed
|
||||
if remaining > 0:
|
||||
logger.debug(
|
||||
"tradein-browser[%s]: pacing — ждём %.2fс перед goto", provider, remaining
|
||||
)
|
||||
await asyncio.sleep(remaining)
|
||||
_last_goto_at[provider] = asyncio.get_event_loop().time()
|
||||
|
||||
|
||||
async def _do_fetch(provider: str, url: str) -> str:
|
||||
"""Одна попытка навигации; при краше браузера — relaunch и один retry.
|
||||
|
||||
|
|
@ -554,6 +607,7 @@ async def _fetch_once(provider: str, url: str) -> str:
|
|||
page = await browser.new_page() # type: ignore[attr-defined]
|
||||
try:
|
||||
await _apply_resource_block(page)
|
||||
await _pace_provider(provider)
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
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())
|
||||
|
||||
|
||||
|
|
@ -290,5 +291,227 @@ def test_mixed_load_serializes_within_parallelizes_across(
|
|||
assert probe.global_peak >= 2, "avito и cian должны перекрываться во времени"
|
||||
|
||||
|
||||
# ── _parse_block_types: env-парсинг ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_block_types_default() -> None:
|
||||
"""None (env не задана) → дефолт font,media."""
|
||||
assert server._parse_block_types(None) == frozenset({"font", "media"})
|
||||
|
||||
|
||||
def test_parse_block_types_empty_disables() -> None:
|
||||
"""Пустая строка → пустой набор (ничего не блокируем)."""
|
||||
assert server._parse_block_types("") == frozenset()
|
||||
assert server._parse_block_types(" ") == frozenset()
|
||||
assert server._parse_block_types(" , , ") == frozenset()
|
||||
|
||||
|
||||
def test_parse_block_types_custom_csv() -> None:
|
||||
"""CSV парсится с trim/lower, дубликаты схлопываются."""
|
||||
assert server._parse_block_types("font, Media , font") == frozenset({"font", "media"})
|
||||
assert server._parse_block_types("FONT,IMAGE") == frozenset({"font", "image"})
|
||||
|
||||
|
||||
# ── route-handler: блокировка font/media, пропуск document/script/css/xhr ──────────
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
def __init__(self, resource_type: str) -> None:
|
||||
self.resource_type = resource_type
|
||||
|
||||
|
||||
class _FakeRoute:
|
||||
"""Мок Playwright Route: фиксирует, был ли abort или continue_."""
|
||||
|
||||
def __init__(self, resource_type: str) -> None:
|
||||
self.request = _FakeRequest(resource_type)
|
||||
self.aborted = False
|
||||
self.continued = False
|
||||
|
||||
async def abort(self) -> None:
|
||||
self.aborted = True
|
||||
|
||||
async def continue_(self) -> None:
|
||||
self.continued = True
|
||||
|
||||
|
||||
class _CapturePage:
|
||||
"""Мок page: ловит handler, переданный в page.route, чтобы дёрнуть его напрямую."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.handler: Any = None
|
||||
self.route_pattern: Any = None
|
||||
|
||||
async def route(self, pattern: str, handler: Any) -> None:
|
||||
self.route_pattern = pattern
|
||||
self.handler = handler
|
||||
|
||||
|
||||
def test_resource_block_aborts_font_and_media(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Дефолтный набор font/media → abort; document/script/stylesheet/xhr/fetch → continue."""
|
||||
monkeypatch.setattr(server, "_BLOCKED_TYPES", frozenset({"font", "media"}))
|
||||
page = _CapturePage()
|
||||
|
||||
asyncio.run(server._apply_resource_block(page))
|
||||
assert page.handler is not None, "route-handler должен быть навешен"
|
||||
assert page.route_pattern == "**/*"
|
||||
|
||||
async def _check(resource_type: str) -> _FakeRoute:
|
||||
route = _FakeRoute(resource_type)
|
||||
await page.handler(route)
|
||||
return route
|
||||
|
||||
# Блокируемые типы → abort
|
||||
for rt in ("font", "media"):
|
||||
route = asyncio.run(_check(rt))
|
||||
assert route.aborted is True and route.continued is False, f"{rt} должен abort'иться"
|
||||
|
||||
# Критичные для extraction/fingerprint типы → continue
|
||||
for rt in ("document", "script", "stylesheet", "xhr", "fetch", "image"):
|
||||
route = asyncio.run(_check(rt))
|
||||
assert route.continued is True and route.aborted is False, f"{rt} должен проходить"
|
||||
|
||||
|
||||
def test_resource_block_noop_when_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Пустой _BLOCKED_TYPES → page.route не навешивается (no-op)."""
|
||||
monkeypatch.setattr(server, "_BLOCKED_TYPES", frozenset())
|
||||
page = _CapturePage()
|
||||
asyncio.run(server._apply_resource_block(page))
|
||||
assert page.handler is None, "при пустом наборе route навешивать не должны"
|
||||
|
||||
|
||||
def test_resource_block_handler_degrades_to_continue(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Сбой abort() деградирует в continue_() — битый ресурс не рвёт навигацию."""
|
||||
monkeypatch.setattr(server, "_BLOCKED_TYPES", frozenset({"font"}))
|
||||
page = _CapturePage()
|
||||
asyncio.run(server._apply_resource_block(page))
|
||||
|
||||
class _FlakyRoute(_FakeRoute):
|
||||
async def abort(self) -> None:
|
||||
raise RuntimeError("route already handled")
|
||||
|
||||
route = _FlakyRoute("font")
|
||||
asyncio.run(page.handler(route))
|
||||
assert route.continued is True, "при сбое abort должны fallback'нуть в continue_()"
|
||||
|
||||
|
||||
# ── _pace_provider: межстраничный пейсинг одного провайдера ────────────────────────
|
||||
|
||||
|
||||
def test_pace_provider_first_call_no_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Первый goto провайдера — без ожидания, но timestamp пишется."""
|
||||
monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0)
|
||||
slept: list[float] = []
|
||||
|
||||
async def _fake_sleep(d: float) -> None:
|
||||
slept.append(d)
|
||||
|
||||
monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
|
||||
asyncio.run(server._pace_provider("avito"))
|
||||
assert slept == [], "первый вызов не должен спать"
|
||||
assert "avito" in server._last_goto_at
|
||||
|
||||
|
||||
def test_pace_provider_waits_remaining_interval(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Второй goto в пределах интервала → sleep на остаток (interval - elapsed)."""
|
||||
monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0)
|
||||
slept: list[float] = []
|
||||
|
||||
async def _fake_sleep(d: float) -> None:
|
||||
slept.append(d)
|
||||
|
||||
# _pace_provider читает time() дважды за вызов (now + запись last).
|
||||
# 1-й вызов: now=100.0, last←100.0. 2-й: now=100.5 (elapsed=0.5→remaining=1.5),
|
||||
# после sleep last←102.0.
|
||||
clock = iter([100.0, 100.0, 100.5, 102.0])
|
||||
|
||||
class _FakeLoop:
|
||||
def time(self) -> float:
|
||||
return next(clock)
|
||||
|
||||
monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
|
||||
monkeypatch.setattr(server.asyncio, "get_event_loop", lambda: _FakeLoop())
|
||||
|
||||
async def _run() -> None:
|
||||
await server._pace_provider("cian") # первый — last=100.0
|
||||
await server._pace_provider("cian") # второй — elapsed=0.5, remaining=1.5
|
||||
|
||||
asyncio.run(_run())
|
||||
assert len(slept) == 1, "второй вызов должен один раз sleep'нуть"
|
||||
assert slept[0] == pytest.approx(1.5), "ждём остаток интервала (2.0 - 0.5)"
|
||||
|
||||
|
||||
def test_pace_provider_no_sleep_when_interval_elapsed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Если с прошлого goto прошло >= интервала → не спим."""
|
||||
monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0)
|
||||
slept: list[float] = []
|
||||
|
||||
async def _fake_sleep(d: float) -> None:
|
||||
slept.append(d)
|
||||
|
||||
# 2 чтения time() на вызов: [now1, last1, now2, last2]; elapsed=5.0 > 2.0 → no sleep.
|
||||
clock = iter([100.0, 100.0, 105.0, 105.0])
|
||||
|
||||
class _FakeLoop:
|
||||
def time(self) -> float:
|
||||
return next(clock)
|
||||
|
||||
monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
|
||||
monkeypatch.setattr(server.asyncio, "get_event_loop", lambda: _FakeLoop())
|
||||
|
||||
async def _run() -> None:
|
||||
await server._pace_provider("yandex")
|
||||
await server._pace_provider("yandex") # elapsed=5.0 > 2.0 → no sleep
|
||||
|
||||
asyncio.run(_run())
|
||||
assert slept == [], "интервал уже прошёл — спать не нужно"
|
||||
|
||||
|
||||
def test_pace_provider_disabled_when_zero(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""BROWSER_MIN_PAGE_INTERVAL_S <= 0 → пейсинг выключен, timestamp не пишется."""
|
||||
monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0)
|
||||
slept: list[float] = []
|
||||
|
||||
async def _fake_sleep(d: float) -> None:
|
||||
slept.append(d)
|
||||
|
||||
monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
|
||||
|
||||
async def _run() -> None:
|
||||
await server._pace_provider("avito")
|
||||
await server._pace_provider("avito")
|
||||
|
||||
asyncio.run(_run())
|
||||
assert slept == []
|
||||
assert "avito" not in server._last_goto_at
|
||||
|
||||
|
||||
def test_pace_provider_independent_per_provider(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Timestamp независим по провайдерам — пейсинг avito не влияет на cian."""
|
||||
monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 2.0)
|
||||
slept: list[float] = []
|
||||
|
||||
async def _fake_sleep(d: float) -> None:
|
||||
slept.append(d)
|
||||
|
||||
# 2 чтения time() на вызов: avito [100.0, 100.0]; cian первый [100.1, 100.1]
|
||||
# (нет last для cian → no sleep).
|
||||
clock = iter([100.0, 100.0, 100.1, 100.1])
|
||||
|
||||
class _FakeLoop:
|
||||
def time(self) -> float:
|
||||
return next(clock)
|
||||
|
||||
monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
|
||||
monkeypatch.setattr(server.asyncio, "get_event_loop", lambda: _FakeLoop())
|
||||
|
||||
async def _run() -> None:
|
||||
await server._pace_provider("avito")
|
||||
await server._pace_provider("cian")
|
||||
|
||||
asyncio.run(_run())
|
||||
assert slept == [], "разные провайдеры не пейсят друг друга"
|
||||
|
||||
|
||||
async def _coro(value: Any) -> Any:
|
||||
return value
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
"""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), не роняя навигацию.
|
||||
1. _parse_bool — общий разбор булевых env-флагов;
|
||||
2. _parse_block_types — разбор BROWSER_BLOCK_RESOURCE_TYPES CSV (дефолт font,media);
|
||||
3. _apply_resource_block навешивает route когда _BLOCKED_TYPES непуст и НЕ навешивает
|
||||
когда пуст (no-op);
|
||||
4. сам route-handler аборт'ит блокируемые типы и continue'ит document/script/xhr;
|
||||
5. сбой abort() деградирует в continue_() (fallback), не роняя навигацию.
|
||||
|
||||
camoufox/Playwright НЕ поднимается: route + request мокаются заглушками. Хендлер
|
||||
вызывается напрямую через захваченный _apply_resource_block-callback.
|
||||
|
|
@ -50,10 +51,19 @@ def test_parse_bool_none_uses_default() -> None:
|
|||
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"})
|
||||
def test_parse_block_types_default_font_media() -> None:
|
||||
"""Дефолт BROWSER_BLOCK_RESOURCE_TYPES (env не задана) — font/media."""
|
||||
assert server._parse_block_types(None) == frozenset({"font", "media"})
|
||||
|
||||
|
||||
def test_parse_block_types_empty_string_disables() -> None:
|
||||
"""Пустая строка → пустой набор (блокировка выключена)."""
|
||||
assert server._parse_block_types("") == frozenset()
|
||||
|
||||
|
||||
def test_parse_block_types_csv_trim_lower() -> None:
|
||||
"""CSV парсится с trim/lower, дубликаты схлопываются."""
|
||||
assert server._parse_block_types(" Font , MEDIA ,font") == frozenset({"font", "media"})
|
||||
|
||||
|
||||
# ── route-interception заглушки ──────────────────────────────────────────────────
|
||||
|
|
@ -94,43 +104,37 @@ class _FakePage:
|
|||
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)
|
||||
def _capture_handler(monkeypatch: pytest.MonkeyPatch, *, blocked: set[str]) -> Any:
|
||||
"""Ставит _BLOCKED_TYPES, прогоняет _apply_resource_block, возвращает page (с handler).
|
||||
|
||||
Пустой blocked → блокировка выключена (no-op), route не навешивается.
|
||||
"""
|
||||
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"})
|
||||
def test_apply_resource_block_noop_when_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Пустой _BLOCKED_TYPES (BROWSER_BLOCK_RESOURCE_TYPES="") → route НЕ навешивается."""
|
||||
page = _capture_handler(monkeypatch, blocked=set())
|
||||
assert page.route_handler is None
|
||||
assert page.route_pattern is None
|
||||
|
||||
|
||||
def test_apply_resource_block_registers_route_when_enabled(
|
||||
def test_apply_resource_block_registers_route_when_nonempty(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Флаг включён → route на '**/*' с хендлером."""
|
||||
page = _capture_handler(monkeypatch, enabled=True, blocked={"image", "media", "font"})
|
||||
"""Непустой набор → route на '**/*' с хендлером."""
|
||||
page = _capture_handler(monkeypatch, blocked={"font", "media"})
|
||||
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"):
|
||||
def test_handler_aborts_blocked_types(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Дефолтные font/media (в _BLOCKED_TYPES) → abort, без continue."""
|
||||
page = _capture_handler(monkeypatch, blocked={"font", "media"})
|
||||
for rtype in ("font", "media"):
|
||||
route = _FakeRoute(rtype)
|
||||
asyncio.run(page.route_handler(route))
|
||||
assert route.aborted == 1, rtype
|
||||
|
|
@ -138,9 +142,9 @@ def test_handler_aborts_media_and_font(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
|
||||
|
||||
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"):
|
||||
"""document/script/stylesheet/xhr/fetch/image — НЕ блокируются (гидрация/fingerprint)."""
|
||||
page = _capture_handler(monkeypatch, blocked={"font", "media"})
|
||||
for rtype in ("document", "script", "stylesheet", "xhr", "fetch", "image"):
|
||||
route = _FakeRoute(rtype)
|
||||
asyncio.run(page.route_handler(route))
|
||||
assert route.aborted == 0, rtype
|
||||
|
|
@ -151,8 +155,8 @@ 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)
|
||||
page = _capture_handler(monkeypatch, blocked={"font"})
|
||||
route = _FakeRoute("font", fail_abort=True)
|
||||
# Не должно бросить наружу.
|
||||
asyncio.run(page.route_handler(route))
|
||||
assert route.continued == 1, "после сбоя abort должен быть fallback-continue"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue