nothumanallowed 9.7.2 → 9.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1240 @@
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
+ }