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
@@ -9,6 +9,8 @@ import { BrowserProcessor } from '../../core/processing/BrowserProcessor.js';
9
9
  import { HTMLCleaner, ContentQualityAssessor } from '../../utils/contentUtils.js';
10
10
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js'; // D3.1
11
11
  import { safeFetch } from '../../utils/ssrfGuard.js';
12
+ import { preflightFetch } from '../../utils/robotsGate.js';
13
+ import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
12
14
 
13
15
  const ExtractContentSchema = z.object({
14
16
  url: z.string().url(),
@@ -17,6 +19,8 @@ const ExtractContentSchema = z.object({
17
19
  // deep_research). Without this field Zod stripped it and the tool always
18
20
  // re-fetched the URL — silently defeating any pre-fetched-HTML caller.
19
21
  html: z.string().optional(),
22
+ respect_robots: z.boolean().optional(),
23
+ user_agent: z.string().optional(),
20
24
  options: z.object({
21
25
  // Content extraction options
22
26
  useReadability: z.boolean().default(true),
@@ -130,7 +134,7 @@ export class ExtractContentTool {
130
134
 
131
135
  try {
132
136
  const validated = ExtractContentSchema.parse(params);
133
- const { url, html: providedHtml, options } = validated;
137
+ const { url, html: providedHtml, options, respect_robots, user_agent } = validated;
134
138
 
135
139
  const result = {
136
140
  url,
@@ -147,6 +151,15 @@ export class ExtractContentTool {
147
151
  html = providedHtml;
148
152
  pageTitle = this.extractTitleFromHTML(html);
149
153
  } else {
154
+ // Robots gate before either fetch path — the browser render is a request
155
+ // to the target too.
156
+ const gate = await preflightFetch(url, {
157
+ respectRobots: respect_robots,
158
+ userAgent: user_agent,
159
+ tool: 'extract_content'
160
+ });
161
+ if (gate.warnings.length > 0) result.warnings = gate.warnings;
162
+
150
163
  const shouldUseJavaScript = options.requiresJavaScript || await this.shouldUseJavaScript(url);
151
164
 
152
165
  if (shouldUseJavaScript) {
@@ -171,13 +184,14 @@ export class ExtractContentTool {
171
184
  } else {
172
185
  // Simple HTTP fetch
173
186
  const response = await safeFetch(url, {
174
- headers: {
175
- 'User-Agent': 'Mozilla/5.0 (compatible; MCP-WebScraper/3.0; Enhanced-Content-Extractor)'
176
- },
187
+ headers: { ...gate.headers },
177
188
  signal: AbortSignal.timeout(15000)
178
189
  });
179
190
 
180
191
  if (!response.ok) {
192
+ if (response.status === 429 || response.status === 503) {
193
+ noteRetryAfter(url, response.headers.get('retry-after'));
194
+ }
181
195
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
182
196
  }
183
197
 
@@ -8,12 +8,9 @@ import { z } from 'zod';
8
8
  import { ElicitationHelper } from '../../core/ElicitationHelper.js'; // D1.4
9
9
  import { load } from 'cheerio';
10
10
  import { LLMManager } from '../../core/llm/LLMManager.js';
11
- import { createRequire } from 'module';
12
-
13
- const _require = createRequire(import.meta.url);
14
- const _pkg = _require('../../../package.json');
15
- const CRAWLFORGE_UA = `CrawlForge/${_pkg.version} (+https://crawlforge.dev)`;
16
- import { fetchAndParse } from './_fetchAndParse.js';
11
+ import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
12
+ import { fetchAndParse, flattenBodyText } from './_fetchAndParse.js';
13
+ import { extractMainContent } from '../scrape/_mainContent.js';
17
14
 
18
15
  // Semantic element selectors for well-known field names, tried as a last
19
16
  // resort in the CSS fallback so common fields (e.g. "title") still resolve when
@@ -32,6 +29,41 @@ const SEMANTIC_FIELD_SELECTORS = {
32
29
  price: ['[itemprop="price"]', '[class*="price"]']
33
30
  };
34
31
 
32
+ /**
33
+ * Main-content text for the LLM, or '' when there is none to fall back from.
34
+ *
35
+ * Whole-body text hands the model the page chrome, and it answers from the
36
+ * first heading-shaped string it sees: the Cloudflare blog post returned
37
+ * headline "Skip to content". This is the same Readability pass `scrape` runs.
38
+ *
39
+ * @param {import('cheerio').CheerioAPI} $ - parsed document from fetchAndParse
40
+ * @param {string} html
41
+ * @param {string} url
42
+ * @returns {string}
43
+ */
44
+ function mainContentText($, html, url) {
45
+ // fetchAndParse returns an empty $ for text/plain and JSON bodies. Those are
46
+ // not markup and must not go through Readability.
47
+ if ($('body').children().length === 0) return '';
48
+ const { html: mainHtml, title } = extractMainContent(html, url);
49
+ if (!mainHtml) return '';
50
+ // Readability strips the article's own heading out of the content it
51
+ // returns, so the title has to be put back: without it the IANA page's main
52
+ // text never says "Example Domains" and the model answers from the body.
53
+ return [title, flattenBodyText(load(mainHtml))].filter(Boolean).join('\n');
54
+ }
55
+
56
+ /**
57
+ * Missing, null, blank string or empty array — what "the extraction did not
58
+ * fill this field in" looks like across every extraction method.
59
+ */
60
+ function isEmptyValue(value) {
61
+ if (value === undefined || value === null) return true;
62
+ if (typeof value === 'string') return value.trim() === '';
63
+ if (Array.isArray(value)) return value.length === 0;
64
+ return false;
65
+ }
66
+
35
67
  const ExtractStructuredSchema = z.object({
36
68
  url: z.string().url(),
37
69
  schema: z.object({
@@ -45,14 +77,16 @@ const ExtractStructuredSchema = z.object({
45
77
  apiKey: z.string().optional()
46
78
  }).optional(),
47
79
  fallbackToSelectors: z.boolean().optional().default(true),
48
- selectorHints: z.record(z.string()).optional()
80
+ selectorHints: z.record(z.string()).optional(),
81
+ respect_robots: z.boolean().optional(),
82
+ user_agent: z.string().optional()
49
83
  });
50
84
 
51
85
  export class ExtractStructuredTool {
52
86
  constructor(options = {}) {
53
87
  this.llmManager = null;
54
88
  this.llmConfig = options.llmConfig || {};
55
- this.userAgent = CRAWLFORGE_UA;
89
+ this.userAgent = CRAWLFORGE_USER_AGENT;
56
90
  // D1.4: Elicitation helper
57
91
  this._elicitation = new ElicitationHelper({});
58
92
  }
@@ -102,10 +136,14 @@ export class ExtractStructuredTool {
102
136
 
103
137
  try {
104
138
  const validated = ExtractStructuredSchema.parse(params);
105
- const { url, schema, prompt, llmConfig, fallbackToSelectors, selectorHints } = validated;
139
+ const { url, schema, prompt, llmConfig, fallbackToSelectors, selectorHints, respect_robots, user_agent } = validated;
106
140
 
107
141
  // Step 1: Fetch and parse — shared helper strips scripts/styles/iframes/svgs
108
- const { html, $, textContent } = await fetchAndParse(url, { userAgent: this.userAgent });
142
+ const { html, $, textContent, warnings } = await fetchAndParse(url, {
143
+ userAgent: user_agent || this.userAgent,
144
+ respectRobots: respect_robots,
145
+ tool: 'extract_structured'
146
+ });
109
147
 
110
148
  // Step 3: Try LLM extraction first
111
149
  let extractionResult = null;
@@ -120,7 +158,7 @@ export class ExtractStructuredTool {
120
158
  // local Ollama was never used.
121
159
  llmAvailable = await llm.ready();
122
160
  if (llmAvailable) {
123
- const result = await llm.extractStructured(textContent, schema, {
161
+ const result = await llm.extractStructured(mainContentText($, html, url) || textContent, schema, {
124
162
  prompt: prompt || '',
125
163
  maxContentLength: 6000
126
164
  });
@@ -155,6 +193,7 @@ export class ExtractStructuredTool {
155
193
  );
156
194
  if (!proceed) {
157
195
  return {
196
+ success: false,
158
197
  url,
159
198
  data: {},
160
199
  extraction_method: 'none',
@@ -185,22 +224,37 @@ export class ExtractStructuredTool {
185
224
  extractionNotes.push(`LLM extraction failed: ${llmErrorMessage}`);
186
225
  }
187
226
 
227
+ // A required field that came back missing or empty is a failed
228
+ // extraction, not a successful one carrying a note: surface it at the
229
+ // top level so a caller checking `success` sees it without reaching
230
+ // into `validation`.
231
+ const missingRequired = (schema.required || []).filter(
232
+ (field) => isEmptyValue((extractionResult.data || {})[field])
233
+ );
234
+ const failedRequired = extractionResult.valid !== true && missingRequired.length > 0;
235
+
188
236
  return {
237
+ success: !failedRequired,
189
238
  url,
190
239
  data: extractionResult.data || {},
191
240
  extraction_method: extractionMethod,
192
241
  confidence,
193
242
  schema_used: schema,
194
243
  processingTime: Date.now() - startTime,
244
+ ...(failedRequired
245
+ ? { error: `Required field(s) missing or empty: ${missingRequired.join(', ')}` }
246
+ : {}),
195
247
  validation: {
196
248
  valid: extractionResult.valid || false,
197
249
  errors: extractionResult.validationErrors || []
198
250
  },
199
- extractionNotes
251
+ extractionNotes,
252
+ ...(warnings?.length ? { warnings } : {})
200
253
  };
201
254
 
202
255
  } catch (error) {
203
256
  return {
257
+ success: false,
204
258
  url: params.url || 'unknown',
205
259
  data: {},
206
260
  extraction_method: 'none',
@@ -255,6 +255,27 @@ function looksLikeSchemaEcho(parsed, schema) {
255
255
  return declarations.length === returned.length;
256
256
  }
257
257
 
258
+ /**
259
+ * Detect output that parsed cleanly but carries nothing taken from the page —
260
+ * `{}`, `[]`, or a structure whose every leaf is null/blank. A small model that
261
+ * loses the instruction on a long input answers with `{}` and 2 output tokens,
262
+ * which would otherwise reach the caller as a successful extraction of nothing.
263
+ *
264
+ * Recurses through objects and arrays. Numbers and booleans are data at any
265
+ * depth, so `{count: 0}` and `{found: false}` are real results, not emptiness.
266
+ *
267
+ * @param {*} parsed - Parsed LLM output (or any value inside it)
268
+ * @returns {boolean}
269
+ */
270
+ function hasNoExtractableData(parsed) {
271
+ if (parsed === null || parsed === undefined) return true;
272
+ if (typeof parsed === 'string') return parsed.trim() === '';
273
+ if (typeof parsed !== 'object') return false;
274
+ // Object.values covers arrays too; an empty object/array vacuously satisfies
275
+ // every(), which is the answer we want.
276
+ return Object.values(parsed).every(hasNoExtractableData);
277
+ }
278
+
258
279
  /**
259
280
  * Validate parsed output against the schema hint.
260
281
  * @returns {{ valid: boolean, errors: string[] }}
@@ -462,6 +483,8 @@ export class ExtractWithLlm {
462
483
  * @param {string} [params.provider] - 'openai' | 'anthropic' | 'auto'
463
484
  * @param {string} [params.model] - Override default model
464
485
  * @param {number} [params.maxTokens] - Max output tokens (default 4096)
486
+ * @param {boolean} [params.respect_robots] - Per-request robots.txt override
487
+ * @param {string} [params.user_agent] - Per-request identity override
465
488
  * @returns {Promise<Object>}
466
489
  */
467
490
  async execute(params) {
@@ -472,7 +495,9 @@ export class ExtractWithLlm {
472
495
  schema,
473
496
  provider: providerParam = 'auto',
474
497
  model: modelParam,
475
- maxTokens = 4096
498
+ maxTokens = 4096,
499
+ respect_robots,
500
+ user_agent
476
501
  } = params;
477
502
 
478
503
  // Validate: exactly one of url or content must be provided
@@ -505,10 +530,16 @@ export class ExtractWithLlm {
505
530
 
506
531
  // Step 1: Get text to extract from
507
532
  let text;
533
+ let fetchWarnings = [];
508
534
  try {
509
535
  if (url) {
510
- const { textContent } = await fetchAndParse(url);
536
+ const { textContent, warnings } = await fetchAndParse(url, {
537
+ respectRobots: respect_robots,
538
+ userAgent: user_agent,
539
+ tool: 'extract_with_llm'
540
+ });
511
541
  text = textContent;
542
+ fetchWarnings = warnings || [];
512
543
  } else {
513
544
  text = content;
514
545
  }
@@ -550,8 +581,8 @@ export class ExtractWithLlm {
550
581
  }
551
582
 
552
583
  // Step 3: Parse JSON; retry once with a stricter prompt if the response is
553
- // unusable. "Unusable" covers both unparseable output and a schema echo
554
- // the latter parses cleanly but contains no page data at all.
584
+ // unusable. "Unusable" covers unparseable output, a schema echo and an
585
+ // empty result — the latter two parse cleanly but contain no page data.
555
586
  let parsed = null;
556
587
  let unusableReason = null;
557
588
  try {
@@ -559,6 +590,9 @@ export class ExtractWithLlm {
559
590
  if (looksLikeSchemaEcho(parsed, schema)) {
560
591
  parsed = null;
561
592
  unusableReason = 'echoed the output schema instead of extracting data from the page';
593
+ } else if (hasNoExtractableData(parsed)) {
594
+ parsed = null;
595
+ unusableReason = 'contained no data from the page — every field was empty';
562
596
  }
563
597
  } catch (_parseErr) {
564
598
  unusableReason = 'was not valid JSON';
@@ -606,6 +640,18 @@ export class ExtractWithLlm {
606
640
  raw: JSON.stringify(parsed).slice(0, 500)
607
641
  };
608
642
  }
643
+
644
+ if (hasNoExtractableData(parsed)) {
645
+ // Same failure mode as the echo: well-formed JSON with nothing from the
646
+ // page in it. Returning it would read as a successful extraction.
647
+ return {
648
+ success: false,
649
+ error: `LLM (${model}) returned no data from the page after retry — every field was empty. ` +
650
+ 'The page text is likely too long or too noisy for this model — try a larger model ' +
651
+ '(OLLAMA_DEFAULT_MODEL) or narrow the input with onlyMainContent.',
652
+ raw: JSON.stringify(parsed).slice(0, 500)
653
+ };
654
+ }
609
655
  }
610
656
 
611
657
  // C3: surface truncation metadata so callers know the input was clipped
@@ -620,6 +666,7 @@ export class ExtractWithLlm {
620
666
  result.truncated = true;
621
667
  result.original_length = original_length;
622
668
  }
669
+ if (fetchWarnings.length > 0) result.warnings = fetchWarnings;
623
670
  // C3: validate output against the schema hint (zod). Non-fatal — the data
624
671
  // is still returned; callers can inspect `valid`/`validationErrors`.
625
672
  if (schema && Object.keys(schema).length > 0) {
@@ -12,10 +12,14 @@ import { BrowserProcessor } from '../../core/processing/BrowserProcessor.js';
12
12
  import { HTMLCleaner, ContentQualityAssessor } from '../../utils/contentUtils.js';
13
13
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js'; // D3.1
14
14
  import { safeFetch } from '../../utils/ssrfGuard.js';
15
+ import { preflightFetch } from '../../utils/robotsGate.js';
16
+ import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
15
17
 
16
18
  const ProcessDocumentSchema = z.object({
17
19
  source: z.string().min(1),
18
20
  sourceType: z.enum(['url', 'pdf_url', 'file', 'pdf_file']).default('url'),
21
+ respect_robots: z.boolean().optional(),
22
+ user_agent: z.string().optional(),
19
23
  options: z.object({
20
24
  // PDF processing options
21
25
  extractText: z.boolean().default(true),
@@ -142,7 +146,7 @@ export class ProcessDocumentTool {
142
146
 
143
147
  try {
144
148
  const validated = ProcessDocumentSchema.parse(params);
145
- const { source, sourceType, options } = validated;
149
+ const { source, sourceType, options, respect_robots, user_agent } = validated;
146
150
 
147
151
  const result = {
148
152
  source,
@@ -155,13 +159,13 @@ export class ProcessDocumentTool {
155
159
  // Determine document type and processing method
156
160
  if (sourceType.includes('pdf')) {
157
161
  result.documentType = 'pdf';
158
- await this.processPDFDocument(result, source, sourceType, options);
162
+ await this.processPDFDocument(result, source, sourceType, options, { respect_robots, user_agent });
159
163
  } else if (sourceType === 'file') {
160
164
  result.documentType = 'file';
161
165
  await this.processLocalFileDocument(result, source, options);
162
166
  } else {
163
167
  result.documentType = 'web';
164
- await this.processWebDocument(result, source, options);
168
+ await this.processWebDocument(result, source, options, { respect_robots, user_agent });
165
169
  }
166
170
 
167
171
  // Add statistics if requested
@@ -177,6 +181,15 @@ export class ProcessDocumentTool {
177
181
  );
178
182
  }
179
183
 
184
+ // Readability is populated once, here, for every source type, from the
185
+ // same Flesch implementation that backs
186
+ // qualityAssessment.metrics.readability — so the two fields on a
187
+ // response can never contradict each other.
188
+ if (result.content?.text) {
189
+ const { score, level, ...metrics } = ContentQualityAssessor.calculateSimpleReadability(result.content.text);
190
+ result.readabilityScore = { score, level, metrics };
191
+ }
192
+
180
193
  result.processingTime = Date.now() - startTime;
181
194
  result.success = true;
182
195
 
@@ -204,7 +217,17 @@ export class ProcessDocumentTool {
204
217
  * @param {Object} options - Processing options
205
218
  * @returns {Promise<void>}
206
219
  */
207
- async processPDFDocument(result, source, sourceType, options) {
220
+ async processPDFDocument(result, source, sourceType, options, identity = {}) {
221
+ // A remote PDF is a fetch of the target like any other; a local file is not.
222
+ if (sourceType === 'pdf_url') {
223
+ const gate = await preflightFetch(source, {
224
+ respectRobots: identity.respect_robots,
225
+ userAgent: identity.user_agent,
226
+ tool: 'process_document'
227
+ });
228
+ if (gate.warnings.length > 0) result.warnings = gate.warnings;
229
+ }
230
+
208
231
  const pdfResult = await this.pdfProcessor.processPDF({
209
232
  source,
210
233
  sourceType: sourceType.replace('pdf_', ''),
@@ -251,14 +274,6 @@ export class ProcessDocumentTool {
251
274
  encrypted: pdfResult.metadata.encrypted
252
275
  };
253
276
  }
254
-
255
- // Calculate readability score for text content
256
- if (options.assessContentQuality && result.content.text) {
257
- const readabilityScore = this.calculateReadabilityScore(result.content.text);
258
- if (readabilityScore) {
259
- result.readabilityScore = readabilityScore;
260
- }
261
- }
262
277
  }
263
278
 
264
279
  /**
@@ -268,11 +283,18 @@ export class ProcessDocumentTool {
268
283
  * @param {Object} options - Processing options
269
284
  * @returns {Promise<void>}
270
285
  */
271
- async processWebDocument(result, source, options) {
272
- // Step 1: Fetch content (with or without JavaScript rendering)
286
+ async processWebDocument(result, source, options, identity = {}) {
287
+ // Step 1: Fetch content (with or without JavaScript rendering).
288
+ // Robots gate before either path — the browser render is a request too.
273
289
  let html, pageTitle;
290
+ const gate = await preflightFetch(source, {
291
+ respectRobots: identity.respect_robots,
292
+ userAgent: identity.user_agent,
293
+ tool: 'process_document'
294
+ });
295
+ if (gate.warnings.length > 0) result.warnings = gate.warnings;
274
296
  const shouldUseJavaScript = options.requiresJavaScript || await this.shouldUseJavaScript(source);
275
-
297
+
276
298
  if (shouldUseJavaScript) {
277
299
  console.error('Using browser rendering for JavaScript content...');
278
300
  const browserResult = await this.browserProcessor.processURL({
@@ -294,13 +316,14 @@ export class ProcessDocumentTool {
294
316
  } else {
295
317
  // Simple HTTP fetch
296
318
  const response = await safeFetch(source, {
297
- headers: {
298
- 'User-Agent': 'Mozilla/5.0 (compatible; MCP-WebScraper/3.0; Document-Processor)'
299
- },
319
+ headers: { ...gate.headers },
300
320
  signal: AbortSignal.timeout(15000)
301
321
  });
302
322
 
303
323
  if (!response.ok) {
324
+ if (response.status === 429 || response.status === 503) {
325
+ noteRetryAfter(source, response.headers.get('retry-after'));
326
+ }
304
327
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
305
328
  }
306
329
 
@@ -358,7 +381,9 @@ export class ProcessDocumentTool {
358
381
  url,
359
382
  options: {
360
383
  extractStructuredData: options.extractStructuredData,
361
- calculateReadabilityScore: true,
384
+ // execute() computes readability once for every source type; asking
385
+ // ContentProcessor for its own score here would only duplicate it.
386
+ calculateReadabilityScore: false,
362
387
  removeBoilerplate: options.useReadability,
363
388
  preserveImageInfo: false,
364
389
  extractMetadata: true
@@ -413,12 +438,7 @@ export class ProcessDocumentTool {
413
438
  };
414
439
  }
415
440
 
416
- // Step 5: Add readability score
417
- if (processingResult.readability_score) {
418
- result.readabilityScore = processingResult.readability_score;
419
- }
420
-
421
- // Step 6: Add structured data
441
+ // Step 5: Add structured data
422
442
  if (options.extractStructuredData && processingResult.structured_data) {
423
443
  result.structuredData = processingResult.structured_data;
424
444
  }
@@ -449,59 +469,6 @@ export class ProcessDocumentTool {
449
469
  };
450
470
  }
451
471
 
452
- /**
453
- * Calculate readability score
454
- * @param {string} text - Text to analyze
455
- * @returns {Object|null} - Readability score
456
- */
457
- calculateReadabilityScore(text) {
458
- try {
459
- const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0);
460
- const words = text.split(/\s+/).filter(w => w.length > 0);
461
-
462
- if (sentences.length === 0 || words.length === 0) {
463
- return null;
464
- }
465
-
466
- const avgWordsPerSentence = words.length / sentences.length;
467
- const avgCharsPerWord = text.replace(/\s/g, '').length / words.length;
468
-
469
- // Flesch Reading Ease Score approximation
470
- const score = 206.835 - (1.015 * avgWordsPerSentence) - (84.6 * (avgCharsPerWord / 4.7));
471
- const clampedScore = Math.max(0, Math.min(100, score));
472
-
473
- return {
474
- score: Math.round(clampedScore * 100) / 100,
475
- level: this.getReadabilityLevel(clampedScore),
476
- metrics: {
477
- sentences: sentences.length,
478
- words: words.length,
479
- avgWordsPerSentence: Math.round(avgWordsPerSentence * 100) / 100,
480
- avgCharsPerWord: Math.round(avgCharsPerWord * 100) / 100
481
- }
482
- };
483
-
484
- } catch (error) {
485
- console.warn('Readability calculation failed:', error.message);
486
- return null;
487
- }
488
- }
489
-
490
- /**
491
- * Get readability level from score
492
- * @param {number} score - Readability score
493
- * @returns {string} - Readability level
494
- */
495
- getReadabilityLevel(score) {
496
- if (score >= 90) return 'Very Easy';
497
- if (score >= 80) return 'Easy';
498
- if (score >= 70) return 'Fairly Easy';
499
- if (score >= 60) return 'Standard';
500
- if (score >= 50) return 'Fairly Difficult';
501
- if (score >= 30) return 'Difficult';
502
- return 'Very Difficult';
503
- }
504
-
505
472
  /**
506
473
  * Determine if JavaScript rendering is needed
507
474
  * @param {string} url - URL to analyze
@@ -73,6 +73,25 @@ const SummarizeContentResult = z.object({
73
73
  error: z.string().optional()
74
74
  });
75
75
 
76
+ // Navigation chrome that some sites write with a trailing full stop, e.g. a
77
+ // skip link rendered "Jump to content." Chrome without a terminator is already
78
+ // caught by the punctuation test below; these are the only lines allowed to
79
+ // pass it. Half were observed live in extract_text output (en/es Wikipedia,
80
+ // MDN, BBC, GOV.UK); the rest are the same construction. Kept multilingual on
81
+ // purpose so the exception does not quietly narrow a language-agnostic rule.
82
+ const NAVIGATION_PHRASES = new Set([
83
+ 'jump to content',
84
+ 'jump to navigation',
85
+ 'jump to search',
86
+ 'skip to content',
87
+ 'skip to main content',
88
+ 'skip to navigation',
89
+ 'skip to search',
90
+ 'from wikipedia, the free encyclopedia',
91
+ 'ir al contenido',
92
+ 'de wikipedia, la enciclopedia libre'
93
+ ]);
94
+
76
95
  export class SummarizeContentTool {
77
96
  constructor() {
78
97
  this.contentAnalyzer = new ContentAnalyzer();
@@ -269,6 +288,17 @@ export class SummarizeContentTool {
269
288
  * mid-sentence survives: it is either punctuated, longer than the caps, or
270
289
  * protected by the size guard — if the strip would remove more than
271
290
  * min(600 chars, 20% of the text), nothing is stripped at all.
291
+ *
292
+ * One exception to the punctuation test: a line that matches NAVIGATION_PHRASES
293
+ * exactly — case-insensitively, ignoring trailing terminators — is stripped
294
+ * even though it is punctuated, so a skip link written "Jump to content." is
295
+ * caught. That exception has to be a list rather than a rule, because
296
+ * "Jump to content." and "It was cold." are identical on every feature this
297
+ * function can measure (≤ 60 chars, ≤ 8 words, a single terminator in final
298
+ * position, nothing punctuated in between). Anything general enough to catch
299
+ * the first eats the second, and dropping a summary's opening sentence is the
300
+ * worse failure. The cost is the usual one for a list: it catches the phrases
301
+ * it names and no others.
272
302
  * @param {string} text - Text to clean
273
303
  * @returns {string} - Text without leading navigation chrome
274
304
  */
@@ -287,7 +317,11 @@ export class SummarizeContentTool {
287
317
  }
288
318
  const wordCount = line.split(/\s+/).length;
289
319
  const hasSentencePunctuation = /[.!?…。!?]/.test(line);
290
- const isBoilerplate = line.length <= 60 && wordCount <= 8 && !hasSentencePunctuation;
320
+ const isNavigationPhrase = NAVIGATION_PHRASES.has(
321
+ line.replace(/[.!?…。!?]+$/, '').trim().toLowerCase()
322
+ );
323
+ const isBoilerplate = line.length <= 60 && wordCount <= 8 &&
324
+ (!hasSentencePunctuation || isNavigationPhrase);
291
325
  if (!isBoilerplate) break;
292
326
 
293
327
  strippedChars += line.length;
@@ -4,6 +4,8 @@ import { LLMsTxtAnalyzer } from '../../core/LLMsTxtAnalyzer.js';
4
4
  import { Logger } from '../../utils/Logger.js';
5
5
  import { getBaseUrl } from '../../utils/urlNormalizer.js';
6
6
  import { safeFetch } from '../../utils/ssrfGuard.js';
7
+ import { resolveUserAgent } from '../../utils/fetchIdentity.js';
8
+ import { preflightFetch } from '../../utils/robotsGate.js';
7
9
 
8
10
  const logger = new Logger('GenerateLLMsTxtTool');
9
11
 
@@ -52,7 +54,7 @@ export class GenerateLLMsTxtTool {
52
54
  constructor(options = {}) {
53
55
  this.options = {
54
56
  timeout: options.timeout || 30000,
55
- userAgent: options.userAgent || 'LLMs.txt-Generator/1.0',
57
+ userAgent: resolveUserAgent(options.userAgent),
56
58
  ...options
57
59
  };
58
60
  }
@@ -86,7 +88,7 @@ export class GenerateLLMsTxtTool {
86
88
  // link (llmstxt.org) instead of boilerplate. Best-effort: null on
87
89
  // failure, in which case the generic fallbacks below apply.
88
90
  if (!outputOptions.robotsStyle) {
89
- analysis.homePage = await this.fetchHomePageMetadata(baseUrl);
91
+ analysis.homePage = await this.fetchHomePageMetadata(baseUrl, analysisOptions.respectRobots);
90
92
  }
91
93
 
92
94
  // Step 2: Generate LLMs.txt Content
@@ -240,6 +242,14 @@ export class GenerateLLMsTxtTool {
240
242
  emitSection('Tools', flatten('tools'));
241
243
  emitSection('Navigation', flatten('navigation'));
242
244
 
245
+ // Pages the categorizer's keyword lists did not place — the site root and
246
+ // any section whose name it does not recognise. The analysis spent its
247
+ // page budget on these, so they belong in the guide; before this they were
248
+ // listed only when no named section matched at all, which silently dropped
249
+ // the homepage and whole sections (e.g. /specification) from sites that
250
+ // happen to also have a /docs.
251
+ emitSection('Pages', flatten('other'));
252
+
243
253
  // Fallback: if no categorized section produced output, list the raw
244
254
  // sitemap so llms.txt always carries a URL inventory. Must run BEFORE the
245
255
  // APIs section — an APIs entry alone used to set hasBody and suppress it.
@@ -355,13 +365,18 @@ export class GenerateLLMsTxtTool {
355
365
  * Fetch the site's homepage once and extract naming metadata for the spec
356
366
  * output. Best-effort: returns null on any failure.
357
367
  */
358
- async fetchHomePageMetadata(baseUrl) {
368
+ async fetchHomePageMetadata(baseUrl, respectRobots) {
359
369
  const controller = new AbortController();
360
370
  const timeoutId = setTimeout(() => controller.abort(), Math.min(this.options.timeout, 10000));
361
371
  try {
372
+ const gate = await preflightFetch(baseUrl, {
373
+ respectRobots,
374
+ userAgent: this.options.userAgent,
375
+ tool: 'generate_llms_txt'
376
+ });
362
377
  const response = await safeFetch(baseUrl, {
363
378
  signal: controller.signal,
364
- headers: { 'User-Agent': this.options.userAgent }
379
+ headers: { ...gate.headers }
365
380
  });
366
381
  if (!response.ok) return null;
367
382
  return this.extractHomePageMetadata(await response.text());
@@ -5,6 +5,7 @@ import { ResearchOrchestrator } from '../../core/ResearchOrchestrator.js';
5
5
  import { getToolConfig } from '../../constants/config.js';
6
6
  import { Logger } from '../../utils/Logger.js';
7
7
  import { safeFetch } from '../../utils/ssrfGuard.js';
8
+ import { identityHeaders } from '../../utils/fetchIdentity.js';
8
9
 
9
10
  /**
10
11
  * DeepResearchTool - MCP tool for conducting comprehensive multi-stage research
@@ -735,7 +736,7 @@ export class DeepResearchTool {
735
736
  method: 'POST',
736
737
  headers: {
737
738
  'Content-Type': 'application/json',
738
- 'User-Agent': 'MCP-WebScraper-DeepResearch/1.0',
739
+ ...identityHeaders({ role: 'webhook' }),
739
740
  ...webhook.headers
740
741
  },
741
742
  body: JSON.stringify(payload),