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.
@@ -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(1),
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
- maxConcurrentSessions = 3,
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.maxConcurrentSessions = maxConcurrentSessions;
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 startTime = Date.now();
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
- // Check concurrent sessions limit
332
- if (this.activeSessions.size >= this.maxConcurrentSessions) {
333
- throw new Error(`Maximum concurrent sessions (${this.maxConcurrentSessions}) reached`);
334
- }
335
-
336
- // Create session context
337
- const sessionContext = {
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
- this.activeSessions.set(sessionId, sessionContext);
350
- this.emit('sessionStarted', sessionContext);
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
- try {
353
- const result = await this.executeSession(sessionContext);
354
-
355
- this.stats.successfulSessions++;
356
- this.stats.totalActions += validated.actions.length;
357
- this.stats.successfulActions += result.actionResults.filter(r => r.success).length;
358
- this.stats.failedActions += result.actionResults.filter(r => !r.success).length;
359
-
360
- const executionTime = Date.now() - startTime;
361
- this.updateAverageSessionTime(executionTime);
362
- this.updateAverageActionsPerSession(validated.actions.length);
363
- this.updateStats();
364
-
365
- if (this.enableCaching) {
366
- this.sessionResults.set(sessionId, {
367
- result,
368
- timestamp: Date.now(),
369
- ttl: 3600000
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
- this.activeSessions.delete(sessionId);
374
- this.emit('sessionCompleted', result);
371
+ this.activeSessions.set(sessionId, sessionContext);
372
+ this.emit('sessionStarted', sessionContext);
375
373
 
376
- return result;
374
+ try {
375
+ const result = await this.executeSession(sessionContext);
376
+ result.queued_ms = queuedMs;
377
377
 
378
- } catch (error) {
379
- this.stats.failedSessions++;
380
- this.activeSessions.delete(sessionId);
381
- this.emit('sessionFailed', { sessionId, url: validated.url, error });
382
- throw error;
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.log('error', `Scrape with actions failed: ${error.message}`);
387
- throw new Error(`Scrape with actions failed: ${error.message}`);
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: [{ type: 'text', text: `Failed to extract embedded state: ${error.message}` }],
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
- const element = await page.waitForSelector(selector);
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, options);
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
- await page.evaluate((sel) => {
366
- const element = document.querySelector(sel);
367
- if (element) {
368
- element.scrollIntoView({ behavior: 'smooth', block: 'center' });
369
- }
370
- }, target);
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
- textLength = await page.evaluate((sel) => {
459
- const element = document.querySelector(sel);
460
- return element ? element.textContent.length : 0;
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
  }