patchright-cli 0.2.0__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,3 @@
1
1
  """patchright-cli — Undetected browser automation CLI using Patchright."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.1"
patchright_cli/cli.py CHANGED
@@ -64,14 +64,14 @@ COMMANDS_HELP = {
64
64
  "goto": "goto <url> Navigate to URL",
65
65
  "click": "click <ref> [button] Click element [--modifiers=Alt,Shift]",
66
66
  "dblclick": "dblclick <ref> [btn] Double-click [--modifiers=Alt,Shift]",
67
- "fill": "fill <ref> <value> Fill text into element",
68
- "type": "type <text> Type text via keyboard",
67
+ "fill": "fill <ref> <value> Fill text into element [--submit]",
68
+ "type": "type <text> Type text via keyboard [--submit]",
69
69
  "hover": "hover <ref> Hover over element",
70
70
  "select": "select <ref> <value> Select dropdown option",
71
71
  "check": "check <ref> Check checkbox/radio",
72
72
  "uncheck": "uncheck <ref> Uncheck checkbox/radio",
73
- "snapshot": "snapshot Take accessibility snapshot",
74
- "eval": "eval <expr> Evaluate JavaScript",
73
+ "snapshot": "snapshot [ref] Take accessibility snapshot",
74
+ "eval": "eval <expr> Evaluate JavaScript [--file=F or stdin]",
75
75
  "screenshot": "screenshot [ref] Save screenshot [--full-page] [--filename=F]",
76
76
  "drag": "drag <from> <to> Drag element to target",
77
77
  "close": "close Close browser session",
@@ -120,22 +120,23 @@ COMMANDS_HELP = {
120
120
  "sessionstorage-delete": "sessionstorage-delete <k> Delete sessionStorage item",
121
121
  "sessionstorage-clear": "sessionstorage-clear Clear sessionStorage",
122
122
  # Route
123
- "route": "route <pattern> [--status=N] [--body=S] [--header=K:V] [--remove-header=H] Mock requests",
123
+ "route": "route <pattern> [--status=N] [--body=S] [--content-type=T] [--header=K:V] Mock requests",
124
124
  "route-list": "route-list List active routes",
125
125
  "unroute": "unroute [pattern] Remove route(s)",
126
+ "network-state-set": "network-state-set <state> Set online/offline (online|offline)",
126
127
  # Run code
127
- "run-code": "run-code <code> Run raw JS in page context",
128
+ "run-code": "run-code <code> Run raw JS in page context [--file=F or stdin]",
128
129
  # Tracing
129
130
  "tracing-start": "tracing-start Start Playwright tracing",
130
131
  "tracing-stop": "tracing-stop Stop tracing and save",
131
132
  # Video
132
133
  "video-start": "video-start Start video recording",
133
- "video-stop": "video-stop [file] Stop recording and save",
134
+ "video-stop": "video-stop Stop recording and save [--filename=F]",
134
135
  # PDF
135
136
  "pdf": "pdf [--filename=F] Save page as PDF",
136
137
  # DevTools
137
- "console": "console [level] Show console messages",
138
- "network": "network Show network requests",
138
+ "console": "console [level] Show console messages [--clear]",
139
+ "network": "network Show network requests [--static] [--clear]",
139
140
  # Session
140
141
  "list": "list List sessions",
141
142
  "close-all": "close-all Close all sessions",
@@ -153,6 +154,7 @@ def _print_help():
153
154
  click.echo(" --headless Run headless (default: headed)")
154
155
  click.echo(" --persistent Use persistent profile")
155
156
  click.echo(" --profile=<path> Custom profile directory")
157
+ click.echo(" --proxy=<url> Proxy server (e.g. http://host:port, socks5://host:port)")
156
158
  click.echo(" -s=<name> Named session (default: 'default')")
157
159
  click.echo(" --port=<n> Daemon port (default: 9321)")
158
160
  click.echo(" --version Show version")
@@ -207,7 +209,7 @@ def _print_help():
207
209
  "sessionstorage-clear",
208
210
  ],
209
211
  ),
210
- ("Route", ["route", "route-list", "unroute"]),
212
+ ("Route", ["route", "route-list", "unroute", "network-state-set"]),
211
213
  ("Code", ["run-code"]),
212
214
  ("Tracing", ["tracing-start", "tracing-stop"]),
213
215
  ("Video", ["video-start", "video-stop"]),
@@ -230,6 +232,7 @@ def main():
230
232
  headless = False
231
233
  persistent = False
232
234
  profile = None
235
+ proxy = None
233
236
  session_name = "default"
234
237
  port = DEFAULT_PORT
235
238
 
@@ -244,19 +247,32 @@ def main():
244
247
  persistent = True
245
248
  elif arg.startswith("--profile="):
246
249
  profile = arg.split("=", 1)[1]
247
- elif arg.startswith("--profile") and i + 1 < len(argv):
250
+ elif arg == "--profile" and i + 1 < len(argv):
248
251
  i += 1
249
252
  profile = argv[i]
253
+ elif arg.startswith("--proxy="):
254
+ proxy = arg.split("=", 1)[1]
255
+ elif arg == "--proxy" and i + 1 < len(argv):
256
+ i += 1
257
+ proxy = argv[i]
250
258
  elif arg.startswith("-s="):
251
259
  session_name = arg.split("=", 1)[1]
252
260
  elif arg == "-s" and i + 1 < len(argv):
253
261
  i += 1
254
262
  session_name = argv[i]
255
263
  elif arg.startswith("--port="):
256
- port = int(arg.split("=", 1)[1])
264
+ try:
265
+ port = int(arg.split("=", 1)[1])
266
+ except ValueError:
267
+ click.echo(f"Invalid port value: {arg}", err=True)
268
+ sys.exit(1)
257
269
  elif arg == "--port" and i + 1 < len(argv):
258
270
  i += 1
259
- port = int(argv[i])
271
+ try:
272
+ port = int(argv[i])
273
+ except ValueError:
274
+ click.echo(f"Invalid port value: {argv[i]}", err=True)
275
+ sys.exit(1)
260
276
  elif arg in ("--version", "-v"):
261
277
  click.echo(f"patchright-cli {__version__}")
262
278
  sys.exit(0)
@@ -292,6 +308,27 @@ def main():
292
308
  positional_args.append(a)
293
309
  args = positional_args
294
310
 
311
+ # For eval/run-code: support --file=<path> and stdin via "-"
312
+ if command in ("eval", "run-code"):
313
+ file_opt = extra_opts.pop("file", None)
314
+ if file_opt:
315
+ # Read JS from file
316
+ try:
317
+ with open(file_opt, encoding="utf-8") as f:
318
+ args = [f.read()]
319
+ except FileNotFoundError:
320
+ click.echo(f"File not found: {file_opt}", err=True)
321
+ sys.exit(1)
322
+ elif args and args[0] == "-":
323
+ # Read JS from stdin
324
+ args = [sys.stdin.read()]
325
+ elif not args and not sys.stdin.isatty():
326
+ # Piped input with no positional arg
327
+ args = [sys.stdin.read()]
328
+ if not args:
329
+ click.echo(f"'{command}' requires a JS expression, --file=<path>, or piped stdin.", err=True)
330
+ sys.exit(1)
331
+
295
332
  # Build options dict
296
333
  options = {"session": session_name, **extra_opts}
297
334
  if headless:
@@ -300,6 +337,8 @@ def main():
300
337
  options["persistent"] = True
301
338
  if profile:
302
339
  options["profile"] = profile
340
+ if proxy:
341
+ options["proxy"] = proxy
303
342
 
304
343
  # Ensure daemon is running (auto-start for 'open', require for others)
305
344
  try:
@@ -309,17 +348,18 @@ def main():
309
348
  # Try connecting first; if fails, tell user to open
310
349
  import socket as _socket
311
350
 
351
+ sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
312
352
  try:
313
- sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
314
353
  sock.settimeout(1)
315
354
  sock.connect(("127.0.0.1", port))
316
- sock.close()
317
355
  except (ConnectionRefusedError, OSError, TimeoutError):
318
356
  click.echo(
319
357
  f"Daemon is not running on port {port}. Run 'patchright-cli open' first.",
320
358
  err=True,
321
359
  )
322
360
  sys.exit(1)
361
+ finally:
362
+ sock.close()
323
363
  except RuntimeError as e:
324
364
  click.echo(str(e), err=True)
325
365
  sys.exit(1)
patchright_cli/daemon.py CHANGED
@@ -44,6 +44,9 @@ class Session:
44
44
  self._pending_dialog_action: tuple | None = None
45
45
  self._profile_dir: str | None = None
46
46
  self._cdp_sessions: dict[int, object] = {}
47
+ self._video_cdp = None
48
+ self._video_frames: list[bytes] = []
49
+ self._video_recording: bool = False
47
50
 
48
51
  # -- internal helpers ---------------------------------------------------
49
52
 
@@ -54,8 +57,9 @@ class Session:
54
57
  self.context.on("page", lambda page: asyncio.ensure_future(self._on_new_page(page)))
55
58
 
56
59
  async def _on_new_page(self, page):
57
- self.pages.append(page)
58
- self.current_tab = len(self.pages) - 1
60
+ if page not in self.pages:
61
+ self.pages.append(page)
62
+ self.current_tab = len(self.pages) - 1
59
63
  await self._attach_page_listeners(page)
60
64
 
61
65
  async def _attach_page_listeners(self, page):
@@ -133,6 +137,7 @@ class DaemonState:
133
137
  headless: bool | None = None,
134
138
  persistent: bool = True,
135
139
  profile: str | None = None,
140
+ proxy: str | None = None,
136
141
  url: str | None = None,
137
142
  ) -> Session:
138
143
  if name in self.sessions:
@@ -147,14 +152,18 @@ class DaemonState:
147
152
  profile_dir = profile or str(Path.home() / ".patchright-cli" / "profiles" / name)
148
153
  Path(profile_dir).mkdir(parents=True, exist_ok=True)
149
154
 
155
+ launch_kwargs = {
156
+ "channel": "chrome",
157
+ "headless": use_headless,
158
+ "no_viewport": True,
159
+ "args": ["--disable-blink-features=AutomationControlled"],
160
+ }
161
+ if proxy:
162
+ launch_kwargs["proxy"] = {"server": proxy}
163
+
150
164
  context = await self.playwright.chromium.launch_persistent_context(
151
165
  profile_dir,
152
- channel="chrome",
153
- headless=use_headless,
154
- no_viewport=True,
155
- args=[
156
- "--disable-blink-features=AutomationControlled",
157
- ],
166
+ **launch_kwargs,
158
167
  )
159
168
 
160
169
  pages = context.pages or []
@@ -259,6 +268,7 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
259
268
  headless=headless,
260
269
  persistent=options.get("persistent", True),
261
270
  profile=options.get("profile"),
271
+ proxy=options.get("proxy"),
262
272
  url=url,
263
273
  )
264
274
  return await _page_info(session, cwd)
@@ -286,6 +296,50 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
286
296
 
287
297
  page = session.page
288
298
 
299
+ # Commands that don't need a page
300
+ if cmd == "list":
301
+ lines = ["### Sessions"]
302
+ for sname, s in state.sessions.items():
303
+ marker = " *" if sname == session_name else ""
304
+ tab_count = len(s.pages)
305
+ lines.append(f" - {sname}{marker}: {tab_count} tab(s)")
306
+ return {"success": True, "output": "\n".join(lines)}
307
+
308
+ if cmd == "close":
309
+ closed = await state.close_session(session_name)
310
+ return {"success": True, "output": f"Session '{session_name}' closed." if closed else "Session not found."}
311
+
312
+ if cmd == "close-all":
313
+ names = list(state.sessions.keys())
314
+ for n in names:
315
+ await state.close_session(n)
316
+ return {"success": True, "output": f"Closed {len(names)} session(s)."}
317
+
318
+ if cmd == "kill-all":
319
+ names = list(state.sessions.keys())
320
+ for n in names:
321
+ s = state.sessions.get(n)
322
+ if s:
323
+ try:
324
+ for p in s.pages:
325
+ try:
326
+ await p.close()
327
+ except Exception:
328
+ pass
329
+ await s.context.close()
330
+ except Exception:
331
+ pass
332
+ state.sessions.pop(n, None)
333
+ if hasattr(state, "shutdown_event"):
334
+ state.shutdown_event.set()
335
+ return {"success": True, "output": f"Killed {len(names)} session(s) and stopping daemon."}
336
+
337
+ if page is None:
338
+ return {
339
+ "success": False,
340
+ "output": "No page open. Run 'tab-new' to create one, or 'close' and 'open' again.",
341
+ }
342
+
289
343
  # -- Navigation -----------------------------------------------------
290
344
  if cmd == "goto":
291
345
  await page.goto(args[0])
@@ -321,11 +375,15 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
321
375
  if cmd == "fill":
322
376
  elem = await _resolve_ref(session, page, args[0])
323
377
  await elem.fill(args[1])
378
+ if options.get("submit"):
379
+ await page.keyboard.press("Enter")
324
380
  return await _page_info(session, cwd)
325
381
 
326
382
  if cmd == "type":
327
383
  text = args[0] if args else ""
328
384
  await page.keyboard.type(text)
385
+ if options.get("submit"):
386
+ await page.keyboard.press("Enter")
329
387
  return await _page_info(session, cwd)
330
388
 
331
389
  if cmd == "hover":
@@ -356,7 +414,13 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
356
414
 
357
415
  # -- Snapshot -------------------------------------------------------
358
416
  if cmd == "snapshot":
359
- yaml_text, session.ref_map = await take_snapshot(page)
417
+ element_ref = args[0] if args else None
418
+ if element_ref:
419
+ # Partial snapshot: scope JS to a specific element subtree
420
+ elem = await _resolve_ref(session, page, element_ref)
421
+ yaml_text, session.ref_map = await take_snapshot(page, root_element=elem)
422
+ else:
423
+ yaml_text, session.ref_map = await take_snapshot(page)
360
424
  fn = options.get("filename")
361
425
  if fn:
362
426
  Path(fn).parent.mkdir(parents=True, exist_ok=True)
@@ -402,11 +466,6 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
402
466
  await page.screenshot(path=str(filepath), full_page=full_page)
403
467
  return {"success": True, "output": f"Screenshot saved to {filepath}"}
404
468
 
405
- # -- Close ----------------------------------------------------------
406
- if cmd == "close":
407
- closed = await state.close_session(session_name)
408
- return {"success": True, "output": f"Session '{session_name}' closed." if closed else "Session not found."}
409
-
410
469
  # -- Keyboard -------------------------------------------------------
411
470
  if cmd == "press":
412
471
  await page.keyboard.press(args[0])
@@ -463,6 +522,7 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
463
522
  idx = int(args[0]) if args else session.current_tab
464
523
  if 0 <= idx < len(session.pages):
465
524
  p = session.pages.pop(idx)
525
+ session._cdp_sessions.pop(id(p), None)
466
526
  await p.close()
467
527
  session.current_tab = max(0, min(session.current_tab, len(session.pages) - 1))
468
528
  return {"success": True, "output": f"Tab {idx} closed."}
@@ -501,9 +561,9 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
501
561
  cookie["path"] = options.get("path", "/")
502
562
  else:
503
563
  cookie["url"] = page.url
504
- if options.get("httpOnly") or "httpOnly" in options:
564
+ if options.get("httpOnly"):
505
565
  cookie["httpOnly"] = True
506
- if options.get("secure") or "secure" in options:
566
+ if options.get("secure"):
507
567
  cookie["secure"] = True
508
568
  if options.get("sameSite"):
509
569
  cookie["sameSite"] = options["sameSite"]
@@ -564,34 +624,38 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
564
624
  if level_filter and m["type"] != level_filter:
565
625
  continue
566
626
  lines.append(f"[{m['type']}] {m['text']}")
627
+ if options.get("clear"):
628
+ session.console_messages.clear()
567
629
  return {"success": True, "output": "\n".join(lines) or "(no console messages)"}
568
630
 
569
631
  if cmd == "network":
632
+ include_static = bool(options.get("static"))
633
+ static_types = {"image", "font", "stylesheet", "script", "media"}
570
634
  lines = []
571
635
  for r in session.network_log[-50:]:
636
+ if not include_static and r["resource"] in static_types:
637
+ continue
572
638
  lines.append(f"{r['method']} {r['url']} [{r['resource']}]")
639
+ if options.get("clear"):
640
+ session.network_log.clear()
573
641
  return {"success": True, "output": "\n".join(lines) or "(no network requests)"}
574
642
 
575
- # -- Session management ---------------------------------------------
576
- if cmd == "list":
577
- lines = ["### Sessions"]
578
- for sname, s in state.sessions.items():
579
- marker = " *" if sname == session_name else ""
580
- tab_count = len(s.pages)
581
- lines.append(f" - {sname}{marker}: {tab_count} tab(s)")
582
- return {"success": True, "output": "\n".join(lines)}
583
-
584
- if cmd == "close-all":
585
- names = list(state.sessions.keys())
586
- for n in names:
587
- await state.close_session(n)
588
- return {"success": True, "output": f"Closed {len(names)} session(s)."}
589
-
590
- if cmd == "kill-all":
591
- names = list(state.sessions.keys())
592
- for n in names:
593
- await state.close_session(n)
594
- return {"success": True, "output": f"Killed {len(names)} session(s)."}
643
+ if cmd == "network-state-set":
644
+ state_val = args[0] if args else ""
645
+ if state_val not in ("online", "offline"):
646
+ return {"success": False, "output": "Usage: network-state-set <online|offline>"}
647
+ cdp = await page.context.new_cdp_session(page)
648
+ if state_val == "offline":
649
+ await cdp.send(
650
+ "Network.emulateNetworkConditions",
651
+ {"offline": True, "latency": 0, "downloadThroughput": -1, "uploadThroughput": -1},
652
+ )
653
+ else:
654
+ await cdp.send(
655
+ "Network.emulateNetworkConditions",
656
+ {"offline": False, "latency": 0, "downloadThroughput": -1, "uploadThroughput": -1},
657
+ )
658
+ return {"success": True, "output": f"Network state set to {state_val}."}
595
659
 
596
660
  # -- Dialog handling ------------------------------------------------
597
661
  if cmd == "dialog-accept":
@@ -637,7 +701,9 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
637
701
  # Apply cookies
638
702
  if state_data.get("cookies"):
639
703
  await session.context.add_cookies(state_data["cookies"])
640
- # Apply localStorage via JS
704
+ # Apply localStorage via JS (only works if page is on the matching origin)
705
+ ls_applied = 0
706
+ ls_skipped = 0
641
707
  for origin_data in state_data.get("origins", []):
642
708
  origin = origin_data.get("origin", "")
643
709
  ls = origin_data.get("localStorage", [])
@@ -646,7 +712,13 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
646
712
  await page.evaluate(
647
713
  f"() => localStorage.setItem({json.dumps(item['name'])}, {json.dumps(item['value'])})"
648
714
  )
649
- return {"success": True, "output": f"State loaded from {filepath}"}
715
+ ls_applied += len(ls)
716
+ elif ls:
717
+ ls_skipped += len(ls)
718
+ msg = f"State loaded from {filepath}"
719
+ if ls_skipped:
720
+ msg += f" (note: {ls_skipped} localStorage item(s) skipped — navigate to the matching origin first)"
721
+ return {"success": True, "output": msg}
650
722
 
651
723
  # -- Session storage ------------------------------------------------
652
724
  if cmd == "sessionstorage-list":
@@ -728,8 +800,7 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
728
800
  # -- Run code -------------------------------------------------------
729
801
  if cmd == "run-code":
730
802
  code = args[0] if args else ""
731
- fn = f"async (page) => {{ {code} }}"
732
- result = await page.evaluate(f"async () => {{ const page = window; {code} }}")
803
+ result = await page.evaluate(f"async () => {{ {code} }}")
733
804
  return {
734
805
  "success": True,
735
806
  "output": json.dumps(result, indent=2, default=str) if result is not None else "Code executed.",
@@ -751,19 +822,88 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
751
822
 
752
823
  # -- Video recording ------------------------------------------------
753
824
  if cmd == "video-start":
754
- session._video_page = page
755
- # Video requires a new context with record_video_dir
756
- return {
757
- "success": False,
758
- "output": "Video recording requires starting a new session with video enabled. Use: open --video",
759
- }
825
+ if session._video_recording:
826
+ return {"success": False, "output": "Video recording is already in progress."}
827
+ cdp = await page.context.new_cdp_session(page)
828
+ session._video_cdp = cdp
829
+ session._video_frames = []
830
+ session._video_recording = True
831
+
832
+ def _on_frame(event):
833
+ import base64
834
+
835
+ session._video_frames.append(base64.b64decode(event["data"]))
836
+ asyncio.ensure_future(cdp.send("Page.screencastFrameAck", {"sessionId": event["sessionId"]}))
837
+
838
+ cdp.on("Page.screencastFrame", _on_frame)
839
+ await cdp.send(
840
+ "Page.startScreencast", {"format": "jpeg", "quality": 80, "maxWidth": 1280, "maxHeight": 720}
841
+ )
842
+ return {"success": True, "output": "Video recording started."}
760
843
 
761
844
  if cmd == "video-stop":
762
- if hasattr(session, "_video_page") and session._video_page and session._video_page.video:
763
- path = await session._video_page.video.path()
764
- dest = args[0] if args else str(path)
765
- return {"success": True, "output": f"Video saved to {dest}"}
766
- return {"success": False, "output": "No video recording in progress."}
845
+ if not session._video_recording or not session._video_cdp:
846
+ return {"success": False, "output": "No video recording in progress."}
847
+ try:
848
+ await session._video_cdp.send("Page.stopScreencast")
849
+ except Exception:
850
+ pass
851
+ session._video_recording = False
852
+ frames = session._video_frames
853
+ session._video_frames = []
854
+ session._video_cdp = None
855
+
856
+ if not frames:
857
+ return {"success": False, "output": "No video frames were captured."}
858
+
859
+ base = Path(cwd) if cwd else Path.cwd()
860
+ snap_dir = base / ".patchright-cli"
861
+ snap_dir.mkdir(parents=True, exist_ok=True)
862
+ ts = int(time.time() * 1000)
863
+
864
+ fn = options.get("filename")
865
+
866
+ # Try ffmpeg for webm output, fall back to saving frames as images
867
+ video_path = snap_dir / (fn or f"video-{ts}.webm")
868
+ try:
869
+ import shutil
870
+ import tempfile
871
+
872
+ if not shutil.which("ffmpeg"):
873
+ raise FileNotFoundError("ffmpeg not found")
874
+
875
+ with tempfile.TemporaryDirectory() as tmpdir:
876
+ for i, frame_data in enumerate(frames):
877
+ Path(tmpdir, f"frame-{i:06d}.jpg").write_bytes(frame_data)
878
+ proc = await asyncio.create_subprocess_exec(
879
+ "ffmpeg",
880
+ "-y",
881
+ "-framerate",
882
+ "5",
883
+ "-i",
884
+ str(Path(tmpdir, "frame-%06d.jpg")),
885
+ "-c:v",
886
+ "libvpx",
887
+ "-b:v",
888
+ "1M",
889
+ str(video_path),
890
+ stdout=asyncio.subprocess.DEVNULL,
891
+ stderr=asyncio.subprocess.DEVNULL,
892
+ )
893
+ await proc.wait()
894
+ if proc.returncode != 0:
895
+ raise RuntimeError("ffmpeg failed")
896
+ return {"success": True, "output": f"Video saved to {video_path} ({len(frames)} frames)"}
897
+ except (FileNotFoundError, RuntimeError):
898
+ # Fallback: save frames as individual images
899
+ frames_dir = snap_dir / f"video-{ts}-frames"
900
+ frames_dir.mkdir(parents=True, exist_ok=True)
901
+ for i, frame_data in enumerate(frames):
902
+ (frames_dir / f"frame-{i:04d}.jpg").write_bytes(frame_data)
903
+ return {
904
+ "success": True,
905
+ "output": f"Saved {len(frames)} frames to {frames_dir}/ (install ffmpeg for video output)",
906
+ }
767
907
 
768
908
  # -- PDF ------------------------------------------------------------
769
909
  if cmd == "pdf":
@@ -840,6 +980,7 @@ async def run_daemon(port: int = DEFAULT_PORT, headless: bool = False):
840
980
  """Start the daemon TCP server."""
841
981
  state = DaemonState()
842
982
  state.default_headless = headless
983
+ state.shutdown_event = asyncio.Event()
843
984
 
844
985
  async def client_handler(reader, writer):
845
986
  await _handle_client(reader, writer, state)
@@ -850,7 +991,7 @@ async def run_daemon(port: int = DEFAULT_PORT, headless: bool = False):
850
991
  print(f"patchright-cli daemon listening on {addr[0]}:{addr[1]}", flush=True)
851
992
 
852
993
  # Handle graceful shutdown
853
- shutdown_event = asyncio.Event()
994
+ shutdown_event = state.shutdown_event
854
995
 
855
996
  def _signal_handler():
856
997
  logger.info("Shutdown signal received")
@@ -862,12 +1003,11 @@ async def run_daemon(port: int = DEFAULT_PORT, headless: bool = False):
862
1003
  loop.add_signal_handler(sig, _signal_handler)
863
1004
 
864
1005
  try:
865
- if sys.platform == "win32":
866
- # On Windows, asyncio signal handlers don't work; just serve forever
867
- async with server:
868
- await server.serve_forever()
869
- else:
870
- async with server:
1006
+ async with server:
1007
+ if sys.platform == "win32":
1008
+ # On Windows, asyncio signal handlers don't work; use shutdown_event from kill-all
1009
+ await shutdown_event.wait()
1010
+ else:
871
1011
  await shutdown_event.wait()
872
1012
  except (KeyboardInterrupt, asyncio.CancelledError):
873
1013
  pass
@@ -48,7 +48,10 @@ _SNAPSHOT_JS = r"""
48
48
  }
49
49
 
50
50
  function isVis(el) {
51
- if (!el.offsetParent && el.tagName !== "BODY" && el.tagName !== "HTML") return false;
51
+ if (!el.offsetParent && el.tagName !== "BODY" && el.tagName !== "HTML") {
52
+ try { const pos = getComputedStyle(el).position; if (pos !== "fixed" && pos !== "sticky") return false; }
53
+ catch(e) { return false; }
54
+ }
52
55
  try { const s = getComputedStyle(el); return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0"; }
53
56
  catch(e) { return false; }
54
57
  }
@@ -115,6 +118,110 @@ _SNAPSHOT_JS = r"""
115
118
  })()
116
119
  """
117
120
 
121
+ # Variant that takes a root element argument for partial snapshots
122
+ _SNAPSHOT_ELEMENT_JS = r"""
123
+ (rootEl) => {
124
+ const SKIP = new Set(["SCRIPT","STYLE","NOSCRIPT","SVG","LINK","META","BR","HR"]);
125
+ const INTERACTIVE = new Set(["A","BUTTON","INPUT","TEXTAREA","SELECT","DETAILS","SUMMARY","LABEL"]);
126
+ const SEMANTIC = new Set(["H1","H2","H3","H4","H5","H6","NAV","MAIN","HEADER","FOOTER","ARTICLE","SECTION","ASIDE","FORM","TABLE","UL","OL","LI","IMG","VIDEO","AUDIO","IFRAME"]);
127
+
128
+ rootEl.querySelectorAll("[data-patchright-ref]").forEach(el => el.removeAttribute("data-patchright-ref"));
129
+
130
+ function getRole(el) {
131
+ const ar = el.getAttribute && el.getAttribute("role");
132
+ if (ar) return ar;
133
+ const t = el.tagName;
134
+ const m = {A:"link",BUTTON:"button",TEXTAREA:"textbox",SELECT:"combobox",IMG:"img",TABLE:"table",FORM:"form",NAV:"navigation",MAIN:"main",HEADER:"banner",FOOTER:"contentinfo"};
135
+ if (m[t]) return m[t];
136
+ if (t === "INPUT") { const tp = (el.type||"text").toLowerCase(); return tp==="checkbox"?"checkbox":tp==="radio"?"radio":(tp==="submit"||tp==="button")?"button":"textbox"; }
137
+ if (t === "UL" || t === "OL") return "list";
138
+ if (t === "LI") return "listitem";
139
+ if (/^H[1-6]$/.test(t)) return "heading";
140
+ return t.toLowerCase();
141
+ }
142
+
143
+ function getName(el) {
144
+ const t = el.tagName;
145
+ const ar = el.getAttribute && el.getAttribute("aria-label");
146
+ if (ar) return ar.substring(0, 120);
147
+ if (t === "IMG") return (el.getAttribute("alt") || "").substring(0, 120);
148
+ if (t === "INPUT" || t === "TEXTAREA") return (el.getAttribute("placeholder") || "").substring(0, 80);
149
+ if (t === "A" || t === "BUTTON" || /^H[1-6]$/.test(t) || t === "LABEL" || t === "LI" || t === "SUMMARY")
150
+ return (el.innerText || "").replace(/\s+/g, " ").trim().substring(0, 120);
151
+ const ti = el.getAttribute && el.getAttribute("title");
152
+ if (ti) return ti.substring(0, 80);
153
+ return "";
154
+ }
155
+
156
+ function isVis(el) {
157
+ if (!el.offsetParent && el.tagName !== "BODY" && el.tagName !== "HTML") {
158
+ try { const pos = getComputedStyle(el).position; if (pos !== "fixed" && pos !== "sticky") return false; }
159
+ catch(e) { return false; }
160
+ }
161
+ try { const s = getComputedStyle(el); return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0"; }
162
+ catch(e) { return false; }
163
+ }
164
+
165
+ function shouldTag(el) {
166
+ return INTERACTIVE.has(el.tagName) || SEMANTIC.has(el.tagName) || !!el.getAttribute("role") || !!el.getAttribute("data-testid") || !!getName(el);
167
+ }
168
+
169
+ const tw = document.createTreeWalker(rootEl, NodeFilter.SHOW_ELEMENT, {
170
+ acceptNode(n) {
171
+ if (SKIP.has(n.tagName)) return NodeFilter.FILTER_REJECT;
172
+ if (!isVis(n)) return NodeFilter.FILTER_REJECT;
173
+ return NodeFilter.FILTER_ACCEPT;
174
+ }
175
+ });
176
+
177
+ let counter = 0;
178
+ const flatList = [];
179
+ let node;
180
+ while (node = tw.nextNode()) {
181
+ if (shouldTag(node)) {
182
+ counter++;
183
+ const ref = "e" + counter;
184
+ node.setAttribute("data-patchright-ref", ref);
185
+ const role = getRole(node);
186
+ const name = getName(node);
187
+ const entry = { ref, role, name, tag: node.tagName };
188
+ if (node.tagName === "INPUT" || node.tagName === "TEXTAREA" || node.tagName === "SELECT") {
189
+ if (node.value) entry.value = node.value.substring(0, 200);
190
+ if (node.type === "checkbox" || node.type === "radio") entry.checked = node.checked;
191
+ if (node.disabled) entry.disabled = true;
192
+ }
193
+ if (node.tagName === "A" && node.href) entry.url = node.href.substring(0, 200);
194
+ if (/^H[1-6]$/.test(node.tagName)) entry.level = parseInt(node.tagName[1]);
195
+ flatList.push(entry);
196
+ }
197
+ }
198
+
199
+ function buildTree(el, depth) {
200
+ if (depth > 12 || !el || !el.tagName) return null;
201
+ if (SKIP.has(el.tagName)) return null;
202
+ const ref = el.getAttribute("data-patchright-ref");
203
+ const children = [];
204
+ for (const ch of el.children) {
205
+ if (!isVis(ch)) continue;
206
+ const c = buildTree(ch, depth + 1);
207
+ if (c) children.push(c);
208
+ }
209
+ if (!ref && !children.length) return null;
210
+ const out = {};
211
+ if (ref) {
212
+ const f = flatList.find(x => x.ref === ref);
213
+ if (f) Object.assign(out, f);
214
+ delete out.tag;
215
+ }
216
+ if (children.length) out.children = children;
217
+ return out;
218
+ }
219
+
220
+ const tree = buildTree(rootEl, 0) || { role: "element", name: "", children: [] };
221
+ return { tree, flatList };
222
+ }
223
+ """
224
+
118
225
 
119
226
  def _walk_tree(node: dict, depth: int = 0) -> list[str]:
120
227
  """Recursively walk the parsed tree and produce YAML lines."""
@@ -158,11 +265,13 @@ def _yaml_escape(s: str) -> str:
158
265
  if any(
159
266
  c in s
160
267
  for c in (
268
+ "\\",
161
269
  ":",
162
270
  "#",
163
271
  "'",
164
272
  '"',
165
273
  "\n",
274
+ "\r",
166
275
  "[",
167
276
  "]",
168
277
  "{",
@@ -182,50 +291,45 @@ def _yaml_escape(s: str) -> str:
182
291
  "`",
183
292
  )
184
293
  ):
185
- escaped = s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
294
+ escaped = s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r")
186
295
  return f'"{escaped}"'
187
296
  return s
188
297
 
189
298
 
190
- async def take_snapshot(page) -> tuple[str, dict[str, dict]]:
191
- """Take a DOM snapshot. Returns (yaml_text, ref_map)."""
299
+ async def take_snapshot(page, root_element=None) -> tuple[str, dict[str, dict]]:
300
+ """Take a DOM snapshot. Returns (yaml_text, ref_map).
301
+
302
+ If root_element is provided, only snapshot the subtree under that element.
303
+ """
192
304
  result = None
193
- try:
194
- result = await page.evaluate(_SNAPSHOT_JS, isolated_context=False)
195
- except TypeError:
305
+ if root_element:
196
306
  try:
197
- result = await page.evaluate(_SNAPSHOT_JS)
307
+ result = await root_element.evaluate(_SNAPSHOT_ELEMENT_JS)
308
+ except Exception:
309
+ pass
310
+ else:
311
+ try:
312
+ result = await page.evaluate(_SNAPSHOT_JS, isolated_context=False)
313
+ except TypeError:
314
+ try:
315
+ result = await page.evaluate(_SNAPSHOT_JS)
316
+ except Exception:
317
+ pass
198
318
  except Exception:
199
319
  pass
200
- except Exception:
201
- pass
202
320
 
203
321
  if not result or not result.get("tree"):
204
322
  return "# Empty page - no accessible elements found\n", {}
205
323
 
206
324
  flat_list = result.get("flatList", [])
325
+ tree = result.get("tree", {})
207
326
 
208
327
  refs: dict[str, dict] = {}
209
- lines: list[str] = []
210
328
  for item in flat_list:
211
329
  refs[item["ref"]] = item
212
- lines.append(f"- ref: {item['ref']}")
213
- lines.append(f" role: {item.get('role', '')}")
214
- name = item.get("name", "")
215
- if name:
216
- lines.append(f" name: {_yaml_escape(name)}")
217
- for key in ("value", "url"):
218
- val = item.get(key, "")
219
- if val:
220
- lines.append(f" {key}: {_yaml_escape(str(val))}")
221
- if item.get("checked") is not None:
222
- lines.append(f" checked: {str(item['checked']).lower()}")
223
- if item.get("disabled"):
224
- lines.append(" disabled: true")
225
- if item.get("level") is not None:
226
- lines.append(f" level: {item['level']}")
227
-
228
- yaml_text = "\n".join(lines) + "\n"
330
+
331
+ lines = _walk_tree(tree)
332
+ yaml_text = "\n".join(lines) + "\n" if lines else "# Empty page - no accessible elements found\n"
229
333
  return yaml_text, refs
230
334
 
231
335
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchright-cli
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Anti-detect browser automation CLI using Patchright (undetected Playwright fork)
5
5
  Project-URL: Homepage, https://github.com/AhaiMk01/patchright-cli
6
6
  Project-URL: Repository, https://github.com/AhaiMk01/patchright-cli
@@ -78,25 +78,22 @@ curl -s https://raw.githubusercontent.com/AhaiMk01/patchright-cli/main/docs/inst
78
78
  > [!IMPORTANT]
79
79
  > **Requirements:** Python 3.10+ and Google Chrome
80
80
 
81
- <details>
82
- <summary><b>Manual Install</b></summary>
83
-
84
81
  ```bash
85
- # 1. Install the package
86
- pip install patchright-cli # or: uv tool install patchright-cli
82
+ # Recommended — always runs latest version, no install needed
83
+ uvx patchright-cli open https://example.com
84
+ ```
87
85
 
88
- # 2. Install the Patchright browser (fallback if Chrome is not found)
89
- python -m patchright install chromium
86
+ <details>
87
+ <summary><b>Other install methods</b></summary>
90
88
 
91
- # 3. Verify
89
+ ```bash
90
+ # Via pip
91
+ pip install patchright-cli
92
92
  patchright-cli open https://example.com
93
93
  patchright-cli close
94
- ```
95
94
 
96
- Or run without installing (like npx):
97
-
98
- ```bash
99
- uvx patchright-cli open https://example.com
95
+ # Update
96
+ pip install --upgrade patchright-cli
100
97
  ```
101
98
 
102
99
  **From source:**
@@ -167,16 +164,21 @@ patchright-cli click <ref> --modifiers=Alt,Shift
167
164
  patchright-cli dblclick <ref> # Double-click
168
165
  patchright-cli dblclick <ref> --modifiers=Shift
169
166
  patchright-cli fill <ref> <value> # Fill text input
167
+ patchright-cli fill <ref> <value> --submit # Fill and press Enter
170
168
  patchright-cli type <text> # Type via keyboard
169
+ patchright-cli type <text> --submit # Type and press Enter
171
170
  patchright-cli hover <ref> # Hover over element
172
171
  patchright-cli select <ref> <value> # Select dropdown option
173
172
  patchright-cli check <ref> # Check checkbox
174
173
  patchright-cli uncheck <ref> # Uncheck checkbox
175
174
  patchright-cli drag <from> <to> # Drag and drop
176
175
  patchright-cli snapshot # Accessibility snapshot
176
+ patchright-cli snapshot <ref> # Snapshot element subtree
177
177
  patchright-cli snapshot --filename=f # Save to custom path
178
178
  patchright-cli eval <expr> # Run JavaScript
179
+ patchright-cli eval --file=script.js # Run JS from file
179
180
  patchright-cli run-code <code> # Run JS with return value
181
+ patchright-cli run-code --file=f.js # Run JS from file
180
182
  patchright-cli screenshot # Page screenshot
181
183
  patchright-cli screenshot --full-page # Full scrollable page
182
184
  patchright-cli screenshot <ref> # Element screenshot
@@ -269,18 +271,30 @@ patchright-cli unroute "**/*.jpg"
269
271
  patchright-cli unroute # Remove all routes
270
272
  ```
271
273
 
272
- ### Tracing / PDF
274
+ ### Tracing / Video / PDF
273
275
  ```bash
274
276
  patchright-cli tracing-start
275
277
  patchright-cli tracing-stop # Saves .zip trace file
278
+ patchright-cli video-start # Start video recording (CDP screencast)
279
+ patchright-cli video-stop # Stop and save video (requires ffmpeg for .webm)
280
+ patchright-cli video-stop --filename=recording.webm
276
281
  patchright-cli pdf --filename=page.pdf
277
282
  ```
278
283
 
284
+ ### Network
285
+ ```bash
286
+ patchright-cli network # Network request log
287
+ patchright-cli network --static # Include static resources
288
+ patchright-cli network --clear # Clear log after printing
289
+ patchright-cli network-state-set offline # Simulate offline mode
290
+ patchright-cli network-state-set online # Restore connectivity
291
+ ```
292
+
279
293
  ### DevTools
280
294
  ```bash
281
295
  patchright-cli console # All console messages
282
296
  patchright-cli console warning # Filter by level
283
- patchright-cli network # Network request log
297
+ patchright-cli console --clear # Clear after printing
284
298
  ```
285
299
 
286
300
  ### Sessions
@@ -0,0 +1,10 @@
1
+ patchright_cli/__init__.py,sha256=EPqY-eBKEojjl1y9dwsndFlrKpBqB0Wz6OBMfewzVWE,100
2
+ patchright_cli/__main__.py,sha256=iVuBuI_rPOENLhffOEHoS7JWCDBQciqT1kPXjRuhbZ4,127
3
+ patchright_cli/cli.py,sha256=pbwyLdszogGpgSiCEBW5lrsTssOEJaQzbbx7o1HduL4,14137
4
+ patchright_cli/daemon.py,sha256=AhGUiiuvAH197sDMhBsHvvDvB8YMQiYmX4XD4ie4oWM,44323
5
+ patchright_cli/snapshot.py,sha256=JuXO3dN6-JNSQ0-lLEXIkg3YqGXW6Ul4I6KskdPygtQ,13621
6
+ patchright_cli-0.3.1.dist-info/METADATA,sha256=Va7G26o7qU29yC0vcF3yZFsi6BVjxuchtviG5Nsdc0E,15498
7
+ patchright_cli-0.3.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ patchright_cli-0.3.1.dist-info/entry_points.txt,sha256=Syh904eOQA8ylakyqIvG4LmIHx3LMjgeNcnFap3O0NQ,59
9
+ patchright_cli-0.3.1.dist-info/licenses/LICENSE,sha256=adszqL4YR4pQUrc7LeAFxeg5Mc2cvljXp7QPlzKVWm8,10760
10
+ patchright_cli-0.3.1.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- patchright_cli/__init__.py,sha256=5FxYv6u1EbGSdJfoc1ZD217_KGIHbI5M8LjoKfv0M1w,100
2
- patchright_cli/__main__.py,sha256=iVuBuI_rPOENLhffOEHoS7JWCDBQciqT1kPXjRuhbZ4,127
3
- patchright_cli/cli.py,sha256=wR5v8OlXUBkHFW3rkBuEcG4wDq4wHHjMSifYL8l_Lfg,12415
4
- patchright_cli/daemon.py,sha256=OtM3Gsm36F3tCDH6y5X8wEA2igp-cpsTmuvjl4ItDuI,38273
5
- patchright_cli/snapshot.py,sha256=KTIcRyUl2kUFs3yfpPi3YVvz17zRjr4rcfqFgiyt1PM,8906
6
- patchright_cli-0.2.0.dist-info/METADATA,sha256=VFBWzcV2Tzb3iIowYb5EFwudQjGoZehi2UWmAXy2nec,14710
7
- patchright_cli-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
- patchright_cli-0.2.0.dist-info/entry_points.txt,sha256=Syh904eOQA8ylakyqIvG4LmIHx3LMjgeNcnFap3O0NQ,59
9
- patchright_cli-0.2.0.dist-info/licenses/LICENSE,sha256=adszqL4YR4pQUrc7LeAFxeg5Mc2cvljXp7QPlzKVWm8,10760
10
- patchright_cli-0.2.0.dist-info/RECORD,,