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
@@ -20,9 +20,9 @@ export const MAX_INLINE_CHARS_PARAM = {
20
20
  /**
21
21
  * Per-tool rule. `textPaths` are dotted paths tried in order; the first one
22
22
  * holding a string becomes the text view the preview and read_result work
23
- * on. An empty list means the pretty-printed JSON is the view. `truncate:
24
- * false` keeps the whole result inline and only adds the handle
25
- * (extract_embedded_state's never-truncate rule). `when` gates on params.
23
+ * on. An empty list means the pretty-printed JSON is the view. `when` gates
24
+ * on params. extract_embedded_state has a shape of its own (shapeEmbeddedState
25
+ * below).
26
26
  */
27
27
  /** The browser_session operations that hand back content worth shaping. */
28
28
  const BROWSER_SESSION_CONTENT_OPERATIONS = new Set(['snapshot', 'act', 'read']);
@@ -52,7 +52,10 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
52
52
  },
53
53
  process_document: { textPaths: ['content.text'], truncate: true },
54
54
  deep_research: { textPaths: [], truncate: true },
55
- extract_embedded_state: { textPaths: [], truncate: false }
55
+ // Truncated since plan Phase 3.1: returned whole, producthunt.com and
56
+ // zappos.com overflowed the client. Shaped by shapeEmbeddedState, which never
57
+ // cuts inside a JSON value.
58
+ extract_embedded_state: { textPaths: [], truncate: true }
56
59
  });
57
60
 
58
61
  /** param -> env (an int of at least 1,000) -> default. */
@@ -147,21 +150,8 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
147
150
  const viewDesc = view === 'text' ? `the ${view_path} text` : 'the pretty-printed JSON';
148
151
  const readWith = 'read it with read_result (1 credit) - operation "search" (query), "slice" (offset, length), "lines" or "json_path" (path) - and do not fetch the page again';
149
152
 
150
- if (!config.truncate) {
151
- const hint = `Result is ${json.length} chars as JSON, over the inline limit of ${maxInline}; it is returned whole (this tool never truncates) and is also kept for 1 hour under result_handle ${handle}: ${readWith}.`;
152
- return {
153
- result: {
154
- ...resultObject,
155
- result_handle: handle,
156
- total_chars: text.length,
157
- view,
158
- view_path,
159
- truncated: false,
160
- expires_at,
161
- warnings: [...warningsOf(resultObject), hint]
162
- },
163
- stored: true
164
- };
153
+ if (toolName === 'extract_embedded_state') {
154
+ return { result: shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith }), stored: true };
165
155
  }
166
156
 
167
157
  const preview = text.slice(0, maxInline);
@@ -200,3 +190,75 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
200
190
  });
201
191
  return { result: shaped, stored: true };
202
192
  }
193
+
194
+ /** Object.keys(data) for a plain object, "array(<n>)" for an array, null otherwise. */
195
+ export function embeddedDataKeys(data) {
196
+ if (Array.isArray(data)) return `array(${data.length})`;
197
+ if (data !== null && typeof data === 'object') return Object.keys(data);
198
+ return null;
199
+ }
200
+
201
+ /**
202
+ * The longest run of whole lines from the start of `text` whose JSON-string
203
+ * form fits in `budget` characters. A line that does not fit ends the preview,
204
+ * so it never stops inside a value; with a budget too small for the first
205
+ * line it is empty.
206
+ */
207
+ export function wholeLinePreview(text, budget) {
208
+ const room = budget - 2; // the string's own quotes
209
+ let used = 0;
210
+ let end = 0;
211
+ let start = 0;
212
+ while (start < text.length) {
213
+ const newline = text.indexOf('\n', start);
214
+ const stop = newline === -1 ? text.length : newline;
215
+ // A line's escaped length, plus the escaped newline that joins it to the last.
216
+ const cost = JSON.stringify(text.slice(start, stop)).length - 2 + (end > 0 ? 2 : 0);
217
+ if (used + cost > room) break;
218
+ used += cost;
219
+ end = stop;
220
+ start = stop + 1;
221
+ }
222
+ return text.slice(0, end);
223
+ }
224
+
225
+ /**
226
+ * extract_embedded_state over the limit (plan Phase 3.1). What stays inline is
227
+ * everything that says what the page carries — url, path, bytes, the whole
228
+ * `found` list, data_keys, the escalation report and window_state's list —
229
+ * then a preview of the pretty-printed JSON cut at a line boundary. The
230
+ * preview gets whatever max_inline_chars leaves after the rest, measured as
231
+ * compact JSON, so the whole inline result stays within the limit whenever
232
+ * the rest fits. The full result is under the handle for read_result
233
+ * json_path. The REST route shapes it the same way; keep the two in step.
234
+ */
235
+ function shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith }) {
236
+ const dataKeys = embeddedDataKeys(resultObject.data);
237
+ const firstKey = Array.isArray(dataKeys) && dataKeys.length > 0 ? dataKeys[0] : null;
238
+ const example = firstKey && /^[A-Za-z_$][\w$]*$/.test(firstKey) ? ` Read one payload with operation "json_path", path "data.${firstKey}".` : '';
239
+ const hint = `Result is ${json.length} chars as JSON, over the inline limit of ${maxInline}; preview holds the pretty-printed JSON up to the last whole line that fits (${text.length} chars in total), data_keys lists the top-level keys of data, and the full result is kept for 1 hour under result_handle ${handle}: ${readWith}.${example} Or re-run with path, or keys_only:true, to ask for less.`;
240
+
241
+ const shaped = {};
242
+ for (const key of ['url', 'path', 'bytes', 'found']) {
243
+ if (key in resultObject) shaped[key] = resultObject[key];
244
+ }
245
+ shaped.data_keys = dataKeys;
246
+ if ('escalated' in resultObject) shaped.escalated = resultObject.escalated;
247
+ if (resultObject.stealth) shaped.stealth = resultObject.stealth;
248
+ if (resultObject.window_state && typeof resultObject.window_state === 'object') {
249
+ shaped.window_state = { note: resultObject.window_state.note, found: resultObject.window_state.found };
250
+ }
251
+ const tail = {
252
+ result_handle: handle,
253
+ total_chars: text.length,
254
+ view: 'json',
255
+ view_path: null,
256
+ truncated: true,
257
+ expires_at,
258
+ warnings: [...warningsOf(resultObject), hint]
259
+ };
260
+ // `"preview":"",` is what the key itself adds once the value is in.
261
+ const envelope = JSON.stringify({ ...shaped, preview: '', ...tail }).length;
262
+ const preview = wholeLinePreview(text, Math.max(0, maxInline - envelope + 2));
263
+ return { ...shaped, preview, ...tail };
264
+ }
@@ -22,7 +22,7 @@ metered; there is no free tier. Tools marked "scales" cost more as work grows.
22
22
  |------|-------|
23
23
  | `scrape` | Unified multi-format single fetch. A `highlights` or `question` format adds 1 once per call; `mode:"model"` on one of them adds 3 once. `escalate:true` adds 5 as the projected ceiling; the actual charge drops back to the base when the plain fetch succeeded and no escalation ran. |
24
24
  | `scrape_structured` | CSS-selector extraction. |
25
- | `extract_embedded_state` | Embedded JS state (`__NEXT_DATA__`, RSC, Nuxt, Apollo, Redux). |
25
+ | `extract_embedded_state` | Embedded JS state (`__NEXT_DATA__`, RSC, Nuxt, Apollo, Redux). `escalate:true` adds 5 as the projected ceiling for the stealth re-read; the actual charge drops back to 2 when the plain fetch succeeded and no escalation ran. `keys_only` and `find` cost nothing extra. |
26
26
  | `extract_content` | Readability-cleaned article. |
27
27
  | `map_site` | URL discovery / sitemap. |
28
28
  | `process_document` | PDF / DOCX / TXT parsing. |
@@ -36,15 +36,34 @@ already, exact and typed — that beats asking a model to read them off the rend
36
36
  { "tool": "extract_embedded_state", "params": { "url": "https://www.ticketmaster.com/discover/concerts", "path": "next_data.props.pageProps" } }
37
37
  ```
38
38
 
39
- Finds `__NEXT_DATA__`, `self.__next_f` (React Server Component payloads),
40
- `window.__NUXT__`, `__APOLLO_STATE__`, `__INITIAL_STATE__`, `__PRELOADED_STATE__`
41
- and `<script type="application/json">` blocks, keyed by source name. No LLM in
42
- the path, so values are exact rather than inferred.
39
+ Finds `__NEXT_DATA__`, `self.__next_f` (React Server Component payloads, with
40
+ `$<id>` references between rows resolved and a `data_rows` index of the rows
41
+ carrying data), `window.__NUXT__` and Nuxt 3's `__NUXT_DATA__` (decoded as
42
+ `nuxt_data`; `raw: true` also keeps the undecoded array), SvelteKit `kit.start`
43
+ data, `__APOLLO_STATE__`, `__INITIAL_STATE__`, `__PRELOADED_STATE__`,
44
+ `ytInitialData`, `ytInitialPlayerResponse`, Inertia `data-page`, Shopify meta and
45
+ product JSON, JSON `data-*` attributes and `<script type="application/json">`
46
+ blocks, keyed by source name. No LLM in the path, so values are exact rather
47
+ than inferred.
43
48
 
44
49
  These payloads are routinely over a megabyte — pass `path` (dotted keys and
45
50
  array indexes, e.g. `next_data.props.pageProps` or `next_f[0].f`) to return one
46
- subtree. Without it, a large result comes back with a warning naming the biggest
47
- source and a ready-to-paste path.
51
+ subtree, or `keys_only: true` first to see the first two levels of keys (types,
52
+ no values) before choosing one. When you know a field's name but not where it
53
+ is, `find: "<key>"` returns every path holding it (case-insensitive, up to 50,
54
+ each with a 200-character preview) instead of the data; each path can be passed
55
+ straight back as `path`. `find` and `keys_only` cannot be combined. A result
56
+ over `max_inline_chars` comes back as a `preview` (whole lines of the JSON),
57
+ `data_keys` and a `result_handle`: read the
58
+ rest with `read_result` operation `json_path` (e.g. path `data.next_data.props`)
59
+ instead of fetching again.
60
+
61
+ On a site that blocks a plain fetch (403/429/challenge page), pass
62
+ `escalate: true` (projected 7, charged 2 when the plain fetch works): the stealth
63
+ browser re-reads the page, the same parser runs on it, and the framework globals
64
+ are read off `window` after JavaScript ran — `ytInitialData`, `__NUXT__`,
65
+ `__remixContext` and others, under `window_state` (path
66
+ `window_state.ytInitialData.contents`). A 404 or 5xx never escalates.
48
67
 
49
68
  ## scrape_template — known sites, zero selectors (cost: 1)
50
69
 
@@ -111,8 +111,10 @@ const BrowserSessionSchema = z.object({
111
111
  timeout: z.number().min(10000).max(120000).default(30000),
112
112
 
113
113
  // Applies to the call it is sent on: on `open` to the first load, on `act` to
114
- // every navigate in that call. It is never remembered by the session, so an
115
- // override has to be repeated as deliberately as it was made.
114
+ // every navigate in that call, and on any call to wherever the page has moved
115
+ // since it was last checked (a click, a form, a script). It is never
116
+ // remembered by the session, so an override has to be repeated as
117
+ // deliberately as it was made. A URL that passed is not asked about again.
116
118
  respect_robots: z.boolean().optional(),
117
119
 
118
120
  // snapshot
@@ -358,6 +360,8 @@ export class BrowserSessionTool {
358
360
 
359
361
  let session;
360
362
  try {
363
+ // The load was gated; a script may have moved the page on since.
364
+ await this.actionExecutor.assertPageAllowed(page, browserOptions);
361
365
  session = this.store.create({
362
366
  ownerId,
363
367
  page,
@@ -371,8 +375,9 @@ export class BrowserSessionTool {
371
375
  maxPerOwner: ownerId.startsWith(REST_OWNER_PREFIX) ? REST_MAX_SESSIONS_PER_OWNER : undefined
372
376
  });
373
377
  } catch (error) {
374
- // A cap refusal arrives with a live page in hand. Give it back before
375
- // rethrowing, or the refused call leaks the context it just pinned.
378
+ // A cap refusal, or a page the gate refused, arrives with a live page in
379
+ // hand. Give it back before rethrowing, or the refused call leaks the
380
+ // context it just pinned.
376
381
  await releasePage();
377
382
  throw error;
378
383
  }
@@ -438,13 +443,29 @@ export class BrowserSessionTool {
438
443
  }
439
444
  }
440
445
 
446
+ /**
447
+ * Read from a session's page once the URL it stands on has passed the gate.
448
+ *
449
+ * A session's page outlives the call that loaded it, and between calls it
450
+ * can go anywhere: the last `act` clicked a link, or a script redirected.
451
+ * ActionExecutor.readGated holds it to the SSRF guard and the blocklist/
452
+ * robots gate on both sides of the read, with this call's `respect_robots`.
453
+ */
454
+ readPage(session, params, read) {
455
+ return this.actionExecutor.readGated(
456
+ session.page,
457
+ { respectRobots: params.respect_robots, tool: 'browser_session' },
458
+ read
459
+ );
460
+ }
461
+
441
462
  async snapshotSession(params, ownerId) {
442
463
  const session = this.requireSession(params, ownerId);
443
464
 
444
- const snapshot = await captureSnapshot(session.page, {
465
+ const snapshot = await this.readPage(session, params, () => captureSnapshot(session.page, {
445
466
  interactiveOnly: params.interactive_only,
446
467
  maxNodes: params.max_nodes
447
- });
468
+ }));
448
469
 
449
470
  this.store.touch(session, session.page.url());
450
471
  return { success: true, operation: 'snapshot', ...sessionInfo(session), snapshot };
@@ -473,9 +494,11 @@ export class BrowserSessionTool {
473
494
  }
474
495
 
475
496
  // Every `navigate` in here re-runs the SSRF guard and the blocklist/robots
476
- // gate inside executeNavigateAction — verified, and the reason the gate is
477
- // not repeated here. A long-lived session is a repeatable navigation
478
- // primitive, so that per-hop check is what stops it becoming an SSRF hop.
497
+ // gate inside executeNavigateAction, and every action is bracketed by the
498
+ // same check on wherever the page has moved (executeGatedAction) — the
499
+ // reason the gate is not repeated here. A long-lived session is a
500
+ // repeatable navigation primitive, so those checks are what stop it
501
+ // becoming an SSRF hop.
479
502
  const result = await this.actionExecutor.executeActionsOnPage(session.page, params.actions, {
480
503
  continueOnError: params.continue_on_error,
481
504
  timeout: params.timeout,
@@ -503,8 +526,10 @@ export class BrowserSessionTool {
503
526
 
504
527
  async readSession(params, ownerId) {
505
528
  const session = this.requireSession(params, ownerId);
506
- const url = session.page.url();
507
- const html = await session.page.content();
529
+ const { url, html } = await this.readPage(session, params, async () => ({
530
+ url: session.page.url(),
531
+ html: await session.page.content()
532
+ }));
508
533
 
509
534
  const options = {};
510
535
  if (params.formats.includes('markdown')) options.outputFormat = 'markdown';
@@ -564,12 +589,12 @@ export class BrowserSessionTool {
564
589
  async screenshotSession(params, ownerId) {
565
590
  const session = this.requireSession(params, ownerId);
566
591
 
567
- const shot = await this.actionExecutor.captureScreenshot(session.page, {
592
+ const shot = await this.readPage(session, params, () => this.actionExecutor.captureScreenshot(session.page, {
568
593
  fullPage: params.full_page,
569
594
  format: params.format,
570
595
  quality: params.quality,
571
596
  selector: params.selector
572
- });
597
+ }));
573
598
 
574
599
  this.store.touch(session, session.page.url());
575
600
  // The actionId is what lets the server publish the image as a
@@ -14,7 +14,7 @@ import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
14
14
  import { elementText } from '../../utils/elementText.js';
15
15
  import { pageTitle } from '../../utils/pageTitle.js';
16
16
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
17
- import { resolveStealthEngine } from '../../core/StealthBrowserManager.js';
17
+ import { resolveStealthEngine, redactProxyEntry, assertProxyEngineAllowed } from '../../core/StealthBrowserManager.js';
18
18
  import { actionQueue as sharedActionQueue } from '../../core/browser/actionQueue.js';
19
19
 
20
20
  // Recording / replay helpers
@@ -27,6 +27,20 @@ import {
27
27
  recordedEntryToAction
28
28
  } from './scrapeWithActions/recorder.js';
29
29
 
30
+ // metadata.browserOptions echoes the merged options, and a proxy entry carries
31
+ // its password.
32
+ function redactProxyCredentials(browserOptions) {
33
+ const proxies = browserOptions.proxyRotation?.proxies;
34
+ if (!proxies) return browserOptions;
35
+ return {
36
+ ...browserOptions,
37
+ proxyRotation: {
38
+ ...browserOptions.proxyRotation,
39
+ proxies: proxies.map(redactProxyEntry)
40
+ }
41
+ };
42
+ }
43
+
30
44
  // Action schemas (re-using from ActionExecutor but with tool-specific additions)
31
45
  const BaseActionSchema = z.object({
32
46
  type: z.string(),
@@ -203,7 +217,15 @@ const ScrapeWithActionsSchema = z.object({
203
217
  // Answer a cookie/consent wall (autoconsent) after the initial load and
204
218
  // after each navigate action: "reject" opts out, "accept" opts in. Off by
205
219
  // default so a chain that clicks the banner itself keeps working.
206
- consent: z.enum(['off', 'reject', 'accept']).default('off')
220
+ consent: z.enum(['off', 'reject', 'accept']).default('off'),
221
+ // The caller's own proxies for a stealth chain — the shape and parsing of
222
+ // stealth_mode's stealthConfig.proxyRotation. Refused without stealth:true
223
+ // (see executeSession); CrawlForge supplies none.
224
+ proxyRotation: z.object({
225
+ enabled: z.boolean().default(false),
226
+ proxies: z.array(z.string()).optional(),
227
+ rotationInterval: z.number().default(300000)
228
+ }).optional()
207
229
  }).optional(),
208
230
 
209
231
  // Content extraction options
@@ -425,6 +447,8 @@ export class ScrapeWithActionsTool extends EventEmitter {
425
447
  const warnings = [];
426
448
  if (browserOptions.stealth) {
427
449
  const resolved = await resolveStealthEngine(browserOptions.engine);
450
+ // Refused on the engine that will run, before the chain starts.
451
+ assertProxyEngineAllowed(resolved.engine, browserOptions);
428
452
  browserOptions.stealthMode = { enabled: true, engine: resolved.engine };
429
453
  if (resolved.fallbackWarning) warnings.push(resolved.fallbackWarning);
430
454
  } else if (browserOptions.engine === 'camoufox') {
@@ -439,6 +463,15 @@ export class ScrapeWithActionsTool extends EventEmitter {
439
463
  'to use the standard Chromium browser.'
440
464
  );
441
465
  }
466
+ if (browserOptions.proxyRotation?.enabled && !browserOptions.stealth) {
467
+ // Only the stealth browser takes a proxy; the standard pool would run
468
+ // the chain from this server's own address while the caller believes
469
+ // it is proxied.
470
+ throw new Error(
471
+ 'browserOptions.proxyRotation requires browserOptions.stealth:true — only the stealth ' +
472
+ 'browser routes through a proxy. Set stealth:true, or drop proxyRotation.'
473
+ );
474
+ }
442
475
 
443
476
  // The robots override rides with the browser options so ActionExecutor's
444
477
  // gate sees it on the initial load and on every `navigate` action.
@@ -534,7 +567,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
534
567
  text: finalContent?.content?.text || '',
535
568
  html: chainResult?.finalHtml || '',
536
569
  status: chainResult?.navigationStatus ?? null
537
- }, { allowEmpty: true });
570
+ }, { allowEmpty: true, fetcher: browserOptions.stealthMode?.enabled ? 'the stealth browser' : 'the browser' });
538
571
 
539
572
  return {
540
573
  success: chainResult.success && verdict.success,
@@ -553,6 +586,9 @@ export class ScrapeWithActionsTool extends EventEmitter {
553
586
  // The landing page's consent result; each navigate action's own is on
554
587
  // its result. Absent when consent handling is off.
555
588
  ...(chainResult.consent ? { consent: chainResult.consent } : {}),
589
+ // Every navigation of the reported attempt — [0] the initial load, then
590
+ // each navigate action — with its status and any wall it reached.
591
+ navigations: chainResult.navigations || [],
556
592
 
557
593
  actionResults,
558
594
  attempt: chainResult.attempt ?? attempts.length,
@@ -576,7 +612,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
576
612
  replayedFrom: params.replayRecording || undefined,
577
613
 
578
614
  metadata: {
579
- browserOptions,
615
+ browserOptions: redactProxyCredentials(browserOptions),
580
616
  formAutoFillApplied: !!params.formAutoFill,
581
617
  intermediateStatesCount: intermediateStates.length,
582
618
  screenshotsCount: sessionContext.screenshots.length,
@@ -10,6 +10,7 @@ import { config as appConfig } from '../../../constants/config.js';
10
10
  import { ssrfGuard, isSsrfError } from '../../../utils/ssrfGuard.js';
11
11
  import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
12
12
  import { preflightFetch } from '../../../utils/robotsGate.js';
13
+ import { fetchResigned } from '../../../utils/resignedFetch.js';
13
14
  import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
14
15
  import { elementText } from '../../../utils/elementText.js';
15
16
  import { pageTitle } from '../../../utils/pageTitle.js';
@@ -31,9 +32,10 @@ export async function fetchUrl(url, options = {}) {
31
32
  const controller = new AbortController();
32
33
  const timeoutId = setTimeout(() => controller.abort(), timeout);
33
34
  try {
34
- const response = await fetch(url, {
35
+ const response = await fetchResigned(url, {
35
36
  signal: controller.signal,
36
37
  headers: { ...gate.headers, ...headers },
38
+ onRedirect: gate.onRedirect,
37
39
  ...guard
38
40
  });
39
41
  if (response.status === 429 || response.status === 503) {
@@ -8,6 +8,7 @@ import { config } from '../../constants/config.js';
8
8
  import { ssrfGuard, isSsrfError } from '../../utils/ssrfGuard.js';
9
9
  import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
10
10
  import { preflightFetch } from '../../utils/robotsGate.js';
11
+ import { fetchResigned } from '../../utils/resignedFetch.js';
11
12
 
12
13
  /**
13
14
  * Fetch a URL with a configurable timeout and body-size cap.
@@ -51,12 +52,13 @@ export async function fetchWithTimeout(url, options = {}) {
51
52
  try {
52
53
  let response;
53
54
  try {
54
- response = await fetch(url, {
55
+ response = await fetchResigned(url, {
55
56
  signal: controller.signal,
56
57
  headers: {
57
58
  ...gate.headers,
58
59
  ...headers
59
60
  },
61
+ onRedirect: gate.onRedirect,
60
62
  ...guard
61
63
  });
62
64
  } catch (error) {
@@ -223,7 +223,8 @@ export class SessionContext {
223
223
  const fetchOpts = {
224
224
  method,
225
225
  headers: requestHeaders,
226
- redirect: 'follow'
226
+ redirect: 'follow',
227
+ onRedirect: gate.onRedirect
227
228
  };
228
229
 
229
230
  if (body) {
@@ -384,7 +384,8 @@ export class MapSiteTool {
384
384
  try {
385
385
  const response = await safeFetch(url, {
386
386
  signal: controller.signal,
387
- headers: gate.headers
387
+ headers: gate.headers,
388
+ onRedirect: gate.onRedirect
388
389
  });
389
390
  clearTimeout(timeoutId);
390
391
  return response;
@@ -147,6 +147,7 @@ export async function fetchAndParse(url, options = {}) {
147
147
  ...gate.headers,
148
148
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
149
149
  },
150
+ onRedirect: gate.onRedirect,
150
151
  signal: AbortSignal.timeout(timeoutMs)
151
152
  });
152
153
 
@@ -176,7 +176,7 @@ export class ExtractContentTool {
176
176
  enableImages: options.preserveImageInfo,
177
177
  captureScreenshot: false
178
178
  }
179
- });
179
+ }, gate);
180
180
 
181
181
  if (!browserResult.success) {
182
182
  throw new Error(`Browser processing failed: ${browserResult.error}`);
@@ -188,6 +188,7 @@ export class ExtractContentTool {
188
188
  // Simple HTTP fetch
189
189
  const response = await safeFetch(url, {
190
190
  headers: { ...gate.headers },
191
+ onRedirect: gate.onRedirect,
191
192
  signal: AbortSignal.timeout(15000)
192
193
  });
193
194