crawlforge-mcp-server 4.9.0 → 5.0.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 (60) hide show
  1. package/CLAUDE.md +6 -5
  2. package/README.md +19 -3
  3. package/package.json +10 -12
  4. package/server.js +315 -214
  5. package/src/core/ActionExecutor.js +117 -33
  6. package/src/core/AgentOrchestrator.js +8 -2
  7. package/src/core/AuthManager.js +51 -17
  8. package/src/core/ChangeTracker.js +26 -10
  9. package/src/core/JobManager.js +9 -1
  10. package/src/core/LocalizationManager.js +19 -6
  11. package/src/core/ResearchOrchestrator.js +173 -35
  12. package/src/core/SnapshotManager.js +162 -165
  13. package/src/core/StealthBrowserManager.js +25 -3
  14. package/src/core/WebhookDispatcher.js +19 -14
  15. package/src/core/analysis/ContentAnalyzer.js +52 -7
  16. package/src/core/crawlers/BFSCrawler.js +27 -3
  17. package/src/core/processing/BrowserProcessor.js +19 -1
  18. package/src/core/processing/PDFProcessor.js +129 -65
  19. package/src/core/queue/QueueManager.js +3 -2
  20. package/src/schemas/toolOutputSchemas.js +269 -0
  21. package/src/server/auth/oauth.js +37 -7
  22. package/src/server/specHygiene.js +192 -0
  23. package/src/server/taskSupport.js +233 -0
  24. package/src/server/toolFilter.js +98 -0
  25. package/src/server/transports/streamableHttp.js +148 -11
  26. package/src/server/withAuth.js +11 -4
  27. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +15 -0
  28. package/src/tools/advanced/ScrapeWithActionsTool.js +43 -52
  29. package/src/tools/advanced/batchScrape/index.js +128 -27
  30. package/src/tools/advanced/batchScrape/worker.js +55 -5
  31. package/src/tools/advanced/scrapeWithActions/recorder.js +3 -0
  32. package/src/tools/basic/_fetch.js +125 -70
  33. package/src/tools/basic/extractLinks.js +14 -12
  34. package/src/tools/basic/scrapeStructured.js +21 -4
  35. package/src/tools/crawl/crawlDeep.js +110 -48
  36. package/src/tools/crawl/mapSite.js +25 -6
  37. package/src/tools/extract/_fetchAndParse.js +98 -1
  38. package/src/tools/extract/extractContent.js +7 -4
  39. package/src/tools/extract/extractStructured.js +125 -84
  40. package/src/tools/extract/extractWithLlm.js +10 -2
  41. package/src/tools/extract/processDocument.js +54 -6
  42. package/src/tools/extract/summarizeContent.js +7 -1
  43. package/src/tools/llmstxt/generateLLMsTxt.js +8 -6
  44. package/src/tools/research/deepResearch.js +51 -31
  45. package/src/tools/scrape/_brandingExtractor.js +49 -11
  46. package/src/tools/scrape/unifiedScrape.js +27 -17
  47. package/src/tools/search/providers/searxng.js +5 -1
  48. package/src/tools/search/ranking/ResultDeduplicator.js +9 -1
  49. package/src/tools/search/ranking/ResultRanker.js +17 -2
  50. package/src/tools/search/searchWeb.js +31 -14
  51. package/src/tools/search/serpRank.js +23 -0
  52. package/src/tools/templates/TemplateRegistry.js +7 -1
  53. package/src/tools/tracking/trackChanges/index.js +87 -26
  54. package/src/tools/tracking/trackChanges/schema.js +2 -2
  55. package/src/utils/CircuitBreaker.js +11 -9
  56. package/src/utils/contentUtils.js +66 -53
  57. package/src/utils/secretMask.js +1 -1
  58. package/src/utils/sitemapParser.js +11 -9
  59. package/src/utils/ssrfGuard.js +212 -40
  60. package/src/utils/urlNormalizer.js +2 -2
@@ -13,6 +13,35 @@ const _require = createRequire(import.meta.url);
13
13
  const _pkg = _require('../../../package.json');
14
14
  const CRAWLFORGE_UA = `CrawlForge/${_pkg.version} (+https://crawlforge.dev)`;
15
15
 
16
+ /**
17
+ * Determine the charset to decode a response body with: Content-Type header
18
+ * first, then a <meta charset> sniff of the first bytes, defaulting to utf-8.
19
+ * @param {Response} response
20
+ * @param {Uint8Array} bytes
21
+ * @returns {string}
22
+ */
23
+ function detectCharset(response, bytes) {
24
+ const contentType = response.headers?.get?.('content-type') || '';
25
+ const headerMatch = /charset=["']?([\w-]+)/i.exec(contentType);
26
+ if (headerMatch) {
27
+ return headerMatch[1].trim().toLowerCase();
28
+ }
29
+
30
+ // <meta charset> tags must appear within the first 1024 bytes per the
31
+ // HTML5 spec's prescan algorithm; ASCII-range bytes decode identically
32
+ // under latin1 regardless of the document's real encoding.
33
+ const sniffLength = Math.min(bytes.byteLength, 1024);
34
+ const sniffText = new TextDecoder('latin1').decode(bytes.subarray(0, sniffLength));
35
+ const metaMatch =
36
+ /<meta[^>]+charset=["']?([\w-]+)/i.exec(sniffText) ||
37
+ /<meta[^>]+http-equiv=["']?content-type["']?[^>]*content=["'][^"']*charset=([\w-]+)/i.exec(sniffText);
38
+ if (metaMatch) {
39
+ return metaMatch[1].trim().toLowerCase();
40
+ }
41
+
42
+ return 'utf-8';
43
+ }
44
+
16
45
  /**
17
46
  * Fetch a URL with a configurable timeout and body-size cap.
18
47
  *
@@ -39,83 +68,109 @@ export async function fetchWithTimeout(url, options = {}) {
39
68
  const controller = new AbortController();
40
69
  const timeoutId = setTimeout(() => controller.abort(), timeout);
41
70
 
42
- let response;
71
+ // The timeout must stay armed for the entire body read, not just until
72
+ // headers arrive — a stalled/trickling body (slowloris, hung proxy) would
73
+ // otherwise hang the awaiting reader.read() forever. clearTimeout runs in
74
+ // this finally, after the body has been fully consumed (or an error has
75
+ // already ended the request), and any abort() during that window rejects
76
+ // the in-flight reader.read() with an AbortError, which we map below.
43
77
  try {
44
- response = await fetch(url, {
45
- signal: controller.signal,
46
- headers: {
47
- 'User-Agent': CRAWLFORGE_UA,
48
- ...headers
49
- },
50
- ...guard
51
- });
52
- clearTimeout(timeoutId);
53
- } catch (error) {
54
- clearTimeout(timeoutId);
55
- if (isSsrfError(error)) {
56
- throw new Error(error.cause?.message || error.message);
78
+ let response;
79
+ try {
80
+ response = await fetch(url, {
81
+ signal: controller.signal,
82
+ headers: {
83
+ 'User-Agent': CRAWLFORGE_UA,
84
+ ...headers
85
+ },
86
+ ...guard
87
+ });
88
+ } catch (error) {
89
+ if (isSsrfError(error)) {
90
+ throw new Error(error.cause?.message || error.message);
91
+ }
92
+ if (error.name === 'AbortError') {
93
+ throw new Error(`Request timeout after ${timeout}ms`);
94
+ }
95
+ throw error;
96
+ }
97
+
98
+ // --- Body-size cap ---
99
+
100
+ // Early rejection via Content-Length (servers may omit or lie — guard below
101
+ // handles that case). Optional-chained so non-standard responses (e.g. test
102
+ // mocks) without a Headers object don't throw.
103
+ const contentLengthHeader = response.headers?.get?.('content-length') ?? null;
104
+ if (contentLengthHeader !== null) {
105
+ const declared = parseInt(contentLengthHeader, 10);
106
+ if (!isNaN(declared) && declared > maxBodySize) {
107
+ throw new Error(
108
+ `Response body too large: Content-Length ${declared} exceeds limit of ${maxBodySize} bytes`
109
+ );
110
+ }
57
111
  }
58
- if (error.name === 'AbortError') {
59
- throw new Error(`Request timeout after ${timeout}ms`);
112
+
113
+ // Only the streaming byte-count guard requires a readable body. Responses
114
+ // without a ReadableStream body (already-buffered responses, test mocks)
115
+ // are returned unchanged so callers' native .text()/.json() still work.
116
+ if (!response.body || typeof response.body.getReader !== 'function') {
117
+ return response;
60
118
  }
61
- throw error;
62
- }
63
119
 
64
- // --- Body-size cap ---
65
-
66
- // Early rejection via Content-Length (servers may omit or lie — guard below
67
- // handles that case). Optional-chained so non-standard responses (e.g. test
68
- // mocks) without a Headers object don't throw.
69
- const contentLengthHeader = response.headers?.get?.('content-length') ?? null;
70
- if (contentLengthHeader !== null) {
71
- const declared = parseInt(contentLengthHeader, 10);
72
- if (!isNaN(declared) && declared > maxBodySize) {
73
- throw new Error(
74
- `Response body too large: Content-Length ${declared} exceeds limit of ${maxBodySize} bytes`
75
- );
120
+ // Stream the body and abort if accumulated bytes exceed the cap.
121
+ const reader = response.body.getReader();
122
+ const chunks = [];
123
+ let totalBytes = 0;
124
+
125
+ try {
126
+ while (true) {
127
+ const { done, value } = await reader.read();
128
+ if (done) break;
129
+ totalBytes += value.byteLength;
130
+ if (totalBytes > maxBodySize) {
131
+ reader.cancel();
132
+ throw new Error(
133
+ `Response body too large: exceeded limit of ${maxBodySize} bytes`
134
+ );
135
+ }
136
+ chunks.push(value);
137
+ }
138
+ } catch (error) {
139
+ if (error.name === 'AbortError') {
140
+ throw new Error(`Request timeout after ${timeout}ms`);
141
+ }
142
+ throw error;
76
143
  }
77
- }
78
144
 
79
- // Only the streaming byte-count guard requires a readable body. Responses
80
- // without a ReadableStream body (already-buffered responses, test mocks)
81
- // are returned unchanged so callers' native .text()/.json() still work.
82
- if (!response.body || typeof response.body.getReader !== 'function') {
83
- return response;
84
- }
145
+ // Reassemble the raw bytes in a single pass (totalBytes is already known,
146
+ // so this is one allocation + one copy per chunk, not the O(n^2) cost of
147
+ // reallocating/copying the whole buffer on every chunk), then decode using
148
+ // the response's actual charset (Content-Type header, falling back to a
149
+ // <meta charset> sniff) instead of always assuming UTF-8.
150
+ const mergedBytes = new Uint8Array(totalBytes);
151
+ let offset = 0;
152
+ for (const chunk of chunks) {
153
+ mergedBytes.set(chunk, offset);
154
+ offset += chunk.byteLength;
155
+ }
85
156
 
86
- // Stream the body and abort if accumulated bytes exceed the cap.
87
- const reader = response.body.getReader();
88
- const chunks = [];
89
- let totalBytes = 0;
90
-
91
- while (true) {
92
- const { done, value } = await reader.read();
93
- if (done) break;
94
- totalBytes += value.byteLength;
95
- if (totalBytes > maxBodySize) {
96
- reader.cancel();
97
- throw new Error(
98
- `Response body too large: exceeded limit of ${maxBodySize} bytes`
99
- );
157
+ const charset = detectCharset(response, mergedBytes);
158
+ let bodyText;
159
+ try {
160
+ bodyText = new TextDecoder(charset).decode(mergedBytes);
161
+ } catch {
162
+ // Unrecognized charset label — fall back to UTF-8 rather than throwing.
163
+ bodyText = new TextDecoder().decode(mergedBytes);
100
164
  }
101
- chunks.push(value);
102
- }
103
165
 
104
- // Reassemble and expose as a response-like object that callers can use.
105
- const bodyText = new TextDecoder().decode(
106
- chunks.reduce((acc, chunk) => {
107
- const merged = new Uint8Array(acc.byteLength + chunk.byteLength);
108
- merged.set(acc, 0);
109
- merged.set(chunk, acc.byteLength);
110
- return merged;
111
- }, new Uint8Array(0))
112
- );
113
-
114
- // Attach the pre-read text so callers can call .text() on the result.
115
- // We wrap it in a minimal compatible object.
116
- return Object.assign(response, {
117
- text: () => Promise.resolve(bodyText),
118
- json: () => Promise.resolve(JSON.parse(bodyText)),
119
- _body: bodyText
120
- });
166
+ // Attach the pre-read text so callers can call .text() on the result.
167
+ // We wrap it in a minimal compatible object.
168
+ return Object.assign(response, {
169
+ text: () => Promise.resolve(bodyText),
170
+ json: () => Promise.resolve(JSON.parse(bodyText)),
171
+ _body: bodyText
172
+ });
173
+ } finally {
174
+ clearTimeout(timeoutId);
175
+ }
121
176
  }
@@ -19,8 +19,18 @@ export async function extractLinksHandler({ url, filter_external, base_url }) {
19
19
  const html = await response.text();
20
20
  const $ = load(html);
21
21
 
22
- const baseUrl = base_url || new URL(url).origin;
23
- const pageUrl = new URL(url);
22
+ const finalUrl = response.url || url;
23
+ const pageUrl = new URL(finalUrl);
24
+
25
+ // <base href>, if present, overrides the page URL as the resolution base
26
+ // for relative links (but an explicit base_url override wins over both).
27
+ let docBase = finalUrl;
28
+ const baseHref = $('base[href]').first().attr('href');
29
+ if (baseHref) {
30
+ try { docBase = new URL(baseHref, finalUrl).toString(); } catch { /* ignore invalid <base href> */ }
31
+ }
32
+
33
+ const baseUrl = base_url || docBase;
24
34
  const links = [];
25
35
 
26
36
  $('a[href]').each((_, element) => {
@@ -29,17 +39,9 @@ export async function extractLinksHandler({ url, filter_external, base_url }) {
29
39
 
30
40
  if (!href) return;
31
41
 
32
- let absoluteUrl;
33
- let isExternal = false;
34
-
35
42
  try {
36
- if (href.startsWith('http://') || href.startsWith('https://')) {
37
- absoluteUrl = href;
38
- isExternal = new URL(href).origin !== pageUrl.origin;
39
- } else {
40
- absoluteUrl = new URL(href, baseUrl).toString();
41
- isExternal = false;
42
- }
43
+ const absoluteUrl = new URL(href, baseUrl).toString();
44
+ const isExternal = new URL(absoluteUrl).origin !== pageUrl.origin;
43
45
 
44
46
  if (filter_external && !isExternal) return;
45
47
 
@@ -8,18 +8,32 @@
8
8
  import { load } from 'cheerio';
9
9
  import { fetchWithTimeout } from './_fetch.js';
10
10
 
11
+ // Matches a trailing "@attr" suffix (e.g. "@href", "@data-id") — the attribute
12
+ // name must look like a real attribute, not the "@" of a CSS attribute-value
13
+ // selector such as a[href*="@"].
14
+ const ATTR_SUFFIX_RE = /@[A-Za-z_:][\w:.-]*$/;
15
+
11
16
  /**
12
17
  * Parse a selector string that may include an attribute suffix: "css@attr"
13
18
  * e.g. "a.link@href" -> { selector: "a.link", attribute: "href" }
14
19
  * "img@src" -> { selector: "img", attribute: "src" }
15
20
  * "h1" -> { selector: "h1", attribute: null }
21
+ * 'a[href*="@"]' -> { selector: 'a[href*="@"]', attribute: null }
16
22
  * @param {string} raw
17
23
  * @returns {{ selector: string, attribute: string|null }}
18
24
  */
19
25
  function parseSelectorSpec(raw) {
20
- const atIdx = raw.lastIndexOf('@');
21
- if (atIdx > 0) {
22
- return { selector: raw.slice(0, atIdx), attribute: raw.slice(atIdx + 1) };
26
+ const match = ATTR_SUFFIX_RE.exec(raw);
27
+ // Only treat it as an attribute suffix if it isn't inside brackets/quotes,
28
+ // i.e. the selector portion before it has balanced [ ] and quotes.
29
+ if (match && match.index > 0) {
30
+ const selectorPart = raw.slice(0, match.index);
31
+ const openBrackets = (selectorPart.match(/\[/g) || []).length;
32
+ const closeBrackets = (selectorPart.match(/\]/g) || []).length;
33
+ const quoteCount = (selectorPart.match(/["']/g) || []).length;
34
+ if (openBrackets === closeBrackets && quoteCount % 2 === 0) {
35
+ return { selector: selectorPart, attribute: raw.slice(match.index + 1) };
36
+ }
23
37
  }
24
38
  return { selector: raw, attribute: null };
25
39
  }
@@ -64,7 +78,10 @@ export async function scrapeStructuredHandler({ url, selectors, max_results }) {
64
78
  if (elements.length === 1) {
65
79
  results[fieldName] = extract(elements.get(0));
66
80
  } else {
67
- results[fieldName] = elements.map((_, el) => extract(el)).get();
81
+ // cheerio's .map().get() drops null/undefined results, which would
82
+ // desynchronize this array from elements_found and from parallel
83
+ // fields. Build it from toArray() instead so length always matches.
84
+ results[fieldName] = elements.toArray().map(extract);
68
85
  }
69
86
  }
70
87
  } catch (selectorError) {
@@ -82,7 +82,12 @@ export class CrawlDeepTool {
82
82
  userAgent = 'MCP-WebScraper/1.0',
83
83
  timeout = 30000,
84
84
  cacheEnabled = true,
85
- cacheTTL = 3600000
85
+ cacheTTL = 3600000,
86
+ maxDepth = 5,
87
+ maxPages = 100,
88
+ respectRobots = true,
89
+ followExternal = false,
90
+ concurrency = 10
86
91
  } = options;
87
92
 
88
93
  this.userAgent = userAgent;
@@ -91,6 +96,13 @@ export class CrawlDeepTool {
91
96
  this.cache = cacheEnabled ? new CacheManager({ ttl: cacheTTL }) : null;
92
97
  // D1.4: Elicitation helper
93
98
  this._elicitation = new ElicitationHelper({});
99
+ // Server-configured ceilings/defaults (MAX_CRAWL_DEPTH, MAX_PAGES_PER_CRAWL,
100
+ // RESPECT_ROBOTS_TXT, FOLLOW_EXTERNAL_LINKS, QUEUE_CONCURRENCY)
101
+ this.configMaxDepth = maxDepth;
102
+ this.configMaxPages = maxPages;
103
+ this.configRespectRobots = respectRobots;
104
+ this.configFollowExternal = followExternal;
105
+ this.configConcurrency = concurrency;
94
106
  }
95
107
 
96
108
  /** D1.4: Wire MCP server for elicitation. Call from server.js after instantiation. */
@@ -102,21 +114,38 @@ export class CrawlDeepTool {
102
114
  try {
103
115
  const validated = CrawlDeepSchema.parse(params);
104
116
 
117
+ // Apply server-configured ceilings/defaults: max_depth/max_pages are
118
+ // clamped to the operator's configured maxima; respect_robots/
119
+ // follow_external/concurrency fall back to the configured value only
120
+ // when the caller left them unspecified (raw params, since zod has
121
+ // already filled in its own schema default by this point).
122
+ const effectiveMaxDepth = Math.min(validated.max_depth, this.configMaxDepth);
123
+ const effectiveMaxPages = Math.min(validated.max_pages, this.configMaxPages);
124
+ const effectiveRespectRobots = params.respect_robots === undefined ? this.configRespectRobots : validated.respect_robots;
125
+ const effectiveFollowExternal = params.follow_external === undefined ? this.configFollowExternal : validated.follow_external;
126
+ const effectiveConcurrency = params.concurrency === undefined ? this.configConcurrency : validated.concurrency;
127
+
105
128
  // Cache dedup: skip re-crawling the same root URL within the TTL window
106
129
  if (this.cache) {
107
- const cacheKey = this.cache.generateKey('crawl_deep', { url: validated.url, depth: validated.max_depth, pages: validated.max_pages });
130
+ const cacheKey = this._buildCacheKey(validated, {
131
+ maxDepth: effectiveMaxDepth,
132
+ maxPages: effectiveMaxPages,
133
+ respectRobots: effectiveRespectRobots,
134
+ followExternal: effectiveFollowExternal,
135
+ concurrency: effectiveConcurrency
136
+ });
108
137
  const cached = await this.cache.get(cacheKey);
109
138
  if (cached) return cached;
110
139
  }
111
140
 
112
141
  // D1.4: Elicitation — warn when max_pages is very high
113
- if (validated.max_pages > 500) {
142
+ if (effectiveMaxPages > 500) {
114
143
  const proceed = await this._elicitation.confirm(
115
- `crawl_deep will crawl up to ${validated.max_pages} pages from ${validated.url}. Large crawls consume many credits.`,
144
+ `crawl_deep will crawl up to ${effectiveMaxPages} pages from ${validated.url}. Large crawls consume many credits.`,
116
145
  {
117
146
  url: validated.url,
118
- max_pages: validated.max_pages,
119
- max_depth: validated.max_depth,
147
+ max_pages: effectiveMaxPages,
148
+ max_depth: effectiveMaxDepth,
120
149
  }
121
150
  );
122
151
  if (!proceed) {
@@ -142,8 +171,8 @@ export class CrawlDeepTool {
142
171
  } else if (validated.domain_filter) {
143
172
  // Create from inline configuration
144
173
  domainFilter = new DomainFilter({
145
- allowSubdomains: !validated.follow_external,
146
- defaultMaxDepth: validated.max_depth,
174
+ allowSubdomains: !effectiveFollowExternal,
175
+ defaultMaxDepth: effectiveMaxDepth,
147
176
  defaultRateLimit: 10
148
177
  });
149
178
 
@@ -187,60 +216,93 @@ export class CrawlDeepTool {
187
216
 
188
217
  // Create crawler instance
189
218
  const crawler = new BFSCrawler({
190
- maxDepth: validated.max_depth,
191
- maxPages: validated.max_pages,
192
- followExternal: validated.follow_external,
193
- respectRobots: validated.respect_robots,
219
+ maxDepth: effectiveMaxDepth,
220
+ maxPages: effectiveMaxPages,
221
+ followExternal: effectiveFollowExternal,
222
+ respectRobots: effectiveRespectRobots,
194
223
  userAgent: this.userAgent,
195
224
  timeout: this.timeout,
196
- concurrency: validated.concurrency,
225
+ concurrency: effectiveConcurrency,
197
226
  domainFilter: domainFilter,
198
227
  enableLinkAnalysis: validated.enable_link_analysis,
199
228
  linkAnalyzerOptions: validated.link_analysis_options,
200
229
  sessionContext
201
230
  });
202
-
231
+
203
232
  // Start crawling
204
- const startTime = Date.now();
205
- const results = await crawler.crawl(validated.url, {
206
- includePatterns: validated.include_patterns,
207
- excludePatterns: validated.exclude_patterns,
208
- extractContent: validated.extract_content
209
- });
210
- const duration = Date.now() - startTime;
211
-
212
- // Process and format results
213
- const response = {
214
- url: validated.url,
215
- crawl_depth: validated.max_depth,
216
- pages_crawled: results.urls.length,
217
- pages_found: results.results.length,
218
- errors: results.errors.length,
219
- duration_ms: duration,
220
- pages_per_second: results.urls.length / (duration / 1000),
221
- results: this.formatResults(results.results, validated.extract_content, validated.content_max_length),
222
- errors: results.errors,
223
- stats: results.stats,
224
- site_structure: this.analyzeSiteStructure(results.urls),
225
- domain_filter_config: domainFilter ? domainFilter.exportConfig() : null,
226
- link_analysis: results.linkAnalysis,
227
- session: sessionContext
228
- ? { enabled: true, cookies_captured: sessionContext.cookieCount }
229
- : { enabled: false }
230
- };
231
-
232
- // Store in cache before returning
233
- if (this.cache) {
234
- const cacheKey = this.cache.generateKey('crawl_deep', { url: validated.url, depth: validated.max_depth, pages: validated.max_pages });
235
- await this.cache.set(cacheKey, response);
236
- }
233
+ try {
234
+ const startTime = Date.now();
235
+ const results = await crawler.crawl(validated.url, {
236
+ includePatterns: validated.include_patterns,
237
+ excludePatterns: validated.exclude_patterns,
238
+ extractContent: validated.extract_content
239
+ });
240
+ const duration = Date.now() - startTime;
241
+
242
+ // Process and format results
243
+ const response = {
244
+ url: validated.url,
245
+ crawl_depth: effectiveMaxDepth,
246
+ pages_crawled: results.urls.length,
247
+ pages_found: results.results.length,
248
+ error_count: results.errors.length,
249
+ duration_ms: duration,
250
+ pages_per_second: results.urls.length / (duration / 1000),
251
+ results: this.formatResults(results.results, validated.extract_content, validated.content_max_length),
252
+ errors: results.errors,
253
+ stats: results.stats,
254
+ site_structure: this.analyzeSiteStructure(results.urls),
255
+ domain_filter_config: domainFilter ? domainFilter.exportConfig() : null,
256
+ link_analysis: results.linkAnalysis,
257
+ session: sessionContext
258
+ ? { enabled: true, cookies_captured: sessionContext.cookieCount }
259
+ : { enabled: false }
260
+ };
261
+
262
+ // Store in cache before returning
263
+ if (this.cache) {
264
+ const cacheKey = this._buildCacheKey(validated, {
265
+ maxDepth: effectiveMaxDepth,
266
+ maxPages: effectiveMaxPages,
267
+ respectRobots: effectiveRespectRobots,
268
+ followExternal: effectiveFollowExternal,
269
+ concurrency: effectiveConcurrency
270
+ });
271
+ await this.cache.set(cacheKey, response);
272
+ }
237
273
 
238
- return response;
274
+ return response;
275
+ } finally {
276
+ // Release the per-crawl CacheManager's timers so the crawler
277
+ // instance (and its cached page bodies) can be garbage collected.
278
+ crawler.destroy();
279
+ }
239
280
  } catch (error) {
240
281
  throw new Error(`Crawl failed: ${error.message}`);
241
282
  }
242
283
  }
243
284
 
285
+ // Cache key must reflect every validated/effective field that changes the
286
+ // response, otherwise a hit under one set of options silently returns a
287
+ // result produced under different extraction/filtering/session settings.
288
+ _buildCacheKey(validated, effective) {
289
+ return this.cache.generateKey('crawl_deep', {
290
+ url: validated.url,
291
+ depth: effective.maxDepth,
292
+ pages: effective.maxPages,
293
+ extractContent: validated.extract_content,
294
+ contentMaxLength: validated.content_max_length,
295
+ includePatterns: validated.include_patterns,
296
+ excludePatterns: validated.exclude_patterns,
297
+ followExternal: effective.followExternal,
298
+ respectRobots: effective.respectRobots,
299
+ concurrency: effective.concurrency,
300
+ domainFilter: validated.domain_filter ?? null,
301
+ importFilterConfig: validated.import_filter_config ?? null,
302
+ sessionEnabled: validated.session?.enabled ?? false
303
+ });
304
+ }
305
+
244
306
  formatResults(results, includeContent, contentMaxLength = 500) {
245
307
  return results.map(result => {
246
308
  const formatted = {
@@ -5,6 +5,7 @@ import { normalizeUrl, getBaseUrl } from '../../utils/urlNormalizer.js';
5
5
  import { CacheManager } from '../../core/cache/CacheManager.js';
6
6
  import { SitemapParser } from '../../utils/sitemapParser.js';
7
7
  import { ResultRanker } from '../search/ranking/ResultRanker.js';
8
+ import { safeFetch } from '../../utils/ssrfGuard.js';
8
9
 
9
10
  // Lazy singleton — avoids creating a CacheManager timer per request
10
11
  let _ranker = null;
@@ -52,7 +53,7 @@ export class MapSiteTool {
52
53
 
53
54
  // Cache dedup: skip re-mapping the same site within the TTL window
54
55
  if (this.cache) {
55
- const cacheKey = this.cache.generateKey('map_site', { url: validated.url, maxUrls: validated.max_urls });
56
+ const cacheKey = this._buildCacheKey(validated);
56
57
  const cached = await this.cache.get(cacheKey);
57
58
  if (cached) return cached;
58
59
  }
@@ -93,7 +94,7 @@ export class MapSiteTool {
93
94
 
94
95
  // Try to fetch sitemap first
95
96
  if (validated.include_sitemap) {
96
- const sitemapUrls = await this.fetchSitemapUrls(baseUrl, domainFilter);
97
+ const sitemapUrls = await this.fetchSitemapUrls(baseUrl, domainFilter, validated.max_urls);
97
98
  sitemapUrls.forEach(url => urls.add(normalizeUrl(url)));
98
99
  }
99
100
 
@@ -150,7 +151,7 @@ export class MapSiteTool {
150
151
 
151
152
  // Store in cache before returning
152
153
  if (this.cache) {
153
- const cacheKey = this.cache.generateKey('map_site', { url: validated.url, maxUrls: validated.max_urls });
154
+ const cacheKey = this._buildCacheKey(validated);
154
155
  await this.cache.set(cacheKey, result);
155
156
  }
156
157
 
@@ -160,7 +161,22 @@ export class MapSiteTool {
160
161
  }
161
162
  }
162
163
 
163
- async fetchSitemapUrls(baseUrl, domainFilter = null) {
164
+ // Cache key must reflect every validated field that changes the response,
165
+ // otherwise a hit under one set of options silently returns another's
166
+ // result (e.g. search ranking or domain filtering getting dropped/leaked).
167
+ _buildCacheKey(validated) {
168
+ return this.cache.generateKey('map_site', {
169
+ url: validated.url,
170
+ maxUrls: validated.max_urls,
171
+ search: validated.search ?? null,
172
+ domainFilter: validated.domain_filter ?? null,
173
+ importFilterConfig: validated.import_filter_config ?? null,
174
+ includeMetadata: validated.include_metadata,
175
+ groupByPath: validated.group_by_path
176
+ });
177
+ }
178
+
179
+ async fetchSitemapUrls(baseUrl, domainFilter = null, maxUrls = Infinity) {
164
180
  // Discover sitemaps via robots.txt and common paths, then parse with full
165
181
  // SitemapParser support (sitemap-index recursion, gzip, CDATA/entities).
166
182
  const discovered = await this.sitemapParser.discoverSitemaps(baseUrl, {
@@ -182,9 +198,12 @@ export class MapSiteTool {
182
198
  if (!domainFilter || domainFilter.isAllowed(url).allowed) {
183
199
  urls.add(url);
184
200
  }
201
+ if (urls.size >= maxUrls) break;
185
202
  }
186
203
  }
187
- if (urls.size > 0) break;
204
+ // Keep accumulating across every discovered sitemap (a site can
205
+ // declare several) instead of stopping at the first productive one.
206
+ if (urls.size >= maxUrls) break;
188
207
  } catch {
189
208
  // Continue to next discovered sitemap
190
209
  }
@@ -261,7 +280,7 @@ export class MapSiteTool {
261
280
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
262
281
 
263
282
  try {
264
- const response = await fetch(url, {
283
+ const response = await safeFetch(url, {
265
284
  signal: controller.signal,
266
285
  headers: {
267
286
  'User-Agent': this.userAgent