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.
- package/CLAUDE.md +6 -5
- package/README.md +19 -3
- package/package.json +10 -12
- package/server.js +315 -214
- package/src/core/ActionExecutor.js +117 -33
- package/src/core/AgentOrchestrator.js +8 -2
- package/src/core/AuthManager.js +51 -17
- package/src/core/ChangeTracker.js +26 -10
- package/src/core/JobManager.js +9 -1
- package/src/core/LocalizationManager.js +19 -6
- package/src/core/ResearchOrchestrator.js +173 -35
- package/src/core/SnapshotManager.js +162 -165
- package/src/core/StealthBrowserManager.js +25 -3
- package/src/core/WebhookDispatcher.js +19 -14
- package/src/core/analysis/ContentAnalyzer.js +52 -7
- package/src/core/crawlers/BFSCrawler.js +27 -3
- package/src/core/processing/BrowserProcessor.js +19 -1
- package/src/core/processing/PDFProcessor.js +129 -65
- package/src/core/queue/QueueManager.js +3 -2
- package/src/schemas/toolOutputSchemas.js +269 -0
- package/src/server/auth/oauth.js +37 -7
- package/src/server/specHygiene.js +192 -0
- package/src/server/taskSupport.js +233 -0
- package/src/server/toolFilter.js +98 -0
- package/src/server/transports/streamableHttp.js +148 -11
- package/src/server/withAuth.js +11 -4
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +15 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +43 -52
- package/src/tools/advanced/batchScrape/index.js +128 -27
- package/src/tools/advanced/batchScrape/worker.js +55 -5
- package/src/tools/advanced/scrapeWithActions/recorder.js +3 -0
- package/src/tools/basic/_fetch.js +125 -70
- package/src/tools/basic/extractLinks.js +14 -12
- package/src/tools/basic/scrapeStructured.js +21 -4
- package/src/tools/crawl/crawlDeep.js +110 -48
- package/src/tools/crawl/mapSite.js +25 -6
- package/src/tools/extract/_fetchAndParse.js +98 -1
- package/src/tools/extract/extractContent.js +7 -4
- package/src/tools/extract/extractStructured.js +125 -84
- package/src/tools/extract/extractWithLlm.js +10 -2
- package/src/tools/extract/processDocument.js +54 -6
- package/src/tools/extract/summarizeContent.js +7 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +8 -6
- package/src/tools/research/deepResearch.js +51 -31
- package/src/tools/scrape/_brandingExtractor.js +49 -11
- package/src/tools/scrape/unifiedScrape.js +27 -17
- package/src/tools/search/providers/searxng.js +5 -1
- package/src/tools/search/ranking/ResultDeduplicator.js +9 -1
- package/src/tools/search/ranking/ResultRanker.js +17 -2
- package/src/tools/search/searchWeb.js +31 -14
- package/src/tools/search/serpRank.js +23 -0
- package/src/tools/templates/TemplateRegistry.js +7 -1
- package/src/tools/tracking/trackChanges/index.js +87 -26
- package/src/tools/tracking/trackChanges/schema.js +2 -2
- package/src/utils/CircuitBreaker.js +11 -9
- package/src/utils/contentUtils.js +66 -53
- package/src/utils/secretMask.js +1 -1
- package/src/utils/sitemapParser.js +11 -9
- package/src/utils/ssrfGuard.js +212 -40
- 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
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
23
|
-
const pageUrl = new 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
|
-
|
|
37
|
-
|
|
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
|
|
21
|
-
if
|
|
22
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
142
|
+
if (effectiveMaxPages > 500) {
|
|
114
143
|
const proceed = await this._elicitation.confirm(
|
|
115
|
-
`crawl_deep will crawl up to ${
|
|
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:
|
|
119
|
-
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: !
|
|
146
|
-
defaultMaxDepth:
|
|
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:
|
|
191
|
-
maxPages:
|
|
192
|
-
followExternal:
|
|
193
|
-
respectRobots:
|
|
219
|
+
maxDepth: effectiveMaxDepth,
|
|
220
|
+
maxPages: effectiveMaxPages,
|
|
221
|
+
followExternal: effectiveFollowExternal,
|
|
222
|
+
respectRobots: effectiveRespectRobots,
|
|
194
223
|
userAgent: this.userAgent,
|
|
195
224
|
timeout: this.timeout,
|
|
196
|
-
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
283
|
+
const response = await safeFetch(url, {
|
|
265
284
|
signal: controller.signal,
|
|
266
285
|
headers: {
|
|
267
286
|
'User-Agent': this.userAgent
|