cdpilot 0.1.0

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,2335 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ browserctl — Zero-dependency browser automation from your terminal.
4
+
5
+ Controls any Chromium-based browser (Brave, Chrome, Chromium) via the
6
+ Chrome DevTools Protocol (CDP). No Puppeteer, no Playwright, no Selenium.
7
+
8
+ Usage:
9
+ browserctl <command> [arguments]
10
+
11
+ Environment:
12
+ CDP_PORT CDP debugging port (default: 9222)
13
+ CHROME_BIN Browser binary path (auto-detected if not set)
14
+ BROWSERCTL_PROFILE Isolated browser profile directory
15
+ """
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ import asyncio
20
+ import json
21
+ import sys
22
+ import base64
23
+ import os
24
+ import time
25
+ import urllib.request
26
+ import subprocess
27
+ import shutil
28
+
29
+ # ─── Session Configuration ───
30
+ # browserctl runs in its own Chrome instance on the configured CDP port.
31
+ # The user's existing Chrome/browser session is not affected.
32
+
33
+ CDP_PORT = int(os.environ.get("CDP_PORT", "9222"))
34
+ CDP_BASE = f"http://127.0.0.1:{CDP_PORT}"
35
+ CHROME_BIN = os.environ.get("CHROME_BIN")
36
+ PROFILE_DIR = os.environ.get("BROWSERCTL_PROFILE", os.path.expanduser("~/.browserctl/profile"))
37
+ SCREENSHOT_DIR = "/tmp"
38
+
39
+ DEV_EXTENSIONS_FILE = os.path.join(PROFILE_DIR, 'dev-extensions.json')
40
+ PROXY_CONFIG_FILE = os.path.join(PROFILE_DIR, 'proxy.json')
41
+ HEADLESS_CONFIG_FILE = os.path.join(PROFILE_DIR, 'headless.json')
42
+ DOWNLOAD_CONFIG_FILE = os.path.join(PROFILE_DIR, 'download-config.json')
43
+ SESSION_FILE = os.path.join(PROFILE_DIR, 'sessions.json')
44
+
45
+ # ─── Session Management ───
46
+ # Each session gets its own browser window.
47
+ # The BROWSER_SESSION env var sets the session identifier.
48
+
49
+ def _get_session_id():
50
+ """Return the unique identifier for the current session."""
51
+ sid = os.environ.get('BROWSER_SESSION', '')
52
+ if sid:
53
+ return sid
54
+ # Default session — all commands share the same window
55
+ return "browserctl-default"
56
+
57
+ def _load_sessions():
58
+ """Read the session registry."""
59
+ try:
60
+ with open(SESSION_FILE) as f:
61
+ return json.load(f)
62
+ except (FileNotFoundError, json.JSONDecodeError):
63
+ return {}
64
+
65
+ def _save_sessions(sessions):
66
+ """Write the session registry."""
67
+ os.makedirs(os.path.dirname(SESSION_FILE), exist_ok=True)
68
+ with open(SESSION_FILE, 'w') as f:
69
+ json.dump(sessions, f, indent=2)
70
+
71
+ def _cleanup_stale_sessions():
72
+ """Remove sessions whose window/target no longer exists."""
73
+ sessions = _load_sessions()
74
+ if not sessions:
75
+ return sessions
76
+ tabs = cdp_get("/json") or []
77
+ active_target_ids = {t.get("id") for t in tabs}
78
+ cleaned = {}
79
+ for sid, info in sessions.items():
80
+ # Keep session if its target is still active
81
+ if info.get("target_id") in active_target_ids:
82
+ cleaned[sid] = info
83
+ if len(cleaned) != len(sessions):
84
+ _save_sessions(cleaned)
85
+ return cleaned
86
+
87
+ # ─── Global State ───
88
+ INTERCEPT_RULES = [] # list of (pattern, action, data) tuples
89
+ DIALOG_MODE = None # 'accept', 'dismiss', or None
90
+ _current_session_id = None # lazy init
91
+
92
+ # ─── Visual Indicator Overlay CSS ───
93
+
94
+ GLOW_CSS = """
95
+ (function() {
96
+ if (document.getElementById('browserctl-glow')) return 'already active';
97
+ const style = document.createElement('style');
98
+ style.id = 'browserctl-glow';
99
+ style.textContent = `
100
+ @keyframes browserctl-pulse {
101
+ 0%, 100% { box-shadow: inset 0 0 20px 4px rgba(34, 197, 94, 0.25), inset 0 0 60px 8px rgba(34, 197, 94, 0.08); }
102
+ 50% { box-shadow: inset 0 0 30px 6px rgba(34, 197, 94, 0.35), inset 0 0 80px 12px rgba(34, 197, 94, 0.12); }
103
+ }
104
+ body::after {
105
+ content: '';
106
+ position: fixed;
107
+ top: 0; left: 0; right: 0; bottom: 0;
108
+ pointer-events: none;
109
+ z-index: 2147483647;
110
+ animation: browserctl-pulse 2s ease-in-out infinite;
111
+ border: 2px solid rgba(34, 197, 94, 0.3);
112
+ border-radius: 0;
113
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
114
+ }
115
+ `;
116
+ document.head.appendChild(style);
117
+ return 'glow active';
118
+ })()
119
+ """
120
+
121
+ GLOW_OFF_CSS = """
122
+ (function() {
123
+ const el = document.getElementById('browserctl-glow');
124
+ if (el) { el.remove(); return 'glow off'; }
125
+ return 'already off';
126
+ })()
127
+ """
128
+
129
+ # ─── Input Blocker (prevent user interference during automation) ───
130
+
131
+ INPUT_BLOCKER_ON = """
132
+ (function() {
133
+ if (document.getElementById('browserctl-input-blocker')) return 'blocker already active';
134
+ const overlay = document.createElement('div');
135
+ overlay.id = 'browserctl-input-blocker';
136
+ overlay.style.cssText = `
137
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
138
+ z-index: 2147483646; cursor: not-allowed;
139
+ background: transparent;
140
+ `;
141
+ overlay.addEventListener('mousedown', e => { e.stopPropagation(); e.preventDefault(); }, true);
142
+ overlay.addEventListener('mouseup', e => { e.stopPropagation(); e.preventDefault(); }, true);
143
+ overlay.addEventListener('click', e => { e.stopPropagation(); e.preventDefault(); }, true);
144
+ overlay.addEventListener('dblclick', e => { e.stopPropagation(); e.preventDefault(); }, true);
145
+ overlay.addEventListener('contextmenu', e => { e.stopPropagation(); e.preventDefault(); }, true);
146
+ overlay.addEventListener('wheel', e => { e.stopPropagation(); e.preventDefault(); }, {capture: true, passive: false});
147
+ document.addEventListener('keydown', function _cb(e) {
148
+ if (!document.getElementById('browserctl-input-blocker')) {
149
+ document.removeEventListener('keydown', _cb, true);
150
+ return;
151
+ }
152
+ e.stopPropagation(); e.preventDefault();
153
+ }, true);
154
+ document.addEventListener('keyup', function _cb(e) {
155
+ if (!document.getElementById('browserctl-input-blocker')) {
156
+ document.removeEventListener('keyup', _cb, true);
157
+ return;
158
+ }
159
+ e.stopPropagation(); e.preventDefault();
160
+ }, true);
161
+ document.addEventListener('keypress', function _cb(e) {
162
+ if (!document.getElementById('browserctl-input-blocker')) {
163
+ document.removeEventListener('keypress', _cb, true);
164
+ return;
165
+ }
166
+ e.stopPropagation(); e.preventDefault();
167
+ }, true);
168
+ document.body.appendChild(overlay);
169
+ return 'input blocker active';
170
+ })()
171
+ """
172
+
173
+ INPUT_BLOCKER_OFF = """
174
+ (function() {
175
+ const el = document.getElementById('browserctl-input-blocker');
176
+ if (el) { el.remove(); return 'input blocker off'; }
177
+ return 'blocker already off';
178
+ })()
179
+ """
180
+
181
+ # ─── Automation Indicator Wrapper ───
182
+
183
+ _glow_script_id = None # addScriptToEvaluateOnNewDocument identifier
184
+
185
+ GLOW_ACTIVATE_JS = "document.documentElement.dataset.browserctlActive = 'true';"
186
+ GLOW_DEACTIVATE_JS = """
187
+ document.documentElement.removeAttribute('data-browserctl-active');
188
+ var _go = document.getElementById('browserctl-glow-overlay');
189
+ var _gs = document.getElementById('browserctl-glow-style');
190
+ if (_go) _go.remove();
191
+ if (_gs) _gs.remove();
192
+ """
193
+
194
+ async def _control_start(ws_url):
195
+ """Enable visual indicator and input blocker at command start."""
196
+ global _glow_script_id
197
+ try:
198
+ # Inject activation marker on every new page load
199
+ r = await cdp_send(ws_url, [
200
+ (901, "Page.addScriptToEvaluateOnNewDocument", {"source": GLOW_ACTIVATE_JS}),
201
+ (902, "Runtime.evaluate", {"expression": GLOW_ACTIVATE_JS, "returnByValue": True}),
202
+ (903, "Runtime.evaluate", {"expression": INPUT_BLOCKER_ON, "returnByValue": True}),
203
+ ])
204
+ # Save script identifier for cleanup
205
+ if 901 in r and "identifier" in r.get(901, {}):
206
+ _glow_script_id = r[901]["identifier"]
207
+ except Exception:
208
+ pass # Silent fail if no connection
209
+
210
+ async def _control_end(ws_url):
211
+ """Disable visual indicator and input blocker at command end."""
212
+ global _glow_script_id
213
+ try:
214
+ cmds = [
215
+ (903, "Runtime.evaluate", {"expression": GLOW_DEACTIVATE_JS, "returnByValue": True}),
216
+ (904, "Runtime.evaluate", {"expression": INPUT_BLOCKER_OFF, "returnByValue": True}),
217
+ ]
218
+ # Remove the persistent new-document script
219
+ if _glow_script_id:
220
+ cmds.append((905, "Page.removeScriptToEvaluateOnNewDocument", {"identifier": _glow_script_id}))
221
+ _glow_script_id = None
222
+ await cdp_send(ws_url, cmds)
223
+ except Exception:
224
+ pass # Silent fail if no connection
225
+
226
+ # ─── Connection Helpers ───
227
+
228
+ def cdp_get(path):
229
+ """GET request to a CDP HTTP endpoint."""
230
+ try:
231
+ with urllib.request.urlopen(f"{CDP_BASE}{path}", timeout=3) as resp:
232
+ return json.loads(resp.read())
233
+ except Exception as e:
234
+ return None
235
+
236
+
237
+ def get_tabs():
238
+ """Retrieve all CDP targets."""
239
+ result = cdp_get("/json")
240
+ if result is None:
241
+ print("CDP connection error. Is the browser running?", file=sys.stderr)
242
+ sys.exit(1)
243
+ return result
244
+
245
+
246
+ def _get_session_window_target_id():
247
+ """Return the window target ID for the current session (None if not set)."""
248
+ sid = _get_session_id()
249
+ sessions = _load_sessions()
250
+ info = sessions.get(sid)
251
+ if not info:
252
+ return None
253
+ return info.get("target_id")
254
+
255
+ SESSION_IDLE_TIMEOUT = 300 # 5 minutes (seconds)
256
+
257
+
258
+ def _cleanup_idle_sessions():
259
+ """Close session windows idle for more than 5 minutes."""
260
+ sessions = _load_sessions()
261
+ if not sessions:
262
+ return
263
+ now = time.time()
264
+ to_remove = []
265
+ for sid, info in sessions.items():
266
+ last_used = info.get("last_used", 0)
267
+ if last_used and (now - last_used) > SESSION_IDLE_TIMEOUT:
268
+ to_remove.append(sid)
269
+ target_id = info.get("target_id")
270
+ if target_id:
271
+ try:
272
+ urllib.request.urlopen(
273
+ f"{CDP_BASE}/json/close/{target_id}", timeout=2)
274
+ except Exception:
275
+ pass
276
+ if to_remove:
277
+ for sid in to_remove:
278
+ sessions.pop(sid, None)
279
+ _save_sessions(sessions)
280
+
281
+
282
+ def _update_session_timestamp():
283
+ """Update the last_used timestamp for the current session."""
284
+ sid = _get_session_id()
285
+ sessions = _load_sessions()
286
+ if sid in sessions:
287
+ sessions[sid]["last_used"] = time.time()
288
+ _save_sessions(sessions)
289
+
290
+
291
+ def _create_session_window():
292
+ """Create a new tab for the current session and register it.
293
+
294
+ Uses CDP Target.createTarget to open a tab in the existing window
295
+ (does not steal focus). newWindow: False — no new window is opened.
296
+ """
297
+ sid = _get_session_id()
298
+
299
+ # Check existing tabs — reuse if already open
300
+ tabs = cdp_get("/json")
301
+ if tabs:
302
+ pages = [t for t in tabs if t.get("type") == "page"]
303
+ if pages:
304
+ # A page is already open, no need to create a new tab
305
+ target_id = pages[0].get("id")
306
+ if target_id:
307
+ sessions = _load_sessions()
308
+ sessions[sid] = {
309
+ "target_id": target_id,
310
+ "created": time.strftime("%Y-%m-%d %H:%M:%S"),
311
+ "last_used": time.time(),
312
+ }
313
+ _save_sessions(sessions)
314
+ return target_id
315
+
316
+ # No tabs open — create a new tab (not a window)
317
+ try:
318
+ req = urllib.request.Request(
319
+ f"{CDP_BASE}/json/new?about:blank",
320
+ method="PUT"
321
+ )
322
+ resp = urllib.request.urlopen(req, timeout=5)
323
+ data = json.loads(resp.read())
324
+ target_id = data.get("id")
325
+ except Exception:
326
+ target_id = None
327
+
328
+ if target_id:
329
+ sessions = _load_sessions()
330
+ sessions[sid] = {
331
+ "target_id": target_id,
332
+ "created": time.strftime("%Y-%m-%d %H:%M:%S"),
333
+ "last_used": time.time(),
334
+ }
335
+ _save_sessions(sessions)
336
+
337
+ return target_id
338
+
339
+ def _ensure_session_window():
340
+ """Create a session window if none exists, or validate the existing one."""
341
+ target_id = _get_session_window_target_id()
342
+ if target_id:
343
+ # Verify target still exists
344
+ tabs = cdp_get("/json") or []
345
+ if any(t.get("id") == target_id for t in tabs):
346
+ return target_id
347
+ # Target gone — clean up and recreate
348
+ sessions = _load_sessions()
349
+ sid = _get_session_id()
350
+ sessions.pop(sid, None)
351
+ _save_sessions(sessions)
352
+ return _create_session_window()
353
+
354
+ def get_page_ws(prefer_url=None):
355
+ """Find the WebSocket URL for the appropriate page target.
356
+
357
+ If a session window exists, only looks at tabs in that window.
358
+ Otherwise creates a new session window.
359
+ """
360
+ tabs = get_tabs()
361
+ pages = [t for t in tabs if t.get("type") == "page"]
362
+
363
+ # Get target ID for the current session window
364
+ session_target_id = _get_session_window_target_id()
365
+
366
+ if session_target_id and pages:
367
+ session_page = None
368
+ for p in pages:
369
+ if p.get("id") == session_target_id:
370
+ session_page = p
371
+ break
372
+
373
+ if session_page:
374
+ return session_page["webSocketDebuggerUrl"], session_page
375
+ else:
376
+ # Session target gone — clean up
377
+ sessions = _load_sessions()
378
+ sid = _get_session_id()
379
+ sessions.pop(sid, None)
380
+ _save_sessions(sessions)
381
+
382
+ # No session window or no tabs — create one
383
+ if cdp_get("/json/version"):
384
+ new_target_id = _create_session_window()
385
+ if new_target_id:
386
+ # Short wait for CDP to register the new target
387
+ for _ in range(10):
388
+ time.sleep(0.3)
389
+ tabs = get_tabs()
390
+ for t in tabs:
391
+ if t.get("id") == new_target_id:
392
+ return t["webSocketDebuggerUrl"], t
393
+
394
+ # Fallback: use any available page
395
+ if pages:
396
+ if prefer_url:
397
+ for p in pages:
398
+ if prefer_url in p.get("url", ""):
399
+ return p["webSocketDebuggerUrl"], p
400
+ for p in pages:
401
+ url = p.get("url", "")
402
+ if "chrome://" not in url and "omnibox" not in url:
403
+ return p["webSocketDebuggerUrl"], p
404
+ return pages[0]["webSocketDebuggerUrl"], pages[0]
405
+
406
+ print("No active page found.", file=sys.stderr)
407
+ sys.exit(1)
408
+
409
+
410
+ def activate_tab(page_id):
411
+ """Bring a tab to the foreground."""
412
+ try:
413
+ urllib.request.urlopen(f"{CDP_BASE}/json/activate/{page_id}", timeout=2)
414
+ except:
415
+ pass
416
+
417
+
418
+ # ─── CDP WebSocket Operations ───
419
+
420
+ async def cdp_send(ws_url, commands, timeout=15):
421
+ """Send multiple CDP commands and collect results."""
422
+ import websockets
423
+ results = {}
424
+ async with websockets.connect(ws_url, max_size=100 * 1024 * 1024) as ws:
425
+ for cmd_id, method, params in commands:
426
+ await ws.send(json.dumps({"id": cmd_id, "method": method, "params": params or {}}))
427
+
428
+ pending = {c[0] for c in commands}
429
+ start = time.time()
430
+ while pending and (time.time() - start) < timeout:
431
+ try:
432
+ resp = await asyncio.wait_for(ws.recv(), timeout=2)
433
+ data = json.loads(resp)
434
+ if "id" in data and data["id"] in pending:
435
+ pending.discard(data["id"])
436
+ results[data["id"]] = data.get("result", data.get("error", {}))
437
+ except asyncio.TimeoutError:
438
+ continue
439
+ return results
440
+
441
+
442
+ async def navigate_collect(ws_url, url, network=False, console=False, glow=True):
443
+ """Navigate to a page and optionally collect network/console events."""
444
+ import websockets
445
+ events = {"network": [], "console": []}
446
+
447
+ async with websockets.connect(ws_url, max_size=100 * 1024 * 1024) as ws:
448
+ # Enable CDP domains
449
+ sid = 1
450
+ for domain in ["Page", "Network", "Runtime", "Log"]:
451
+ await ws.send(json.dumps({"id": sid, "method": f"{domain}.enable", "params": {}}))
452
+ sid += 1
453
+
454
+ # Navigate
455
+ await ws.send(json.dumps({"id": 100, "method": "Page.navigate", "params": {"url": url}}))
456
+
457
+ # Collect events until page load
458
+ loaded = False
459
+ start = time.time()
460
+ while time.time() - start < 20:
461
+ try:
462
+ resp = await asyncio.wait_for(ws.recv(), timeout=1)
463
+ data = json.loads(resp)
464
+ method = data.get("method", "")
465
+
466
+ if network and method == "Network.responseReceived":
467
+ r = data["params"]["response"]
468
+ events["network"].append({
469
+ "url": r.get("url", "")[:150],
470
+ "status": r.get("status"),
471
+ "type": data["params"].get("type", ""),
472
+ "mime": r.get("mimeType", ""),
473
+ })
474
+
475
+ if console and method == "Runtime.consoleAPICalled":
476
+ args = data["params"].get("args", [])
477
+ text = " ".join(str(a.get("value", a.get("description", ""))) for a in args)
478
+ events["console"].append({
479
+ "type": data["params"].get("type", "log"),
480
+ "text": text[:300],
481
+ })
482
+
483
+ if console and method == "Log.entryAdded":
484
+ entry = data["params"]["entry"]
485
+ events["console"].append({
486
+ "type": entry.get("level", "log"),
487
+ "text": entry.get("text", "")[:300],
488
+ })
489
+
490
+ if method == "Page.loadEventFired":
491
+ loaded = True
492
+ await asyncio.sleep(1.5)
493
+ break
494
+ except asyncio.TimeoutError:
495
+ if loaded:
496
+ break
497
+
498
+ # Inject visual indicator (sets data-browserctl-active attribute)
499
+ if glow:
500
+ await ws.send(json.dumps({
501
+ "id": 200, "method": "Runtime.evaluate",
502
+ "params": {"expression": GLOW_ACTIVATE_JS, "returnByValue": True}
503
+ }))
504
+
505
+ # Inject dev extension content scripts via the existing WS connection
506
+ ext_scripts = _get_dev_extension_scripts(url)
507
+ ext_injected = []
508
+ for ext_name, filename, code, _ in ext_scripts:
509
+ try:
510
+ sid += 1
511
+ await ws.send(json.dumps({
512
+ "id": sid, "method": "Runtime.evaluate",
513
+ "params": {"expression": code, "returnByValue": True}
514
+ }))
515
+ ext_injected.append(f"{ext_name}/{filename}")
516
+ except Exception:
517
+ pass
518
+ if ext_injected:
519
+ # Wait for injection responses
520
+ for _ in ext_injected:
521
+ try:
522
+ await asyncio.wait_for(ws.recv(), timeout=3)
523
+ except Exception:
524
+ pass
525
+ print(f" Dev extension injected: {', '.join(ext_injected)}")
526
+
527
+ # Get DOM text content
528
+ await ws.send(json.dumps({
529
+ "id": 201, "method": "Runtime.evaluate",
530
+ "params": {
531
+ "expression": "document.body.innerText.substring(0, 10000)",
532
+ "returnByValue": True,
533
+ }
534
+ }))
535
+
536
+ content = ""
537
+ while True:
538
+ try:
539
+ resp = await asyncio.wait_for(ws.recv(), timeout=5)
540
+ data = json.loads(resp)
541
+ m = data.get("method", "")
542
+ if network and m == "Network.responseReceived":
543
+ r = data["params"]["response"]
544
+ events["network"].append({
545
+ "url": r.get("url", "")[:150],
546
+ "status": r.get("status"),
547
+ "type": data["params"].get("type", ""),
548
+ })
549
+ if data.get("id") == 201:
550
+ content = data.get("result", {}).get("result", {}).get("value", "")
551
+ break
552
+ except asyncio.TimeoutError:
553
+ break
554
+
555
+ return content, events
556
+
557
+ # ─── Helper Functions ───
558
+
559
+ def get_dev_extensions():
560
+ """Read registered dev mode extension paths."""
561
+ if os.path.exists(DEV_EXTENSIONS_FILE):
562
+ try:
563
+ with open(DEV_EXTENSIONS_FILE) as f:
564
+ return json.load(f)
565
+ except:
566
+ pass
567
+ return []
568
+
569
+ def save_dev_extensions(extensions):
570
+ """Save dev mode extension paths."""
571
+ os.makedirs(os.path.dirname(DEV_EXTENSIONS_FILE), exist_ok=True)
572
+ with open(DEV_EXTENSIONS_FILE, 'w') as f:
573
+ json.dump(extensions, f, indent=2)
574
+
575
+
576
+ def _match_url_pattern(pattern, url):
577
+ """Test a Chrome extension match pattern against a URL.
578
+
579
+ Supported pattern formats:
580
+ *://*.google.com/*
581
+ https://example.com/path/*
582
+ <all_urls>
583
+ """
584
+ if pattern == '<all_urls>':
585
+ return url.startswith('http://') or url.startswith('https://')
586
+
587
+ import re
588
+ # Pattern: scheme://host/path
589
+ m = re.match(r'^(\*|https?|ftp)://((?:\*\.)?[^/]*)(/.*)$', pattern)
590
+ if not m:
591
+ return False
592
+ p_scheme, p_host, p_path = m.groups()
593
+
594
+ # Parse URL
595
+ from urllib.parse import urlparse
596
+ parsed = urlparse(url)
597
+ u_scheme = parsed.scheme
598
+ u_host = parsed.hostname or ''
599
+ u_path = parsed.path or '/'
600
+ if not u_path:
601
+ u_path = '/'
602
+ if parsed.query:
603
+ u_path += '?' + parsed.query
604
+
605
+ # Scheme check
606
+ if p_scheme != '*' and p_scheme != u_scheme:
607
+ return False
608
+
609
+ # Host check
610
+ if p_host == '*':
611
+ pass # any host
612
+ elif p_host.startswith('*.'):
613
+ suffix = p_host[2:]
614
+ if u_host != suffix and not u_host.endswith('.' + suffix):
615
+ return False
616
+ else:
617
+ if u_host != p_host:
618
+ return False
619
+
620
+ # Path check — convert glob to regex
621
+ path_re = re.escape(p_path).replace(r'\*', '.*')
622
+ if not re.fullmatch(path_re, u_path):
623
+ return False
624
+
625
+ return True
626
+
627
+
628
+ def _get_dev_extension_scripts(page_url):
629
+ """Collect content_scripts from dev extensions matching the current page URL.
630
+
631
+ Returns: list of (ext_name, filename, code, type) tuples
632
+ type: 'js' or 'css'
633
+ """
634
+ dev_exts = get_dev_extensions()
635
+ if not dev_exts:
636
+ return []
637
+
638
+ scripts = []
639
+ for ext_path in dev_exts:
640
+ manifest_path = os.path.join(ext_path, 'manifest.json')
641
+ if not os.path.exists(manifest_path):
642
+ continue
643
+ try:
644
+ with open(manifest_path) as f:
645
+ manifest = json.load(f)
646
+ except Exception:
647
+ continue
648
+
649
+ ext_name = manifest.get('name', os.path.basename(ext_path))
650
+
651
+ for cs in manifest.get('content_scripts', []):
652
+ matches = cs.get('matches', [])
653
+ matched = any(_match_url_pattern(pat, page_url) for pat in matches)
654
+ if not matched:
655
+ continue
656
+
657
+ for js_file in cs.get('js', []):
658
+ js_path = os.path.join(ext_path, js_file)
659
+ if not os.path.exists(js_path):
660
+ continue
661
+ try:
662
+ with open(js_path) as f:
663
+ scripts.append((ext_name, js_file, f.read(), 'js'))
664
+ except Exception:
665
+ pass
666
+
667
+ for css_file in cs.get('css', []):
668
+ css_path = os.path.join(ext_path, css_file)
669
+ if not os.path.exists(css_path):
670
+ continue
671
+ try:
672
+ with open(css_path) as f:
673
+ css_code = f.read()
674
+ css_escaped = json.dumps(css_code)
675
+ inject_css = f"""(function() {{
676
+ const style = document.createElement('style');
677
+ style.textContent = {css_escaped};
678
+ document.head.appendChild(style);
679
+ }})()"""
680
+ scripts.append((ext_name, css_file, inject_css, 'css'))
681
+ except Exception:
682
+ pass
683
+
684
+ return scripts
685
+
686
+
687
+ async def inject_dev_extension_scripts(ws_url, page_url):
688
+ """Inject dev extension content_scripts via CDP (separate connection).
689
+
690
+ For use outside navigate_collect (e.g. after cmd_eval).
691
+ """
692
+ scripts = _get_dev_extension_scripts(page_url)
693
+ if not scripts:
694
+ return
695
+
696
+ injected = []
697
+ for ext_name, filename, code, _ in scripts:
698
+ try:
699
+ await cdp_send(ws_url, [(
700
+ 500, "Runtime.evaluate", {
701
+ "expression": code,
702
+ "returnByValue": True,
703
+ }
704
+ )])
705
+ injected.append(f"{ext_name}/{filename}")
706
+ except Exception:
707
+ pass
708
+
709
+ if injected:
710
+ print(f" Dev extension scripts injected: {', '.join(injected)}")
711
+
712
+
713
+ def get_proxy_config():
714
+ """Read proxy configuration."""
715
+ proxy = os.environ.get('CHROME_PROXY', '')
716
+ if proxy:
717
+ return proxy
718
+ if os.path.exists(PROXY_CONFIG_FILE):
719
+ try:
720
+ with open(PROXY_CONFIG_FILE) as f:
721
+ data = json.load(f)
722
+ return data.get('proxy', '')
723
+ except:
724
+ pass
725
+ return ''
726
+
727
+ def get_headless_config():
728
+ """Return whether headless mode is active."""
729
+ env = os.environ.get('CHROME_HEADLESS', '')
730
+ if env:
731
+ return env.lower() in ('1', 'true', 'yes')
732
+ if os.path.exists(HEADLESS_CONFIG_FILE):
733
+ try:
734
+ with open(HEADLESS_CONFIG_FILE) as f:
735
+ return json.load(f).get('headless', False)
736
+ except:
737
+ pass
738
+ return False
739
+
740
+ # ─── Commands ───
741
+
742
+ def cmd_launch():
743
+ """Launch the browser with CDP enabled (isolated session — does not touch existing browser)."""
744
+ if cdp_get('/json/version'):
745
+ print(f'Browser already running on port {CDP_PORT}.')
746
+ return
747
+
748
+ if not CHROME_BIN:
749
+ print('Error: CHROME_BIN is not set. Export CHROME_BIN=/path/to/browser.', file=sys.stderr)
750
+ sys.exit(1)
751
+
752
+ os.makedirs(PROFILE_DIR, exist_ok=True)
753
+
754
+ print(f'Launching browser (isolated session, port {CDP_PORT})...')
755
+
756
+ chrome_args = [
757
+ CHROME_BIN,
758
+ f'--remote-debugging-port={CDP_PORT}',
759
+ f'--user-data-dir={PROFILE_DIR}',
760
+ '--remote-allow-origins=*',
761
+ '--disable-fre', '--no-default-browser-check', '--no-first-run',
762
+ # ─── Brave-specific features (harmless on other Chromium builds) ───
763
+ '--disable-brave-rewards',
764
+ '--disable-brave-wallet',
765
+ '--disable-brave-shields',
766
+ '--disable-brave-news',
767
+ '--disable-brave-vpn',
768
+ '--disable-brave-wayback-machine',
769
+ '--disable-ai-chat',
770
+ '--disable-speedreader',
771
+ '--disable-tor',
772
+ '--disable-ipfs',
773
+ '--disable-brave-extension',
774
+ # ─── Chromium performance flags ───
775
+ '--disable-background-networking',
776
+ '--disable-background-timer-throttling',
777
+ '--disable-backgrounding-occluded-windows',
778
+ '--disable-breakpad',
779
+ '--disable-client-side-phishing-detection',
780
+ '--disable-component-update',
781
+ '--disable-default-apps',
782
+ '--disable-domain-reliability',
783
+ '--disable-hang-monitor',
784
+ '--disable-ipc-flooding-protection',
785
+ '--disable-popup-blocking',
786
+ '--disable-prompt-on-repost',
787
+ '--disable-renderer-backgrounding',
788
+ '--disable-sync',
789
+ '--disable-translate',
790
+ '--metrics-recording-only',
791
+ '--no-pings',
792
+ '--safebrowsing-disable-auto-update',
793
+ '--password-store=basic',
794
+ # ─── GPU / rendering ───
795
+ '--disable-gpu-compositing',
796
+ '--disable-smooth-scrolling',
797
+ '--new-window', 'about:blank',
798
+ ]
799
+
800
+ # Dev extensions
801
+ dev_exts = get_dev_extensions()
802
+ valid_exts = [p for p in dev_exts if os.path.isdir(p)]
803
+ if valid_exts:
804
+ ext_list = ','.join(valid_exts)
805
+ chrome_args.append(f"--load-extension={ext_list}")
806
+ print(f' Dev extensions: {len(valid_exts)}')
807
+
808
+ # Proxy
809
+ proxy = get_proxy_config()
810
+ if proxy:
811
+ chrome_args.append(f'--proxy-server={proxy}')
812
+ print(f' Proxy: {proxy}')
813
+
814
+ # Headless
815
+ if get_headless_config():
816
+ chrome_args.append('--headless=new')
817
+ print(' Mode: headless')
818
+
819
+ subprocess.Popen(chrome_args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
820
+
821
+ for _ in range(20):
822
+ time.sleep(0.5)
823
+ if cdp_get('/json/version'):
824
+ print(f'CDP ready! (port {CDP_PORT})')
825
+ return
826
+ print('Failed to start CDP (timeout).', file=sys.stderr)
827
+ sys.exit(1)
828
+
829
+
830
+ def cmd_tabs():
831
+ tabs = get_tabs()
832
+ pages = [t for t in tabs if t.get("type") == "page"]
833
+ for i, p in enumerate(pages):
834
+ url = p.get("url", "")
835
+ icon = "🔵" if url.startswith("chrome://") else "🟢"
836
+ print(f" {icon} [{i}] {p.get('title', '')[:70]}")
837
+ print(f" {url[:120]}")
838
+ print(f"\n{len(pages)} pages, {len(tabs)} targets")
839
+
840
+
841
+ async def cmd_go(url):
842
+ if not cdp_get("/json/version"):
843
+ cmd_launch()
844
+
845
+ ws, page = get_page_ws()
846
+ content, _ = await navigate_collect(ws, url)
847
+ print(content)
848
+
849
+
850
+ async def cmd_content():
851
+ ws, _ = get_page_ws()
852
+ r = await cdp_send(ws, [(1, "Runtime.evaluate", {
853
+ "expression": "document.body.innerText.substring(0, 10000)",
854
+ "returnByValue": True,
855
+ })])
856
+ print(r.get(1, {}).get("result", {}).get("value", "(empty)"))
857
+
858
+
859
+ async def cmd_html():
860
+ ws, _ = get_page_ws()
861
+ r = await cdp_send(ws, [(1, "Runtime.evaluate", {
862
+ "expression": "document.documentElement.outerHTML.substring(0, 30000)",
863
+ "returnByValue": True,
864
+ })])
865
+ print(r.get(1, {}).get("result", {}).get("value", "(empty)"))
866
+
867
+
868
+ async def cmd_shot(output=None):
869
+ if not output:
870
+ output = f"{SCREENSHOT_DIR}/screenshot.png"
871
+ ws, _ = get_page_ws()
872
+ r = await cdp_send(ws, [(1, "Page.captureScreenshot", {"format": "png", "captureBeyondViewport": True})])
873
+ b64 = r.get(1, {}).get("data", "")
874
+ if b64:
875
+ with open(output, "wb") as f:
876
+ f.write(base64.b64decode(b64))
877
+ print(f"{output}")
878
+ else:
879
+ print("Screenshot failed", file=sys.stderr)
880
+
881
+
882
+ async def cmd_eval(js_code):
883
+ ws, _ = get_page_ws()
884
+ r = await cdp_send(ws, [(1, "Runtime.evaluate", {
885
+ "expression": js_code,
886
+ "returnByValue": True,
887
+ "awaitPromise": True,
888
+ })])
889
+ result = r.get(1, {})
890
+ if "exceptionDetails" in result:
891
+ exc = result["exceptionDetails"]
892
+ print(f"Error: {exc.get('text', '')} — {exc.get('exception', {}).get('description', '')}")
893
+ else:
894
+ val = result.get("result", {})
895
+ if val.get("type") == "undefined":
896
+ print("(undefined)")
897
+ elif val.get("value") is not None:
898
+ v = val["value"]
899
+ print(json.dumps(v, indent=2, ensure_ascii=False) if isinstance(v, (dict, list)) else str(v))
900
+ else:
901
+ print(json.dumps(val, indent=2, ensure_ascii=False))
902
+
903
+
904
+ async def cmd_click(selector):
905
+ ws, _ = get_page_ws()
906
+ js = f"""(function() {{
907
+ const el = document.querySelector('{selector}');
908
+ if (!el) return 'Not found: {selector}';
909
+ el.scrollIntoView({{behavior:'smooth', block:'center'}});
910
+ el.click();
911
+ return 'Clicked: ' + el.tagName + ' ' + (el.textContent || '').substring(0, 60).trim();
912
+ }})()"""
913
+ r = await cdp_send(ws, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
914
+ print(r.get(1, {}).get("result", {}).get("value", "?"))
915
+
916
+
917
+ async def cmd_fill(selector, value):
918
+ """Fill an input field (React/Vue compatible)."""
919
+ ws, _ = get_page_ws()
920
+ safe_value = json.dumps(value)
921
+ js = f"""(function() {{
922
+ const el = document.querySelector('{selector}');
923
+ if (!el) return 'Not found: {selector}';
924
+ el.focus();
925
+ const nativeSet = Object.getOwnPropertyDescriptor(
926
+ window.HTMLInputElement.prototype, 'value'
927
+ ).set;
928
+ nativeSet.call(el, {safe_value});
929
+ el.dispatchEvent(new Event('input', {{bubbles: true}}));
930
+ el.dispatchEvent(new Event('change', {{bubbles: true}}));
931
+ return 'Filled: ' + el.tagName + ' = ' + el.value.substring(0, 50);
932
+ }})()"""
933
+ r = await cdp_send(ws, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
934
+ print(r.get(1, {}).get("result", {}).get("value", "?"))
935
+
936
+
937
+ async def cmd_submit(selector="form"):
938
+ ws, _ = get_page_ws()
939
+ js = f"""(function() {{
940
+ const form = document.querySelector('{selector}');
941
+ if (!form) return 'Form not found: {selector}';
942
+ const btn = form.querySelector('button[type=submit], input[type=submit], button:last-of-type');
943
+ if (btn) {{ btn.click(); return 'Submit clicked: ' + btn.textContent.trim(); }}
944
+ form.submit();
945
+ return 'Form submitted';
946
+ }})()"""
947
+ r = await cdp_send(ws, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
948
+ print(r.get(1, {}).get("result", {}).get("value", "?"))
949
+
950
+
951
+ async def cmd_wait(selector, timeout=5):
952
+ ws, _ = get_page_ws()
953
+ js = f"""new Promise((resolve) => {{
954
+ const el = document.querySelector('{selector}');
955
+ if (el) return resolve('Found: ' + el.tagName + ' ' + (el.textContent||'').substring(0,60).trim());
956
+ const obs = new MutationObserver(() => {{
957
+ const el = document.querySelector('{selector}');
958
+ if (el) {{ obs.disconnect(); resolve('Found: ' + el.tagName + ' ' + (el.textContent||'').substring(0,60).trim()); }}
959
+ }});
960
+ obs.observe(document.body, {{childList:true, subtree:true}});
961
+ setTimeout(() => {{ obs.disconnect(); resolve('Timeout: {selector} not found ({timeout}s)'); }}, {int(timeout)*1000});
962
+ }})"""
963
+ r = await cdp_send(ws, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True, "awaitPromise": True})])
964
+ print(r.get(1, {}).get("result", {}).get("value", "?"))
965
+
966
+
967
+ async def cmd_network(url=None):
968
+ ws, page = get_page_ws()
969
+ if url is None:
970
+ url = page.get("url", "")
971
+ content, events = await navigate_collect(ws, url, network=True)
972
+ print("=== Network Requests ===")
973
+ for req in events["network"]:
974
+ s = req.get("status", "?")
975
+ m = "✓" if str(s).startswith("2") else "✗" if str(s).startswith(("4", "5")) else "→"
976
+ print(f" {m} [{s}] {req.get('type',''):>10} {req['url']}")
977
+ print(f"\nTotal: {len(events['network'])} requests")
978
+ print(f"\n=== Content (first 3000 chars) ===\n{content[:3000]}")
979
+
980
+
981
+ async def cmd_console(url=None):
982
+ ws, page = get_page_ws()
983
+ if url is None:
984
+ url = page.get("url", "")
985
+ content, events = await navigate_collect(ws, url, console=True)
986
+ print("=== Console ===")
987
+ icons = {"log": "📝", "error": "❌", "warning": "⚠️", "info": "ℹ️"}
988
+ for log in events["console"]:
989
+ lvl = log.get("type", "log")
990
+ print(f" {icons.get(lvl, '📝')} [{lvl.upper()}] {log['text']}")
991
+ if not events["console"]:
992
+ print(" (empty)")
993
+ print(f"\n=== Content (first 3000 chars) ===\n{content[:3000]}")
994
+
995
+
996
+ async def cmd_cookies(domain=None):
997
+ ws, _ = get_page_ws()
998
+ params = {}
999
+ if domain:
1000
+ params["urls"] = [f"https://{domain}", f"http://{domain}"]
1001
+ r = await cdp_send(ws, [(1, "Network.getCookies", params)])
1002
+ cookies = r.get(1, {}).get("cookies", [])
1003
+ for c in cookies:
1004
+ sec = "🔒" if c.get("secure") else " "
1005
+ print(f" {sec} {c['name'][:35]:35} = {str(c.get('value',''))[:50]:50} ({c.get('domain','')})")
1006
+ print(f"\n{len(cookies)} cookies")
1007
+
1008
+
1009
+ async def cmd_storage():
1010
+ ws, _ = get_page_ws()
1011
+ r = await cdp_send(ws, [(1, "Runtime.evaluate", {
1012
+ "expression": "JSON.stringify(Object.fromEntries(Object.entries(localStorage).map(([k,v])=>[k,v.substring(0,200)])))",
1013
+ "returnByValue": True,
1014
+ })])
1015
+ val = r.get(1, {}).get("result", {}).get("value", "{}")
1016
+ try:
1017
+ data = json.loads(val)
1018
+ for k, v in data.items():
1019
+ print(f" {k}: {v[:120]}")
1020
+ print(f"\n{len(data)} entries")
1021
+ except:
1022
+ print(val)
1023
+
1024
+
1025
+ async def cmd_perf():
1026
+ ws, _ = get_page_ws()
1027
+ r = await cdp_send(ws, [
1028
+ (1, "Performance.enable", {}),
1029
+ (2, "Performance.getMetrics", {}),
1030
+ ])
1031
+ metrics = r.get(2, {}).get("metrics", [])
1032
+ important = {
1033
+ "Nodes": "DOM Nodes", "Documents": "Documents",
1034
+ "JSEventListeners": "Event Listeners", "LayoutCount": "Layout Count",
1035
+ "RecalcStyleCount": "Style Recalc", "JSHeapUsedSize": "JS Heap (Used)",
1036
+ "JSHeapTotalSize": "JS Heap (Total)", "FirstMeaningfulPaint": "First Meaningful Paint",
1037
+ "DomContentLoaded": "DomContentLoaded",
1038
+ }
1039
+ print("=== Performance ===")
1040
+ for m in metrics:
1041
+ if m["name"] in important:
1042
+ val = m["value"]
1043
+ if "Size" in m["name"]:
1044
+ val = f"{val / 1024 / 1024:.1f} MB"
1045
+ elif "Paint" in m["name"] or "Loaded" in m["name"]:
1046
+ val = f"{val:.3f}s"
1047
+ else:
1048
+ val = f"{int(val)}"
1049
+ print(f" {important[m['name']]:25} {val}")
1050
+
1051
+
1052
+ async def cmd_emulate(device):
1053
+ devices = {
1054
+ "iphone": (390, 844, 3, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"),
1055
+ "ipad": (820, 1180, 2, "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)"),
1056
+ "android": (412, 915, 2.625, "Mozilla/5.0 (Linux; Android 14)"),
1057
+ }
1058
+ ws, _ = get_page_ws()
1059
+ if device == "reset" or device not in devices:
1060
+ await cdp_send(ws, [
1061
+ (1, "Emulation.clearDeviceMetricsOverride", {}),
1062
+ (2, "Network.setUserAgentOverride", {"userAgent": ""}),
1063
+ ])
1064
+ print("Emulation reset (desktop)")
1065
+ return
1066
+
1067
+ w, h, s, ua = devices[device]
1068
+ await cdp_send(ws, [
1069
+ (1, "Emulation.setDeviceMetricsOverride", {
1070
+ "width": w, "height": h, "deviceScaleFactor": s, "mobile": True
1071
+ }),
1072
+ (2, "Network.setUserAgentOverride", {"userAgent": ua}),
1073
+ ])
1074
+ print(f"Emulating: {device} ({w}x{h})")
1075
+
1076
+
1077
+ async def cmd_glow(state="on"):
1078
+ ws, page = get_page_ws()
1079
+ js = GLOW_ACTIVATE_JS if state == "on" else GLOW_DEACTIVATE_JS
1080
+ r = await cdp_send(ws, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
1081
+ print(f"Visual indicator {'on' if state == 'on' else 'off'}")
1082
+
1083
+
1084
+ async def cmd_debug(url=None):
1085
+ """Full auto-debug: navigate + console + network + perf + screenshot."""
1086
+ if not cdp_get("/json/version"):
1087
+ cmd_launch()
1088
+
1089
+ ws, page = get_page_ws()
1090
+
1091
+ if url is None:
1092
+ url = page.get("url", "")
1093
+
1094
+ print(f"🔍 Debug: {url}")
1095
+ print("=" * 60)
1096
+
1097
+ content, events = await navigate_collect(ws, url, network=True, console=True, glow=True)
1098
+
1099
+ print("\n📋 CONSOLE LOGS")
1100
+ print("-" * 40)
1101
+ errors = [l for l in events["console"] if l["type"] in ("error", "warning")]
1102
+ all_logs = events["console"]
1103
+ if errors:
1104
+ for log in errors:
1105
+ icon = "❌" if log["type"] == "error" else "⚠️"
1106
+ print(f" {icon} {log['text']}")
1107
+ elif all_logs:
1108
+ for log in all_logs[:10]:
1109
+ print(f" 📝 {log['text']}")
1110
+ else:
1111
+ print(" ✅ Clean (no errors)")
1112
+
1113
+ print(f"\n🌐 NETWORK ({len(events['network'])} requests)")
1114
+ print("-" * 40)
1115
+ failed = [r for r in events["network"] if str(r.get("status", "")).startswith(("4", "5"))]
1116
+ if failed:
1117
+ for req in failed:
1118
+ print(f" ❌ [{req['status']}] {req['url']}")
1119
+ else:
1120
+ print(" ✅ All requests successful")
1121
+ slow = [r for r in events["network"] if r.get("type") in ("XHR", "Fetch", "Document")]
1122
+ if slow:
1123
+ print(f" 📊 API/Document requests: {len(slow)}")
1124
+ for r in slow[:5]:
1125
+ print(f" [{r.get('status','?')}] {r.get('type','')} {r['url'][:100]}")
1126
+
1127
+ print("\n⚡ PERFORMANCE")
1128
+ print("-" * 40)
1129
+ try:
1130
+ r = await cdp_send(ws, [
1131
+ (1, "Performance.enable", {}),
1132
+ (2, "Performance.getMetrics", {}),
1133
+ ])
1134
+ metrics = {m["name"]: m["value"] for m in r.get(2, {}).get("metrics", [])}
1135
+ heap = metrics.get("JSHeapUsedSize", 0) / 1024 / 1024
1136
+ nodes = int(metrics.get("Nodes", 0))
1137
+ listeners = int(metrics.get("JSEventListeners", 0))
1138
+ print(f" JS Heap: {heap:.1f} MB")
1139
+ print(f" DOM Nodes: {nodes}")
1140
+ print(f" Event Listeners: {listeners}")
1141
+ if heap > 50:
1142
+ print(f" ⚠️ High memory usage ({heap:.0f} MB)")
1143
+ if nodes > 3000:
1144
+ print(f" ⚠️ High DOM node count ({nodes})")
1145
+ except:
1146
+ print(" (metrics unavailable)")
1147
+
1148
+ print("\n📸 SCREENSHOT")
1149
+ print("-" * 40)
1150
+ shot_path = f"{SCREENSHOT_DIR}/debug-{int(time.time())}.png"
1151
+ try:
1152
+ r = await cdp_send(ws, [(10, "Page.captureScreenshot", {"format": "png"})])
1153
+ b64 = r.get(10, {}).get("data", "")
1154
+ if b64:
1155
+ with open(shot_path, "wb") as f:
1156
+ f.write(base64.b64decode(b64))
1157
+ print(f" {shot_path}")
1158
+ except:
1159
+ print(" (unavailable)")
1160
+
1161
+ print(f"\n📄 PAGE CONTENT (first 2000 chars)")
1162
+ print("-" * 40)
1163
+ print(content[:2000])
1164
+
1165
+ print(f"\n{'=' * 60}")
1166
+ print(f"Debug complete: {url}")
1167
+
1168
+
1169
+ async def cmd_close():
1170
+ ws, page = get_page_ws()
1171
+ r = await cdp_send(ws, [(1, "Page.close", {})])
1172
+ print("Tab closed")
1173
+
1174
+
1175
+ def cmd_session():
1176
+ """Show current session info."""
1177
+ sid = _get_session_id()
1178
+ sessions = _load_sessions()
1179
+ info = sessions.get(sid)
1180
+ if info:
1181
+ print(f"Session: {sid}")
1182
+ print(f" Target ID: {info.get('target_id', '?')}")
1183
+ print(f" Created: {info.get('created', '?')}")
1184
+ # Check if target is still active
1185
+ tabs = cdp_get("/json") or []
1186
+ active = any(t.get("id") == info.get("target_id") for t in tabs)
1187
+ print(f" Status: {'active' if active else 'gone (will be recreated)'}")
1188
+ else:
1189
+ print(f"Session: {sid}")
1190
+ print(" No window assigned yet (will be created on first command)")
1191
+
1192
+
1193
+ def cmd_sessions():
1194
+ """List all active sessions."""
1195
+ sessions = _cleanup_stale_sessions()
1196
+ if not sessions:
1197
+ print("No active sessions.")
1198
+ return
1199
+ tabs = cdp_get("/json") or []
1200
+ active_ids = {t.get("id") for t in tabs}
1201
+ current_sid = _get_session_id()
1202
+ print(f"{'Session ID':<30} {'Target ID':<40} {'Status':<8} {'Created'}")
1203
+ print("─" * 100)
1204
+ for sid, info in sessions.items():
1205
+ tid = info.get("target_id", "?")
1206
+ created = info.get("created", "?")
1207
+ active = "active" if tid in active_ids else "gone"
1208
+ marker = " ← (current)" if sid == current_sid else ""
1209
+ print(f"{sid:<30} {tid:<40} {active:<8} {created}{marker}")
1210
+ print(f"\nTotal: {len(sessions)} sessions")
1211
+
1212
+
1213
+ def cmd_session_close(session_id=None):
1214
+ """Close a specific session window and remove its registry entry."""
1215
+ sid = session_id or _get_session_id()
1216
+ sessions = _load_sessions()
1217
+ info = sessions.get(sid)
1218
+ if not info:
1219
+ print(f"Session not found: {sid}")
1220
+ return
1221
+
1222
+ target_id = info.get("target_id")
1223
+ if target_id:
1224
+ # Close the tab
1225
+ tabs = cdp_get("/json") or []
1226
+ for t in tabs:
1227
+ if t.get("id") == target_id and "webSocketDebuggerUrl" in t:
1228
+ try:
1229
+ import websockets
1230
+ async def _close():
1231
+ async with websockets.connect(
1232
+ t["webSocketDebuggerUrl"],
1233
+ max_size=10*1024*1024
1234
+ ) as ws:
1235
+ await ws.send(json.dumps({
1236
+ "id": 1, "method": "Page.close", "params": {}
1237
+ }))
1238
+ try:
1239
+ await asyncio.wait_for(ws.recv(), timeout=2)
1240
+ except:
1241
+ pass
1242
+ asyncio.run(_close())
1243
+ except:
1244
+ pass
1245
+ break
1246
+
1247
+ sessions.pop(sid, None)
1248
+ _save_sessions(sessions)
1249
+ print(f"Session closed: {sid}")
1250
+
1251
+
1252
+ def cmd_extensions():
1253
+ """List installed extensions."""
1254
+ ext_dir = os.path.join(PROFILE_DIR, "Default", "Extensions")
1255
+ if not os.path.isdir(ext_dir):
1256
+ print("No extensions installed.")
1257
+ return
1258
+
1259
+ prefs_path = os.path.join(PROFILE_DIR, "Default", "Preferences")
1260
+ ext_names = {}
1261
+ if os.path.exists(prefs_path):
1262
+ try:
1263
+ with open(prefs_path) as f:
1264
+ prefs = json.load(f)
1265
+ settings = prefs.get("extensions", {}).get("settings", {})
1266
+ for ext_id, info in settings.items():
1267
+ manifest = info.get("manifest", {})
1268
+ ext_names[ext_id] = {
1269
+ "name": manifest.get("name", ext_id),
1270
+ "version": manifest.get("version", "?"),
1271
+ "enabled": info.get("state", 1) == 1,
1272
+ "path": info.get("path", ""),
1273
+ }
1274
+ except Exception:
1275
+ pass
1276
+
1277
+ ext_ids = [d for d in os.listdir(ext_dir) if not d.startswith(".")]
1278
+ if not ext_ids:
1279
+ print("No extensions installed.")
1280
+ return
1281
+
1282
+ for ext_id in sorted(ext_ids):
1283
+ info = ext_names.get(ext_id, {})
1284
+ name = info.get("name", ext_id)
1285
+ version = info.get("version", "?")
1286
+ enabled = info.get("enabled", True)
1287
+ status = "✅" if enabled else "⏸️"
1288
+ print(f" {status} {name} (v{version})")
1289
+ print(f" ID: {ext_id}")
1290
+
1291
+ print(f"\n{len(ext_ids)} extensions")
1292
+
1293
+ dev_exts = get_dev_extensions()
1294
+ if dev_exts:
1295
+ print(f'\nDev Mode Extensions ({len(dev_exts)}):')
1296
+ for i, path in enumerate(dev_exts):
1297
+ exists = '✅' if os.path.isdir(path) else '❌ (directory not found)'
1298
+ print(f' {exists} [{i}] {path}')
1299
+
1300
+
1301
+ def cmd_ext_install(source):
1302
+ """Install an extension from a CRX file or unpacked directory.
1303
+
1304
+ Usage:
1305
+ ext-install /path/to/extension.crx
1306
+ ext-install /path/to/unpacked-extension-dir/
1307
+ """
1308
+ ext_dir = os.path.join(PROFILE_DIR, "Default", "Extensions")
1309
+ os.makedirs(ext_dir, exist_ok=True)
1310
+
1311
+ source = os.path.expanduser(source)
1312
+
1313
+ if os.path.isdir(source):
1314
+ manifest_path = os.path.join(source, 'manifest.json')
1315
+ if not os.path.exists(manifest_path):
1316
+ print(f'Error: {source}/manifest.json not found.', file=sys.stderr)
1317
+ sys.exit(1)
1318
+
1319
+ with open(manifest_path) as f:
1320
+ manifest = json.load(f)
1321
+
1322
+ name = manifest.get('name', os.path.basename(source))
1323
+ version = manifest.get('version', '1.0')
1324
+ abs_source = os.path.abspath(source)
1325
+
1326
+ # Add to dev extensions list
1327
+ exts = get_dev_extensions()
1328
+ if abs_source not in exts:
1329
+ exts.append(abs_source)
1330
+ save_dev_extensions(exts)
1331
+
1332
+ print(f'✅ Dev extension registered: {name} (v{version})')
1333
+ print(f' Path: {abs_source}')
1334
+ print(' Restarting browser...')
1335
+ cmd_stop()
1336
+ import time as _time
1337
+ _time.sleep(1)
1338
+ cmd_launch()
1339
+
1340
+ elif source.endswith(".crx"):
1341
+ if not os.path.exists(source):
1342
+ print(f"Error: {source} not found.", file=sys.stderr)
1343
+ sys.exit(1)
1344
+
1345
+ import hashlib
1346
+ ext_id = hashlib.md5(os.path.basename(source).encode()).hexdigest()[:32]
1347
+ dest_dir = os.path.join(ext_dir, ext_id)
1348
+ os.makedirs(dest_dir, exist_ok=True)
1349
+
1350
+ dest = os.path.join(dest_dir, os.path.basename(source))
1351
+ shutil.copy2(source, dest)
1352
+
1353
+ print(f"✅ CRX copied: {os.path.basename(source)}")
1354
+ print(f" ID: {ext_id}")
1355
+ print(" Note: Restart browser and load via chrome://extensions.")
1356
+ print(" Alternative: use an unpacked directory for direct loading.")
1357
+
1358
+ else:
1359
+ print("Error: provide a .crx file or unpacked extension directory.", file=sys.stderr)
1360
+ print("Usage:")
1361
+ print(" ext-install /path/to/extension.crx")
1362
+ print(" ext-install /path/to/unpacked-extension-dir/")
1363
+ sys.exit(1)
1364
+
1365
+
1366
+ def cmd_ext_remove(ext_id):
1367
+ """Remove an extension by ID."""
1368
+ # Check dev extensions list first
1369
+ dev_exts = get_dev_extensions()
1370
+ # ext_id may be a directory path or a list index
1371
+ try:
1372
+ idx = int(ext_id)
1373
+ if 0 <= idx < len(dev_exts):
1374
+ removed_path = dev_exts.pop(idx)
1375
+ save_dev_extensions(dev_exts)
1376
+ print(f'🗑️ Dev extension removed from list: {removed_path}')
1377
+ print(' Restart the browser (stop → launch).')
1378
+ return
1379
+ except ValueError:
1380
+ # Try matching by path
1381
+ if ext_id in dev_exts:
1382
+ dev_exts.remove(ext_id)
1383
+ save_dev_extensions(dev_exts)
1384
+ print(f'🗑️ Dev extension removed from list: {ext_id}')
1385
+ print(' Restart the browser (stop → launch).')
1386
+ return
1387
+
1388
+ ext_dir = os.path.join(PROFILE_DIR, "Default", "Extensions", ext_id)
1389
+ if not os.path.isdir(ext_dir):
1390
+ print(f"Extension not found: {ext_id}", file=sys.stderr)
1391
+ sys.exit(1)
1392
+
1393
+ name = ext_id
1394
+ prefs_path = os.path.join(PROFILE_DIR, "Default", "Preferences")
1395
+ if os.path.exists(prefs_path):
1396
+ try:
1397
+ with open(prefs_path) as f:
1398
+ prefs = json.load(f)
1399
+ info = prefs.get("extensions", {}).get("settings", {}).get(ext_id, {})
1400
+ name = info.get("manifest", {}).get("name", ext_id)
1401
+ except Exception:
1402
+ pass
1403
+
1404
+ shutil.rmtree(ext_dir)
1405
+ print(f"🗑️ Extension removed: {name}")
1406
+ print(" Note: Restart the browser (stop → launch).")
1407
+
1408
+ async def cmd_new_tab(url='about:blank'):
1409
+ """Open a new tab."""
1410
+ import urllib.parse
1411
+ data = cdp_get(f'/json/new?{urllib.parse.quote(url, safe=":/?#[]@!$&\'()*+,;=")}')
1412
+ if data:
1413
+ print(f'New tab opened: {data.get("url", url)}')
1414
+ print(f' ID: {data.get("id", "?")}')
1415
+ else:
1416
+ print('Failed to open tab', file=sys.stderr)
1417
+
1418
+ def cmd_switch_tab(index_or_id):
1419
+ """Switch to a tab by index number or tab ID."""
1420
+ tabs = get_tabs()
1421
+ pages = [t for t in tabs if t.get('type') == 'page']
1422
+
1423
+ target = None
1424
+ try:
1425
+ idx = int(index_or_id)
1426
+ if 0 <= idx < len(pages):
1427
+ target = pages[idx]
1428
+ except ValueError:
1429
+ for p in pages:
1430
+ if p.get('id') == index_or_id:
1431
+ target = p
1432
+ break
1433
+
1434
+ if target:
1435
+ activate_tab(target['id'])
1436
+ print(f'Switched to tab: {target.get("title", "")[:60]}')
1437
+ print(f' URL: {target.get("url", "")[:120]}')
1438
+ else:
1439
+ print(f'Tab not found: {index_or_id}', file=sys.stderr)
1440
+ print('Available tabs:')
1441
+ cmd_tabs()
1442
+
1443
+ async def cmd_close_tab(index_or_id=None):
1444
+ """Close a specific tab by index or ID (active tab if omitted)."""
1445
+ if index_or_id is None:
1446
+ ws, page = get_page_ws()
1447
+ r = await cdp_send(ws, [(1, 'Page.close', {})])
1448
+ print('Active tab closed')
1449
+ return
1450
+
1451
+ tabs = get_tabs()
1452
+ pages = [t for t in tabs if t.get('type') == 'page']
1453
+
1454
+ target = None
1455
+ try:
1456
+ idx = int(index_or_id)
1457
+ if 0 <= idx < len(pages):
1458
+ target = pages[idx]
1459
+ except ValueError:
1460
+ for p in pages:
1461
+ if p.get('id') == index_or_id:
1462
+ target = p
1463
+ break
1464
+
1465
+ if target:
1466
+ import websockets
1467
+ async with websockets.connect(target['webSocketDebuggerUrl'], max_size=100*1024*1024) as ws:
1468
+ await ws.send(json.dumps({'id': 1, 'method': 'Page.close', 'params': {}}))
1469
+ try:
1470
+ await asyncio.wait_for(ws.recv(), timeout=3)
1471
+ except:
1472
+ pass
1473
+ print(f'Tab closed: {target.get("title", "")[:60]}')
1474
+ else:
1475
+ print(f'Tab not found: {index_or_id}', file=sys.stderr)
1476
+
1477
+ async def cmd_pdf(output=None):
1478
+ """Save the current page as a PDF."""
1479
+ if not output:
1480
+ output = f'{SCREENSHOT_DIR}/page-{int(time.time())}.pdf'
1481
+ ws, _ = get_page_ws()
1482
+ r = await cdp_send(ws, [(1, 'Page.printToPDF', {
1483
+ 'printBackground': True,
1484
+ 'preferCSSPageSize': True,
1485
+ })], timeout=30)
1486
+ b64 = r.get(1, {}).get('data', '')
1487
+ if b64:
1488
+ with open(output, 'wb') as f:
1489
+ f.write(base64.b64decode(b64))
1490
+ print(f'PDF saved: {output}')
1491
+ else:
1492
+ print('PDF generation failed', file=sys.stderr)
1493
+
1494
+ async def cmd_upload(selector, file_path):
1495
+ """Upload a file to a file input element."""
1496
+ file_path = os.path.expanduser(file_path)
1497
+ if not os.path.exists(file_path):
1498
+ print(f'File not found: {file_path}', file=sys.stderr)
1499
+ sys.exit(1)
1500
+
1501
+ abs_path = os.path.abspath(file_path)
1502
+ ws_url, _ = get_page_ws()
1503
+
1504
+ import websockets
1505
+ async with websockets.connect(ws_url, max_size=100*1024*1024) as conn:
1506
+ # Enable DOM
1507
+ await conn.send(json.dumps({'id': 1, 'method': 'DOM.enable', 'params': {}}))
1508
+ await asyncio.wait_for(conn.recv(), timeout=5)
1509
+
1510
+ # Get document root
1511
+ await conn.send(json.dumps({'id': 2, 'method': 'DOM.getDocument', 'params': {}}))
1512
+ while True:
1513
+ resp = await asyncio.wait_for(conn.recv(), timeout=5)
1514
+ data = json.loads(resp)
1515
+ if data.get('id') == 2:
1516
+ break
1517
+ root_id = data['result']['root']['nodeId']
1518
+
1519
+ # querySelector
1520
+ await conn.send(json.dumps({'id': 3, 'method': 'DOM.querySelector', 'params': {
1521
+ 'nodeId': root_id, 'selector': selector
1522
+ }}))
1523
+ while True:
1524
+ resp = await asyncio.wait_for(conn.recv(), timeout=5)
1525
+ data = json.loads(resp)
1526
+ if data.get('id') == 3:
1527
+ break
1528
+
1529
+ node_id = data.get('result', {}).get('nodeId', 0)
1530
+ if not node_id:
1531
+ print(f'Element not found: {selector}', file=sys.stderr)
1532
+ return
1533
+
1534
+ # setFileInputFiles
1535
+ await conn.send(json.dumps({'id': 4, 'method': 'DOM.setFileInputFiles', 'params': {
1536
+ 'nodeId': node_id,
1537
+ 'files': [abs_path]
1538
+ }}))
1539
+ while True:
1540
+ resp = await asyncio.wait_for(conn.recv(), timeout=5)
1541
+ data = json.loads(resp)
1542
+ if data.get('id') == 4:
1543
+ break
1544
+
1545
+ if 'error' in data:
1546
+ print(f'Upload error: {data["error"].get("message", "")}', file=sys.stderr)
1547
+ else:
1548
+ print(f'File uploaded: {os.path.basename(file_path)} → {selector}')
1549
+
1550
+ async def cmd_multi_eval(js_code):
1551
+ """Execute JavaScript across all open tabs (parallel)."""
1552
+ tabs = get_tabs()
1553
+ pages = [t for t in tabs if t.get('type') == 'page' and 'chrome://' not in t.get('url', '')]
1554
+
1555
+ if not pages:
1556
+ print('No open pages.', file=sys.stderr)
1557
+ return
1558
+
1559
+ import websockets
1560
+
1561
+ async def eval_on_tab(tab):
1562
+ try:
1563
+ async with websockets.connect(tab['webSocketDebuggerUrl'], max_size=100*1024*1024) as ws:
1564
+ await ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': {
1565
+ 'expression': js_code, 'returnByValue': True, 'awaitPromise': True
1566
+ }}))
1567
+ resp = await asyncio.wait_for(ws.recv(), timeout=10)
1568
+ data = json.loads(resp)
1569
+ result = data.get('result', {}).get('result', {})
1570
+ return tab.get('title', '?')[:40], result.get('value', result.get('description', '?'))
1571
+ except Exception as e:
1572
+ return tab.get('title', '?')[:40], f'Error: {e}'
1573
+
1574
+ results = await asyncio.gather(*[eval_on_tab(t) for t in pages])
1575
+
1576
+ for title, value in results:
1577
+ print(f' [{title}] → {value}')
1578
+ print(f'\nExecuted on {len(results)} tabs')
1579
+
1580
+ def cmd_proxy(proxy_url=None):
1581
+ """Set, show, or clear the proxy. Empty = clear."""
1582
+ if proxy_url is None:
1583
+ current = get_proxy_config()
1584
+ if current:
1585
+ print(f'Active proxy: {current}')
1586
+ else:
1587
+ print('No proxy configured.')
1588
+ return
1589
+
1590
+ os.makedirs(os.path.dirname(PROXY_CONFIG_FILE), exist_ok=True)
1591
+ if proxy_url in ('off', ''):
1592
+ if os.path.exists(PROXY_CONFIG_FILE):
1593
+ os.remove(PROXY_CONFIG_FILE)
1594
+ print('Proxy removed. Restart browser (stop → launch).')
1595
+ else:
1596
+ with open(PROXY_CONFIG_FILE, 'w') as f:
1597
+ json.dump({'proxy': proxy_url}, f)
1598
+ print(f'Proxy set: {proxy_url}')
1599
+ print('Restart browser (stop → launch).')
1600
+
1601
+ def cmd_headless(state=None):
1602
+ """Enable or disable headless mode."""
1603
+ if state is None:
1604
+ current = get_headless_config()
1605
+ print(f'Headless mode: {"on" if current else "off"}')
1606
+ return
1607
+
1608
+ os.makedirs(os.path.dirname(HEADLESS_CONFIG_FILE), exist_ok=True)
1609
+ enabled = state.lower() in ('on', '1', 'true', 'yes')
1610
+ with open(HEADLESS_CONFIG_FILE, 'w') as f:
1611
+ json.dump({'headless': enabled}, f)
1612
+ print(f'Headless mode: {"on" if enabled else "off"}')
1613
+ print('Restart browser (stop → launch).')
1614
+
1615
+
1616
+ def cmd_stop():
1617
+ """Stop the browser instance managed by browserctl."""
1618
+ import signal
1619
+ try:
1620
+ result = subprocess.run(
1621
+ ["lsof", "-ti", f":{CDP_PORT}"],
1622
+ capture_output=True, text=True
1623
+ )
1624
+ pids = result.stdout.strip().split("\n")
1625
+ for pid in pids:
1626
+ if pid.strip():
1627
+ os.kill(int(pid.strip()), signal.SIGTERM)
1628
+ print(f" PID {pid.strip()} terminated")
1629
+ print(f"Browser stopped (port {CDP_PORT}).")
1630
+ except Exception as e:
1631
+ print(f"Stop error: {e}", file=sys.stderr)
1632
+
1633
+
1634
+ def cmd_version():
1635
+ """Show browserctl version."""
1636
+ print(f"browserctl v{__version__}")
1637
+
1638
+
1639
+ # ─── New CDP Commands ───
1640
+
1641
+ async def _get_element_center(ws_url, selector):
1642
+ """Return the screen center (x, y) of the element matching selector."""
1643
+ js = f"""
1644
+ (function() {{
1645
+ var el = document.querySelector({json.dumps(selector)});
1646
+ if (!el) return null;
1647
+ var r = el.getBoundingClientRect();
1648
+ return {{x: Math.round(r.left + r.width/2), y: Math.round(r.top + r.height/2)}};
1649
+ }})()
1650
+ """
1651
+ res = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
1652
+ val = res.get(1, {}).get("result", {}).get("value")
1653
+ if not val:
1654
+ print(f"Error: element '{selector}' not found.", file=sys.stderr)
1655
+ sys.exit(1)
1656
+ return val["x"], val["y"]
1657
+
1658
+
1659
+ async def _get_browser_ws():
1660
+ """Return the browser-level WebSocket URL (/json/version)."""
1661
+ info = cdp_get("/json/version")
1662
+ if not info:
1663
+ print("Error: browser not running (CDP /json/version unreachable).", file=sys.stderr)
1664
+ sys.exit(1)
1665
+ return info.get("webSocketDebuggerUrl")
1666
+
1667
+
1668
+ # ─── 1. Request Interception ───
1669
+
1670
+ async def _run_intercept_session(ws_url, duration=30):
1671
+ """Intercept requests via Fetch.enable and apply rules."""
1672
+ import websockets
1673
+ import fnmatch
1674
+ global INTERCEPT_RULES
1675
+
1676
+ if not INTERCEPT_RULES:
1677
+ print("No interception rules. Use 'intercept block/mock/headers' first.")
1678
+ return
1679
+
1680
+ patterns = [{"urlPattern": "*", "requestStage": "Request"}]
1681
+ async with websockets.connect(ws_url, max_size=100 * 1024 * 1024) as ws:
1682
+ await ws.send(json.dumps({"id": 1, "method": "Fetch.enable", "params": {"patterns": patterns}}))
1683
+ await asyncio.wait_for(ws.recv(), timeout=5)
1684
+
1685
+ print(f"Intercepting requests ({duration} seconds)...")
1686
+ start = time.time()
1687
+ cmd_id = 100
1688
+ while time.time() - start < duration:
1689
+ try:
1690
+ raw = await asyncio.wait_for(ws.recv(), timeout=1)
1691
+ event = json.loads(raw)
1692
+ if event.get("method") != "Fetch.requestPaused":
1693
+ continue
1694
+ params = event["params"]
1695
+ req_id = params["requestId"]
1696
+ req_url = params["request"]["url"]
1697
+
1698
+ handled = False
1699
+ for rule_type, pattern, data in INTERCEPT_RULES:
1700
+ if fnmatch.fnmatch(req_url, pattern):
1701
+ cmd_id += 1
1702
+ if rule_type == "block":
1703
+ print(f" Blocked: {req_url[:80]}")
1704
+ await ws.send(json.dumps({"id": cmd_id, "method": "Fetch.failRequest",
1705
+ "params": {"requestId": req_id, "errorReason": "BlockedByClient"}}))
1706
+ elif rule_type == "mock":
1707
+ try:
1708
+ with open(data, "r", encoding="utf-8") as f:
1709
+ body = base64.b64encode(f.read().encode()).decode()
1710
+ print(f" Mocked: {req_url[:80]} → {data}")
1711
+ await ws.send(json.dumps({"id": cmd_id, "method": "Fetch.fulfillRequest",
1712
+ "params": {"requestId": req_id, "responseCode": 200, "body": body,
1713
+ "responseHeaders": [{"name": "Content-Type", "value": "application/json"}]}}))
1714
+ except FileNotFoundError:
1715
+ print(f" Warning: mock file not found: {data}", file=sys.stderr)
1716
+ await ws.send(json.dumps({"id": cmd_id, "method": "Fetch.continueRequest",
1717
+ "params": {"requestId": req_id}}))
1718
+ elif rule_type == "headers":
1719
+ extra = []
1720
+ for pair in data.split(";"):
1721
+ if ":" in pair:
1722
+ n, v = pair.split(":", 1)
1723
+ extra.append({"name": n.strip(), "value": v.strip()})
1724
+ print(f" Headers added: {req_url[:80]}")
1725
+ await ws.send(json.dumps({"id": cmd_id, "method": "Fetch.continueRequest",
1726
+ "params": {"requestId": req_id, "headers": extra}}))
1727
+ handled = True
1728
+ break
1729
+
1730
+ if not handled:
1731
+ cmd_id += 1
1732
+ await ws.send(json.dumps({"id": cmd_id, "method": "Fetch.continueRequest",
1733
+ "params": {"requestId": req_id}}))
1734
+
1735
+ except asyncio.TimeoutError:
1736
+ continue
1737
+
1738
+ await ws.send(json.dumps({"id": 999, "method": "Fetch.disable", "params": {}}))
1739
+ print("Request interception complete.")
1740
+
1741
+
1742
+ async def cmd_intercept(subcmd, *subcmd_args):
1743
+ """Request interception: block, mock, add headers, clear, list."""
1744
+ global INTERCEPT_RULES
1745
+ ws_url, _ = get_page_ws()
1746
+
1747
+ if subcmd == "block":
1748
+ if not subcmd_args:
1749
+ print("Usage: intercept block <url-pattern>")
1750
+ sys.exit(1)
1751
+ pattern = subcmd_args[0]
1752
+ INTERCEPT_RULES.append(("block", pattern, None))
1753
+ print(f"Block rule added: {pattern}")
1754
+ await _run_intercept_session(ws_url, duration=30)
1755
+
1756
+ elif subcmd == "mock":
1757
+ if len(subcmd_args) < 2:
1758
+ print("Usage: intercept mock <url-pattern> <json-file>")
1759
+ sys.exit(1)
1760
+ INTERCEPT_RULES.append(("mock", subcmd_args[0], subcmd_args[1]))
1761
+ print(f"Mock rule added: {subcmd_args[0]} → {subcmd_args[1]}")
1762
+ await _run_intercept_session(ws_url, duration=30)
1763
+
1764
+ elif subcmd == "headers":
1765
+ if len(subcmd_args) < 2:
1766
+ print("Usage: intercept headers <url-pattern> <header:value>")
1767
+ sys.exit(1)
1768
+ INTERCEPT_RULES.append(("headers", subcmd_args[0], subcmd_args[1]))
1769
+ print(f"Header rule added: {subcmd_args[0]} → {subcmd_args[1]}")
1770
+ await _run_intercept_session(ws_url, duration=30)
1771
+
1772
+ elif subcmd == "clear":
1773
+ INTERCEPT_RULES.clear()
1774
+ res = await cdp_send(ws_url, [(1, "Fetch.disable", {})])
1775
+ print("All interception rules cleared.")
1776
+
1777
+ elif subcmd == "list":
1778
+ if not INTERCEPT_RULES:
1779
+ print("No active interception rules.")
1780
+ else:
1781
+ print(f"Active rules ({len(INTERCEPT_RULES)}):")
1782
+ for i, (rt, pat, dat) in enumerate(INTERCEPT_RULES, 1):
1783
+ extra = f" → {dat}" if dat else ""
1784
+ print(f" {i}. [{rt}] {pat}{extra}")
1785
+ else:
1786
+ print("Usage: intercept [block|mock|headers|clear|list] ...")
1787
+ sys.exit(1)
1788
+
1789
+
1790
+ # ─── 2. Accessibility Tree ───
1791
+
1792
+ async def cmd_a11y(subcmd=""):
1793
+ """Analyze the accessibility tree."""
1794
+ ws_url, _ = get_page_ws()
1795
+ res = await cdp_send(ws_url, [(1, "Accessibility.getFullAXTree", {})])
1796
+ nodes = res.get(1, {}).get("nodes", [])
1797
+ if not nodes:
1798
+ print("Could not get accessibility tree. Is the browser running?", file=sys.stderr)
1799
+ sys.exit(1)
1800
+
1801
+ visible = [n for n in nodes if not n.get("ignored")]
1802
+
1803
+ def get_prop(node, prop_name):
1804
+ for p in node.get("properties", []):
1805
+ if p.get("name") == prop_name:
1806
+ return p.get("value", {}).get("value", "")
1807
+ return ""
1808
+
1809
+ parts = subcmd.strip().split(None, 1)
1810
+ sub = parts[0] if parts else ""
1811
+ arg = parts[1] if len(parts) > 1 else ""
1812
+
1813
+ if sub == "" or sub == "full":
1814
+ print(f"Accessibility tree ({len(visible)} visible nodes):")
1815
+ for n in visible:
1816
+ role = n.get("role", {}).get("value", "?")
1817
+ name = get_prop(n, "name")
1818
+ val = get_prop(n, "value")
1819
+ desc = get_prop(n, "description")
1820
+ out = f" [{role}]"
1821
+ if name:
1822
+ out += f" '{name}'"
1823
+ if val:
1824
+ out += f" value='{val}'"
1825
+ if desc:
1826
+ out += f" description='{desc}'"
1827
+ print(out)
1828
+
1829
+ elif sub == "summary":
1830
+ counts = {}
1831
+ for n in visible:
1832
+ role = n.get("role", {}).get("value", "other")
1833
+ counts[role] = counts.get(role, 0) + 1
1834
+ interactive = ["button", "link", "textField", "comboBox", "checkbox", "radio", "menuItem"]
1835
+ print("Accessibility summary:")
1836
+ for role, count in sorted(counts.items(), key=lambda x: -x[1]):
1837
+ tag = " ← interactive" if role in interactive else ""
1838
+ print(f" {role}: {count}{tag}")
1839
+
1840
+ elif sub == "find":
1841
+ if not arg:
1842
+ print("Usage: a11y find <role>")
1843
+ sys.exit(1)
1844
+ found = [n for n in visible if n.get("role", {}).get("value", "") == arg]
1845
+ if not found:
1846
+ print(f"No elements with role '{arg}'.")
1847
+ else:
1848
+ print(f"{len(found)} elements with role '{arg}':")
1849
+ for n in found:
1850
+ name = get_prop(n, "name")
1851
+ print(f" - '{name or '(unnamed)'}'")
1852
+ else:
1853
+ print("Usage: a11y [full|summary|find <role>]")
1854
+ sys.exit(1)
1855
+
1856
+
1857
+ # ─── 3. Advanced Input Commands ───
1858
+
1859
+ async def cmd_hover(selector):
1860
+ """Move the mouse cursor to the specified element."""
1861
+ ws_url, _ = get_page_ws()
1862
+ x, y = await _get_element_center(ws_url, selector)
1863
+ await cdp_send(ws_url, [(1, "Input.dispatchMouseEvent",
1864
+ {"type": "mouseMoved", "x": x, "y": y, "button": "none", "modifiers": 0})])
1865
+ print(f"Hover: {selector} ({x}, {y})")
1866
+
1867
+
1868
+ async def cmd_dblclick(selector):
1869
+ """Double-click the specified element."""
1870
+ ws_url, _ = get_page_ws()
1871
+ x, y = await _get_element_center(ws_url, selector)
1872
+ cmds = [
1873
+ (1, "Input.dispatchMouseEvent", {"type": "mousePressed", "x": x, "y": y, "button": "left", "clickCount": 1}),
1874
+ (2, "Input.dispatchMouseEvent", {"type": "mouseReleased", "x": x, "y": y, "button": "left", "clickCount": 1}),
1875
+ (3, "Input.dispatchMouseEvent", {"type": "mousePressed", "x": x, "y": y, "button": "left", "clickCount": 2}),
1876
+ (4, "Input.dispatchMouseEvent", {"type": "mouseReleased", "x": x, "y": y, "button": "left", "clickCount": 2}),
1877
+ ]
1878
+ await cdp_send(ws_url, cmds)
1879
+ print(f"Double-clicked: {selector}")
1880
+
1881
+
1882
+ async def cmd_rightclick(selector):
1883
+ """Right-click the specified element."""
1884
+ ws_url, _ = get_page_ws()
1885
+ x, y = await _get_element_center(ws_url, selector)
1886
+ cmds = [
1887
+ (1, "Input.dispatchMouseEvent", {"type": "mousePressed", "x": x, "y": y, "button": "right", "clickCount": 1}),
1888
+ (2, "Input.dispatchMouseEvent", {"type": "mouseReleased", "x": x, "y": y, "button": "right", "clickCount": 1}),
1889
+ ]
1890
+ await cdp_send(ws_url, cmds)
1891
+ print(f"Right-clicked: {selector}")
1892
+
1893
+
1894
+ async def cmd_drag(from_selector, to_selector):
1895
+ """Drag an element onto another element."""
1896
+ ws_url, _ = get_page_ws()
1897
+ fx, fy = await _get_element_center(ws_url, from_selector)
1898
+ tx, ty = await _get_element_center(ws_url, to_selector)
1899
+
1900
+ import websockets
1901
+ async with websockets.connect(ws_url, max_size=100 * 1024 * 1024) as ws:
1902
+ async def send_mouse(cid, etype, x, y, button="left"):
1903
+ await ws.send(json.dumps({"id": cid, "method": "Input.dispatchMouseEvent",
1904
+ "params": {"type": etype, "x": x, "y": y, "button": button, "modifiers": 0}}))
1905
+ await asyncio.wait_for(ws.recv(), timeout=3)
1906
+
1907
+ await send_mouse(1, "mousePressed", fx, fy)
1908
+ steps = 5
1909
+ for i in range(1, steps + 1):
1910
+ ix = int(fx + (tx - fx) * i / steps)
1911
+ iy = int(fy + (ty - fy) * i / steps)
1912
+ await send_mouse(10 + i, "mouseMoved", ix, iy)
1913
+ await asyncio.sleep(0.05)
1914
+ await send_mouse(20, "mouseReleased", tx, ty)
1915
+
1916
+ print(f"Dragged: {from_selector} → {to_selector}")
1917
+
1918
+
1919
+ async def cmd_keys(combo):
1920
+ """Send a keyboard shortcut (ctrl+a, shift+tab, enter, etc.)."""
1921
+ KEY_MAP = {
1922
+ "enter": ("Return", 13), "tab": ("Tab", 9), "escape": ("Escape", 27),
1923
+ "backspace": ("Backspace", 8), "delete": ("Delete", 46),
1924
+ "arrowup": ("ArrowUp", 38), "arrowdown": ("ArrowDown", 40),
1925
+ "arrowleft": ("ArrowLeft", 37), "arrowright": ("ArrowRight", 39),
1926
+ "home": ("Home", 36), "end": ("End", 35), "pageup": ("PageUp", 33), "pagedown": ("PageDown", 34),
1927
+ "f1": ("F1", 112), "f2": ("F2", 113), "f3": ("F3", 114), "f4": ("F4", 115),
1928
+ "f5": ("F5", 116), "f6": ("F6", 117), "f11": ("F11", 122), "f12": ("F12", 123),
1929
+ "a": ("a", 65), "b": ("b", 66), "c": ("c", 67), "d": ("d", 68), "e": ("e", 69),
1930
+ "f": ("f", 70), "g": ("g", 71), "h": ("h", 72), "i": ("i", 73), "j": ("j", 74),
1931
+ "k": ("k", 75), "l": ("l", 76), "m": ("m", 77), "n": ("n", 78), "o": ("o", 79),
1932
+ "p": ("p", 80), "q": ("q", 81), "r": ("r", 82), "s": ("s", 83), "t": ("t", 84),
1933
+ "u": ("u", 85), "v": ("v", 86), "w": ("w", 87), "x": ("x", 88), "y": ("y", 89), "z": ("z", 90),
1934
+ }
1935
+ MODIFIER_MAP = {"ctrl": 2, "control": 2, "shift": 8, "alt": 1, "meta": 4}
1936
+
1937
+ ws_url, _ = get_page_ws()
1938
+ parts = combo.lower().split("+")
1939
+ modifiers = 0
1940
+ key_name = None
1941
+ key_code = 0
1942
+
1943
+ for part in parts:
1944
+ if part in MODIFIER_MAP:
1945
+ modifiers |= MODIFIER_MAP[part]
1946
+ elif part in KEY_MAP:
1947
+ key_name, key_code = KEY_MAP[part]
1948
+ else:
1949
+ print(f"Error: unknown key '{part}'. Supported: {', '.join(list(KEY_MAP.keys())[:20])} ...", file=sys.stderr)
1950
+ sys.exit(1)
1951
+
1952
+ if not key_name:
1953
+ print("Error: specify a valid key (e.g. ctrl+a, enter, tab).", file=sys.stderr)
1954
+ sys.exit(1)
1955
+
1956
+ cmds = [
1957
+ (1, "Input.dispatchKeyEvent", {"type": "keyDown", "modifiers": modifiers,
1958
+ "key": key_name, "windowsVirtualKeyCode": key_code, "nativeVirtualKeyCode": key_code}),
1959
+ (2, "Input.dispatchKeyEvent", {"type": "keyUp", "modifiers": modifiers,
1960
+ "key": key_name, "windowsVirtualKeyCode": key_code, "nativeVirtualKeyCode": key_code}),
1961
+ ]
1962
+ await cdp_send(ws_url, cmds)
1963
+ print(f"Key sent: {combo}")
1964
+
1965
+
1966
+ async def cmd_scroll_to(selector):
1967
+ """Scroll the specified element into view."""
1968
+ ws_url, _ = get_page_ws()
1969
+ js = f"(function(){{ var el=document.querySelector({json.dumps(selector)}); if(!el) return false; el.scrollIntoView({{behavior:'smooth',block:'center'}}); return true; }})()"
1970
+ res = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
1971
+ ok = res.get(1, {}).get("result", {}).get("value", False)
1972
+ if ok:
1973
+ print(f"Scrolled to: {selector}")
1974
+ else:
1975
+ print(f"Error: element '{selector}' not found.", file=sys.stderr)
1976
+ sys.exit(1)
1977
+
1978
+
1979
+ # ─── 4. iframe / Shadow DOM ───
1980
+
1981
+ async def cmd_frame(subcmd, *subcmd_args):
1982
+ """iframe and Shadow DOM access."""
1983
+ ws_url, _ = get_page_ws()
1984
+
1985
+ if subcmd == "list":
1986
+ js = """(function(){
1987
+ var iframes = document.querySelectorAll('iframe');
1988
+ return Array.from(iframes).map(function(f, i){
1989
+ return {index: i, src: f.src || '(no source)', name: f.name || '', id: f.id || ''};
1990
+ });
1991
+ })()"""
1992
+ res = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
1993
+ frames = res.get(1, {}).get("result", {}).get("value", [])
1994
+ if not frames:
1995
+ print("No iframes found on page.")
1996
+ else:
1997
+ print(f"iframes ({len(frames)}):")
1998
+ for f in frames:
1999
+ print(f" [{f['index']}] src={f['src'][:80]} name={f['name']} id={f['id']}")
2000
+
2001
+ elif subcmd == "eval":
2002
+ if not subcmd_args:
2003
+ print("Usage: frame eval <js>")
2004
+ sys.exit(1)
2005
+ js_code = " ".join(subcmd_args)
2006
+ res = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js_code, "returnByValue": True})])
2007
+ val = res.get(1, {})
2008
+ if "error" in val or val.get("exceptionDetails"):
2009
+ print(f"Error: {val}", file=sys.stderr)
2010
+ else:
2011
+ print(f"Result: {val.get('result', {}).get('value', val)}")
2012
+
2013
+ elif subcmd == "shadow":
2014
+ if not subcmd_args:
2015
+ print("Usage: frame shadow <selector>")
2016
+ sys.exit(1)
2017
+ selector = subcmd_args[0]
2018
+ js = f"(function(){{ var el=document.querySelector({json.dumps(selector)}); if(!el) return 'Element not found'; if(!el.shadowRoot) return 'No shadow root'; return el.shadowRoot.innerHTML.substring(0,3000); }})()"
2019
+ res = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
2020
+ val = res.get(1, {}).get("result", {}).get("value", "")
2021
+ print(val or "(empty)")
2022
+
2023
+ else:
2024
+ print("Usage: frame [list|eval <js>|shadow <selector>]")
2025
+ sys.exit(1)
2026
+
2027
+
2028
+ # ─── 5. Dialog Handling ───
2029
+
2030
+ async def cmd_dialog(subcmd, *subcmd_args):
2031
+ """JavaScript dialog management."""
2032
+ global DIALOG_MODE
2033
+ ws_url, _ = get_page_ws()
2034
+
2035
+ if subcmd == "auto-accept":
2036
+ DIALOG_MODE = "accept"
2037
+ print("Dialogs will be automatically accepted.")
2038
+
2039
+ elif subcmd == "auto-dismiss":
2040
+ DIALOG_MODE = "dismiss"
2041
+ print("Dialogs will be automatically dismissed.")
2042
+
2043
+ elif subcmd == "prompt":
2044
+ text = " ".join(subcmd_args) if subcmd_args else ""
2045
+ res = await cdp_send(ws_url, [(1, "Page.handleJavaScriptDialog",
2046
+ {"accept": True, "promptText": text})])
2047
+ print(f"Dialog accepted with text: '{text}'")
2048
+
2049
+ elif subcmd == "off":
2050
+ DIALOG_MODE = None
2051
+ print("Automatic dialog handling disabled.")
2052
+
2053
+ else:
2054
+ print("Usage: dialog [auto-accept|auto-dismiss|prompt <text>|off]")
2055
+ sys.exit(1)
2056
+
2057
+
2058
+ # ─── 6. Download ───
2059
+
2060
+ async def cmd_download(subcmd, *subcmd_args):
2061
+ """Manage download behavior."""
2062
+ browser_ws = await _get_browser_ws()
2063
+
2064
+ if subcmd == "set":
2065
+ if not subcmd_args:
2066
+ print("Usage: download set <directory>")
2067
+ sys.exit(1)
2068
+ download_dir = os.path.abspath(subcmd_args[0])
2069
+ os.makedirs(download_dir, exist_ok=True)
2070
+ import websockets
2071
+ async with websockets.connect(browser_ws, max_size=100 * 1024 * 1024) as ws:
2072
+ await ws.send(json.dumps({"id": 1, "method": "Browser.setDownloadBehavior",
2073
+ "params": {"behavior": "allow", "downloadPath": download_dir}}))
2074
+ await asyncio.wait_for(ws.recv(), timeout=5)
2075
+ cfg = {"downloadPath": download_dir}
2076
+ os.makedirs(PROFILE_DIR, exist_ok=True)
2077
+ with open(DOWNLOAD_CONFIG_FILE, "w") as f:
2078
+ json.dump(cfg, f, indent=2)
2079
+ print(f"Download directory: {download_dir}")
2080
+
2081
+ elif subcmd == "status":
2082
+ if os.path.exists(DOWNLOAD_CONFIG_FILE):
2083
+ with open(DOWNLOAD_CONFIG_FILE) as f:
2084
+ cfg = json.load(f)
2085
+ print(f"Download directory: {cfg.get('downloadPath', '(not set)')}")
2086
+ else:
2087
+ print("Download directory not configured.")
2088
+
2089
+ else:
2090
+ print("Usage: download [set <directory>|status]")
2091
+ sys.exit(1)
2092
+
2093
+
2094
+ # ─── 7. Network Throttling ───
2095
+
2096
+ async def cmd_throttle(preset, *throttle_args):
2097
+ """Network throttling simulation."""
2098
+ PRESETS = {
2099
+ "slow3g": {"offline": False, "downloadThroughput": 63750, "uploadThroughput": 63750, "latency": 2000},
2100
+ "fast3g": {"offline": False, "downloadThroughput": 192000, "uploadThroughput": 96000, "latency": 563},
2101
+ "offline": {"offline": True, "downloadThroughput": 0, "uploadThroughput": 0, "latency": 0},
2102
+ "off": {"offline": False, "downloadThroughput": -1, "uploadThroughput": -1, "latency": 0},
2103
+ }
2104
+
2105
+ ws_url, _ = get_page_ws()
2106
+
2107
+ if preset in PRESETS:
2108
+ params = PRESETS[preset]
2109
+ elif preset == "custom":
2110
+ if len(throttle_args) < 3:
2111
+ print("Usage: throttle custom <down_kbps> <up_kbps> <latency_ms>")
2112
+ sys.exit(1)
2113
+ try:
2114
+ down = int(throttle_args[0]) * 1024 // 8
2115
+ up = int(throttle_args[1]) * 1024 // 8
2116
+ lat = int(throttle_args[2])
2117
+ except ValueError:
2118
+ print("Error: numeric values required.", file=sys.stderr)
2119
+ sys.exit(1)
2120
+ params = {"offline": False, "downloadThroughput": down, "uploadThroughput": up, "latency": lat}
2121
+ else:
2122
+ print(f"Error: unknown preset '{preset}'. Options: slow3g, fast3g, offline, off, custom")
2123
+ sys.exit(1)
2124
+
2125
+ await cdp_send(ws_url, [(1, "Network.enable", {})])
2126
+ await cdp_send(ws_url, [(1, "Network.emulateNetworkConditions", params)])
2127
+ print(f"Network throttle: {preset}")
2128
+
2129
+
2130
+ # ─── 8. Geolocation & Permissions ───
2131
+
2132
+ GEO_PRESETS = {
2133
+ "istanbul": (41.0082, 28.9784),
2134
+ "london": (51.5074, -0.1278),
2135
+ "newyork": (40.7128, -74.0060),
2136
+ "paris": (48.8566, 2.3522),
2137
+ "tokyo": (35.6762, 139.6503),
2138
+ }
2139
+
2140
+
2141
+ async def cmd_geo(lat_or_preset, lng=None, accuracy=None):
2142
+ """Set or clear geolocation override."""
2143
+ ws_url, _ = get_page_ws()
2144
+
2145
+ if lat_or_preset == "off":
2146
+ await cdp_send(ws_url, [(1, "Emulation.clearGeolocationOverride", {})])
2147
+ print("Geolocation override cleared.")
2148
+ return
2149
+
2150
+ if lat_or_preset in GEO_PRESETS:
2151
+ lat, lng_val = GEO_PRESETS[lat_or_preset]
2152
+ acc = 100.0
2153
+ label = lat_or_preset.capitalize()
2154
+ else:
2155
+ try:
2156
+ lat = float(lat_or_preset)
2157
+ lng_val = float(lng) if lng else 0.0
2158
+ acc = float(accuracy) if accuracy else 100.0
2159
+ label = f"({lat}, {lng_val})"
2160
+ except (TypeError, ValueError):
2161
+ print(f"Error: invalid coordinates or preset. Presets: {', '.join(GEO_PRESETS.keys())}", file=sys.stderr)
2162
+ sys.exit(1)
2163
+
2164
+ await cdp_send(ws_url, [(1, "Emulation.setGeolocationOverride",
2165
+ {"latitude": lat, "longitude": lng_val, "accuracy": acc})])
2166
+ print(f"Location set: {label}")
2167
+
2168
+
2169
+ async def cmd_permission(subcmd, perm=None):
2170
+ """Manage browser permissions."""
2171
+ browser_ws = await _get_browser_ws()
2172
+ ws_url, page_info = get_page_ws()
2173
+
2174
+ import websockets
2175
+ async with websockets.connect(browser_ws, max_size=100 * 1024 * 1024) as ws:
2176
+ if subcmd == "grant":
2177
+ if not perm:
2178
+ print("Usage: permission grant <permission> (geolocation, notifications, camera, microphone, etc.)")
2179
+ sys.exit(1)
2180
+ origin = page_info.get("url", "").split("?")[0].rstrip("/")
2181
+ if not origin.startswith("http"):
2182
+ print("Error: a web page must be open to grant permissions.", file=sys.stderr)
2183
+ sys.exit(1)
2184
+ await ws.send(json.dumps({"id": 1, "method": "Browser.grantPermissions",
2185
+ "params": {"permissions": [perm], "origin": origin}}))
2186
+ await asyncio.wait_for(ws.recv(), timeout=5)
2187
+ print(f"Permission granted: {perm} ({origin})")
2188
+
2189
+ elif subcmd == "deny":
2190
+ if not perm:
2191
+ print("Usage: permission deny <permission>")
2192
+ sys.exit(1)
2193
+ await ws.send(json.dumps({"id": 1, "method": "Browser.setPermission",
2194
+ "params": {"permission": {"name": perm}, "setting": "denied"}}))
2195
+ await asyncio.wait_for(ws.recv(), timeout=5)
2196
+ print(f"Permission denied: {perm}")
2197
+
2198
+ elif subcmd == "reset":
2199
+ await ws.send(json.dumps({"id": 1, "method": "Browser.resetPermissions", "params": {}}))
2200
+ await asyncio.wait_for(ws.recv(), timeout=5)
2201
+ print("All permissions reset.")
2202
+
2203
+ else:
2204
+ print("Usage: permission [grant <permission>|deny <permission>|reset]")
2205
+ sys.exit(1)
2206
+
2207
+
2208
+ # ─── CLI ───
2209
+
2210
+ if __name__ == "__main__":
2211
+ if len(sys.argv) < 2:
2212
+ print(__doc__)
2213
+ sys.exit(0)
2214
+
2215
+ cmd = sys.argv[1]
2216
+ args = sys.argv[2:]
2217
+
2218
+ sync_cmds = {
2219
+ 'launch': cmd_launch,
2220
+ 'tabs': cmd_tabs,
2221
+ 'extensions': cmd_extensions,
2222
+ 'stop': cmd_stop,
2223
+ 'version': cmd_version,
2224
+ 'proxy': lambda: cmd_proxy(args[0] if args else None),
2225
+ 'headless': lambda: cmd_headless(args[0] if args else None),
2226
+ 'session': cmd_session,
2227
+ 'sessions': cmd_sessions,
2228
+ 'session-close': lambda: cmd_session_close(args[0] if args else None),
2229
+ }
2230
+
2231
+ if cmd == "ext-install":
2232
+ if not args:
2233
+ print("Usage: ext-install <crx-file-or-directory>")
2234
+ sys.exit(1)
2235
+ cmd_ext_install(args[0])
2236
+ sys.exit(0)
2237
+
2238
+ if cmd == "ext-remove":
2239
+ if not args:
2240
+ print("Usage: ext-remove <extension-id>")
2241
+ sys.exit(1)
2242
+ cmd_ext_remove(args[0])
2243
+ sys.exit(0)
2244
+
2245
+ if cmd == 'switch-tab':
2246
+ if not args:
2247
+ print('Usage: switch-tab <index-or-id>')
2248
+ sys.exit(1)
2249
+ cmd_switch_tab(args[0])
2250
+ sys.exit(0)
2251
+
2252
+ if cmd in sync_cmds:
2253
+ sync_cmds[cmd]()
2254
+ sys.exit(0)
2255
+
2256
+ def require_args(n, usage):
2257
+ if len(args) < n:
2258
+ print(f"Usage: {usage}")
2259
+ sys.exit(1)
2260
+
2261
+ async_map = {
2262
+ "go": lambda: (require_args(1, "go <url>"), cmd_go(args[0]))[1] if not args else cmd_go(args[0]),
2263
+ "content": cmd_content,
2264
+ "html": cmd_html,
2265
+ "shot": lambda: cmd_shot(args[0] if args else None),
2266
+ "eval": lambda: (require_args(1, "eval <js>"), None)[1] if not args else cmd_eval(" ".join(args)),
2267
+ "click": lambda: (require_args(1, "click <selector>"), None)[1] if not args else cmd_click(args[0]),
2268
+ "fill": lambda: (require_args(2, "fill <selector> <value>"), None)[1] if len(args) < 2 else cmd_fill(args[0], " ".join(args[1:])),
2269
+ "submit": lambda: cmd_submit(args[0] if args else "form"),
2270
+ "type": lambda: (require_args(2, "type <selector> <value>"), None)[1] if len(args) < 2 else cmd_fill(args[0], " ".join(args[1:])),
2271
+ "wait": lambda: (require_args(1, "wait <selector>"), None)[1] if not args else cmd_wait(args[0], int(args[1]) if len(args) > 1 else 5),
2272
+ "tabs": cmd_tabs,
2273
+ "network": lambda: cmd_network(args[0] if args else None),
2274
+ "console": lambda: cmd_console(args[0] if args else None),
2275
+ "cookies": lambda: cmd_cookies(args[0] if args else None),
2276
+ "storage": cmd_storage,
2277
+ "perf": cmd_perf,
2278
+ "emulate": lambda: (require_args(1, "emulate <device>"), None)[1] if not args else cmd_emulate(args[0]),
2279
+ "glow": lambda: cmd_glow(args[0] if args else "on"),
2280
+ "debug": lambda: cmd_debug(args[0] if args else None),
2281
+ "close": cmd_close,
2282
+ 'new-tab': lambda: cmd_new_tab(args[0] if args else 'about:blank'),
2283
+ 'close-tab': lambda: cmd_close_tab(args[0] if args else None),
2284
+ 'pdf': lambda: cmd_pdf(args[0] if args else None),
2285
+ 'upload': lambda: (require_args(2, 'upload <selector> <file-path>'), None)[1] if len(args) < 2 else cmd_upload(args[0], ' '.join(args[1:])),
2286
+ 'multi-eval': lambda: (require_args(1, 'multi-eval <js>'), None)[1] if not args else cmd_multi_eval(' '.join(args)),
2287
+ 'intercept': lambda: (require_args(1, 'intercept [block|mock|headers|clear|list] ...'), None)[1] if not args else cmd_intercept(args[0], *args[1:]),
2288
+ 'a11y': lambda: cmd_a11y(' '.join(args)),
2289
+ 'hover': lambda: (require_args(1, 'hover <selector>'), None)[1] if not args else cmd_hover(args[0]),
2290
+ 'dblclick': lambda: (require_args(1, 'dblclick <selector>'), None)[1] if not args else cmd_dblclick(args[0]),
2291
+ 'rightclick': lambda: (require_args(1, 'rightclick <selector>'), None)[1] if not args else cmd_rightclick(args[0]),
2292
+ 'drag': lambda: (require_args(2, 'drag <from-sel> <to-sel>'), None)[1] if len(args) < 2 else cmd_drag(args[0], args[1]),
2293
+ 'keys': lambda: (require_args(1, 'keys <combo>'), None)[1] if not args else cmd_keys(args[0]),
2294
+ 'scroll-to': lambda: (require_args(1, 'scroll-to <selector>'), None)[1] if not args else cmd_scroll_to(args[0]),
2295
+ 'frame': lambda: (require_args(1, 'frame [list|eval <js>|shadow <selector>]'), None)[1] if not args else cmd_frame(args[0], *args[1:]),
2296
+ 'dialog': lambda: (require_args(1, 'dialog [auto-accept|auto-dismiss|prompt <text>|off]'), None)[1] if not args else cmd_dialog(args[0], *args[1:]),
2297
+ 'download': lambda: (require_args(1, 'download [set <directory>|status]'), None)[1] if not args else cmd_download(args[0], *args[1:]),
2298
+ 'throttle': lambda: (require_args(1, 'throttle [slow3g|fast3g|offline|off|custom <down> <up> <lat>]'), None)[1] if not args else cmd_throttle(args[0], *args[1:]),
2299
+ 'geo': lambda: (require_args(1, 'geo [<lat> <lng>|istanbul|london|newyork|off]'), None)[1] if not args else cmd_geo(args[0], args[1] if len(args) > 1 else None, args[2] if len(args) > 2 else None),
2300
+ 'permission': lambda: (require_args(1, 'permission [grant|deny|reset] [<permission>]'), None)[1] if not args else cmd_permission(args[0], args[1] if len(args) > 1 else None),
2301
+ }
2302
+
2303
+ # Commands that do not require the visual indicator / input blocker
2304
+ NO_CONTROL_CMDS = {'glow', 'stop', 'tabs', 'close', 'close-tab', 'new-tab',
2305
+ 'dialog', 'download', 'throttle', 'permission', 'intercept'}
2306
+ # Clean up idle sessions before running any command
2307
+ _cleanup_idle_sessions()
2308
+
2309
+ if cmd in async_map:
2310
+ if cmd in NO_CONTROL_CMDS:
2311
+ asyncio.run(async_map[cmd]())
2312
+ _update_session_timestamp()
2313
+ else:
2314
+ async def _wrapped():
2315
+ ws_url = None
2316
+ try:
2317
+ ws_url, _ = get_page_ws()
2318
+ await _control_start(ws_url)
2319
+ except Exception:
2320
+ pass
2321
+ try:
2322
+ await async_map[cmd]()
2323
+ finally:
2324
+ if ws_url:
2325
+ try:
2326
+ ws_new, _ = get_page_ws()
2327
+ await _control_end(ws_new)
2328
+ except Exception:
2329
+ if ws_url:
2330
+ await _control_end(ws_url)
2331
+ asyncio.run(_wrapped())
2332
+ _update_session_timestamp()
2333
+ else:
2334
+ print(f"Unknown command: {cmd}")
2335
+ print(__doc__)