chromex-mcp 1.0.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 +411 -0
- package/package.json +50 -0
- package/plugins/chromex/skills/chromex/scripts/chromex.mjs +343 -0
- package/plugins/chromex/skills/chromex/scripts/lib/browser.mjs +66 -0
- package/plugins/chromex/skills/chromex/scripts/lib/client.mjs +98 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/console.mjs +37 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/cookies.mjs +77 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/coverage.mjs +95 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/cpu.mjs +14 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/dialog.mjs +38 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/domsnapshot.mjs +84 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/download.mjs +25 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/drag.mjs +71 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/emulate.mjs +44 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/evaluate.mjs +31 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/form.mjs +163 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/geo.mjs +37 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/har.mjs +101 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/heap.mjs +24 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/highlight.mjs +36 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/html.mjs +10 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/inject.mjs +39 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/interact.mjs +88 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/intercept.mjs +99 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/navigate.mjs +45 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/network.mjs +13 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/pdf.mjs +16 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/perf.mjs +98 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/refs.mjs +67 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/screenshot.mjs +54 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/scroll.mjs +44 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/snapshot.mjs +100 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/storage.mjs +47 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/tab.mjs +31 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/throttle.mjs +38 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/touch.mjs +62 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/trace.mjs +51 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/upload.mjs +43 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/wait.mjs +84 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/webauthn.mjs +47 -0
- package/plugins/chromex/skills/chromex/scripts/lib/config.mjs +100 -0
- package/plugins/chromex/skills/chromex/scripts/lib/daemon.mjs +368 -0
- package/plugins/chromex/skills/chromex/scripts/lib/ipc.mjs +178 -0
- package/plugins/chromex/skills/chromex/scripts/lib/launcher.mjs +111 -0
- package/plugins/chromex/skills/chromex/scripts/lib/security.mjs +48 -0
- package/plugins/chromex/skills/chromex/scripts/lib/utils.mjs +47 -0
- package/plugins/chromex/skills/chromex/scripts/mcp-server.mjs +726 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// chromex -- Chrome DevTools Protocol CLI for AI agents
|
|
3
|
+
// Zero dependencies. Node 22+ (built-in WebSocket).
|
|
4
|
+
|
|
5
|
+
import { writeFileSync, existsSync, readFileSync } from 'fs';
|
|
6
|
+
import { loadConfig } from './lib/config.mjs';
|
|
7
|
+
import { CDP } from './lib/client.mjs';
|
|
8
|
+
import { getWsUrl, getPages, formatPageList } from './lib/browser.mjs';
|
|
9
|
+
import { audit } from './lib/security.mjs';
|
|
10
|
+
import { resolvePrefix, listDaemonSockets } from './lib/utils.mjs';
|
|
11
|
+
import { runDaemon } from './lib/daemon.mjs';
|
|
12
|
+
import { getOrStartTabDaemon, sendCommand, stopDaemons, findAnyDaemonSocket, checkTargetDomain } from './lib/ipc.mjs';
|
|
13
|
+
import { launchBrowser, incognitoContext } from './lib/launcher.mjs';
|
|
14
|
+
|
|
15
|
+
const config = loadConfig();
|
|
16
|
+
|
|
17
|
+
// Comandos que precisam de target (tab)
|
|
18
|
+
const NEEDS_TARGET = new Set([
|
|
19
|
+
'snap', 'snapshot', 'eval', 'shot', 'screenshot', 'html', 'nav', 'navigate',
|
|
20
|
+
'net', 'network', 'click', 'clickxy', 'type', 'loadall', 'evalraw', 'waitfor',
|
|
21
|
+
'fill', 'clear', 'select', 'check', 'form',
|
|
22
|
+
'scroll', 'cookies', 'pdf', 'console', 'storage', 'emulate', 'perf',
|
|
23
|
+
// Tier 1
|
|
24
|
+
'wait', 'dialog', 'upload',
|
|
25
|
+
'geo', 'timezone', 'locale', 'throttle', 'cpu', 'inject', 'download',
|
|
26
|
+
// Tier 2
|
|
27
|
+
'intercept', 'har', 'coverage',
|
|
28
|
+
// Tier 3
|
|
29
|
+
'trace', 'heap', 'webauthn', 'drag', 'touch', 'domsnapshot', 'highlight',
|
|
30
|
+
'hover',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const USAGE = `chromex - Chrome DevTools Protocol CLI for AI agents
|
|
34
|
+
|
|
35
|
+
Usage: chromex <command> [args]
|
|
36
|
+
|
|
37
|
+
PAGES
|
|
38
|
+
list List open pages (shows unique target prefixes)
|
|
39
|
+
open <url> Open new tab
|
|
40
|
+
close <target> Close tab
|
|
41
|
+
focus <target> Activate/focus tab
|
|
42
|
+
launch [options] Launch browser with remote debugging
|
|
43
|
+
--incognito Launch in incognito mode
|
|
44
|
+
--browser chrome|brave|edge Choose browser
|
|
45
|
+
--profile NAME Use named profile
|
|
46
|
+
--url URL Open URL on launch
|
|
47
|
+
incognito [url] Create isolated browser context (no relaunch)
|
|
48
|
+
|
|
49
|
+
INSPECT
|
|
50
|
+
snap <target> Accessibility tree snapshot (compact)
|
|
51
|
+
html <target> [selector] Get HTML (full page or CSS selector)
|
|
52
|
+
shot <target> [file] [--full] Screenshot (viewport or full page)
|
|
53
|
+
net <target> Network performance entries
|
|
54
|
+
perf <target> Core Web Vitals + performance metrics
|
|
55
|
+
console <target> [duration_ms] Capture console output (default 5000ms)
|
|
56
|
+
domsnapshot <target> [--styles] Structured DOM snapshot with bounding rects
|
|
57
|
+
highlight <target> <sel|clear> Highlight element with overlay
|
|
58
|
+
|
|
59
|
+
EVALUATE
|
|
60
|
+
eval <target> <expr> Evaluate JS expression
|
|
61
|
+
evalraw <target> <method> [json] Raw CDP command (some methods blocked)
|
|
62
|
+
|
|
63
|
+
NAVIGATE
|
|
64
|
+
nav <target> <url> Navigate to URL and wait for load
|
|
65
|
+
waitfor <target> <selector> [ms] Wait for CSS selector to appear
|
|
66
|
+
wait <target> <event> [ms] Wait for: networkidle, load, domready, fcp
|
|
67
|
+
scroll <target> <dir> [amount] Scroll: up, down, top, bottom, to <selector>
|
|
68
|
+
|
|
69
|
+
INTERACT
|
|
70
|
+
click <target> <selector> Click element by CSS selector
|
|
71
|
+
clickxy <target> <x> <y> Click at CSS pixel coordinates
|
|
72
|
+
type <target> <text> Type text at current focus
|
|
73
|
+
drag <target> <from> <to> Drag & drop (selectors or x1,y1 x2,y2)
|
|
74
|
+
touch <target> <gesture> [args] Touch: tap, swipe, pinch, longpress
|
|
75
|
+
dialog <target> <action> [text] Handle dialogs: accept, dismiss, auto
|
|
76
|
+
|
|
77
|
+
FORMS
|
|
78
|
+
fill <target> <selector> <value> Fill input/textarea
|
|
79
|
+
clear <target> <selector> Clear input field
|
|
80
|
+
select <target> <selector> <value> Select option in <select>
|
|
81
|
+
check <target> <selector> [bool] Toggle checkbox/radio (default: true)
|
|
82
|
+
form <target> <json> Batch fill: {"#email":"x","#terms":true}
|
|
83
|
+
upload <target> <selector> <files> Upload file(s) to input[type=file]
|
|
84
|
+
loadall <target> <selector> [ms] Click "load more" until gone
|
|
85
|
+
|
|
86
|
+
DATA
|
|
87
|
+
cookies <target> [action] [arg] Cookies: list, set <json>, clear [domain]
|
|
88
|
+
storage <target> <type> Storage: local, session, clear
|
|
89
|
+
pdf <target> [file] Save page as PDF
|
|
90
|
+
|
|
91
|
+
NETWORK
|
|
92
|
+
throttle <target> <preset|reset> Throttle: 3g, slow-3g, 4g, offline, custom, reset
|
|
93
|
+
intercept <target> <action> [args] Intercept: on, block, mock, off, rules
|
|
94
|
+
har <target> start|stop [file] Record HTTP traffic as HAR file
|
|
95
|
+
|
|
96
|
+
EMULATE
|
|
97
|
+
emulate <target> <device|reset> Emulate device (iphone-14, pixel-7, etc.)
|
|
98
|
+
geo <target> <lat> <lon>|reset Set geolocation override
|
|
99
|
+
timezone <target> <tz|reset> Set timezone (e.g. America/Sao_Paulo)
|
|
100
|
+
locale <target> <locale|reset> Set locale (e.g. pt-BR)
|
|
101
|
+
cpu <target> <rate|reset> CPU throttle (1=normal, 4=4x slower, 6=mobile)
|
|
102
|
+
|
|
103
|
+
ADVANCED
|
|
104
|
+
inject <target> <script|flags> Inject JS on every page load (--file, --remove, --list)
|
|
105
|
+
download <target> allow|deny|reset Control download behavior
|
|
106
|
+
coverage <target> start|stop CSS/JS code coverage report
|
|
107
|
+
trace <target> start|stop [file] Performance trace (chrome://tracing format)
|
|
108
|
+
heap <target> snapshot [file] Heap snapshot for memory analysis
|
|
109
|
+
webauthn <target> enable|creds|dis Virtual authenticator for passkey testing
|
|
110
|
+
|
|
111
|
+
DAEMON
|
|
112
|
+
stop [target] Stop daemon(s)
|
|
113
|
+
|
|
114
|
+
<target> is a unique targetId prefix from "chromex list". Ambiguous prefixes are rejected.
|
|
115
|
+
|
|
116
|
+
SECURITY
|
|
117
|
+
Config: ~/.chromex/config.json
|
|
118
|
+
- blockedDomains / allowedDomains: domain filtering
|
|
119
|
+
- blockedCdpMethods: CDP methods blocked in evalraw
|
|
120
|
+
- socketAuth / auditLog: daemon security
|
|
121
|
+
|
|
122
|
+
COORDINATES
|
|
123
|
+
shot captures at native resolution: image px = CSS px * DPR.
|
|
124
|
+
clickxy takes CSS pixels. CSS px = screenshot px / DPR.
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
async function main() {
|
|
128
|
+
const [cmd, ...args] = process.argv.slice(2);
|
|
129
|
+
|
|
130
|
+
// Daemon mode (internal)
|
|
131
|
+
if (cmd === '_daemon') {
|
|
132
|
+
await runDaemon(args[0], config);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
137
|
+
console.log(USAGE);
|
|
138
|
+
process.exit(0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- Comandos sem target ---
|
|
142
|
+
|
|
143
|
+
// List
|
|
144
|
+
if (cmd === 'list' || cmd === 'ls') {
|
|
145
|
+
let pages;
|
|
146
|
+
const existingSock = findAnyDaemonSocket(config);
|
|
147
|
+
if (existingSock) {
|
|
148
|
+
try {
|
|
149
|
+
const conn = await connectAndAuthFromSocket(existingSock);
|
|
150
|
+
const resp = await sendCommand(conn, { cmd: 'list_raw' });
|
|
151
|
+
if (resp.ok) pages = JSON.parse(resp.result);
|
|
152
|
+
} catch { /* fallback para conexão direta */ }
|
|
153
|
+
}
|
|
154
|
+
if (!pages) {
|
|
155
|
+
const cdp = new CDP(config.commandTimeout);
|
|
156
|
+
await cdp.connect(getWsUrl());
|
|
157
|
+
pages = await getPages(cdp);
|
|
158
|
+
cdp.close();
|
|
159
|
+
}
|
|
160
|
+
writeFileSync(config._pagesCachePath, JSON.stringify(pages));
|
|
161
|
+
audit('list', null, [], { ok: true }, config);
|
|
162
|
+
console.log(formatPageList(pages, config));
|
|
163
|
+
setTimeout(() => process.exit(0), 100);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Launch
|
|
168
|
+
if (cmd === 'launch') {
|
|
169
|
+
const options = parseFlags(args, ['incognito'], ['browser', 'profile', 'url']);
|
|
170
|
+
const result = await launchBrowser(options);
|
|
171
|
+
console.log(result);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Incognito (sem target -- cria novo context)
|
|
176
|
+
if (cmd === 'incognito') {
|
|
177
|
+
const cdp = new CDP(config.commandTimeout);
|
|
178
|
+
await cdp.connect(getWsUrl());
|
|
179
|
+
const result = await incognitoContext(cdp, args[0]);
|
|
180
|
+
console.log(result.message);
|
|
181
|
+
cdp.close();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Tab management (no daemon needed -- direct browser WebSocket)
|
|
186
|
+
if (cmd === 'open' || cmd === 'close' || cmd === 'focus') {
|
|
187
|
+
const { openTabStr, closeTabStr, focusTabStr } = await import('./lib/commands/tab.mjs');
|
|
188
|
+
const cdp = new CDP(config.commandTimeout);
|
|
189
|
+
await cdp.connect(getWsUrl());
|
|
190
|
+
let result;
|
|
191
|
+
if (cmd === 'open') {
|
|
192
|
+
if (!args[0]) { console.error('Error: URL required'); process.exit(1); }
|
|
193
|
+
result = await openTabStr(cdp, args[0]);
|
|
194
|
+
} else if (cmd === 'close') {
|
|
195
|
+
if (!args[0]) { console.error('Error: target prefix required'); process.exit(1); }
|
|
196
|
+
result = await closeTabStr(cdp, args[0]);
|
|
197
|
+
} else {
|
|
198
|
+
if (!args[0]) { console.error('Error: target prefix required'); process.exit(1); }
|
|
199
|
+
result = await focusTabStr(cdp, args[0]);
|
|
200
|
+
}
|
|
201
|
+
console.log(result);
|
|
202
|
+
cdp.close();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Stop
|
|
207
|
+
if (cmd === 'stop') {
|
|
208
|
+
await stopDaemons(args[0], config);
|
|
209
|
+
audit('stop', args[0] || 'all', [], { ok: true }, config);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- Comandos com target ---
|
|
214
|
+
if (!NEEDS_TARGET.has(cmd)) {
|
|
215
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
216
|
+
console.log(USAGE);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const targetPrefix = args[0];
|
|
221
|
+
if (!targetPrefix) {
|
|
222
|
+
console.error('Error: target ID required. Run "chromex list" first.');
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Resolver prefix -> targetId
|
|
227
|
+
let targetId;
|
|
228
|
+
const daemonTargetIds = listDaemonSockets(config._socketDir).map(d => d.targetId);
|
|
229
|
+
const daemonMatches = daemonTargetIds.filter(id => id.toUpperCase().startsWith(targetPrefix.toUpperCase()));
|
|
230
|
+
|
|
231
|
+
if (daemonMatches.length > 0) {
|
|
232
|
+
targetId = resolvePrefix(targetPrefix, daemonTargetIds, 'daemon');
|
|
233
|
+
} else {
|
|
234
|
+
if (!existsSync(config._pagesCachePath)) {
|
|
235
|
+
console.error('No page list cached. Run "chromex list" first.');
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
const pages = JSON.parse(readFileSync(config._pagesCachePath, 'utf8'));
|
|
239
|
+
targetId = resolvePrefix(targetPrefix, pages.map(p => p.targetId), 'target', 'Run "chromex list".');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Checar domínio da tab
|
|
243
|
+
checkTargetDomain(targetId, config);
|
|
244
|
+
|
|
245
|
+
const conn = await getOrStartTabDaemon(targetId, config);
|
|
246
|
+
|
|
247
|
+
const cmdArgs = args.slice(1);
|
|
248
|
+
|
|
249
|
+
// Juntar argumentos para comandos que aceitam texto livre
|
|
250
|
+
if (cmd === 'eval') {
|
|
251
|
+
const expr = cmdArgs.join(' ');
|
|
252
|
+
if (!expr) { console.error('Error: expression required'); process.exit(1); }
|
|
253
|
+
cmdArgs[0] = expr;
|
|
254
|
+
cmdArgs.length = 1;
|
|
255
|
+
} else if (cmd === 'type') {
|
|
256
|
+
const text = cmdArgs.join(' ');
|
|
257
|
+
if (!text) { console.error('Error: text required'); process.exit(1); }
|
|
258
|
+
cmdArgs[0] = text;
|
|
259
|
+
cmdArgs.length = 1;
|
|
260
|
+
} else if (cmd === 'fill') {
|
|
261
|
+
// fill <selector> <value...>
|
|
262
|
+
if (cmdArgs.length < 2) { console.error('Error: selector and value required'); process.exit(1); }
|
|
263
|
+
const selector = cmdArgs[0];
|
|
264
|
+
const value = cmdArgs.slice(1).join(' ');
|
|
265
|
+
cmdArgs[0] = selector;
|
|
266
|
+
cmdArgs[1] = value;
|
|
267
|
+
cmdArgs.length = 2;
|
|
268
|
+
} else if (cmd === 'evalraw') {
|
|
269
|
+
if (!cmdArgs[0]) { console.error('Error: CDP method required'); process.exit(1); }
|
|
270
|
+
if (cmdArgs.length > 2) {
|
|
271
|
+
cmdArgs[1] = cmdArgs.slice(1).join(' ');
|
|
272
|
+
cmdArgs.length = 2;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if ((cmd === 'nav' || cmd === 'navigate') && !cmdArgs[0]) {
|
|
277
|
+
console.error('Error: URL required');
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const response = await sendCommand(conn, { cmd, args: cmdArgs });
|
|
282
|
+
|
|
283
|
+
if (response.ok) {
|
|
284
|
+
if (response.result) console.log(response.result);
|
|
285
|
+
} else {
|
|
286
|
+
console.error('Error:', response.error);
|
|
287
|
+
process.exitCode = 1;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Parser de flags simples: --flag (boolean) e --key value
|
|
292
|
+
function parseFlags(args, booleanFlags, valueFlags) {
|
|
293
|
+
const result = {};
|
|
294
|
+
let i = 0;
|
|
295
|
+
while (i < args.length) {
|
|
296
|
+
const arg = args[i];
|
|
297
|
+
if (arg.startsWith('--')) {
|
|
298
|
+
const key = arg.slice(2);
|
|
299
|
+
if (booleanFlags.includes(key)) {
|
|
300
|
+
result[key] = true;
|
|
301
|
+
i++;
|
|
302
|
+
} else if (valueFlags.includes(key)) {
|
|
303
|
+
result[key] = args[i + 1];
|
|
304
|
+
i += 2;
|
|
305
|
+
} else {
|
|
306
|
+
i++;
|
|
307
|
+
}
|
|
308
|
+
} else {
|
|
309
|
+
// Argumento posicional -> assume URL
|
|
310
|
+
if (!result.url) result.url = arg;
|
|
311
|
+
i++;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return result;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Helper para list -- conectar via socket existente
|
|
318
|
+
async function connectAndAuthFromSocket(socketPath) {
|
|
319
|
+
const { getOrCreateToken } = await import('./lib/daemon.mjs');
|
|
320
|
+
const authToken = getOrCreateToken(config);
|
|
321
|
+
const net = await import('net');
|
|
322
|
+
return new Promise((resolve, reject) => {
|
|
323
|
+
const conn = net.connect(socketPath);
|
|
324
|
+
conn.on('connect', () => {
|
|
325
|
+
if (!authToken) { resolve(conn); return; }
|
|
326
|
+
let buf = '';
|
|
327
|
+
conn.on('data', (chunk) => {
|
|
328
|
+
buf += chunk.toString();
|
|
329
|
+
const idx = buf.indexOf('\n');
|
|
330
|
+
if (idx === -1) return;
|
|
331
|
+
try {
|
|
332
|
+
const resp = JSON.parse(buf.slice(0, idx));
|
|
333
|
+
if (resp.ok) resolve(conn);
|
|
334
|
+
else reject(new Error('Auth failed'));
|
|
335
|
+
} catch { reject(new Error('Invalid response')); }
|
|
336
|
+
});
|
|
337
|
+
conn.write(JSON.stringify({ auth: authToken, id: 0 }) + '\n');
|
|
338
|
+
});
|
|
339
|
+
conn.on('error', reject);
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
main().catch(e => { console.error(e.message); process.exit(1); });
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Detecção de browser e listagem de páginas
|
|
2
|
+
import { readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { checkDomain } from './security.mjs';
|
|
6
|
+
import { getDisplayPrefixLength } from './utils.mjs';
|
|
7
|
+
|
|
8
|
+
// Gera candidatos para cada browser: path base + Default/ subfolder
|
|
9
|
+
function candidates(base) {
|
|
10
|
+
return [resolve(base, 'DevToolsActivePort'), resolve(base, 'Default/DevToolsActivePort')];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const home = homedir();
|
|
14
|
+
const DEVTOOLS_CANDIDATES = [
|
|
15
|
+
// Brave
|
|
16
|
+
...candidates(resolve(home, 'Library/Application Support/BraveSoftware/Brave-Browser')),
|
|
17
|
+
...candidates(resolve(home, '.config/BraveSoftware/Brave-Browser')),
|
|
18
|
+
// Chrome
|
|
19
|
+
...candidates(resolve(home, 'Library/Application Support/Google/Chrome')),
|
|
20
|
+
...candidates(resolve(home, '.config/google-chrome')),
|
|
21
|
+
// Chrome Canary
|
|
22
|
+
...candidates(resolve(home, 'Library/Application Support/Google/Chrome Canary')),
|
|
23
|
+
// Chromium
|
|
24
|
+
...candidates(resolve(home, 'Library/Application Support/Chromium')),
|
|
25
|
+
...candidates(resolve(home, '.config/chromium')),
|
|
26
|
+
// Edge
|
|
27
|
+
...candidates(resolve(home, 'Library/Application Support/Microsoft Edge')),
|
|
28
|
+
...candidates(resolve(home, '.config/microsoft-edge')),
|
|
29
|
+
// Vivaldi
|
|
30
|
+
...candidates(resolve(home, 'Library/Application Support/Vivaldi')),
|
|
31
|
+
...candidates(resolve(home, '.config/vivaldi')),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export function getWsUrl() {
|
|
35
|
+
// Override via env var para setups não-padrão
|
|
36
|
+
if (process.env.CDP_PORT_FILE && existsSync(process.env.CDP_PORT_FILE)) {
|
|
37
|
+
const lines = readFileSync(process.env.CDP_PORT_FILE, 'utf8').trim().split('\n');
|
|
38
|
+
return `ws://127.0.0.1:${lines[0]}${lines[1]}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const portFile = DEVTOOLS_CANDIDATES.find(path => existsSync(path));
|
|
42
|
+
if (!portFile) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'Could not find DevToolsActivePort file.\n' +
|
|
45
|
+
'Enable remote debugging: chrome://inspect/#remote-debugging\n' +
|
|
46
|
+
'Or launch with: chromex launch'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const lines = readFileSync(portFile, 'utf8').trim().split('\n');
|
|
50
|
+
return `ws://127.0.0.1:${lines[0]}${lines[1]}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function getPages(cdp) {
|
|
54
|
+
const { targetInfos } = await cdp.send('Target.getTargets');
|
|
55
|
+
return targetInfos.filter(t => t.type === 'page' && !t.url.startsWith('chrome://'));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatPageList(pages, config) {
|
|
59
|
+
const prefixLen = getDisplayPrefixLength(pages.map(p => p.targetId));
|
|
60
|
+
return pages.map(p => {
|
|
61
|
+
const id = p.targetId.slice(0, prefixLen).padEnd(prefixLen);
|
|
62
|
+
const title = p.title.substring(0, 54).padEnd(54);
|
|
63
|
+
const blocked = checkDomain(p.url, config) ? ' [BLOCKED]' : '';
|
|
64
|
+
return `${id} ${title} ${p.url}${blocked}`;
|
|
65
|
+
}).join('\n');
|
|
66
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// CDP WebSocket client -- protocolo raw, zero dependencies
|
|
2
|
+
// Requer Node 22+ (WebSocket nativo)
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TIMEOUT = 15000;
|
|
5
|
+
|
|
6
|
+
export class CDP {
|
|
7
|
+
#ws;
|
|
8
|
+
#id = 0;
|
|
9
|
+
#pending = new Map();
|
|
10
|
+
#eventHandlers = new Map();
|
|
11
|
+
#closeHandlers = [];
|
|
12
|
+
#timeout;
|
|
13
|
+
|
|
14
|
+
constructor(timeout = DEFAULT_TIMEOUT) {
|
|
15
|
+
this.#timeout = timeout;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async connect(wsUrl) {
|
|
19
|
+
return new Promise((res, rej) => {
|
|
20
|
+
this.#ws = new WebSocket(wsUrl);
|
|
21
|
+
this.#ws.onopen = () => res();
|
|
22
|
+
this.#ws.onerror = (e) => rej(new Error('WebSocket error: ' + (e.message || e.type)));
|
|
23
|
+
this.#ws.onclose = () => this.#closeHandlers.forEach(h => h());
|
|
24
|
+
this.#ws.onmessage = (ev) => {
|
|
25
|
+
const msg = JSON.parse(ev.data);
|
|
26
|
+
if (msg.id && this.#pending.has(msg.id)) {
|
|
27
|
+
const { resolve, reject } = this.#pending.get(msg.id);
|
|
28
|
+
this.#pending.delete(msg.id);
|
|
29
|
+
if (msg.error) reject(new Error(msg.error.message));
|
|
30
|
+
else resolve(msg.result);
|
|
31
|
+
} else if (msg.method && this.#eventHandlers.has(msg.method)) {
|
|
32
|
+
for (const handler of [...this.#eventHandlers.get(msg.method)]) {
|
|
33
|
+
handler(msg.params || {}, msg);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
send(method, params = {}, sessionId) {
|
|
41
|
+
const id = ++this.#id;
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
this.#pending.set(id, { resolve, reject });
|
|
44
|
+
const msg = { id, method, params };
|
|
45
|
+
if (sessionId) msg.sessionId = sessionId;
|
|
46
|
+
this.#ws.send(JSON.stringify(msg));
|
|
47
|
+
setTimeout(() => {
|
|
48
|
+
if (this.#pending.has(id)) {
|
|
49
|
+
this.#pending.delete(id);
|
|
50
|
+
reject(new Error(`Timeout: ${method}`));
|
|
51
|
+
}
|
|
52
|
+
}, this.#timeout);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
onEvent(method, handler) {
|
|
57
|
+
if (!this.#eventHandlers.has(method)) this.#eventHandlers.set(method, new Set());
|
|
58
|
+
const handlers = this.#eventHandlers.get(method);
|
|
59
|
+
handlers.add(handler);
|
|
60
|
+
return () => {
|
|
61
|
+
handlers.delete(handler);
|
|
62
|
+
if (handlers.size === 0) this.#eventHandlers.delete(method);
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
waitForEvent(method, timeout = this.#timeout) {
|
|
67
|
+
let settled = false;
|
|
68
|
+
let off;
|
|
69
|
+
let timer;
|
|
70
|
+
const promise = new Promise((resolve, reject) => {
|
|
71
|
+
off = this.onEvent(method, (params) => {
|
|
72
|
+
if (settled) return;
|
|
73
|
+
settled = true;
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
off();
|
|
76
|
+
resolve(params);
|
|
77
|
+
});
|
|
78
|
+
timer = setTimeout(() => {
|
|
79
|
+
if (settled) return;
|
|
80
|
+
settled = true;
|
|
81
|
+
off();
|
|
82
|
+
reject(new Error(`Timeout waiting for event: ${method}`));
|
|
83
|
+
}, timeout);
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
promise,
|
|
87
|
+
cancel() {
|
|
88
|
+
if (settled) return;
|
|
89
|
+
settled = true;
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
off?.();
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
onClose(handler) { this.#closeHandlers.push(handler); }
|
|
97
|
+
close() { this.#ws.close(); }
|
|
98
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Captura de console.log/error/warn
|
|
2
|
+
|
|
3
|
+
import { sleep } from '../utils.mjs';
|
|
4
|
+
|
|
5
|
+
export async function consoleStr(cdp, sid, durationMs = 5000) {
|
|
6
|
+
const duration = parseInt(durationMs) || 5000;
|
|
7
|
+
const entries = [];
|
|
8
|
+
|
|
9
|
+
await cdp.send('Runtime.enable', {}, sid);
|
|
10
|
+
|
|
11
|
+
const off = cdp.onEvent('Runtime.consoleAPICalled', (params) => {
|
|
12
|
+
const type = params.type; // log, error, warn, info, debug
|
|
13
|
+
const args = (params.args || []).map(a => {
|
|
14
|
+
if (a.type === 'string') return a.value;
|
|
15
|
+
if (a.type === 'number') return String(a.value);
|
|
16
|
+
if (a.type === 'boolean') return String(a.value);
|
|
17
|
+
if (a.type === 'undefined') return 'undefined';
|
|
18
|
+
if (a.subtype === 'null') return 'null';
|
|
19
|
+
return a.description || JSON.stringify(a.value) || `[${a.type}]`;
|
|
20
|
+
});
|
|
21
|
+
entries.push({
|
|
22
|
+
ts: new Date().toISOString().slice(11, 23),
|
|
23
|
+
type,
|
|
24
|
+
msg: args.join(' '),
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
await sleep(duration);
|
|
29
|
+
off();
|
|
30
|
+
|
|
31
|
+
if (entries.length === 0) return `No console output captured in ${duration}ms.`;
|
|
32
|
+
|
|
33
|
+
return entries.map(e => {
|
|
34
|
+
const prefix = e.type === 'error' ? 'ERR' : e.type === 'warn' ? 'WRN' : e.type.toUpperCase().slice(0, 3);
|
|
35
|
+
return `[${e.ts}] ${prefix.padEnd(3)} ${e.msg.substring(0, 200)}`;
|
|
36
|
+
}).join('\n');
|
|
37
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Cookie management via CDP Network domain
|
|
2
|
+
|
|
3
|
+
import { evalStr } from './evaluate.mjs';
|
|
4
|
+
|
|
5
|
+
export async function cookiesStr(cdp, sid, action, arg) {
|
|
6
|
+
switch (action) {
|
|
7
|
+
case undefined:
|
|
8
|
+
case 'list': {
|
|
9
|
+
// Obter URL atual para filtrar cookies
|
|
10
|
+
const url = await evalStr(cdp, sid, 'window.location.href');
|
|
11
|
+
await cdp.send('Network.enable', {}, sid);
|
|
12
|
+
const { cookies } = await cdp.send('Network.getCookies', { urls: [url] }, sid);
|
|
13
|
+
await cdp.send('Network.disable', {}, sid);
|
|
14
|
+
|
|
15
|
+
if (cookies.length === 0) return 'No cookies found for this page.';
|
|
16
|
+
|
|
17
|
+
return cookies.map(c => {
|
|
18
|
+
const flags = [
|
|
19
|
+
c.httpOnly ? 'HttpOnly' : '',
|
|
20
|
+
c.secure ? 'Secure' : '',
|
|
21
|
+
c.sameSite !== 'None' ? `SameSite=${c.sameSite}` : '',
|
|
22
|
+
].filter(Boolean).join(' ');
|
|
23
|
+
const exp = c.expires > 0
|
|
24
|
+
? new Date(c.expires * 1000).toISOString().slice(0, 19)
|
|
25
|
+
: 'Session';
|
|
26
|
+
const val = c.value.length > 40 ? c.value.slice(0, 40) + '...' : c.value;
|
|
27
|
+
return `${c.name.padEnd(30)} ${val.padEnd(44)} ${exp} ${flags}`;
|
|
28
|
+
}).join('\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
case 'set': {
|
|
32
|
+
if (!arg) throw new Error('Cookie JSON required: {"name":"x","value":"y","domain":".example.com"}');
|
|
33
|
+
let cookie;
|
|
34
|
+
try { cookie = JSON.parse(arg); }
|
|
35
|
+
catch { throw new Error(`Invalid JSON: ${arg}`); }
|
|
36
|
+
|
|
37
|
+
if (!cookie.name || cookie.value == null) throw new Error('Cookie must have "name" and "value"');
|
|
38
|
+
|
|
39
|
+
// Default domain da página atual
|
|
40
|
+
if (!cookie.domain) {
|
|
41
|
+
const host = await evalStr(cdp, sid, 'window.location.hostname');
|
|
42
|
+
cookie.domain = host;
|
|
43
|
+
}
|
|
44
|
+
if (!cookie.path) cookie.path = '/';
|
|
45
|
+
|
|
46
|
+
await cdp.send('Network.enable', {}, sid);
|
|
47
|
+
const result = await cdp.send('Network.setCookie', cookie, sid);
|
|
48
|
+
await cdp.send('Network.disable', {}, sid);
|
|
49
|
+
|
|
50
|
+
if (!result.success) throw new Error('Failed to set cookie');
|
|
51
|
+
return `Cookie "${cookie.name}" set on ${cookie.domain}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
case 'clear': {
|
|
55
|
+
const url = await evalStr(cdp, sid, 'window.location.href');
|
|
56
|
+
await cdp.send('Network.enable', {}, sid);
|
|
57
|
+
const { cookies } = await cdp.send('Network.getCookies', { urls: [url] }, sid);
|
|
58
|
+
|
|
59
|
+
const domain = arg; // Filtro opcional por domínio
|
|
60
|
+
const toDelete = domain
|
|
61
|
+
? cookies.filter(c => c.domain === domain || c.domain === '.' + domain)
|
|
62
|
+
: cookies;
|
|
63
|
+
|
|
64
|
+
for (const c of toDelete) {
|
|
65
|
+
await cdp.send('Network.deleteCookies', {
|
|
66
|
+
name: c.name, domain: c.domain, path: c.path,
|
|
67
|
+
}, sid);
|
|
68
|
+
}
|
|
69
|
+
await cdp.send('Network.disable', {}, sid);
|
|
70
|
+
|
|
71
|
+
return `Cleared ${toDelete.length} cookie(s)${domain ? ` for domain ${domain}` : ''}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
default:
|
|
75
|
+
throw new Error(`Unknown cookies action: ${action}. Use: list, set, clear`);
|
|
76
|
+
}
|
|
77
|
+
}
|