patchright-cli 0.1.0__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.
@@ -0,0 +1,909 @@
1
+ """Long-running browser server process using Patchright.
2
+
3
+ Launches a persistent Chrome context with anti-detect settings and listens
4
+ on TCP port 9321 for JSON commands from the CLI client.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import os
13
+ import signal
14
+ import struct
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ import traceback
19
+ from pathlib import Path
20
+
21
+ from patchright_cli.snapshot import save_snapshot, take_snapshot
22
+
23
+ logger = logging.getLogger("patchright-cli.daemon")
24
+
25
+ DEFAULT_PORT = 9321
26
+ DEFAULT_PROFILE_DIR = str(Path.home() / ".patchright-cli" / "profiles" / "default")
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Session management — multiple named sessions each with their own context
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ class Session:
34
+ """A single browser session (one persistent context, multiple pages/tabs)."""
35
+
36
+ def __init__(self, name: str, context, pages: list | None = None):
37
+ self.name = name
38
+ self.context = context
39
+ self.pages: list = pages or []
40
+ self.current_tab: int = 0
41
+ self.ref_map: dict[str, dict] = {}
42
+ self.console_messages: list[dict] = []
43
+ self.network_log: list[dict] = []
44
+ self._pending_dialog_action: tuple | None = None
45
+ self._profile_dir: str | None = None
46
+ self._setup_listeners()
47
+
48
+ # -- internal helpers ---------------------------------------------------
49
+
50
+ def _setup_listeners(self):
51
+ """Attach console / network listeners to all existing pages."""
52
+ for page in self.pages:
53
+ self._attach_page_listeners(page)
54
+ self.context.on("page", self._on_new_page)
55
+
56
+ def _on_new_page(self, page):
57
+ self.pages.append(page)
58
+ self.current_tab = len(self.pages) - 1
59
+ self._attach_page_listeners(page)
60
+
61
+ def _attach_page_listeners(self, page):
62
+ page.on(
63
+ "console",
64
+ lambda msg: self.console_messages.append(
65
+ {
66
+ "type": msg.type,
67
+ "text": msg.text,
68
+ "url": page.url,
69
+ "ts": time.time(),
70
+ }
71
+ ),
72
+ )
73
+ page.on(
74
+ "request",
75
+ lambda req: self.network_log.append(
76
+ {
77
+ "method": req.method,
78
+ "url": req.url,
79
+ "resource": req.resource_type,
80
+ "ts": time.time(),
81
+ }
82
+ ),
83
+ )
84
+ page.on("dialog", lambda dialog: self._handle_dialog(dialog))
85
+
86
+ def _handle_dialog(self, dialog):
87
+ """Auto-handle dialogs based on pending action or auto-dismiss."""
88
+ import asyncio
89
+
90
+ action = self._pending_dialog_action
91
+ self._pending_dialog_action = None
92
+
93
+ async def _do():
94
+ if action and action[0] == "accept":
95
+ await dialog.accept(action[1] or "")
96
+ elif action and action[0] == "dismiss":
97
+ await dialog.dismiss()
98
+ else:
99
+ await dialog.dismiss() # default: dismiss
100
+
101
+ asyncio.ensure_future(_do())
102
+
103
+ # -- page access --------------------------------------------------------
104
+
105
+ @property
106
+ def page(self):
107
+ if not self.pages:
108
+ return None
109
+ idx = max(0, min(self.current_tab, len(self.pages) - 1))
110
+ return self.pages[idx]
111
+
112
+
113
+ class DaemonState:
114
+ """Global daemon state holding all sessions."""
115
+
116
+ def __init__(self):
117
+ self.sessions: dict[str, Session] = {}
118
+ self.playwright = None
119
+ self.default_headless: bool = False
120
+
121
+ async def get_or_create_session(
122
+ self,
123
+ name: str = "default",
124
+ *,
125
+ headless: bool | None = None,
126
+ persistent: bool = True,
127
+ profile: str | None = None,
128
+ url: str | None = None,
129
+ ) -> Session:
130
+ if name in self.sessions:
131
+ return self.sessions[name]
132
+
133
+ if self.playwright is None:
134
+ from patchright.async_api import async_playwright
135
+
136
+ self.playwright = await async_playwright().start()
137
+
138
+ use_headless = headless if headless is not None else self.default_headless
139
+ profile_dir = profile or str(Path.home() / ".patchright-cli" / "profiles" / name)
140
+ Path(profile_dir).mkdir(parents=True, exist_ok=True)
141
+
142
+ context = await self.playwright.chromium.launch_persistent_context(
143
+ profile_dir,
144
+ channel="chrome",
145
+ headless=use_headless,
146
+ no_viewport=True,
147
+ args=[
148
+ "--disable-blink-features=AutomationControlled",
149
+ ],
150
+ )
151
+
152
+ pages = context.pages or []
153
+ if not pages:
154
+ page = await context.new_page()
155
+ pages = [page]
156
+
157
+ if url:
158
+ await pages[0].goto(url)
159
+
160
+ session = Session(name, context, list(pages))
161
+ self.sessions[name] = session
162
+ return session
163
+
164
+ async def close_session(self, name: str) -> bool:
165
+ session = self.sessions.pop(name, None)
166
+ if session is None:
167
+ return False
168
+ try:
169
+ await session.context.close()
170
+ except Exception:
171
+ pass
172
+ return True
173
+
174
+ async def shutdown(self):
175
+ for name in list(self.sessions):
176
+ await self.close_session(name)
177
+ if self.playwright:
178
+ await self.playwright.stop()
179
+ self.playwright = None
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Command handlers
184
+ # ---------------------------------------------------------------------------
185
+
186
+
187
+ async def _page_info(session: Session, cwd: str | None = None) -> dict:
188
+ """Return standard page info + snapshot after a state-changing command."""
189
+ page = session.page
190
+ if page is None:
191
+ return {"success": True, "output": "No page open."}
192
+
193
+ url = page.url
194
+ try:
195
+ title = await page.title()
196
+ except Exception:
197
+ title = ""
198
+
199
+ yaml_text, session.ref_map = await take_snapshot(page)
200
+ snap_path = save_snapshot(yaml_text, cwd)
201
+
202
+ output_lines = [
203
+ "### Page",
204
+ f"- Page URL: {url}",
205
+ f"- Page Title: {title}",
206
+ "### Snapshot",
207
+ f"[Snapshot]({snap_path})",
208
+ ]
209
+ return {"success": True, "output": "\n".join(output_lines), "snapshot_path": snap_path}
210
+
211
+
212
+ async def _resolve_ref(session: Session, page, ref: str):
213
+ """Resolve an element ref (e.g. 'e5') to a Playwright locator.
214
+
215
+ The snapshot assigns data-patchright-ref attributes to DOM elements,
216
+ so we can locate them directly via CSS selector.
217
+ """
218
+ loc = page.locator(f'[data-patchright-ref="{ref}"]')
219
+ try:
220
+ count = await loc.count()
221
+ if count >= 1:
222
+ return loc.first
223
+ except Exception:
224
+ pass
225
+
226
+ raise ValueError(
227
+ f"Could not locate element for ref '{ref}'. The page may have changed — run 'snapshot' to refresh."
228
+ )
229
+
230
+
231
+ async def handle_command(state: DaemonState, msg: dict) -> dict:
232
+ """Dispatch a single command and return a JSON-serialisable response."""
233
+ cmd = msg.get("command", "")
234
+ args = msg.get("args", [])
235
+ options = msg.get("options", {})
236
+ cwd = msg.get("cwd")
237
+
238
+ session_name = options.pop("session", "default") or "default"
239
+
240
+ try:
241
+ # -- Session / lifecycle commands -----------------------------------
242
+ if cmd == "open":
243
+ url = args[0] if args else None
244
+ session = await state.get_or_create_session(
245
+ session_name,
246
+ headless=options.get("headless", False),
247
+ persistent=options.get("persistent", True),
248
+ profile=options.get("profile"),
249
+ url=url,
250
+ )
251
+ return await _page_info(session, cwd)
252
+
253
+ # All other commands require an existing session
254
+ session = state.sessions.get(session_name)
255
+ if session is None:
256
+ return {
257
+ "success": False,
258
+ "output": f"Session '{session_name}' is not open. Run 'open' first.",
259
+ }
260
+
261
+ page = session.page
262
+
263
+ # -- Navigation -----------------------------------------------------
264
+ if cmd == "goto":
265
+ await page.goto(args[0])
266
+ return await _page_info(session, cwd)
267
+
268
+ if cmd == "go-back":
269
+ await page.go_back()
270
+ return await _page_info(session, cwd)
271
+
272
+ if cmd == "go-forward":
273
+ await page.go_forward()
274
+ return await _page_info(session, cwd)
275
+
276
+ if cmd == "reload":
277
+ await page.reload()
278
+ return await _page_info(session, cwd)
279
+
280
+ # -- Core interactions ----------------------------------------------
281
+ if cmd == "click":
282
+ elem = await _resolve_ref(session, page, args[0])
283
+ await elem.click()
284
+ return await _page_info(session, cwd)
285
+
286
+ if cmd == "dblclick":
287
+ elem = await _resolve_ref(session, page, args[0])
288
+ await elem.dblclick()
289
+ return await _page_info(session, cwd)
290
+
291
+ if cmd == "fill":
292
+ elem = await _resolve_ref(session, page, args[0])
293
+ await elem.fill(args[1])
294
+ return await _page_info(session, cwd)
295
+
296
+ if cmd == "type":
297
+ text = args[0] if args else ""
298
+ await page.keyboard.type(text)
299
+ return await _page_info(session, cwd)
300
+
301
+ if cmd == "hover":
302
+ elem = await _resolve_ref(session, page, args[0])
303
+ await elem.hover()
304
+ return await _page_info(session, cwd)
305
+
306
+ if cmd == "select":
307
+ elem = await _resolve_ref(session, page, args[0])
308
+ await elem.select_option(args[1])
309
+ return await _page_info(session, cwd)
310
+
311
+ if cmd == "check":
312
+ elem = await _resolve_ref(session, page, args[0])
313
+ await elem.check()
314
+ return await _page_info(session, cwd)
315
+
316
+ if cmd == "uncheck":
317
+ elem = await _resolve_ref(session, page, args[0])
318
+ await elem.uncheck()
319
+ return await _page_info(session, cwd)
320
+
321
+ if cmd == "drag":
322
+ src = await _resolve_ref(session, page, args[0])
323
+ dst = await _resolve_ref(session, page, args[1])
324
+ await src.drag_to(dst)
325
+ return await _page_info(session, cwd)
326
+
327
+ # -- Snapshot -------------------------------------------------------
328
+ if cmd == "snapshot":
329
+ yaml_text, session.ref_map = await take_snapshot(page)
330
+ fn = options.get("filename")
331
+ if fn:
332
+ Path(fn).parent.mkdir(parents=True, exist_ok=True)
333
+ Path(fn).write_text(yaml_text, encoding="utf-8")
334
+ snap_path = fn
335
+ else:
336
+ snap_path = save_snapshot(yaml_text, cwd)
337
+ url = page.url
338
+ try:
339
+ title = await page.title()
340
+ except Exception:
341
+ title = ""
342
+ output_lines = [
343
+ "### Page",
344
+ f"- Page URL: {url}",
345
+ f"- Page Title: {title}",
346
+ "### Snapshot",
347
+ f"[Snapshot]({snap_path})",
348
+ ]
349
+ return {"success": True, "output": "\n".join(output_lines), "snapshot_path": snap_path}
350
+
351
+ # -- Eval -----------------------------------------------------------
352
+ if cmd == "eval":
353
+ expr = args[0] if args else ""
354
+ result = await page.evaluate(expr)
355
+ return {"success": True, "output": json.dumps(result, indent=2, default=str)}
356
+
357
+ # -- Screenshot -----------------------------------------------------
358
+ if cmd == "screenshot":
359
+ base = Path(cwd) if cwd else Path.cwd()
360
+ snap_dir = base / ".patchright-cli"
361
+ snap_dir.mkdir(parents=True, exist_ok=True)
362
+ ts = int(time.time() * 1000)
363
+ fn = options.get("filename")
364
+ if args and args[0].startswith("e"):
365
+ # Element screenshot
366
+ elem = await _resolve_ref(session, page, args[0])
367
+ filepath = snap_dir / (fn or f"element-{ts}.png")
368
+ await elem.screenshot(path=str(filepath))
369
+ else:
370
+ filepath = snap_dir / (fn or f"page-{ts}.png")
371
+ await page.screenshot(path=str(filepath))
372
+ return {"success": True, "output": f"Screenshot saved to {filepath}"}
373
+
374
+ # -- Close ----------------------------------------------------------
375
+ if cmd == "close":
376
+ closed = await state.close_session(session_name)
377
+ return {"success": True, "output": f"Session '{session_name}' closed." if closed else "Session not found."}
378
+
379
+ # -- Keyboard -------------------------------------------------------
380
+ if cmd == "press":
381
+ await page.keyboard.press(args[0])
382
+ return await _page_info(session, cwd)
383
+
384
+ if cmd == "keydown":
385
+ await page.keyboard.down(args[0])
386
+ return {"success": True, "output": f"Key down: {args[0]}"}
387
+
388
+ if cmd == "keyup":
389
+ await page.keyboard.up(args[0])
390
+ return {"success": True, "output": f"Key up: {args[0]}"}
391
+
392
+ # -- Mouse ----------------------------------------------------------
393
+ if cmd == "mousemove":
394
+ await page.mouse.move(float(args[0]), float(args[1]))
395
+ return {"success": True, "output": f"Mouse moved to ({args[0]}, {args[1]})"}
396
+
397
+ if cmd == "mousedown":
398
+ button = args[0] if args else "left"
399
+ await page.mouse.down(button=button)
400
+ return {"success": True, "output": f"Mouse down: {button}"}
401
+
402
+ if cmd == "mouseup":
403
+ button = args[0] if args else "left"
404
+ await page.mouse.up(button=button)
405
+ return {"success": True, "output": f"Mouse up: {button}"}
406
+
407
+ if cmd == "mousewheel":
408
+ await page.mouse.wheel(float(args[0]), float(args[1]))
409
+ return {"success": True, "output": f"Mouse wheel: dx={args[0]}, dy={args[1]}"}
410
+
411
+ # -- Tabs -----------------------------------------------------------
412
+ if cmd == "tab-list":
413
+ lines = ["### Tabs"]
414
+ for i, p in enumerate(session.pages):
415
+ marker = " *" if i == session.current_tab else ""
416
+ try:
417
+ t = await p.title()
418
+ except Exception:
419
+ t = ""
420
+ lines.append(f" [{i}]{marker} {p.url} — {t}")
421
+ return {"success": True, "output": "\n".join(lines)}
422
+
423
+ if cmd == "tab-new":
424
+ url = args[0] if args else "about:blank"
425
+ new_page = await session.context.new_page()
426
+ if url and url != "about:blank":
427
+ await new_page.goto(url)
428
+ # _on_new_page callback already appended it
429
+ return await _page_info(session, cwd)
430
+
431
+ if cmd == "tab-close":
432
+ idx = int(args[0]) if args else session.current_tab
433
+ if 0 <= idx < len(session.pages):
434
+ p = session.pages.pop(idx)
435
+ await p.close()
436
+ session.current_tab = max(0, min(session.current_tab, len(session.pages) - 1))
437
+ return {"success": True, "output": f"Tab {idx} closed."}
438
+
439
+ if cmd == "tab-select":
440
+ idx = int(args[0])
441
+ if 0 <= idx < len(session.pages):
442
+ session.current_tab = idx
443
+ await session.pages[idx].bring_to_front()
444
+ return await _page_info(session, cwd)
445
+ return {"success": False, "output": f"Invalid tab index: {idx}"}
446
+
447
+ # -- Cookies --------------------------------------------------------
448
+ if cmd == "cookie-list":
449
+ cookies = await session.context.cookies()
450
+ domain = options.get("domain")
451
+ if domain:
452
+ cookies = [c for c in cookies if domain in c.get("domain", "")]
453
+ return {"success": True, "output": json.dumps(cookies, indent=2, default=str)}
454
+
455
+ if cmd == "cookie-get":
456
+ name = args[0]
457
+ cookies = await session.context.cookies()
458
+ found = [c for c in cookies if c.get("name") == name]
459
+ return {"success": True, "output": json.dumps(found, indent=2, default=str)}
460
+
461
+ if cmd == "cookie-set":
462
+ cookie_name = args[0]
463
+ cookie_value = args[1]
464
+ cookie = {"name": cookie_name, "value": cookie_value}
465
+ if options.get("domain"):
466
+ cookie["domain"] = options["domain"]
467
+ cookie["path"] = options.get("path", "/")
468
+ else:
469
+ cookie["url"] = page.url
470
+ if options.get("httpOnly") or "httpOnly" in options:
471
+ cookie["httpOnly"] = True
472
+ if options.get("secure") or "secure" in options:
473
+ cookie["secure"] = True
474
+ if options.get("sameSite"):
475
+ cookie["sameSite"] = options["sameSite"]
476
+ await session.context.add_cookies([cookie])
477
+ return {"success": True, "output": f"Cookie '{cookie_name}' set."}
478
+
479
+ if cmd == "cookie-delete":
480
+ cookie_name = args[0]
481
+ # Playwright doesn't have a direct delete — clear and re-add all except target
482
+ cookies = await session.context.cookies()
483
+ await session.context.clear_cookies()
484
+ remaining = [c for c in cookies if c.get("name") != cookie_name]
485
+ if remaining:
486
+ await session.context.add_cookies(remaining)
487
+ return {"success": True, "output": f"Cookie '{cookie_name}' deleted."}
488
+
489
+ if cmd == "cookie-clear":
490
+ await session.context.clear_cookies()
491
+ return {"success": True, "output": "All cookies cleared."}
492
+
493
+ # -- LocalStorage ---------------------------------------------------
494
+ if cmd == "localstorage-list":
495
+ result = await page.evaluate("() => JSON.stringify(localStorage)")
496
+ return {"success": True, "output": result}
497
+
498
+ if cmd == "localstorage-get":
499
+ key = args[0]
500
+ result = await page.evaluate(f"() => localStorage.getItem({json.dumps(key)})")
501
+ return {"success": True, "output": json.dumps(result, default=str)}
502
+
503
+ if cmd == "localstorage-set":
504
+ key, value = args[0], args[1]
505
+ await page.evaluate(f"() => localStorage.setItem({json.dumps(key)}, {json.dumps(value)})")
506
+ return {"success": True, "output": f"localStorage['{key}'] set."}
507
+
508
+ if cmd == "localstorage-delete":
509
+ key = args[0]
510
+ await page.evaluate(f"() => localStorage.removeItem({json.dumps(key)})")
511
+ return {"success": True, "output": f"localStorage['{key}'] deleted."}
512
+
513
+ if cmd == "localstorage-clear":
514
+ await page.evaluate("() => localStorage.clear()")
515
+ return {"success": True, "output": "localStorage cleared."}
516
+
517
+ # -- DevTools -------------------------------------------------------
518
+ if cmd == "console":
519
+ level_filter = args[0] if args else None
520
+ lines = []
521
+ for m in session.console_messages[-50:]:
522
+ if level_filter and m["type"] != level_filter:
523
+ continue
524
+ lines.append(f"[{m['type']}] {m['text']}")
525
+ return {"success": True, "output": "\n".join(lines) or "(no console messages)"}
526
+
527
+ if cmd == "network":
528
+ lines = []
529
+ for r in session.network_log[-50:]:
530
+ lines.append(f"{r['method']} {r['url']} [{r['resource']}]")
531
+ return {"success": True, "output": "\n".join(lines) or "(no network requests)"}
532
+
533
+ # -- Session management ---------------------------------------------
534
+ if cmd == "list":
535
+ lines = ["### Sessions"]
536
+ for sname, s in state.sessions.items():
537
+ marker = " *" if sname == session_name else ""
538
+ tab_count = len(s.pages)
539
+ lines.append(f" - {sname}{marker}: {tab_count} tab(s)")
540
+ return {"success": True, "output": "\n".join(lines)}
541
+
542
+ if cmd == "close-all":
543
+ names = list(state.sessions.keys())
544
+ for n in names:
545
+ await state.close_session(n)
546
+ return {"success": True, "output": f"Closed {len(names)} session(s)."}
547
+
548
+ if cmd == "kill-all":
549
+ names = list(state.sessions.keys())
550
+ for n in names:
551
+ await state.close_session(n)
552
+ return {"success": True, "output": f"Killed {len(names)} session(s)."}
553
+
554
+ # -- Dialog handling ------------------------------------------------
555
+ if cmd == "dialog-accept":
556
+ text = args[0] if args else None
557
+ session._pending_dialog_action = ("accept", text)
558
+ return {"success": True, "output": "Will accept next dialog" + (f" with '{text}'" if text else "")}
559
+
560
+ if cmd == "dialog-dismiss":
561
+ session._pending_dialog_action = ("dismiss", None)
562
+ return {"success": True, "output": "Will dismiss next dialog"}
563
+
564
+ # -- Upload ---------------------------------------------------------
565
+ if cmd == "upload":
566
+ filepath = args[0] if args else ""
567
+ elem = (
568
+ await _resolve_ref(session, page, args[1])
569
+ if len(args) > 1
570
+ else page.locator('input[type="file"]').first
571
+ )
572
+ await elem.set_input_files(filepath)
573
+ return await _page_info(session, cwd)
574
+
575
+ # -- Resize ---------------------------------------------------------
576
+ if cmd == "resize":
577
+ w, h = int(args[0]), int(args[1])
578
+ await page.set_viewport_size({"width": w, "height": h})
579
+ return {"success": True, "output": f"Viewport resized to {w}x{h}"}
580
+
581
+ # -- State save/load ------------------------------------------------
582
+ if cmd == "state-save":
583
+ base = Path(cwd) if cwd else Path.cwd()
584
+ filepath = args[0] if args else str(base / ".patchright-cli" / "state.json")
585
+ Path(filepath).parent.mkdir(parents=True, exist_ok=True)
586
+ state_data = await session.context.storage_state()
587
+ Path(filepath).write_text(json.dumps(state_data, indent=2), encoding="utf-8")
588
+ return {"success": True, "output": f"State saved to {filepath}"}
589
+
590
+ if cmd == "state-load":
591
+ filepath = args[0] if args else ""
592
+ if not filepath or not Path(filepath).exists():
593
+ return {"success": False, "output": f"File not found: {filepath}"}
594
+ state_data = json.loads(Path(filepath).read_text(encoding="utf-8"))
595
+ # Apply cookies
596
+ if state_data.get("cookies"):
597
+ await session.context.add_cookies(state_data["cookies"])
598
+ # Apply localStorage via JS
599
+ for origin_data in state_data.get("origins", []):
600
+ origin = origin_data.get("origin", "")
601
+ ls = origin_data.get("localStorage", [])
602
+ if ls and page.url.startswith(origin):
603
+ for item in ls:
604
+ await page.evaluate(
605
+ f"() => localStorage.setItem({json.dumps(item['name'])}, {json.dumps(item['value'])})"
606
+ )
607
+ return {"success": True, "output": f"State loaded from {filepath}"}
608
+
609
+ # -- Session storage ------------------------------------------------
610
+ if cmd == "sessionstorage-list":
611
+ result = await page.evaluate("() => JSON.stringify(sessionStorage)")
612
+ return {"success": True, "output": result}
613
+
614
+ if cmd == "sessionstorage-get":
615
+ key = args[0]
616
+ result = await page.evaluate(f"() => sessionStorage.getItem({json.dumps(key)})")
617
+ return {"success": True, "output": json.dumps(result, default=str)}
618
+
619
+ if cmd == "sessionstorage-set":
620
+ key, value = args[0], args[1]
621
+ await page.evaluate(f"() => sessionStorage.setItem({json.dumps(key)}, {json.dumps(value)})")
622
+ return {"success": True, "output": f"sessionStorage['{key}'] set."}
623
+
624
+ if cmd == "sessionstorage-delete":
625
+ key = args[0]
626
+ await page.evaluate(f"() => sessionStorage.removeItem({json.dumps(key)})")
627
+ return {"success": True, "output": f"sessionStorage['{key}'] deleted."}
628
+
629
+ if cmd == "sessionstorage-clear":
630
+ await page.evaluate("() => sessionStorage.clear()")
631
+ return {"success": True, "output": "sessionStorage cleared."}
632
+
633
+ # -- Route (request interception) -----------------------------------
634
+ if cmd == "route":
635
+ pattern = args[0] if args else "**/*"
636
+ status = int(options.get("status", 200))
637
+ body = options.get("body", "")
638
+ content_type = options.get("content-type", "text/plain")
639
+
640
+ async def _route_handler(route):
641
+ await route.fulfill(status=status, body=body, content_type=content_type)
642
+
643
+ if not hasattr(session, "_routes"):
644
+ session._routes = {}
645
+ await page.route(pattern, _route_handler)
646
+ session._routes[pattern] = _route_handler
647
+ return {"success": True, "output": f"Route added: {pattern} → status={status}"}
648
+
649
+ if cmd == "route-list":
650
+ routes = getattr(session, "_routes", {})
651
+ if not routes:
652
+ return {"success": True, "output": "(no active routes)"}
653
+ lines = ["### Active Routes"]
654
+ for pat in routes:
655
+ lines.append(f" - {pat}")
656
+ return {"success": True, "output": "\n".join(lines)}
657
+
658
+ if cmd == "unroute":
659
+ pattern = args[0] if args else None
660
+ routes = getattr(session, "_routes", {})
661
+ if pattern:
662
+ handler = routes.pop(pattern, None)
663
+ if handler:
664
+ await page.unroute(pattern, handler)
665
+ return {"success": True, "output": f"Route removed: {pattern}"}
666
+ else:
667
+ for pat, handler in routes.items():
668
+ await page.unroute(pat, handler)
669
+ routes.clear()
670
+ return {"success": True, "output": "All routes removed."}
671
+
672
+ # -- Run code -------------------------------------------------------
673
+ if cmd == "run-code":
674
+ code = args[0] if args else ""
675
+ fn = f"async (page) => {{ {code} }}"
676
+ result = await page.evaluate(f"async () => {{ const page = window; {code} }}")
677
+ return {
678
+ "success": True,
679
+ "output": json.dumps(result, indent=2, default=str) if result is not None else "Code executed.",
680
+ }
681
+
682
+ # -- Tracing --------------------------------------------------------
683
+ if cmd == "tracing-start":
684
+ await session.context.tracing.start(screenshots=True, snapshots=True, sources=True)
685
+ return {"success": True, "output": "Tracing started."}
686
+
687
+ if cmd == "tracing-stop":
688
+ base = Path(cwd) if cwd else Path.cwd()
689
+ snap_dir = base / ".patchright-cli"
690
+ snap_dir.mkdir(parents=True, exist_ok=True)
691
+ ts = int(time.time() * 1000)
692
+ filepath = snap_dir / f"trace-{ts}.zip"
693
+ await session.context.tracing.stop(path=str(filepath))
694
+ return {"success": True, "output": f"Tracing saved to {filepath}"}
695
+
696
+ # -- Video recording ------------------------------------------------
697
+ if cmd == "video-start":
698
+ session._video_page = page
699
+ # Video requires a new context with record_video_dir
700
+ return {
701
+ "success": False,
702
+ "output": "Video recording requires starting a new session with video enabled. Use: open --video",
703
+ }
704
+
705
+ if cmd == "video-stop":
706
+ if hasattr(session, "_video_page") and session._video_page and session._video_page.video:
707
+ path = await session._video_page.video.path()
708
+ dest = args[0] if args else str(path)
709
+ return {"success": True, "output": f"Video saved to {dest}"}
710
+ return {"success": False, "output": "No video recording in progress."}
711
+
712
+ # -- PDF ------------------------------------------------------------
713
+ if cmd == "pdf":
714
+ base = Path(cwd) if cwd else Path.cwd()
715
+ snap_dir = base / ".patchright-cli"
716
+ snap_dir.mkdir(parents=True, exist_ok=True)
717
+ fn = options.get("filename")
718
+ ts = int(time.time() * 1000)
719
+ filepath = snap_dir / (fn or f"page-{ts}.pdf")
720
+ await page.pdf(path=str(filepath))
721
+ return {"success": True, "output": f"PDF saved to {filepath}"}
722
+
723
+ # -- Delete data ----------------------------------------------------
724
+ if cmd == "delete-data":
725
+ session_obj = state.sessions.get(session_name)
726
+ if session_obj and hasattr(session_obj, "_profile_dir") and session_obj._profile_dir:
727
+ import shutil
728
+
729
+ await state.close_session(session_name)
730
+ try:
731
+ shutil.rmtree(session_obj._profile_dir, ignore_errors=True)
732
+ except Exception:
733
+ pass
734
+ return {"success": True, "output": f"Profile data deleted for '{session_name}'."}
735
+ return {"success": False, "output": "No persistent profile to delete."}
736
+
737
+ return {"success": False, "output": f"Unknown command: {cmd}"}
738
+
739
+ except Exception as e:
740
+ logger.error("Command %s failed: %s", cmd, traceback.format_exc())
741
+ return {"success": False, "output": f"Error: {e}"}
742
+
743
+
744
+ # ---------------------------------------------------------------------------
745
+ # TCP server
746
+ # ---------------------------------------------------------------------------
747
+
748
+
749
+ async def _read_message(reader: asyncio.StreamReader) -> dict | None:
750
+ """Read a length-prefixed JSON message from the stream."""
751
+ header = await reader.readexactly(4)
752
+ length = struct.unpack("!I", header)[0]
753
+ data = await reader.readexactly(length)
754
+ return json.loads(data.decode("utf-8"))
755
+
756
+
757
+ async def _write_message(writer: asyncio.StreamWriter, obj: dict):
758
+ """Write a length-prefixed JSON message to the stream."""
759
+ data = json.dumps(obj, default=str).encode("utf-8")
760
+ writer.write(struct.pack("!I", len(data)) + data)
761
+ await writer.drain()
762
+
763
+
764
+ async def _handle_client(
765
+ reader: asyncio.StreamReader,
766
+ writer: asyncio.StreamWriter,
767
+ state: DaemonState,
768
+ ):
769
+ """Handle a single client connection: read one command, respond, close."""
770
+ addr = writer.get_extra_info("peername")
771
+ logger.debug("Client connected: %s", addr)
772
+ try:
773
+ msg = await _read_message(reader)
774
+ if msg is None:
775
+ return
776
+ response = await handle_command(state, msg)
777
+ await _write_message(writer, response)
778
+ except asyncio.IncompleteReadError:
779
+ logger.debug("Client disconnected prematurely")
780
+ except Exception as e:
781
+ logger.error("Error handling client: %s", e)
782
+ try:
783
+ await _write_message(writer, {"success": False, "output": f"Daemon error: {e}"})
784
+ except Exception:
785
+ pass
786
+ finally:
787
+ writer.close()
788
+ try:
789
+ await writer.wait_closed()
790
+ except Exception:
791
+ pass
792
+
793
+
794
+ async def run_daemon(port: int = DEFAULT_PORT, headless: bool = False):
795
+ """Start the daemon TCP server."""
796
+ state = DaemonState()
797
+ state.default_headless = headless
798
+
799
+ async def client_handler(reader, writer):
800
+ await _handle_client(reader, writer, state)
801
+
802
+ server = await asyncio.start_server(client_handler, "127.0.0.1", port)
803
+ addr = server.sockets[0].getsockname()
804
+ logger.info("Daemon listening on %s:%s", addr[0], addr[1])
805
+ print(f"patchright-cli daemon listening on {addr[0]}:{addr[1]}", flush=True)
806
+
807
+ # Handle graceful shutdown
808
+ shutdown_event = asyncio.Event()
809
+
810
+ def _signal_handler():
811
+ logger.info("Shutdown signal received")
812
+ shutdown_event.set()
813
+
814
+ loop = asyncio.get_running_loop()
815
+ if sys.platform != "win32":
816
+ for sig in (signal.SIGINT, signal.SIGTERM):
817
+ loop.add_signal_handler(sig, _signal_handler)
818
+
819
+ try:
820
+ if sys.platform == "win32":
821
+ # On Windows, asyncio signal handlers don't work; just serve forever
822
+ async with server:
823
+ await server.serve_forever()
824
+ else:
825
+ async with server:
826
+ await shutdown_event.wait()
827
+ except (KeyboardInterrupt, asyncio.CancelledError):
828
+ pass
829
+ finally:
830
+ logger.info("Shutting down daemon...")
831
+ await state.shutdown()
832
+ server.close()
833
+ await server.wait_closed()
834
+ logger.info("Daemon stopped.")
835
+
836
+
837
+ def start_daemon(port: int = DEFAULT_PORT, headless: bool = False):
838
+ """Entry point to start the daemon (blocking)."""
839
+ logging.basicConfig(
840
+ level=logging.DEBUG if os.environ.get("PATCHRIGHT_DEBUG") else logging.INFO,
841
+ format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
842
+ )
843
+ asyncio.run(run_daemon(port, headless))
844
+
845
+
846
+ def ensure_daemon_running(port: int = DEFAULT_PORT, headless: bool = False) -> bool:
847
+ """Check if daemon is running; if not, start it in background. Returns True if started."""
848
+ import socket as _socket
849
+
850
+ try:
851
+ sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
852
+ sock.settimeout(1)
853
+ sock.connect(("127.0.0.1", port))
854
+ sock.close()
855
+ return False # Already running
856
+ except (ConnectionRefusedError, OSError, TimeoutError):
857
+ pass
858
+
859
+ # Start daemon as a background subprocess
860
+ cmd = [sys.executable, "-m", "patchright_cli.daemon"]
861
+ if headless:
862
+ cmd.append("--headless")
863
+ cmd.extend(["--port", str(port)])
864
+
865
+ if sys.platform == "win32":
866
+ # CREATE_NEW_PROCESS_GROUP + DETACHED_PROCESS
867
+ flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
868
+ subprocess.Popen(
869
+ cmd,
870
+ stdout=subprocess.DEVNULL,
871
+ stderr=subprocess.DEVNULL,
872
+ stdin=subprocess.DEVNULL,
873
+ creationflags=flags,
874
+ )
875
+ else:
876
+ subprocess.Popen(
877
+ cmd,
878
+ stdout=subprocess.DEVNULL,
879
+ stderr=subprocess.DEVNULL,
880
+ stdin=subprocess.DEVNULL,
881
+ start_new_session=True,
882
+ )
883
+
884
+ # Wait for daemon to be ready
885
+ import time as _time
886
+
887
+ for _ in range(30): # up to 3 seconds
888
+ _time.sleep(0.1)
889
+ try:
890
+ sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
891
+ sock.settimeout(1)
892
+ sock.connect(("127.0.0.1", port))
893
+ sock.close()
894
+ return True
895
+ except (ConnectionRefusedError, OSError, TimeoutError):
896
+ continue
897
+
898
+ raise RuntimeError(f"Failed to start daemon on port {port}")
899
+
900
+
901
+ # Allow running daemon directly: python -m patchright_cli.daemon
902
+ if __name__ == "__main__":
903
+ import argparse
904
+
905
+ parser = argparse.ArgumentParser(description="patchright-cli daemon")
906
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT)
907
+ parser.add_argument("--headless", action="store_true")
908
+ parsed = parser.parse_args()
909
+ start_daemon(parsed.port, parsed.headless)