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.
- package/LICENSE +21 -0
- package/README.md +283 -0
- package/bin/browserctl.js +264 -0
- package/package.json +44 -0
- package/src/browserctl.py +2335 -0
|
@@ -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__)
|