|
9 | 9 | import urllib.request |
10 | 10 | from importlib import metadata |
11 | 11 | from pathlib import Path |
12 | | -from typing import Annotated, Literal |
| 12 | +from typing import Annotated, Any, Literal, NoReturn |
13 | 13 |
|
14 | 14 | import typer |
15 | 15 | from rich.console import Console |
16 | 16 | from rich.table import Table |
17 | 17 |
|
18 | 18 | from webskrap.client import WebSkrapClient |
19 | 19 | from webskrap.models import ( |
| 20 | + FetchResult, |
20 | 21 | ResourcePolicy, |
21 | 22 | SessionConfig, |
22 | 23 | WaitUntil, |
@@ -179,20 +180,32 @@ async def _doctor() -> dict[str, object]: |
179 | 180 | "hint": "Run: webskrap install", |
180 | 181 | } |
181 | 182 |
|
182 | | - try: |
183 | | - manager = async_playwright() |
184 | | - playwright = await manager.start() |
185 | | - browser = await playwright.chromium.launch(channel="chrome", headless=True) |
186 | | - await browser.close() |
187 | | - await playwright.stop() |
188 | | - except Exception as exc: |
| 183 | + # The chrome channel is unavailable on some platforms (Linux ARM64), where |
| 184 | + # bundled chromium still works. Report the best channel that launches |
| 185 | + # instead of failing the whole check. |
| 186 | + failure: Exception | None = None |
| 187 | + for channel in ("chrome", None): |
| 188 | + try: |
| 189 | + manager = async_playwright() |
| 190 | + playwright = await manager.start() |
| 191 | + browser = await playwright.chromium.launch(channel=channel, headless=True) |
| 192 | + await browser.close() |
| 193 | + await playwright.stop() |
| 194 | + except Exception as exc: # noqa: BLE001 - try the next channel |
| 195 | + failure = exc |
| 196 | + continue |
| 197 | + label = channel or "chromium" |
189 | 198 | return { |
190 | | - "ok": False, |
191 | | - "message": f"Patchright headless Chrome did not launch: {exc}", |
192 | | - "hint": "Run: webskrap install", |
| 199 | + "ok": True, |
| 200 | + "message": f"Patchright headless {label} is ready.", |
| 201 | + "channel": label, |
193 | 202 | } |
194 | 203 |
|
195 | | - return {"ok": True, "message": "Patchright headless Chrome is ready."} |
| 204 | + return { |
| 205 | + "ok": False, |
| 206 | + "message": f"Patchright headless Chrome did not launch: {failure}", |
| 207 | + "hint": "Run: webskrap install", |
| 208 | + } |
196 | 209 |
|
197 | 210 |
|
198 | 211 | @app.command("fetch") |
@@ -373,16 +386,15 @@ async def _fetch( |
373 | 386 | webrtc_ip_handling_policy=_parse_webrtc_ip_handling_policy(webrtc_ip_handling_policy), |
374 | 387 | ) |
375 | 388 |
|
376 | | - async with WebSkrapClient() as client: |
377 | | - result = await client.fetch( |
378 | | - url, |
379 | | - profile=selected_profile, |
380 | | - config=config, |
381 | | - wait_until=_parse_wait_until(wait_until), |
382 | | - screenshot=screenshot or False, |
383 | | - timeout_ms=timeout_ms, |
384 | | - text_only=text_only, |
385 | | - ) |
| 389 | + result = await _fetch_with_channel_fallback( |
| 390 | + config, |
| 391 | + url=url, |
| 392 | + profile=selected_profile, |
| 393 | + wait_until=_parse_wait_until(wait_until), |
| 394 | + screenshot=screenshot or False, |
| 395 | + timeout_ms=timeout_ms, |
| 396 | + text_only=text_only, |
| 397 | + ) |
386 | 398 |
|
387 | 399 | if output: |
388 | 400 | output.parent.mkdir(parents=True, exist_ok=True) |
@@ -410,6 +422,58 @@ async def _fetch( |
410 | 422 | console.print(f"[bold]HTML:[/bold] {output}") |
411 | 423 |
|
412 | 424 |
|
| 425 | +LAUNCH_FAILURE_MARKERS = ( |
| 426 | + "executable doesn't exist", |
| 427 | + "is not found at", |
| 428 | + "playwright install", |
| 429 | + "failed to launch", |
| 430 | + "browsertype.launch", |
| 431 | +) |
| 432 | + |
| 433 | + |
| 434 | +def _is_launch_failure(exc: Exception) -> bool: |
| 435 | + return any(marker in str(exc).lower() for marker in LAUNCH_FAILURE_MARKERS) |
| 436 | + |
| 437 | + |
| 438 | +def _fail_launch(exc: Exception) -> NoReturn: |
| 439 | + """Report an unlaunchable browser the way `doctor` does, not as a traceback.""" |
| 440 | + detail = str(exc).strip().splitlines() |
| 441 | + stderr = Console(stderr=True, highlight=False) |
| 442 | + stderr.print(f"[red]Browser did not launch:[/red] {detail[0] if detail else exc}") |
| 443 | + stderr.print("Run: [bold]webskrap install[/bold]") |
| 444 | + raise typer.Exit(code=1) |
| 445 | + |
| 446 | + |
| 447 | +async def _run_fetch(config: SessionConfig, **kwargs: Any) -> FetchResult: |
| 448 | + async with WebSkrapClient() as client: |
| 449 | + return await client.fetch(config=config, **kwargs) |
| 450 | + |
| 451 | + |
| 452 | +async def _fetch_with_channel_fallback(config: SessionConfig, **kwargs: Any) -> FetchResult: |
| 453 | + """Fetch, retrying on bundled chromium when the chosen channel cannot launch. |
| 454 | +
|
| 455 | + The default channel is `chrome`, which does not exist on every platform |
| 456 | + (Linux ARM64 has no Chrome build). Falling back keeps `webskrap fetch` |
| 457 | + working there instead of dumping a Playwright traceback. |
| 458 | + """ |
| 459 | + try: |
| 460 | + return await _run_fetch(config, **kwargs) |
| 461 | + except Exception as exc: |
| 462 | + if not _is_launch_failure(exc): |
| 463 | + raise |
| 464 | + if config.channel is None: |
| 465 | + _fail_launch(exc) |
| 466 | + Console(stderr=True, highlight=False).print( |
| 467 | + f"[yellow]channel '{config.channel}' did not launch; retrying with chromium[/yellow]" |
| 468 | + ) |
| 469 | + try: |
| 470 | + return await _run_fetch(config.model_copy(update={"channel": None}), **kwargs) |
| 471 | + except Exception as retry_exc: |
| 472 | + if not _is_launch_failure(retry_exc): |
| 473 | + raise |
| 474 | + _fail_launch(retry_exc) |
| 475 | + |
| 476 | + |
413 | 477 | def _parse_wait_until(value: str) -> WaitUntil: |
414 | 478 | try: |
415 | 479 | return parse_wait_until(value) |
|
0 commit comments