crawlforge-mcp-server 6.12.0 → 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 +14 -18
- package/src/cli/commands/actions.js +1 -0
- package/src/core/ActionExecutor.js +129 -63
- 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/server/screenshotResources.js +50 -0
- package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +6 -0
- package/src/tools/advanced/BrowserSessionTool.js +18 -2
- package/src/tools/advanced/ScrapeWithActionsTool.js +84 -54
- package/src/tools/extract/extractEmbeddedState.js +11 -1
- 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
|
|
@@ -215,7 +220,7 @@ const ScrapeWithActionsSchema = z.object({
|
|
|
215
220
|
|
|
216
221
|
// Error handling
|
|
217
222
|
continueOnActionError: z.boolean().default(false),
|
|
218
|
-
maxRetries: z.number().min(0).max(3).default(
|
|
223
|
+
maxRetries: z.number().min(0).max(3).default(0),
|
|
219
224
|
screenshotOnError: z.boolean().default(true),
|
|
220
225
|
|
|
221
226
|
// ── Recording / replay ──────────────────────────────────────────────────
|
|
@@ -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
|
|
|
@@ -446,6 +464,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
446
464
|
continueOnError: params.continueOnActionError,
|
|
447
465
|
timeout: browserOptions.timeout || 30000,
|
|
448
466
|
retryChain: params.maxRetries,
|
|
467
|
+
screenshotOnError: params.screenshotOnError,
|
|
449
468
|
metadata: {
|
|
450
469
|
sessionId: sessionContext.id,
|
|
451
470
|
originalActionCount: params.actions.length,
|
|
@@ -483,6 +502,12 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
483
502
|
|
|
484
503
|
// Process action results
|
|
485
504
|
const actionResults = this.processActionResults(chainResult.results);
|
|
505
|
+
// Every attempt the executor made (retryChain > 0 replays the whole
|
|
506
|
+
// chain), in the same shape as actionResults; `attempt` is the one whose
|
|
507
|
+
// results are reported above.
|
|
508
|
+
const attempts = (chainResult.attempts || [
|
|
509
|
+
{ attempt: 1, success: chainResult.success, error: chainResult.error, results: chainResult.results }
|
|
510
|
+
]).map((a) => ({ ...a, results: this.processActionResults(a.results || []) }));
|
|
486
511
|
const intermediateStates = params.captureIntermediateStates ?
|
|
487
512
|
await this.extractIntermediateStates(chainResult.capturedStates || [], params) : [];
|
|
488
513
|
|
|
@@ -525,8 +550,13 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
525
550
|
// was chosen (an "auto" that fell back to Chromium says so here).
|
|
526
551
|
engine: browserOptions.stealthMode?.engine || 'chromium',
|
|
527
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 } : {}),
|
|
528
556
|
|
|
529
557
|
actionResults,
|
|
558
|
+
attempt: chainResult.attempt ?? attempts.length,
|
|
559
|
+
attempts,
|
|
530
560
|
totalActions: params.actions.length,
|
|
531
561
|
successfulActions: actionResults.filter(r => r.success).length,
|
|
532
562
|
failedActions: actionResults.filter(r => !r.success).length,
|
|
@@ -19,6 +19,9 @@ import { extractEmbeddedState, selectJsonPath } from 'crawlforge-extractors';
|
|
|
19
19
|
// the caller an exact way to ask for less.
|
|
20
20
|
const LARGE_RESULT_BYTES = 256_000;
|
|
21
21
|
|
|
22
|
+
const BLOCKED_HINT =
|
|
23
|
+
'stealth_mode operation:"scrape" renders the page in a browser and returns the HTML; the embedded state is in the returned html. Do not repeat this call with a different path.';
|
|
24
|
+
|
|
22
25
|
/**
|
|
23
26
|
* @param {{ url: string, path?: string, user_agent?: string, respect_robots?: boolean }} params
|
|
24
27
|
*/
|
|
@@ -81,8 +84,15 @@ export async function extractEmbeddedStateHandler({ url, path, user_agent, respe
|
|
|
81
84
|
}]
|
|
82
85
|
};
|
|
83
86
|
} catch (error) {
|
|
87
|
+
// A block or server error is not a path problem: the generic hint
|
|
88
|
+
// ("call again without `path`") would send the caller back into the same
|
|
89
|
+
// wall. Naming the next step here keeps withAuth from appending it.
|
|
90
|
+
const blocked = /^HTTP (403|429|444|5\d\d)\b/.test(error.message);
|
|
84
91
|
return {
|
|
85
|
-
content: [{
|
|
92
|
+
content: [{
|
|
93
|
+
type: 'text',
|
|
94
|
+
text: `Failed to extract embedded state: ${error.message}` + (blocked ? `\nNext step: ${BLOCKED_HINT}` : '')
|
|
95
|
+
}],
|
|
86
96
|
isError: true
|
|
87
97
|
};
|
|
88
98
|
}
|
|
@@ -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
|
}
|