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
@@ -2,38 +2,47 @@
2
2
  * Snapshot — the accessibility-style page tree that hands an agent stable
3
3
  * element refs (`@e1`, `@e2`, …) to act on instead of guessed CSS selectors.
4
4
  *
5
- * Why ours and not Playwright's: 1.62's `locator.ariaSnapshot()` emits YAML
6
- * with no element refs at all, and `page._snapshotForAI()` is private API we
7
- * will not depend on. The walk below is injected by us and returns the tree
8
- * and the refs from one pass.
5
+ * TWO SOURCES, one public format:
9
6
  *
10
- * A REF LIVES IN TWO PLACES, and the halves do different jobs:
7
+ * 1. `page.ariaSnapshot({ mode: 'ai' })` (Playwright ≥ 1.59; verified on
8
+ * 1.62.1, Chromium and Camoufox). It descends open shadow roots and
9
+ * iframes, and tags each visible node `[ref=e5]` (`[ref=f1e3]` in a
10
+ * frame) that the public `aria-ref=` selector engine resolves — shadow
11
+ * and frame refs included. Its YAML is translated into our tree below.
12
+ * 2. The injected walk (snapshotScript), kept as the fallback for a page
13
+ * where the native call throws. It stamps `data-cf-ref="e1"` on the
14
+ * elements it refs and sees neither shadow roots nor frames.
11
15
  *
12
- * 1. In the page — the walk stamps `data-cf-ref="e1"` onto every element it
13
- * refs, so a ref resolves to an ordinary CSS selector,
14
- * `[data-cf-ref="e1"]`. That is what makes refs work with every existing
15
- * action path for free, including the stealth human-behaviour code that
16
- * takes a raw selector string.
17
- * 2. In Node — a WeakMap<Page, state> cleared on every main-frame
18
- * navigation. This is the half that DETECTS staleness. Without it a ref
19
- * from a previous page would merely fail to match, and the caller would
20
- * be told "selector not found" instead of "take a new snapshot".
16
+ * The result's `source` ('aria' | 'walk') says which one described the page.
21
17
  *
22
- * So a stale ref fails loudly, with the reason and the fix (StaleRefError),
23
- * and never silently hits the wrong element.
18
+ * Refs are OURS, not Playwright's: `@e1…@eN`, contiguous, in document order
19
+ * across the page, its shadow roots and its frames. Playwright numbers every
20
+ * visible node (the body is usually its e1), so its ids have gaps and a page's
21
+ * one button would be e2; each of our refs instead maps to the selector that
22
+ * finds its element — `aria-ref=f1e3` natively, `[data-cf-ref="e1"]` from the
23
+ * walk. A resolved selector is therefore NOT always CSS: a consumer must hand
24
+ * it to page.locator()/waitForSelector(), never to document.querySelector().
25
+ *
26
+ * STALENESS lives in Node — a WeakMap<Page, state> cleared on every main-frame
27
+ * navigation. Without it a ref from a previous page would merely fail to
28
+ * match, and the caller would be told "selector not found" instead of "take a
29
+ * new snapshot". So a stale ref fails loudly, with the reason and the fix
30
+ * (StaleRefError), and never silently hits the wrong element.
24
31
  *
25
32
  * Not to be confused with src/core/SnapshotManager.js, which is change-
26
33
  * tracking history.
27
34
  */
28
35
 
29
36
  import { randomUUID } from 'node:crypto';
37
+ import { settlePage } from './settle.js';
30
38
 
31
39
  export const REF_ATTRIBUTE = 'data-cf-ref';
32
40
  export const DEFAULT_MAX_NODES = 200;
33
41
  export const MAX_NODES_LIMIT = 1000;
34
42
 
35
- // Walks of one snapshot call, including the retry when the page navigates
36
- // mid-walk. Two: one retry is enough for a page that settles, and a page
43
+ // Captures of one snapshot call, including the retry when the page navigates
44
+ // mid-capture (a fallback from the native snapshot to the walk is not one of
45
+ // them). Two: one retry is enough for a page that settles, and a page
37
46
  // navigating repeatedly is not one a snapshot can describe.
38
47
  const MAX_WALK_ATTEMPTS = 2;
39
48
 
@@ -44,6 +53,26 @@ const MAX_NAME_LENGTH = 120;
44
53
 
45
54
  const REF_PATTERN = /^@e[1-9]\d*$/;
46
55
 
56
+ // Shared by both sources: the walk gets them as arguments, the translation of
57
+ // the native snapshot reads them directly.
58
+ const INTERACTIVE_ROLES = [
59
+ 'button', 'link', 'checkbox', 'radio', 'textbox', 'combobox', 'menuitem',
60
+ 'menuitemcheckbox', 'menuitemradio', 'tab', 'switch', 'option', 'searchbox',
61
+ 'slider', 'spinbutton'
62
+ ];
63
+ const STRUCTURAL_ROLES = [
64
+ 'heading', 'banner', 'navigation', 'contentinfo', 'complementary', 'main',
65
+ 'form', 'region', 'search', 'iframe'
66
+ ];
67
+ const INTERACTIVE_ROLE_SET = new Set(INTERACTIVE_ROLES);
68
+ const STRUCTURAL_ROLE_SET = new Set(STRUCTURAL_ROLES);
69
+
70
+ // An editing host (`contenteditable`) has no ARIA role, so the native snapshot
71
+ // shows it as a plain `generic`. Only a page that has one pays for the per-node
72
+ // check that finds it, and each check gets this long before it counts as "no".
73
+ const EDITABLE_SELECTOR = '[contenteditable]:not([contenteditable="false"])';
74
+ const EDITABLE_CHECK_TIMEOUT_MS = 1000;
75
+
47
76
  /** Thrown when a ref cannot be resolved against the page's current snapshot. */
48
77
  export class StaleRefError extends Error {
49
78
  constructor(message) {
@@ -66,17 +95,12 @@ export function isRef(selector) {
66
95
  * JavaScript, so it has nothing to do with the ALLOW_JAVASCRIPT_EXECUTION flag
67
96
  * that gates the `executeJavaScript` action. Do not put it behind that flag.
68
97
  */
69
- function snapshotScript({ refAttribute, interactiveOnly, maxNodes, maxIndent, maxNameLength }) {
98
+ function snapshotScript({
99
+ refAttribute, interactiveOnly, maxNodes, maxIndent, maxNameLength, interactiveRoles, structuralRoles
100
+ }) {
70
101
  const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'head']);
71
- const INTERACTIVE_ROLES = new Set([
72
- 'button', 'link', 'checkbox', 'radio', 'textbox', 'combobox', 'menuitem',
73
- 'menuitemcheckbox', 'menuitemradio', 'tab', 'switch', 'option', 'searchbox',
74
- 'slider', 'spinbutton'
75
- ]);
76
- const STRUCTURAL_ROLES = new Set([
77
- 'heading', 'banner', 'navigation', 'contentinfo', 'complementary', 'main',
78
- 'form', 'region', 'search'
79
- ]);
102
+ const INTERACTIVE_ROLES = new Set(interactiveRoles);
103
+ const STRUCTURAL_ROLES = new Set(structuralRoles);
80
104
  const LANDMARK_TAGS = {
81
105
  main: 'main', nav: 'navigation', header: 'banner', footer: 'contentinfo',
82
106
  aside: 'complementary', form: 'form'
@@ -214,6 +238,148 @@ function snapshotScript({ refAttribute, interactiveOnly, maxNodes, maxIndent, ma
214
238
  return { title: clean(document.title), lines, refs, truncated };
215
239
  }
216
240
 
241
+ // Same name rules as the walk's clean()/truncate(): one line, no double quotes.
242
+ const cleanName = (value) => (value || '').replace(/\s+/g, ' ').trim().replace(/"/g, "'");
243
+ const truncateName = (value) =>
244
+ (value.length > MAX_NAME_LENGTH ? `${value.slice(0, MAX_NAME_LENGTH - 1)}…` : value);
245
+
246
+ /**
247
+ * One line of `page.ariaSnapshot({ mode: 'ai' })`:
248
+ *
249
+ * - role "name" [checked] [level=1] [ref=e5] [cursor=pointer]: text
250
+ *
251
+ * The name is JSON-quoted, or printed bare when it is itself `/…/`. A key YAML
252
+ * would misread (a name holding `: ` or ` #`) comes wrapped in single quotes
253
+ * with `''` for `'`; a text value that needs it is double-quoted. Property
254
+ * lines (`- /url: …`) and bare text (`- text: …`) are not nodes and return null.
255
+ */
256
+ function parseAriaLine(line) {
257
+ const item = /^( *)- (.*)$/.exec(line);
258
+ if (!item) return null;
259
+
260
+ let key = item[2];
261
+ let rest = '';
262
+ const quoted = /^'((?:[^']|'')*)'(.*)$/.exec(key);
263
+ if (quoted) {
264
+ key = quoted[1].replace(/''/g, "'");
265
+ rest = quoted[2];
266
+ } else {
267
+ // An unquoted key never contains `:` followed by a space or the end.
268
+ const colon = key.search(/:(\s|$)/);
269
+ if (colon >= 0) [key, rest] = [key.slice(0, colon), key.slice(colon)];
270
+ }
271
+
272
+ const parts = /^([a-z]+)(?: ("(?:[^"\\]|\\.)*"|\/.*\/(?= \[|$)))?(.*)$/.exec(key);
273
+ if (!parts || parts[1] === 'text') return null;
274
+
275
+ let text = rest.replace(/^:\s?/, '');
276
+ if (text.startsWith('"')) {
277
+ try {
278
+ text = JSON.parse(text.replace(/\\x([0-9a-f]{2})/gi, '\\u00$1'));
279
+ } catch {
280
+ // keep the raw value; it is only ever a fallback name
281
+ }
282
+ }
283
+
284
+ const ref = /\[ref=([^\]\s]+)\]/.exec(parts[3]);
285
+ return {
286
+ indent: item[1].length,
287
+ role: parts[1],
288
+ name: parts[2] ? (parts[2].startsWith('"') ? JSON.parse(parts[2]) : parts[2]) : '',
289
+ text,
290
+ ref: ref ? ref[1] : null,
291
+ pointer: parts[3].includes('[cursor=pointer]')
292
+ };
293
+ }
294
+
295
+ /** Playwright refs of the `generic` nodes that are contenteditable editing hosts. */
296
+ async function editingHosts(page, nodes) {
297
+ const found = await Promise.all(page.frames().map((frame) =>
298
+ frame.locator(EDITABLE_SELECTOR).count().catch(() => 0)));
299
+ if (!found.some(Boolean)) return new Set();
300
+
301
+ const generics = nodes.filter((node) => node.role === 'generic');
302
+ const hosts = await Promise.all(generics.map((node) =>
303
+ page.locator(`aria-ref=${node.ref}`)
304
+ .evaluate(
305
+ (el) => el.isContentEditable && !(el.parentElement && el.parentElement.isContentEditable),
306
+ undefined,
307
+ { timeout: EDITABLE_CHECK_TIMEOUT_MS }
308
+ )
309
+ .catch(() => false)));
310
+ return new Set(generics.filter((_, i) => hosts[i]).map((node) => node.ref));
311
+ }
312
+
313
+ /**
314
+ * Describe the page with Playwright's native snapshot, in the walk's format.
315
+ *
316
+ * A node is interactive by role; or, having no such role, when it is an
317
+ * editing host (printed as the textbox it is typed into like) or the outermost
318
+ * node with a pointer cursor — the mark an onclick `<div>` usually carries.
319
+ * Nodes without a Playwright ref are left out: those are the invisible ones
320
+ * (display:none, zero-size, a closed <select>'s options). A `tabindex` or
321
+ * `onclick` element with neither a role nor a pointer cursor is not found —
322
+ * the YAML does not carry those attributes.
323
+ */
324
+ async function ariaCapture(page, { interactiveOnly, maxNodes, timeout }) {
325
+ const yaml = await page.ariaSnapshot({ mode: 'ai', timeout });
326
+ const nodes = yaml.split('\n').map(parseAriaLine).filter((node) => node && node.ref);
327
+ const editable = await editingHosts(page, nodes);
328
+
329
+ const lines = [];
330
+ const refs = [];
331
+ let truncated = false;
332
+ // The emitted ancestors of the current node. Indentation follows EMITTED
333
+ // nesting, as in the walk, so it is this stack's depth, not the YAML's.
334
+ const emitted = [];
335
+
336
+ for (const node of nodes) {
337
+ while (emitted.length && emitted[emitted.length - 1].indent >= node.indent) emitted.pop();
338
+
339
+ const isHost = editable.has(node.ref);
340
+ const interactive = INTERACTIVE_ROLE_SET.has(node.role) || isHost ||
341
+ (node.pointer && !emitted.some((ancestor) => ancestor.interactive));
342
+ const structural = !interactive && !interactiveOnly && STRUCTURAL_ROLE_SET.has(node.role);
343
+ if (!interactive && !structural) continue;
344
+
345
+ if (lines.length >= maxNodes) {
346
+ truncated = true;
347
+ break;
348
+ }
349
+ const role = isHost ? 'textbox' : node.role;
350
+ // A control with no accessible name is captioned by its text, as the walk
351
+ // does; a landmark is named by its label only.
352
+ const name = truncateName(cleanName(node.name || (interactive ? node.text : '')));
353
+ let ref = '';
354
+ if (interactive) {
355
+ const id = `e${refs.length + 1}`;
356
+ refs.push({ id, role, name, selector: `aria-ref=${node.ref}` });
357
+ ref = `@${id} `;
358
+ }
359
+ lines.push(`${' '.repeat(Math.min(emitted.length + 1, MAX_INDENT))}${ref}[${role}]${name ? ` "${name}"` : ''}`);
360
+ emitted.push({ indent: node.indent, interactive });
361
+ }
362
+
363
+ return { title: cleanName(await page.title()), lines, refs, truncated };
364
+ }
365
+
366
+ /** The injected walk, with each ref's selector pointing at the stamp it left. */
367
+ async function walkCapture(page, { interactiveOnly, maxNodes }) {
368
+ const walk = await page.evaluate(snapshotScript, {
369
+ refAttribute: REF_ATTRIBUTE,
370
+ interactiveOnly,
371
+ maxNodes,
372
+ maxIndent: MAX_INDENT,
373
+ maxNameLength: MAX_NAME_LENGTH,
374
+ interactiveRoles: INTERACTIVE_ROLES,
375
+ structuralRoles: STRUCTURAL_ROLES
376
+ });
377
+ return {
378
+ ...walk,
379
+ refs: walk.refs.map(({ id, role, name }) => ({ id, role, name, selector: `[${REF_ATTRIBUTE}="${id}"]` }))
380
+ };
381
+ }
382
+
217
383
  function stateFor(page) {
218
384
  let state = pageState.get(page);
219
385
  if (!state) {
@@ -224,12 +390,15 @@ function stateFor(page) {
224
390
  }
225
391
 
226
392
  /**
227
- * Walk the page and return its tree plus the refs it assigned.
393
+ * Describe the page and return its tree plus the refs it assigned. `source`
394
+ * in the result says whether the native snapshot or the walk produced it.
228
395
  *
229
396
  * @param {import('playwright').Page} page
230
397
  * @param {object} [options]
231
398
  * @param {boolean} [options.interactiveOnly=true] — false also emits headings and landmarks, unreffed
232
399
  * @param {number} [options.maxNodes=200] — cap on emitted nodes, clamped to [1, MAX_NODES_LIMIT]
400
+ * @param {number} [options.timeout] — budget for the render wait before the capture (see settle.js),
401
+ * and for the native snapshot itself
233
402
  */
234
403
  export async function captureSnapshot(page, options = {}) {
235
404
  const interactiveOnly = options.interactiveOnly !== false;
@@ -242,27 +411,42 @@ export async function captureSnapshot(page, options = {}) {
242
411
  // refs rather than leaving them to fail as a missing selector.
243
412
  attachRefTracking(page);
244
413
 
414
+ // Walking before the page has rendered describes an empty shell. Both the
415
+ // snapshot action and browser_session's snapshot operation come through here.
416
+ const settle = await settlePage(page, { timeout: options.timeout });
417
+
245
418
  const state = stateFor(page);
246
419
  let title, lines, refs, truncated;
247
-
248
- // A navigation that commits WHILE the walk is running would otherwise leave us
249
- // holding refs for a document that has gone — the attributes were stamped on
250
- // the old page, so `@e1` would match nothing and surface as a locator timeout
251
- // instead of the named error D2 requires. `generation` moves on every
252
- // main-frame navigation, so a change across the evaluate means exactly that.
253
- // Walk the new document instead; if it navigates again, give up and leave the
254
- // refs invalidated rather than publishing a tree for a page nobody is on.
255
- for (let attempt = 0; ; attempt++) {
420
+ // The native snapshot unless this page object has none (a stub, an old
421
+ // Playwright) or it throws here; the walk is the fallback either way.
422
+ let source = typeof page.ariaSnapshot === 'function' ? 'aria' : 'walk';
423
+
424
+ // A navigation that commits WHILE the snapshot is running would otherwise
425
+ // leave us holding refs for a document that has gone, so `@e1` would match
426
+ // nothing and surface as a locator timeout instead of the named error D2
427
+ // requires. `generation` moves on every main-frame navigation, so a change
428
+ // across the capture means exactly that — whether the capture returned or
429
+ // threw because its context was destroyed. Capture the new document instead;
430
+ // if it navigates again, give up and leave the refs invalidated rather than
431
+ // publishing a tree for a page nobody is on.
432
+ for (let attempt = 0; ;) {
256
433
  const generation = state.generation;
257
- ({ title, lines, refs, truncated } = await page.evaluate(snapshotScript, {
258
- refAttribute: REF_ATTRIBUTE,
259
- interactiveOnly,
260
- maxNodes,
261
- maxIndent: MAX_INDENT,
262
- maxNameLength: MAX_NAME_LENGTH
263
- }));
264
- if (state.generation === generation) break;
265
- if (attempt >= MAX_WALK_ATTEMPTS - 1) {
434
+ const capture = source === 'aria' ? ariaCapture : walkCapture;
435
+ let result;
436
+ try {
437
+ result = await capture(page, { interactiveOnly, maxNodes, timeout: options.timeout });
438
+ } catch (error) {
439
+ if (state.generation === generation) {
440
+ if (source === 'walk') throw error;
441
+ source = 'walk'; // the native snapshot failed on a page that stayed put
442
+ continue;
443
+ }
444
+ }
445
+ if (state.generation === generation) {
446
+ ({ title, lines, refs, truncated } = result);
447
+ break;
448
+ }
449
+ if (++attempt >= MAX_WALK_ATTEMPTS) {
266
450
  clearRefs(page);
267
451
  throw new StaleRefError(
268
452
  'The page navigated while the snapshot was being taken — take a new snapshot.'
@@ -272,7 +456,7 @@ export async function captureSnapshot(page, options = {}) {
272
456
 
273
457
  const snapshotId = randomUUID().slice(0, 8);
274
458
  state.snapshotId = snapshotId;
275
- state.refs = new Map(refs.map(({ id, role, name, tag }) => [id, { role, name, tag }]));
459
+ state.refs = new Map(refs.map(({ id, role, name, selector }) => [id, { role, name, selector }]));
276
460
  state.invalidated = false;
277
461
 
278
462
  return {
@@ -283,12 +467,18 @@ export async function captureSnapshot(page, options = {}) {
283
467
  refCount: refs.length,
284
468
  nodeCount: lines.length,
285
469
  truncated,
286
- interactiveOnly
470
+ interactiveOnly,
471
+ source,
472
+ waited_ms: settle.waited_ms,
473
+ settled_by: settle.settled_by
287
474
  };
288
475
  }
289
476
 
290
477
  /**
291
- * Turn `@e1` into the CSS selector every action path already understands.
478
+ * Turn `@e1` into the selector for the element it named: `aria-ref=…` from the
479
+ * native snapshot, `[data-cf-ref="e1"]` from the walk. Either works anywhere a
480
+ * Playwright selector does (locator, waitForSelector, page.type) — not in a
481
+ * raw document.querySelector().
292
482
  * Throws StaleRefError when the ref does not belong to the page's current
293
483
  * snapshot — it never guesses.
294
484
  */
@@ -318,7 +508,7 @@ export function resolveRef(page, selector) {
318
508
  : `Unknown element ref ${selector}: the current snapshot has ${count} refs (@e1-@e${count}) — take a new snapshot.`);
319
509
  }
320
510
 
321
- return `[${REF_ATTRIBUTE}="${id}"]`;
511
+ return state.refs.get(id).selector;
322
512
  }
323
513
 
324
514
  /** Register the navigation listener that invalidates this page's refs. Idempotent. */
@@ -0,0 +1,88 @@
1
+ /**
2
+ * windowState — read a page's framework state straight off `window`, after
3
+ * its JavaScript ran (extract_embedded_state's escalated path, plan Phase 3.2).
4
+ *
5
+ * The served HTML is not always where the state ends up: Nuxt 2 ships
6
+ * __NUXT__ as an IIFE the HTML parser cannot evaluate, and YouTube's
7
+ * ytInitialData is assigned by a script. Inside the stealth render those are
8
+ * plain globals, so they are read as the page itself sees them.
9
+ *
10
+ * Engines: on Chromium page.evaluate runs in the page's main world, where the
11
+ * globals live. On camoufox it runs in an isolated world that cannot see them
12
+ * (window.__NEXT_DATA__ is undefined there), but Firefox's Xray waiver,
13
+ * `window.wrappedJSObject`, reaches the page's own window from that world
14
+ * without evaluating anything in the page's — measured against camoufox
15
+ * 0.1.19, 2026-09-29. So the read works on both engines and the page never
16
+ * sees a script of ours run in its world.
17
+ *
18
+ * Each value is serialised INSIDE the page with its own try/catch: a cyclic or
19
+ * otherwise unserialisable global is skipped with a warning instead of
20
+ * failing the read of the others.
21
+ */
22
+
23
+ /** The globals read, in this order. Only the ones present come back. */
24
+ export const WINDOW_STATE_GLOBALS = Object.freeze([
25
+ '__NEXT_DATA__',
26
+ '__NUXT__',
27
+ '__remixContext',
28
+ 'ytInitialData',
29
+ 'ytInitialPlayerResponse',
30
+ '__INITIAL_STATE__',
31
+ '__PRELOADED_STATE__',
32
+ '__APOLLO_STATE__',
33
+ '__TGT_DATA__',
34
+ '__PWS_DATA__'
35
+ ]);
36
+
37
+ /** Said wherever these values are reported: they are not what the server sent. */
38
+ export const WINDOW_STATE_NOTE = 'read from window after JavaScript ran, not from the served HTML';
39
+
40
+ /**
41
+ * Runs in the page. `{ json }` for a global that serialised, `{ error }` for
42
+ * one that did not; absent (null/undefined) globals are left out.
43
+ * @param {string[]} names
44
+ */
45
+ export function serialiseWindowGlobals(names) {
46
+ const target = window.wrappedJSObject || window;
47
+ const out = {};
48
+ for (const name of names) {
49
+ try {
50
+ const value = target[name];
51
+ if (value === undefined || value === null) continue;
52
+ const json = JSON.stringify(value);
53
+ out[name] = typeof json === 'string' ? { json } : { error: 'it has no JSON form' };
54
+ } catch (error) {
55
+ out[name] = { error: String((error && error.message) || error) };
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * @param {{ evaluate: Function }} page a Playwright page
63
+ * @returns {Promise<{ state: Record<string, unknown>, warnings: string[] }>}
64
+ */
65
+ export async function readWindowState(page) {
66
+ const warnings = [];
67
+ let raw;
68
+ try {
69
+ raw = await page.evaluate(serialiseWindowGlobals, [...WINDOW_STATE_GLOBALS]);
70
+ } catch (error) {
71
+ warnings.push(`window_state could not be read: ${String(error.message).split('\n')[0]}`);
72
+ return { state: {}, warnings };
73
+ }
74
+ const state = {};
75
+ for (const [name, entry] of Object.entries(raw || {})) {
76
+ if (entry && typeof entry.json === 'string') {
77
+ try {
78
+ state[name] = JSON.parse(entry.json);
79
+ continue;
80
+ } catch (error) {
81
+ warnings.push(`window_state: ${name} skipped - its JSON did not parse (${error.message})`);
82
+ continue;
83
+ }
84
+ }
85
+ warnings.push(`window_state: ${name} skipped - not serialisable (${entry?.error ?? 'unknown reason'})`);
86
+ }
87
+ return { state, warnings };
88
+ }
@@ -8,9 +8,10 @@ import { LinkAnalyzer } from '../analysis/LinkAnalyzer.js';
8
8
  import { normalizeUrl, extractLinks, isValidUrl } from '../../utils/urlNormalizer.js';
9
9
  import { Logger } from '../../utils/Logger.js';
10
10
  import { safeFetch } from '../../utils/ssrfGuard.js';
11
- import { robotsPreflight, RobotsDisallowedError } from '../../utils/robotsGate.js';
11
+ import { robotsPreflight, redirectGate, RobotsDisallowedError } from '../../utils/robotsGate.js';
12
12
  import { throttleHost } from '../../utils/hostRateLimiter.js';
13
13
  import { CRAWLFORGE_USER_AGENT, identityHeaders } from '../../utils/fetchIdentity.js';
14
+ import { signRequestHeaders } from '../../utils/webBotAuth.js';
14
15
  import { pageTitle } from '../../utils/pageTitle.js';
15
16
  import { extractMainContent, isThinMainContent } from '../../tools/scrape/_mainContent.js';
16
17
 
@@ -337,7 +338,10 @@ export class BFSCrawler {
337
338
  'Accept-Language': 'en-US,en;q=0.5',
338
339
  'Accept-Encoding': 'gzip, deflate',
339
340
  'Connection': 'keep-alive',
340
- 'Upgrade-Insecure-Requests': '1'
341
+ 'Upgrade-Insecure-Requests': '1',
342
+ // Web Bot Auth, as on every other fetching tool. crawl_deep pages went
343
+ // out with the identity alone: a claim with no proof.
344
+ ...signRequestHeaders(url)
341
345
  };
342
346
 
343
347
  let headers = { ...defaultHeaders, ...domainRules.customHeaders };
@@ -357,7 +361,12 @@ export class BFSCrawler {
357
361
 
358
362
  const response = await safeFetch(url, {
359
363
  signal: controller.signal,
360
- headers
364
+ headers,
365
+ onRedirect: redirectGate(url, {
366
+ respectRobots: this.respectRobots,
367
+ userAgent: this.userAgent,
368
+ tool: 'crawl_deep'
369
+ })
361
370
  });
362
371
 
363
372
  // Capture any cookies the server sets during the crawl
@@ -9,7 +9,7 @@ import { z } from 'zod';
9
9
  import StealthBrowserManager from '../StealthBrowserManager.js';
10
10
  import HumanBehaviorSimulator from '../../utils/HumanBehaviorSimulator.js';
11
11
  import LocalizationManager from '../LocalizationManager.js';
12
- import { safeGoto } from '../../utils/ssrfGuard.js';
12
+ import { safeGoto, assertNavigationAllowed } from '../../utils/ssrfGuard.js';
13
13
 
14
14
  const BrowserProcessorSchema = z.object({
15
15
  url: z.string().url(),
@@ -156,9 +156,12 @@ export class BrowserProcessor {
156
156
  * @param {Object} params - Processing parameters
157
157
  * @param {string} params.url - URL to process
158
158
  * @param {Object} params.options - Browser options
159
+ * @param {{ onRedirect?: (to: string) => Promise<void> }} [gate] - what the
160
+ * calling tool's `preflightFetch` returned, so a redirect is gated the way
161
+ * the URL was. A second argument because the schema strips it from `params`.
159
162
  * @returns {Promise<Object>} - Processing result with rendered content
160
163
  */
161
- async processURL(params) {
164
+ async processURL(params, gate) {
162
165
  const startTime = Date.now();
163
166
 
164
167
  try {
@@ -178,7 +181,7 @@ export class BrowserProcessor {
178
181
 
179
182
  try {
180
183
  // Navigate and wait for content
181
- const navigationResult = await this.navigateAndWait(page, url, processingOptions);
184
+ const navigationResult = await this.navigateAndWait(page, url, processingOptions, gate?.onRedirect);
182
185
 
183
186
  // Extract content and metadata
184
187
  const contentResult = await this.extractContent(page, processingOptions);
@@ -635,18 +638,23 @@ export class BrowserProcessor {
635
638
  * @param {Page} page - Playwright page
636
639
  * @param {string} url - URL to navigate to
637
640
  * @param {Object} options - Navigation options
641
+ * @param {(to: string) => Promise<void>} [onRedirect] - the calling tool's
642
+ * redirect gate (robotsGate.js `redirectGate`)
638
643
  * @returns {Promise<Object>} - Navigation result
639
644
  */
640
- async navigateAndWait(page, url, options) {
645
+ async navigateAndWait(page, url, options, onRedirect) {
641
646
  const startTime = Date.now();
642
647
 
643
648
  // Navigate to URL. safeGoto adds the SSRF/DNS-rebinding guard on top of the
644
- // robots/blocklist preflight the calling tool already ran, and re-checks the
645
- // landed URL so a redirect can't carry us into a private/metadata range.
649
+ // robots/blocklist preflight the calling tool already ran, and re-checks
650
+ // where the navigation went, so a redirect can't carry us into a
651
+ // private/metadata range or past the gate the URL itself went through.
646
652
  await safeGoto(page, url, {
647
653
  waitUntil: 'domcontentloaded',
648
- timeout: 30000
654
+ timeout: 30000,
655
+ onRedirect
649
656
  });
657
+ const landedUrl = page.url();
650
658
 
651
659
  // Wait for specific selector if provided
652
660
  if (options.waitForSelector) {
@@ -687,6 +695,10 @@ export class BrowserProcessor {
687
695
  }
688
696
  }
689
697
 
698
+ // The waits above are time in which a client-side redirect can move the
699
+ // page on; the document about to be read is the one that has to pass.
700
+ await assertNavigationAllowed(page, landedUrl, null, onRedirect);
701
+
690
702
  return {
691
703
  navigationTime: Date.now() - startTime
692
704
  };
@@ -10,6 +10,7 @@ import path from 'path';
10
10
  import { safeFetch } from '../../utils/ssrfGuard.js';
11
11
  import { config } from '../../constants/config.js';
12
12
  import { identityHeaders } from '../../utils/fetchIdentity.js';
13
+ import { signRequestHeaders } from '../../utils/webBotAuth.js';
13
14
 
14
15
  const PDFProcessorSchema = z.object({
15
16
  // A Buffer is the 'buffer' source: process_document hands over a body it
@@ -85,9 +86,11 @@ export class PDFProcessor {
85
86
  * @param {string} params.source - PDF source (URL, file path, or buffer)
86
87
  * @param {string} params.sourceType - Type of source ('url', 'file', 'buffer')
87
88
  * @param {Object} params.options - Processing options
89
+ * @param {{ headers: Record<string,string>, onRedirect: Function }} [gate] -
90
+ * what `preflightFetch` returned for a URL source
88
91
  * @returns {Promise<Object>} - Processing result with text and metadata
89
92
  */
90
- async processPDF(params) {
93
+ async processPDF(params, gate) {
91
94
  const startTime = Date.now();
92
95
 
93
96
  try {
@@ -106,7 +109,7 @@ export class PDFProcessor {
106
109
  // Get PDF buffer based on source type
107
110
  let pdfBuffer;
108
111
  try {
109
- pdfBuffer = await this.getPDFBuffer(source, sourceType);
112
+ pdfBuffer = await this.getPDFBuffer(source, sourceType, gate);
110
113
  } catch (error) {
111
114
  result.error = `Failed to load PDF: ${error.message}`;
112
115
  result.processingTime = Date.now() - startTime;
@@ -214,12 +217,13 @@ export class PDFProcessor {
214
217
  * Get PDF buffer from various sources
215
218
  * @param {string} source - PDF source
216
219
  * @param {string} sourceType - Source type
220
+ * @param {Object} [gate] - see processPDF
217
221
  * @returns {Promise<Buffer>} - PDF buffer
218
222
  */
219
- async getPDFBuffer(source, sourceType) {
223
+ async getPDFBuffer(source, sourceType, gate) {
220
224
  switch (sourceType) {
221
225
  case 'url':
222
- return await this.downloadPDFFromURL(source);
226
+ return await this.downloadPDFFromURL(source, gate);
223
227
  case 'file':
224
228
  return await this.readPDFFromFile(source);
225
229
  case 'buffer':
@@ -232,15 +236,20 @@ export class PDFProcessor {
232
236
  /**
233
237
  * Download PDF from URL
234
238
  * @param {string} url - PDF URL
239
+ * @param {Object} [gate] - see processPDF
235
240
  * @returns {Promise<Buffer>} - PDF buffer
236
241
  */
237
- async downloadPDFFromURL(url) {
242
+ async downloadPDFFromURL(url, gate) {
238
243
  try {
239
244
  // `timeout` is not a fetch init option — undici/Node fetch silently
240
245
  // ignores unknown properties, so only `signal` actually enforces a
241
246
  // deadline here.
247
+ // The gate's headers are the identity the URL was cleared under plus the
248
+ // Web Bot Auth signature; this download used to discard them and go out
249
+ // unsigned. With no gate, sign as ourselves.
242
250
  const response = await safeFetch(url, {
243
- headers: identityHeaders(),
251
+ headers: gate?.headers ?? { ...identityHeaders(), ...signRequestHeaders(url) },
252
+ onRedirect: gate?.onRedirect,
244
253
  signal: AbortSignal.timeout(30000)
245
254
  });
246
255