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
|
@@ -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 {
|
|
12
|
-
|
|
13
|
-
|
|
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 =
|
|
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, {
|
|
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
|
|
554
|
-
// the latter
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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: {
|
|
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
|
-
|
|
739
|
+
...identityHeaders({ role: 'webhook' }),
|
|
739
740
|
...webhook.headers
|
|
740
741
|
},
|
|
741
742
|
body: JSON.stringify(payload),
|