crawlforge-mcp-server 6.21.0 → 6.22.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/package.json +3 -2
- package/server.js +12 -12
- package/src/core/ActionExecutor.js +23 -4
- package/src/core/AgentOrchestrator.js +141 -19
- package/src/core/AuthManager.js +4 -1
- package/src/core/LLMsTxtAnalyzer.js +6 -7
- package/src/core/LocalizationManager.js +29 -4
- package/src/core/ResearchOrchestrator.js +48 -13
- package/src/core/SamplingClient.js +38 -8
- package/src/core/SnapshotManager.js +32 -13
- package/src/core/analysis/ContentAnalyzer.js +26 -25
- package/src/core/crawlers/BFSCrawler.js +48 -10
- package/src/core/processing/ContentProcessor.js +4 -2
- package/src/core/queue/QueueManager.js +16 -2
- package/src/resources/ResourceRegistry.js +19 -5
- package/src/schemas/toolOutputSchemas.js +23 -15
- package/src/server/fallbackHints.js +37 -7
- package/src/server/withAuth.js +27 -44
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +11 -14
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
- package/src/tools/advanced/BrowserSessionTool.js +11 -2
- package/src/tools/advanced/batchScrape/queue.js +4 -2
- package/src/tools/advanced/batchScrape/worker.js +50 -11
- package/src/tools/agent/agent.js +1 -1
- package/src/tools/basic/_fetch.js +2 -1
- package/src/tools/basic/extractMetadata.js +2 -1
- package/src/tools/basic/extractText.js +12 -8
- package/src/tools/crawl/mapSite.js +8 -0
- package/src/tools/extract/extractContent.js +2 -1
- package/src/tools/extract/extractStructured.js +4 -2
- package/src/tools/extract/extractWithLlm.js +8 -4
- package/src/tools/extract/processDocument.js +29 -1
- package/src/tools/extract/summarizeContent.js +5 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +6 -2
- package/src/tools/scrape/_mainContent.js +54 -2
- package/src/tools/scrape/unifiedScrape.js +94 -6
- package/src/tools/search/adapters/braveSearch.js +27 -5
- package/src/tools/search/providers/searxng.js +20 -3
- package/src/tools/search/ranking/ResultDeduplicator.js +51 -65
- package/src/tools/search/searchWeb.js +2 -2
- package/src/tools/tracking/trackChanges/differ.js +2 -1
- package/src/tools/tracking/trackChanges/index.js +6 -1
- package/src/tools/tracking/trackChanges/schema.js +15 -1
- package/src/utils/fetchError.js +47 -0
- package/src/utils/fetchLadder.js +5 -5
- package/src/utils/htmlToMarkdown.js +36 -2
- package/src/utils/ssrfGuard.js +2 -1
|
@@ -11,10 +11,14 @@ import { ssrfGuard, isSsrfError } from '../../../utils/ssrfGuard.js';
|
|
|
11
11
|
import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
|
|
12
12
|
import { preflightFetch } from '../../../utils/robotsGate.js';
|
|
13
13
|
import { fetchResigned } from '../../../utils/resignedFetch.js';
|
|
14
|
+
import { fetchErrorMessage } from '../../../utils/fetchError.js';
|
|
14
15
|
import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
|
|
16
|
+
import { extractMainContent, isThinMainContent, keepPageHeading } from '../../scrape/_mainContent.js';
|
|
15
17
|
import { elementText } from '../../../utils/elementText.js';
|
|
16
18
|
import { pageTitle } from '../../../utils/pageTitle.js';
|
|
17
19
|
import { classifyContentType, unsupportedContentTypeError } from '../../extract/_fetchAndParse.js';
|
|
20
|
+
import { requestContext } from '../../../server/requestContext.js';
|
|
21
|
+
import { classifyError, CLASS_HINTS, FALLBACK_HINTS } from '../../../server/fallbackHints.js';
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* Fetch a URL with AbortController timeout (SSRF-guarded + per-host throttled).
|
|
@@ -57,7 +61,9 @@ export async function fetchUrl(url, options = {}) {
|
|
|
57
61
|
} catch (error) {
|
|
58
62
|
if (isSsrfError(error)) throw new Error(error.cause?.message || error.message);
|
|
59
63
|
if (error.name === 'AbortError') throw new Error(`Request timeout after ${timeout}ms`);
|
|
60
|
-
|
|
64
|
+
const message = fetchErrorMessage(error, url);
|
|
65
|
+
if (message === error.message) throw error;
|
|
66
|
+
throw new Error(message, { cause: error });
|
|
61
67
|
} finally {
|
|
62
68
|
// Cleared only after the body read finishes (or the fetch itself fails),
|
|
63
69
|
// so the abort timer keeps protecting against a trickling body, not just
|
|
@@ -103,13 +109,14 @@ export async function scrapeUrl(config, options, defaultTimeout) {
|
|
|
103
109
|
result.extracted = extractStructuredData($, { ...config.selectors, ...options.extractionSchema });
|
|
104
110
|
}
|
|
105
111
|
|
|
106
|
-
result.content = generateFormats($, html, options.formats);
|
|
112
|
+
result.content = generateFormats($, html, options.formats, config.url);
|
|
107
113
|
return result;
|
|
108
114
|
} catch (error) {
|
|
109
115
|
return {
|
|
110
116
|
success: false,
|
|
111
117
|
url: config.url,
|
|
112
118
|
error: error.message,
|
|
119
|
+
next_step: failedEntryHint(error.message),
|
|
113
120
|
timestamp: Date.now(),
|
|
114
121
|
executionTime: Date.now() - startTime,
|
|
115
122
|
metadata: config.metadata || {}
|
|
@@ -117,6 +124,17 @@ export async function scrapeUrl(config, options, defaultTimeout) {
|
|
|
117
124
|
}
|
|
118
125
|
}
|
|
119
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The next step for one failed URL (R25 3.1): its error class's hint, else
|
|
129
|
+
* scrape's. A batch with failed URLs still succeeds, so withAuth never hints
|
|
130
|
+
* it. Classified from the message alone: the invocation's preflight-refusal
|
|
131
|
+
* flag belongs to the whole batch, and would give every URL one URL's refusal.
|
|
132
|
+
*/
|
|
133
|
+
export function failedEntryHint(message) {
|
|
134
|
+
const c = requestContext.exit(() => classifyError(null, message));
|
|
135
|
+
return c ? CLASS_HINTS[c.cls]('batch_scrape', c.params) : FALLBACK_HINTS.scrape;
|
|
136
|
+
}
|
|
137
|
+
|
|
120
138
|
function extractStructuredData($, selectors) {
|
|
121
139
|
const extracted = {};
|
|
122
140
|
for (const [key, selector] of Object.entries(selectors)) {
|
|
@@ -144,7 +162,7 @@ function valueText($, el) {
|
|
|
144
162
|
: flattenText($, $el);
|
|
145
163
|
}
|
|
146
164
|
|
|
147
|
-
export function generateFormats($, html, formats) {
|
|
165
|
+
export function generateFormats($, html, formats, url) {
|
|
148
166
|
// Script, style, noscript and template bodies are never rendered text.
|
|
149
167
|
// Left in, the text format carried an entire theme-toggle script
|
|
150
168
|
// (erlang.org/downloads) and CSS rules (opennet.ru) — R15, 2026-09-04.
|
|
@@ -153,7 +171,7 @@ export function generateFormats($, html, formats) {
|
|
|
153
171
|
const content = {};
|
|
154
172
|
if (formats.includes('html')) content.html = html;
|
|
155
173
|
if (formats.includes('text')) content.text = flattenText($);
|
|
156
|
-
if (formats.includes('markdown')) content.markdown = buildMarkdown(
|
|
174
|
+
if (formats.includes('markdown')) content.markdown = buildMarkdown($, html, url);
|
|
157
175
|
if (formats.includes('json')) {
|
|
158
176
|
content.json = {
|
|
159
177
|
title: pageTitle($),
|
|
@@ -166,8 +184,15 @@ export function generateFormats($, html, formats) {
|
|
|
166
184
|
return content;
|
|
167
185
|
}
|
|
168
186
|
|
|
169
|
-
|
|
170
|
-
|
|
187
|
+
/**
|
|
188
|
+
* The page's main content, by the same pass scrape's markdown uses: converting
|
|
189
|
+
* the whole <main> handed back Wikipedia's language list and menus (R25,
|
|
190
|
+
* 2026-10-19). A page Readability finds no article in, or only a thin
|
|
191
|
+
* fragment of, falls back to the first content container below.
|
|
192
|
+
*/
|
|
193
|
+
function mainContentHtml($, html, url) {
|
|
194
|
+
const main = extractMainContent(html, url);
|
|
195
|
+
if (main.html && !isThinMainContent(main.html, html)) return keepPageHeading(main.html, $);
|
|
171
196
|
|
|
172
197
|
const selectors = ['article', 'main', '.content', '#content', '.post-content', '.entry-content'];
|
|
173
198
|
let $body = null;
|
|
@@ -176,17 +201,31 @@ function buildMarkdown($) {
|
|
|
176
201
|
if ($body.length > 0) break;
|
|
177
202
|
}
|
|
178
203
|
if (!$body || $body.length === 0) $body = $('body');
|
|
204
|
+
return $.html($body);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// "Web scraping - Wikipedia" names the page "Web scraping": the H1 plus a
|
|
208
|
+
// separator and the site's name.
|
|
209
|
+
function titleRepeatsHeading(title, heading) {
|
|
210
|
+
const t = title.toLowerCase();
|
|
211
|
+
const h = heading.toLowerCase();
|
|
212
|
+
return t === h || (t.startsWith(h) && /^\s+[-–—|·]\s+\S/.test(t.slice(h.length)));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function buildMarkdown($, html, url) {
|
|
216
|
+
const title = pageTitle($);
|
|
217
|
+
const mainHtml = mainContentHtml($, html, url);
|
|
179
218
|
|
|
180
219
|
// Full-fidelity conversion via the shared Turndown helper (same converter
|
|
181
220
|
// the unified `scrape` tool uses). The previous hand-rolled h1–h3/p/li walk
|
|
182
221
|
// silently dropped any text living outside those tags (e.g. quotes in
|
|
183
222
|
// <span>/<small> on quotes.toscrape.com).
|
|
184
|
-
let md = htmlToMarkdown(
|
|
223
|
+
let md = htmlToMarkdown(mainHtml);
|
|
185
224
|
|
|
186
|
-
// C3: de-dup title — only emit the <title> heading if the
|
|
187
|
-
// or
|
|
188
|
-
const firstH1 =
|
|
189
|
-
const titleDuplicated = firstH1 &&
|
|
225
|
+
// C3: de-dup title — only emit the <title> heading if the content has no
|
|
226
|
+
// <h1> or its first <h1> is not what the <title> names (case-insensitive).
|
|
227
|
+
const firstH1 = load(mainHtml)('h1').first().text().replace(/\s+/g, ' ').trim();
|
|
228
|
+
const titleDuplicated = firstH1 && titleRepeatsHeading(title, firstH1);
|
|
190
229
|
if (title && !titleDuplicated) md = `# ${title}\n\n${md}`;
|
|
191
230
|
|
|
192
231
|
return md.trim();
|
package/src/tools/agent/agent.js
CHANGED
|
@@ -16,7 +16,7 @@ import { AGENT_ESCALATION_CREDITS } from './escalation.js';
|
|
|
16
16
|
export const AGENT_BASE_CREDITS = 8;
|
|
17
17
|
|
|
18
18
|
export const AgentInputSchema = z.object({
|
|
19
|
-
prompt: z.string().min(1).max(2000).describe('Natural-language task or question'),
|
|
19
|
+
prompt: z.string().trim().min(1).max(2000).describe('Natural-language task or question'),
|
|
20
20
|
urls: z.array(z.string().url()).max(20).optional().describe('Optional seed URLs to include (max 20)'),
|
|
21
21
|
schema: z.record(z.any()).optional().describe('Optional JSON schema for structured output'),
|
|
22
22
|
model: z.enum(['default', 'pro']).optional().default('default').describe('"default" = SamplingClient loop; "pro" = full ResearchOrchestrator'),
|
|
@@ -9,6 +9,7 @@ import { ssrfGuard, isSsrfError } from '../../utils/ssrfGuard.js';
|
|
|
9
9
|
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
10
10
|
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
11
11
|
import { fetchResigned } from '../../utils/resignedFetch.js';
|
|
12
|
+
import { withFetchCause } from '../../utils/fetchError.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Fetch a URL with a configurable timeout and body-size cap.
|
|
@@ -68,7 +69,7 @@ export async function fetchWithTimeout(url, options = {}) {
|
|
|
68
69
|
if (error.name === 'AbortError') {
|
|
69
70
|
throw new Error(`Request timeout after ${timeout}ms`);
|
|
70
71
|
}
|
|
71
|
-
throw error;
|
|
72
|
+
throw withFetchCause(error, url);
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
// A host asking us to back off is honoured on the *next* request to it,
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { load } from 'cheerio';
|
|
9
9
|
import { fetchWithTimeout } from './_fetch.js';
|
|
10
|
+
import { fetchErrorMessage } from '../../utils/fetchError.js';
|
|
10
11
|
import { parseJsonLd, filterJsonLdByType } from '../../utils/jsonLd.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -161,7 +162,7 @@ export async function extractMetadataHandler({ url, user_agent, respect_robots,
|
|
|
161
162
|
};
|
|
162
163
|
} catch (error) {
|
|
163
164
|
return {
|
|
164
|
-
content: [{ type: 'text', text: `Failed to extract metadata: ${error
|
|
165
|
+
content: [{ type: 'text', text: `Failed to extract metadata: ${fetchErrorMessage(error, url)}` }],
|
|
165
166
|
isError: true
|
|
166
167
|
};
|
|
167
168
|
}
|
|
@@ -15,6 +15,7 @@ import { JSDOM } from 'jsdom';
|
|
|
15
15
|
import { Readability } from '@mozilla/readability';
|
|
16
16
|
import { fetchLadder, ladderErrorResult, isJsonType } from '../../utils/fetchLadder.js';
|
|
17
17
|
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
18
|
+
import { tooLargeForReadability } from '../scrape/_mainContent.js';
|
|
18
19
|
|
|
19
20
|
// Block-level elements whose boundaries should become paragraph breaks
|
|
20
21
|
const BLOCK_ELEMENTS = new Set([
|
|
@@ -66,14 +67,17 @@ export function extractBlockText($) {
|
|
|
66
67
|
* @returns {string}
|
|
67
68
|
*/
|
|
68
69
|
export function readabilityToMarkdown(html, pageUrl) {
|
|
69
|
-
let articleHtml;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
let articleHtml = html;
|
|
71
|
+
// A page too large for Readability is converted whole (R25).
|
|
72
|
+
if (!tooLargeForReadability(html)) {
|
|
73
|
+
try {
|
|
74
|
+
const dom = new JSDOM(html, { url: pageUrl });
|
|
75
|
+
const reader = new Readability(dom.window.document);
|
|
76
|
+
const article = reader.parse();
|
|
77
|
+
if (article) articleHtml = article.content;
|
|
78
|
+
} catch {
|
|
79
|
+
// the whole page
|
|
80
|
+
}
|
|
77
81
|
}
|
|
78
82
|
const $ = load(articleHtml);
|
|
79
83
|
absoluteUrls($, pageUrl);
|
|
@@ -167,6 +167,11 @@ export class MapSiteTool {
|
|
|
167
167
|
// unresolvable host used to return a success with total_urls: 0 (R24).
|
|
168
168
|
try {
|
|
169
169
|
const pageUrls = await this.fetchPageUrls(validated.url, domainFilter, identity);
|
|
170
|
+
// The start page is a page of the site too: a one-page site with no
|
|
171
|
+
// sitemap used to map to 0 URLs (R25 1.2).
|
|
172
|
+
if (!domainFilter || domainFilter.isAllowed(validated.url).allowed) {
|
|
173
|
+
urls.add(normalizeUrl(validated.url));
|
|
174
|
+
}
|
|
170
175
|
pageUrls.forEach(url => {
|
|
171
176
|
if (urls.size < poolLimit) {
|
|
172
177
|
urls.add(normalizeUrl(url));
|
|
@@ -200,6 +205,9 @@ export class MapSiteTool {
|
|
|
200
205
|
|
|
201
206
|
// Convert to array and limit
|
|
202
207
|
const urlArray = pool.slice(0, validated.max_urls);
|
|
208
|
+
if (urlArray.length === 0) {
|
|
209
|
+
warnings.push('No URLs were found: no sitemap listed any, and the start page and its links were all excluded by the domain filter.');
|
|
210
|
+
}
|
|
203
211
|
|
|
204
212
|
// Fetch metadata if requested
|
|
205
213
|
if (validated.include_metadata) {
|
|
@@ -401,7 +401,8 @@ export class ExtractContentTool {
|
|
|
401
401
|
*/
|
|
402
402
|
extractTitleFromHTML(html) {
|
|
403
403
|
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
404
|
-
|
|
404
|
+
// Decode entities: MDN's <table> page is titled "<table> ...".
|
|
405
|
+
return titleMatch ? load(titleMatch[1], null, false).root().text().trim() : null;
|
|
405
406
|
}
|
|
406
407
|
|
|
407
408
|
/**
|
|
@@ -10,6 +10,7 @@ import { load } from 'cheerio';
|
|
|
10
10
|
import { LLMManager } from '../../core/llm/LLMManager.js';
|
|
11
11
|
import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
|
|
12
12
|
import { fetchAndParse, flattenBodyText } from './_fetchAndParse.js';
|
|
13
|
+
import { fetchErrorMessage } from '../../utils/fetchError.js';
|
|
13
14
|
import { extractMainContent } from '../scrape/_mainContent.js';
|
|
14
15
|
import { verifyNumericProvenance } from '../../utils/provenance.js';
|
|
15
16
|
import { validateAgainstSchema, validateFieldsAgainstSchema } from '../../utils/schemaValidate.js';
|
|
@@ -532,6 +533,7 @@ export class ExtractStructuredTool {
|
|
|
532
533
|
};
|
|
533
534
|
|
|
534
535
|
} catch (error) {
|
|
536
|
+
const message = fetchErrorMessage(error, params.url);
|
|
535
537
|
return {
|
|
536
538
|
success: false,
|
|
537
539
|
url: params.url || 'unknown',
|
|
@@ -540,8 +542,8 @@ export class ExtractStructuredTool {
|
|
|
540
542
|
confidence: 0,
|
|
541
543
|
schema_used: params.schema || {},
|
|
542
544
|
processingTime: Date.now() - startTime,
|
|
543
|
-
error: `Structured extraction failed: ${
|
|
544
|
-
validation: { valid: false, errors: [
|
|
545
|
+
error: `Structured extraction failed: ${message}`,
|
|
546
|
+
validation: { valid: false, errors: [message] }
|
|
545
547
|
};
|
|
546
548
|
}
|
|
547
549
|
}
|
|
@@ -529,8 +529,10 @@ export class ExtractWithLlm {
|
|
|
529
529
|
}));
|
|
530
530
|
} catch (llmErr) {
|
|
531
531
|
if (signal?.aborted) return { success: false, error: `LLM call aborted: ${llmErr.message}` };
|
|
532
|
-
// D1.3: If provider is 'auto'/'ollama' and it failed, try MCP sampling as final fallback
|
|
533
|
-
|
|
532
|
+
// D1.3: If provider is 'auto'/'ollama' and it failed, try MCP sampling as final fallback.
|
|
533
|
+
// Not when the caller named a model: sampling would answer with a
|
|
534
|
+
// different one, silently (R25 3.3).
|
|
535
|
+
if ((providerParam === 'auto' || providerParam === 'ollama') && !modelParam) {
|
|
534
536
|
try {
|
|
535
537
|
const SamplingClient = await getSamplingClient();
|
|
536
538
|
const samplingClient = new SamplingClient({ mcpServer: this._mcpServer });
|
|
@@ -639,7 +641,9 @@ export class ExtractWithLlm {
|
|
|
639
641
|
if (checked.skipped) provenance.skipped = checked.skipped;
|
|
640
642
|
}
|
|
641
643
|
|
|
642
|
-
// C3: surface truncation metadata so callers know the input was clipped
|
|
644
|
+
// C3: surface truncation metadata so callers know the input was clipped.
|
|
645
|
+
// `input_truncated`, not `truncated`: a top-level `truncated: true` means
|
|
646
|
+
// "this result is a preview, read the rest with read_result" (R25).
|
|
643
647
|
const result = {
|
|
644
648
|
success: true,
|
|
645
649
|
data: parsed,
|
|
@@ -649,7 +653,7 @@ export class ExtractWithLlm {
|
|
|
649
653
|
usage
|
|
650
654
|
};
|
|
651
655
|
if (inputTruncated) {
|
|
652
|
-
result.
|
|
656
|
+
result.input_truncated = true;
|
|
653
657
|
result.original_length = original_length;
|
|
654
658
|
}
|
|
655
659
|
if (fetchWarnings.length > 0) result.warnings = fetchWarnings;
|
|
@@ -300,6 +300,33 @@ export class ProcessDocumentTool {
|
|
|
300
300
|
tool: 'process_document'
|
|
301
301
|
});
|
|
302
302
|
if (gate.warnings.length > 0) result.warnings = gate.warnings;
|
|
303
|
+
|
|
304
|
+
// What came back is checked before the PDF parser sees it, as the url
|
|
305
|
+
// branch does: a PNG fetched as pdf_url failed as "Invalid PDF
|
|
306
|
+
// structure" (R25 3.3).
|
|
307
|
+
const response = await safeFetch(source, {
|
|
308
|
+
headers: { ...gate.headers },
|
|
309
|
+
onRedirect: gate.onRedirect,
|
|
310
|
+
signal: AbortSignal.timeout(30000)
|
|
311
|
+
});
|
|
312
|
+
if (!response.ok) {
|
|
313
|
+
if (response.status === 429 || response.status === 503) {
|
|
314
|
+
noteRetryAfter(source, response.headers.get('retry-after'));
|
|
315
|
+
}
|
|
316
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
317
|
+
}
|
|
318
|
+
const contentType = response.headers.get('content-type') || '';
|
|
319
|
+
const buffer = await this.pdfProcessor.readBodyWithSizeCap(response);
|
|
320
|
+
const kind = sniffDocumentKind(contentType, buffer, source);
|
|
321
|
+
if (kind !== 'pdf') {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`${contentType.split(';')[0] || kind} content is not a PDF: sourceType "pdf_url" needs a URL that serves one. ` +
|
|
324
|
+
'process_document reads PDF, DOCX, HTML and plain text; sourceType "url" detects which.'
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
source = buffer;
|
|
328
|
+
sourceType = 'pdf_buffer';
|
|
329
|
+
gate = undefined;
|
|
303
330
|
}
|
|
304
331
|
|
|
305
332
|
const pdfResult = await this.pdfProcessor.processPDF({
|
|
@@ -619,7 +646,8 @@ export class ProcessDocumentTool {
|
|
|
619
646
|
*/
|
|
620
647
|
extractTitleFromHTML(html) {
|
|
621
648
|
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
622
|
-
|
|
649
|
+
// Decode entities: MDN's <table> page is titled "<table> ...".
|
|
650
|
+
return titleMatch ? load(titleMatch[1], null, false).root().text().trim() : null;
|
|
623
651
|
}
|
|
624
652
|
|
|
625
653
|
/**
|
|
@@ -211,11 +211,15 @@ export class SummarizeContentTool {
|
|
|
211
211
|
return result;
|
|
212
212
|
|
|
213
213
|
} catch (error) {
|
|
214
|
+
// A ZodError's message is its issue array as JSON; one line per issue reads better.
|
|
215
|
+
const reason = error instanceof z.ZodError
|
|
216
|
+
? `Invalid input - ${error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ')}`
|
|
217
|
+
: error.message;
|
|
214
218
|
return {
|
|
215
219
|
originalText: params.text?.substring(0, 100) || 'unknown',
|
|
216
220
|
summarizedAt: new Date().toISOString(),
|
|
217
221
|
success: false,
|
|
218
|
-
error: `Content summarization failed: ${
|
|
222
|
+
error: `Content summarization failed: ${reason}`,
|
|
219
223
|
processingTime: Date.now() - startTime,
|
|
220
224
|
summary: {
|
|
221
225
|
text: '',
|
|
@@ -33,7 +33,7 @@ const GenerateLLMsTxtSchema = z.object({
|
|
|
33
33
|
robotsStyle: z.boolean().optional().default(false).describe('Emit legacy robots.txt-style directives instead of spec-compliant llmstxt.org markdown')
|
|
34
34
|
}).optional().prefault({}),
|
|
35
35
|
|
|
36
|
-
complianceLevel: z.enum(['basic', 'standard', 'strict']).optional().default('standard').describe('
|
|
36
|
+
complianceLevel: z.enum(['basic', 'standard', 'strict']).optional().default('standard').describe('Applies only with outputOptions.robotsStyle:true, where it sets how many paths the llms.txt Disallow list covers (basic < standard < strict). The default llmstxt.org markdown is the same at every level; llms-full.txt only records the level as a label.'),
|
|
37
37
|
|
|
38
38
|
format: z.enum(['both', 'llms-txt', 'llms-full-txt']).optional().default('both').describe('Which files to generate')
|
|
39
39
|
});
|
|
@@ -740,7 +740,11 @@ export class GenerateLLMsTxtTool {
|
|
|
740
740
|
lines.push('If you encounter issues or need clarification on these guidelines:');
|
|
741
741
|
lines.push('1. Check this document for guidance');
|
|
742
742
|
lines.push('2. Review the basic llms.txt file');
|
|
743
|
-
|
|
743
|
+
// Only when section 1 printed something to contact: without either field
|
|
744
|
+
// this pointed at contact information the file did not contain.
|
|
745
|
+
if (outputOptions.contactEmail || outputOptions.organizationName) {
|
|
746
|
+
lines.push('3. Contact us using the information above');
|
|
747
|
+
}
|
|
744
748
|
lines.push('');
|
|
745
749
|
lines.push('### Updates');
|
|
746
750
|
lines.push('These guidelines may be updated periodically. Check the generation date above');
|
|
@@ -49,6 +49,12 @@ import { load } from 'cheerio';
|
|
|
49
49
|
// Readability already kept it. Long enough to be unique to that table.
|
|
50
50
|
const SIGNATURE_LENGTH = 120;
|
|
51
51
|
|
|
52
|
+
// Navigation tables are link lists laid out as tables, not data. Wikipedia's
|
|
53
|
+
// navbox and sidebar templates pass the data-table test (a `<th>` and 2+
|
|
54
|
+
// cells a row), so List_of_HTTP_status_codes came back with its "HTTP" sidebar
|
|
55
|
+
// and footer navbox re-attached as pipe tables (R25, 2026-10-19).
|
|
56
|
+
const NAVIGATION_TABLE = 'table.navbox, .navbox-inner, .vertical-navbox, [role="navigation"], nav';
|
|
57
|
+
|
|
52
58
|
// Copied verbatim from @mozilla/readability's REGEXPS. Two of them delete
|
|
53
59
|
// content, and both are unanchored: `unlikelyCandidates` removes an element
|
|
54
60
|
// outright, `negative` docks 25 from its class weight, which is enough for
|
|
@@ -151,6 +157,26 @@ export function sanitizeAccidentalClasses(document) {
|
|
|
151
157
|
return sanitized;
|
|
152
158
|
}
|
|
153
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Undo MediaWiki's heading wrapper before Readability sees the page.
|
|
162
|
+
*
|
|
163
|
+
* Since 2024 MediaWiki emits every section heading as
|
|
164
|
+
* `<div class="mw-heading"><h2>History</h2><span class="mw-editsection">[edit]</span></div>`.
|
|
165
|
+
* Readability scores that div as a link-heavy fragment: the History and
|
|
166
|
+
* Techniques h2s of Wikipedia's *Web scraping* article were dropped, and the
|
|
167
|
+
* headings that survived each came back followed by a "[edit]" link line
|
|
168
|
+
* (R25, 2026-10-19). Deleting the edit links and unwrapping the div leaves the
|
|
169
|
+
* bare heading Readability keeps.
|
|
170
|
+
*
|
|
171
|
+
* @param {Document} document - mutated in place
|
|
172
|
+
*/
|
|
173
|
+
export function unwrapMediaWikiHeadings(document) {
|
|
174
|
+
for (const el of Array.from(document.querySelectorAll('.mw-editsection'))) el.remove();
|
|
175
|
+
for (const wrapper of Array.from(document.querySelectorAll('div.mw-heading'))) {
|
|
176
|
+
wrapper.replaceWith(...Array.from(wrapper.childNodes));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
154
180
|
/**
|
|
155
181
|
* Readability's own data-table test (`_markDataTables`): 10+ rows or more than
|
|
156
182
|
* 4 columns means the table carries data rather than layout, counting
|
|
@@ -232,6 +258,7 @@ export function recoverDroppedTables(html, url, keptText = '') {
|
|
|
232
258
|
// A nested table travels with its parent; re-attaching it separately
|
|
233
259
|
// would duplicate it.
|
|
234
260
|
.filter((table) => !table.parentElement?.closest('table'))
|
|
261
|
+
.filter((table) => !table.closest(NAVIGATION_TABLE))
|
|
235
262
|
.filter((table) => isDataTable(table) || isHeadedTable(table))
|
|
236
263
|
.filter((table) => {
|
|
237
264
|
const signature = normalizeWhitespace(table.textContent || '').slice(0, SIGNATURE_LENGTH);
|
|
@@ -243,6 +270,28 @@ export function recoverDroppedTables(html, url, keptText = '') {
|
|
|
243
270
|
}
|
|
244
271
|
}
|
|
245
272
|
|
|
273
|
+
// Readability's time grows faster than the page, and all of it blocks the
|
|
274
|
+
// event loop: about 1 s at 25k elements (the largest Wikipedia articles), 9 s
|
|
275
|
+
// at 114k (docs.python.org's whatsnew/changelog, 7.3 MB), which starved other
|
|
276
|
+
// crawls past their 30 s queue budget (R25 Phase 2 follow-up). Above this many
|
|
277
|
+
// elements a page skips the main-content pass and callers use the whole page.
|
|
278
|
+
export const MAX_READABILITY_ELEMENTS = 50000;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* True when `html` has more elements than Readability should be given. Counts
|
|
282
|
+
* opening tags with a regex (about 5 ms on 7 MB) rather than building a DOM,
|
|
283
|
+
* which would itself cost seconds.
|
|
284
|
+
* @param {string} html
|
|
285
|
+
*/
|
|
286
|
+
export function tooLargeForReadability(html) {
|
|
287
|
+
const tag = /<[a-zA-Z]/g;
|
|
288
|
+
let count = 0;
|
|
289
|
+
while (tag.exec(html)) {
|
|
290
|
+
if (++count > MAX_READABILITY_ELEMENTS) return true;
|
|
291
|
+
}
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
|
|
246
295
|
/**
|
|
247
296
|
* Extract a page's main content, re-attaching data tables Readability dropped.
|
|
248
297
|
*
|
|
@@ -252,14 +301,17 @@ export function recoverDroppedTables(html, url, keptText = '') {
|
|
|
252
301
|
*
|
|
253
302
|
* @param {string} html - the page's HTML
|
|
254
303
|
* @param {string} [url] - document URL, used as the base for relative links
|
|
255
|
-
* @returns {{ html: string|null, title: string, tablesRecovered: number }}
|
|
256
|
-
* is null when Readability found no article
|
|
304
|
+
* @returns {{ html: string|null, title: string, tablesRecovered: number, tooLarge?: boolean }}
|
|
305
|
+
* `html` is null when Readability found no article, or was not run because the
|
|
306
|
+
* page is too large (`tooLarge`); callers decide what to fall back to.
|
|
257
307
|
*/
|
|
258
308
|
export function extractMainContent(html, url) {
|
|
309
|
+
if (tooLargeForReadability(html)) return { html: null, title: '', tablesRecovered: 0, tooLarge: true };
|
|
259
310
|
let article;
|
|
260
311
|
try {
|
|
261
312
|
const dom = new JSDOM(html, { url });
|
|
262
313
|
sanitizeAccidentalClasses(dom.window.document);
|
|
314
|
+
unwrapMediaWikiHeadings(dom.window.document);
|
|
263
315
|
article = new Readability(dom.window.document).parse();
|
|
264
316
|
} catch {
|
|
265
317
|
return { html: null, title: '', tablesRecovered: 0 };
|
|
@@ -27,7 +27,8 @@ import { toPublicUnit, parseChosenIndexes, groundingCheck, headingUnits, NOT_IN_
|
|
|
27
27
|
import { setActualCost } from '../../server/requestContext.js';
|
|
28
28
|
import { fenceUntrusted } from '../../utils/untrustedContent.js';
|
|
29
29
|
import { fetchAndParse } from '../extract/_fetchAndParse.js';
|
|
30
|
-
import {
|
|
30
|
+
import { fetchErrorMessage } from '../../utils/fetchError.js';
|
|
31
|
+
import { extractMainContent, isThinMainContent, keepPageHeading, MAX_READABILITY_ELEMENTS } from './_mainContent.js';
|
|
31
32
|
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
32
33
|
import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
|
|
33
34
|
import { extractBlockText } from '../basic/extractText.js';
|
|
@@ -53,6 +54,85 @@ export const JSON_TIMEOUT_MS = 60000;
|
|
|
53
54
|
|
|
54
55
|
const isQueryFormat = (fmt) => Boolean(fmt) && typeof fmt === 'object' && (fmt.type === 'highlights' || fmt.type === 'question');
|
|
55
56
|
|
|
57
|
+
// ── Question evidence support (R25 1.4) ──────────────────────────────────────
|
|
58
|
+
// A ranked unit is not support for an answer by being ranked: before
|
|
59
|
+
// crawlforge-extractors 1.18.0 the ranker scored link URLs, and Wikipedia's
|
|
60
|
+
// reference list answered "Who created Python…?" with citation titles — as
|
|
61
|
+
// grounded:true. These checks read only the units this call returns, so they
|
|
62
|
+
// hold with any extractors version.
|
|
63
|
+
const EVIDENCE_LINK = /(!?)\[((?:\\.|[^[\]\\])*)\]\((?:\\.|[^()\\\s]|\([^()\s]*\))*(?:\s+"(?:\\.|[^"\\])*")?\)/g;
|
|
64
|
+
const REFERENCE_HEADING = /^(?:(?:references?|notes?|citations?|sources?|footnotes?|bibliography|works cited)(?:\s*(?:,|&|and)\s*|\s*$))+$/i;
|
|
65
|
+
// Words any question shares with any page; they show no unit answers it.
|
|
66
|
+
const QUESTION_STOPWORDS = new Set([
|
|
67
|
+
'a', 'an', 'the', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'at', 'by', 'for', 'with', 'from', 'as', 'into', 'about',
|
|
68
|
+
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'am', 'do', 'does', 'did', 'has', 'have', 'had', 'can', 'could',
|
|
69
|
+
'will', 'would', 'should', 'may', 'might', 'it', 'its', 'this', 'that', 'these', 'those', 'there', 'their', 'they',
|
|
70
|
+
'he', 'she', 'his', 'her', 'i', 'you', 'we', 'my', 'your', 'our', 'me', 'us', 'them', 'any', 'some', 'not', 'no',
|
|
71
|
+
'who', 'whom', 'whose', 'what', 'which', 'when', 'where', 'why', 'how', 'many', 'much'
|
|
72
|
+
]);
|
|
73
|
+
// crawlforge-extractors' word runs: a CJK run and any other run never merge.
|
|
74
|
+
const CJK_CHAR = '[\\p{scx=Han}\\p{scx=Hiragana}\\p{scx=Katakana}\\p{scx=Hangul}]';
|
|
75
|
+
const QUESTION_TOKEN = new RegExp(`(?:(?=${CJK_CHAR})[\\p{L}\\p{N}])+|(?:(?!${CJK_CHAR})[\\p{L}\\p{N}\\p{M}])+`, 'gu');
|
|
76
|
+
const CJK_RUN = new RegExp(`^${CJK_CHAR}`, 'u');
|
|
77
|
+
|
|
78
|
+
/** A unit's text as a reader sees it: link labels without URLs, no citation markers. */
|
|
79
|
+
function visibleEvidenceText(text) {
|
|
80
|
+
let out = String(text ?? '');
|
|
81
|
+
for (let pass = 0; pass < 2 && out.includes(']('); pass++) {
|
|
82
|
+
out = out.replace(EVIDENCE_LINK, (match, _bang, label) => (/\]\(#cite_(?:note|ref)/.test(match) ? ' ' : label));
|
|
83
|
+
}
|
|
84
|
+
return out.toLowerCase();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** An entry of a references-type list: under such a heading, or carrying a Wikipedia back-link. */
|
|
88
|
+
function isReferenceUnit(unit) {
|
|
89
|
+
if (/\]\(#cite_ref/.test(String(unit.text ?? ''))) return true;
|
|
90
|
+
return typeof unit.heading === 'string' && REFERENCE_HEADING.test(unit.heading.trim());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The question's content terms, stemmed lightly ("released" → "releas") so a
|
|
95
|
+
* substring test finds their inflections; a CJK run becomes bigrams.
|
|
96
|
+
*/
|
|
97
|
+
function questionTerms(question) {
|
|
98
|
+
const terms = new Set();
|
|
99
|
+
for (const [run] of String(question ?? '').toLowerCase().matchAll(QUESTION_TOKEN)) {
|
|
100
|
+
if (CJK_RUN.test(run)) {
|
|
101
|
+
const chars = Array.from(run);
|
|
102
|
+
if (chars.length === 1) terms.add(run);
|
|
103
|
+
for (let i = 0; i + 1 < chars.length; i++) terms.add(chars[i] + chars[i + 1]);
|
|
104
|
+
} else if (!QUESTION_STOPWORDS.has(run)) {
|
|
105
|
+
terms.add(run.length > 4 ? run.replace(/(?:ing|ed|es|s)$/, '').replace(/(.{4,})e$/, '$1') : run);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return [...terms];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether the ranked evidence can ground an answer: some unit outside a
|
|
113
|
+
* reference list whose visible text (or heading) holds a content term of the
|
|
114
|
+
* question. Returns the reason when it cannot.
|
|
115
|
+
* @param {Array<{ text: string, heading?: string | null }>} evidence
|
|
116
|
+
* @param {string} question
|
|
117
|
+
* @returns {{ supported: boolean, reason?: string }}
|
|
118
|
+
*/
|
|
119
|
+
export function evidenceSupport(evidence, question) {
|
|
120
|
+
if (evidence.length === 0) return { supported: false };
|
|
121
|
+
const body = evidence.filter((unit) => !isReferenceUnit(unit));
|
|
122
|
+
if (body.length === 0) {
|
|
123
|
+
return { supported: false, reason: 'every evidence unit is a reference-list entry (References, Notes, Citations or Sources)' };
|
|
124
|
+
}
|
|
125
|
+
const terms = questionTerms(question);
|
|
126
|
+
if (terms.length === 0) return { supported: true };
|
|
127
|
+
const holds = body.some((unit) => {
|
|
128
|
+
const visible = `${visibleEvidenceText(unit.text)}\n${visibleEvidenceText(unit.heading)}`;
|
|
129
|
+
return terms.some((term) => visible.includes(term));
|
|
130
|
+
});
|
|
131
|
+
return holds
|
|
132
|
+
? { supported: true }
|
|
133
|
+
: { supported: false, reason: 'no evidence unit outside a reference list contains a term of the question in its visible text' };
|
|
134
|
+
}
|
|
135
|
+
|
|
56
136
|
// The six public fields in tools/list order, each carrying the description
|
|
57
137
|
// the client sees. server.js spreads this with COMPLIANCE_PARAMS instead of
|
|
58
138
|
// keeping its own copy (Phase 0, 0.3).
|
|
@@ -359,7 +439,7 @@ export class UnifiedScrapeTool {
|
|
|
359
439
|
errorDocuments: true
|
|
360
440
|
}));
|
|
361
441
|
} catch (err) {
|
|
362
|
-
throw new Error(`scrape: fetch failed for ${url}: ${err
|
|
442
|
+
throw new Error(`scrape: fetch failed for ${url}: ${fetchErrorMessage(err, url)}`);
|
|
363
443
|
}
|
|
364
444
|
warnings.push(...fetchWarnings);
|
|
365
445
|
|
|
@@ -504,6 +584,11 @@ export class UnifiedScrapeTool {
|
|
|
504
584
|
if (mainHtml !== null) return mainHtml;
|
|
505
585
|
const main = extractMainContent(html, finalUrl);
|
|
506
586
|
mainHtml = main.html ?? html;
|
|
587
|
+
if (main.tooLarge) {
|
|
588
|
+
warnings.push(
|
|
589
|
+
`mainContent: the page has more than ${MAX_READABILITY_ELEMENTS.toLocaleString('en-US')} elements, too many for main-content extraction; the whole page is used instead`
|
|
590
|
+
);
|
|
591
|
+
}
|
|
507
592
|
if (main.tablesRecovered > 0) {
|
|
508
593
|
warnings.push(
|
|
509
594
|
`mainContent: re-attached ${main.tablesRecovered} data table(s) that main-content extraction had dropped`
|
|
@@ -638,7 +723,7 @@ export class UnifiedScrapeTool {
|
|
|
638
723
|
`json: output did not match the requested schema — ${(result.validationErrors || []).join('; ')}`
|
|
639
724
|
);
|
|
640
725
|
}
|
|
641
|
-
if (result.
|
|
726
|
+
if (result.input_truncated) {
|
|
642
727
|
warnings.push(
|
|
643
728
|
`json: page text was truncated from ${result.original_length} chars before extraction; ` +
|
|
644
729
|
'fields appearing late in the page may be missing'
|
|
@@ -686,8 +771,11 @@ export class UnifiedScrapeTool {
|
|
|
686
771
|
const evidence = rankUnits(candidates, fmt.question, { maxUnits: QUESTION_EVIDENCE_UNITS });
|
|
687
772
|
const evidenceText = evidence.map((unit) => unit.text).join('\n');
|
|
688
773
|
let text = evidenceText;
|
|
689
|
-
// An empty answer rests on nothing, so it is never grounded
|
|
690
|
-
|
|
774
|
+
// An empty answer rests on nothing, so it is never grounded; nor
|
|
775
|
+
// does one resting on reference-list entries or link URLs alone.
|
|
776
|
+
const support = evidenceSupport(evidence, fmt.question);
|
|
777
|
+
if (support.reason) warnings.push(`question: ${support.reason}; grounded: false`);
|
|
778
|
+
let grounded = support.supported;
|
|
691
779
|
if (fmt.mode === 'model' && evidence.length > 0) {
|
|
692
780
|
try {
|
|
693
781
|
text = await this._answerQuestion(evidence, fmt.question);
|
|
@@ -698,7 +786,7 @@ export class UnifiedScrapeTool {
|
|
|
698
786
|
warnings.push('question: the model found no answer in the evidence; grounded: false');
|
|
699
787
|
} else {
|
|
700
788
|
const check = groundingCheck(text, evidenceText, fmt.question);
|
|
701
|
-
grounded = check.grounded;
|
|
789
|
+
grounded = check.grounded && support.supported;
|
|
702
790
|
if (!grounded) {
|
|
703
791
|
warnings.push(`question: the model's answer has ${check.unbacked.length} token(s) not found in the evidence or the question (${check.unbacked.join(', ')}); grounded: false`);
|
|
704
792
|
}
|