@monostate/node-scraper 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,322 @@
1
+ /**
2
+ * LocalProvider — runs Xvfb + Chrome + xdotool on the local machine.
3
+ *
4
+ * Requires Linux with: xvfb, xdotool, chromium/google-chrome
5
+ * Optional: x11vnc, noVNC (for VNC streaming)
6
+ *
7
+ * @example
8
+ * import { createSession, LocalProvider } from '@monostate/node-scraper';
9
+ *
10
+ * const session = await createSession({
11
+ * mode: 'computer-use',
12
+ * provider: new LocalProvider({ screenWidth: 1280, screenHeight: 800 }),
13
+ * });
14
+ * await session.goto('https://example.com');
15
+ * await session.clickAt(640, 400);
16
+ * await session.close();
17
+ */
18
+
19
+ import { ComputerUseProvider } from '../computer-use-provider.js';
20
+ import { spawn, execFile } from 'child_process';
21
+ import { createServer } from 'net';
22
+
23
+ function findFreePort() {
24
+ return new Promise((resolve, reject) => {
25
+ const srv = createServer();
26
+ srv.listen(0, '127.0.0.1', () => {
27
+ const port = srv.address().port;
28
+ srv.close(() => resolve(port));
29
+ });
30
+ srv.on('error', reject);
31
+ });
32
+ }
33
+
34
+ function waitForProcess(proc, readyCheck, timeoutMs = 10000) {
35
+ return new Promise((resolve, reject) => {
36
+ const timer = setTimeout(() => reject(new Error('Process startup timed out')), timeoutMs);
37
+ const check = setInterval(async () => {
38
+ try {
39
+ if (await readyCheck()) {
40
+ clearTimeout(timer);
41
+ clearInterval(check);
42
+ resolve();
43
+ }
44
+ } catch { /* still starting */ }
45
+ }, 200);
46
+ proc.on('error', (err) => {
47
+ clearTimeout(timer);
48
+ clearInterval(check);
49
+ reject(err);
50
+ });
51
+ proc.on('exit', (code) => {
52
+ if (code !== 0 && code !== null) {
53
+ clearTimeout(timer);
54
+ clearInterval(check);
55
+ reject(new Error(`Process exited with code ${code}`));
56
+ }
57
+ });
58
+ });
59
+ }
60
+
61
+ function execAsync(cmd, args, env) {
62
+ return new Promise((resolve, reject) => {
63
+ execFile(cmd, args, { env, timeout: 10000 }, (err, stdout, stderr) => {
64
+ if (err) reject(err);
65
+ else resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
66
+ });
67
+ });
68
+ }
69
+
70
+ export class LocalProvider extends ComputerUseProvider {
71
+ /**
72
+ * @param {object} [options]
73
+ * @param {number} [options.screenWidth=1280]
74
+ * @param {number} [options.screenHeight=800]
75
+ * @param {boolean} [options.enableVnc=false]
76
+ * @param {string} [options.chromePath] - Path to Chrome/Chromium binary (auto-detected)
77
+ * @param {string[]} [options.chromeArgs] - Additional Chrome launch args
78
+ */
79
+ constructor(options = {}) {
80
+ super();
81
+ this.screenWidth = options.screenWidth || 1280;
82
+ this.screenHeight = options.screenHeight || 800;
83
+ this.enableVnc = options.enableVnc ?? false;
84
+ this.chromePath = options.chromePath || null;
85
+ this.chromeArgs = options.chromeArgs || [];
86
+
87
+ this._display = null;
88
+ this._displayNum = null;
89
+ this._cdpPort = null;
90
+ this._vncPort = null;
91
+ this._xvfbProc = null;
92
+ this._chromeProc = null;
93
+ this._vncProc = null;
94
+ this._env = null;
95
+ this._cdpWsUrl = null;
96
+ }
97
+
98
+ async start() {
99
+ if (process.platform !== 'linux') {
100
+ throw new Error(
101
+ 'LocalProvider requires Linux (Xvfb + xdotool). ' +
102
+ 'On macOS/Windows, use Docker or a remote provider.'
103
+ );
104
+ }
105
+
106
+ // Find free ports
107
+ this._cdpPort = await findFreePort();
108
+ this._displayNum = 10 + Math.floor(Math.random() * 90); // :10-:99
109
+ this._display = `:${this._displayNum}`;
110
+
111
+ this._env = {
112
+ ...process.env,
113
+ DISPLAY: this._display,
114
+ DBUS_SESSION_BUS_ADDRESS: '/dev/null',
115
+ };
116
+
117
+ // 1. Start Xvfb
118
+ this._xvfbProc = spawn('Xvfb', [
119
+ this._display,
120
+ '-screen', '0', `${this.screenWidth}x${this.screenHeight}x24`,
121
+ '-ac',
122
+ ], { stdio: 'pipe', env: this._env });
123
+
124
+ await new Promise(resolve => setTimeout(resolve, 500));
125
+
126
+ // 2. Find Chrome binary
127
+ const chromePath = this.chromePath || await this._findChrome();
128
+
129
+ // 3. Start Chrome
130
+ const chromeArgs = [
131
+ '--no-sandbox',
132
+ '--disable-setuid-sandbox',
133
+ '--disable-dev-shm-usage',
134
+ '--disable-gpu',
135
+ `--remote-debugging-port=${this._cdpPort}`,
136
+ '--remote-debugging-address=127.0.0.1',
137
+ `--window-size=${this.screenWidth},${this.screenHeight}`,
138
+ '--disable-features=dbus',
139
+ '--disable-sync',
140
+ '--disable-extensions',
141
+ '--disable-component-update',
142
+ '--no-first-run',
143
+ '--user-data-dir=/tmp/chrome-local-provider-' + this._displayNum,
144
+ ...this.chromeArgs,
145
+ 'about:blank',
146
+ ];
147
+
148
+ this._chromeProc = spawn(chromePath, chromeArgs, {
149
+ stdio: 'pipe',
150
+ env: this._env,
151
+ });
152
+
153
+ // Wait for CDP to be ready
154
+ await waitForProcess(this._chromeProc, async () => {
155
+ const res = await fetch(`http://127.0.0.1:${this._cdpPort}/json/version`);
156
+ return res.ok;
157
+ }, 15000);
158
+
159
+ // Get the CDP WebSocket URL
160
+ const versionRes = await fetch(`http://127.0.0.1:${this._cdpPort}/json/version`);
161
+ const versionData = await versionRes.json();
162
+ this._cdpWsUrl = versionData.webSocketDebuggerUrl;
163
+
164
+ // 4. Optionally start VNC
165
+ let vncUrl = null;
166
+ if (this.enableVnc) {
167
+ try {
168
+ this._vncPort = await findFreePort();
169
+ this._vncProc = spawn('x11vnc', [
170
+ '-display', this._display,
171
+ '-rfbport', String(this._vncPort),
172
+ '-shared', '-nopw', '-forever',
173
+ ], { stdio: 'pipe', env: this._env });
174
+ await new Promise(resolve => setTimeout(resolve, 500));
175
+ vncUrl = `vnc://127.0.0.1:${this._vncPort}`;
176
+ } catch {
177
+ // VNC is optional — don't fail if x11vnc is not installed
178
+ }
179
+ }
180
+
181
+ return {
182
+ cdpUrl: this._cdpWsUrl,
183
+ vncUrl,
184
+ screenSize: { width: this.screenWidth, height: this.screenHeight },
185
+ };
186
+ }
187
+
188
+ async stop() {
189
+ for (const proc of [this._vncProc, this._chromeProc, this._xvfbProc]) {
190
+ if (proc && !proc.killed) {
191
+ try { proc.kill('SIGTERM'); } catch { /* ignore */ }
192
+ }
193
+ }
194
+ this._vncProc = null;
195
+ this._chromeProc = null;
196
+ this._xvfbProc = null;
197
+ }
198
+
199
+ async screenshot() {
200
+ // Use CDP Page.captureScreenshot
201
+ const pagesRes = await fetch(`http://127.0.0.1:${this._cdpPort}/json`);
202
+ const pages = await pagesRes.json();
203
+ const page = pages[0];
204
+ if (!page) throw new Error('No open pages for screenshot');
205
+
206
+ const wsUrl = page.webSocketDebuggerUrl;
207
+ const { default: WebSocket } = await import('ws');
208
+
209
+ return new Promise((resolve, reject) => {
210
+ const ws = new WebSocket(wsUrl);
211
+ ws.on('open', () => {
212
+ ws.send(JSON.stringify({ id: 1, method: 'Page.captureScreenshot', params: { format: 'png' } }));
213
+ });
214
+ ws.on('message', (data) => {
215
+ const msg = JSON.parse(data.toString());
216
+ if (msg.id === 1) {
217
+ ws.close();
218
+ if (msg.error) {
219
+ reject(new Error(msg.error.message));
220
+ } else {
221
+ resolve({ screenshot: `data:image/png;base64,${msg.result.data}` });
222
+ }
223
+ }
224
+ });
225
+ ws.on('error', reject);
226
+ setTimeout(() => { ws.close(); reject(new Error('Screenshot timed out')); }, 10000);
227
+ });
228
+ }
229
+
230
+ // ── Coordinate-based actions via xdotool ──────────────────
231
+
232
+ async mouseMove(x, y) {
233
+ await this._xdotool(['mousemove', '--', String(Math.round(x)), String(Math.round(y))]);
234
+ return { success: true };
235
+ }
236
+
237
+ async mouseClick(x, y, button = 'left') {
238
+ const btn = { left: '1', right: '3', middle: '2' }[button] || '1';
239
+ await this._xdotool(['mousemove', '--', String(Math.round(x)), String(Math.round(y))]);
240
+ await this._xdotool(['click', btn]);
241
+ return { success: true };
242
+ }
243
+
244
+ async mouseDoubleClick(x, y, button = 'left') {
245
+ const btn = { left: '1', right: '3', middle: '2' }[button] || '1';
246
+ await this._xdotool(['mousemove', '--', String(Math.round(x)), String(Math.round(y))]);
247
+ await this._xdotool(['click', '--repeat', '2', '--delay', '50', btn]);
248
+ return { success: true };
249
+ }
250
+
251
+ async mouseDrag(startX, startY, endX, endY) {
252
+ await this._xdotool([
253
+ 'mousemove', '--', String(Math.round(startX)), String(Math.round(startY)),
254
+ ]);
255
+ await this._xdotool(['mousedown', '1']);
256
+ await this._xdotool([
257
+ 'mousemove', '--', String(Math.round(endX)), String(Math.round(endY)),
258
+ ]);
259
+ await this._xdotool(['mouseup', '1']);
260
+ return { success: true };
261
+ }
262
+
263
+ async scroll(x, y, direction, amount = 3) {
264
+ await this._xdotool(['mousemove', '--', String(Math.round(x)), String(Math.round(y))]);
265
+ const btn = direction === 'up' ? '4' : '5';
266
+ await this._xdotool(['click', '--repeat', String(amount), '--delay', '50', btn]);
267
+ return { success: true };
268
+ }
269
+
270
+ async pressKey(key) {
271
+ await this._xdotool(['key', key]);
272
+ return { success: true };
273
+ }
274
+
275
+ async typeText(text) {
276
+ // Type in chunks to avoid buffer issues
277
+ const CHUNK = 50;
278
+ for (let i = 0; i < text.length; i += CHUNK) {
279
+ const chunk = text.substring(i, i + CHUNK);
280
+ await this._xdotool(['type', '--delay', '12', '--clearmodifiers', chunk]);
281
+ }
282
+ return { success: true };
283
+ }
284
+
285
+ async getCursorPosition() {
286
+ const { stdout } = await this._xdotool(['getmouselocation', '--shell']);
287
+ const x = parseInt(stdout.match(/X=(\d+)/)?.[1] || '0', 10);
288
+ const y = parseInt(stdout.match(/Y=(\d+)/)?.[1] || '0', 10);
289
+ return { x, y };
290
+ }
291
+
292
+ async getScreenSize() {
293
+ return { width: this.screenWidth, height: this.screenHeight };
294
+ }
295
+
296
+ // ── Internal helpers ──────────────────────────────────────
297
+
298
+ async _xdotool(args) {
299
+ return execAsync('xdotool', args, this._env);
300
+ }
301
+
302
+ async _findChrome() {
303
+ const candidates = [
304
+ 'chromium-browser',
305
+ 'chromium',
306
+ 'google-chrome',
307
+ 'google-chrome-stable',
308
+ ];
309
+ for (const name of candidates) {
310
+ try {
311
+ const { stdout } = await execAsync('which', [name], this._env);
312
+ if (stdout) return stdout;
313
+ } catch { /* not found */ }
314
+ }
315
+ throw new Error(
316
+ 'Chrome/Chromium not found. Install with: apt-get install chromium ' +
317
+ 'or pass chromePath option.'
318
+ );
319
+ }
320
+ }
321
+
322
+ export default LocalProvider;