crawlforge-mcp-server 6.23.1 → 6.24.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 (35) hide show
  1. package/package.json +1 -1
  2. package/server.js +21 -11
  3. package/src/core/LLMsTxtAnalyzer.js +4 -1
  4. package/src/core/pageCache.js +249 -0
  5. package/src/schemas/toolOutputSchemas.js +18 -0
  6. package/src/server/inlineThreshold.js +12 -5
  7. package/src/server/requestContext.js +40 -0
  8. package/src/server/withAuth.js +84 -9
  9. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  10. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  11. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
  12. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  13. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +4 -1
  14. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -0
  15. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
  16. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  17. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  18. package/src/tools/basic/_fetch.js +35 -4
  19. package/src/tools/basic/extractLinks.js +3 -2
  20. package/src/tools/basic/extractMetadata.js +3 -2
  21. package/src/tools/basic/extractText.js +3 -2
  22. package/src/tools/basic/fetchUrl.js +3 -2
  23. package/src/tools/basic/scrapeStructured.js +3 -2
  24. package/src/tools/extract/_fetchAndParse.js +47 -18
  25. package/src/tools/extract/extractEmbeddedState.js +58 -7
  26. package/src/tools/extract/extractStructured.js +4 -1
  27. package/src/tools/extract/extractWithLlm.js +3 -0
  28. package/src/tools/extract/processDocument.js +12 -5
  29. package/src/tools/llmstxt/generateLLMsTxt.js +25 -14
  30. package/src/tools/scrape/unifiedScrape.js +4 -1
  31. package/src/utils/canonicalUrl.js +29 -0
  32. package/src/utils/fetchLadder.js +3 -3
  33. package/src/utils/maxAgeParam.js +18 -0
  34. package/src/utils/robotsGate.js +24 -5
  35. package/src/utils/ssrfGuard.js +2 -1
@@ -21,7 +21,7 @@ import { z } from 'zod';
21
21
  import { fetchAndParse } from './_fetchAndParse.js';
22
22
  // Both live in crawlforge-extractors so the REST API's extract_embedded_state
23
23
  // runs this exact reader — one RSC flight-stream parser, not two.
24
- import { extractEmbeddedState, selectJsonPath, findJsonPaths } from 'crawlforge-extractors';
24
+ import { extractEmbeddedState, selectJsonPath, findJsonPaths, parseJsonPath } from 'crawlforge-extractors';
25
25
  import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
26
26
  import { pageTitle } from '../../utils/pageTitle.js';
27
27
  import { setActualCost, markPreflightRefusal } from '../../server/requestContext.js';
@@ -38,6 +38,12 @@ const EMBEDDED_STATE_BASE_CREDITS = 2;
38
38
  // `find` ask for less up front.
39
39
  const LARGE_RESULT_BYTES = 256_000;
40
40
 
41
+ // A path miss carries the key tree where the path stopped, so the next path is
42
+ // aimed rather than guessed (repeat-call plan 2.1). Capped per object and in
43
+ // characters; over the character cap it drops to one level.
44
+ const MISS_TREE_KEYS = 30;
45
+ const MISS_TREE_CHARS = 2000;
46
+
41
47
  // A wall, as opposed to a missing page or a broken server (G4: a 404 or a 5xx
42
48
  // never escalates — see aBrowserMightPass).
43
49
  const WALL_STATUSES = new Set([403, 429, 444]);
@@ -75,24 +81,49 @@ function label(value) {
75
81
  * Shared shape with the REST route — keep the two in step.
76
82
  * @param {unknown} value
77
83
  * @param {number} depth
84
+ * @param {number} [maxKeys] per object; the rest are counted under "…"
78
85
  */
79
- export function describeKeys(value, depth) {
86
+ export function describeKeys(value, depth, maxKeys = Infinity) {
80
87
  if (Array.isArray(value)) {
81
88
  if (depth <= 1) return label(value);
82
89
  return value.length > 0
83
- ? { length: value.length, first: describeKeys(value[0], depth - 1) }
90
+ ? { length: value.length, first: describeKeys(value[0], depth - 1, maxKeys) }
84
91
  : { length: 0 };
85
92
  }
86
93
  if (value !== null && typeof value === 'object') {
87
94
  const keys = {};
88
- for (const [key, child] of Object.entries(value)) {
89
- keys[key] = depth > 1 ? describeKeys(child, depth - 1) : label(child);
95
+ const entries = Object.entries(value);
96
+ for (const [key, child] of entries.slice(0, maxKeys)) {
97
+ keys[key] = depth > 1 ? describeKeys(child, depth - 1, maxKeys) : label(child);
90
98
  }
99
+ if (entries.length > maxKeys) keys['…'] = `${entries.length - maxKeys} more keys`;
91
100
  return keys;
92
101
  }
93
102
  return label(value);
94
103
  }
95
104
 
105
+ /**
106
+ * The key tree where `path` stops resolving: `null` when the value there has
107
+ * no keys (the path ran into a string or number).
108
+ * @param {unknown} root
109
+ * @param {string} path
110
+ * @returns {{ at: string, tree: string } | null}
111
+ */
112
+ function missKeyTree(root, path) {
113
+ let current = root;
114
+ const walked = [];
115
+ for (const segment of parseJsonPath(path)) {
116
+ const key = Array.isArray(current) ? Number(segment) : segment;
117
+ if (current === null || typeof current !== 'object' || !Object.hasOwn(current, key)) break;
118
+ current = current[key];
119
+ walked.push(segment);
120
+ }
121
+ if (current === null || typeof current !== 'object') return null;
122
+ let tree = JSON.stringify(describeKeys(current, 2, MISS_TREE_KEYS));
123
+ if (tree.length > MISS_TREE_CHARS) tree = JSON.stringify(describeKeys(current, 1, MISS_TREE_KEYS));
124
+ return { at: walked.join('.'), tree };
125
+ }
126
+
96
127
  function httpError(status) {
97
128
  return new Error(`HTTP ${status}: ${STATUS_CODES[status] ?? ''}`.trimEnd());
98
129
  }
@@ -120,7 +151,7 @@ function failed(verdict, status, gotState) {
120
151
  */
121
152
  export function createExtractEmbeddedStateHandler({ escalateFetch } = {}) {
122
153
  return async function extractEmbeddedStateHandler({
123
- url, path, keys_only, find, raw = false, escalate, escalate_engine = 'auto', wait_for, user_agent, respect_robots
154
+ url, path, keys_only, find, raw = false, escalate, escalate_engine = 'auto', wait_for, user_agent, respect_robots, maxAge
124
155
  }) {
125
156
  // Each replaces `data` with something else, so asking for both has no
126
157
  // answer. Nothing was fetched, so - like a robots refusal - it costs nothing.
@@ -149,6 +180,7 @@ export function createExtractEmbeddedStateHandler({ escalateFetch } = {}) {
149
180
  const fetched = await fetchAndParse(url, {
150
181
  userAgent: user_agent,
151
182
  respectRobots: respect_robots,
183
+ maxAge,
152
184
  tool: 'extract_embedded_state',
153
185
  errorDocuments: true
154
186
  });
@@ -283,7 +315,26 @@ export function createExtractEmbeddedStateHandler({ escalateFetch } = {}) {
283
315
  }
284
316
  }
285
317
 
286
- const data = effectivePath ? selectJsonPath(state.data, effectivePath) : state.data;
318
+ let data = state.data;
319
+ if (effectivePath) {
320
+ try {
321
+ data = selectJsonPath(state.data, effectivePath);
322
+ } catch (error) {
323
+ // A path miss: the key tree where it stopped, and a next step that
324
+ // stays on this tool (the fallback hint would say to drop `path`).
325
+ reportCost();
326
+ const miss = missKeyTree(state.data, effectivePath);
327
+ return {
328
+ content: [{
329
+ type: 'text',
330
+ text: `Failed to extract embedded state: ${error.message}` +
331
+ (miss ? `\nKeys at ${miss.at ? `"${miss.at}"` : 'the top level'}: ${miss.tree}` : '') +
332
+ '\nNext step: Call again with a path built from the keys above, or with find:"<key>" to get the paths where a field of that name lives.'
333
+ }],
334
+ isError: true
335
+ };
336
+ }
337
+ }
287
338
  const bytes = Buffer.byteLength(JSON.stringify(data) ?? '');
288
339
 
289
340
  if (!path && !keys_only && find === undefined && bytes > LARGE_RESULT_BYTES) {
@@ -9,6 +9,7 @@ import { ElicitationHelper } from '../../core/ElicitationHelper.js'; // D1.4
9
9
  import { load } from 'cheerio';
10
10
  import { LLMManager } from '../../core/llm/LLMManager.js';
11
11
  import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
12
+ import { MAX_AGE_PARAM } from '../../utils/maxAgeParam.js';
12
13
  import { fetchAndParse, flattenBodyText } from './_fetchAndParse.js';
13
14
  import { fetchErrorMessage } from '../../utils/fetchError.js';
14
15
  import { extractMainContent } from '../scrape/_mainContent.js';
@@ -267,6 +268,7 @@ const ExtractStructuredSchema = z.object({
267
268
  selectorHints: z.record(z.string()).optional(),
268
269
  respect_robots: z.boolean().optional(),
269
270
  user_agent: z.string().optional(),
271
+ ...MAX_AGE_PARAM,
270
272
  verify_numbers: z.boolean().optional().default(true)
271
273
  });
272
274
 
@@ -324,7 +326,7 @@ export class ExtractStructuredTool {
324
326
 
325
327
  try {
326
328
  const validated = ExtractStructuredSchema.parse(params);
327
- const { url, schema: requestedSchema, prompt, llmConfig, fallbackToSelectors, selectorHints, respect_robots, user_agent, verify_numbers } = validated;
329
+ const { url, schema: requestedSchema, prompt, llmConfig, fallbackToSelectors, selectorHints, respect_robots, user_agent, maxAge, verify_numbers } = validated;
328
330
  // `type` is optional on input, and the tool's own example omits it. The
329
331
  // validator reads a typeless schema as "anything", so the CSS fallback
330
332
  // reported valid:true for "2 reviews" in an integer field (R24). A schema
@@ -387,6 +389,7 @@ export class ExtractStructuredTool {
387
389
  const { html, $, textContent, warnings } = await fetchAndParse(url, {
388
390
  userAgent: user_agent || this.userAgent,
389
391
  respectRobots: respect_robots,
392
+ maxAge,
390
393
  tool: 'extract_structured'
391
394
  });
392
395
 
@@ -440,6 +440,7 @@ export class ExtractWithLlm {
440
440
  * @param {number} [params.maxTokens] - Max output tokens (default 4096)
441
441
  * @param {boolean} [params.respect_robots] - Per-request robots.txt override
442
442
  * @param {string} [params.user_agent] - Per-request identity override
443
+ * @param {number} [params.maxAge] - Page-cache age limit in ms, passed to fetchAndParse
443
444
  * @param {boolean} [params.verify_numbers] - Numeric provenance guard (default true)
444
445
  * @param {AbortSignal} [params.signal] - Aborts the model request(s); an aborted
445
446
  * call skips the retry and the sampling fallback
@@ -456,6 +457,7 @@ export class ExtractWithLlm {
456
457
  maxTokens = 4096,
457
458
  respect_robots,
458
459
  user_agent,
460
+ maxAge,
459
461
  verify_numbers = true,
460
462
  signal
461
463
  } = params;
@@ -501,6 +503,7 @@ export class ExtractWithLlm {
501
503
  const { html, textContent, warnings } = await fetchAndParse(url, {
502
504
  respectRobots: respect_robots,
503
505
  userAgent: user_agent,
506
+ maxAge,
504
507
  tool: 'extract_with_llm'
505
508
  });
506
509
  text = textContent;
@@ -187,15 +187,22 @@ const MAX_TITLE_CHARS = 200;
187
187
  /**
188
188
  * A title for a document with none in its metadata: the first non-empty line
189
189
  * of its text when that line is short enough to be one, else null. arXiv
190
- * stamps and page numbers before it are skipped.
190
+ * stamps, page numbers and lines of a prose paragraph before it are skipped:
191
+ * a line ending a sentence, or one the next line continues in lower case
192
+ * (the arXiv "Attention Is All You Need" PDF opens with a permission notice,
193
+ * live sweep 7.3).
191
194
  * @param {string} text
192
195
  * @returns {string|null}
193
196
  */
194
197
  export function titleFromText(text) {
195
- for (const line of String(text || '').split(/\r?\n/)) {
196
- const candidate = line.replace(/\s+/g, ' ').trim();
197
- if (candidate === '' || NON_TITLE_LINE_RE.test(candidate)) continue;
198
- return candidate.length <= MAX_TITLE_CHARS ? candidate : null;
198
+ const lines = String(text || '')
199
+ .split(/\r?\n/)
200
+ .map((line) => line.replace(/\s+/g, ' ').trim())
201
+ .filter((line) => line !== '' && !NON_TITLE_LINE_RE.test(line));
202
+ for (const [i, candidate] of lines.entries()) {
203
+ if (candidate.length > MAX_TITLE_CHARS) return null;
204
+ const isProse = /[.!?]$/.test(candidate) || /^\p{Ll}/u.test(lines[i + 1] || '');
205
+ if (!isProse) return candidate;
199
206
  }
200
207
  return null;
201
208
  }
@@ -218,18 +218,21 @@ export class GenerateLLMsTxtTool {
218
218
  listed.add(key);
219
219
  return true;
220
220
  };
221
- const emitSection = (heading, urls) => {
222
- // Coerce to an array: sitemap/sections may arrive as a flat array, a
223
- // grouped object ({path: [...]}), or a single value.
221
+ // Coerce to a URL array: sitemap/sections may arrive as a flat array, a
222
+ // grouped object ({path: [...]}), or a single value.
223
+ const toUrlList = (urls) => {
224
224
  let arr = [];
225
225
  if (Array.isArray(urls)) {
226
226
  arr = urls;
227
227
  } else if (urls && typeof urls === 'object') {
228
228
  arr = Object.values(urls).flat();
229
229
  }
230
- const list = arr
230
+ return arr
231
231
  .map((u) => (typeof u === 'string' ? u : (u?.url || u?.loc)))
232
- .filter(Boolean)
232
+ .filter(Boolean);
233
+ };
234
+ const emitSection = (heading, urls) => {
235
+ const list = toUrlList(urls)
233
236
  .filter(firstListing)
234
237
  .slice(0, 25);
235
238
  if (list.length === 0) return;
@@ -262,19 +265,22 @@ export class GenerateLLMsTxtTool {
262
265
  // listed only when no named section matched at all, which silently dropped
263
266
  // the homepage and whole sections (e.g. /specification) from sites that
264
267
  // happen to also have a /docs.
265
- emitSection('Pages', flatten('other'));
268
+ // Only endpoints observed answering JSON/XML are APIs. Links to API
269
+ // documentation are HTML pages ("Python's C API" on docs.python.org was
270
+ // listed under APIs, live sweep 7.4), so they are listed as pages.
271
+ const allApis = (Array.isArray(analysis.apis) ? analysis.apis : []).filter((api) => api?.url);
272
+ const isMachineReadable = (api) => /json|xml/i.test(api.contentType || '');
273
+ const docLinks = allApis.filter((api) => !isMachineReadable(api)).map((api) => api.url);
266
274
 
267
275
  // Fallback: if no categorized section produced output, list the raw
268
- // sitemap so llms.txt always carries a URL inventory. Must run BEFORE the
269
- // APIs section — an APIs entry alone used to set hasBody and suppress it.
270
- const hasBody = lines.some((l) => l.startsWith('## '));
271
- if (!hasBody) {
272
- emitSection('Pages', analysis.structure?.sitemap || []);
273
- }
276
+ // sitemap so llms.txt always carries a URL inventory. Decided BEFORE the
277
+ // linked pages and APIs are added — those alone used to suppress it.
278
+ const pageUrls = flatten('other');
279
+ const hasBody = lines.some((l) => l.startsWith('## ')) || pageUrls.length > 0;
280
+ emitSection('Pages', [...(hasBody ? pageUrls : toUrlList(analysis.structure?.sitemap)), ...docLinks]);
274
281
 
275
282
  // APIs as their own section.
276
- const apis = (Array.isArray(analysis.apis) ? analysis.apis : [])
277
- .filter((api) => api?.url && firstListing(api.url));
283
+ const apis = allApis.filter((api) => isMachineReadable(api) && firstListing(api.url));
278
284
  if (apis.length > 0) {
279
285
  lines.push('## APIs');
280
286
  lines.push('');
@@ -310,6 +316,11 @@ export class GenerateLLMsTxtTool {
310
316
  counts.set(clean, (counts.get(clean) || 0) + 1);
311
317
  };
312
318
 
319
+ // Link text of API documentation links first: a title captured from the
320
+ // page itself replaces it below.
321
+ for (const api of Array.isArray(analysis.apis) ? analysis.apis : []) {
322
+ if (api?.type === 'documentation') add(api.url, api.title);
323
+ }
313
324
  for (const entries of Object.values(analysis.contentTypes || {})) {
314
325
  if (!Array.isArray(entries)) continue;
315
326
  for (const entry of entries) {
@@ -33,6 +33,7 @@ import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
33
33
  import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
34
34
  import { extractBlockText } from '../basic/extractText.js';
35
35
  import { pageTitle } from '../../utils/pageTitle.js';
36
+ import { MAX_AGE_PARAM } from '../../utils/maxAgeParam.js';
36
37
  import { noteHostBlocked, clearHostBlocked, getHostBlock } from '../../utils/hostRateLimiter.js';
37
38
 
38
39
  // ── Schema ────────────────────────────────────────────────────────────────────
@@ -168,7 +169,8 @@ export const UnifiedScrapeSchema = z.object({
168
169
  // Compliance overrides, per request: identify as yourself for a target you
169
170
  // have your own agreement with, and take responsibility for ignoring robots.
170
171
  user_agent: z.string().optional(),
171
- respect_robots: z.boolean().optional()
172
+ respect_robots: z.boolean().optional(),
173
+ ...MAX_AGE_PARAM
172
174
  });
173
175
 
174
176
  // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -432,6 +434,7 @@ export class UnifiedScrapeTool {
432
434
  timeoutMs,
433
435
  userAgent: validated.user_agent,
434
436
  respectRobots: validated.respect_robots,
437
+ maxAge: validated.maxAge,
435
438
  tool: 'scrape',
436
439
  stripTags: [], // we handle boilerplate ourselves
437
440
  // A real wall reaches a plain fetch as a 403 with the challenge in
@@ -0,0 +1,29 @@
1
+ /**
2
+ * canonicalUrl — one spelling per page, for keys that must match when two
3
+ * calls ask for the same page (the invocation log's urlKey now, the page cache
4
+ * of REPEAT_CALL_REDUCTION_PLAN Phase 1 next).
5
+ *
6
+ * Rules (plan 1.2): lowercase scheme and host, drop the default port (80 for
7
+ * http, 443 for https) and the #fragment, keep path and query string exactly
8
+ * as written. An empty path is the root path ("/"), which is what is requested.
9
+ * Anything that does not parse as a URL returns null.
10
+ */
11
+
12
+ export function canonicalUrl(input) {
13
+ if (typeof input !== 'string') return null;
14
+ let u;
15
+ try { u = new URL(input.trim()); } catch { return null; }
16
+
17
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
18
+ u.hash = '';
19
+ return u.href;
20
+ }
21
+
22
+ // WHATWG URL already lowercases scheme and host and drops a default port,
23
+ // but it also rewrites the path (dot segments, percent-encoding), so the
24
+ // path and query are taken from the original string instead.
25
+ const rest = input.trim().match(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*([^#]*)/i)?.[1] ?? '';
26
+ const userinfo = u.username ? `${u.username}${u.password ? `:${u.password}` : ''}@` : '';
27
+ const pathAndQuery = rest === '' || rest.startsWith('?') ? `/${rest}` : rest;
28
+ return `${u.protocol}//${userinfo}${u.host}${pathAndQuery}`;
29
+ }
@@ -122,7 +122,7 @@ async function plainFetch(url, options) {
122
122
 
123
123
  /**
124
124
  * @param {string} url
125
- * @param {{ tool: 'extract_text'|'extract_links', userAgent?: string, respectRobots?: boolean,
125
+ * @param {{ tool: 'extract_text'|'extract_links', userAgent?: string, respectRobots?: boolean, maxAge?: number,
126
126
  * escalate?: boolean, escalateEngine?: string,
127
127
  * escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean }) => Promise<object> }} options
128
128
  * @returns {Promise<
@@ -132,10 +132,10 @@ async function plainFetch(url, options) {
132
132
  * `fields` carries `escalated` (only when escalate was asked for), `stealth`
133
133
  * (when the stage ran) and `rendered: false` (a client-rendered shell)
134
134
  */
135
- export async function fetchLadder(url, { tool, userAgent, respectRobots, escalate = false, escalateEngine = 'auto', escalateFetch } = {}) {
135
+ export async function fetchLadder(url, { tool, userAgent, respectRobots, maxAge, escalate = false, escalateEngine = 'auto', escalateFetch } = {}) {
136
136
  let escalationRan = false;
137
137
  try {
138
- const response = await plainFetch(url, { timeout: LADDER_TIMEOUT_MS, userAgent, respectRobots, tool });
138
+ const response = await plainFetch(url, { timeout: LADDER_TIMEOUT_MS, userAgent, respectRobots, maxAge, tool });
139
139
  const type = mediaType(response);
140
140
  if (response.ok && type && !TEXTUAL_TYPE.test(type)) return { unsupported: type };
141
141
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * maxAgeParam — the one declaration of `maxAge` (repeat-call reduction plan,
3
+ * Phase 1, 1.5 / D2). Passed through as `options.maxAge` to the two page-cache
4
+ * seams, fetchWithTimeout and fetchAndParse; undefined means the server's
5
+ * default TTL there.
6
+ */
7
+
8
+ import { z } from 'zod';
9
+ import { pageCacheActive } from '../core/pageCache.js';
10
+
11
+ export const MAX_AGE_PARAM = {
12
+ maxAge: z.number().int().min(0).optional().describe("Reuse this session's copy of the page if it was fetched within this many ms (default 600000 = 10 min); 0 always fetches fresh.")
13
+ };
14
+
15
+ // Phase 3 (3.1): the one sentence the nine cached tools' descriptions carry.
16
+ // Empty where the cache is off (hosted HTTP, CRAWLFORGE_PAGE_CACHE_MB=0).
17
+ export const PAGE_CACHE_TEXT = ' Asking again for the same page with different formats, paths or selectors reuses this session\'s copy for 10 minutes at 1 credit; pass maxAge: 0 to force a fresh fetch.';
18
+ export const PAGE_CACHE_SENTENCE = pageCacheActive() ? PAGE_CACHE_TEXT : '';
@@ -273,6 +273,29 @@ export function outboundHeaders(userAgent, signature = {}) {
273
273
  };
274
274
  }
275
275
 
276
+ /**
277
+ * The gate's decision, refusing as {@link preflightFetch} does, without the
278
+ * per-host throttle. A page-cache hit (src/core/pageCache.js) sends no
279
+ * request, so there is nothing to space, but the blocklist and robots.txt are
280
+ * asked again: either may have changed since the copy was fetched.
281
+ *
282
+ * @param {string} url
283
+ * @param {object} [options] see {@link robotsPreflight}
284
+ * @param {{ redirectedFrom?: string }} [reached] set when `url` is where a
285
+ * redirect from the requested URL led
286
+ * @returns {Promise<{ allowed: true, userAgent: string, crawlDelayMs: number,
287
+ * warnings: string[], overridden: boolean }>}
288
+ * @throws {BlockedHostError|RobotsDisallowedError}
289
+ */
290
+ export async function preflightDecision(url, options = {}, reached = {}) {
291
+ const decision = await robotsPreflight(url, options);
292
+ if (!decision.allowed) {
293
+ markPreflightRefusal('ROBOTS_DISALLOWED');
294
+ throw new RobotsDisallowedError(url, reached);
295
+ }
296
+ return decision;
297
+ }
298
+
276
299
  /**
277
300
  * The call-site helper: run the gate, honour Crawl-delay and any recorded
278
301
  * `Retry-After`, and hand back the identity headers to send and the gate for
@@ -286,11 +309,7 @@ export function outboundHeaders(userAgent, signature = {}) {
286
309
  * @throws {BlockedHostError|RobotsDisallowedError}
287
310
  */
288
311
  export async function preflightFetch(url, options = {}) {
289
- const decision = await robotsPreflight(url, options);
290
- if (!decision.allowed) {
291
- markPreflightRefusal('ROBOTS_DISALLOWED');
292
- throw new RobotsDisallowedError(url);
293
- }
312
+ const decision = await preflightDecision(url, options);
294
313
 
295
314
  await throttleHost(url, { crawlDelayMs: decision.crawlDelayMs });
296
315
 
@@ -342,7 +342,8 @@ export async function safeFetch(url, options = {}) {
342
342
  return await fetchResigned(url, { ...options, ...guard });
343
343
  } catch (err) {
344
344
  if (isSsrfError(err)) {
345
- throw new Error(err.cause?.message || err.message);
345
+ // Keep the code: callers tell a refusal from a fetch failure by it.
346
+ throw Object.assign(new Error(err.cause?.message || err.message), { code: 'SSRF_BLOCKED' });
346
347
  }
347
348
  throw withFetchCause(err, url);
348
349
  }