|
6 | 6 | here and get back the DOM after ``domcontentloaded`` (or a custom |
7 | 7 | selector) has fired. |
8 | 8 |
|
9 | | -Playwright is an **optional** dependency. The module imports cleanly |
10 | | -without it and exposes ``AVAILABLE = False``. |
| 9 | +Browser priority: |
| 10 | + 1. Patchright (stealth Playwright fork, 30/30 bot-detection tests) |
| 11 | + 2. Playwright (standard, requires manual stealth patches) |
| 12 | + 3. None (aiohttp-only fallback) |
| 13 | +
|
| 14 | +All are **optional** dependencies. The module imports cleanly without |
| 15 | +any of them and exposes ``AVAILABLE = False``. |
11 | 16 | """ |
12 | 17 |
|
13 | 18 | from __future__ import annotations |
|
20 | 25 |
|
21 | 26 | log = logging.getLogger(__name__) |
22 | 27 |
|
23 | | -try: |
24 | | - from playwright.async_api import ( # type: ignore[import-not-found] |
25 | | - async_playwright, |
26 | | - ) |
| 28 | +# ── browser backend selection ────────────────────────────────────────── |
| 29 | +_async_playwright = None |
| 30 | +_BACKEND = "" |
27 | 31 |
|
| 32 | +try: |
| 33 | + from patchright.async_api import async_playwright as _patchright_playwright # type: ignore[import-not-found] |
| 34 | + _async_playwright = _patchright_playwright |
| 35 | + _BACKEND = "patchright" |
28 | 36 | AVAILABLE = True |
29 | | -except ImportError: # pragma: no cover - optional dep |
30 | | - async_playwright = None # type: ignore[assignment] |
31 | | - AVAILABLE = False |
| 37 | +except ImportError: |
| 38 | + try: |
| 39 | + from playwright.async_api import ( # type: ignore[import-not-found] |
| 40 | + async_playwright as _pw, |
| 41 | + ) |
| 42 | + _async_playwright = _pw |
| 43 | + _BACKEND = "playwright" |
| 44 | + AVAILABLE = True |
| 45 | + except ImportError: # pragma: no cover - optional dep |
| 46 | + AVAILABLE = False |
| 47 | + |
| 48 | +log.debug("browser backend: %s (available=%s)", _BACKEND or "none", AVAILABLE) |
32 | 49 |
|
33 | 50 |
|
34 | 51 | @dataclass(frozen=True) |
@@ -57,19 +74,19 @@ async def fetch_rendered( |
57 | 74 | screenshot_dir: Path | None = None, |
58 | 75 | screenshot_name: str | None = None, |
59 | 76 | ) -> RenderedPage | None: |
60 | | - """Fetch ``url`` via a headless Chromium. |
| 77 | + """Fetch ``url`` via a headless Chromium (Patchright or Playwright). |
61 | 78 |
|
62 | | - Returns None if Playwright is missing or the render failed. Callers |
| 79 | + Returns None if no browser is installed or the render failed. Callers |
63 | 80 | should treat None as "fallback unavailable, move on". When |
64 | 81 | ``screenshot_dir`` is provided a PNG is saved inside it and the path |
65 | 82 | is returned on ``RenderedPage.screenshot_path``. |
66 | 83 | """ |
67 | 84 | if not AVAILABLE: |
68 | | - log.debug("playwright not installed; skipping rendered fetch for %s", url) |
| 85 | + log.debug("no browser backend installed; skipping rendered fetch for %s", url) |
69 | 86 | return None |
70 | 87 |
|
71 | 88 | try: |
72 | | - async with async_playwright() as pw: # type: ignore[misc] |
| 89 | + async with _async_playwright() as pw: # type: ignore[misc] |
73 | 90 | launch_args: dict[str, object] = {"headless": True} |
74 | 91 | if proxy: |
75 | 92 | launch_args["proxy"] = {"server": proxy} |
@@ -112,5 +129,5 @@ async def fetch_rendered( |
112 | 129 | except asyncio.CancelledError: |
113 | 130 | raise |
114 | 131 | except Exception as exc: # noqa: BLE001 - fallback must not crash scan |
115 | | - log.debug("playwright render failed for %s: %s", url, exc) |
| 132 | + log.debug("browser render failed for %s: %s", url, exc) |
116 | 133 | return None |
0 commit comments