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.
- patchright_cli/__init__.py +3 -0
- patchright_cli/__main__.py +6 -0
- patchright_cli/cli.py +348 -0
- patchright_cli/daemon.py +909 -0
- patchright_cli/snapshot.py +242 -0
- patchright_cli-0.1.0.dist-info/METADATA +311 -0
- patchright_cli-0.1.0.dist-info/RECORD +10 -0
- patchright_cli-0.1.0.dist-info/WHEEL +4 -0
- patchright_cli-0.1.0.dist-info/entry_points.txt +2 -0
- patchright_cli-0.1.0.dist-info/licenses/LICENSE +191 -0
patchright_cli/daemon.py
ADDED
|
@@ -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)
|