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.
@@ -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
- 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
 
@@ -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
- 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
  }