patchright-cli 0.4.0__py3-none-any.whl → 0.4.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.4.0"
3
+ __version__ = "0.4.1"
patchright_cli/cli.py CHANGED
@@ -11,6 +11,7 @@ import os
11
11
  import socket
12
12
  import struct
13
13
  import sys
14
+ from pathlib import Path
14
15
 
15
16
  import click
16
17
 
@@ -18,6 +19,23 @@ from patchright_cli import __version__
18
19
  from patchright_cli.daemon import DEFAULT_PORT, ensure_daemon_running
19
20
 
20
21
 
22
+ def _load_config(config_path: str | None) -> dict:
23
+ """Load JSON config file. If config_path is None, try .patchright-cli/config.json in cwd."""
24
+ p = Path(config_path) if config_path else Path.cwd() / ".patchright-cli" / "config.json"
25
+ if p.exists():
26
+ return json.loads(p.read_text(encoding="utf-8"))
27
+ return {}
28
+
29
+
30
+ def _merge_config_with_options(config: dict, options: dict) -> dict:
31
+ """Merge config dict with CLI options. CLI options take precedence."""
32
+ merged = dict(config)
33
+ for k, v in options.items():
34
+ if v is not None:
35
+ merged[k] = v
36
+ return merged
37
+
38
+
21
39
  def _send_command(command: str, args: list, options: dict, port: int = DEFAULT_PORT) -> dict:
22
40
  """Connect to daemon, send command, receive response."""
23
41
  msg = {
@@ -61,6 +79,7 @@ def _recv_exact(sock: socket.socket, n: int) -> bytes:
61
79
  COMMANDS_HELP = {
62
80
  # Core
63
81
  "open": "open [url] Open browser (starts daemon if needed)",
82
+ "attach": "attach --cdp=<url> Attach to running Chrome via CDP",
64
83
  "goto": "goto <url> Navigate to URL",
65
84
  "click": "click <ref> [button] Click element [--modifiers=Alt,Shift]",
66
85
  "dblclick": "dblclick <ref> [btn] Double-click [--modifiers=Alt,Shift]",
@@ -70,8 +89,8 @@ COMMANDS_HELP = {
70
89
  "select": "select <ref> <value> Select dropdown option",
71
90
  "check": "check <ref> Check checkbox/radio",
72
91
  "uncheck": "uncheck <ref> Uncheck checkbox/radio",
73
- "snapshot": "snapshot [ref] Take accessibility snapshot",
74
- "eval": "eval <expr> Evaluate JavaScript [--file=F or stdin]",
92
+ "snapshot": "snapshot [ref] Take accessibility snapshot [--filename=F] [--depth=N]",
93
+ "eval": "eval <expr> [ref] Evaluate JavaScript [--file=F or stdin]",
75
94
  "text": "text <ref|selector> Get text content of element",
76
95
  "screenshot": "screenshot [ref] Save screenshot [--full-page] [--filename=F]",
77
96
  "drag": "drag <from> <to> Drag element to target",
@@ -117,6 +136,8 @@ COMMANDS_HELP = {
117
136
  # Dialog
118
137
  "dialog-accept": "dialog-accept [text] Accept next dialog",
119
138
  "dialog-dismiss": "dialog-dismiss Dismiss next dialog",
139
+ # Permissions
140
+ "grant-permissions": "grant-permissions <perms> Grant permissions [--origin=url]",
120
141
  # Upload / Resize
121
142
  "upload": "upload <file> [ref] Upload file to input",
122
143
  "resize": "resize <w> <h> Resize viewport",
@@ -142,6 +163,7 @@ COMMANDS_HELP = {
142
163
  # Video
143
164
  "video-start": "video-start Start video recording",
144
165
  "video-stop": "video-stop Stop recording and save [--filename=F]",
166
+ "video-chapter": "video-chapter <title> Add chapter marker to video",
145
167
  # PDF
146
168
  "pdf": "pdf [--filename=F] Save page as PDF",
147
169
  # DevTools
@@ -152,6 +174,11 @@ COMMANDS_HELP = {
152
174
  "close-all": "close-all Close all sessions",
153
175
  "kill-all": "kill-all Kill all sessions",
154
176
  "delete-data": "delete-data Delete persistent profile",
177
+ # Dashboard
178
+ "show": "show [--port=N] Open session dashboard in browser",
179
+ # Codegen
180
+ "codegen": "codegen [file] Start recording interactions",
181
+ "codegen-stop": "codegen-stop [file] Stop recording and save script",
155
182
  }
156
183
 
157
184
  ALL_COMMANDS = list(COMMANDS_HELP.keys())
@@ -167,6 +194,18 @@ def _print_help():
167
194
  click.echo(" --proxy=<url> Proxy server (e.g. http://host:port, socks5://host:port)")
168
195
  click.echo(" -s=<name> Named session (default: 'default')")
169
196
  click.echo(" --port=<n> Daemon port (default: 9321)")
197
+ click.echo(" --config=<path> Load config from JSON file")
198
+ click.echo(" --timeout-action=ms Default action timeout")
199
+ click.echo(" --timeout-navigation=ms Default navigation timeout")
200
+ click.echo(" --device=<name> Emulate a device (e.g. 'iPhone 15')")
201
+ click.echo(" --viewport-size=WxH Set viewport size")
202
+ click.echo(" --locale=<code> Locale (e.g. en-US)")
203
+ click.echo(" --timezone=<id> Timezone ID")
204
+ click.echo(" --geolocation=lat,lon Geolocation override")
205
+ click.echo(" --user-agent=<ua> Custom user agent")
206
+ click.echo(" --grant-permissions=P Comma-separated permissions to grant")
207
+ click.echo(" --cdp=<url> Attach to Chrome via CDP endpoint")
208
+ click.echo(" --show-port=N Dashboard port (default: 9322)")
170
209
  click.echo(" --version Show version")
171
210
  click.echo(" --help Show this help\n")
172
211
  click.echo("Commands:")
@@ -176,6 +215,7 @@ def _print_help():
176
215
  "Core",
177
216
  [
178
217
  "open",
218
+ "attach",
179
219
  "goto",
180
220
  "click",
181
221
  "dblclick",
@@ -223,13 +263,16 @@ def _print_help():
223
263
  "sessionstorage-clear",
224
264
  ],
225
265
  ),
266
+ ("Permissions", ["grant-permissions"]),
226
267
  ("Route", ["route", "route-list", "unroute", "network-state-set"]),
227
268
  ("Code", ["run-code"]),
228
269
  ("Tracing", ["tracing-start", "tracing-stop"]),
229
- ("Video", ["video-start", "video-stop"]),
270
+ ("Video", ["video-start", "video-stop", "video-chapter"]),
230
271
  ("PDF", ["pdf"]),
231
272
  ("DevTools", ["console", "network"]),
232
273
  ("Session", ["list", "close-all", "kill-all", "delete-data"]),
274
+ ("Dashboard", ["show"]),
275
+ ("Codegen", ["codegen", "codegen-stop"]),
233
276
  ]
234
277
  for cat_name, cmds in categories:
235
278
  click.echo(f"\n {cat_name}:")
@@ -247,8 +290,10 @@ def main():
247
290
  persistent = False
248
291
  profile = None
249
292
  proxy = None
250
- session_name = "default"
293
+ config_path = None
294
+ session_name = os.environ.get("PATCHRIGHT_CLI_SESSION", "default")
251
295
  port = DEFAULT_PORT
296
+ extra_opts: dict = {}
252
297
 
253
298
  # Extract options
254
299
  remaining = []
@@ -287,6 +332,60 @@ def main():
287
332
  except ValueError:
288
333
  click.echo(f"Invalid port value: {argv[i]}", err=True)
289
334
  sys.exit(1)
335
+ elif arg.startswith("--config="):
336
+ config_path = arg.split("=", 1)[1]
337
+ elif arg == "--config" and i + 1 < len(argv):
338
+ i += 1
339
+ config_path = argv[i]
340
+ elif arg.startswith("--device="):
341
+ extra_opts["device"] = arg.split("=", 1)[1]
342
+ elif arg == "--device" and i + 1 < len(argv):
343
+ i += 1
344
+ extra_opts["device"] = argv[i]
345
+ elif arg.startswith("--viewport-size="):
346
+ vw, vh = arg.split("=", 1)[1].split("x", 1)
347
+ extra_opts["viewport"] = {"width": vw, "height": vh}
348
+ elif arg == "--viewport-size" and i + 1 < len(argv):
349
+ i += 1
350
+ vw, vh = argv[i].split("x", 1)
351
+ extra_opts["viewport"] = {"width": vw, "height": vh}
352
+ elif arg.startswith("--locale="):
353
+ extra_opts["locale"] = arg.split("=", 1)[1]
354
+ elif arg == "--locale" and i + 1 < len(argv):
355
+ i += 1
356
+ extra_opts["locale"] = argv[i]
357
+ elif arg.startswith("--timezone="):
358
+ extra_opts["timezone"] = arg.split("=", 1)[1]
359
+ elif arg == "--timezone" and i + 1 < len(argv):
360
+ i += 1
361
+ extra_opts["timezone"] = argv[i]
362
+ elif arg.startswith("--geolocation="):
363
+ lat, lon = arg.split("=", 1)[1].split(",", 1)
364
+ extra_opts["geolocation"] = {"lat": lat, "lon": lon}
365
+ elif arg == "--geolocation" and i + 1 < len(argv):
366
+ i += 1
367
+ lat, lon = argv[i].split(",", 1)
368
+ extra_opts["geolocation"] = {"lat": lat, "lon": lon}
369
+ elif arg.startswith("--user-agent="):
370
+ extra_opts["user-agent"] = arg.split("=", 1)[1]
371
+ elif arg == "--user-agent" and i + 1 < len(argv):
372
+ i += 1
373
+ extra_opts["user-agent"] = argv[i]
374
+ elif arg.startswith("--grant-permissions="):
375
+ extra_opts["grant-permissions"] = arg.split("=", 1)[1]
376
+ elif arg == "--grant-permissions" and i + 1 < len(argv):
377
+ i += 1
378
+ extra_opts["grant-permissions"] = argv[i]
379
+ elif arg.startswith("--cdp="):
380
+ extra_opts["cdp"] = arg.split("=", 1)[1]
381
+ elif arg == "--cdp" and i + 1 < len(argv):
382
+ i += 1
383
+ extra_opts["cdp"] = argv[i]
384
+ elif arg.startswith("--show-port="):
385
+ extra_opts["show-port"] = arg.split("=", 1)[1]
386
+ elif arg == "--show-port" and i + 1 < len(argv):
387
+ i += 1
388
+ extra_opts["show-port"] = argv[i]
290
389
  elif arg in ("--version", "-v"):
291
390
  click.echo(f"patchright-cli {__version__}")
292
391
  sys.exit(0)
@@ -311,16 +410,18 @@ def main():
311
410
 
312
411
  # Separate --key=value and --flag from positional args
313
412
  positional_args = []
314
- extra_opts = {}
413
+ cmd_opts: dict = {}
315
414
  for a in args:
316
415
  if a.startswith("--") and "=" in a:
317
416
  k, v = a[2:].split("=", 1)
318
- extra_opts[k] = v
417
+ cmd_opts[k] = v
319
418
  elif a.startswith("--"):
320
- extra_opts[a[2:]] = True
419
+ cmd_opts[a[2:]] = True
321
420
  else:
322
421
  positional_args.append(a)
323
422
  args = positional_args
423
+ # Merge: global opts as base, command-level opts take precedence
424
+ extra_opts = {**extra_opts, **cmd_opts}
324
425
 
325
426
  # For eval/run-code: support --file=<path> and stdin via "-"
326
427
  if command in ("eval", "run-code"):
@@ -343,8 +444,9 @@ def main():
343
444
  click.echo(f"'{command}' requires a JS expression, --file=<path>, or piped stdin.", err=True)
344
445
  sys.exit(1)
345
446
 
346
- # Build options dict
347
- options = {"session": session_name, **extra_opts}
447
+ # Build options dict (config file values are overridden by explicit CLI flags)
448
+ config = _load_config(config_path)
449
+ options = {"session": session_name, **_merge_config_with_options(config, extra_opts)}
348
450
  if headless:
349
451
  options["headless"] = True
350
452
  if persistent:
patchright_cli/daemon.py CHANGED
@@ -27,6 +27,8 @@ logger = logging.getLogger("patchright-cli.daemon")
27
27
  DEFAULT_PORT = 9321
28
28
  DEFAULT_PROFILE_DIR = str(Path.home() / ".patchright-cli" / "profiles" / "default")
29
29
 
30
+ _dashboard_runners: dict[int, tuple] = {}
31
+
30
32
  # ---------------------------------------------------------------------------
31
33
  # Command handler registry
32
34
  # ---------------------------------------------------------------------------
@@ -66,8 +68,10 @@ class Session:
66
68
  self._video_cdp = None
67
69
  self._video_frames: list[bytes] = []
68
70
  self._video_recording: bool = False
71
+ self._video_chapters: list[tuple[int, str]] = []
69
72
  self._history: list[str] = []
70
73
  self._history_index: int = -1
74
+ self._codegen: list[str] | None = None
71
75
 
72
76
  # -- internal helpers ---------------------------------------------------
73
77
 
@@ -185,6 +189,16 @@ class DaemonState:
185
189
  profile: str | None = None,
186
190
  proxy: str | None = None,
187
191
  url: str | None = None,
192
+ device: str | None = None,
193
+ viewport: dict | None = None,
194
+ locale: str | None = None,
195
+ timezone: str | None = None,
196
+ geolocation: dict | None = None,
197
+ user_agent: str | None = None,
198
+ grant_permissions: str | None = None,
199
+ cdp_endpoint: str | None = None,
200
+ cdp_headers: dict | None = None,
201
+ cdp_timeout: int = 30000,
188
202
  ) -> Session:
189
203
  if name in self.sessions:
190
204
  return self.sessions[name]
@@ -198,37 +212,69 @@ class DaemonState:
198
212
  profile_dir = profile or str(Path.home() / ".patchright-cli" / "profiles" / name)
199
213
  Path(profile_dir).mkdir(parents=True, exist_ok=True)
200
214
 
201
- launch_kwargs = {
202
- "channel": "chrome",
203
- "headless": use_headless,
204
- "no_viewport": True,
205
- "args": ["--disable-blink-features=AutomationControlled"],
206
- }
207
- if proxy:
208
- parsed = urlparse(proxy)
209
- if parsed.username or parsed.password:
210
- import base64
211
-
212
- creds = base64.b64encode(f"{parsed.username or ''}:{parsed.password or ''}".encode()).decode()
213
- launch_kwargs["extra_http_headers"] = {"Proxy-Authorization": f"Basic {creds}"}
214
- # Rebuild proxy URL without credentials
215
- netloc = parsed.hostname or ""
216
- if parsed.port:
217
- netloc += f":{parsed.port}"
218
- cleaned = parsed._replace(netloc=netloc).geturl()
219
- launch_kwargs["proxy"] = {"server": cleaned}
220
- else:
221
- launch_kwargs["proxy"] = {"server": proxy}
222
-
223
- context = await self.playwright.chromium.launch_persistent_context(
224
- profile_dir,
225
- **launch_kwargs,
226
- )
227
-
228
- pages = context.pages or []
229
- if not pages:
230
- page = await context.new_page()
231
- pages = [page]
215
+ context_options: dict = {}
216
+ if device and self.playwright is not None:
217
+ device_descriptor = self.playwright.devices.get(device)
218
+ if device_descriptor:
219
+ context_options.update(device_descriptor)
220
+ if viewport:
221
+ context_options["viewport"] = {"width": int(viewport["width"]), "height": int(viewport["height"])}
222
+ if locale:
223
+ context_options["locale"] = locale
224
+ if timezone:
225
+ context_options["timezone_id"] = timezone
226
+ if geolocation:
227
+ context_options["geolocation"] = {
228
+ "latitude": float(geolocation["lat"]),
229
+ "longitude": float(geolocation["lon"]),
230
+ }
231
+ context_options["permissions"] = context_options.get("permissions", []) + ["geolocation"]
232
+ if user_agent:
233
+ context_options["user_agent"] = user_agent
234
+ if grant_permissions:
235
+ perms = [p.strip() for p in grant_permissions.split(",") if p.strip()]
236
+ context_options["permissions"] = list(set(context_options.get("permissions", []) + perms))
237
+
238
+ if cdp_endpoint:
239
+ browser = await self.playwright.chromium.connect_over_cdp(
240
+ cdp_endpoint, headers=cdp_headers, timeout=cdp_timeout
241
+ )
242
+ context = await browser.new_context(**context_options)
243
+ pages = context.pages or []
244
+ if not pages:
245
+ page = await context.new_page()
246
+ pages = [page]
247
+ else:
248
+ launch_kwargs = {
249
+ "channel": "chrome",
250
+ "headless": use_headless,
251
+ "no_viewport": True,
252
+ "args": ["--disable-blink-features=AutomationControlled"],
253
+ }
254
+ if proxy:
255
+ parsed = urlparse(proxy)
256
+ if parsed.username or parsed.password:
257
+ import base64
258
+
259
+ creds = base64.b64encode(f"{parsed.username or ''}:{parsed.password or ''}".encode()).decode()
260
+ launch_kwargs["extra_http_headers"] = {"Proxy-Authorization": f"Basic {creds}"}
261
+ # Rebuild proxy URL without credentials
262
+ netloc = parsed.hostname or ""
263
+ if parsed.port:
264
+ netloc += f":{parsed.port}"
265
+ cleaned = parsed._replace(netloc=netloc).geturl()
266
+ launch_kwargs["proxy"] = {"server": cleaned}
267
+ else:
268
+ launch_kwargs["proxy"] = {"server": proxy}
269
+ launch_kwargs.update(context_options)
270
+ context = await self.playwright.chromium.launch_persistent_context(
271
+ profile_dir,
272
+ **launch_kwargs,
273
+ )
274
+ pages = context.pages or []
275
+ if not pages:
276
+ page = await context.new_page()
277
+ pages = [page]
232
278
 
233
279
  if url:
234
280
  await pages[0].goto(url)
@@ -294,6 +340,20 @@ async def _resolve_ref(session: Session, page, ref: str):
294
340
  return session.ref_registry.resolve(page, ref)
295
341
 
296
342
 
343
+ async def _apply_timeouts(page, options: dict) -> None:
344
+ """Apply per-command timeout overrides from CLI options."""
345
+ if options.get("timeout-action"):
346
+ try:
347
+ page.set_default_timeout(float(options["timeout-action"]))
348
+ except Exception:
349
+ pass
350
+ if options.get("timeout-navigation"):
351
+ try:
352
+ page.set_default_navigation_timeout(float(options["timeout-navigation"]))
353
+ except Exception:
354
+ pass
355
+
356
+
297
357
  # ---------------------------------------------------------------------------
298
358
  # Command handlers
299
359
  # ---------------------------------------------------------------------------
@@ -433,11 +493,12 @@ async def cmd_drag(session: Session, page, args: list, options: dict, cwd: str |
433
493
  @register("snapshot")
434
494
  async def cmd_snapshot(session: Session, page, args: list, options: dict, cwd: str | None, state: DaemonState) -> dict:
435
495
  element_ref = args[0] if args else None
496
+ max_depth = int(options["depth"]) if options.get("depth") is not None else None
436
497
  if element_ref:
437
498
  elem = await _resolve_ref(session, page, element_ref)
438
- snapshot_text, session.ref_registry = await take_snapshot(page, root_element=elem)
499
+ snapshot_text, session.ref_registry = await take_snapshot(page, root_element=elem, max_depth=max_depth)
439
500
  else:
440
- snapshot_text, session.ref_registry = await take_snapshot(page)
501
+ snapshot_text, session.ref_registry = await take_snapshot(page, max_depth=max_depth)
441
502
  fn = options.get("filename")
442
503
  if fn:
443
504
  Path(fn).parent.mkdir(parents=True, exist_ok=True)
@@ -465,8 +526,16 @@ async def cmd_snapshot(session: Session, page, args: list, options: dict, cwd: s
465
526
 
466
527
  @register("eval")
467
528
  async def cmd_eval(session: Session, page, args: list, options: dict, cwd: str | None, state: DaemonState) -> dict:
468
- expr = args[0] if args else ""
469
- result = await page.evaluate(expr)
529
+ import re
530
+
531
+ if len(args) >= 2 and re.fullmatch(r"e\d+", args[-1]) and session.ref_registry is not None:
532
+ expr = " ".join(args[:-1])
533
+ ref = args[-1]
534
+ elem = await _resolve_ref(session, page, ref)
535
+ result = await elem.evaluate(expr)
536
+ else:
537
+ expr = args[0] if args else ""
538
+ result = await page.evaluate(expr)
470
539
  return {"success": True, "output": json.dumps(result, indent=2, default=str)}
471
540
 
472
541
 
@@ -848,6 +917,25 @@ async def cmd_resize(session: Session, page, args: list, options: dict, cwd: str
848
917
  return {"success": True, "output": f"Viewport resized to {w}x{h}"}
849
918
 
850
919
 
920
+ # -- Permissions -------------------------------------------------------------
921
+
922
+
923
+ @register("grant-permissions")
924
+ async def cmd_grant_permissions(
925
+ session: Session, page, args: list, options: dict, cwd: str | None, state: DaemonState
926
+ ) -> dict:
927
+ perms_str = args[0] if args else ""
928
+ perms = [p.strip() for p in perms_str.split(",") if p.strip()]
929
+ if not perms:
930
+ return {"success": False, "output": "Usage: grant-permissions <perm1,perm2> [--origin=url]"}
931
+ origin = options.get("origin")
932
+ kwargs = {"permissions": perms}
933
+ if origin:
934
+ kwargs["origin"] = origin
935
+ await session.context.grant_permissions(**kwargs)
936
+ return {"success": True, "output": f"Granted permissions: {', '.join(perms)}"}
937
+
938
+
851
939
  # -- State save/load ---------------------------------------------------------
852
940
 
853
941
 
@@ -1075,6 +1163,8 @@ async def cmd_video_stop(
1075
1163
  frames = session._video_frames
1076
1164
  session._video_frames = []
1077
1165
  session._video_cdp = None
1166
+ chapters = list(session._video_chapters)
1167
+ session._video_chapters = []
1078
1168
 
1079
1169
  if not frames:
1080
1170
  return {"success": False, "output": "No video frames were captured."}
@@ -1084,6 +1174,13 @@ async def cmd_video_stop(
1084
1174
  snap_dir.mkdir(parents=True, exist_ok=True)
1085
1175
  ts = int(time.time() * 1000)
1086
1176
 
1177
+ if chapters:
1178
+ chapters_path = snap_dir / f"video-{ts}-chapters.json"
1179
+ chapters_path.write_text(
1180
+ json.dumps([{"frame": f, "title": t} for f, t in chapters], indent=2),
1181
+ encoding="utf-8",
1182
+ )
1183
+
1087
1184
  fn = options.get("filename")
1088
1185
  video_path = snap_dir / (fn or f"video-{ts}.webm")
1089
1186
  try:
@@ -1126,6 +1223,18 @@ async def cmd_video_stop(
1126
1223
  }
1127
1224
 
1128
1225
 
1226
+ @register("video-chapter")
1227
+ async def cmd_video_chapter(
1228
+ session: Session, page, args: list, options: dict, cwd: str | None, state: DaemonState
1229
+ ) -> dict:
1230
+ if not session._video_recording:
1231
+ return {"success": False, "output": "No video recording in progress."}
1232
+ title = args[0] if args else "Chapter"
1233
+ frame_index = len(session._video_frames)
1234
+ session._video_chapters.append((frame_index, title))
1235
+ return {"success": True, "output": f"Chapter '{title}' added at frame {frame_index}."}
1236
+
1237
+
1129
1238
  # -- PDF ---------------------------------------------------------------------
1130
1239
 
1131
1240
 
@@ -1174,6 +1283,98 @@ async def cmd_wait_for(session: Session, page, args: list, options: dict, cwd: s
1174
1283
  return {"success": True, "output": f"Element is {state_arg}"}
1175
1284
 
1176
1285
 
1286
+ @register("show")
1287
+ async def cmd_show(session: Session, page, args: list, options: dict, cwd: str | None, state: DaemonState) -> dict:
1288
+ from patchright_cli.dashboard import start_dashboard_server
1289
+
1290
+ port = int(options.get("show-port", 9322))
1291
+ if port not in _dashboard_runners:
1292
+ runner, url = await start_dashboard_server(state, port=port)
1293
+ _dashboard_runners[port] = (runner, url)
1294
+ else:
1295
+ _, url = _dashboard_runners[port]
1296
+ return {"success": True, "output": f"Dashboard running at {url}"}
1297
+
1298
+
1299
+ _CODEGEN_RECORDABLE = {
1300
+ "goto",
1301
+ "go-back",
1302
+ "go-forward",
1303
+ "reload",
1304
+ "click",
1305
+ "dblclick",
1306
+ "fill",
1307
+ "type",
1308
+ "hover",
1309
+ "select",
1310
+ "check",
1311
+ "uncheck",
1312
+ "drag",
1313
+ "press",
1314
+ "keydown",
1315
+ "keyup",
1316
+ "mousemove",
1317
+ "mousedown",
1318
+ "mouseup",
1319
+ "mousewheel",
1320
+ "scroll",
1321
+ "scroll-to",
1322
+ "wait",
1323
+ "wait-for",
1324
+ "tab-new",
1325
+ "tab-close",
1326
+ "tab-select",
1327
+ "upload",
1328
+ "resize",
1329
+ "cookie-set",
1330
+ "cookie-delete",
1331
+ "cookie-clear",
1332
+ "localstorage-set",
1333
+ "localstorage-delete",
1334
+ "localstorage-clear",
1335
+ "sessionstorage-set",
1336
+ "sessionstorage-delete",
1337
+ "sessionstorage-clear",
1338
+ "state-load",
1339
+ "route",
1340
+ "unroute",
1341
+ "network-state-set",
1342
+ "dialog-accept",
1343
+ "dialog-dismiss",
1344
+ "screenshot",
1345
+ "pdf",
1346
+ }
1347
+
1348
+
1349
+ @register("codegen")
1350
+ async def cmd_codegen(session: Session, page, args: list, options: dict, cwd: str | None, state: DaemonState) -> dict:
1351
+ session._codegen = []
1352
+ if page:
1353
+ url = page.url
1354
+ session._codegen.append(f'patchright-cli open "{url}"')
1355
+ return {"success": True, "output": "Recording started. Run 'codegen-stop' to save the script."}
1356
+
1357
+
1358
+ @register("codegen-stop")
1359
+ async def cmd_codegen_stop(
1360
+ session: Session, page, args: list, options: dict, cwd: str | None, state: DaemonState
1361
+ ) -> dict:
1362
+ if session._codegen is None:
1363
+ return {"success": False, "output": "No recording in progress. Run 'codegen' first."}
1364
+ lines = ["#!/usr/bin/env bash", "set -e", ""] + session._codegen
1365
+ script = "\n".join(lines) + "\n"
1366
+ session._codegen = None
1367
+
1368
+ base = Path(cwd) if cwd else Path.cwd()
1369
+ snap_dir = base / ".patchright-cli"
1370
+ snap_dir.mkdir(parents=True, exist_ok=True)
1371
+ ts = int(time.time() * 1000)
1372
+ filepath = args[0] if args else str(snap_dir / f"script-{ts}.sh")
1373
+ Path(filepath).parent.mkdir(parents=True, exist_ok=True)
1374
+ Path(filepath).write_text(script, encoding="utf-8")
1375
+ return {"success": True, "output": f"Saved {len(lines) - 3} command(s) to {filepath}"}
1376
+
1377
+
1177
1378
  # ---------------------------------------------------------------------------
1178
1379
  # Command dispatch
1179
1380
  # ---------------------------------------------------------------------------
@@ -1202,6 +1403,36 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
1202
1403
  profile=options.get("profile"),
1203
1404
  proxy=options.get("proxy"),
1204
1405
  url=url,
1406
+ device=options.get("device"),
1407
+ viewport=options.get("viewport"),
1408
+ locale=options.get("locale"),
1409
+ timezone=options.get("timezone"),
1410
+ geolocation=options.get("geolocation"),
1411
+ user_agent=options.get("user-agent") or options.get("userAgent"),
1412
+ grant_permissions=options.get("grant-permissions") or options.get("grantPermissions"),
1413
+ )
1414
+ if session.page:
1415
+ session.push_history(session.page.url)
1416
+ return await _page_info(session, cwd)
1417
+
1418
+ if cmd == "attach":
1419
+ cdp = options.get("cdp")
1420
+ if not cdp:
1421
+ return {"success": False, "output": "Usage: attach --cdp=<url>"}
1422
+ headless = options.get("headless", False)
1423
+ session = await state.get_or_create_session(
1424
+ session_name,
1425
+ headless=headless,
1426
+ cdp_endpoint=cdp,
1427
+ cdp_headers=options.get("cdp-headers"),
1428
+ cdp_timeout=int(options.get("cdp-timeout", 30000)),
1429
+ device=options.get("device"),
1430
+ viewport=options.get("viewport"),
1431
+ locale=options.get("locale"),
1432
+ timezone=options.get("timezone"),
1433
+ geolocation=options.get("geolocation"),
1434
+ user_agent=options.get("user-agent") or options.get("userAgent"),
1435
+ grant_permissions=options.get("grant-permissions") or options.get("grantPermissions"),
1205
1436
  )
1206
1437
  if session.page:
1207
1438
  session.push_history(session.page.url)
@@ -1218,6 +1449,11 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
1218
1449
  return {"success": True, "output": f"Profile data deleted for '{session_name}'."}
1219
1450
  return {"success": False, "output": "No persistent profile to delete."}
1220
1451
 
1452
+ # Dashboard command — no session required
1453
+ if cmd == "show":
1454
+ handler = COMMAND_HANDLERS.get("show")
1455
+ return await handler(None, None, args, options, cwd, state)
1456
+
1221
1457
  # All other commands require an existing session
1222
1458
  session = state.sessions.get(session_name)
1223
1459
  if session is None:
@@ -1272,11 +1508,26 @@ async def handle_command(state: DaemonState, msg: dict) -> dict:
1272
1508
  "output": "No page open. Run 'tab-new' to create one, or 'close' and 'open' again.",
1273
1509
  }
1274
1510
 
1511
+ await _apply_timeouts(page, options)
1512
+
1275
1513
  handler = COMMAND_HANDLERS.get(cmd)
1276
1514
  if handler is None:
1277
1515
  return {"success": False, "output": f"Unknown command: {cmd}"}
1278
1516
 
1279
- return await handler(session, page, args, options, cwd, state)
1517
+ result = await handler(session, page, args, options, cwd, state)
1518
+ if result.get("success") and session._codegen is not None and cmd in _CODEGEN_RECORDABLE:
1519
+ args_quoted = [f'"{a}"' if " " in a else a for a in args]
1520
+ opt_parts = []
1521
+ for k, v in options.items():
1522
+ if k in ("session", "headless", "persistent", "profile", "proxy"):
1523
+ continue
1524
+ if v is True:
1525
+ opt_parts.append(f"--{k}")
1526
+ elif isinstance(v, str):
1527
+ opt_parts.append(f"--{k}={v}")
1528
+ cmd_line = " ".join(["patchright-cli"] + opt_parts + [cmd] + args_quoted)
1529
+ session._codegen.append(cmd_line)
1530
+ return result
1280
1531
 
1281
1532
  except Exception as e:
1282
1533
  logger.error("Command %s failed: %s", cmd, traceback.format_exc())
@@ -0,0 +1,135 @@
1
+ """Web dashboard for monitoring patchright-cli sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+
8
+ from aiohttp import web
9
+
10
+ HTML = """
11
+ <!DOCTYPE html>
12
+ <html>
13
+ <head>
14
+ <meta charset="utf-8">
15
+ <title>patchright-cli dashboard</title>
16
+ <style>
17
+ body { font-family: sans-serif; margin: 0; padding: 20px; background: #111; color: #eee; }
18
+ h1 { margin-top: 0; }
19
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
20
+ .card { background: #222; border-radius: 8px; padding: 12px; }
21
+ .card h3 { margin: 0 0 8px; font-size: 14px; }
22
+ .card img { width: 100%; border-radius: 4px; background: #333; }
23
+ .card p { margin: 4px 0; font-size: 12px; color: #aaa; }
24
+ .status { font-size: 11px; color: #888; }
25
+ </style>
26
+ </head>
27
+ <body>
28
+ <h1>patchright-cli sessions</h1>
29
+ <div id="grid" class="grid"></div>
30
+ <script>
31
+ const grid = document.getElementById('grid');
32
+ const ws = new WebSocket(`ws://${location.host}/ws`);
33
+ const sessions = {};
34
+
35
+ ws.onmessage = (ev) => {
36
+ const data = JSON.parse(ev.data);
37
+ if (!data.sessions) return;
38
+ data.sessions.forEach(s => {
39
+ sessions[s.name] = s;
40
+ });
41
+ render();
42
+ };
43
+
44
+ function render() {
45
+ grid.innerHTML = '';
46
+ Object.values(sessions).forEach(s => {
47
+ const div = document.createElement('div');
48
+ div.className = 'card';
49
+ div.innerHTML = `
50
+ <h3>${s.name}</h3>
51
+ <p>${s.url}</p>
52
+ <p class="status">${s.title} — ${s.tabs} tab(s)</p>
53
+ ${s.screenshot ? `<img src="data:image/png;base64,${s.screenshot}" />` : '<p class="status">No screenshot yet</p>'}
54
+ `;
55
+ grid.appendChild(div);
56
+ });
57
+ }
58
+ </script>
59
+ </body>
60
+ </html>
61
+ """
62
+
63
+
64
+ class DashboardState:
65
+ def __init__(self, daemon_state):
66
+ self.daemon_state = daemon_state
67
+ self._screenshots: dict[str, str | None] = {}
68
+
69
+ async def capture_loop(self):
70
+ while True:
71
+ await asyncio.sleep(1)
72
+ for name, session in list(self.daemon_state.sessions.items()):
73
+ page = session.page
74
+ if page is None:
75
+ self._screenshots[name] = None
76
+ continue
77
+ try:
78
+ data = await page.screenshot(type="png")
79
+ self._screenshots[name] = base64.b64encode(data).decode()
80
+ except Exception:
81
+ self._screenshots[name] = None
82
+
83
+ def _session_payload(self):
84
+ sessions = []
85
+ for name, session in self.daemon_state.sessions.items():
86
+ page = session.page
87
+ sessions.append(
88
+ {
89
+ "name": name,
90
+ "url": page.url if page else "",
91
+ "title": "",
92
+ "tabs": len(session.pages),
93
+ "screenshot": self._screenshots.get(name),
94
+ }
95
+ )
96
+ return {"sessions": sessions}
97
+
98
+
99
+ async def index(request: web.Request):
100
+ return web.Response(text=HTML, content_type="text/html")
101
+
102
+
103
+ async def api_sessions(request: web.Request):
104
+ state: DashboardState = request.app["dashboard_state"]
105
+ return web.json_response(state._session_payload())
106
+
107
+
108
+ async def websocket_handler(request: web.Request):
109
+ ws = web.WebSocketResponse()
110
+ await ws.prepare(request)
111
+ state: DashboardState = request.app["dashboard_state"]
112
+ try:
113
+ while not ws.closed:
114
+ await ws.send_json(state._session_payload())
115
+ await asyncio.sleep(1)
116
+ except Exception:
117
+ pass
118
+ return ws
119
+
120
+
121
+ async def start_dashboard_server(daemon_state, port: int = 9322):
122
+ dashboard_state = DashboardState(daemon_state)
123
+ app = web.Application()
124
+ app["dashboard_state"] = dashboard_state
125
+ app.router.add_get("/", index)
126
+ app.router.add_get("/api/sessions", api_sessions)
127
+ app.router.add_get("/ws", websocket_handler)
128
+
129
+ runner = web.AppRunner(app)
130
+ await runner.setup()
131
+ site = web.TCPSite(runner, "127.0.0.1", port)
132
+ await site.start()
133
+
134
+ asyncio.create_task(dashboard_state.capture_loop())
135
+ return runner, f"http://127.0.0.1:{port}"
@@ -31,7 +31,7 @@ class RefRegistry:
31
31
  self.entries: dict[str, AriaRefEntry] = {}
32
32
  self._counter = 0
33
33
 
34
- def parse(self, aria_text: str) -> str:
34
+ def parse(self, aria_text: str, max_depth: int | None = None) -> str:
35
35
  """Return annotated snapshot text with [ref=eN] tags inserted."""
36
36
  self.entries.clear()
37
37
  self._counter = 0
@@ -44,6 +44,12 @@ class RefRegistry:
44
44
  result_lines.append(line)
45
45
  continue
46
46
 
47
+ indent = len(line) - len(line.lstrip())
48
+ depth = indent // 2
49
+ if max_depth is not None and depth > max_depth:
50
+ result_lines.append(line)
51
+ continue
52
+
47
53
  role = m.group(1)
48
54
  name = m.group(2) or ""
49
55
 
@@ -12,7 +12,7 @@ from pathlib import Path
12
12
  from patchright_cli.ref_registry import RefRegistry
13
13
 
14
14
 
15
- async def take_snapshot(page, root_element=None) -> tuple[str, RefRegistry]:
15
+ async def take_snapshot(page, root_element=None, max_depth: int | None = None) -> tuple[str, RefRegistry]:
16
16
  """Take a DOM snapshot. Returns (annotated_text, registry).
17
17
 
18
18
  If root_element is provided, only snapshot the subtree under that element.
@@ -29,7 +29,7 @@ async def take_snapshot(page, root_element=None) -> tuple[str, RefRegistry]:
29
29
  return "# Empty page - no accessible elements found\n", RefRegistry()
30
30
 
31
31
  registry = RefRegistry()
32
- annotated = registry.parse(aria_text)
32
+ annotated = registry.parse(aria_text, max_depth=max_depth)
33
33
  return annotated + "\n", registry
34
34
 
35
35
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchright-cli
3
- Version: 0.4.0
3
+ Version: 0.4.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
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
22
22
  Classifier: Topic :: Software Development :: Testing
23
23
  Requires-Python: >=3.10
24
+ Requires-Dist: aiohttp>=3.9
24
25
  Requires-Dist: click
25
26
  Requires-Dist: patchright
26
27
  Requires-Dist: pyyaml
@@ -0,0 +1,12 @@
1
+ patchright_cli/__init__.py,sha256=oB58iGqDXQVGaly01ZXz2e6NME0PUB_q3tsutDeGG-4,100
2
+ patchright_cli/__main__.py,sha256=iVuBuI_rPOENLhffOEHoS7JWCDBQciqT1kPXjRuhbZ4,127
3
+ patchright_cli/cli.py,sha256=NFNouWurV7Ccq6-yR6yaF9GfM3qtTxvtkS_X9Dn5PJE,19201
4
+ patchright_cli/daemon.py,sha256=nYJLTVs3X0n7giCZ1xH7IQXg0K80-goEu7t1Fq0JaMA,63687
5
+ patchright_cli/dashboard.py,sha256=aQHujW9tHTOQhbyqm4M6OknShLaJSnRFHFZRRfs3l20,4022
6
+ patchright_cli/ref_registry.py,sha256=OeXJFdOkEaJdqWAquykve654u0mYByt3-SKatAO5pOk,2413
7
+ patchright_cli/snapshot.py,sha256=mk3WO_caVIgVmUgb_NQok_wLrvYJhQ7dXPE59mHRtrk,1497
8
+ patchright_cli-0.4.1.dist-info/METADATA,sha256=Q0nYW6Q-HF2zGAuhwD-IgrzqEc9cfeDzDya6zcyxMAM,15526
9
+ patchright_cli-0.4.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ patchright_cli-0.4.1.dist-info/entry_points.txt,sha256=Syh904eOQA8ylakyqIvG4LmIHx3LMjgeNcnFap3O0NQ,59
11
+ patchright_cli-0.4.1.dist-info/licenses/LICENSE,sha256=adszqL4YR4pQUrc7LeAFxeg5Mc2cvljXp7QPlzKVWm8,10760
12
+ patchright_cli-0.4.1.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- patchright_cli/__init__.py,sha256=C_CFSqu0FaTOqyo9f_La4N54hGykX-OR-EX9QBt-bjE,100
2
- patchright_cli/__main__.py,sha256=iVuBuI_rPOENLhffOEHoS7JWCDBQciqT1kPXjRuhbZ4,127
3
- patchright_cli/cli.py,sha256=FzHkMDl2mI4Moh6MVN0xfdyfoqJX2ubyttN8QQJct9Y,14259
4
- patchright_cli/daemon.py,sha256=Zwy35BFFzcXB9AX-Bz9Ms3YyqaBVlIyquTeegrnUfu8,54021
5
- patchright_cli/ref_registry.py,sha256=_RhBC0xcVUhghWJLqWXV3ycWngtVCEnozbX-FVZ_UXM,2171
6
- patchright_cli/snapshot.py,sha256=ZvJaikXEI9MOwBInI1rW0M3pFfla5bTFtGZYryQYM94,1446
7
- patchright_cli-0.4.0.dist-info/METADATA,sha256=Z6iUn8P_vfylXnzCHHrUiPtMZGjXjaVC1tWe1VGzdiM,15498
8
- patchright_cli-0.4.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
- patchright_cli-0.4.0.dist-info/entry_points.txt,sha256=Syh904eOQA8ylakyqIvG4LmIHx3LMjgeNcnFap3O0NQ,59
10
- patchright_cli-0.4.0.dist-info/licenses/LICENSE,sha256=adszqL4YR4pQUrc7LeAFxeg5Mc2cvljXp7QPlzKVWm8,10760
11
- patchright_cli-0.4.0.dist-info/RECORD,,