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
@@ -7,9 +7,11 @@ import { z } from 'zod';
7
7
  import BrowserProcessor from './processing/BrowserProcessor.js';
8
8
  import { EventEmitter } from 'events';
9
9
  import { createHash } from 'node:crypto';
10
- import { assertUrlAllowed } from '../utils/ssrfGuard.js';
11
- import { browserPreflight } from '../utils/robotsGate.js';
10
+ import { assertUrlAllowed, assertNavigationAllowed } from '../utils/ssrfGuard.js';
11
+ import { browserPreflight, redirectGate, pageMoveGate, gateRefusalCode } from '../utils/robotsGate.js';
12
12
  import { isRef, resolveRef, captureSnapshot } from './browser/snapshot.js';
13
+ import { settlePage } from './browser/settle.js';
14
+ import { handleConsent } from './browser/consent.js';
13
15
 
14
16
  // executeJavaScript hardening limits (only relevant when the deploy-time flag
15
17
  // ALLOW_JAVASCRIPT_EXECUTION=true is set; JS execution stays off by default).
@@ -303,17 +305,23 @@ export class ActionExecutor extends EventEmitter {
303
305
  page = await this.initializePage(url, browserOptions);
304
306
  executionContext.page = page;
305
307
 
308
+ // Cookie/consent wall on the landing page (browserOptions.consent,
309
+ // off unless asked for). A `navigate` action does the same for its
310
+ // page — see executeNavigateAction.
311
+ if (browserOptions.consent && browserOptions.consent !== 'off') {
312
+ executionContext.consent = await handleConsent(page, browserOptions.consent);
313
+ }
314
+
306
315
  // Execute chain with potential retries
307
316
  chainResult = await this.executeChainWithRetries(executionContext);
308
317
 
309
318
  // Capture the LIVE post-action page state before the page is closed,
310
319
  // so callers can extract final content reflecting all actions
311
320
  // (instead of re-fetching the original URL).
312
- try {
313
- executionContext.finalHtml = await page.content();
314
- executionContext.finalUrl = page.url();
315
- } catch (captureErr) {
316
- this.log('warn', 'Failed to capture final page content: ' + captureErr.message);
321
+ const finalState = await this.captureState(page, browserOptions, 'final page content');
322
+ if (finalState) {
323
+ executionContext.finalHtml = finalState.html;
324
+ executionContext.finalUrl = finalState.url;
317
325
  }
318
326
 
319
327
  this.stats.successfulChains++;
@@ -327,6 +335,9 @@ export class ActionExecutor extends EventEmitter {
327
335
  // Capture error screenshot if enabled
328
336
  if ((validatedChain.screenshotOnError ?? this.enableScreenshotOnError) && page) {
329
337
  try {
338
+ // Not of a page the gate has not passed: the chain may have failed
339
+ // for another reason after the page moved.
340
+ await this.assertPageAllowed(page, browserOptions);
330
341
  const errorScreenshot = await this.captureScreenshot(page, {
331
342
  fullPage: true,
332
343
  description: 'Error screenshot'
@@ -408,6 +419,7 @@ export class ActionExecutor extends EventEmitter {
408
419
  finalUrl: executionContext.finalUrl || url,
409
420
  finalHtml: executionContext.finalHtml,
410
421
  navigationStatus: executionContext.page?.__crawlforgeNavigation?.status ?? null,
422
+ consent: executionContext.consent,
411
423
  executionTime: Date.now() - startTime,
412
424
  results: executionContext.results,
413
425
  attempt: executionContext.attempt,
@@ -463,7 +475,9 @@ export class ActionExecutor extends EventEmitter {
463
475
  // (form half-filled, menu open, possibly a different URL) is not a
464
476
  // retry. Reload the starting URL so every attempt begins where the
465
477
  // first one did.
466
- await this.navigateToUrl(page, executionContext.url);
478
+ await this.navigateToUrl(page, executionContext.url, {
479
+ browserOptions: executionContext.browserOptions
480
+ });
467
481
  }
468
482
 
469
483
  // Execute actions in sequence
@@ -476,7 +490,7 @@ export class ActionExecutor extends EventEmitter {
476
490
  ...chain.actions[i],
477
491
  timeout: Math.min(chain.actions[i].timeout || chain.timeout, chain.timeout)
478
492
  };
479
- const actionResult = await this.executeActionInternal(page, action, executionContext);
493
+ const actionResult = await this.executeGatedAction(page, action, executionContext);
480
494
 
481
495
  executionContext.results.push(actionResult);
482
496
  this.stats.totalActions++;
@@ -500,7 +514,7 @@ export class ActionExecutor extends EventEmitter {
500
514
 
501
515
  // Handle action failure
502
516
  if (!action.continueOnError && !chain.continueOnError) {
503
- throw new Error('Action failed: ' + actionResult.error);
517
+ throw Object.assign(new Error('Action failed: ' + actionResult.error), { code: actionResult.errorCode });
504
518
  }
505
519
  }
506
520
 
@@ -509,18 +523,16 @@ export class ActionExecutor extends EventEmitter {
509
523
  // the ALLOW_JAVASCRIPT_EXECUTION flag and doesn't add phantom
510
524
  // actions to the chain's failure/success counts.
511
525
  if (action.captureAfter) {
512
- try {
513
- const capturedHtml = await page.content();
526
+ const captured = await this.captureState(page, executionContext.browserOptions, 'intermediate state');
527
+ if (captured) {
514
528
  executionContext.capturedStates = executionContext.capturedStates || [];
515
529
  executionContext.capturedStates.push({
516
530
  afterActionIndex: i,
517
531
  afterActionId: actionResult.id,
518
- url: page.url(),
519
- html: capturedHtml,
532
+ url: captured.url,
533
+ html: captured.html,
520
534
  timestamp: Date.now()
521
535
  });
522
- } catch (captureErr) {
523
- this.log('warn', 'Failed to capture intermediate state: ' + captureErr.message);
524
536
  }
525
537
  }
526
538
 
@@ -541,7 +553,12 @@ export class ActionExecutor extends EventEmitter {
541
553
  attempt: attempt + 1, success: false, error: error.message, results: [...executionContext.results]
542
554
  });
543
555
  this.log('warn', 'Chain execution attempt ' + (attempt + 1) + ' failed: ' + error.message);
544
-
556
+
557
+ // A refusal is the gate's answer, not a fault. Replaying the chain
558
+ // asks the same question again, after sending the site every request
559
+ // that led up to it a second time.
560
+ if (gateRefusalCode(error)) break;
561
+
545
562
  if (attempt < chain.retryChain) {
546
563
  // Wait before retry
547
564
  await this.delay(1000 * Math.pow(2, attempt));
@@ -567,8 +584,12 @@ export class ActionExecutor extends EventEmitter {
567
584
  *
568
585
  * Gating comes for free and must not be duplicated by callers: a `navigate`
569
586
  * action goes through executeNavigateAction, which re-runs the SSRF guard and
570
- * the blocklist/robots gate on every hop (see the comment at its definition).
571
- * `browserOptions.respectRobots` is what reaches that gate.
587
+ * the blocklist/robots gate on every hop (see the comment at its definition),
588
+ * and every action is run through executeGatedAction, which holds the page to
589
+ * the same gate wherever a click or a script has taken it. A refusal there
590
+ * throws rather than failing one action: the page has been emptied, and
591
+ * nothing after it could run. `browserOptions.respectRobots` is what reaches
592
+ * both gates.
572
593
  *
573
594
  * No `finalHtml` here, unlike the chain: the page is still open afterwards, so
574
595
  * content is read from it when it is asked for rather than on every call.
@@ -604,7 +625,7 @@ export class ActionExecutor extends EventEmitter {
604
625
  // per-call timeout is applied as the default for actions that set none.
605
626
  const action = timeout && !actions[i].timeout ? { ...actions[i], timeout } : actions[i];
606
627
 
607
- const actionResult = await this.executeActionInternal(page, action, executionContext);
628
+ const actionResult = await this.executeGatedAction(page, action, executionContext);
608
629
  executionContext.results.push(actionResult);
609
630
  this.stats.totalActions++;
610
631
 
@@ -628,17 +649,15 @@ export class ActionExecutor extends EventEmitter {
628
649
  }
629
650
 
630
651
  if (action.captureAfter) {
631
- try {
632
- const capturedHtml = await page.content();
652
+ const captured = await this.captureState(page, browserOptions, 'intermediate state');
653
+ if (captured) {
633
654
  executionContext.capturedStates.push({
634
655
  afterActionIndex: i,
635
656
  afterActionId: actionResult.id,
636
- url: page.url(),
637
- html: capturedHtml,
657
+ url: captured.url,
658
+ html: captured.html,
638
659
  timestamp: Date.now()
639
660
  });
640
- } catch (captureErr) {
641
- this.log('warn', 'Failed to capture intermediate state: ' + captureErr.message);
642
661
  }
643
662
  }
644
663
 
@@ -669,6 +688,88 @@ export class ActionExecutor extends EventEmitter {
669
688
  };
670
689
  }
671
690
 
691
+ /**
692
+ * Hold the page to the gate wherever it stands now.
693
+ *
694
+ * navigateToUrl gates a load and its redirects. After that a page moves
695
+ * without being sent anywhere: a click follows a link, a form posts, a
696
+ * script redirects, a client-side router rewrites the address. Any URL the
697
+ * page has reached since it was last checked gets what a `navigate` to it
698
+ * would: the SSRF guard, then the blocklist/robots gate with this call's
699
+ * `respectRobots`. A refusal leaves the page empty and throws.
700
+ *
701
+ * Cheap when nothing moved (one string comparison), so it is called on both
702
+ * sides of everything that touches the page.
703
+ * @param {Page} page - Playwright page
704
+ * @param {Object} [browserOptions] - Browser options (`respectRobots` override)
705
+ * @returns {Promise<void>}
706
+ * @throws {Error} SSRF_BLOCKED, BlockedHostError or RobotsDisallowedError
707
+ */
708
+ async assertPageAllowed(page, browserOptions = {}) {
709
+ const gatedUrl = page.__crawlforgeGatedUrl;
710
+ if (page.url() === gatedUrl) return;
711
+ page.__crawlforgeGatedUrl = await assertNavigationAllowed(page, gatedUrl, null, pageMoveGate(gatedUrl, {
712
+ respectRobots: browserOptions?.respectRobots,
713
+ tool: browserOptions?.tool || 'scrape_with_actions'
714
+ }));
715
+ }
716
+
717
+ /**
718
+ * Read from the page with the gate held on both sides of the read, so what
719
+ * comes back is from a document that passed it: the check after the read
720
+ * discards anything read while the page was moving somewhere it may not be.
721
+ * @param {Page} page - Playwright page
722
+ * @param {Object} [browserOptions] - Browser options (`respectRobots` override)
723
+ * @param {() => Promise<T>} read - the read to make
724
+ * @returns {Promise<T>}
725
+ * @template T
726
+ */
727
+ async readGated(page, browserOptions, read) {
728
+ await this.assertPageAllowed(page, browserOptions);
729
+ const value = await read();
730
+ await this.assertPageAllowed(page, browserOptions);
731
+ return value;
732
+ }
733
+
734
+ /**
735
+ * The page's URL and HTML, read natively (no in-page JS execution, so it
736
+ * works regardless of ALLOW_JAVASCRIPT_EXECUTION). A page that cannot be
737
+ * read yields null and a warning; a page the gate refuses throws.
738
+ * @param {Page} page - Playwright page
739
+ * @param {Object} [browserOptions] - Browser options (`respectRobots` override)
740
+ * @param {string} what - names the capture in the warning
741
+ * @returns {Promise<{ url: string, html: string }|null>}
742
+ */
743
+ async captureState(page, browserOptions, what) {
744
+ return await this.readGated(page, browserOptions, async () => {
745
+ try {
746
+ return { html: await page.content(), url: page.url() };
747
+ } catch (captureErr) {
748
+ this.log('warn', 'Failed to capture ' + what + ': ' + captureErr.message);
749
+ return null;
750
+ }
751
+ });
752
+ }
753
+
754
+ /**
755
+ * Run one action with the page held to the gate before it and after it.
756
+ * Before, because an action must not read or drive a page that moved while
757
+ * nothing was looking; after, because the action is what usually moves it,
758
+ * and its result is not kept if where it led is refused.
759
+ * @param {Page} page - Playwright page
760
+ * @param {Object} action - Action to execute
761
+ * @param {Object} executionContext - Execution context (`browserOptions` feeds the gate)
762
+ * @returns {Promise<Object>} Action result
763
+ */
764
+ async executeGatedAction(page, action, executionContext) {
765
+ const browserOptions = executionContext?.browserOptions;
766
+ // A navigate leaves the page it is on and gates the URL it is sent to.
767
+ if (action.type !== 'navigate') await this.assertPageAllowed(page, browserOptions);
768
+ const actionResult = await this.executeActionInternal(page, action, executionContext);
769
+ await this.assertPageAllowed(page, browserOptions);
770
+ return actionResult;
771
+ }
772
+
672
773
  /**
673
774
  * Execute individual action (original internal method)
674
775
  * @param {Page} page - Playwright page
@@ -743,11 +844,15 @@ export class ActionExecutor extends EventEmitter {
743
844
  return actionResult;
744
845
 
745
846
  } catch (error) {
847
+ const refusalCode = gateRefusalCode(error);
746
848
  const actionResult = {
747
849
  id: actionId,
748
850
  type: action.type,
749
851
  success: false,
750
852
  error: error.message,
853
+ // Only when the gate refused (a `navigate` to a URL it would not
854
+ // load), so the chain can tell an answer from a fault.
855
+ ...(refusalCode ? { errorCode: refusalCode } : {}),
751
856
  executionTime: Date.now() - actionStartTime,
752
857
  timestamp: Date.now(),
753
858
  description: action.description
@@ -799,14 +904,15 @@ export class ActionExecutor extends EventEmitter {
799
904
  * Resolve a caller-supplied selector.
800
905
  *
801
906
  * A selector starting with `@` names a ref a prior snapshot action assigned,
802
- * and resolves to the attribute selector that snapshot stamped on the
803
- * element; anything else is already a CSS selector. Every selector a caller
907
+ * and resolves to the Playwright selector that snapshot recorded for it
908
+ * (`aria-ref=…` from the native snapshot, `[data-cf-ref=…]` from the walk
909
+ * fallback); anything else is already a CSS selector. Every selector a caller
804
910
  * writes goes through here, so refs work in every action type without any
805
- * schema change — and, because a ref resolves to an ordinary CSS selector,
806
- * in the stealth human-behaviour paths too.
911
+ * schema change — including the stealth human-behaviour paths, which take
912
+ * Playwright selectors.
807
913
  * @param {Page} page - Playwright page
808
914
  * @param {string} selector - CSS selector, or an `@e1` snapshot ref
809
- * @returns {string} CSS selector
915
+ * @returns {string} Playwright selector
810
916
  */
811
917
  resolveSelector(page, selector) {
812
918
  return isRef(selector) ? resolveRef(page, selector) : selector;
@@ -862,23 +968,25 @@ export class ActionExecutor extends EventEmitter {
862
968
  }
863
969
 
864
970
  /**
865
- * Give a navigation started by the action that just ran a chance to commit.
971
+ * Let the page settle after the action that just ran — a navigation commit
972
+ * and load, or an SPA re-render that fires no load event at all.
866
973
  *
867
974
  * Playwright auto-waits on the element it acts on, but nothing waits on the
868
- * *document* a click or keypress may have replaced, so the next action could
869
- * run against the outgoing page. A page that never navigated is already past
870
- * this state and returns immediately; a page that doesn't settle in time is
871
- * not itself an action failure, hence the catch.
975
+ * *document* a click or keypress may have changed, so the next action could
976
+ * run against the outgoing page. settlePage waits for `load` and then a quiet
977
+ * window (no DOM mutations, no requests in flight), capped and never longer
978
+ * than what is left of the action's `timeout`; a page that doesn't settle in
979
+ * time is not itself an action failure, so it never throws.
872
980
  * @param {Page} page - Playwright page
873
- * @param {number} timeout - Timeout in ms
874
- * @returns {Promise<void>}
981
+ * @param {number} timeout - The action's timeout in ms
982
+ * @param {number} startedAt - When the action started (Date.now()); the settle
983
+ * gets only the remainder, so a slow element wait plus a busy page cannot
984
+ * outrun the backstop and turn a successful action into a timeout
985
+ * @returns {Promise<{waited_ms: number, settled_by: string}>}
875
986
  */
876
- async settleAfterInteraction(page, timeout) {
877
- try {
878
- await page.waitForLoadState('domcontentloaded', { timeout });
879
- } catch {
880
- // Ignored on purpose — see above.
881
- }
987
+ async settleAfterInteraction(page, timeout, startedAt) {
988
+ const remaining = Math.max(1, timeout - (Date.now() - startedAt));
989
+ return await settlePage(page, { timeout: remaining });
882
990
  }
883
991
 
884
992
  /**
@@ -960,6 +1068,7 @@ export class ActionExecutor extends EventEmitter {
960
1068
  * @returns {Promise<Object>} Click result
961
1069
  */
962
1070
  async executeClickAction(page, action) {
1071
+ const startedAt = Date.now();
963
1072
  const timeout = this.actionTimeout(action);
964
1073
  const locator = this.elementLocator(page, action.selector);
965
1074
 
@@ -977,7 +1086,8 @@ export class ActionExecutor extends EventEmitter {
977
1086
  button: action.button,
978
1087
  clickCount: action.clickCount,
979
1088
  delay: action.delay,
980
- force: action.force
1089
+ force: action.force,
1090
+ timeout
981
1091
  });
982
1092
  } else {
983
1093
  // Standard click behavior
@@ -998,7 +1108,7 @@ export class ActionExecutor extends EventEmitter {
998
1108
 
999
1109
  // A click can follow a link or submit a form; let that navigation commit
1000
1110
  // before the next action runs against the outgoing document.
1001
- await this.settleAfterInteraction(page, timeout);
1111
+ await this.settleAfterInteraction(page, timeout, startedAt);
1002
1112
 
1003
1113
  return {
1004
1114
  selector: action.selector,
@@ -1050,6 +1160,7 @@ export class ActionExecutor extends EventEmitter {
1050
1160
  * @returns {Promise<Object>} Press result
1051
1161
  */
1052
1162
  async executePressAction(page, action) {
1163
+ const startedAt = Date.now();
1053
1164
  const timeout = this.actionTimeout(action);
1054
1165
  const keyOptions = { timeout };
1055
1166
  if (action.modifiers?.length > 0) {
@@ -1063,7 +1174,7 @@ export class ActionExecutor extends EventEmitter {
1063
1174
  }
1064
1175
 
1065
1176
  // Enter on a form field navigates as often as a click does.
1066
- await this.settleAfterInteraction(page, timeout);
1177
+ await this.settleAfterInteraction(page, timeout, startedAt);
1067
1178
 
1068
1179
  return {
1069
1180
  key: action.key,
@@ -1161,6 +1272,7 @@ export class ActionExecutor extends EventEmitter {
1161
1272
  * @returns {Promise<Object>} Select result
1162
1273
  */
1163
1274
  async executeSelectAction(page, action) {
1275
+ const startedAt = Date.now();
1164
1276
  const timeout = this.actionTimeout(action);
1165
1277
  const values = action.values?.length ? action.values : [action.value];
1166
1278
 
@@ -1169,7 +1281,7 @@ export class ActionExecutor extends EventEmitter {
1169
1281
 
1170
1282
  // Faceted-search dropdowns commonly submit the form on change, so let any
1171
1283
  // navigation commit before the next action runs.
1172
- await this.settleAfterInteraction(page, timeout);
1284
+ await this.settleAfterInteraction(page, timeout, startedAt);
1173
1285
 
1174
1286
  return {
1175
1287
  selector: action.selector,
@@ -1214,6 +1326,7 @@ export class ActionExecutor extends EventEmitter {
1214
1326
  * @returns {Promise<Object>} Navigate result
1215
1327
  */
1216
1328
  async executeNavigateAction(page, action, executionContext) {
1329
+ const startedAt = Date.now();
1217
1330
  const timeout = this.actionTimeout(action);
1218
1331
 
1219
1332
  await assertUrlAllowed(action.url, { resolveDns: true });
@@ -1221,14 +1334,23 @@ export class ActionExecutor extends EventEmitter {
1221
1334
 
1222
1335
  await this.navigateToUrl(page, action.url, {
1223
1336
  waitUntil: action.waitUntil,
1224
- timeout
1337
+ timeout,
1338
+ browserOptions: executionContext?.browserOptions
1225
1339
  });
1226
1340
  page.__crawlforgeGateWarnings = gateWarnings;
1227
1341
 
1342
+ // Consent handling gets at most what is left of the action's deadline, so
1343
+ // it cannot push a navigation that succeeded past the backstop.
1344
+ const consentMode = executionContext?.browserOptions?.consent;
1345
+ const consent = consentMode && consentMode !== 'off'
1346
+ ? await handleConsent(page, consentMode, { timeout: Math.min(2000, timeout - (Date.now() - startedAt)) })
1347
+ : undefined;
1348
+
1228
1349
  return {
1229
1350
  url: action.url,
1230
1351
  finalUrl: page.url(),
1231
- waitUntil: action.waitUntil || 'domcontentloaded'
1352
+ waitUntil: action.waitUntil || 'domcontentloaded',
1353
+ ...(consent ? { consent } : {})
1232
1354
  };
1233
1355
  }
1234
1356
 
@@ -1314,8 +1436,8 @@ export class ActionExecutor extends EventEmitter {
1314
1436
  }
1315
1437
 
1316
1438
  /**
1317
- * Execute snapshot action - the page's interactive elements, each stamped
1318
- * with a ref later actions can target instead of a guessed CSS selector.
1439
+ * Execute snapshot action - the page's interactive elements, each carrying
1440
+ * a ref later actions can target instead of a guessed CSS selector.
1319
1441
  * @param {Page} page - Playwright page
1320
1442
  * @param {Object} action - Snapshot action
1321
1443
  * @returns {Promise<Object>} Snapshot tree with refs
@@ -1323,7 +1445,9 @@ export class ActionExecutor extends EventEmitter {
1323
1445
  async executeSnapshotAction(page, action) {
1324
1446
  return await captureSnapshot(page, {
1325
1447
  interactiveOnly: action.interactiveOnly,
1326
- maxNodes: action.maxNodes
1448
+ maxNodes: action.maxNodes,
1449
+ // The render wait before the walk stays inside the action's deadline.
1450
+ timeout: this.actionTimeout(action)
1327
1451
  });
1328
1452
  }
1329
1453
 
@@ -1364,12 +1488,14 @@ export class ActionExecutor extends EventEmitter {
1364
1488
  }
1365
1489
 
1366
1490
  /**
1367
- * Navigate an existing page to a URL under the SSRF checks every load needs.
1368
- * Used for the initial load, again before each chain retry, and by the
1369
- * `navigate` action.
1491
+ * Navigate an existing page to a URL under the SSRF checks every load needs,
1492
+ * and put wherever it is redirected to through the same blocklist/robots
1493
+ * gate the URL itself passed (assertRobotsAllowed). Used for the initial
1494
+ * load, again before each chain retry, and by the `navigate` action.
1370
1495
  * @param {Page} page - Playwright page
1371
1496
  * @param {string} url - URL to navigate to
1372
- * @param {{ waitUntil?: string, timeout?: number }} [options] - Navigation options
1497
+ * @param {{ waitUntil?: string, timeout?: number, browserOptions?: Object }} [options] -
1498
+ * Navigation options; `browserOptions` carries `respectRobots` to the redirect gate
1373
1499
  * @returns {Promise<void>}
1374
1500
  */
1375
1501
  async navigateToUrl(page, url, options = {}) {
@@ -1382,12 +1508,16 @@ export class ActionExecutor extends EventEmitter {
1382
1508
  timeout: options.timeout || 30000
1383
1509
  });
1384
1510
 
1385
- // Re-validate the landed URL: a redirect during navigation could have
1386
- // taken us into a blocked range even though the original URL was safe.
1387
- const landedUrl = page.url();
1388
- if (/^https?:\/\//i.test(landedUrl)) {
1389
- await assertUrlAllowed(landedUrl, { resolveDns: true });
1390
- }
1511
+ // Re-validate where the navigation went: a redirect could have taken us
1512
+ // into a blocked range, onto a blocklisted host or into a path robots.txt
1513
+ // disallows, even though the original URL was fine. A refusal leaves the
1514
+ // page empty, so a session that keeps it cannot read the refused document.
1515
+ const landedUrl = await assertNavigationAllowed(page, url, response, redirectGate(url, {
1516
+ respectRobots: options.browserOptions?.respectRobots,
1517
+ tool: options.browserOptions?.tool || 'scrape_with_actions'
1518
+ }));
1519
+ // What assertPageAllowed compares the page against from here on.
1520
+ page.__crawlforgeGatedUrl = landedUrl;
1391
1521
 
1392
1522
  // Remembered on the page so the chain result can report the status of
1393
1523
  // the last navigation: tesla.com's Akamai denial ran a full action chain
@@ -1461,7 +1591,7 @@ export class ActionExecutor extends EventEmitter {
1461
1591
  // Navigate to URL. The pre-flight above repeats inside navigateToUrl —
1462
1592
  // that one is deliberately before page creation so a blocked URL never
1463
1593
  // launches a browser (tests/unit/phase1-ssrf-paths.test.js pins it).
1464
- await this.navigateToUrl(page, url);
1594
+ await this.navigateToUrl(page, url, { browserOptions });
1465
1595
 
1466
1596
  // Handle CloudFlare challenges and reCAPTCHA if stealth mode is enabled
1467
1597
  if (isStealth && this.browserProcessor.stealthManager) {
@@ -680,6 +680,13 @@ class AuthManager {
680
680
  return costs.scrape + query + model + scrapeEscalationSurcharge(params?.escalate) + redaction;
681
681
  }
682
682
 
683
+ // extract_embedded_state's escalate:true (plan Phase 3.2) is scrape's
684
+ // second stage on the same terms: the projection carries the stealth
685
+ // browser's 5, and the tool reports 2 when the plain fetch sufficed.
686
+ if (tool === 'extract_embedded_state') {
687
+ return costs.extract_embedded_state + scrapeEscalationSurcharge(params?.escalate) + redaction;
688
+ }
689
+
683
690
  // search_web's batch form (5.1) runs one backend search per query, so it
684
691
  // is priced as that many search_web calls. The count rule lives with the
685
692
  // schema that declares `queries` (src/tools/search/batchSearch.js).
@@ -766,6 +773,11 @@ class AuthManager {
766
773
  'json format may incur external LLM cost (billed by your provider).';
767
774
  break;
768
775
  }
776
+ case 'extract_embedded_state':
777
+ note = params?.escalate === true
778
+ ? 'Base 2; escalate:true adds 5 for the stealth browser it may need - the projection is the ceiling, and the actual charge drops back to 2 when the plain fetch succeeded and no escalation ran.'
779
+ : 'Fixed cost per invocation.';
780
+ break;
769
781
  case 'agent': {
770
782
  const agentUrls = params?.maxUrls || 10;
771
783
  const isPro = params?.model === 'pro';
@@ -474,6 +474,7 @@ export class LLMsTxtAnalyzer {
474
474
  const response = await safeFetch(url, {
475
475
  signal: controller.signal,
476
476
  headers: { ...gate.headers },
477
+ onRedirect: gate.onRedirect,
477
478
  ...options
478
479
  });
479
480
  clearTimeout(timeoutId);
@@ -8,9 +8,9 @@ import { ResultRanker } from '../tools/search/ranking/ResultRanker.js';
8
8
  import { CacheManager } from './cache/CacheManager.js';
9
9
  import { Logger } from '../utils/Logger.js';
10
10
  import { LLMManager } from './llm/LLMManager.js';
11
- import { safeFetch, safeGoto } from '../utils/ssrfGuard.js';
11
+ import { safeFetch, safeGoto, assertNavigationAllowed } from '../utils/ssrfGuard.js';
12
12
  import { serverStealthProxies } from '../constants/config.js';
13
- import { preflightFetch, browserPreflight } from '../utils/robotsGate.js';
13
+ import { preflightFetch, browserPreflight, redirectGate } from '../utils/robotsGate.js';
14
14
  import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
15
15
  import { noteRetryAfter } from '../utils/hostRateLimiter.js';
16
16
  import {
@@ -791,6 +791,7 @@ export class ResearchOrchestrator extends EventEmitter {
791
791
  const gate = await preflightFetch(source.link, { tool: 'deep_research' });
792
792
  const fetchResponse = await safeFetch(source.link, {
793
793
  headers: { ...gate.headers },
794
+ onRedirect: gate.onRedirect,
794
795
  signal: AbortSignal.timeout(10000)
795
796
  });
796
797
  if (fetchResponse.status === 429 || fetchResponse.status === 503) {
@@ -1107,14 +1108,19 @@ export class ResearchOrchestrator extends EventEmitter {
1107
1108
  }
1108
1109
  try {
1109
1110
  // browserPreflight (robots + platform blocklist) ran in _stealthFetchHtml;
1110
- // safeGoto adds the SSRF/DNS-rebinding guard the browser path also needs.
1111
- const resp = await safeGoto(page, url, { waitUntil: 'domcontentloaded', timeout: this.stealthTimeoutMs });
1111
+ // safeGoto adds the SSRF/DNS-rebinding guard the browser path also needs,
1112
+ // and puts wherever the page is redirected to through the same gate.
1113
+ const onRedirect = redirectGate(url, { tool: 'deep_research' });
1114
+ const resp = await safeGoto(page, url, { waitUntil: 'domcontentloaded', timeout: this.stealthTimeoutMs, onRedirect });
1115
+ const landedUrl = page.url();
1112
1116
  // Do NOT bail on the initial HTTP status: anti-bot challenges (Cloudflare
1113
1117
  // Turnstile) return 403 on the first response and only resolve to the
1114
1118
  // real page after their JS runs. Let it settle, then judge by the
1115
1119
  // *rendered* content instead.
1116
1120
  await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
1117
1121
  await page.waitForTimeout(2500).catch(() => {});
1122
+ // A challenge or a client-side redirect may have moved the page on since.
1123
+ await assertNavigationAllowed(page, landedUrl, null, onRedirect);
1118
1124
  const html = await page.content();
1119
1125
  const title = (await page.title().catch(() => '')) || '';
1120
1126
  const bodyLen = await page.evaluate(() => document.body?.innerText?.trim().length || 0).catch(() => 0);