22
33import asyncio
44import json
5- import os
65import subprocess
76import sys
8- import time
9- import urllib .request
10- from importlib import metadata
117from pathlib import Path
12- from typing import Annotated , Any , Literal , NoReturn
8+ from typing import Annotated , Any , Literal , NoReturn , TypedDict
139
1410import typer
1511from rich .console import Console
1612from rich .table import Table
1713
18- from webskrap .client import WebSkrapClient
14+ from webskrap .client import WebSkrapClient , browser_doctor
1915from webskrap .models import (
2016 FetchResult ,
2117 ResourcePolicy ,
3329app = typer .Typer (help = "WebSkrap browser scraping toolkit." )
3430console = Console ()
3531OutputFormat = Literal ["human" , "json" ]
36- INSTALL_COMMANDS = (
37- (sys .executable , "-m" , "playwright" , "install" , "chromium" ),
38- (sys .executable , "-m" , "patchright" , "install" , "chromium" ),
39- )
40- UPDATE_CHECK_URL = "https://pypi.org/pypi/webskrap/json"
41- UPDATE_CHECK_INTERVAL = 86_400 # once per day
42- UPDATE_CHECK_CACHE = Path .home () / ".webskrap" / "update-check.json"
43- # ponytail: ~/.webskrap not XDG/APPDATA-aware; swap to platformdirs if that matters
44-
45-
46- def _is_newer (latest : str , current : str ) -> bool :
47- # ponytail: naive X.Y.Z compare; swap to packaging.version if pre-release tags ever ship
48- try :
49- return tuple (map (int , latest .split ("." ))) > tuple (map (int , current .split ("." )))
50- except ValueError :
51- return False
52-
5332
54- def _check_for_update () -> None :
55- """Best-effort 'update available' notice. Never raises, never touches stdout."""
56- try :
57- if (
58- os .environ .get ("WEBSKRAP_NO_UPDATE_CHECK" )
59- or os .environ .get ("CI" )
60- or not sys .stderr .isatty ()
61- ):
62- return
63-
64- current = metadata .version ("webskrap" )
65- latest : str | None = None
6633
67- try :
68- cached = json .loads (UPDATE_CHECK_CACHE .read_text ())
69- if time .time () - cached ["checked_at" ] < UPDATE_CHECK_INTERVAL :
70- latest = cached ["latest" ]
71- except Exception :
72- latest = None
73-
74- if latest is None :
75- fetched : str | None = None
76- try :
77- with urllib .request .urlopen (UPDATE_CHECK_URL , timeout = 2 ) as response :
78- fetched = json .load (response )["info" ]["version" ]
79- except Exception :
80- fetched = None
81- # Stamp the attempt either way so a PyPI outage can't cause hammering.
82- latest = fetched or current
83- try :
84- UPDATE_CHECK_CACHE .parent .mkdir (parents = True , exist_ok = True )
85- UPDATE_CHECK_CACHE .write_text (
86- json .dumps ({"checked_at" : time .time (), "latest" : latest })
87- )
88- except Exception :
89- pass
90-
91- if _is_newer (latest , current ):
92- Console (stderr = True , highlight = False ).print (
93- f"[yellow]webskrap { latest } available[/] (you have { current } ) — "
94- "upgrade: [bold]pip install -U webskrap[/]"
95- )
96- except Exception :
97- return
34+ class InstallResult (TypedDict ):
35+ ok : bool
36+ command : list [str ]
37+ message : str
9838
9939
100- @app .callback ()
101- def _main () -> None :
102- _check_for_update ()
40+ INSTALL_COMMANDS = (
41+ (sys .executable , "-m" , "playwright" , "install" , "chromium" ),
42+ (sys .executable , "-m" , "patchright" , "install" , "chromium" ),
43+ )
10344
10445
10546@app .command ("install" )
@@ -171,49 +112,19 @@ def doctor_command(
171112
172113
173114async def _doctor () -> dict [str , object ]:
174- try :
175- from patchright .async_api import async_playwright
176- except Exception as exc :
177- return {
178- "ok" : False ,
179- "message" : f"Patchright import failed: { exc } " ,
180- "hint" : "Run: webskrap install" ,
181- }
182-
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"
198- return {
199- "ok" : True ,
200- "message" : f"Patchright headless { label } is ready." ,
201- "channel" : label ,
202- }
203-
204- return {
205- "ok" : False ,
206- "message" : f"Patchright headless Chrome did not launch: { failure } " ,
207- "hint" : "Run: webskrap install" ,
208- }
115+ return await browser_doctor ()
209116
210117
211118@app .command ("fetch" )
212119def fetch_command (
213120 url : Annotated [str , typer .Argument (help = "URL to fetch." )],
214121 profile : Annotated [
215122 str ,
216- typer .Option ("--profile" , "-p" , help = "Bundled profile name." ),
123+ typer .Option (
124+ "--profile" ,
125+ "-p" ,
126+ help = "Bundled profile metadata (requires --patchright-context-profile)." ,
127+ ),
217128 ] = "desktop-chrome" ,
218129 channel : Annotated [
219130 str | None ,
@@ -500,7 +411,7 @@ def _print_json(payload: object) -> None:
500411 typer .echo (json .dumps (payload , ensure_ascii = False ))
501412
502413
503- def _run_install_command (command : tuple [str , ...]) -> dict [ str , object ] :
414+ def _run_install_command (command : tuple [str , ...]) -> InstallResult :
504415 try :
505416 completed = subprocess .run (command , capture_output = True , text = True , check = False )
506417 except OSError as exc :
@@ -517,7 +428,7 @@ def _run_install_command(command: tuple[str, ...]) -> dict[str, object]:
517428 }
518429
519430
520- def _print_install_result (results : list [dict [ str , object ] ]) -> None :
431+ def _print_install_result (results : list [InstallResult ]) -> None :
521432 for result in results :
522433 command = " " .join (str (part ) for part in result ["command" ])
523434 if result ["ok" ]:
@@ -533,13 +444,7 @@ def _print_doctor_result(result: dict[str, object]) -> None:
533444 if result ["ok" ]:
534445 console .print (f"[green]{ message } [/green]" )
535446 return
536- if message .startswith ("Patchright import failed: " ):
537- detail = message .removeprefix ("Patchright import failed: " )
538- console .print (f"[red]Patchright import failed:[/red] { detail } " )
539- else :
540- console .print (
541- "[yellow]Patchright is installed, but headless Chrome did not launch.[/yellow]"
542- )
543- console .print (message .removeprefix ("Patchright headless Chrome did not launch: " ))
447+ console .print ("[yellow]Patchright is unavailable.[/yellow]" )
448+ console .print (message )
544449 if hint := result .get ("hint" ):
545450 console .print (str (hint ))
0 commit comments