crawlforge-mcp-server 6.14.0 → 6.17.1

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 (39) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/server.js +74 -22
  4. package/src/cli/commands/stealth.js +2 -2
  5. package/src/core/ActionExecutor.js +219 -39
  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 +117 -13
  10. package/src/core/browser/actionQueue.js +4 -1
  11. package/src/core/browser/windowState.js +88 -0
  12. package/src/core/crawlers/BFSCrawler.js +12 -3
  13. package/src/core/processing/BrowserProcessor.js +27 -8
  14. package/src/core/processing/PDFProcessor.js +15 -6
  15. package/src/server/fallbackHints.js +7 -4
  16. package/src/server/inlineThreshold.js +81 -19
  17. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
  18. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +25 -6
  19. package/src/tools/advanced/BrowserSessionTool.js +38 -13
  20. package/src/tools/advanced/ScrapeWithActionsTool.js +40 -4
  21. package/src/tools/advanced/batchScrape/worker.js +3 -1
  22. package/src/tools/basic/_fetch.js +3 -1
  23. package/src/tools/crawl/_sessionContext.js +2 -1
  24. package/src/tools/crawl/mapSite.js +2 -1
  25. package/src/tools/extract/_fetchAndParse.js +1 -0
  26. package/src/tools/extract/extractContent.js +2 -1
  27. package/src/tools/extract/extractEmbeddedState.js +320 -71
  28. package/src/tools/extract/processDocument.js +5 -3
  29. package/src/tools/llmstxt/generateLLMsTxt.js +2 -1
  30. package/src/tools/templates/ScrapeTemplateTool.js +11 -3
  31. package/src/tools/tracking/trackChanges/differ.js +1 -0
  32. package/src/utils/complianceAudit.js +35 -5
  33. package/src/utils/impitRung.js +16 -5
  34. package/src/utils/resignedFetch.js +165 -0
  35. package/src/utils/robotsChecker.js +6 -1
  36. package/src/utils/robotsGate.js +124 -11
  37. package/src/utils/secretMask.js +1 -1
  38. package/src/utils/sitemapParser.js +2 -1
  39. package/src/utils/ssrfGuard.js +88 -11
@@ -17,11 +17,12 @@ import path from 'path';
17
17
  import { createRequire } from 'module';
18
18
  import HumanBehaviorSimulator from '../utils/HumanBehaviorSimulator.js';
19
19
  import { BrowserContextPool } from './BrowserContextPool.js';
20
- import { safeGoto } from '../utils/ssrfGuard.js';
20
+ import { safeGoto, assertNavigationAllowed, assertUrlAllowed } from '../utils/ssrfGuard.js';
21
21
  import { looksLikeInterstitial, detectChallengePage } from '../utils/challengeDetection.js';
22
22
  import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
23
23
  import { serverStealthProxies } from '../constants/config.js';
24
24
  import { ClearanceJar, sharedClearanceJar } from './ClearanceJar.js';
25
+ import { readWindowState } from './browser/windowState.js';
25
26
 
26
27
  // Grace given to a document that rendered no title and no text (see _waitOutEmptyDocument).
27
28
  export const EMPTY_DOCUMENT_GRACE_MS = 8000;
@@ -158,6 +159,39 @@ function isChromium(page) {
158
159
  }
159
160
  }
160
161
 
162
+ /**
163
+ * A proxy entry with its credentials removed. Everything before the last "@"
164
+ * of the authority goes, with or without a scheme: parseProxyEntry accepts a
165
+ * bare `user:pass@host:port`, which a rule anchored on "//" left whole.
166
+ * @param {string} entry
167
+ * @returns {string}
168
+ */
169
+ export function redactProxyEntry(entry) {
170
+ return String(entry).replace(/^([a-z][a-z0-9+.-]*:\/\/)?[^/]*@/i, '$1');
171
+ }
172
+
173
+ /**
174
+ * Refuse a per-call proxy list on camoufox. camoufox takes its proxy at
175
+ * browser launch and that one browser serves every later camoufox call, so a
176
+ * caller's proxy would carry other callers' traffic, on the caller's
177
+ * credentials, or be ignored when a browser is already up. Chromium sets the
178
+ * proxy per context. Called with the engine that will actually run, so an
179
+ * "auto" that lands on camoufox is refused too. The operator's
180
+ * CRAWLFORGE_STEALTH_PROXIES is not a per-call list and is not refused.
181
+ * @param {string} engine - the resolved engine
182
+ * @param {{ proxyRotation?: { enabled?: boolean, proxies?: string[] } }} config
183
+ * @throws {Error}
184
+ */
185
+ export function assertProxyEngineAllowed(engine, config) {
186
+ const requested = config?.proxyRotation?.enabled ? (config.proxyRotation.proxies || []) : [];
187
+ if (engine !== 'camoufox' || !requested.length) return;
188
+ throw new Error(
189
+ 'proxyRotation is refused on the camoufox engine: camoufox applies its proxy at browser launch ' +
190
+ 'and shares that browser across calls, so a per-call proxy cannot be kept to this call. Use ' +
191
+ 'engine:"chromium" (proxies are per context there), or set the operator list CRAWLFORGE_STEALTH_PROXIES.'
192
+ );
193
+ }
194
+
161
195
  /**
162
196
  * Resolve a requested engine to the one that will actually launch.
163
197
  *
@@ -421,12 +455,19 @@ export class StealthBrowserManager {
421
455
  */
422
456
  async launchStealthBrowser(config = {}) {
423
457
  const validatedConfig = StealthConfigSchema.parse({ ...this.defaultConfig, ...config });
458
+ // Every path to a browser comes through here, createStealthContext's
459
+ // included, so this is where a caller's proxy list is held to the SSRF
460
+ // guard before either engine is handed it.
461
+ await this.assertCallerProxiesAllowed(validatedConfig);
424
462
  // 'auto' has to become a concrete engine before the comparison below: an
425
463
  // unresolved 'auto' never equals _launchedEngine, so every call would park
426
464
  // a perfectly good browser and launch a second one beside it. This is the
427
465
  // call that decides the engine — every other path reaches a browser
428
466
  // through it — so it is the one that records the outcome.
429
467
  await this._resolveConfigEngine(validatedConfig, true);
468
+ // The tools refuse this up front; this is the backstop for every other
469
+ // path, decided on the engine 'auto' resolved to, before any launch.
470
+ assertProxyEngineAllowed(validatedConfig.engine, validatedConfig);
430
471
 
431
472
  // A Chromium that was OOM-killed or crashed doesn't error on reuse — its
432
473
  // protocol calls hang. Detect the corpse and relaunch instead.
@@ -527,7 +568,11 @@ export class StealthBrowserManager {
527
568
  // leave camoufox reporting the first proxy's city behind the second
528
569
  // proxy's exit IP, which is a worse signal than not rotating at all.
529
570
  // A rotation takes effect on the next launch, after cleanup().
530
- const proxy = this.resolveProxy(validatedConfig);
571
+ //
572
+ // Only the operator's CRAWLFORGE_STEALTH_PROXIES, never a caller's list:
573
+ // this browser outlives the call, and every later camoufox context
574
+ // inherits its proxy (assertProxyEngineAllowed refuses the caller's).
575
+ const proxy = this.resolveProxy({ ...validatedConfig, proxyRotation: undefined });
531
576
  // The caller's locale goes to the launcher, not to the context. camoufox
532
577
  // sets language, Accept-Language and Intl together below the JS layer,
533
578
  // where a Worker reads the same answer as the document; Playwright's
@@ -2386,18 +2431,23 @@ export class StealthBrowserManager {
2386
2431
  // Wait for potential challenge page
2387
2432
  await page.waitForTimeout(2000);
2388
2433
 
2389
- // Check for CloudFlare challenge indicators
2434
+ // Check for CloudFlare challenge indicators. Cloudflare's current
2435
+ // interstitial reads "Performing security verification" under the title
2436
+ // "Just a moment..." (doordash.com, 2026-10-03); none of the older
2437
+ // phrases below appear on it, so the wait further down ended at once.
2390
2438
  const challengeDetected = await page.evaluate(() => {
2391
2439
  const indicators = [
2392
2440
  'cf-browser-verification',
2393
2441
  'cf-challenge-running',
2394
2442
  'Checking your browser',
2443
+ 'Performing security verification',
2395
2444
  'DDoS protection by Cloudflare',
2396
2445
  'Ray ID'
2397
2446
  ];
2398
2447
 
2399
2448
  const pageText = document.body.innerText;
2400
- return indicators.some(indicator => pageText.includes(indicator));
2449
+ return /^just a moment/i.test(document.title.trim()) ||
2450
+ indicators.some(indicator => pageText.includes(indicator));
2401
2451
  });
2402
2452
 
2403
2453
  if (challengeDetected) {
@@ -2419,16 +2469,25 @@ export class StealthBrowserManager {
2419
2469
  }
2420
2470
  }
2421
2471
 
2422
- // Wait for challenge to complete (up to 30 seconds)
2472
+ // Wait for the challenge to complete, up to 10 s. Only waits for
2473
+ // Cloudflare's own script to finish; nothing is clicked. A challenge
2474
+ // that passes clears in seconds; one that will not pass (an
2475
+ // interactive check, or this IP's reputation) never clears, and 30 s
2476
+ // there put a walled chain past the REST window (doordash.com stealth
2477
+ // Chromium, 3 of 3 runs walled at 39 s with the 30 s cap, 2026-10-03).
2423
2478
  await page.waitForFunction(() => {
2424
2479
  const indicators = [
2425
2480
  'cf-browser-verification',
2426
2481
  'cf-challenge-running',
2427
- 'Checking your browser'
2482
+ 'Checking your browser',
2483
+ 'Performing security verification'
2428
2484
  ];
2429
- const pageText = document.body.innerText;
2430
- return !indicators.some(indicator => pageText.includes(indicator));
2431
- }, { timeout: 30000 }).catch(() => {});
2485
+ const pageText = document.body ? document.body.innerText : '';
2486
+ return !/^just a moment/i.test(document.title.trim()) &&
2487
+ !indicators.some(indicator => pageText.includes(indicator));
2488
+ // Options are the third argument; in the second they were the page
2489
+ // function's argument and the wait ran Playwright's 30 s default.
2490
+ }, undefined, { timeout: 10000 }).catch(() => {});
2432
2491
 
2433
2492
  this.performanceMetrics.successfulBypasses++;
2434
2493
  return true;
@@ -2549,9 +2608,34 @@ export class StealthBrowserManager {
2549
2608
  return proxy;
2550
2609
  }
2551
2610
 
2611
+ /**
2612
+ * Refuse a caller's proxy whose host the SSRF guard would refuse as a page:
2613
+ * a proxy on loopback, link-local or a metadata address (RFC 1918 too under
2614
+ * SSRF_STRICT, and ALLOWED_DOMAINS exempt, as everywhere else) would have
2615
+ * the browser send the call's traffic into this server's own network. Only
2616
+ * the per-call list is checked — CRAWLFORGE_STEALTH_PROXIES is the
2617
+ * operator's, who may well run a proxy on localhost. The message names the
2618
+ * host, never the entry, which carries the password.
2619
+ * @param {{ proxyRotation?: { enabled?: boolean, proxies?: string[] } }} config
2620
+ * @returns {Promise<void>}
2621
+ * @throws {Error} code SSRF_BLOCKED
2622
+ */
2623
+ async assertCallerProxiesAllowed(config) {
2624
+ const requested = config.proxyRotation?.enabled ? (config.proxyRotation.proxies || []) : [];
2625
+ for (const entry of requested) {
2626
+ // url.host of socks4/socks5 parses the same way; the guard only takes http(s).
2627
+ const { host } = new URL(this.parseProxyEntry(entry).server);
2628
+ try {
2629
+ await assertUrlAllowed(`http://${host}/`, { resolveDns: true });
2630
+ } catch (err) {
2631
+ throw Object.assign(new Error(`proxyRotation: proxy host ${host} refused — ${err.message}`), { code: err.code });
2632
+ }
2633
+ }
2634
+ }
2635
+
2552
2636
  /** A proxy entry with its credentials removed, for logs and get_stats. */
2553
2637
  redactProxy(entry) {
2554
- return String(entry).replace(/\/\/[^/@]*@/, '//');
2638
+ return redactProxyEntry(entry);
2555
2639
  }
2556
2640
 
2557
2641
  /**
@@ -2844,7 +2928,16 @@ export class StealthBrowserManager {
2844
2928
  return this._clickTurnstile(page);
2845
2929
  }
2846
2930
 
2847
- async scrapeWithStealth({ url, engine, wait_for = 0, screenshot = false, stealthConfig = {} } = {}) {
2931
+ /**
2932
+ * @param {object} options
2933
+ * @param {boolean} [options.readWindowState] also read the framework globals
2934
+ * off `window` once the page has settled (extract_embedded_state's
2935
+ * escalation, src/core/browser/windowState.js); returned as `windowState`
2936
+ * @param {(to: string) => Promise<void>} [options.onRedirect] the caller's
2937
+ * gate for wherever the page is redirected to (robotsGate.js
2938
+ * `redirectGate`); a refusal throws and nothing of the page is returned
2939
+ */
2940
+ async scrapeWithStealth({ url, engine, wait_for = 0, screenshot = false, stealthConfig = {}, readWindowState: wantWindowState = false, onRedirect } = {}) {
2848
2941
  if (!url) throw new Error('scrapeWithStealth requires a url');
2849
2942
 
2850
2943
  const { contextId, engineFallbackWarning = null } = await this.createStealthContext({ ...stealthConfig, engine });
@@ -2870,7 +2963,8 @@ export class StealthBrowserManager {
2870
2963
  });
2871
2964
  // SSRF guard at the navigation boundary: the stealth engine resolves DNS
2872
2965
  // itself, so a URL/host-only check would miss rebinding to private/metadata IPs.
2873
- const response = await safeGoto(page, url, { waitUntil: 'domcontentloaded' });
2966
+ const response = await safeGoto(page, url, { waitUntil: 'domcontentloaded', onRedirect });
2967
+ const landedUrl = page.url();
2874
2968
  if (response && status === null) status = response.status();
2875
2969
  if (wait_for > 0) await page.waitForTimeout(wait_for);
2876
2970
  // Cloudflare's and Vercel's JavaScript challenges solve themselves in a
@@ -2888,6 +2982,10 @@ export class StealthBrowserManager {
2888
2982
  // a successful scrape.
2889
2983
  const renderMs = await this._settleRender(page);
2890
2984
  const gracedMs = emptyGraceMs + renderMs;
2985
+ // The waits above are time in which a challenge or a client-side redirect
2986
+ // can move the page on. The document read below is the one that has to
2987
+ // pass, so wherever the page stands now is checked like a redirect hop.
2988
+ await assertNavigationAllowed(page, landedUrl, null, onRedirect);
2891
2989
 
2892
2990
  // A failure to read the document is a failure. With every read wrapped
2893
2991
  // in .catch(() => ''), a renderer that crashed or a page closed during
@@ -2913,6 +3011,12 @@ export class StealthBrowserManager {
2913
3011
  const shot = screenshot
2914
3012
  ? await page.screenshot({ encoding: 'base64', fullPage: false }).catch(() => null)
2915
3013
  : null;
3014
+ let windowState;
3015
+ if (wantWindowState) {
3016
+ const read = await readWindowState(page);
3017
+ windowState = read.state;
3018
+ warnings.push(...read.warnings);
3019
+ }
2916
3020
 
2917
3021
  // Still a wall after everything above: whatever clearance this context
2918
3022
  // carried for the site did not work, so closeContext drops it instead of
@@ -2921,7 +3025,7 @@ export class StealthBrowserManager {
2921
3025
  this._markBlocked(contextId, [url, page.url()]);
2922
3026
  }
2923
3027
 
2924
- return { success: true, url, title, text, html, screenshot: shot, status, gracedMs, engine: engineUsed, warnings };
3028
+ return { success: true, url, title, text, html, screenshot: shot, status, gracedMs, engine: engineUsed, warnings, ...(windowState ? { windowState } : {}) };
2925
3029
  } finally {
2926
3030
  await this.closeContext(contextId).catch(() => {});
2927
3031
  }
@@ -57,7 +57,10 @@ export function createActionQueue({
57
57
  'CRAWLFORGE_MAX_ACTION_SESSIONS / CRAWLFORGE_ACTION_QUEUE_TIMEOUT_MS if the machine has room.'
58
58
  ));
59
59
  }, timeoutMs);
60
- timer.unref?.();
60
+ // Not unref()'d: a caller is awaiting this wait, so it must keep the event
61
+ // loop alive. Unref'd, Node 22 could exit mid-wait with the promise still
62
+ // pending (it did, under node:test in CI). The timer never outlives the
63
+ // wait — it is cleared when the job starts.
61
64
 
62
65
  return queue.add(() => {
63
66
  clearTimeout(timer);
@@ -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);
@@ -370,6 +373,11 @@ export class BrowserProcessor {
370
373
  // createStealthContext re-validates the config it is given — so a context
371
374
  // asked for without it would relaunch on the default engine underneath.
372
375
  const engine = options.stealthMode.engine || 'chromium';
376
+ // The caller's own proxies (scrape_with_actions browserOptions.proxyRotation),
377
+ // to both calls for the same reason as the engine: camoufox takes its proxy
378
+ // at launch, Chromium per context. Absent unless the caller sent one, so
379
+ // CRAWLFORGE_STEALTH_PROXIES still applies when nobody did.
380
+ const proxy = options.proxyRotation ? { proxyRotation: options.proxyRotation } : {};
373
381
 
374
382
  // Launch stealth browser
375
383
  await this.stealthManager.launchStealthBrowser({
@@ -378,12 +386,14 @@ export class BrowserProcessor {
378
386
  randomizeFingerprint: options.stealthMode.randomizeFingerprint,
379
387
  hideWebDriver: options.stealthMode.hideWebDriver,
380
388
  blockWebRTC: options.stealthMode.blockWebRTC,
381
- customUserAgent: options.stealthMode.customUserAgent || options.userAgent
389
+ customUserAgent: options.stealthMode.customUserAgent || options.userAgent,
390
+ ...proxy
382
391
  });
383
392
 
384
393
  // Create stealth context
385
394
  const { context, contextId } = await this.stealthManager.createStealthContext({
386
395
  engine,
396
+ ...proxy,
387
397
  level: options.stealthMode.level,
388
398
  customViewport: {
389
399
  width: options.viewportWidth || 1280,
@@ -635,18 +645,23 @@ export class BrowserProcessor {
635
645
  * @param {Page} page - Playwright page
636
646
  * @param {string} url - URL to navigate to
637
647
  * @param {Object} options - Navigation options
648
+ * @param {(to: string) => Promise<void>} [onRedirect] - the calling tool's
649
+ * redirect gate (robotsGate.js `redirectGate`)
638
650
  * @returns {Promise<Object>} - Navigation result
639
651
  */
640
- async navigateAndWait(page, url, options) {
652
+ async navigateAndWait(page, url, options, onRedirect) {
641
653
  const startTime = Date.now();
642
654
 
643
655
  // 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.
656
+ // robots/blocklist preflight the calling tool already ran, and re-checks
657
+ // where the navigation went, so a redirect can't carry us into a
658
+ // private/metadata range or past the gate the URL itself went through.
646
659
  await safeGoto(page, url, {
647
660
  waitUntil: 'domcontentloaded',
648
- timeout: 30000
661
+ timeout: 30000,
662
+ onRedirect
649
663
  });
664
+ const landedUrl = page.url();
650
665
 
651
666
  // Wait for specific selector if provided
652
667
  if (options.waitForSelector) {
@@ -687,6 +702,10 @@ export class BrowserProcessor {
687
702
  }
688
703
  }
689
704
 
705
+ // The waits above are time in which a client-side redirect can move the
706
+ // page on; the document about to be read is the one that has to pass.
707
+ await assertNavigationAllowed(page, landedUrl, null, onRedirect);
708
+
690
709
  return {
691
710
  navigationTime: Date.now() - startTime
692
711
  };
@@ -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
 
@@ -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