crawlforge-mcp-server 5.2.9 → 5.3.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 +13 -1
- package/README.md +9 -9
- package/package.json +2 -2
- package/server.js +175 -26
- package/src/cli/commands/stealth.js +7 -1
- package/src/constants/config.js +2 -1
- package/src/core/ActionExecutor.js +168 -16
- package/src/core/AlertNotificationSystem.js +2 -1
- package/src/core/AuthManager.js +19 -1
- package/src/core/ChangeTracker.js +34 -6
- package/src/core/LLMsTxtAnalyzer.js +94 -12
- package/src/core/LocalizationManager.js +2 -1
- package/src/core/ResearchOrchestrator.js +407 -86
- package/src/core/StealthBrowserManager.js +186 -105
- package/src/core/WebhookDispatcher.js +3 -4
- package/src/core/analysis/ContentAnalyzer.js +41 -15
- package/src/core/analysis/sentenceUtils.js +16 -5
- package/src/core/crawlers/BFSCrawler.js +44 -21
- package/src/core/llm/LLMManager.js +473 -0
- package/src/core/processing/BrowserProcessor.js +27 -0
- package/src/core/processing/ContentProcessor.js +11 -39
- package/src/core/processing/PDFProcessor.js +2 -3
- package/src/core/research/claimFilters.js +235 -0
- package/src/schemas/toolOutputSchemas.js +5 -1
- package/src/security/wave3-security.js +2 -1
- package/src/server/requestContext.js +23 -0
- package/src/server/withAuth.js +21 -5
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
- package/src/tools/advanced/batchScrape/schema.js +4 -0
- package/src/tools/advanced/batchScrape/worker.js +19 -10
- package/src/tools/basic/_fetch.js +19 -15
- package/src/tools/basic/extractLinks.js +8 -3
- package/src/tools/basic/extractMetadata.js +7 -3
- package/src/tools/basic/extractText.js +8 -3
- package/src/tools/basic/fetchUrl.js +7 -3
- package/src/tools/basic/scrapeStructured.js +76 -3
- package/src/tools/crawl/_sessionContext.js +10 -2
- package/src/tools/crawl/crawlDeep.js +29 -12
- package/src/tools/crawl/mapSite.js +39 -14
- package/src/tools/extract/_fetchAndParse.js +23 -8
- package/src/tools/extract/analyzeContent.js +5 -3
- package/src/tools/extract/extractContent.js +18 -4
- package/src/tools/extract/extractStructured.js +66 -12
- package/src/tools/extract/extractWithLlm.js +51 -4
- package/src/tools/extract/processDocument.js +45 -78
- package/src/tools/extract/summarizeContent.js +35 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
- package/src/tools/research/deepResearch.js +2 -1
- package/src/tools/scrape/_brandingExtractor.js +42 -3
- package/src/tools/scrape/_mainContent.js +105 -0
- package/src/tools/scrape/unifiedScrape.js +21 -14
- package/src/tools/search/adapters/redditOfficialApi.js +7 -6
- package/src/tools/search/redditSearch.js +6 -3
- package/src/tools/search/searchWeb.js +26 -3
- package/src/tools/templates/ScrapeTemplateTool.js +17 -6
- package/src/tools/tracking/trackChanges/differ.js +26 -3
- package/src/tools/tracking/trackChanges/index.js +12 -5
- package/src/tools/tracking/trackChanges/notifier.js +3 -1
- package/src/tools/tracking/trackChanges/schema.js +3 -0
- package/src/utils/complianceAudit.js +72 -0
- package/src/utils/contentUtils.js +12 -1
- package/src/utils/domainFilter.js +38 -19
- package/src/utils/fetchIdentity.js +62 -0
- package/src/utils/hostBlocklist.js +81 -0
- package/src/utils/hostRateLimiter.js +101 -2
- package/src/utils/robotsChecker.js +90 -43
- package/src/utils/robotsGate.js +206 -0
- package/src/utils/sitemapParser.js +33 -15
- package/src/utils/ssrfProtection.js +2 -1
- package/src/utils/webBotAuth.js +193 -0
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared HTTP fetch helper for basic tools.
|
|
3
|
-
* Applies an AbortController timeout and
|
|
3
|
+
* Applies an AbortController timeout and the shared pre-fetch gate.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { readBody } from 'crawlforge-extractors';
|
|
7
7
|
import { config } from '../../constants/config.js';
|
|
8
|
-
import { createRequire } from 'module';
|
|
9
8
|
import { ssrfGuard, isSsrfError } from '../../utils/ssrfGuard.js';
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
// Derive User-Agent from package version so it reflects the actual release.
|
|
13
|
-
const _require = createRequire(import.meta.url);
|
|
14
|
-
const _pkg = _require('../../../package.json');
|
|
15
|
-
const CRAWLFORGE_UA = `CrawlForge/${_pkg.version} (+https://crawlforge.dev)`;
|
|
9
|
+
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
10
|
+
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
16
11
|
|
|
17
12
|
/**
|
|
18
13
|
* Fetch a URL with a configurable timeout and body-size cap.
|
|
@@ -23,19 +18,21 @@ const CRAWLFORGE_UA = `CrawlForge/${_pkg.version} (+https://crawlforge.dev)`;
|
|
|
23
18
|
* default 25 MB).
|
|
24
19
|
*
|
|
25
20
|
* @param {string} url
|
|
26
|
-
* @param {{ timeout?: number, headers?: Record<string,string
|
|
27
|
-
*
|
|
21
|
+
* @param {{ timeout?: number, headers?: Record<string,string>, userAgent?: string,
|
|
22
|
+
* respectRobots?: boolean, tool?: string, apiKey?: string }} [options]
|
|
23
|
+
* @returns {Promise<Response & { _body: string, _warnings: string[] }>}
|
|
28
24
|
*/
|
|
29
25
|
export async function fetchWithTimeout(url, options = {}) {
|
|
30
|
-
const { timeout = 10000, headers = {} } = options;
|
|
26
|
+
const { timeout = 10000, headers = {}, userAgent, respectRobots, tool, apiKey } = options;
|
|
31
27
|
const maxBodySize = config.fetch.maxBodySize;
|
|
32
28
|
|
|
33
29
|
// SSRF pre-flight (protocol / metadata host). Throws a clear error before any
|
|
34
30
|
// connection is attempted; `guard.dispatcher` enforces IP rules at connect time.
|
|
35
31
|
const guard = ssrfGuard(url);
|
|
36
32
|
|
|
37
|
-
//
|
|
38
|
-
|
|
33
|
+
// robots.txt / blocklist gate + per-host politeness throttle, before the
|
|
34
|
+
// timeout window starts. Throws if the gate refuses the URL.
|
|
35
|
+
const gate = await preflightFetch(url, { userAgent, respectRobots, tool, apiKey });
|
|
39
36
|
|
|
40
37
|
const controller = new AbortController();
|
|
41
38
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
@@ -57,7 +54,7 @@ export async function fetchWithTimeout(url, options = {}) {
|
|
|
57
54
|
response = await fetch(url, {
|
|
58
55
|
signal: controller.signal,
|
|
59
56
|
headers: {
|
|
60
|
-
|
|
57
|
+
...gate.headers,
|
|
61
58
|
...headers
|
|
62
59
|
},
|
|
63
60
|
...guard
|
|
@@ -72,6 +69,12 @@ export async function fetchWithTimeout(url, options = {}) {
|
|
|
72
69
|
throw error;
|
|
73
70
|
}
|
|
74
71
|
|
|
72
|
+
// A host asking us to back off is honoured on the *next* request to it,
|
|
73
|
+
// rather than retrying straight into the wall.
|
|
74
|
+
if (response.status === 429 || response.status === 503) {
|
|
75
|
+
noteRetryAfter(url, response.headers?.get?.('retry-after'));
|
|
76
|
+
}
|
|
77
|
+
|
|
75
78
|
// Reading is delegated to crawlforge-extractors so the REST API applies
|
|
76
79
|
// the same cap and the same charset handling to the same page.
|
|
77
80
|
let bodyText;
|
|
@@ -89,7 +92,8 @@ export async function fetchWithTimeout(url, options = {}) {
|
|
|
89
92
|
text: () => Promise.resolve(bodyText),
|
|
90
93
|
json: () => Promise.resolve(JSON.parse(bodyText)),
|
|
91
94
|
_body: bodyText,
|
|
92
|
-
_responseTime: Date.now() - startedAt
|
|
95
|
+
_responseTime: Date.now() - startedAt,
|
|
96
|
+
_warnings: gate.warnings
|
|
93
97
|
});
|
|
94
98
|
} finally {
|
|
95
99
|
clearTimeout(timeoutId);
|
|
@@ -7,11 +7,16 @@ import { load } from 'cheerio';
|
|
|
7
7
|
import { fetchWithTimeout } from './_fetch.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* @param {{ url: string, filter_external?: boolean, base_url?: string
|
|
10
|
+
* @param {{ url: string, filter_external?: boolean, base_url?: string,
|
|
11
|
+
* user_agent?: string, respect_robots?: boolean }} params
|
|
11
12
|
*/
|
|
12
|
-
export async function extractLinksHandler({ url, filter_external, base_url }) {
|
|
13
|
+
export async function extractLinksHandler({ url, filter_external, base_url, user_agent, respect_robots }) {
|
|
13
14
|
try {
|
|
14
|
-
const response = await fetchWithTimeout(url
|
|
15
|
+
const response = await fetchWithTimeout(url, {
|
|
16
|
+
userAgent: user_agent,
|
|
17
|
+
respectRobots: respect_robots,
|
|
18
|
+
tool: 'extract_links'
|
|
19
|
+
});
|
|
15
20
|
if (!response.ok) {
|
|
16
21
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
17
22
|
}
|
|
@@ -60,11 +60,15 @@ function parseMicrodata($) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* @param {{ url: string }} params
|
|
63
|
+
* @param {{ url: string, user_agent?: string, respect_robots?: boolean }} params
|
|
64
64
|
*/
|
|
65
|
-
export async function extractMetadataHandler({ url }) {
|
|
65
|
+
export async function extractMetadataHandler({ url, user_agent, respect_robots }) {
|
|
66
66
|
try {
|
|
67
|
-
const response = await fetchWithTimeout(url
|
|
67
|
+
const response = await fetchWithTimeout(url, {
|
|
68
|
+
userAgent: user_agent,
|
|
69
|
+
respectRobots: respect_robots,
|
|
70
|
+
tool: 'extract_metadata'
|
|
71
|
+
});
|
|
68
72
|
if (!response.ok) {
|
|
69
73
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
70
74
|
}
|
|
@@ -74,11 +74,16 @@ export function readabilityToMarkdown(html, pageUrl) {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
|
-
* @param {{ url: string, remove_scripts?: boolean, remove_styles?: boolean,
|
|
77
|
+
* @param {{ url: string, remove_scripts?: boolean, remove_styles?: boolean,
|
|
78
|
+
* output_format?: "text"|"markdown", user_agent?: string, respect_robots?: boolean }} params
|
|
78
79
|
*/
|
|
79
|
-
export async function extractTextHandler({ url, remove_scripts, remove_styles, output_format }) {
|
|
80
|
+
export async function extractTextHandler({ url, remove_scripts, remove_styles, output_format, user_agent, respect_robots }) {
|
|
80
81
|
try {
|
|
81
|
-
const response = await fetchWithTimeout(url
|
|
82
|
+
const response = await fetchWithTimeout(url, {
|
|
83
|
+
userAgent: user_agent,
|
|
84
|
+
respectRobots: respect_robots,
|
|
85
|
+
tool: 'extract_text'
|
|
86
|
+
});
|
|
82
87
|
if (!response.ok) {
|
|
83
88
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
84
89
|
}
|
|
@@ -6,13 +6,17 @@
|
|
|
6
6
|
import { fetchWithTimeout } from './_fetch.js';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* @param {{ url: string, headers?: Record<string,string>, timeout?: number
|
|
9
|
+
* @param {{ url: string, headers?: Record<string,string>, timeout?: number,
|
|
10
|
+
* user_agent?: string, respect_robots?: boolean }} params
|
|
10
11
|
*/
|
|
11
|
-
export async function fetchUrlHandler({ url, headers, timeout }) {
|
|
12
|
+
export async function fetchUrlHandler({ url, headers, timeout, user_agent, respect_robots }) {
|
|
12
13
|
try {
|
|
13
14
|
const response = await fetchWithTimeout(url, {
|
|
14
15
|
timeout: timeout || 10000,
|
|
15
|
-
headers: headers || {}
|
|
16
|
+
headers: headers || {},
|
|
17
|
+
userAgent: user_agent,
|
|
18
|
+
respectRobots: respect_robots,
|
|
19
|
+
tool: 'fetch_url'
|
|
16
20
|
});
|
|
17
21
|
|
|
18
22
|
const body = await response.text();
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Extracted from server.js inline handler.
|
|
4
4
|
* B1: Support attribute extraction (selector@attr), add max_results,
|
|
5
5
|
* fix elements_found to report real per-field DOM match counts.
|
|
6
|
+
* 3.2: Optional row_selector returns row-aligned records instead of the
|
|
7
|
+
* default parallel arrays, which are matched independently per field.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import { load } from 'cheerio';
|
|
@@ -39,17 +41,88 @@ function parseSelectorSpec(raw) {
|
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
/**
|
|
42
|
-
*
|
|
44
|
+
* Row-aligned extraction: every field is matched *inside* each row element, so
|
|
45
|
+
* record N of the output is row N of the page. A field a row lacks is null
|
|
46
|
+
* rather than borrowed from a neighbouring row, which is what the default
|
|
47
|
+
* parallel-array output does — there each field is matched independently across
|
|
48
|
+
* the whole document and the arrays are not row-aligned.
|
|
49
|
+
*
|
|
50
|
+
* @param {import('cheerio').CheerioAPI} $
|
|
51
|
+
* @param {{ row_selector: string, selectors: Record<string, string>, max_results?: number }} params
|
|
52
|
+
* @returns {{ records: object[], rowsFound: number, matchCounts: Record<string, number> }}
|
|
43
53
|
*/
|
|
44
|
-
|
|
54
|
+
function extractRows($, { row_selector, selectors, max_results }) {
|
|
55
|
+
const allRows = $(row_selector);
|
|
56
|
+
// In row mode max_results caps rows, not per-field matches.
|
|
57
|
+
const rows = (max_results != null && max_results > 0)
|
|
58
|
+
? allRows.slice(0, max_results)
|
|
59
|
+
: allRows;
|
|
60
|
+
|
|
61
|
+
const specs = Object.entries(selectors).map(([field, raw]) => [field, raw, parseSelectorSpec(raw)]);
|
|
62
|
+
const matchCounts = Object.fromEntries(specs.map(([field]) => [field, 0]));
|
|
63
|
+
|
|
64
|
+
const records = rows.toArray().map((rowEl) => {
|
|
65
|
+
const $row = $(rowEl);
|
|
66
|
+
const record = {};
|
|
67
|
+
for (const [field, raw, { selector, attribute }] of specs) {
|
|
68
|
+
try {
|
|
69
|
+
// A field selector may name the row element itself (row "tr.athing",
|
|
70
|
+
// field "tr@id"); .find() searches descendants only.
|
|
71
|
+
const el = $row.is(selector) ? $row : $row.find(selector).first();
|
|
72
|
+
if (el.length === 0) {
|
|
73
|
+
record[field] = null;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
record[field] = attribute ? (el.attr(attribute) ?? null) : el.text().trim();
|
|
77
|
+
matchCounts[field] += 1;
|
|
78
|
+
} catch (selectorError) {
|
|
79
|
+
record[field] = {
|
|
80
|
+
error: `Invalid selector: ${raw}`,
|
|
81
|
+
message: selectorError.message
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return record;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return { records, rowsFound: allRows.length, matchCounts };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {{ url: string, selectors: Record<string, string>, row_selector?: string,
|
|
93
|
+
* max_results?: number, user_agent?: string, respect_robots?: boolean }} params
|
|
94
|
+
*/
|
|
95
|
+
export async function scrapeStructuredHandler({ url, selectors, row_selector, max_results, user_agent, respect_robots }) {
|
|
45
96
|
try {
|
|
46
|
-
const response = await fetchWithTimeout(url
|
|
97
|
+
const response = await fetchWithTimeout(url, {
|
|
98
|
+
userAgent: user_agent,
|
|
99
|
+
respectRobots: respect_robots,
|
|
100
|
+
tool: 'scrape_structured'
|
|
101
|
+
});
|
|
47
102
|
if (!response.ok) {
|
|
48
103
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
49
104
|
}
|
|
50
105
|
|
|
51
106
|
const html = await response.text();
|
|
52
107
|
const $ = load(html);
|
|
108
|
+
|
|
109
|
+
if (row_selector) {
|
|
110
|
+
const { records, rowsFound, matchCounts } = extractRows($, { row_selector, selectors, max_results });
|
|
111
|
+
return {
|
|
112
|
+
content: [{
|
|
113
|
+
type: 'text',
|
|
114
|
+
text: JSON.stringify({
|
|
115
|
+
data: records,
|
|
116
|
+
selectors_used: selectors,
|
|
117
|
+
row_selector,
|
|
118
|
+
rows_found: rowsFound,
|
|
119
|
+
elements_found: matchCounts,
|
|
120
|
+
url: response.url
|
|
121
|
+
}, null, 2)
|
|
122
|
+
}]
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
53
126
|
const results = {};
|
|
54
127
|
const matchCounts = {};
|
|
55
128
|
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
19
|
+
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Parse a single Set-Cookie header value into a cookie object.
|
|
@@ -201,13 +202,20 @@ export class SessionContext {
|
|
|
201
202
|
* any cookies it sets into the jar. Returns the response body text.
|
|
202
203
|
*
|
|
203
204
|
* @param {{ url: string, method?: string, headers?: Record<string,string>, body?: string }} req
|
|
205
|
+
* @param {{ userAgent?: string, respectRobots?: boolean }} [options]
|
|
204
206
|
* @returns {Promise<{ status: number, body: string }>}
|
|
205
207
|
*/
|
|
206
|
-
async performInitialRequest(req) {
|
|
208
|
+
async performInitialRequest(req, options = {}) {
|
|
207
209
|
const { url, method = 'GET', headers: extraHeaders = {}, body } = req;
|
|
208
210
|
|
|
211
|
+
const gate = await preflightFetch(url, {
|
|
212
|
+
respectRobots: options.respectRobots,
|
|
213
|
+
userAgent: options.userAgent,
|
|
214
|
+
tool: 'crawl_deep'
|
|
215
|
+
});
|
|
216
|
+
|
|
209
217
|
const requestHeaders = this.applyToHeaders(url, {
|
|
210
|
-
|
|
218
|
+
...gate.headers,
|
|
211
219
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
212
220
|
...extraHeaders
|
|
213
221
|
});
|
|
@@ -4,6 +4,7 @@ import { BFSCrawler } from '../../core/crawlers/BFSCrawler.js';
|
|
|
4
4
|
import { DomainFilter } from '../../utils/domainFilter.js';
|
|
5
5
|
import { CacheManager } from '../../core/cache/CacheManager.js';
|
|
6
6
|
import { SessionContext } from './_sessionContext.js';
|
|
7
|
+
import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
|
|
7
8
|
|
|
8
9
|
const CrawlDeepSchema = z.object({
|
|
9
10
|
url: z.string().url(),
|
|
@@ -79,7 +80,7 @@ const CrawlDeepSchema = z.object({
|
|
|
79
80
|
export class CrawlDeepTool {
|
|
80
81
|
constructor(options = {}) {
|
|
81
82
|
const {
|
|
82
|
-
userAgent =
|
|
83
|
+
userAgent = CRAWLFORGE_USER_AGENT,
|
|
83
84
|
timeout = 30000,
|
|
84
85
|
cacheEnabled = true,
|
|
85
86
|
cacheTTL = 3600000,
|
|
@@ -218,7 +219,10 @@ export class CrawlDeepTool {
|
|
|
218
219
|
|
|
219
220
|
// Perform optional login / pre-crawl request
|
|
220
221
|
if (validated.session.initialRequest) {
|
|
221
|
-
await sessionContext.performInitialRequest(validated.session.initialRequest
|
|
222
|
+
await sessionContext.performInitialRequest(validated.session.initialRequest, {
|
|
223
|
+
userAgent: this.userAgent,
|
|
224
|
+
respectRobots: effectiveRespectRobots
|
|
225
|
+
});
|
|
222
226
|
}
|
|
223
227
|
}
|
|
224
228
|
|
|
@@ -263,7 +267,7 @@ export class CrawlDeepTool {
|
|
|
263
267
|
results: this.formatResults(results.results, validated.extract_content, validated.content_max_length),
|
|
264
268
|
errors: results.errors,
|
|
265
269
|
stats: results.stats,
|
|
266
|
-
site_structure: this.analyzeSiteStructure(results.urls),
|
|
270
|
+
site_structure: this.analyzeSiteStructure(results.urls, results.results),
|
|
267
271
|
domain_filter_config: domainFilter ? domainFilter.exportConfig() : null,
|
|
268
272
|
link_analysis: results.linkAnalysis,
|
|
269
273
|
session: sessionContext
|
|
@@ -344,23 +348,35 @@ export class CrawlDeepTool {
|
|
|
344
348
|
});
|
|
345
349
|
}
|
|
346
350
|
|
|
347
|
-
analyzeSiteStructure(urls) {
|
|
351
|
+
analyzeSiteStructure(urls, pages = []) {
|
|
348
352
|
const structure = {
|
|
349
353
|
total_pages: urls.length,
|
|
350
354
|
depth_distribution: {},
|
|
355
|
+
path_depth_distribution: {},
|
|
351
356
|
path_patterns: {},
|
|
352
357
|
file_types: {},
|
|
353
358
|
subdomains: new Set()
|
|
354
359
|
};
|
|
355
|
-
|
|
360
|
+
|
|
361
|
+
// depth_distribution reports crawl depth — how many links from the start URL each
|
|
362
|
+
// page was reached at — taken from the depth the crawler recorded on each result.
|
|
363
|
+
// URL path depth is a different measurement and keeps its own field below.
|
|
364
|
+
for (const page of pages) {
|
|
365
|
+
const depth = page?.depth;
|
|
366
|
+
if (typeof depth === 'number') {
|
|
367
|
+
structure.depth_distribution[depth] = (structure.depth_distribution[depth] || 0) + 1;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
356
371
|
for (const url of urls) {
|
|
357
372
|
try {
|
|
358
373
|
const urlObj = new URL(url);
|
|
359
|
-
|
|
360
|
-
// Analyze depth
|
|
361
|
-
const
|
|
362
|
-
structure.
|
|
363
|
-
|
|
374
|
+
|
|
375
|
+
// Analyze URL path depth
|
|
376
|
+
const pathDepth = urlObj.pathname.split('/').filter(s => s).length;
|
|
377
|
+
structure.path_depth_distribution[pathDepth] =
|
|
378
|
+
(structure.path_depth_distribution[pathDepth] || 0) + 1;
|
|
379
|
+
|
|
364
380
|
// Analyze path patterns
|
|
365
381
|
const pathSegments = urlObj.pathname.split('/').filter(s => s);
|
|
366
382
|
if (pathSegments.length > 0) {
|
|
@@ -442,10 +458,11 @@ export class CrawlDeepTool {
|
|
|
442
458
|
* Analyze site structure with enhanced link analysis
|
|
443
459
|
* @param {Array} urls - Crawled URLs
|
|
444
460
|
* @param {Object} linkAnalysis - Link analysis results
|
|
461
|
+
* @param {Array} [pages] - Crawl results, each carrying the depth it was reached at
|
|
445
462
|
* @returns {Object} Enhanced site structure analysis
|
|
446
463
|
*/
|
|
447
|
-
analyzeEnhancedSiteStructure(urls, linkAnalysis = null) {
|
|
448
|
-
const basicStructure = this.analyzeSiteStructure(urls);
|
|
464
|
+
analyzeEnhancedSiteStructure(urls, linkAnalysis = null, pages = []) {
|
|
465
|
+
const basicStructure = this.analyzeSiteStructure(urls, pages);
|
|
449
466
|
|
|
450
467
|
if (!linkAnalysis) {
|
|
451
468
|
return basicStructure;
|
|
@@ -6,6 +6,8 @@ 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
8
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
9
|
+
import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
|
|
10
|
+
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
9
11
|
|
|
10
12
|
// Lazy singleton — avoids creating a CacheManager timer per request
|
|
11
13
|
let _ranker = null;
|
|
@@ -28,13 +30,17 @@ const MapSiteSchema = z.object({
|
|
|
28
30
|
exclude_patterns: z.array(z.string()).optional().default([])
|
|
29
31
|
}).optional(),
|
|
30
32
|
import_filter_config: z.string().optional(), // JSON string of exported config
|
|
31
|
-
search: z.string().optional() // when set, rank URLs by relevance and emit ranked_urls
|
|
33
|
+
search: z.string().optional(), // when set, rank URLs by relevance and emit ranked_urls
|
|
34
|
+
// Compliance overrides, per request: identify as yourself for a target you
|
|
35
|
+
// have your own agreement with, and take responsibility for ignoring robots.
|
|
36
|
+
user_agent: z.string().optional(),
|
|
37
|
+
respect_robots: z.boolean().optional()
|
|
32
38
|
});
|
|
33
39
|
|
|
34
40
|
export class MapSiteTool {
|
|
35
41
|
constructor(options = {}) {
|
|
36
42
|
const {
|
|
37
|
-
userAgent =
|
|
43
|
+
userAgent = CRAWLFORGE_USER_AGENT,
|
|
38
44
|
timeout = 10000,
|
|
39
45
|
cacheEnabled = true,
|
|
40
46
|
cacheTTL = 3600000
|
|
@@ -62,6 +68,14 @@ export class MapSiteTool {
|
|
|
62
68
|
const urls = new Set();
|
|
63
69
|
const metadata = new Map();
|
|
64
70
|
|
|
71
|
+
// Per-request identity/robots overrides, carried to every fetch this
|
|
72
|
+
// call makes. Not stored on the instance: one tool object serves
|
|
73
|
+
// concurrent requests.
|
|
74
|
+
const identity = {
|
|
75
|
+
userAgent: validated.user_agent,
|
|
76
|
+
respectRobots: validated.respect_robots
|
|
77
|
+
};
|
|
78
|
+
|
|
65
79
|
// Create domain filter if configuration provided
|
|
66
80
|
let domainFilter = null;
|
|
67
81
|
if (validated.import_filter_config) {
|
|
@@ -99,7 +113,7 @@ export class MapSiteTool {
|
|
|
99
113
|
}
|
|
100
114
|
|
|
101
115
|
// Fetch and parse the main page for additional URLs
|
|
102
|
-
const pageUrls = await this.fetchPageUrls(validated.url, domainFilter);
|
|
116
|
+
const pageUrls = await this.fetchPageUrls(validated.url, domainFilter, identity);
|
|
103
117
|
pageUrls.forEach(url => {
|
|
104
118
|
if (urls.size < validated.max_urls) {
|
|
105
119
|
urls.add(normalizeUrl(url));
|
|
@@ -111,7 +125,7 @@ export class MapSiteTool {
|
|
|
111
125
|
|
|
112
126
|
// Fetch metadata if requested
|
|
113
127
|
if (validated.include_metadata) {
|
|
114
|
-
await this.fetchMetadata(urlArray.slice(0, 50), metadata); // Limit metadata fetching
|
|
128
|
+
await this.fetchMetadata(urlArray.slice(0, 50), metadata, identity); // Limit metadata fetching
|
|
115
129
|
}
|
|
116
130
|
|
|
117
131
|
// Organize results
|
|
@@ -172,7 +186,9 @@ export class MapSiteTool {
|
|
|
172
186
|
domainFilter: validated.domain_filter ?? null,
|
|
173
187
|
importFilterConfig: validated.import_filter_config ?? null,
|
|
174
188
|
includeMetadata: validated.include_metadata,
|
|
175
|
-
groupByPath: validated.group_by_path
|
|
189
|
+
groupByPath: validated.group_by_path,
|
|
190
|
+
userAgent: validated.user_agent ?? null,
|
|
191
|
+
respectRobots: validated.respect_robots ?? null
|
|
176
192
|
});
|
|
177
193
|
}
|
|
178
194
|
|
|
@@ -212,9 +228,9 @@ export class MapSiteTool {
|
|
|
212
228
|
return Array.from(urls);
|
|
213
229
|
}
|
|
214
230
|
|
|
215
|
-
async fetchPageUrls(url, domainFilter = null) {
|
|
231
|
+
async fetchPageUrls(url, domainFilter = null, identity = {}) {
|
|
216
232
|
try {
|
|
217
|
-
const response = await this.fetchWithTimeout(url);
|
|
233
|
+
const response = await this.fetchWithTimeout(url, identity);
|
|
218
234
|
if (!response.ok) {
|
|
219
235
|
return [];
|
|
220
236
|
}
|
|
@@ -246,15 +262,19 @@ export class MapSiteTool {
|
|
|
246
262
|
});
|
|
247
263
|
|
|
248
264
|
return Array.from(urls);
|
|
249
|
-
} catch {
|
|
265
|
+
} catch (error) {
|
|
266
|
+
// A gate refusal is the answer to the request, not a page we failed to
|
|
267
|
+
// read: surface it instead of returning an emptier map than the caller
|
|
268
|
+
// would notice.
|
|
269
|
+
if (error.code === 'ROBOTS_DISALLOWED' || error.code === 'HOST_BLOCKED') throw error;
|
|
250
270
|
return [];
|
|
251
271
|
}
|
|
252
272
|
}
|
|
253
273
|
|
|
254
|
-
async fetchMetadata(urls, metadataMap) {
|
|
274
|
+
async fetchMetadata(urls, metadataMap, identity = {}) {
|
|
255
275
|
const promises = urls.slice(0, 10).map(async (url) => {
|
|
256
276
|
try {
|
|
257
|
-
const response = await this.fetchWithTimeout(url);
|
|
277
|
+
const response = await this.fetchWithTimeout(url, identity);
|
|
258
278
|
if (response.ok) {
|
|
259
279
|
const html = await response.text();
|
|
260
280
|
const $ = load(html);
|
|
@@ -275,16 +295,21 @@ export class MapSiteTool {
|
|
|
275
295
|
await Promise.allSettled(promises);
|
|
276
296
|
}
|
|
277
297
|
|
|
278
|
-
async fetchWithTimeout(url) {
|
|
298
|
+
async fetchWithTimeout(url, { userAgent, respectRobots } = {}) {
|
|
299
|
+
// robots.txt / blocklist gate + per-host throttle. Throws if it refuses.
|
|
300
|
+
const gate = await preflightFetch(url, {
|
|
301
|
+
userAgent: userAgent || this.userAgent,
|
|
302
|
+
respectRobots,
|
|
303
|
+
tool: 'map_site'
|
|
304
|
+
});
|
|
305
|
+
|
|
279
306
|
const controller = new AbortController();
|
|
280
307
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
281
308
|
|
|
282
309
|
try {
|
|
283
310
|
const response = await safeFetch(url, {
|
|
284
311
|
signal: controller.signal,
|
|
285
|
-
headers:
|
|
286
|
-
'User-Agent': this.userAgent
|
|
287
|
-
}
|
|
312
|
+
headers: gate.headers
|
|
288
313
|
});
|
|
289
314
|
clearTimeout(timeoutId);
|
|
290
315
|
return response;
|
|
@@ -6,15 +6,16 @@
|
|
|
6
6
|
* extractContent.js (uses native fetch directly but can adopt this)
|
|
7
7
|
* processDocument.js (URL sources)
|
|
8
8
|
*
|
|
9
|
-
* Returns { html, $, textContent, finalUrl } so callers don't repeat
|
|
9
|
+
* Returns { html, $, textContent, finalUrl, warnings } so callers don't repeat
|
|
10
10
|
* the same fetch/cheerio/cleanup boilerplate.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { load } from 'cheerio';
|
|
14
14
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
15
15
|
import { config } from '../../constants/config.js';
|
|
16
|
+
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
17
|
+
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
16
18
|
|
|
17
|
-
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; CrawlForge-MCP/3.0)';
|
|
18
19
|
const DEFAULT_TIMEOUT_MS = 15000;
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -132,27 +133,41 @@ export function flattenBodyText($) {
|
|
|
132
133
|
*
|
|
133
134
|
* @param {string} url
|
|
134
135
|
* @param {Object} [options]
|
|
135
|
-
* @param {string} [options.userAgent]
|
|
136
|
+
* @param {string} [options.userAgent] — per-request identity override
|
|
137
|
+
* @param {boolean} [options.respectRobots] — false = explicit, audited override
|
|
138
|
+
* @param {string} [options.tool] — tool name, for the audit row
|
|
139
|
+
* @param {string} [options.apiKey] — hashed into the audit row
|
|
136
140
|
* @param {number} [options.timeoutMs]
|
|
137
141
|
* @param {string[]} [options.stripTags] — additional tags to strip (default: script, style, noscript, iframe, svg)
|
|
138
|
-
* @returns {Promise<{ html: string, $: import('cheerio').CheerioAPI, textContent: string, finalUrl: string }>}
|
|
142
|
+
* @returns {Promise<{ html: string, $: import('cheerio').CheerioAPI, textContent: string, finalUrl: string, warnings: string[] }>}
|
|
139
143
|
*/
|
|
140
144
|
export async function fetchAndParse(url, options = {}) {
|
|
141
145
|
const {
|
|
142
|
-
userAgent
|
|
146
|
+
userAgent,
|
|
147
|
+
respectRobots,
|
|
148
|
+
tool,
|
|
149
|
+
apiKey,
|
|
143
150
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
144
151
|
stripTags = ['script', 'style', 'noscript', 'iframe', 'svg']
|
|
145
152
|
} = options;
|
|
146
153
|
|
|
154
|
+
// robots.txt / blocklist gate + per-host throttle. Throws if it refuses.
|
|
155
|
+
const gate = await preflightFetch(url, { userAgent, respectRobots, tool, apiKey });
|
|
156
|
+
const warnings = gate.warnings;
|
|
157
|
+
|
|
147
158
|
const response = await safeFetch(url, {
|
|
148
159
|
headers: {
|
|
149
|
-
|
|
160
|
+
...gate.headers,
|
|
150
161
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
|
151
162
|
},
|
|
152
163
|
signal: AbortSignal.timeout(timeoutMs)
|
|
153
164
|
});
|
|
154
165
|
|
|
155
166
|
if (!response.ok) {
|
|
167
|
+
// Honour a back-off the host asked for before giving up on this request.
|
|
168
|
+
if (response.status === 429 || response.status === 503) {
|
|
169
|
+
noteRetryAfter(url, response.headers?.get?.('retry-after'));
|
|
170
|
+
}
|
|
156
171
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
157
172
|
}
|
|
158
173
|
|
|
@@ -171,7 +186,7 @@ export async function fetchAndParse(url, options = {}) {
|
|
|
171
186
|
// HTML parser risks misinterpreting substrings (e.g. a "<script>" value
|
|
172
187
|
// inside a JSON string) as real tags and stripping/mangling content.
|
|
173
188
|
if (classification === 'text') {
|
|
174
|
-
return { html, $: load(''), textContent: html.trim(), finalUrl: response.url };
|
|
189
|
+
return { html, $: load(''), textContent: html.trim(), finalUrl: response.url, warnings };
|
|
175
190
|
}
|
|
176
191
|
|
|
177
192
|
const $ = load(html);
|
|
@@ -182,5 +197,5 @@ export async function fetchAndParse(url, options = {}) {
|
|
|
182
197
|
|
|
183
198
|
const textContent = flattenBodyText($);
|
|
184
199
|
|
|
185
|
-
return { html, $, textContent, finalUrl: response.url };
|
|
200
|
+
return { html, $, textContent, finalUrl: response.url, warnings };
|
|
186
201
|
}
|
|
@@ -78,8 +78,10 @@ const AnalyzeContentResult = z.object({
|
|
|
78
78
|
})).optional()
|
|
79
79
|
}).optional(),
|
|
80
80
|
readability: z.object({
|
|
81
|
-
score
|
|
82
|
-
|
|
81
|
+
// score/level are absent when Flesch does not apply (CJK) — see notApplicable
|
|
82
|
+
score: z.number().optional(),
|
|
83
|
+
level: z.string().optional(),
|
|
84
|
+
notApplicable: z.string().optional(),
|
|
83
85
|
metrics: z.object({
|
|
84
86
|
sentences: z.number(),
|
|
85
87
|
words: z.number(),
|
|
@@ -561,7 +563,7 @@ export class AnalyzeContentTool {
|
|
|
561
563
|
*/
|
|
562
564
|
compareReadability(results) {
|
|
563
565
|
const readabilityScores = results
|
|
564
|
-
.filter(r => r.success && r.readability)
|
|
566
|
+
.filter(r => r.success && r.readability && typeof r.readability.score === 'number')
|
|
565
567
|
.map(r => r.readability.score);
|
|
566
568
|
|
|
567
569
|
if (readabilityScores.length === 0) return null;
|