@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.
- package/README.md +74 -0
- package/browser-session.js +685 -0
- package/computer-use-provider.js +168 -0
- package/index.d.ts +159 -0
- package/index.js +6 -0
- package/lightpanda-server.js +151 -0
- package/package.json +8 -1
- package/providers/local-provider.js +322 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import { getLightPandaServer, stopLightPandaServer } from './lightpanda-server.js';
|
|
2
|
+
import browserPool from './browser-pool.js';
|
|
3
|
+
|
|
4
|
+
const FALLBACK_REASONS = {
|
|
5
|
+
SCREENSHOT: 'screenshot_requested',
|
|
6
|
+
CDP_ERROR: 'cdp_protocol_error',
|
|
7
|
+
BOT_DETECTION: 'bot_detection',
|
|
8
|
+
NAVIGATION_FAILED: 'navigation_after_click_failed',
|
|
9
|
+
METHOD_NOT_SUPPORTED: 'method_not_supported',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export class BrowserSession {
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} options
|
|
15
|
+
* @param {'headless'|'visual'|'auto'|'computer-use'} options.mode - 'headless' (LightPanda), 'visual' (Chrome visible), 'auto' (LP with Chrome fallback), 'computer-use' (provider with Xvfb+VNC)
|
|
16
|
+
* @param {number} options.timeout - Navigation timeout in ms (default: 15000)
|
|
17
|
+
* @param {string} options.userAgent - Custom user agent
|
|
18
|
+
* @param {string} options.lightpandaPath - Path to LightPanda binary
|
|
19
|
+
* @param {boolean} options.verbose - Enable logging
|
|
20
|
+
* @param {import('./computer-use-provider.js').ComputerUseProvider} options.provider - Required for computer-use mode
|
|
21
|
+
*/
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
this.mode = options.mode || 'auto';
|
|
24
|
+
this.activeBackend = null; // 'lightpanda' | 'chrome' | 'computer-use'
|
|
25
|
+
this.timeout = options.timeout || 15000;
|
|
26
|
+
this.userAgent = options.userAgent || 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
|
|
27
|
+
this.lightpandaPath = options.lightpandaPath;
|
|
28
|
+
this.verbose = options.verbose || false;
|
|
29
|
+
|
|
30
|
+
this.browser = null;
|
|
31
|
+
this.context = null;
|
|
32
|
+
this.page = null;
|
|
33
|
+
this._chromeBrowser = null; // reference for pool release
|
|
34
|
+
this._connected = false;
|
|
35
|
+
this._fallbackCount = 0;
|
|
36
|
+
|
|
37
|
+
// Computer-use provider (Xvfb + Chrome + xdotool + optional VNC)
|
|
38
|
+
this.provider = options.provider || null;
|
|
39
|
+
this._providerInfo = null;
|
|
40
|
+
|
|
41
|
+
this.history = []; // action log for debugging
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Connection ──────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
async connect() {
|
|
47
|
+
if (this._connected) return this;
|
|
48
|
+
|
|
49
|
+
if (this.mode === 'computer-use') {
|
|
50
|
+
await this._connectViaProvider();
|
|
51
|
+
} else if (this.mode === 'visual') {
|
|
52
|
+
await this._connectChromeVisual();
|
|
53
|
+
} else {
|
|
54
|
+
// 'headless' or 'auto' — start with LightPanda
|
|
55
|
+
try {
|
|
56
|
+
await this._connectLightPanda();
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (this.mode === 'auto') {
|
|
59
|
+
this._log(`LightPanda unavailable (${err.message}), falling back to Chrome`);
|
|
60
|
+
await this._connectChrome();
|
|
61
|
+
} else {
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this._connected = true;
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async _connectLightPanda() {
|
|
72
|
+
const server = getLightPandaServer(this.lightpandaPath);
|
|
73
|
+
const endpoint = await server.start();
|
|
74
|
+
|
|
75
|
+
const puppeteer = await this._getPuppeteer();
|
|
76
|
+
this.browser = await puppeteer.connect({ browserWSEndpoint: endpoint });
|
|
77
|
+
this.context = await this.browser.createBrowserContext();
|
|
78
|
+
this.page = await this.context.newPage();
|
|
79
|
+
|
|
80
|
+
await this.page.setUserAgent(this.userAgent);
|
|
81
|
+
this.activeBackend = 'lightpanda';
|
|
82
|
+
this._log('Connected to LightPanda CDP');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async _connectChrome() {
|
|
86
|
+
this._chromeBrowser = await browserPool.getBrowser();
|
|
87
|
+
this.browser = this._chromeBrowser;
|
|
88
|
+
this.page = await this.browser.newPage();
|
|
89
|
+
|
|
90
|
+
await this.page.setUserAgent(this.userAgent);
|
|
91
|
+
await this.page.setViewport({ width: 1280, height: 800 });
|
|
92
|
+
this.activeBackend = 'chrome';
|
|
93
|
+
this._log('Connected to Chrome');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async _connectChromeVisual() {
|
|
97
|
+
const puppeteer = await this._getPuppeteer();
|
|
98
|
+
this.browser = await puppeteer.launch({
|
|
99
|
+
headless: false,
|
|
100
|
+
args: [
|
|
101
|
+
'--no-sandbox',
|
|
102
|
+
'--disable-setuid-sandbox',
|
|
103
|
+
'--disable-dev-shm-usage',
|
|
104
|
+
'--window-size=1280,800',
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
this.page = await this.browser.newPage();
|
|
108
|
+
await this.page.setUserAgent(this.userAgent);
|
|
109
|
+
await this.page.setViewport({ width: 1280, height: 800 });
|
|
110
|
+
this.activeBackend = 'chrome';
|
|
111
|
+
this._log('Connected to Chrome (visual, headless:false)');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async _connectViaProvider() {
|
|
115
|
+
if (!this.provider) {
|
|
116
|
+
throw new Error('computer-use mode requires a provider. Pass { provider: new LocalProvider() }');
|
|
117
|
+
}
|
|
118
|
+
this._providerInfo = await this.provider.start();
|
|
119
|
+
|
|
120
|
+
const puppeteer = await this._getPuppeteer();
|
|
121
|
+
this.browser = await puppeteer.connect({
|
|
122
|
+
browserWSEndpoint: this._providerInfo.cdpUrl,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const pages = await this.browser.pages();
|
|
126
|
+
this.page = pages[0] || await this.browser.newPage();
|
|
127
|
+
await this.page.setViewport({
|
|
128
|
+
width: this._providerInfo.screenSize.width,
|
|
129
|
+
height: this._providerInfo.screenSize.height,
|
|
130
|
+
});
|
|
131
|
+
this.activeBackend = 'computer-use';
|
|
132
|
+
this._log('Connected via ComputerUseProvider');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Navigation ──────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
async goto(url) {
|
|
138
|
+
this._ensureConnected();
|
|
139
|
+
try {
|
|
140
|
+
await this.page.goto(url, {
|
|
141
|
+
waitUntil: 'networkidle0',
|
|
142
|
+
timeout: this.timeout,
|
|
143
|
+
});
|
|
144
|
+
this._logAction('goto', { url });
|
|
145
|
+
return { success: true, url: this.page.url() };
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (await this._shouldFallback(err)) {
|
|
148
|
+
await this._fallbackToChrome(FALLBACK_REASONS.CDP_ERROR);
|
|
149
|
+
return this.goto(url);
|
|
150
|
+
}
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async goBack() {
|
|
156
|
+
this._ensureConnected();
|
|
157
|
+
await this.page.goBack({ waitUntil: 'networkidle0', timeout: this.timeout });
|
|
158
|
+
this._logAction('goBack');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async goForward() {
|
|
162
|
+
this._ensureConnected();
|
|
163
|
+
await this.page.goForward({ waitUntil: 'networkidle0', timeout: this.timeout });
|
|
164
|
+
this._logAction('goForward');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Page Interactions ───────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
async click(selector, options = {}) {
|
|
170
|
+
this._ensureConnected();
|
|
171
|
+
try {
|
|
172
|
+
await this.page.waitForSelector(selector, { timeout: options.timeout || this.timeout });
|
|
173
|
+
const urlBefore = this.page.url();
|
|
174
|
+
await this.page.click(selector);
|
|
175
|
+
|
|
176
|
+
// If navigation expected, wait briefly
|
|
177
|
+
if (options.waitForNavigation !== false) {
|
|
178
|
+
try {
|
|
179
|
+
await this.page.waitForNavigation({ timeout: 3000, waitUntil: 'networkidle0' }).catch(() => {});
|
|
180
|
+
} catch {
|
|
181
|
+
// No navigation happened, that's fine
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this._logAction('click', { selector });
|
|
186
|
+
|
|
187
|
+
// Check for LP's known click-navigation bug
|
|
188
|
+
if (this.activeBackend === 'lightpanda') {
|
|
189
|
+
const urlAfter = this.page.url();
|
|
190
|
+
if (options.expectNavigation && urlBefore === urlAfter) {
|
|
191
|
+
this._log('Click did not trigger expected navigation — falling back to Chrome');
|
|
192
|
+
await this._fallbackToChrome(FALLBACK_REASONS.NAVIGATION_FAILED);
|
|
193
|
+
return this.click(selector, { ...options, _retried: true });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { success: true, url: this.page.url() };
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (await this._shouldFallback(err)) {
|
|
200
|
+
await this._fallbackToChrome(FALLBACK_REASONS.CDP_ERROR);
|
|
201
|
+
return this.click(selector, options);
|
|
202
|
+
}
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async type(selector, text, options = {}) {
|
|
208
|
+
this._ensureConnected();
|
|
209
|
+
try {
|
|
210
|
+
await this.page.waitForSelector(selector, { timeout: options.timeout || this.timeout });
|
|
211
|
+
|
|
212
|
+
if (options.clear) {
|
|
213
|
+
await this.page.click(selector, { clickCount: 3 });
|
|
214
|
+
await this.page.keyboard.press('Backspace');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await this.page.type(selector, text, { delay: options.delay || 0 });
|
|
218
|
+
this._logAction('type', { selector, text: text.substring(0, 20) + (text.length > 20 ? '...' : '') });
|
|
219
|
+
return { success: true };
|
|
220
|
+
} catch (err) {
|
|
221
|
+
if (await this._shouldFallback(err)) {
|
|
222
|
+
await this._fallbackToChrome(FALLBACK_REASONS.CDP_ERROR);
|
|
223
|
+
return this.type(selector, text, options);
|
|
224
|
+
}
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async scroll(direction = 'down', amount = 500) {
|
|
230
|
+
this._ensureConnected();
|
|
231
|
+
const deltaY = direction === 'up' ? -amount : amount;
|
|
232
|
+
await this.page.evaluate((dy) => window.scrollBy(0, dy), deltaY);
|
|
233
|
+
this._logAction('scroll', { direction, amount });
|
|
234
|
+
return { success: true };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async hover(selector) {
|
|
238
|
+
this._ensureConnected();
|
|
239
|
+
if (this.activeBackend === 'lightpanda') {
|
|
240
|
+
// LP doesn't support mouseMoved — fall back
|
|
241
|
+
if (this.mode === 'auto') {
|
|
242
|
+
await this._fallbackToChrome(FALLBACK_REASONS.METHOD_NOT_SUPPORTED);
|
|
243
|
+
return this.hover(selector);
|
|
244
|
+
}
|
|
245
|
+
throw new Error('hover() not supported in LightPanda mode');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await this.page.waitForSelector(selector, { timeout: this.timeout });
|
|
249
|
+
await this.page.hover(selector);
|
|
250
|
+
this._logAction('hover', { selector });
|
|
251
|
+
return { success: true };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async select(selector, ...values) {
|
|
255
|
+
this._ensureConnected();
|
|
256
|
+
await this.page.waitForSelector(selector, { timeout: this.timeout });
|
|
257
|
+
await this.page.select(selector, ...values);
|
|
258
|
+
this._logAction('select', { selector, values });
|
|
259
|
+
return { success: true };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async pressKey(key) {
|
|
263
|
+
this._ensureConnected();
|
|
264
|
+
await this.page.keyboard.press(key);
|
|
265
|
+
this._logAction('pressKey', { key });
|
|
266
|
+
return { success: true };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── Content Extraction ──────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
async extractContent() {
|
|
272
|
+
this._ensureConnected();
|
|
273
|
+
return this.page.evaluate(() => {
|
|
274
|
+
const title = document.title;
|
|
275
|
+
const metaDescription = document.querySelector('meta[name="description"]')?.content || '';
|
|
276
|
+
const headings = Array.from(document.querySelectorAll('h1, h2, h3'))
|
|
277
|
+
.map(h => ({ level: h.tagName.toLowerCase(), text: h.textContent.trim() }))
|
|
278
|
+
.filter(h => h.text.length > 0)
|
|
279
|
+
.slice(0, 20);
|
|
280
|
+
const paragraphs = Array.from(document.querySelectorAll('p'))
|
|
281
|
+
.map(p => p.textContent.trim())
|
|
282
|
+
.filter(t => t.length > 20)
|
|
283
|
+
.slice(0, 15);
|
|
284
|
+
const links = Array.from(document.querySelectorAll('a[href]'))
|
|
285
|
+
.map(a => ({ text: a.textContent.trim(), href: a.href }))
|
|
286
|
+
.filter(l => l.text.length > 0 && l.href.startsWith('http'))
|
|
287
|
+
.slice(0, 30);
|
|
288
|
+
const bodyText = document.body?.innerText?.substring(0, 5000) || '';
|
|
289
|
+
|
|
290
|
+
return { title, metaDescription, headings, paragraphs, links, bodyText, url: location.href };
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async evaluate(fn, ...args) {
|
|
295
|
+
this._ensureConnected();
|
|
296
|
+
return this.page.evaluate(fn, ...args);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async waitFor(selector, timeout) {
|
|
300
|
+
this._ensureConnected();
|
|
301
|
+
await this.page.waitForSelector(selector, { timeout: timeout || this.timeout });
|
|
302
|
+
return { success: true };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── Screenshot ──────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
async screenshot(options = {}) {
|
|
308
|
+
this._ensureConnected();
|
|
309
|
+
|
|
310
|
+
// LightPanda can't screenshot — auto-fallback
|
|
311
|
+
if (this.activeBackend === 'lightpanda') {
|
|
312
|
+
if (this.mode === 'headless') {
|
|
313
|
+
throw new Error('screenshot() not available in headless-only mode (LightPanda has no rendering engine)');
|
|
314
|
+
}
|
|
315
|
+
await this._fallbackToChrome(FALLBACK_REASONS.SCREENSHOT);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// type_ avoids collision with executeAction's { type: 'screenshot' }
|
|
319
|
+
const imageFormat = options.type_ || (options.type !== 'screenshot' && options.type) || 'png';
|
|
320
|
+
const buffer = await this.page.screenshot({
|
|
321
|
+
type: imageFormat,
|
|
322
|
+
fullPage: options.fullPage ?? true,
|
|
323
|
+
encoding: 'base64',
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
this._logAction('screenshot');
|
|
327
|
+
return {
|
|
328
|
+
success: true,
|
|
329
|
+
screenshot: `data:image/${imageFormat};base64,${buffer}`,
|
|
330
|
+
backend: this.activeBackend,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── AI Agent Interface ──────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Returns structured page state for AI decision-making.
|
|
338
|
+
* Includes interactive elements with selectors for the AI to use.
|
|
339
|
+
*/
|
|
340
|
+
async getPageState(options = {}) {
|
|
341
|
+
this._ensureConnected();
|
|
342
|
+
|
|
343
|
+
const state = await this.page.evaluate(() => {
|
|
344
|
+
const interactiveElements = [];
|
|
345
|
+
|
|
346
|
+
// Buttons
|
|
347
|
+
document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]').forEach((el, i) => {
|
|
348
|
+
if (el.offsetParent === null) return; // hidden
|
|
349
|
+
const text = el.textContent?.trim() || el.value || el.getAttribute('aria-label') || '';
|
|
350
|
+
if (!text) return;
|
|
351
|
+
interactiveElements.push({
|
|
352
|
+
type: 'button',
|
|
353
|
+
text: text.substring(0, 100),
|
|
354
|
+
selector: el.id ? `#${el.id}` : `button:nth-of-type(${i + 1})`,
|
|
355
|
+
tag: el.tagName.toLowerCase(),
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// Links
|
|
360
|
+
document.querySelectorAll('a[href]').forEach((el) => {
|
|
361
|
+
if (el.offsetParent === null) return;
|
|
362
|
+
const text = el.textContent?.trim();
|
|
363
|
+
if (!text || text.length < 2) return;
|
|
364
|
+
interactiveElements.push({
|
|
365
|
+
type: 'link',
|
|
366
|
+
text: text.substring(0, 100),
|
|
367
|
+
href: el.href,
|
|
368
|
+
selector: el.id ? `#${el.id}` : `a[href="${el.getAttribute('href')}"]`,
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Inputs
|
|
373
|
+
document.querySelectorAll('input, textarea, select').forEach((el) => {
|
|
374
|
+
if (el.offsetParent === null || el.type === 'hidden') return;
|
|
375
|
+
const label = el.getAttribute('aria-label')
|
|
376
|
+
|| el.placeholder
|
|
377
|
+
|| document.querySelector(`label[for="${el.id}"]`)?.textContent?.trim()
|
|
378
|
+
|| el.name
|
|
379
|
+
|| '';
|
|
380
|
+
interactiveElements.push({
|
|
381
|
+
type: el.tagName.toLowerCase() === 'select' ? 'select' : 'input',
|
|
382
|
+
inputType: el.type || 'text',
|
|
383
|
+
label: label.substring(0, 100),
|
|
384
|
+
value: el.value?.substring(0, 50) || '',
|
|
385
|
+
selector: el.id ? `#${el.id}` : `[name="${el.name}"]`,
|
|
386
|
+
tag: el.tagName.toLowerCase(),
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
url: location.href,
|
|
392
|
+
title: document.title,
|
|
393
|
+
text: document.body?.innerText?.substring(0, 3000) || '',
|
|
394
|
+
interactiveElements: interactiveElements.slice(0, 50),
|
|
395
|
+
};
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// Optionally include screenshot
|
|
399
|
+
if (options.includeScreenshot && this.activeBackend !== 'lightpanda') {
|
|
400
|
+
const { screenshot } = await this.screenshot();
|
|
401
|
+
state.screenshot = screenshot;
|
|
402
|
+
} else if (options.includeScreenshot && this.mode === 'auto') {
|
|
403
|
+
await this._fallbackToChrome(FALLBACK_REASONS.SCREENSHOT);
|
|
404
|
+
const { screenshot } = await this.screenshot();
|
|
405
|
+
state.screenshot = screenshot;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
state.backend = this.activeBackend;
|
|
409
|
+
state.sessionHistory = this.history.slice(-10);
|
|
410
|
+
|
|
411
|
+
return state;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Execute a structured action from an AI agent.
|
|
416
|
+
* @param {{ type: string, selector?: string, text?: string, url?: string, key?: string, direction?: string, amount?: number }} action
|
|
417
|
+
*/
|
|
418
|
+
async executeAction(action) {
|
|
419
|
+
switch (action.type) {
|
|
420
|
+
case 'goto': return this.goto(action.url);
|
|
421
|
+
case 'click': return this.click(action.selector, action);
|
|
422
|
+
case 'type': return this.type(action.selector, action.text, action);
|
|
423
|
+
case 'scroll': return this.scroll(action.direction, action.amount);
|
|
424
|
+
case 'hover': return this.hover(action.selector);
|
|
425
|
+
case 'select': return this.select(action.selector, ...(action.values || []));
|
|
426
|
+
case 'pressKey': return this.pressKey(action.key);
|
|
427
|
+
case 'goBack': return this.goBack();
|
|
428
|
+
case 'goForward': return this.goForward();
|
|
429
|
+
case 'screenshot': return this.screenshot(action);
|
|
430
|
+
case 'extractContent': return this.extractContent();
|
|
431
|
+
case 'waitFor': return this.waitFor(action.selector, action.timeout);
|
|
432
|
+
// Coordinate-based actions (computer-use mode)
|
|
433
|
+
case 'mouseMove': return this.mouseMove(action.x, action.y);
|
|
434
|
+
case 'clickAt': return this.clickAt(action.x, action.y, action.button);
|
|
435
|
+
case 'doubleClickAt': return this.doubleClickAt(action.x, action.y, action.button);
|
|
436
|
+
case 'drag': return this.drag(action.startX, action.startY, action.endX, action.endY);
|
|
437
|
+
case 'scrollAt': return this.scrollAt(action.x, action.y, action.direction, action.amount);
|
|
438
|
+
case 'typeText': return this.typeText(action.text);
|
|
439
|
+
case 'getCursorPosition': return this.getCursorPosition();
|
|
440
|
+
case 'getScreenSize': return this.getScreenSize();
|
|
441
|
+
default: throw new Error(`Unknown action type: ${action.type}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── Cookies ─────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
async getCookies() {
|
|
448
|
+
this._ensureConnected();
|
|
449
|
+
return this.page.cookies();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async setCookies(cookies) {
|
|
453
|
+
this._ensureConnected();
|
|
454
|
+
if (this.activeBackend === 'lightpanda') {
|
|
455
|
+
// LP doesn't support Network.deleteCookies which Puppeteer's setCookie calls.
|
|
456
|
+
// Use the page's internal CDP session to call Network.setCookies directly.
|
|
457
|
+
try {
|
|
458
|
+
const client = this.page._client();
|
|
459
|
+
await client.send('Network.setCookies', { cookies });
|
|
460
|
+
} catch {
|
|
461
|
+
// Fallback: set cookies via document.cookie (limited to non-httpOnly)
|
|
462
|
+
for (const c of cookies) {
|
|
463
|
+
const parts = [`${c.name}=${c.value}`];
|
|
464
|
+
if (c.domain) parts.push(`domain=${c.domain}`);
|
|
465
|
+
if (c.path) parts.push(`path=${c.path}`);
|
|
466
|
+
await this.page.evaluate((cookieStr) => { document.cookie = cookieStr; }, parts.join('; '));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
await this.page.setCookie(...cookies);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── Coordinate-based actions (computer-use mode) ────────────
|
|
475
|
+
|
|
476
|
+
_ensureProvider() {
|
|
477
|
+
if (!this.provider || this.activeBackend !== 'computer-use') {
|
|
478
|
+
throw new Error('Coordinate-based actions require computer-use mode with a provider');
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async mouseMove(x, y) {
|
|
483
|
+
this._ensureConnected();
|
|
484
|
+
this._ensureProvider();
|
|
485
|
+
const result = await this.provider.mouseMove(x, y);
|
|
486
|
+
this._logAction('mouseMove', { x, y });
|
|
487
|
+
return result;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async clickAt(x, y, button = 'left') {
|
|
491
|
+
this._ensureConnected();
|
|
492
|
+
this._ensureProvider();
|
|
493
|
+
const result = await this.provider.mouseClick(x, y, button);
|
|
494
|
+
this._logAction('clickAt', { x, y, button });
|
|
495
|
+
return result;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async doubleClickAt(x, y, button = 'left') {
|
|
499
|
+
this._ensureConnected();
|
|
500
|
+
this._ensureProvider();
|
|
501
|
+
const result = await this.provider.mouseDoubleClick(x, y, button);
|
|
502
|
+
this._logAction('doubleClickAt', { x, y, button });
|
|
503
|
+
return result;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async drag(startX, startY, endX, endY) {
|
|
507
|
+
this._ensureConnected();
|
|
508
|
+
this._ensureProvider();
|
|
509
|
+
const result = await this.provider.mouseDrag(startX, startY, endX, endY);
|
|
510
|
+
this._logAction('drag', { startX, startY, endX, endY });
|
|
511
|
+
return result;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async scrollAt(x, y, direction = 'down', amount = 3) {
|
|
515
|
+
this._ensureConnected();
|
|
516
|
+
this._ensureProvider();
|
|
517
|
+
const result = await this.provider.scroll(x, y, direction, amount);
|
|
518
|
+
this._logAction('scrollAt', { x, y, direction, amount });
|
|
519
|
+
return result;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async typeText(text) {
|
|
523
|
+
this._ensureConnected();
|
|
524
|
+
this._ensureProvider();
|
|
525
|
+
const result = await this.provider.typeText(text);
|
|
526
|
+
this._logAction('typeText', { text: text.substring(0, 20) + (text.length > 20 ? '...' : '') });
|
|
527
|
+
return result;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async getCursorPosition() {
|
|
531
|
+
this._ensureConnected();
|
|
532
|
+
this._ensureProvider();
|
|
533
|
+
return this.provider.getCursorPosition();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async getScreenSize() {
|
|
537
|
+
this._ensureConnected();
|
|
538
|
+
this._ensureProvider();
|
|
539
|
+
return this.provider.getScreenSize();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
getVncUrl() {
|
|
543
|
+
return this._providerInfo?.vncUrl || null;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ── Fallback ────────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
async _fallbackToChrome(reason) {
|
|
549
|
+
if (this.activeBackend === 'chrome') return; // already on Chrome
|
|
550
|
+
if (this._fallbackCount > 2) throw new Error('Too many fallback attempts');
|
|
551
|
+
|
|
552
|
+
this._log(`Falling back to Chrome: ${reason}`);
|
|
553
|
+
this._fallbackCount++;
|
|
554
|
+
|
|
555
|
+
// Save state from LP session
|
|
556
|
+
let cookies = [];
|
|
557
|
+
let currentUrl = null;
|
|
558
|
+
try {
|
|
559
|
+
cookies = await this.page.cookies();
|
|
560
|
+
currentUrl = this.page.url();
|
|
561
|
+
} catch {
|
|
562
|
+
// LP might be in a bad state
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Close LP page (keep server alive for potential reuse)
|
|
566
|
+
try {
|
|
567
|
+
if (this.page && !this.page.isClosed()) await this.page.close();
|
|
568
|
+
if (this.context) await this.context.close();
|
|
569
|
+
if (this.browser) await this.browser.disconnect();
|
|
570
|
+
} catch {
|
|
571
|
+
// ignore cleanup errors
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Connect to Chrome
|
|
575
|
+
await this._connectChrome();
|
|
576
|
+
|
|
577
|
+
// Restore state
|
|
578
|
+
if (cookies.length > 0) {
|
|
579
|
+
await this.page.setCookie(...cookies);
|
|
580
|
+
}
|
|
581
|
+
if (currentUrl && currentUrl !== 'about:blank') {
|
|
582
|
+
await this.page.goto(currentUrl, {
|
|
583
|
+
waitUntil: 'networkidle0',
|
|
584
|
+
timeout: this.timeout,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
this._logAction('fallback', { reason, from: 'lightpanda', to: 'chrome' });
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async _shouldFallback(error) {
|
|
592
|
+
if (this.activeBackend === 'chrome') return false;
|
|
593
|
+
if (this.mode === 'headless') return false; // no auto-fallback in explicit headless mode
|
|
594
|
+
|
|
595
|
+
const msg = error.message || '';
|
|
596
|
+
return (
|
|
597
|
+
msg.includes('Protocol error') ||
|
|
598
|
+
msg.includes('not implemented') ||
|
|
599
|
+
msg.includes('Target closed') ||
|
|
600
|
+
msg.includes('Session closed') ||
|
|
601
|
+
msg.includes('Connection closed') ||
|
|
602
|
+
msg.includes('Execution context was destroyed')
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ── Cleanup ─────────────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
async close() {
|
|
609
|
+
try {
|
|
610
|
+
if (this.page && !this.page.isClosed()) await this.page.close();
|
|
611
|
+
} catch { /* ignore */ }
|
|
612
|
+
|
|
613
|
+
try {
|
|
614
|
+
if (this.context) await this.context.close();
|
|
615
|
+
} catch { /* ignore */ }
|
|
616
|
+
|
|
617
|
+
if (this.activeBackend === 'computer-use') {
|
|
618
|
+
// Provider mode: disconnect from CDP, then stop the provider
|
|
619
|
+
try { if (this.browser) await this.browser.disconnect(); } catch { /* ignore */ }
|
|
620
|
+
try { if (this.provider) await this.provider.stop(); } catch { /* ignore */ }
|
|
621
|
+
} else if (this.activeBackend === 'lightpanda' && this.browser) {
|
|
622
|
+
try { await this.browser.disconnect(); } catch { /* ignore */ }
|
|
623
|
+
} else if (this.mode === 'visual' && this.browser && !this._chromeBrowser) {
|
|
624
|
+
// Visual mode: launched directly, not from pool — close the browser process
|
|
625
|
+
try { await this.browser.close(); } catch { /* ignore */ }
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (this._chromeBrowser) {
|
|
629
|
+
browserPool.releaseBrowser(this._chromeBrowser);
|
|
630
|
+
this._chromeBrowser = null;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
this.page = null;
|
|
634
|
+
this.context = null;
|
|
635
|
+
this.browser = null;
|
|
636
|
+
this._connected = false;
|
|
637
|
+
this._log('Session closed');
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ── Helpers ─────────────────────────────────────────────────
|
|
641
|
+
|
|
642
|
+
_ensureConnected() {
|
|
643
|
+
if (!this._connected || !this.page) {
|
|
644
|
+
throw new Error('Session not connected. Call connect() first.');
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async _getPuppeteer() {
|
|
649
|
+
try {
|
|
650
|
+
const puppeteer = await import('puppeteer');
|
|
651
|
+
return puppeteer.default || puppeteer;
|
|
652
|
+
} catch {
|
|
653
|
+
throw new Error('Puppeteer is required for BrowserSession. Install with: npm install puppeteer');
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
_log(msg) {
|
|
658
|
+
if (this.verbose) console.log(`[BrowserSession] ${msg}`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
_logAction(type, params = {}) {
|
|
662
|
+
const entry = { type, ...params, timestamp: Date.now(), backend: this.activeBackend };
|
|
663
|
+
this.history.push(entry);
|
|
664
|
+
this._log(`${type} ${JSON.stringify(params)}`);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
getHistory() {
|
|
668
|
+
return this.history;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
getBackend() {
|
|
672
|
+
return this.activeBackend;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Convenience function to create and connect a browser session.
|
|
678
|
+
*/
|
|
679
|
+
export async function createSession(options = {}) {
|
|
680
|
+
const session = new BrowserSession(options);
|
|
681
|
+
await session.connect();
|
|
682
|
+
return session;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export default BrowserSession;
|