crawlforge-mcp-server 6.21.1 → 6.22.1
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 +228 -21
- 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 +76 -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 +22 -14
- 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/providers/searxng.js +20 -3
- package/src/tools/search/ranking/ResultDeduplicator.js +51 -65
- 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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-stealth-browsing
|
|
3
3
|
description: "Bypasses bot detection with CrawlForge's stealth_mode tool and looks up a country's locale settings with localization. Use when a site returns 403 or 429, CAPTCHAs, 'please enable JavaScript', or empty content, or is protected by Cloudflare, DataDome, or PerimeterX, or when the user needs a page in a specific locale, language, or timezone. stealth_mode runs a stealth browser (engine auto by default: camoufox/Firefox for advanced fingerprinting, playwright/Chromium when pinned for speed) and can screenshot; localization returns a country's Accept-Language, timezone and currency for you to pass to fetch_url or stealth_mode - it applies nothing itself and uses no proxy. Explains when to escalate from a normal scrape to stealth, and why a hard block needs a residential proxy."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.22.1
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -32,20 +32,13 @@ Still never reach for `stealth_mode` first.
|
|
|
32
32
|
|
|
33
33
|
## stealth_mode (cost: 5)
|
|
34
34
|
|
|
35
|
-
`stealth_mode` is operation-based.
|
|
36
|
-
|
|
35
|
+
`stealth_mode` is operation-based. `operation:"scrape"` is the one-shot path:
|
|
36
|
+
it renders one URL and returns the requested formats (examples below).
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"operation": "create_context",
|
|
43
|
-
"stealthConfig": { "level": "advanced", "simulateHumanBehavior": true }
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
Then use the returned `contextId`:
|
|
38
|
+
`create_context` then `create_page` is a navigation and challenge check, not a
|
|
39
|
+
multi-step path: `create_page` loads `urlToTest`, reports its status, title and
|
|
40
|
+
any challenge page it detected, and closes the page before the call returns.
|
|
41
|
+
Nothing on that page carries over to a later call.
|
|
49
42
|
|
|
50
43
|
```json
|
|
51
44
|
{
|
|
@@ -54,6 +47,10 @@ Then use the returned `contextId`:
|
|
|
54
47
|
}
|
|
55
48
|
```
|
|
56
49
|
|
|
50
|
+
For work over several calls on one page (log in, then read; click through a
|
|
51
|
+
flow), use `browser_session` with `stealth:true` (see the
|
|
52
|
+
crawlforge-browser-sessions skill).
|
|
53
|
+
|
|
57
54
|
Operations: `scrape`, `configure`, `create_context`, `create_page`,
|
|
58
55
|
`get_stats`, `cleanup`. `stealthConfig.level` is `basic` / `medium` (default) /
|
|
59
56
|
`advanced`. Always run `cleanup` when done to release the browser.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-structured-extraction
|
|
3
3
|
description: "Extracts structured JSON and analyzes content with CrawlForge's extract_structured, extract_with_llm, extract_embedded_state, scrape_structured, scrape_template, process_document, analyze_content, summarize_content, and list_ollama_models tools. Use when the user wants to extract specific fields, pull data into a JSON schema, extract by natural-language prompt, scrape with CSS selectors, read a page's embedded JavaScript state (__NEXT_DATA__, React Server Components, Nuxt, Apollo, Redux), get product, profile, or repo data from known sites (Amazon, LinkedIn, GitHub, YouTube, Reddit, and more), parse a PDF or DOCX, summarize a page, or analyze sentiment, entities, or keywords. Defaults to local Ollama for LLM extraction; OpenAI and Anthropic optional."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.22.1
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-web-scraping
|
|
3
3
|
description: "Scrapes web pages and returns clean Markdown, HTML, plain text, links, or metadata using CrawlForge's scrape, fetch_url, extract_content, extract_text, extract_links, extract_metadata, map_site, and crawl_deep tools. Use when the user wants to scrape a URL, fetch a page, get the markdown or text of a website, extract links or metadata, read an article, map a site's URLs, generate a sitemap, or crawl a whole website or documentation site. Prefer the unified scrape tool to get multiple formats in one call; use map_site to discover URLs and crawl_deep to walk an entire site."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.22.1
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { z } from 'zod';
|
|
27
27
|
import { load } from 'cheerio';
|
|
28
|
+
import { absoluteUrls } from 'crawlforge-extractors';
|
|
28
29
|
import { createHash } from 'node:crypto';
|
|
29
30
|
|
|
30
31
|
import ActionExecutor from '../../core/ActionExecutor.js';
|
|
@@ -577,8 +578,16 @@ export class BrowserSessionTool {
|
|
|
577
578
|
if (params.formats.includes('markdown')) {
|
|
578
579
|
// Readability finds no article on most app pages, and then no markdown is
|
|
579
580
|
// produced at all (R20, 2026-09-07). Convert the DOM we hold instead of
|
|
580
|
-
// handing back a placeholder.
|
|
581
|
-
|
|
581
|
+
// handing back a placeholder. The raw DOM carries no base URL, so its
|
|
582
|
+
// links are resolved against the page first: HN's "item?id=…" hrefs came
|
|
583
|
+
// back relative and unusable (R25, 2026-10-19).
|
|
584
|
+
let markdown = params.onlyMainContent && extracted.content?.markdown;
|
|
585
|
+
if (!markdown) {
|
|
586
|
+
const $page = load(html);
|
|
587
|
+
absoluteUrls($page, url);
|
|
588
|
+
markdown = htmlToMarkdown($page.html());
|
|
589
|
+
}
|
|
590
|
+
content.markdown = markdown;
|
|
582
591
|
}
|
|
583
592
|
if (params.formats.includes('json')) {
|
|
584
593
|
content.json = {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Semaphore-based concurrency runner that dispatches work to worker.js.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { scrapeUrl } from './worker.js';
|
|
6
|
+
import { scrapeUrl, failedEntryHint } from './worker.js';
|
|
7
7
|
|
|
8
8
|
/** Semaphore for concurrency limiting. */
|
|
9
9
|
class Semaphore {
|
|
@@ -57,10 +57,12 @@ export async function scrapeUrlsBatch(urlConfigs, options, defaultTimeout) {
|
|
|
57
57
|
|
|
58
58
|
return settled.map((result, index) => {
|
|
59
59
|
if (result.status === 'fulfilled') return result.value;
|
|
60
|
+
const error = result.reason?.message || 'Unknown error';
|
|
60
61
|
return {
|
|
61
62
|
success: false,
|
|
62
63
|
url: urlConfigs[index].url,
|
|
63
|
-
error
|
|
64
|
+
error,
|
|
65
|
+
next_step: failedEntryHint(error),
|
|
64
66
|
timestamp: Date.now()
|
|
65
67
|
};
|
|
66
68
|
});
|
|
@@ -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 };
|