crawlforge-mcp-server 6.12.1 → 6.14.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/package.json +2 -1
- package/server.js +6 -5
- package/src/core/ActionExecutor.js +54 -26
- package/src/core/StealthBrowserManager.js +4 -2
- package/src/core/browser/actionQueue.js +81 -0
- package/src/core/browser/consent.js +182 -0
- package/src/core/browser/settle.js +141 -0
- package/src/core/browser/snapshot.js +241 -51
- package/src/tools/advanced/BrowserSessionTool.js +18 -2
- package/src/tools/advanced/ScrapeWithActionsTool.js +74 -53
- package/src/utils/HumanBehaviorSimulator.js +35 -14
|
@@ -15,6 +15,7 @@ import { elementText } from '../../utils/elementText.js';
|
|
|
15
15
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
16
16
|
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
17
17
|
import { resolveStealthEngine } from '../../core/StealthBrowserManager.js';
|
|
18
|
+
import { actionQueue as sharedActionQueue } from '../../core/browser/actionQueue.js';
|
|
18
19
|
|
|
19
20
|
// Recording / replay helpers
|
|
20
21
|
import {
|
|
@@ -198,7 +199,11 @@ const ScrapeWithActionsSchema = z.object({
|
|
|
198
199
|
// — the standard pool is Chromium and camoufox exists only on the stealth
|
|
199
200
|
// path — so "camoufox" without stealth is refused rather than quietly
|
|
200
201
|
// downgraded (see executeSession).
|
|
201
|
-
engine: z.enum(['auto', 'chromium', 'camoufox', 'playwright']).default('auto')
|
|
202
|
+
engine: z.enum(['auto', 'chromium', 'camoufox', 'playwright']).default('auto'),
|
|
203
|
+
// Answer a cookie/consent wall (autoconsent) after the initial load and
|
|
204
|
+
// after each navigate action: "reject" opts out, "accept" opts in. Off by
|
|
205
|
+
// default so a chain that clicks the banner itself keeps working.
|
|
206
|
+
consent: z.enum(['off', 'reject', 'accept']).default('off')
|
|
202
207
|
}).optional(),
|
|
203
208
|
|
|
204
209
|
// Content extraction options
|
|
@@ -255,7 +260,9 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
255
260
|
extractContentTool = null,
|
|
256
261
|
enableLogging = true,
|
|
257
262
|
enableCaching = false,
|
|
258
|
-
|
|
263
|
+
// The process-wide browser-slot limiter shared with browser_session open.
|
|
264
|
+
// Injectable for tests only; CRAWLFORGE_MAX_ACTION_SESSIONS sizes it.
|
|
265
|
+
actionQueue = sharedActionQueue,
|
|
259
266
|
defaultBrowserOptions = {
|
|
260
267
|
viewportWidth: 1280,
|
|
261
268
|
viewportHeight: 720,
|
|
@@ -274,7 +281,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
274
281
|
this.extractContentTool = extractContentTool || new ExtractContentTool();
|
|
275
282
|
this.enableLogging = enableLogging;
|
|
276
283
|
this.enableCaching = enableCaching;
|
|
277
|
-
this.
|
|
284
|
+
this.actionQueue = actionQueue;
|
|
278
285
|
this.defaultBrowserOptions = defaultBrowserOptions;
|
|
279
286
|
|
|
280
287
|
// Active sessions tracking
|
|
@@ -322,69 +329,80 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
322
329
|
|
|
323
330
|
this.stats.totalSessions++;
|
|
324
331
|
const sessionId = this.generateSessionId();
|
|
325
|
-
const
|
|
332
|
+
const enqueuedAt = Date.now();
|
|
326
333
|
|
|
327
334
|
if (this.enableLogging) {
|
|
328
335
|
console.error(`Starting scrape session ${sessionId} with ${validated.actions.length} actions on ${validated.url}`);
|
|
329
336
|
}
|
|
330
337
|
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
id: sessionId,
|
|
339
|
-
url: validated.url,
|
|
340
|
-
startTime,
|
|
341
|
-
params: validated,
|
|
342
|
-
states: [],
|
|
343
|
-
screenshots: [],
|
|
344
|
-
actionResults: [],
|
|
345
|
-
errors: [],
|
|
346
|
-
status: 'initializing'
|
|
347
|
-
};
|
|
338
|
+
// The whole chain runs inside a slot of the shared browser budget. Over
|
|
339
|
+
// the limit a call waits for one rather than being refused, and fails
|
|
340
|
+
// only after CRAWLFORGE_ACTION_QUEUE_TIMEOUT_MS with the queue depth.
|
|
341
|
+
return await this.actionQueue.run(
|
|
342
|
+
() => this.runSession(sessionId, validated, enqueuedAt),
|
|
343
|
+
{ label: 'scrape_with_actions' }
|
|
344
|
+
);
|
|
348
345
|
|
|
349
|
-
|
|
350
|
-
this.
|
|
346
|
+
} catch (error) {
|
|
347
|
+
this.log('error', `Scrape with actions failed: ${error.message}`);
|
|
348
|
+
throw new Error(`Scrape with actions failed: ${error.message}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
351
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
});
|
|
371
|
-
}
|
|
352
|
+
async runSession(sessionId, validated, enqueuedAt) {
|
|
353
|
+
// The clock starts once the slot is held, so executionTime and the
|
|
354
|
+
// recording's timings measure the run, not the wait before it.
|
|
355
|
+
const startTime = Date.now();
|
|
356
|
+
const queuedMs = startTime - enqueuedAt;
|
|
357
|
+
|
|
358
|
+
// Create session context
|
|
359
|
+
const sessionContext = {
|
|
360
|
+
id: sessionId,
|
|
361
|
+
url: validated.url,
|
|
362
|
+
startTime,
|
|
363
|
+
params: validated,
|
|
364
|
+
states: [],
|
|
365
|
+
screenshots: [],
|
|
366
|
+
actionResults: [],
|
|
367
|
+
errors: [],
|
|
368
|
+
status: 'initializing'
|
|
369
|
+
};
|
|
372
370
|
|
|
373
|
-
|
|
374
|
-
|
|
371
|
+
this.activeSessions.set(sessionId, sessionContext);
|
|
372
|
+
this.emit('sessionStarted', sessionContext);
|
|
375
373
|
|
|
376
|
-
|
|
374
|
+
try {
|
|
375
|
+
const result = await this.executeSession(sessionContext);
|
|
376
|
+
result.queued_ms = queuedMs;
|
|
377
377
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
378
|
+
this.stats.successfulSessions++;
|
|
379
|
+
this.stats.totalActions += validated.actions.length;
|
|
380
|
+
this.stats.successfulActions += result.actionResults.filter(r => r.success).length;
|
|
381
|
+
this.stats.failedActions += result.actionResults.filter(r => !r.success).length;
|
|
382
|
+
|
|
383
|
+
const executionTime = Date.now() - startTime;
|
|
384
|
+
this.updateAverageSessionTime(executionTime);
|
|
385
|
+
this.updateAverageActionsPerSession(validated.actions.length);
|
|
386
|
+
this.updateStats();
|
|
387
|
+
|
|
388
|
+
if (this.enableCaching) {
|
|
389
|
+
this.sessionResults.set(sessionId, {
|
|
390
|
+
result,
|
|
391
|
+
timestamp: Date.now(),
|
|
392
|
+
ttl: 3600000
|
|
393
|
+
});
|
|
383
394
|
}
|
|
384
395
|
|
|
396
|
+
this.activeSessions.delete(sessionId);
|
|
397
|
+
this.emit('sessionCompleted', result);
|
|
398
|
+
|
|
399
|
+
return result;
|
|
400
|
+
|
|
385
401
|
} catch (error) {
|
|
386
|
-
this.
|
|
387
|
-
|
|
402
|
+
this.stats.failedSessions++;
|
|
403
|
+
this.activeSessions.delete(sessionId);
|
|
404
|
+
this.emit('sessionFailed', { sessionId, url: validated.url, error });
|
|
405
|
+
throw error;
|
|
388
406
|
}
|
|
389
407
|
}
|
|
390
408
|
|
|
@@ -532,6 +550,9 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
532
550
|
// was chosen (an "auto" that fell back to Chromium says so here).
|
|
533
551
|
engine: browserOptions.stealthMode?.engine || 'chromium',
|
|
534
552
|
...(warnings.length ? { warnings } : {}),
|
|
553
|
+
// The landing page's consent result; each navigate action's own is on
|
|
554
|
+
// its result. Absent when consent handling is off.
|
|
555
|
+
...(chainResult.consent ? { consent: chainResult.consent } : {}),
|
|
535
556
|
|
|
536
557
|
actionResults,
|
|
537
558
|
attempt: chainResult.attempt ?? attempts.length,
|
|
@@ -102,7 +102,7 @@ export class HumanBehaviorSimulator {
|
|
|
102
102
|
* Simulate human-like mouse movement to target coordinates
|
|
103
103
|
*/
|
|
104
104
|
async simulateMouseMovement(page, fromX, fromY, toX, toY) {
|
|
105
|
-
if (!this.config.mouseMovements.enabled) {
|
|
105
|
+
if (!this.config.mouseMovements.enabled || this.browserHumanizesCursor(page)) {
|
|
106
106
|
await page.mouse.move(toX, toY);
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
@@ -130,6 +130,25 @@ export class HumanBehaviorSimulator {
|
|
|
130
130
|
this.stats.mouseMovements++;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Whether the browser already animates every page.mouse.move itself.
|
|
135
|
+
*
|
|
136
|
+
* camoufox launched with `humanize` (StealthBrowserManager sets it from
|
|
137
|
+
* simulateHumanBehavior, on by default) draws its own curved path for each
|
|
138
|
+
* move, at ~1-2 s a call whatever the distance (measured 2026-09-29: 10 px
|
|
139
|
+
* took 950 ms, across the window 2.2 s). Our path is 11-101 moves, so the
|
|
140
|
+
* two stacked turned one click into 10-20 s and hit the action backstop; one
|
|
141
|
+
* move to the target is still a humanized path. camoufox is the only
|
|
142
|
+
* Firefox this server launches.
|
|
143
|
+
*/
|
|
144
|
+
browserHumanizesCursor(page) {
|
|
145
|
+
try {
|
|
146
|
+
return page.context().browser()?.browserType().name() === 'firefox';
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
133
152
|
/**
|
|
134
153
|
* Generate natural mouse movement path using Bezier curves
|
|
135
154
|
*/
|
|
@@ -312,7 +331,10 @@ export class HumanBehaviorSimulator {
|
|
|
312
331
|
* Simulate human-like clicking with hover and delay
|
|
313
332
|
*/
|
|
314
333
|
async simulateClick(page, selector, options = {}) {
|
|
315
|
-
|
|
334
|
+
// `timeout` bounds the wait for the element; left unset it is Playwright's
|
|
335
|
+
// 30 s default. It is not a mouse.click option, so it stays out of those.
|
|
336
|
+
const { timeout, ...clickOptions } = options;
|
|
337
|
+
const element = await page.waitForSelector(selector, { timeout });
|
|
316
338
|
const boundingBox = await element.boundingBox();
|
|
317
339
|
|
|
318
340
|
if (!boundingBox) {
|
|
@@ -339,7 +361,7 @@ export class HumanBehaviorSimulator {
|
|
|
339
361
|
await this.delay(clickDelay);
|
|
340
362
|
|
|
341
363
|
// Perform the click
|
|
342
|
-
await page.mouse.click(clickX, clickY,
|
|
364
|
+
await page.mouse.click(clickX, clickY, clickOptions);
|
|
343
365
|
|
|
344
366
|
this.stats.totalInteractions++;
|
|
345
367
|
}
|
|
@@ -361,13 +383,13 @@ export class HumanBehaviorSimulator {
|
|
|
361
383
|
} = options;
|
|
362
384
|
|
|
363
385
|
if (target) {
|
|
364
|
-
// Scroll to specific element
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
370
|
-
}
|
|
386
|
+
// Scroll to specific element. A locator, not document.querySelector: a
|
|
387
|
+
// snapshot ref resolves to `aria-ref=…`, which is not CSS, and its element
|
|
388
|
+
// may sit in a shadow root or an iframe that querySelector cannot reach.
|
|
389
|
+
const element = page.locator(target).first();
|
|
390
|
+
if (await element.count()) {
|
|
391
|
+
await element.evaluate((el) => el.scrollIntoView({ behavior: 'smooth', block: 'center' }));
|
|
392
|
+
}
|
|
371
393
|
return;
|
|
372
394
|
}
|
|
373
395
|
|
|
@@ -455,10 +477,9 @@ export class HumanBehaviorSimulator {
|
|
|
455
477
|
let textLength = 0;
|
|
456
478
|
|
|
457
479
|
if (selector) {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
}, selector);
|
|
480
|
+
// A locator for the same reason as simulateScroll's target.
|
|
481
|
+
const element = page.locator(selector).first();
|
|
482
|
+
textLength = (await element.count()) ? await element.evaluate((el) => el.textContent.length) : 0;
|
|
462
483
|
} else {
|
|
463
484
|
textLength = await page.evaluate(() => document.body.textContent.length);
|
|
464
485
|
}
|