Merge pull request 'feat(browser): per-provider page pacing + resource-block tuning — снизить proxy fan-out (#1812)' (#1813) from feat/browser-reduce-page-fanout into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 4s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m4s
Deploy Trade-In / deploy (push) Successful in 1m37s

Reviewed-on: #1813
This commit is contained in:
lekss361 2026-06-19 18:30:13 +00:00
commit 2402287d64
3 changed files with 340 additions and 59 deletions

View file

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

View file

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

View file

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