nothumanallowed 9.5.2 → 9.6.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/README.md +154 -305
- package/bin/nha.mjs +34 -3
- package/package.json +2 -2
- package/src/cli.mjs +105 -153
- package/src/commands/ask.mjs +18 -206
- package/src/commands/chat.mjs +64 -482
- package/src/commands/ui.mjs +41 -837
- package/src/config.mjs +0 -2
- package/src/constants.mjs +1 -1
- package/src/services/google-oauth.mjs +21 -12
- package/src/services/llm.mjs +0 -138
- package/src/services/ops-daemon.mjs +236 -0
- package/src/services/screen-capture.mjs +160 -0
- package/src/services/tool-executor.mjs +88 -335
- package/src/services/web-ui.mjs +126 -423
- package/src/services/browser-engine.mjs +0 -1240
- package/src/services/conversations.mjs +0 -277
- package/src/services/web-tools.mjs +0 -430
|
@@ -1,1240 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* NHA Browser Engine — Chrome DevTools Protocol (CDP) client.
|
|
3
|
-
*
|
|
4
|
-
* Controls Chrome/Chromium headless via the CDP WebSocket protocol.
|
|
5
|
-
* Zero npm dependencies — pure Node.js 22.
|
|
6
|
-
*
|
|
7
|
-
* Capabilities:
|
|
8
|
-
* - Launch Chrome headless (auto-detect path per OS)
|
|
9
|
-
* - Navigate to URLs (with SSRF protection)
|
|
10
|
-
* - Screenshot (full page or viewport, returns base64 PNG)
|
|
11
|
-
* - Click elements by CSS selector or coordinates
|
|
12
|
-
* - Type text into focused elements or selectors
|
|
13
|
-
* - Fill forms (set input values via JS)
|
|
14
|
-
* - Extract page text or HTML
|
|
15
|
-
* - Execute arbitrary JavaScript in page context
|
|
16
|
-
* - Wait for elements or navigation
|
|
17
|
-
* - Cookie & localStorage management
|
|
18
|
-
*
|
|
19
|
-
* Architecture:
|
|
20
|
-
* - Single persistent browser instance (lazy-launched)
|
|
21
|
-
* - WebSocket connection to CDP (ws:// on localhost)
|
|
22
|
-
* - JSON-RPC over WebSocket per CDP spec
|
|
23
|
-
* - Graceful cleanup on process exit
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { spawn } from 'child_process';
|
|
27
|
-
import { createConnection } from 'net';
|
|
28
|
-
import fs from 'fs';
|
|
29
|
-
import os from 'os';
|
|
30
|
-
import path from 'path';
|
|
31
|
-
import dns from 'dns/promises';
|
|
32
|
-
import net from 'net';
|
|
33
|
-
import crypto from 'crypto';
|
|
34
|
-
|
|
35
|
-
// ── Constants ────────────────────────────────────────────────────────────────
|
|
36
|
-
|
|
37
|
-
const CDP_PORT = 9222;
|
|
38
|
-
const LAUNCH_TIMEOUT_MS = 15000;
|
|
39
|
-
const COMMAND_TIMEOUT_MS = 30000;
|
|
40
|
-
const NAV_TIMEOUT_MS = 30000;
|
|
41
|
-
const SCREENSHOT_TIMEOUT_MS = 15000;
|
|
42
|
-
const MAX_OUTPUT_CHARS = 12000;
|
|
43
|
-
|
|
44
|
-
// ── SSRF Protection (reused from web-tools.mjs pattern) ─────────────────────
|
|
45
|
-
|
|
46
|
-
const PRIVATE_RANGES = [
|
|
47
|
-
{ start: '10.0.0.0', end: '10.255.255.255' },
|
|
48
|
-
{ start: '172.16.0.0', end: '172.31.255.255' },
|
|
49
|
-
{ start: '192.168.0.0', end: '192.168.255.255' },
|
|
50
|
-
{ start: '127.0.0.0', end: '127.255.255.255' },
|
|
51
|
-
{ start: '169.254.0.0', end: '169.254.255.255' },
|
|
52
|
-
{ start: '0.0.0.0', end: '0.255.255.255' },
|
|
53
|
-
];
|
|
54
|
-
|
|
55
|
-
function ipToLong(ip) {
|
|
56
|
-
const parts = ip.split('.').map(Number);
|
|
57
|
-
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function isPrivateIp(ip) {
|
|
61
|
-
if (!net.isIPv4(ip)) return false;
|
|
62
|
-
const long = ipToLong(ip);
|
|
63
|
-
for (const range of PRIVATE_RANGES) {
|
|
64
|
-
if (long >= ipToLong(range.start) && long <= ipToLong(range.end)) return true;
|
|
65
|
-
}
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async function isSafeUrl(urlStr) {
|
|
70
|
-
let parsed;
|
|
71
|
-
try {
|
|
72
|
-
parsed = new URL(urlStr);
|
|
73
|
-
} catch {
|
|
74
|
-
return { safe: false, reason: 'Invalid URL' };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
78
|
-
return { safe: false, reason: `Blocked protocol: ${parsed.protocol}` };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const hostname = parsed.hostname.toLowerCase();
|
|
82
|
-
if (
|
|
83
|
-
hostname === 'localhost' ||
|
|
84
|
-
hostname === '0.0.0.0' ||
|
|
85
|
-
hostname === '[::1]' ||
|
|
86
|
-
hostname === '::1' ||
|
|
87
|
-
/^0x[0-9a-f]+$/i.test(hostname) ||
|
|
88
|
-
/^\d+$/.test(hostname)
|
|
89
|
-
) {
|
|
90
|
-
return { safe: false, reason: 'Blocked: localhost/internal' };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
try {
|
|
94
|
-
const addresses = await dns.resolve4(hostname);
|
|
95
|
-
for (const addr of addresses) {
|
|
96
|
-
if (isPrivateIp(addr)) {
|
|
97
|
-
return { safe: false, reason: `Blocked: ${hostname} resolves to private IP ${addr}` };
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
} catch {
|
|
101
|
-
return { safe: false, reason: `DNS resolution failed for ${hostname}` };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return { safe: true };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// ── Chrome Path Detection ───────────────────────────────────────────────────
|
|
108
|
-
|
|
109
|
-
function findChromePath() {
|
|
110
|
-
const platform = os.platform();
|
|
111
|
-
|
|
112
|
-
const candidates = {
|
|
113
|
-
darwin: [
|
|
114
|
-
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
115
|
-
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
116
|
-
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
|
117
|
-
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
118
|
-
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
119
|
-
],
|
|
120
|
-
linux: [
|
|
121
|
-
'/usr/bin/google-chrome',
|
|
122
|
-
'/usr/bin/google-chrome-stable',
|
|
123
|
-
'/usr/bin/chromium',
|
|
124
|
-
'/usr/bin/chromium-browser',
|
|
125
|
-
'/snap/bin/chromium',
|
|
126
|
-
'/usr/bin/brave-browser',
|
|
127
|
-
'/usr/bin/microsoft-edge',
|
|
128
|
-
],
|
|
129
|
-
win32: [
|
|
130
|
-
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
131
|
-
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
132
|
-
`${os.homedir()}\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe`,
|
|
133
|
-
'C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe',
|
|
134
|
-
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
135
|
-
],
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
const paths = candidates[platform] || candidates.linux;
|
|
139
|
-
for (const p of paths) {
|
|
140
|
-
try {
|
|
141
|
-
if (fs.existsSync(p)) return p;
|
|
142
|
-
} catch { /* skip */ }
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// ── CDP WebSocket Client ────────────────────────────────────────────────────
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Minimal WebSocket client for CDP.
|
|
152
|
-
* Implements just enough of RFC 6455 to communicate with Chrome.
|
|
153
|
-
* Handles text frames only (CDP is JSON text).
|
|
154
|
-
*/
|
|
155
|
-
class CDPWebSocket {
|
|
156
|
-
constructor() {
|
|
157
|
-
this._socket = null;
|
|
158
|
-
this._callbacks = new Map();
|
|
159
|
-
this._events = new Map();
|
|
160
|
-
this._buffer = Buffer.alloc(0);
|
|
161
|
-
this._connected = false;
|
|
162
|
-
this._nextId = 1;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Connect to CDP WebSocket endpoint.
|
|
167
|
-
* @param {string} wsUrl - e.g. ws://127.0.0.1:9222/devtools/page/ABC123
|
|
168
|
-
* @returns {Promise<void>}
|
|
169
|
-
*/
|
|
170
|
-
connect(wsUrl) {
|
|
171
|
-
return new Promise((resolve, reject) => {
|
|
172
|
-
const parsed = new URL(wsUrl);
|
|
173
|
-
const host = parsed.hostname;
|
|
174
|
-
const port = parseInt(parsed.port, 10);
|
|
175
|
-
const pathname = parsed.pathname + (parsed.search || '');
|
|
176
|
-
|
|
177
|
-
this._socket = createConnection({ host, port }, () => {
|
|
178
|
-
// Send WebSocket upgrade request
|
|
179
|
-
const key = crypto.randomBytes(16).toString('base64');
|
|
180
|
-
const request = [
|
|
181
|
-
`GET ${pathname} HTTP/1.1`,
|
|
182
|
-
`Host: ${host}:${port}`,
|
|
183
|
-
'Upgrade: websocket',
|
|
184
|
-
'Connection: Upgrade',
|
|
185
|
-
`Sec-WebSocket-Key: ${key}`,
|
|
186
|
-
'Sec-WebSocket-Version: 13',
|
|
187
|
-
'',
|
|
188
|
-
'',
|
|
189
|
-
].join('\r\n');
|
|
190
|
-
|
|
191
|
-
this._socket.write(request);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
let handshakeDone = false;
|
|
195
|
-
|
|
196
|
-
this._socket.on('data', (data) => {
|
|
197
|
-
if (!handshakeDone) {
|
|
198
|
-
// Check for HTTP 101 Switching Protocols
|
|
199
|
-
const str = data.toString();
|
|
200
|
-
if (str.includes('101')) {
|
|
201
|
-
handshakeDone = true;
|
|
202
|
-
this._connected = true;
|
|
203
|
-
// Find end of HTTP headers
|
|
204
|
-
const headerEnd = data.indexOf('\r\n\r\n');
|
|
205
|
-
if (headerEnd !== -1 && headerEnd + 4 < data.length) {
|
|
206
|
-
// Process remaining data as WebSocket frames
|
|
207
|
-
this._processData(data.subarray(headerEnd + 4));
|
|
208
|
-
}
|
|
209
|
-
resolve();
|
|
210
|
-
} else {
|
|
211
|
-
reject(new Error(`WebSocket handshake failed: ${str.slice(0, 200)}`));
|
|
212
|
-
}
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
this._processData(data);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
this._socket.on('error', (err) => {
|
|
220
|
-
if (!handshakeDone) reject(err);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
this._socket.on('close', () => {
|
|
224
|
-
this._connected = false;
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
setTimeout(() => {
|
|
228
|
-
if (!handshakeDone) reject(new Error('WebSocket connection timeout'));
|
|
229
|
-
}, 10000);
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Process incoming WebSocket data — accumulate and parse frames.
|
|
235
|
-
*/
|
|
236
|
-
_processData(data) {
|
|
237
|
-
this._buffer = Buffer.concat([this._buffer, data]);
|
|
238
|
-
this._parseFrames();
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Parse WebSocket frames from buffer.
|
|
243
|
-
* CDP only sends text frames (opcode 0x1) with no masking (server→client).
|
|
244
|
-
*/
|
|
245
|
-
_parseFrames() {
|
|
246
|
-
while (this._buffer.length >= 2) {
|
|
247
|
-
const firstByte = this._buffer[0];
|
|
248
|
-
const secondByte = this._buffer[1];
|
|
249
|
-
const opcode = firstByte & 0x0f;
|
|
250
|
-
const masked = (secondByte & 0x80) !== 0;
|
|
251
|
-
let payloadLen = secondByte & 0x7f;
|
|
252
|
-
let offset = 2;
|
|
253
|
-
|
|
254
|
-
if (payloadLen === 126) {
|
|
255
|
-
if (this._buffer.length < 4) return; // Need more data
|
|
256
|
-
payloadLen = this._buffer.readUInt16BE(2);
|
|
257
|
-
offset = 4;
|
|
258
|
-
} else if (payloadLen === 127) {
|
|
259
|
-
if (this._buffer.length < 10) return;
|
|
260
|
-
// Read as BigInt and convert (CDP payloads shouldn't exceed 2^53)
|
|
261
|
-
payloadLen = Number(this._buffer.readBigUInt64BE(2));
|
|
262
|
-
offset = 10;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
if (masked) offset += 4; // Skip mask key (shouldn't happen for server→client)
|
|
266
|
-
|
|
267
|
-
const totalLen = offset + payloadLen;
|
|
268
|
-
if (this._buffer.length < totalLen) return; // Need more data
|
|
269
|
-
|
|
270
|
-
const payload = this._buffer.subarray(offset, totalLen);
|
|
271
|
-
this._buffer = this._buffer.subarray(totalLen);
|
|
272
|
-
|
|
273
|
-
// Handle text frame (opcode 1) — CDP JSON messages
|
|
274
|
-
if (opcode === 0x01) {
|
|
275
|
-
const text = payload.toString('utf-8');
|
|
276
|
-
try {
|
|
277
|
-
const msg = JSON.parse(text);
|
|
278
|
-
this._handleMessage(msg);
|
|
279
|
-
} catch { /* ignore malformed JSON */ }
|
|
280
|
-
}
|
|
281
|
-
// opcode 0x08 = close
|
|
282
|
-
else if (opcode === 0x08) {
|
|
283
|
-
this.close();
|
|
284
|
-
}
|
|
285
|
-
// opcode 0x09 = ping → send pong
|
|
286
|
-
else if (opcode === 0x09) {
|
|
287
|
-
this._sendFrame(0x0a, payload);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Handle a parsed CDP message — route to callbacks or event listeners.
|
|
294
|
-
*/
|
|
295
|
-
_handleMessage(msg) {
|
|
296
|
-
// Response to a command
|
|
297
|
-
if (msg.id !== undefined && this._callbacks.has(msg.id)) {
|
|
298
|
-
const { resolve, reject } = this._callbacks.get(msg.id);
|
|
299
|
-
this._callbacks.delete(msg.id);
|
|
300
|
-
if (msg.error) {
|
|
301
|
-
reject(new Error(`CDP error: ${msg.error.message} (code ${msg.error.code})`));
|
|
302
|
-
} else {
|
|
303
|
-
resolve(msg.result || {});
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
// Event
|
|
307
|
-
else if (msg.method) {
|
|
308
|
-
const listeners = this._events.get(msg.method) || [];
|
|
309
|
-
for (const fn of listeners) {
|
|
310
|
-
try { fn(msg.params); } catch { /* ignore listener errors */ }
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Send a WebSocket text frame (client→server, masked per RFC 6455).
|
|
317
|
-
*/
|
|
318
|
-
_sendFrame(opcode, payload) {
|
|
319
|
-
if (!this._connected || !this._socket) return;
|
|
320
|
-
|
|
321
|
-
const payloadBuf = typeof payload === 'string' ? Buffer.from(payload, 'utf-8') : payload;
|
|
322
|
-
const len = payloadBuf.length;
|
|
323
|
-
|
|
324
|
-
// Client frames MUST be masked
|
|
325
|
-
const mask = crypto.randomBytes(4);
|
|
326
|
-
let header;
|
|
327
|
-
|
|
328
|
-
if (len < 126) {
|
|
329
|
-
header = Buffer.alloc(6);
|
|
330
|
-
header[0] = 0x80 | opcode; // FIN + opcode
|
|
331
|
-
header[1] = 0x80 | len; // MASK + length
|
|
332
|
-
mask.copy(header, 2);
|
|
333
|
-
} else if (len < 65536) {
|
|
334
|
-
header = Buffer.alloc(8);
|
|
335
|
-
header[0] = 0x80 | opcode;
|
|
336
|
-
header[1] = 0x80 | 126;
|
|
337
|
-
header.writeUInt16BE(len, 2);
|
|
338
|
-
mask.copy(header, 4);
|
|
339
|
-
} else {
|
|
340
|
-
header = Buffer.alloc(14);
|
|
341
|
-
header[0] = 0x80 | opcode;
|
|
342
|
-
header[1] = 0x80 | 127;
|
|
343
|
-
header.writeBigUInt64BE(BigInt(len), 2);
|
|
344
|
-
mask.copy(header, 10);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// Apply mask to payload
|
|
348
|
-
const masked = Buffer.alloc(len);
|
|
349
|
-
for (let i = 0; i < len; i++) {
|
|
350
|
-
masked[i] = payloadBuf[i] ^ mask[i % 4];
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
this._socket.write(Buffer.concat([header, masked]));
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Send a CDP command and wait for response.
|
|
358
|
-
* @param {string} method - CDP method (e.g. "Page.navigate")
|
|
359
|
-
* @param {object} params - CDP params
|
|
360
|
-
* @param {number} timeout - Timeout in ms
|
|
361
|
-
* @returns {Promise<object>}
|
|
362
|
-
*/
|
|
363
|
-
send(method, params = {}, timeout = COMMAND_TIMEOUT_MS) {
|
|
364
|
-
return new Promise((resolve, reject) => {
|
|
365
|
-
const id = this._nextId++;
|
|
366
|
-
const timer = setTimeout(() => {
|
|
367
|
-
this._callbacks.delete(id);
|
|
368
|
-
reject(new Error(`CDP command timeout: ${method} (${timeout}ms)`));
|
|
369
|
-
}, timeout);
|
|
370
|
-
|
|
371
|
-
this._callbacks.set(id, {
|
|
372
|
-
resolve: (result) => { clearTimeout(timer); resolve(result); },
|
|
373
|
-
reject: (err) => { clearTimeout(timer); reject(err); },
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
this._sendFrame(0x01, JSON.stringify({ id, method, params }));
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Register an event listener.
|
|
382
|
-
*/
|
|
383
|
-
on(event, fn) {
|
|
384
|
-
if (!this._events.has(event)) this._events.set(event, []);
|
|
385
|
-
this._events.get(event).push(fn);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/**
|
|
389
|
-
* Remove all listeners for an event.
|
|
390
|
-
*/
|
|
391
|
-
off(event) {
|
|
392
|
-
this._events.delete(event);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Wait for a specific event, with timeout.
|
|
397
|
-
*/
|
|
398
|
-
waitForEvent(event, timeout = NAV_TIMEOUT_MS) {
|
|
399
|
-
return new Promise((resolve, reject) => {
|
|
400
|
-
const timer = setTimeout(() => {
|
|
401
|
-
this.off(event);
|
|
402
|
-
reject(new Error(`Timeout waiting for ${event}`));
|
|
403
|
-
}, timeout);
|
|
404
|
-
|
|
405
|
-
this.on(event, (params) => {
|
|
406
|
-
clearTimeout(timer);
|
|
407
|
-
this.off(event);
|
|
408
|
-
resolve(params);
|
|
409
|
-
});
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* Close the WebSocket connection.
|
|
415
|
-
*/
|
|
416
|
-
close() {
|
|
417
|
-
this._connected = false;
|
|
418
|
-
if (this._socket) {
|
|
419
|
-
try {
|
|
420
|
-
this._sendFrame(0x08, Buffer.alloc(0));
|
|
421
|
-
this._socket.end();
|
|
422
|
-
} catch { /* best effort */ }
|
|
423
|
-
this._socket = null;
|
|
424
|
-
}
|
|
425
|
-
// Reject all pending callbacks
|
|
426
|
-
for (const [id, { reject }] of this._callbacks) {
|
|
427
|
-
reject(new Error('WebSocket closed'));
|
|
428
|
-
}
|
|
429
|
-
this._callbacks.clear();
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
get connected() {
|
|
433
|
-
return this._connected;
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// ── Browser Instance Manager ────────────────────────────────────────────────
|
|
438
|
-
|
|
439
|
-
/** @type {{ process: import('child_process').ChildProcess, ws: CDPWebSocket, wsUrl: string, userDataDir: string } | null} */
|
|
440
|
-
let _browser = null;
|
|
441
|
-
|
|
442
|
-
/**
|
|
443
|
-
* Find a free port for CDP.
|
|
444
|
-
*/
|
|
445
|
-
function findFreePort() {
|
|
446
|
-
return new Promise((resolve, reject) => {
|
|
447
|
-
const server = net.createServer();
|
|
448
|
-
server.listen(0, '127.0.0.1', () => {
|
|
449
|
-
const port = server.address().port;
|
|
450
|
-
server.close(() => resolve(port));
|
|
451
|
-
});
|
|
452
|
-
server.on('error', reject);
|
|
453
|
-
});
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
/**
|
|
457
|
-
* Wait for CDP to become available.
|
|
458
|
-
* Uses Chrome's stderr to get the browser WS URL, then creates a page target.
|
|
459
|
-
* Falls back to polling /json endpoint.
|
|
460
|
-
*/
|
|
461
|
-
async function waitForCDP(port, stderrPromise, timeoutMs = LAUNCH_TIMEOUT_MS) {
|
|
462
|
-
const start = Date.now();
|
|
463
|
-
|
|
464
|
-
// Wait for Chrome to print its DevTools WS URL
|
|
465
|
-
let browserWsUrl;
|
|
466
|
-
try {
|
|
467
|
-
browserWsUrl = await Promise.race([
|
|
468
|
-
stderrPromise,
|
|
469
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)),
|
|
470
|
-
]);
|
|
471
|
-
} catch { /* continue to polling */ }
|
|
472
|
-
|
|
473
|
-
// Extract actual port from wsUrl (may differ from requested port)
|
|
474
|
-
const actualPort = browserWsUrl
|
|
475
|
-
? parseInt(new URL(browserWsUrl).port, 10) || port
|
|
476
|
-
: port;
|
|
477
|
-
|
|
478
|
-
// Poll /json to find or create a page target
|
|
479
|
-
while (Date.now() - start < timeoutMs) {
|
|
480
|
-
try {
|
|
481
|
-
const res = await fetch(`http://127.0.0.1:${actualPort}/json`, {
|
|
482
|
-
signal: AbortSignal.timeout(3000),
|
|
483
|
-
});
|
|
484
|
-
if (res.ok) {
|
|
485
|
-
const targets = await res.json();
|
|
486
|
-
// Look for an existing page target
|
|
487
|
-
const page = targets.find(t => t.type === 'page');
|
|
488
|
-
if (page && page.webSocketDebuggerUrl) {
|
|
489
|
-
return page.webSocketDebuggerUrl;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// No page target — create one via /json/new
|
|
493
|
-
try {
|
|
494
|
-
const newRes = await fetch(`http://127.0.0.1:${actualPort}/json/new?about:blank`, {
|
|
495
|
-
method: 'PUT',
|
|
496
|
-
signal: AbortSignal.timeout(3000),
|
|
497
|
-
});
|
|
498
|
-
if (newRes.ok) {
|
|
499
|
-
const newPage = await newRes.json();
|
|
500
|
-
if (newPage.webSocketDebuggerUrl) {
|
|
501
|
-
return newPage.webSocketDebuggerUrl;
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
} catch { /* /json/new might not work, keep polling */ }
|
|
505
|
-
}
|
|
506
|
-
} catch { /* not ready yet */ }
|
|
507
|
-
await new Promise(r => setTimeout(r, 300));
|
|
508
|
-
}
|
|
509
|
-
throw new Error(`Chrome CDP not available after ${timeoutMs}ms. Is Chrome installed?`);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
/**
|
|
513
|
-
* Launch Chrome headless and connect via CDP.
|
|
514
|
-
* Returns the browser instance or throws.
|
|
515
|
-
*/
|
|
516
|
-
async function launchBrowser() {
|
|
517
|
-
if (_browser && _browser.ws.connected) return _browser;
|
|
518
|
-
|
|
519
|
-
const chromePath = findChromePath();
|
|
520
|
-
if (!chromePath) {
|
|
521
|
-
throw new Error(
|
|
522
|
-
'Chrome/Chromium not found. Install Chrome or set CHROME_PATH environment variable.\n' +
|
|
523
|
-
'Checked paths:\n' +
|
|
524
|
-
' macOS: /Applications/Google Chrome.app\n' +
|
|
525
|
-
' Linux: /usr/bin/google-chrome, /usr/bin/chromium\n' +
|
|
526
|
-
' Windows: C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
|
|
527
|
-
);
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
const userDataDir = path.join(os.tmpdir(), `nha-browser-${Date.now()}`);
|
|
531
|
-
fs.mkdirSync(userDataDir, { recursive: true });
|
|
532
|
-
|
|
533
|
-
const port = await findFreePort();
|
|
534
|
-
|
|
535
|
-
// Realistic user agent to avoid bot detection on major sites
|
|
536
|
-
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
|
537
|
-
|
|
538
|
-
const chromeArgs = [
|
|
539
|
-
`--remote-debugging-port=${port}`,
|
|
540
|
-
'--headless=new',
|
|
541
|
-
`--user-data-dir=${userDataDir}`,
|
|
542
|
-
`--user-agent=${UA}`,
|
|
543
|
-
'--no-first-run',
|
|
544
|
-
'--no-default-browser-check',
|
|
545
|
-
'--disable-blink-features=AutomationControlled',
|
|
546
|
-
'--disable-background-networking',
|
|
547
|
-
'--disable-background-timer-throttling',
|
|
548
|
-
'--disable-backgrounding-occluded-windows',
|
|
549
|
-
'--disable-breakpad',
|
|
550
|
-
'--disable-component-update',
|
|
551
|
-
'--disable-default-apps',
|
|
552
|
-
'--disable-dev-shm-usage',
|
|
553
|
-
'--disable-extensions',
|
|
554
|
-
'--disable-hang-monitor',
|
|
555
|
-
'--disable-ipc-flooding-protection',
|
|
556
|
-
'--disable-popup-blocking',
|
|
557
|
-
'--disable-prompt-on-repost',
|
|
558
|
-
'--disable-renderer-backgrounding',
|
|
559
|
-
'--disable-sync',
|
|
560
|
-
'--disable-translate',
|
|
561
|
-
'--metrics-recording-only',
|
|
562
|
-
'--safebrowsing-disable-auto-update',
|
|
563
|
-
'--window-size=1920,1080',
|
|
564
|
-
'about:blank',
|
|
565
|
-
];
|
|
566
|
-
|
|
567
|
-
const useChromePath = process.env.CHROME_PATH || chromePath;
|
|
568
|
-
|
|
569
|
-
const proc = spawn(useChromePath, chromeArgs, {
|
|
570
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
571
|
-
detached: false,
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
// Capture stderr for debugging + extract DevTools WS URL
|
|
575
|
-
let stderrBuf = '';
|
|
576
|
-
const stderrWsPromise = new Promise((resolve) => {
|
|
577
|
-
proc.stderr.on('data', (d) => {
|
|
578
|
-
stderrBuf += d.toString();
|
|
579
|
-
// Chrome prints: "DevTools listening on ws://127.0.0.1:PORT/devtools/browser/UUID"
|
|
580
|
-
const wsMatch = stderrBuf.match(/DevTools listening on (ws:\/\/\S+)/);
|
|
581
|
-
if (wsMatch) resolve(wsMatch[1]);
|
|
582
|
-
});
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
proc.on('error', (err) => {
|
|
586
|
-
throw new Error(`Failed to launch Chrome: ${err.message}`);
|
|
587
|
-
});
|
|
588
|
-
|
|
589
|
-
// Wait for CDP to be ready
|
|
590
|
-
let wsUrl;
|
|
591
|
-
try {
|
|
592
|
-
wsUrl = await waitForCDP(port, stderrWsPromise);
|
|
593
|
-
} catch (err) {
|
|
594
|
-
proc.kill('SIGTERM');
|
|
595
|
-
// Wait for Chrome to fully exit before cleaning up
|
|
596
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
597
|
-
try { fs.rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); } catch {}
|
|
598
|
-
throw new Error(`${err.message}\nChrome stderr: ${stderrBuf.slice(0, 500)}`);
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// Connect WebSocket
|
|
602
|
-
const ws = new CDPWebSocket();
|
|
603
|
-
await ws.connect(wsUrl);
|
|
604
|
-
|
|
605
|
-
// Enable required CDP domains
|
|
606
|
-
await ws.send('Page.enable');
|
|
607
|
-
await ws.send('Runtime.enable');
|
|
608
|
-
await ws.send('Network.enable');
|
|
609
|
-
await ws.send('DOM.enable');
|
|
610
|
-
|
|
611
|
-
// Anti-detection: remove navigator.webdriver flag and other headless indicators
|
|
612
|
-
await ws.send('Page.addScriptToEvaluateOnNewDocument', {
|
|
613
|
-
source: `
|
|
614
|
-
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
|
615
|
-
Object.defineProperty(navigator, 'languages', { get: () => ['it-IT', 'it', 'en-US', 'en'] });
|
|
616
|
-
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
|
617
|
-
window.chrome = { runtime: {} };
|
|
618
|
-
`,
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
_browser = { process: proc, ws, wsUrl, userDataDir, port };
|
|
622
|
-
|
|
623
|
-
// Cleanup on process exit
|
|
624
|
-
const cleanup = () => {
|
|
625
|
-
if (_browser) {
|
|
626
|
-
try { _browser.ws.close(); } catch {}
|
|
627
|
-
try { _browser.process.kill('SIGTERM'); } catch {}
|
|
628
|
-
try { fs.rmSync(_browser.userDataDir, { recursive: true, force: true }); } catch {}
|
|
629
|
-
_browser = null;
|
|
630
|
-
}
|
|
631
|
-
};
|
|
632
|
-
|
|
633
|
-
process.on('exit', cleanup);
|
|
634
|
-
process.on('SIGINT', cleanup);
|
|
635
|
-
process.on('SIGTERM', cleanup);
|
|
636
|
-
|
|
637
|
-
return _browser;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
/**
|
|
641
|
-
* Get the active browser, launching if needed.
|
|
642
|
-
*/
|
|
643
|
-
async function getBrowser() {
|
|
644
|
-
if (_browser && _browser.ws.connected) return _browser;
|
|
645
|
-
return launchBrowser();
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
// ── Public API ──────────────────────────────────────────────────────────────
|
|
649
|
-
|
|
650
|
-
/**
|
|
651
|
-
* Open a URL in the browser.
|
|
652
|
-
* SSRF-protected — blocks private IPs, localhost, non-HTTP protocols.
|
|
653
|
-
*
|
|
654
|
-
* @param {string} url - URL to navigate to
|
|
655
|
-
* @param {object} [options]
|
|
656
|
-
* @param {number} [options.timeout] - Navigation timeout in ms (default 30s)
|
|
657
|
-
* @param {boolean} [options.waitForLoad] - Wait for page load event (default true)
|
|
658
|
-
* @returns {Promise<{ title: string, url: string, status: number }>}
|
|
659
|
-
*/
|
|
660
|
-
export async function browserOpen(url, options = {}) {
|
|
661
|
-
// SSRF check
|
|
662
|
-
const check = await isSafeUrl(url);
|
|
663
|
-
if (!check.safe) {
|
|
664
|
-
return { error: true, message: `SSRF blocked: ${check.reason}` };
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
const browser = await getBrowser();
|
|
668
|
-
const timeout = options.timeout || NAV_TIMEOUT_MS;
|
|
669
|
-
const waitForLoad = options.waitForLoad !== false;
|
|
670
|
-
|
|
671
|
-
// Navigate
|
|
672
|
-
const navResult = await browser.ws.send('Page.navigate', { url }, timeout);
|
|
673
|
-
|
|
674
|
-
if (navResult.errorText) {
|
|
675
|
-
return { error: true, message: `Navigation error: ${navResult.errorText}` };
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
// Wait for load
|
|
679
|
-
if (waitForLoad) {
|
|
680
|
-
try {
|
|
681
|
-
await browser.ws.waitForEvent('Page.loadEventFired', timeout);
|
|
682
|
-
} catch {
|
|
683
|
-
// Page may not fire load event (e.g. streaming pages), continue anyway
|
|
684
|
-
}
|
|
685
|
-
// Small delay for JS rendering
|
|
686
|
-
await new Promise(r => setTimeout(r, 500));
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// Get page info
|
|
690
|
-
const titleResult = await browser.ws.send('Runtime.evaluate', {
|
|
691
|
-
expression: 'document.title',
|
|
692
|
-
returnByValue: true,
|
|
693
|
-
});
|
|
694
|
-
|
|
695
|
-
const urlResult = await browser.ws.send('Runtime.evaluate', {
|
|
696
|
-
expression: 'window.location.href',
|
|
697
|
-
returnByValue: true,
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
// Check final URL for SSRF (after redirects)
|
|
701
|
-
const finalUrl = urlResult.result?.value || url;
|
|
702
|
-
if (finalUrl !== url) {
|
|
703
|
-
const finalCheck = await isSafeUrl(finalUrl);
|
|
704
|
-
if (!finalCheck.safe) {
|
|
705
|
-
// Navigate away from the blocked page
|
|
706
|
-
await browser.ws.send('Page.navigate', { url: 'about:blank' });
|
|
707
|
-
return { error: true, message: `Redirect blocked: ${finalCheck.reason}` };
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
return {
|
|
712
|
-
error: false,
|
|
713
|
-
title: titleResult.result?.value || '',
|
|
714
|
-
url: finalUrl,
|
|
715
|
-
};
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
/**
|
|
719
|
-
* Take a screenshot of the current page.
|
|
720
|
-
*
|
|
721
|
-
* @param {object} [options]
|
|
722
|
-
* @param {boolean} [options.fullPage] - Capture full scrollable page (default false)
|
|
723
|
-
* @param {'png'|'jpeg'|'webp'} [options.format] - Image format (default 'png')
|
|
724
|
-
* @param {number} [options.quality] - JPEG/WebP quality 0-100 (default 80)
|
|
725
|
-
* @param {string} [options.saveTo] - Save to file path (otherwise returns base64)
|
|
726
|
-
* @returns {Promise<{ base64: string, width: number, height: number, savedTo?: string }>}
|
|
727
|
-
*/
|
|
728
|
-
export async function browserScreenshot(options = {}) {
|
|
729
|
-
const browser = await getBrowser();
|
|
730
|
-
const format = options.format || 'png';
|
|
731
|
-
const quality = format === 'png' ? undefined : (options.quality || 80);
|
|
732
|
-
|
|
733
|
-
const params = {
|
|
734
|
-
format,
|
|
735
|
-
...(quality !== undefined && { quality }),
|
|
736
|
-
};
|
|
737
|
-
|
|
738
|
-
if (options.fullPage) {
|
|
739
|
-
// Get full page dimensions
|
|
740
|
-
const metrics = await browser.ws.send('Page.getLayoutMetrics');
|
|
741
|
-
const { width, height } = metrics.contentSize || metrics.cssContentSize || { width: 1920, height: 1080 };
|
|
742
|
-
|
|
743
|
-
// Set viewport to full page size (capped)
|
|
744
|
-
const cappedHeight = Math.min(height, 16384); // Chrome limit
|
|
745
|
-
await browser.ws.send('Emulation.setDeviceMetricsOverride', {
|
|
746
|
-
width: Math.ceil(width),
|
|
747
|
-
height: Math.ceil(cappedHeight),
|
|
748
|
-
deviceScaleFactor: 1,
|
|
749
|
-
mobile: false,
|
|
750
|
-
});
|
|
751
|
-
|
|
752
|
-
params.clip = { x: 0, y: 0, width: Math.ceil(width), height: Math.ceil(cappedHeight), scale: 1 };
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
const result = await browser.ws.send('Page.captureScreenshot', params, SCREENSHOT_TIMEOUT_MS);
|
|
756
|
-
|
|
757
|
-
// Reset viewport if we changed it
|
|
758
|
-
if (options.fullPage) {
|
|
759
|
-
await browser.ws.send('Emulation.clearDeviceMetricsOverride');
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
const base64 = result.data;
|
|
763
|
-
|
|
764
|
-
if (options.saveTo) {
|
|
765
|
-
const absPath = path.resolve(options.saveTo);
|
|
766
|
-
fs.writeFileSync(absPath, Buffer.from(base64, 'base64'));
|
|
767
|
-
return { error: false, base64, savedTo: absPath, size: base64.length };
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
return { error: false, base64, size: base64.length };
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
/**
|
|
774
|
-
* Click an element by CSS selector, visible text, or coordinates.
|
|
775
|
-
*
|
|
776
|
-
* @param {object} target
|
|
777
|
-
* @param {string} [target.selector] - CSS selector to click
|
|
778
|
-
* @param {string} [target.text] - Visible text of a button/link/element to click (case-insensitive partial match)
|
|
779
|
-
* @param {number} [target.x] - X coordinate
|
|
780
|
-
* @param {number} [target.y] - Y coordinate
|
|
781
|
-
* @returns {Promise<{ clicked: boolean, selector?: string }>}
|
|
782
|
-
*/
|
|
783
|
-
export async function browserClick(target) {
|
|
784
|
-
const browser = await getBrowser();
|
|
785
|
-
|
|
786
|
-
let x, y;
|
|
787
|
-
let label = '';
|
|
788
|
-
|
|
789
|
-
if (target.text) {
|
|
790
|
-
// Find element by visible text — searches buttons, links, and all clickable elements
|
|
791
|
-
const searchText = target.text;
|
|
792
|
-
const result = await browser.ws.send('Runtime.evaluate', {
|
|
793
|
-
expression: `(() => {
|
|
794
|
-
const searchText = ${JSON.stringify(searchText)}.toLowerCase();
|
|
795
|
-
// Search in priority order: buttons, links, then any visible element
|
|
796
|
-
const candidates = [
|
|
797
|
-
...document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]'),
|
|
798
|
-
...document.querySelectorAll('a'),
|
|
799
|
-
...document.querySelectorAll('[onclick], [tabindex]'),
|
|
800
|
-
];
|
|
801
|
-
for (const el of candidates) {
|
|
802
|
-
const text = (el.textContent || el.value || el.getAttribute('aria-label') || '').trim();
|
|
803
|
-
if (text.toLowerCase().includes(searchText) && el.offsetHeight > 0 && el.offsetWidth > 0) {
|
|
804
|
-
const rect = el.getBoundingClientRect();
|
|
805
|
-
if (rect.width > 0 && rect.height > 0) {
|
|
806
|
-
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, tag: el.tagName, text: text.slice(0, 60) };
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
return null;
|
|
811
|
-
})()`,
|
|
812
|
-
returnByValue: true,
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
const info = result.result?.value;
|
|
816
|
-
if (!info) {
|
|
817
|
-
return { error: true, message: `No clickable element found with text "${searchText}". Try browser_extract to see visible buttons.` };
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
x = Math.round(info.x);
|
|
821
|
-
y = Math.round(info.y);
|
|
822
|
-
label = `"${info.text}" (${info.tag})`;
|
|
823
|
-
} else if (target.selector) {
|
|
824
|
-
// Find element by CSS selector and get its center point
|
|
825
|
-
const result = await browser.ws.send('Runtime.evaluate', {
|
|
826
|
-
expression: `(() => {
|
|
827
|
-
const el = document.querySelector(${JSON.stringify(target.selector)});
|
|
828
|
-
if (!el) return null;
|
|
829
|
-
const rect = el.getBoundingClientRect();
|
|
830
|
-
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, tag: el.tagName, text: el.textContent?.slice(0, 50) };
|
|
831
|
-
})()`,
|
|
832
|
-
returnByValue: true,
|
|
833
|
-
awaitPromise: false,
|
|
834
|
-
});
|
|
835
|
-
|
|
836
|
-
const info = result.result?.value;
|
|
837
|
-
if (!info) {
|
|
838
|
-
return { error: true, message: `Element not found: ${target.selector}` };
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
x = Math.round(info.x);
|
|
842
|
-
y = Math.round(info.y);
|
|
843
|
-
label = target.selector;
|
|
844
|
-
} else if (target.x !== undefined && target.y !== undefined) {
|
|
845
|
-
x = target.x;
|
|
846
|
-
y = target.y;
|
|
847
|
-
label = `(${x}, ${y})`;
|
|
848
|
-
} else {
|
|
849
|
-
return { error: true, message: 'Provide text, CSS selector, or x/y coordinates' };
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
// Dispatch mouse events: move → down → up (simulates real click)
|
|
853
|
-
await browser.ws.send('Input.dispatchMouseEvent', {
|
|
854
|
-
type: 'mouseMoved', x, y,
|
|
855
|
-
});
|
|
856
|
-
await browser.ws.send('Input.dispatchMouseEvent', {
|
|
857
|
-
type: 'mousePressed', x, y, button: 'left', clickCount: 1,
|
|
858
|
-
});
|
|
859
|
-
await browser.ws.send('Input.dispatchMouseEvent', {
|
|
860
|
-
type: 'mouseReleased', x, y, button: 'left', clickCount: 1,
|
|
861
|
-
});
|
|
862
|
-
|
|
863
|
-
// Wait for any navigation or JS handlers triggered by the click
|
|
864
|
-
await new Promise(r => setTimeout(r, 500));
|
|
865
|
-
|
|
866
|
-
// Check if the click triggered a navigation — wait for it
|
|
867
|
-
try {
|
|
868
|
-
const navCheck = await browser.ws.send('Runtime.evaluate', {
|
|
869
|
-
expression: 'document.readyState',
|
|
870
|
-
returnByValue: true,
|
|
871
|
-
});
|
|
872
|
-
if (navCheck.result?.value === 'loading') {
|
|
873
|
-
// Page is navigating — wait for load
|
|
874
|
-
try {
|
|
875
|
-
await browser.ws.waitForEvent('Page.loadEventFired', 10000);
|
|
876
|
-
await new Promise(r => setTimeout(r, 500));
|
|
877
|
-
} catch { /* timeout ok */ }
|
|
878
|
-
}
|
|
879
|
-
} catch { /* evaluation failed during navigation, that's fine */ }
|
|
880
|
-
|
|
881
|
-
return { error: false, clicked: true, x, y, selector: label || target.selector || `(${x}, ${y})` };
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
/**
|
|
885
|
-
* Type text into the currently focused element or a specific selector.
|
|
886
|
-
*
|
|
887
|
-
* @param {object} params
|
|
888
|
-
* @param {string} params.text - Text to type
|
|
889
|
-
* @param {string} [params.selector] - CSS selector to focus first
|
|
890
|
-
* @param {boolean} [params.clear] - Clear existing content before typing (default false)
|
|
891
|
-
* @param {number} [params.delay] - Delay between keystrokes in ms (default 0 = instant)
|
|
892
|
-
* @returns {Promise<{ typed: boolean, length: number }>}
|
|
893
|
-
*/
|
|
894
|
-
export async function browserType(params) {
|
|
895
|
-
const browser = await getBrowser();
|
|
896
|
-
|
|
897
|
-
if (params.selector) {
|
|
898
|
-
// Click the element first to focus it
|
|
899
|
-
const clickResult = await browserClick({ selector: params.selector });
|
|
900
|
-
if (clickResult.error) return clickResult;
|
|
901
|
-
await new Promise(r => setTimeout(r, 100));
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
if (params.clear) {
|
|
905
|
-
// Select all and delete
|
|
906
|
-
await browser.ws.send('Input.dispatchKeyEvent', {
|
|
907
|
-
type: 'keyDown', key: 'a', code: 'KeyA',
|
|
908
|
-
modifiers: os.platform() === 'darwin' ? 4 : 2, // Meta (Mac) or Ctrl
|
|
909
|
-
});
|
|
910
|
-
await browser.ws.send('Input.dispatchKeyEvent', {
|
|
911
|
-
type: 'keyUp', key: 'a', code: 'KeyA',
|
|
912
|
-
});
|
|
913
|
-
await browser.ws.send('Input.dispatchKeyEvent', {
|
|
914
|
-
type: 'keyDown', key: 'Backspace', code: 'Backspace',
|
|
915
|
-
});
|
|
916
|
-
await browser.ws.send('Input.dispatchKeyEvent', {
|
|
917
|
-
type: 'keyUp', key: 'Backspace', code: 'Backspace',
|
|
918
|
-
});
|
|
919
|
-
await new Promise(r => setTimeout(r, 50));
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
const text = params.text || '';
|
|
923
|
-
const delay = params.delay || 0;
|
|
924
|
-
|
|
925
|
-
if (delay > 0) {
|
|
926
|
-
// Type character by character with delay
|
|
927
|
-
for (const char of text) {
|
|
928
|
-
await browser.ws.send('Input.dispatchKeyEvent', {
|
|
929
|
-
type: 'keyDown', text: char, key: char,
|
|
930
|
-
unmodifiedText: char,
|
|
931
|
-
});
|
|
932
|
-
await browser.ws.send('Input.dispatchKeyEvent', {
|
|
933
|
-
type: 'keyUp', key: char,
|
|
934
|
-
});
|
|
935
|
-
if (delay > 0) await new Promise(r => setTimeout(r, delay));
|
|
936
|
-
}
|
|
937
|
-
} else {
|
|
938
|
-
// Insert text all at once (faster)
|
|
939
|
-
await browser.ws.send('Input.insertText', { text });
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
return { error: false, typed: true, length: text.length, selector: params.selector || '(focused)' };
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
/**
|
|
946
|
-
* Extract text content or HTML from the current page.
|
|
947
|
-
*
|
|
948
|
-
* @param {object} [options]
|
|
949
|
-
* @param {string} [options.selector] - CSS selector to extract from (default: body)
|
|
950
|
-
* @param {'text'|'html'|'value'|'attribute'} [options.mode] - Extraction mode (default 'text')
|
|
951
|
-
* @param {string} [options.attribute] - Attribute name when mode='attribute'
|
|
952
|
-
* @param {boolean} [options.all] - Extract from all matching elements (default false)
|
|
953
|
-
* @returns {Promise<{ content: string, length: number }>}
|
|
954
|
-
*/
|
|
955
|
-
export async function browserExtract(options = {}) {
|
|
956
|
-
const browser = await getBrowser();
|
|
957
|
-
const selector = options.selector || 'body';
|
|
958
|
-
const mode = options.mode || 'text';
|
|
959
|
-
|
|
960
|
-
let expression;
|
|
961
|
-
|
|
962
|
-
if (options.all) {
|
|
963
|
-
expression = `(() => {
|
|
964
|
-
const els = document.querySelectorAll(${JSON.stringify(selector)});
|
|
965
|
-
if (els.length === 0) return null;
|
|
966
|
-
return Array.from(els).map((el, i) => {
|
|
967
|
-
${mode === 'html' ? 'return el.innerHTML;' : ''}
|
|
968
|
-
${mode === 'value' ? 'return el.value || el.textContent;' : ''}
|
|
969
|
-
${mode === 'attribute' ? `return el.getAttribute(${JSON.stringify(options.attribute || '')});` : ''}
|
|
970
|
-
${mode === 'text' ? 'return el.textContent;' : ''}
|
|
971
|
-
}).filter(Boolean).join('\\n---\\n');
|
|
972
|
-
})()`;
|
|
973
|
-
} else {
|
|
974
|
-
expression = `(() => {
|
|
975
|
-
const el = document.querySelector(${JSON.stringify(selector)});
|
|
976
|
-
if (!el) return null;
|
|
977
|
-
${mode === 'html' ? 'return el.innerHTML;' : ''}
|
|
978
|
-
${mode === 'value' ? 'return el.value || el.textContent;' : ''}
|
|
979
|
-
${mode === 'attribute' ? `return el.getAttribute(${JSON.stringify(options.attribute || '')});` : ''}
|
|
980
|
-
${mode === 'text' ? 'return el.textContent;' : ''}
|
|
981
|
-
})()`;
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
const result = await browser.ws.send('Runtime.evaluate', {
|
|
985
|
-
expression,
|
|
986
|
-
returnByValue: true,
|
|
987
|
-
});
|
|
988
|
-
|
|
989
|
-
const content = result.result?.value;
|
|
990
|
-
if (content === null || content === undefined) {
|
|
991
|
-
return { error: true, message: `Element not found: ${selector}` };
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
// Trim and cap output
|
|
995
|
-
let trimmed = typeof content === 'string'
|
|
996
|
-
? content.replace(/\s+/g, ' ').trim()
|
|
997
|
-
: String(content);
|
|
998
|
-
|
|
999
|
-
if (trimmed.length > MAX_OUTPUT_CHARS) {
|
|
1000
|
-
trimmed = trimmed.slice(0, MAX_OUTPUT_CHARS) + '\n\n[... truncated at 12000 chars]';
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
return { error: false, content: trimmed, length: trimmed.length, selector };
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
/**
|
|
1007
|
-
* Execute arbitrary JavaScript in the page context.
|
|
1008
|
-
*
|
|
1009
|
-
* @param {string} code - JavaScript code to execute
|
|
1010
|
-
* @param {object} [options]
|
|
1011
|
-
* @param {boolean} [options.awaitPromise] - Await promise result (default true)
|
|
1012
|
-
* @returns {Promise<{ result: any, type: string }>}
|
|
1013
|
-
*/
|
|
1014
|
-
export async function browserEval(code, options = {}) {
|
|
1015
|
-
const browser = await getBrowser();
|
|
1016
|
-
|
|
1017
|
-
// Auto-wrap object literals: {key: val} → ({key: val}) to avoid block/label ambiguity
|
|
1018
|
-
let expression = code.trim();
|
|
1019
|
-
if (expression.startsWith('{') && !expression.startsWith('{(')) {
|
|
1020
|
-
expression = `(${expression})`;
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
let result = await browser.ws.send('Runtime.evaluate', {
|
|
1024
|
-
expression,
|
|
1025
|
-
returnByValue: true,
|
|
1026
|
-
awaitPromise: options.awaitPromise !== false,
|
|
1027
|
-
generatePreview: true,
|
|
1028
|
-
}, COMMAND_TIMEOUT_MS);
|
|
1029
|
-
|
|
1030
|
-
// If SyntaxError, retry with IIFE wrapper
|
|
1031
|
-
if (result.exceptionDetails?.exception?.description?.includes('SyntaxError')) {
|
|
1032
|
-
result = await browser.ws.send('Runtime.evaluate', {
|
|
1033
|
-
expression: `(() => { return (${code.trim()}); })()`,
|
|
1034
|
-
returnByValue: true,
|
|
1035
|
-
awaitPromise: options.awaitPromise !== false,
|
|
1036
|
-
generatePreview: true,
|
|
1037
|
-
}, COMMAND_TIMEOUT_MS);
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
if (result.exceptionDetails) {
|
|
1041
|
-
const errMsg = result.exceptionDetails.exception?.description
|
|
1042
|
-
|| result.exceptionDetails.text
|
|
1043
|
-
|| 'Unknown JS error';
|
|
1044
|
-
return { error: true, message: `JS error: ${errMsg}` };
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
const value = result.result?.value;
|
|
1048
|
-
const type = result.result?.type;
|
|
1049
|
-
|
|
1050
|
-
let display;
|
|
1051
|
-
if (type === 'undefined') {
|
|
1052
|
-
display = 'undefined';
|
|
1053
|
-
} else if (value === null) {
|
|
1054
|
-
display = 'null';
|
|
1055
|
-
} else if (typeof value === 'object') {
|
|
1056
|
-
try {
|
|
1057
|
-
display = JSON.stringify(value, null, 2);
|
|
1058
|
-
} catch {
|
|
1059
|
-
display = String(value);
|
|
1060
|
-
}
|
|
1061
|
-
} else {
|
|
1062
|
-
display = String(value);
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
if (display.length > MAX_OUTPUT_CHARS) {
|
|
1066
|
-
display = display.slice(0, MAX_OUTPUT_CHARS) + '\n\n[... truncated]';
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
return { error: false, result: display, type: type || 'unknown' };
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
/**
|
|
1073
|
-
* Wait for an element to appear on the page.
|
|
1074
|
-
*
|
|
1075
|
-
* @param {string} selector - CSS selector to wait for
|
|
1076
|
-
* @param {object} [options]
|
|
1077
|
-
* @param {number} [options.timeout] - Max wait time in ms (default 10000)
|
|
1078
|
-
* @param {boolean} [options.visible] - Wait for element to be visible (default true)
|
|
1079
|
-
* @returns {Promise<{ found: boolean }>}
|
|
1080
|
-
*/
|
|
1081
|
-
export async function browserWaitFor(selector, options = {}) {
|
|
1082
|
-
const browser = await getBrowser();
|
|
1083
|
-
const timeout = options.timeout || 10000;
|
|
1084
|
-
const visible = options.visible !== false;
|
|
1085
|
-
|
|
1086
|
-
const start = Date.now();
|
|
1087
|
-
while (Date.now() - start < timeout) {
|
|
1088
|
-
const check = await browser.ws.send('Runtime.evaluate', {
|
|
1089
|
-
expression: `(() => {
|
|
1090
|
-
const el = document.querySelector(${JSON.stringify(selector)});
|
|
1091
|
-
if (!el) return false;
|
|
1092
|
-
${visible ? `
|
|
1093
|
-
const style = window.getComputedStyle(el);
|
|
1094
|
-
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
|
1095
|
-
const rect = el.getBoundingClientRect();
|
|
1096
|
-
if (rect.width === 0 || rect.height === 0) return false;
|
|
1097
|
-
` : ''}
|
|
1098
|
-
return true;
|
|
1099
|
-
})()`,
|
|
1100
|
-
returnByValue: true,
|
|
1101
|
-
});
|
|
1102
|
-
|
|
1103
|
-
if (check.result?.value === true) {
|
|
1104
|
-
return { error: false, found: true, selector, elapsed: Date.now() - start };
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
await new Promise(r => setTimeout(r, 200));
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
return { error: true, message: `Element "${selector}" not found after ${timeout}ms` };
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
/**
|
|
1114
|
-
* Get the current page URL and title.
|
|
1115
|
-
*/
|
|
1116
|
-
export async function browserInfo() {
|
|
1117
|
-
const browser = await getBrowser();
|
|
1118
|
-
|
|
1119
|
-
const result = await browser.ws.send('Runtime.evaluate', {
|
|
1120
|
-
expression: `({ url: window.location.href, title: document.title, readyState: document.readyState })`,
|
|
1121
|
-
returnByValue: true,
|
|
1122
|
-
});
|
|
1123
|
-
|
|
1124
|
-
return {
|
|
1125
|
-
error: false,
|
|
1126
|
-
url: result.result?.value?.url || 'about:blank',
|
|
1127
|
-
title: result.result?.value?.title || '',
|
|
1128
|
-
readyState: result.result?.value?.readyState || 'unknown',
|
|
1129
|
-
};
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
/**
|
|
1133
|
-
* Close the browser instance.
|
|
1134
|
-
*/
|
|
1135
|
-
export async function browserClose() {
|
|
1136
|
-
if (!_browser) return { error: false, message: 'No browser running' };
|
|
1137
|
-
|
|
1138
|
-
try { _browser.ws.close(); } catch {}
|
|
1139
|
-
try { _browser.process.kill('SIGTERM'); } catch {}
|
|
1140
|
-
try { fs.rmSync(_browser.userDataDir, { recursive: true, force: true }); } catch {}
|
|
1141
|
-
_browser = null;
|
|
1142
|
-
|
|
1143
|
-
return { error: false, message: 'Browser closed' };
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
/**
|
|
1147
|
-
* Press a keyboard key (Enter, Tab, Escape, ArrowDown, etc.)
|
|
1148
|
-
*
|
|
1149
|
-
* @param {string} key - Key name (e.g. 'Enter', 'Tab', 'Escape')
|
|
1150
|
-
* @param {object} [options]
|
|
1151
|
-
* @param {boolean} [options.ctrl] - Hold Ctrl/Meta
|
|
1152
|
-
* @param {boolean} [options.shift] - Hold Shift
|
|
1153
|
-
* @param {boolean} [options.alt] - Hold Alt
|
|
1154
|
-
* @returns {Promise<{ pressed: boolean }>}
|
|
1155
|
-
*/
|
|
1156
|
-
export async function browserKeyPress(key, options = {}) {
|
|
1157
|
-
const browser = await getBrowser();
|
|
1158
|
-
|
|
1159
|
-
let modifiers = 0;
|
|
1160
|
-
if (options.alt) modifiers |= 1;
|
|
1161
|
-
if (options.ctrl) modifiers |= 2;
|
|
1162
|
-
if (os.platform() === 'darwin' && options.ctrl) modifiers |= 4; // Meta on Mac
|
|
1163
|
-
if (options.shift) modifiers |= 8;
|
|
1164
|
-
|
|
1165
|
-
// Map common key names to CDP key codes
|
|
1166
|
-
const keyMap = {
|
|
1167
|
-
'Enter': { key: 'Enter', code: 'Enter' },
|
|
1168
|
-
'Tab': { key: 'Tab', code: 'Tab' },
|
|
1169
|
-
'Escape': { key: 'Escape', code: 'Escape' },
|
|
1170
|
-
'Backspace': { key: 'Backspace', code: 'Backspace' },
|
|
1171
|
-
'Delete': { key: 'Delete', code: 'Delete' },
|
|
1172
|
-
'ArrowUp': { key: 'ArrowUp', code: 'ArrowUp' },
|
|
1173
|
-
'ArrowDown': { key: 'ArrowDown', code: 'ArrowDown' },
|
|
1174
|
-
'ArrowLeft': { key: 'ArrowLeft', code: 'ArrowLeft' },
|
|
1175
|
-
'ArrowRight': { key: 'ArrowRight', code: 'ArrowRight' },
|
|
1176
|
-
'Space': { key: ' ', code: 'Space' },
|
|
1177
|
-
};
|
|
1178
|
-
|
|
1179
|
-
const mapped = keyMap[key] || { key, code: `Key${key.toUpperCase()}` };
|
|
1180
|
-
|
|
1181
|
-
await browser.ws.send('Input.dispatchKeyEvent', {
|
|
1182
|
-
type: 'keyDown',
|
|
1183
|
-
key: mapped.key,
|
|
1184
|
-
code: mapped.code,
|
|
1185
|
-
modifiers,
|
|
1186
|
-
});
|
|
1187
|
-
await browser.ws.send('Input.dispatchKeyEvent', {
|
|
1188
|
-
type: 'keyUp',
|
|
1189
|
-
key: mapped.key,
|
|
1190
|
-
code: mapped.code,
|
|
1191
|
-
modifiers,
|
|
1192
|
-
});
|
|
1193
|
-
|
|
1194
|
-
return { error: false, pressed: true, key };
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
/**
|
|
1198
|
-
* Scroll the page.
|
|
1199
|
-
*
|
|
1200
|
-
* @param {object} [options]
|
|
1201
|
-
* @param {'up'|'down'|'top'|'bottom'} [options.direction] - Scroll direction (default 'down')
|
|
1202
|
-
* @param {number} [options.amount] - Pixels to scroll (default 500)
|
|
1203
|
-
* @returns {Promise<{ scrolled: boolean }>}
|
|
1204
|
-
*/
|
|
1205
|
-
export async function browserScroll(options = {}) {
|
|
1206
|
-
const browser = await getBrowser();
|
|
1207
|
-
const direction = options.direction || 'down';
|
|
1208
|
-
const amount = options.amount || 500;
|
|
1209
|
-
|
|
1210
|
-
let expression;
|
|
1211
|
-
switch (direction) {
|
|
1212
|
-
case 'top':
|
|
1213
|
-
expression = 'window.scrollTo(0, 0)';
|
|
1214
|
-
break;
|
|
1215
|
-
case 'bottom':
|
|
1216
|
-
expression = 'window.scrollTo(0, document.body.scrollHeight)';
|
|
1217
|
-
break;
|
|
1218
|
-
case 'up':
|
|
1219
|
-
expression = `window.scrollBy(0, -${amount})`;
|
|
1220
|
-
break;
|
|
1221
|
-
case 'down':
|
|
1222
|
-
default:
|
|
1223
|
-
expression = `window.scrollBy(0, ${amount})`;
|
|
1224
|
-
break;
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
await browser.ws.send('Runtime.evaluate', {
|
|
1228
|
-
expression: `${expression}; ({ scrollY: window.scrollY, scrollHeight: document.body.scrollHeight })`,
|
|
1229
|
-
returnByValue: true,
|
|
1230
|
-
});
|
|
1231
|
-
|
|
1232
|
-
return { error: false, scrolled: true, direction };
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
/**
|
|
1236
|
-
* Check if browser is currently running.
|
|
1237
|
-
*/
|
|
1238
|
-
export function isBrowserRunning() {
|
|
1239
|
-
return _browser !== null && _browser.ws.connected;
|
|
1240
|
-
}
|