crawlforge-mcp-server 6.12.1 → 6.16.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.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -3
  3. package/server.js +68 -22
  4. package/src/cli/commands/stealth.js +2 -2
  5. package/src/core/ActionExecutor.js +194 -64
  6. package/src/core/AuthManager.js +12 -0
  7. package/src/core/LLMsTxtAnalyzer.js +1 -0
  8. package/src/core/ResearchOrchestrator.js +10 -4
  9. package/src/core/StealthBrowserManager.js +29 -6
  10. package/src/core/browser/actionQueue.js +84 -0
  11. package/src/core/browser/consent.js +182 -0
  12. package/src/core/browser/settle.js +141 -0
  13. package/src/core/browser/snapshot.js +241 -51
  14. package/src/core/browser/windowState.js +88 -0
  15. package/src/core/crawlers/BFSCrawler.js +12 -3
  16. package/src/core/processing/BrowserProcessor.js +19 -7
  17. package/src/core/processing/PDFProcessor.js +15 -6
  18. package/src/server/fallbackHints.js +7 -4
  19. package/src/server/inlineThreshold.js +81 -19
  20. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
  21. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +25 -6
  22. package/src/tools/advanced/BrowserSessionTool.js +56 -15
  23. package/src/tools/advanced/ScrapeWithActionsTool.js +74 -53
  24. package/src/tools/advanced/batchScrape/worker.js +3 -1
  25. package/src/tools/basic/_fetch.js +3 -1
  26. package/src/tools/crawl/_sessionContext.js +2 -1
  27. package/src/tools/crawl/mapSite.js +2 -1
  28. package/src/tools/extract/_fetchAndParse.js +1 -0
  29. package/src/tools/extract/extractContent.js +2 -1
  30. package/src/tools/extract/extractEmbeddedState.js +320 -71
  31. package/src/tools/extract/processDocument.js +5 -3
  32. package/src/tools/llmstxt/generateLLMsTxt.js +2 -1
  33. package/src/tools/templates/ScrapeTemplateTool.js +11 -3
  34. package/src/tools/tracking/trackChanges/differ.js +1 -0
  35. package/src/utils/HumanBehaviorSimulator.js +35 -14
  36. package/src/utils/complianceAudit.js +35 -5
  37. package/src/utils/impitRung.js +16 -5
  38. package/src/utils/resignedFetch.js +165 -0
  39. package/src/utils/robotsChecker.js +6 -1
  40. package/src/utils/robotsGate.js +124 -11
  41. package/src/utils/sitemapParser.js +2 -1
  42. package/src/utils/ssrfGuard.js +88 -11
@@ -44,8 +44,8 @@ export const FALLBACK_HINTS = Object.freeze({
44
44
  });
45
45
 
46
46
  /**
47
- * The second-stage hint (Phase 3, 3.4). A `scrape` that came back
48
- * `escalated: true` has ALREADY run the stealth browser on this URL, so the
47
+ * The second-stage hint (Phase 3, 3.4). A `scrape` or `extract_embedded_state`
48
+ * that came back `escalated: true` has ALREADY run the stealth browser on this URL, so the
49
49
  * ordinary hint would send the model back to a tool that has just failed.
50
50
  * What is left is a region-specific block, which `localization` can change,
51
51
  * or a block that is final: an Akamai-style TLS-level wall is beaten with
@@ -56,9 +56,12 @@ export const SCRAPE_ESCALATED_HINT =
56
56
  'If the block is regional, set a country with localization and try once more; otherwise the block is final - ' +
57
57
  'a TLS-level wall needs residential proxies, which CrawlForge does not offer. Get the content from another source.';
58
58
 
59
- /** The hint for this result: the tool's, unless a scrape already escalated. */
59
+ /** The tools whose escalate:true runs the stealth stage itself (Phase 3; extract_embedded_state since plan Phase 3.2). */
60
+ const ESCALATING_TOOLS = new Set(['scrape', 'extract_embedded_state']);
61
+
62
+ /** The hint for this result: the tool's, unless the call already escalated. */
60
63
  function hintFor(toolName, parsed) {
61
- if (toolName === 'scrape' && parsed?.escalated === true) return SCRAPE_ESCALATED_HINT;
64
+ if (ESCALATING_TOOLS.has(toolName) && parsed?.escalated === true) return SCRAPE_ESCALATED_HINT;
62
65
  return FALLBACK_HINTS[toolName];
63
66
  }
64
67
 
@@ -20,9 +20,9 @@ export const MAX_INLINE_CHARS_PARAM = {
20
20
  /**
21
21
  * Per-tool rule. `textPaths` are dotted paths tried in order; the first one
22
22
  * holding a string becomes the text view the preview and read_result work
23
- * on. An empty list means the pretty-printed JSON is the view. `truncate:
24
- * false` keeps the whole result inline and only adds the handle
25
- * (extract_embedded_state's never-truncate rule). `when` gates on params.
23
+ * on. An empty list means the pretty-printed JSON is the view. `when` gates
24
+ * on params. extract_embedded_state has a shape of its own (shapeEmbeddedState
25
+ * below).
26
26
  */
27
27
  /** The browser_session operations that hand back content worth shaping. */
28
28
  const BROWSER_SESSION_CONTENT_OPERATIONS = new Set(['snapshot', 'act', 'read']);
@@ -52,7 +52,10 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
52
52
  },
53
53
  process_document: { textPaths: ['content.text'], truncate: true },
54
54
  deep_research: { textPaths: [], truncate: true },
55
- extract_embedded_state: { textPaths: [], truncate: false }
55
+ // Truncated since plan Phase 3.1: returned whole, producthunt.com and
56
+ // zappos.com overflowed the client. Shaped by shapeEmbeddedState, which never
57
+ // cuts inside a JSON value.
58
+ extract_embedded_state: { textPaths: [], truncate: true }
56
59
  });
57
60
 
58
61
  /** param -> env (an int of at least 1,000) -> default. */
@@ -147,21 +150,8 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
147
150
  const viewDesc = view === 'text' ? `the ${view_path} text` : 'the pretty-printed JSON';
148
151
  const readWith = 'read it with read_result (1 credit) - operation "search" (query), "slice" (offset, length), "lines" or "json_path" (path) - and do not fetch the page again';
149
152
 
150
- if (!config.truncate) {
151
- const hint = `Result is ${json.length} chars as JSON, over the inline limit of ${maxInline}; it is returned whole (this tool never truncates) and is also kept for 1 hour under result_handle ${handle}: ${readWith}.`;
152
- return {
153
- result: {
154
- ...resultObject,
155
- result_handle: handle,
156
- total_chars: text.length,
157
- view,
158
- view_path,
159
- truncated: false,
160
- expires_at,
161
- warnings: [...warningsOf(resultObject), hint]
162
- },
163
- stored: true
164
- };
153
+ if (toolName === 'extract_embedded_state') {
154
+ return { result: shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith }), stored: true };
165
155
  }
166
156
 
167
157
  const preview = text.slice(0, maxInline);
@@ -200,3 +190,75 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
200
190
  });
201
191
  return { result: shaped, stored: true };
202
192
  }
193
+
194
+ /** Object.keys(data) for a plain object, "array(<n>)" for an array, null otherwise. */
195
+ export function embeddedDataKeys(data) {
196
+ if (Array.isArray(data)) return `array(${data.length})`;
197
+ if (data !== null && typeof data === 'object') return Object.keys(data);
198
+ return null;
199
+ }
200
+
201
+ /**
202
+ * The longest run of whole lines from the start of `text` whose JSON-string
203
+ * form fits in `budget` characters. A line that does not fit ends the preview,
204
+ * so it never stops inside a value; with a budget too small for the first
205
+ * line it is empty.
206
+ */
207
+ export function wholeLinePreview(text, budget) {
208
+ const room = budget - 2; // the string's own quotes
209
+ let used = 0;
210
+ let end = 0;
211
+ let start = 0;
212
+ while (start < text.length) {
213
+ const newline = text.indexOf('\n', start);
214
+ const stop = newline === -1 ? text.length : newline;
215
+ // A line's escaped length, plus the escaped newline that joins it to the last.
216
+ const cost = JSON.stringify(text.slice(start, stop)).length - 2 + (end > 0 ? 2 : 0);
217
+ if (used + cost > room) break;
218
+ used += cost;
219
+ end = stop;
220
+ start = stop + 1;
221
+ }
222
+ return text.slice(0, end);
223
+ }
224
+
225
+ /**
226
+ * extract_embedded_state over the limit (plan Phase 3.1). What stays inline is
227
+ * everything that says what the page carries — url, path, bytes, the whole
228
+ * `found` list, data_keys, the escalation report and window_state's list —
229
+ * then a preview of the pretty-printed JSON cut at a line boundary. The
230
+ * preview gets whatever max_inline_chars leaves after the rest, measured as
231
+ * compact JSON, so the whole inline result stays within the limit whenever
232
+ * the rest fits. The full result is under the handle for read_result
233
+ * json_path. The REST route shapes it the same way; keep the two in step.
234
+ */
235
+ function shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith }) {
236
+ const dataKeys = embeddedDataKeys(resultObject.data);
237
+ const firstKey = Array.isArray(dataKeys) && dataKeys.length > 0 ? dataKeys[0] : null;
238
+ const example = firstKey && /^[A-Za-z_$][\w$]*$/.test(firstKey) ? ` Read one payload with operation "json_path", path "data.${firstKey}".` : '';
239
+ const hint = `Result is ${json.length} chars as JSON, over the inline limit of ${maxInline}; preview holds the pretty-printed JSON up to the last whole line that fits (${text.length} chars in total), data_keys lists the top-level keys of data, and the full result is kept for 1 hour under result_handle ${handle}: ${readWith}.${example} Or re-run with path, or keys_only:true, to ask for less.`;
240
+
241
+ const shaped = {};
242
+ for (const key of ['url', 'path', 'bytes', 'found']) {
243
+ if (key in resultObject) shaped[key] = resultObject[key];
244
+ }
245
+ shaped.data_keys = dataKeys;
246
+ if ('escalated' in resultObject) shaped.escalated = resultObject.escalated;
247
+ if (resultObject.stealth) shaped.stealth = resultObject.stealth;
248
+ if (resultObject.window_state && typeof resultObject.window_state === 'object') {
249
+ shaped.window_state = { note: resultObject.window_state.note, found: resultObject.window_state.found };
250
+ }
251
+ const tail = {
252
+ result_handle: handle,
253
+ total_chars: text.length,
254
+ view: 'json',
255
+ view_path: null,
256
+ truncated: true,
257
+ expires_at,
258
+ warnings: [...warningsOf(resultObject), hint]
259
+ };
260
+ // `"preview":"",` is what the key itself adds once the value is in.
261
+ const envelope = JSON.stringify({ ...shaped, preview: '', ...tail }).length;
262
+ const preview = wholeLinePreview(text, Math.max(0, maxInline - envelope + 2));
263
+ return { ...shaped, preview, ...tail };
264
+ }
@@ -22,7 +22,7 @@ metered; there is no free tier. Tools marked "scales" cost more as work grows.
22
22
  |------|-------|
23
23
  | `scrape` | Unified multi-format single fetch. A `highlights` or `question` format adds 1 once per call; `mode:"model"` on one of them adds 3 once. `escalate:true` adds 5 as the projected ceiling; the actual charge drops back to the base when the plain fetch succeeded and no escalation ran. |
24
24
  | `scrape_structured` | CSS-selector extraction. |
25
- | `extract_embedded_state` | Embedded JS state (`__NEXT_DATA__`, RSC, Nuxt, Apollo, Redux). |
25
+ | `extract_embedded_state` | Embedded JS state (`__NEXT_DATA__`, RSC, Nuxt, Apollo, Redux). `escalate:true` adds 5 as the projected ceiling for the stealth re-read; the actual charge drops back to 2 when the plain fetch succeeded and no escalation ran. `keys_only` and `find` cost nothing extra. |
26
26
  | `extract_content` | Readability-cleaned article. |
27
27
  | `map_site` | URL discovery / sitemap. |
28
28
  | `process_document` | PDF / DOCX / TXT parsing. |
@@ -36,15 +36,34 @@ already, exact and typed — that beats asking a model to read them off the rend
36
36
  { "tool": "extract_embedded_state", "params": { "url": "https://www.ticketmaster.com/discover/concerts", "path": "next_data.props.pageProps" } }
37
37
  ```
38
38
 
39
- Finds `__NEXT_DATA__`, `self.__next_f` (React Server Component payloads),
40
- `window.__NUXT__`, `__APOLLO_STATE__`, `__INITIAL_STATE__`, `__PRELOADED_STATE__`
41
- and `<script type="application/json">` blocks, keyed by source name. No LLM in
42
- the path, so values are exact rather than inferred.
39
+ Finds `__NEXT_DATA__`, `self.__next_f` (React Server Component payloads, with
40
+ `$<id>` references between rows resolved and a `data_rows` index of the rows
41
+ carrying data), `window.__NUXT__` and Nuxt 3's `__NUXT_DATA__` (decoded as
42
+ `nuxt_data`; `raw: true` also keeps the undecoded array), SvelteKit `kit.start`
43
+ data, `__APOLLO_STATE__`, `__INITIAL_STATE__`, `__PRELOADED_STATE__`,
44
+ `ytInitialData`, `ytInitialPlayerResponse`, Inertia `data-page`, Shopify meta and
45
+ product JSON, JSON `data-*` attributes and `<script type="application/json">`
46
+ blocks, keyed by source name. No LLM in the path, so values are exact rather
47
+ than inferred.
43
48
 
44
49
  These payloads are routinely over a megabyte — pass `path` (dotted keys and
45
50
  array indexes, e.g. `next_data.props.pageProps` or `next_f[0].f`) to return one
46
- subtree. Without it, a large result comes back with a warning naming the biggest
47
- source and a ready-to-paste path.
51
+ subtree, or `keys_only: true` first to see the first two levels of keys (types,
52
+ no values) before choosing one. When you know a field's name but not where it
53
+ is, `find: "<key>"` returns every path holding it (case-insensitive, up to 50,
54
+ each with a 200-character preview) instead of the data; each path can be passed
55
+ straight back as `path`. `find` and `keys_only` cannot be combined. A result
56
+ over `max_inline_chars` comes back as a `preview` (whole lines of the JSON),
57
+ `data_keys` and a `result_handle`: read the
58
+ rest with `read_result` operation `json_path` (e.g. path `data.next_data.props`)
59
+ instead of fetching again.
60
+
61
+ On a site that blocks a plain fetch (403/429/challenge page), pass
62
+ `escalate: true` (projected 7, charged 2 when the plain fetch works): the stealth
63
+ browser re-reads the page, the same parser runs on it, and the framework globals
64
+ are read off `window` after JavaScript ran — `ytInitialData`, `__NUXT__`,
65
+ `__remixContext` and others, under `window_state` (path
66
+ `window_state.ytInitialData.contents`). A 404 or 5xx never escalates.
48
67
 
49
68
  ## scrape_template — known sites, zero selectors (cost: 1)
50
69
 
@@ -42,6 +42,7 @@ import { isRemoteTransport } from '../../utils/remoteMode.js';
42
42
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
43
43
  import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
44
44
  import { resolveStealthEngine } from '../../core/StealthBrowserManager.js';
45
+ import { actionQueue as sharedActionQueue } from '../../core/browser/actionQueue.js';
45
46
 
46
47
  const SECOND = 1000;
47
48
 
@@ -110,8 +111,10 @@ const BrowserSessionSchema = z.object({
110
111
  timeout: z.number().min(10000).max(120000).default(30000),
111
112
 
112
113
  // Applies to the call it is sent on: on `open` to the first load, on `act` to
113
- // every navigate in that call. It is never remembered by the session, so an
114
- // override has to be repeated as deliberately as it was made.
114
+ // every navigate in that call, and on any call to wherever the page has moved
115
+ // since it was last checked (a click, a form, a script). It is never
116
+ // remembered by the session, so an override has to be repeated as
117
+ // deliberately as it was made. A URL that passed is not asked about again.
115
118
  respect_robots: z.boolean().optional(),
116
119
 
117
120
  // snapshot
@@ -207,7 +210,10 @@ export class BrowserSessionTool {
207
210
  extractContentTool = null,
208
211
  store = null,
209
212
  storeOptions = {},
210
- enableLogging = true
213
+ enableLogging = true,
214
+ // The process-wide browser-slot limiter shared with scrape_with_actions.
215
+ // Injectable for tests only.
216
+ actionQueue = sharedActionQueue
211
217
  } = options;
212
218
 
213
219
  // An injected executor belongs to whoever built it (server.js hands us
@@ -218,6 +224,7 @@ export class BrowserSessionTool {
218
224
  this.extractContentTool = extractContentTool || new ExtractContentTool();
219
225
  this.storeOptions = storeOptions;
220
226
  this.store = store || new BrowserSessionStore(storeOptions);
227
+ this.actionQueue = actionQueue;
221
228
  }
222
229
 
223
230
  async execute(params) {
@@ -337,11 +344,24 @@ export class BrowserSessionTool {
337
344
  // only then creates a page and navigates — closing the page itself if any
338
345
  // of that fails. That is the whole of 2.5's gating on `open`, which is why
339
346
  // this is not page.goto() behind a gate written here.
340
- const page = await this.actionExecutor.initializePage(params.url, browserOptions);
347
+ //
348
+ // Page creation and the first navigation take a slot from the browser
349
+ // budget scrape_with_actions draws on, so a burst of opens queues behind
350
+ // (and alongside) action chains instead of piling onto the box. The slot is
351
+ // released as soon as the page is up — a live session must NOT hold it for
352
+ // its TTL, or three open sessions would starve every scrape_with_actions
353
+ // call for up to an hour. How many sessions may stay open is the store's
354
+ // per-owner and total caps below, which still refuse rather than queue.
355
+ const page = await this.actionQueue.run(
356
+ () => this.actionExecutor.initializePage(params.url, browserOptions),
357
+ { label: 'browser_session open' }
358
+ );
341
359
  const releasePage = this.releaser(page, params.stealth);
342
360
 
343
361
  let session;
344
362
  try {
363
+ // The load was gated; a script may have moved the page on since.
364
+ await this.actionExecutor.assertPageAllowed(page, browserOptions);
345
365
  session = this.store.create({
346
366
  ownerId,
347
367
  page,
@@ -355,8 +375,9 @@ export class BrowserSessionTool {
355
375
  maxPerOwner: ownerId.startsWith(REST_OWNER_PREFIX) ? REST_MAX_SESSIONS_PER_OWNER : undefined
356
376
  });
357
377
  } catch (error) {
358
- // A cap refusal arrives with a live page in hand. Give it back before
359
- // rethrowing, or the refused call leaks the context it just pinned.
378
+ // A cap refusal, or a page the gate refused, arrives with a live page in
379
+ // hand. Give it back before rethrowing, or the refused call leaks the
380
+ // context it just pinned.
360
381
  await releasePage();
361
382
  throw error;
362
383
  }
@@ -422,13 +443,29 @@ export class BrowserSessionTool {
422
443
  }
423
444
  }
424
445
 
446
+ /**
447
+ * Read from a session's page once the URL it stands on has passed the gate.
448
+ *
449
+ * A session's page outlives the call that loaded it, and between calls it
450
+ * can go anywhere: the last `act` clicked a link, or a script redirected.
451
+ * ActionExecutor.readGated holds it to the SSRF guard and the blocklist/
452
+ * robots gate on both sides of the read, with this call's `respect_robots`.
453
+ */
454
+ readPage(session, params, read) {
455
+ return this.actionExecutor.readGated(
456
+ session.page,
457
+ { respectRobots: params.respect_robots, tool: 'browser_session' },
458
+ read
459
+ );
460
+ }
461
+
425
462
  async snapshotSession(params, ownerId) {
426
463
  const session = this.requireSession(params, ownerId);
427
464
 
428
- const snapshot = await captureSnapshot(session.page, {
465
+ const snapshot = await this.readPage(session, params, () => captureSnapshot(session.page, {
429
466
  interactiveOnly: params.interactive_only,
430
467
  maxNodes: params.max_nodes
431
- });
468
+ }));
432
469
 
433
470
  this.store.touch(session, session.page.url());
434
471
  return { success: true, operation: 'snapshot', ...sessionInfo(session), snapshot };
@@ -457,9 +494,11 @@ export class BrowserSessionTool {
457
494
  }
458
495
 
459
496
  // Every `navigate` in here re-runs the SSRF guard and the blocklist/robots
460
- // gate inside executeNavigateAction — verified, and the reason the gate is
461
- // not repeated here. A long-lived session is a repeatable navigation
462
- // primitive, so that per-hop check is what stops it becoming an SSRF hop.
497
+ // gate inside executeNavigateAction, and every action is bracketed by the
498
+ // same check on wherever the page has moved (executeGatedAction) — the
499
+ // reason the gate is not repeated here. A long-lived session is a
500
+ // repeatable navigation primitive, so those checks are what stop it
501
+ // becoming an SSRF hop.
463
502
  const result = await this.actionExecutor.executeActionsOnPage(session.page, params.actions, {
464
503
  continueOnError: params.continue_on_error,
465
504
  timeout: params.timeout,
@@ -487,8 +526,10 @@ export class BrowserSessionTool {
487
526
 
488
527
  async readSession(params, ownerId) {
489
528
  const session = this.requireSession(params, ownerId);
490
- const url = session.page.url();
491
- const html = await session.page.content();
529
+ const { url, html } = await this.readPage(session, params, async () => ({
530
+ url: session.page.url(),
531
+ html: await session.page.content()
532
+ }));
492
533
 
493
534
  const options = {};
494
535
  if (params.formats.includes('markdown')) options.outputFormat = 'markdown';
@@ -548,12 +589,12 @@ export class BrowserSessionTool {
548
589
  async screenshotSession(params, ownerId) {
549
590
  const session = this.requireSession(params, ownerId);
550
591
 
551
- const shot = await this.actionExecutor.captureScreenshot(session.page, {
592
+ const shot = await this.readPage(session, params, () => this.actionExecutor.captureScreenshot(session.page, {
552
593
  fullPage: params.full_page,
553
594
  format: params.format,
554
595
  quality: params.quality,
555
596
  selector: params.selector
556
- });
597
+ }));
557
598
 
558
599
  this.store.touch(session, session.page.url());
559
600
  // The actionId is what lets the server publish the image as a
@@ -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,
@@ -10,6 +10,7 @@ import { config as appConfig } from '../../../constants/config.js';
10
10
  import { ssrfGuard, isSsrfError } from '../../../utils/ssrfGuard.js';
11
11
  import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
12
12
  import { preflightFetch } from '../../../utils/robotsGate.js';
13
+ import { fetchResigned } from '../../../utils/resignedFetch.js';
13
14
  import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
14
15
  import { elementText } from '../../../utils/elementText.js';
15
16
  import { pageTitle } from '../../../utils/pageTitle.js';
@@ -31,9 +32,10 @@ export async function fetchUrl(url, options = {}) {
31
32
  const controller = new AbortController();
32
33
  const timeoutId = setTimeout(() => controller.abort(), timeout);
33
34
  try {
34
- const response = await fetch(url, {
35
+ const response = await fetchResigned(url, {
35
36
  signal: controller.signal,
36
37
  headers: { ...gate.headers, ...headers },
38
+ onRedirect: gate.onRedirect,
37
39
  ...guard
38
40
  });
39
41
  if (response.status === 429 || response.status === 503) {
@@ -8,6 +8,7 @@ import { config } from '../../constants/config.js';
8
8
  import { ssrfGuard, isSsrfError } from '../../utils/ssrfGuard.js';
9
9
  import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
10
10
  import { preflightFetch } from '../../utils/robotsGate.js';
11
+ import { fetchResigned } from '../../utils/resignedFetch.js';
11
12
 
12
13
  /**
13
14
  * Fetch a URL with a configurable timeout and body-size cap.
@@ -51,12 +52,13 @@ export async function fetchWithTimeout(url, options = {}) {
51
52
  try {
52
53
  let response;
53
54
  try {
54
- response = await fetch(url, {
55
+ response = await fetchResigned(url, {
55
56
  signal: controller.signal,
56
57
  headers: {
57
58
  ...gate.headers,
58
59
  ...headers
59
60
  },
61
+ onRedirect: gate.onRedirect,
60
62
  ...guard
61
63
  });
62
64
  } catch (error) {
@@ -223,7 +223,8 @@ export class SessionContext {
223
223
  const fetchOpts = {
224
224
  method,
225
225
  headers: requestHeaders,
226
- redirect: 'follow'
226
+ redirect: 'follow',
227
+ onRedirect: gate.onRedirect
227
228
  };
228
229
 
229
230
  if (body) {
@@ -384,7 +384,8 @@ export class MapSiteTool {
384
384
  try {
385
385
  const response = await safeFetch(url, {
386
386
  signal: controller.signal,
387
- headers: gate.headers
387
+ headers: gate.headers,
388
+ onRedirect: gate.onRedirect
388
389
  });
389
390
  clearTimeout(timeoutId);
390
391
  return response;
@@ -147,6 +147,7 @@ export async function fetchAndParse(url, options = {}) {
147
147
  ...gate.headers,
148
148
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
149
149
  },
150
+ onRedirect: gate.onRedirect,
150
151
  signal: AbortSignal.timeout(timeoutMs)
151
152
  });
152
153
 
@@ -176,7 +176,7 @@ export class ExtractContentTool {
176
176
  enableImages: options.preserveImageInfo,
177
177
  captureScreenshot: false
178
178
  }
179
- });
179
+ }, gate);
180
180
 
181
181
  if (!browserResult.success) {
182
182
  throw new Error(`Browser processing failed: ${browserResult.error}`);
@@ -188,6 +188,7 @@ export class ExtractContentTool {
188
188
  // Simple HTTP fetch
189
189
  const response = await safeFetch(url, {
190
190
  headers: { ...gate.headers },
191
+ onRedirect: gate.onRedirect,
191
192
  signal: AbortSignal.timeout(15000)
192
193
  });
193
194