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.
Files changed (71) hide show
  1. package/CLAUDE.md +13 -1
  2. package/README.md +9 -9
  3. package/package.json +2 -2
  4. package/server.js +175 -26
  5. package/src/cli/commands/stealth.js +7 -1
  6. package/src/constants/config.js +2 -1
  7. package/src/core/ActionExecutor.js +168 -16
  8. package/src/core/AlertNotificationSystem.js +2 -1
  9. package/src/core/AuthManager.js +19 -1
  10. package/src/core/ChangeTracker.js +34 -6
  11. package/src/core/LLMsTxtAnalyzer.js +94 -12
  12. package/src/core/LocalizationManager.js +2 -1
  13. package/src/core/ResearchOrchestrator.js +407 -86
  14. package/src/core/StealthBrowserManager.js +186 -105
  15. package/src/core/WebhookDispatcher.js +3 -4
  16. package/src/core/analysis/ContentAnalyzer.js +41 -15
  17. package/src/core/analysis/sentenceUtils.js +16 -5
  18. package/src/core/crawlers/BFSCrawler.js +44 -21
  19. package/src/core/llm/LLMManager.js +473 -0
  20. package/src/core/processing/BrowserProcessor.js +27 -0
  21. package/src/core/processing/ContentProcessor.js +11 -39
  22. package/src/core/processing/PDFProcessor.js +2 -3
  23. package/src/core/research/claimFilters.js +235 -0
  24. package/src/schemas/toolOutputSchemas.js +5 -1
  25. package/src/security/wave3-security.js +2 -1
  26. package/src/server/requestContext.js +23 -0
  27. package/src/server/withAuth.js +21 -5
  28. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  29. package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
  30. package/src/tools/advanced/batchScrape/schema.js +4 -0
  31. package/src/tools/advanced/batchScrape/worker.js +19 -10
  32. package/src/tools/basic/_fetch.js +19 -15
  33. package/src/tools/basic/extractLinks.js +8 -3
  34. package/src/tools/basic/extractMetadata.js +7 -3
  35. package/src/tools/basic/extractText.js +8 -3
  36. package/src/tools/basic/fetchUrl.js +7 -3
  37. package/src/tools/basic/scrapeStructured.js +76 -3
  38. package/src/tools/crawl/_sessionContext.js +10 -2
  39. package/src/tools/crawl/crawlDeep.js +29 -12
  40. package/src/tools/crawl/mapSite.js +39 -14
  41. package/src/tools/extract/_fetchAndParse.js +23 -8
  42. package/src/tools/extract/analyzeContent.js +5 -3
  43. package/src/tools/extract/extractContent.js +18 -4
  44. package/src/tools/extract/extractStructured.js +66 -12
  45. package/src/tools/extract/extractWithLlm.js +51 -4
  46. package/src/tools/extract/processDocument.js +45 -78
  47. package/src/tools/extract/summarizeContent.js +35 -1
  48. package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
  49. package/src/tools/research/deepResearch.js +2 -1
  50. package/src/tools/scrape/_brandingExtractor.js +42 -3
  51. package/src/tools/scrape/_mainContent.js +105 -0
  52. package/src/tools/scrape/unifiedScrape.js +21 -14
  53. package/src/tools/search/adapters/redditOfficialApi.js +7 -6
  54. package/src/tools/search/redditSearch.js +6 -3
  55. package/src/tools/search/searchWeb.js +26 -3
  56. package/src/tools/templates/ScrapeTemplateTool.js +17 -6
  57. package/src/tools/tracking/trackChanges/differ.js +26 -3
  58. package/src/tools/tracking/trackChanges/index.js +12 -5
  59. package/src/tools/tracking/trackChanges/notifier.js +3 -1
  60. package/src/tools/tracking/trackChanges/schema.js +3 -0
  61. package/src/utils/complianceAudit.js +72 -0
  62. package/src/utils/contentUtils.js +12 -1
  63. package/src/utils/domainFilter.js +38 -19
  64. package/src/utils/fetchIdentity.js +62 -0
  65. package/src/utils/hostBlocklist.js +81 -0
  66. package/src/utils/hostRateLimiter.js +101 -2
  67. package/src/utils/robotsChecker.js +90 -43
  68. package/src/utils/robotsGate.js +206 -0
  69. package/src/utils/sitemapParser.js +33 -15
  70. package/src/utils/ssrfProtection.js +2 -1
  71. 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 a default User-Agent.
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 { throttleHost } from '../../utils/hostRateLimiter.js';
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> }} [options]
27
- * @returns {Promise<Response & { _body: string }>}
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
- // Per-host politeness throttle (before the timeout window starts).
38
- await throttleHost(url);
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
- 'User-Agent': CRAWLFORGE_UA,
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 }} params
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, output_format?: "text"|"markdown" }} params
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 }} params
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
- * @param {{ url: string, selectors: Record<string, string>, max_results?: number }} params
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
- export async function scrapeStructuredHandler({ url, selectors, max_results }) {
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
- 'User-Agent': 'MCP-WebScraper/1.0',
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 = 'MCP-WebScraper/1.0',
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 depth = urlObj.pathname.split('/').filter(s => s).length;
362
- structure.depth_distribution[depth] = (structure.depth_distribution[depth] || 0) + 1;
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 = 'MCP-WebScraper/1.0',
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 = DEFAULT_USER_AGENT,
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
- 'User-Agent': userAgent,
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: z.number(),
82
- level: z.string(),
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;