crawlforge-mcp-server 6.3.1 → 6.5.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/README.md +1 -1
- package/package.json +2 -1
- package/server.js +10 -5
- package/src/core/ActionExecutor.js +9 -1
- package/src/core/AgentOrchestrator.js +22 -1
- package/src/core/ChangeTracker.js +25 -8
- package/src/core/crawlers/BFSCrawler.js +21 -2
- package/src/core/llm/LLMManager.js +86 -6
- package/src/core/processing/PDFProcessor.js +3 -1
- package/src/server/fallbackHints.js +3 -0
- package/src/server/inlineThreshold.js +34 -1
- package/src/server/requestContext.js +1 -1
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +2 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +21 -0
- package/src/tools/basic/extractLinks.js +3 -1
- package/src/tools/crawl/mapSite.js +81 -7
- package/src/tools/extract/extractEmbeddedState.js +18 -2
- package/src/tools/extract/extractStructured.js +4 -0
- package/src/tools/extract/processDocument.js +94 -1
- package/src/tools/scrape/_brandingExtractor.js +23 -5
- package/src/tools/scrape/_mainContent.js +17 -1
- package/src/tools/scrape/unifiedScrape.js +8 -1
- package/src/tools/search/redditSearch.js +36 -18
- package/src/utils/hiddenContent.js +67 -2
- package/src/utils/htmlToMarkdown.js +33 -1
- package/src/utils/redditHosts.js +123 -0
- package/src/utils/robotsGate.js +27 -3
- package/src/utils/sitemapParser.js +35 -12
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
| | **CrawlForge MCP** | Firecrawl | Raw scraping API |
|
|
49
49
|
|---|:---:|:---:|:---:|
|
|
50
50
|
| Native MCP server | ✅ 30 tools | ✅ | ❌ |
|
|
51
|
-
| Free tier | ✅ 1,000 credits,
|
|
51
|
+
| Free tier | ✅ 1,000 credits, one-time, never expire | Limited | Varies |
|
|
52
52
|
| Self-hosted / local LLM extraction (Ollama) | ✅ default, $0/token | ❌ | ❌ |
|
|
53
53
|
| Autonomous agent (no URLs needed) | ✅ `agent` | ✅ | ❌ |
|
|
54
54
|
| Deep research with source verification | ✅ `deep_research` | Partial | ❌ |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.5.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 30 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
|
|
6
6
|
"main": "server.js",
|
|
@@ -115,6 +115,7 @@
|
|
|
115
115
|
"@modelcontextprotocol/node": "^2.0.0",
|
|
116
116
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
117
117
|
"@mozilla/readability": "^0.6.0",
|
|
118
|
+
"mammoth": "^1.12.2",
|
|
118
119
|
"cheerio": "^1.1.2",
|
|
119
120
|
"commander": "^14.0.3",
|
|
120
121
|
"compromise": "^14.14.4",
|
package/server.js
CHANGED
|
@@ -107,7 +107,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
107
107
|
// Create the server
|
|
108
108
|
const server = new McpServer({
|
|
109
109
|
name: "crawlforge",
|
|
110
|
-
version: "6.
|
|
110
|
+
version: "6.5.0",
|
|
111
111
|
description: "Production-ready MCP server with 30 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
112
112
|
homepage: "https://www.crawlforge.dev",
|
|
113
113
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -541,7 +541,7 @@ registerToolIfEnabled("serp_rank", {
|
|
|
541
541
|
|
|
542
542
|
// Tool: reddit_search — search Reddit posts/comments or read a full thread (via community archives)
|
|
543
543
|
registerToolIfEnabled("reddit_search", {
|
|
544
|
-
description: "Use this to search Reddit posts or comments, or read a full comment thread - reddit.com blocks direct scraping, so this reads the Arctic Shift community archive instead (free, no Reddit credentials). Modes: 'posts' (default) and 'comments' search; 'thread' returns a post plus its nested comment tree by link_id. A subreddit/author-scoped search queries the archive directly. A keyword search across ALL of Reddit finds posts with a site-restricted web search and then reads those posts from the archive, because Arctic Shift can only keyword-search within a scope; results come back as real archive rows, ordered by search relevance. An unscoped COMMENT search discovers posts the same way and then searches each post's comments for the keywords. A scoped comment search Arctic Shift times out on is retried over narrower windows (7d, 3d, 1d) and reports window_applied. Not for reddit.com URLs via scrape or fetch_url (blocked) - use mode:'thread' with the post's link_id. Cost: 5 credits. Example: reddit_search({query: \"best mechanical keyboard\", subreddit: \"MechanicalKeyboards\", limit: 10})",
|
|
544
|
+
description: "Use this to search Reddit posts or comments, or read a full comment thread - reddit.com blocks direct scraping, so this reads the Arctic Shift community archive instead (free, no Reddit credentials). Modes: 'posts' (default) and 'comments' search; 'thread' returns a post plus its nested comment tree by link_id. A subreddit/author-scoped search queries the archive directly. A keyword search across ALL of Reddit finds posts with a site-restricted web search and then reads those posts from the archive, because Arctic Shift can only keyword-search within a scope; results come back as real archive rows, ordered by search relevance. An unscoped COMMENT search discovers posts the same way and then searches each post's comments for the keywords. A scoped comment search Arctic Shift times out on is retried over narrower windows (7d, 3d, 1d) and reports window_applied. Arctic Shift is tried first and the PullPush archive second for posts/comments searches (fallback_used says so; PullPush has refused automated clients since August 2026). Not for reddit.com URLs via scrape or fetch_url (blocked) - use mode:'thread' with the post's link_id. Cost: 5 credits. Example: reddit_search({query: \"best mechanical keyboard\", subreddit: \"MechanicalKeyboards\", limit: 10})",
|
|
545
545
|
annotations: { title: "Reddit Search", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
546
546
|
inputSchema: {
|
|
547
547
|
query: z.string().optional().describe("Keyword search. Posts: matches title+selftext; comments: matches body. Supports \"quoted phrases\", OR, -exclusion"),
|
|
@@ -680,7 +680,7 @@ registerToolIfEnabled("extract_content", {
|
|
|
680
680
|
|
|
681
681
|
// Tool: process_document
|
|
682
682
|
registerToolIfEnabled("process_document", {
|
|
683
|
-
description: "Use this to extract text from a PDF URL or file - research papers, contracts, reports. Returns structured sections, metadata, and word count. Not for ordinary web pages (scrape), though an HTML URL is accepted. Cost: 2 credits. Example: process_document({source: \"https://example.com/report.pdf\", sourceType: \"pdf_url\"})",
|
|
683
|
+
description: "Use this to extract text from a PDF or DOCX URL or file - research papers, contracts, reports. The body decides how it is read: a PDF or Word document served under sourceType \"url\" still reaches its parser, and a body this tool cannot read (an image, an archive) is refused by name. Returns structured sections, metadata, and word count. Not for ordinary web pages (scrape), though an HTML URL is accepted. Cost: 2 credits. Example: process_document({source: \"https://example.com/report.pdf\", sourceType: \"pdf_url\"})",
|
|
684
684
|
annotations: { title: "Process Document", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
685
685
|
inputSchema: {
|
|
686
686
|
source: z.string().describe("Document source - URL or file path"),
|
|
@@ -876,7 +876,8 @@ registerToolIfEnabled("get_batch_results", {
|
|
|
876
876
|
inputSchema: {
|
|
877
877
|
batchId: z.string().describe("The batch ID returned by batch_scrape"),
|
|
878
878
|
page: z.number().min(1).default(1).describe("Page number (1-based)"),
|
|
879
|
-
pageSize: z.number().min(1).max(100).default(25).describe("Number of results per page")
|
|
879
|
+
pageSize: z.number().min(1).max(100).default(25).describe("Number of results per page"),
|
|
880
|
+
...MAX_INLINE_CHARS_PARAM
|
|
880
881
|
}
|
|
881
882
|
}, withAuth("get_batch_results", async ({ batchId, page = 1, pageSize = 25 }) => {
|
|
882
883
|
try {
|
|
@@ -990,11 +991,15 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
990
991
|
|
|
991
992
|
// Publish captured screenshots as crawlforge://screenshot/{actionId}
|
|
992
993
|
// resources (the documented contract) and annotate each with its URI.
|
|
994
|
+
// The base64 `data` is dropped once stored, as the stealth_mode block
|
|
995
|
+
// below already does: a failed chain's error screenshot came back inline
|
|
996
|
+
// as 1.7 MB of base64 beside a 40-char markdown (R21, 2026-09-09).
|
|
993
997
|
if (Array.isArray(result.screenshots)) {
|
|
994
998
|
result.screenshots = result.screenshots.map((shot) => {
|
|
995
999
|
if (shot?.actionId && shot?.data) {
|
|
996
1000
|
resourceRegistry.storeScreenshot(shot.actionId, shot.data);
|
|
997
|
-
|
|
1001
|
+
const { data, ...rest } = shot;
|
|
1002
|
+
return { ...rest, resourceUri: `crawlforge://screenshot/${shot.actionId}` };
|
|
998
1003
|
}
|
|
999
1004
|
return shot;
|
|
1000
1005
|
});
|
|
@@ -314,7 +314,15 @@ export class ActionExecutor extends EventEmitter {
|
|
|
314
314
|
fullPage: true,
|
|
315
315
|
description: 'Error screenshot'
|
|
316
316
|
});
|
|
317
|
-
|
|
317
|
+
// An actionId is what lets the server publish the shot as a
|
|
318
|
+
// crawlforge://screenshot/{actionId} resource and drop the base64
|
|
319
|
+
// from the result; without one a failed chain shipped 1.7 MB of
|
|
320
|
+
// PNG inline (R21, 2026-09-09).
|
|
321
|
+
executionContext.screenshots.push({
|
|
322
|
+
...errorScreenshot,
|
|
323
|
+
actionId: this.generateActionId(),
|
|
324
|
+
error: true
|
|
325
|
+
});
|
|
318
326
|
} catch (screenshotError) {
|
|
319
327
|
this.log('warn', 'Failed to capture error screenshot: ' + screenshotError.message);
|
|
320
328
|
}
|
|
@@ -333,6 +333,23 @@ export class AgentOrchestrator {
|
|
|
333
333
|
quotedTerms.every(t => q.toLowerCase().includes(t.toLowerCase())) ? q : `${q} ${quotedTerms.join(' ')}`.trim()
|
|
334
334
|
);
|
|
335
335
|
}
|
|
336
|
+
// A current-state plan is told to make its first query the bare entity
|
|
337
|
+
// name, and a small model often stops there. The bare query surfaces the
|
|
338
|
+
// live front page, which rarely states the fact asked for: "what does
|
|
339
|
+
// Southwest charge for a first checked bag" fetched southwest.com's home,
|
|
340
|
+
// booking and careers pages and answered that the fee is not stated
|
|
341
|
+
// (R20, 2026-09-07). Keep the entity query first — the domain vote and
|
|
342
|
+
// live-root promotion depend on it — and add the task's own words as a
|
|
343
|
+
// second query so the page that states the fact is in the queue too.
|
|
344
|
+
if (currentState && searchQueries.length === 1) {
|
|
345
|
+
try {
|
|
346
|
+
const { compactSearchTopic, clampSearchQuery } = await import('./ResearchOrchestrator.js');
|
|
347
|
+
const factQuery = clampSearchQuery(compactSearchTopic(prompt));
|
|
348
|
+
if (factQuery && factQuery.toLowerCase() !== searchQueries[0].toLowerCase()) {
|
|
349
|
+
searchQueries.push(factQuery);
|
|
350
|
+
}
|
|
351
|
+
} catch { /* the entity query alone is the pre-R20 behaviour */ }
|
|
352
|
+
}
|
|
336
353
|
|
|
337
354
|
// ── GATHER (search) ───────────────────────────────────────────────────────
|
|
338
355
|
const urlQueue = [...seedUrls]; // start with any user-provided seeds
|
|
@@ -389,7 +406,11 @@ export class AgentOrchestrator {
|
|
|
389
406
|
// and never enters evidence.
|
|
390
407
|
if (currentState && searchResults.length > 0) {
|
|
391
408
|
const originCounts = new Map();
|
|
409
|
+
// Only the bare entity query votes: the fact query added above returns
|
|
410
|
+
// guides and news sites, which must not outvote the official domain.
|
|
411
|
+
const entityQuery = searchQueries[0];
|
|
392
412
|
for (const s of searchResults) {
|
|
413
|
+
if (s.query !== entityQuery) continue;
|
|
393
414
|
try {
|
|
394
415
|
const origin = new URL(s.url).origin;
|
|
395
416
|
originCounts.set(origin, (originCounts.get(origin) || 0) + 1);
|
|
@@ -536,7 +557,7 @@ export class AgentOrchestrator {
|
|
|
536
557
|
// Short and imperative on purpose: the executing model is a small
|
|
537
558
|
// local one (gemma3:4b-class) and ignores hedged phrasing.
|
|
538
559
|
(currentState
|
|
539
|
-
? `- The task asks about the CURRENT state. Answer from the FIRST source below (the live page). NEVER present older or dated content as the current answer.\n`
|
|
560
|
+
? `- The task asks about the CURRENT state. Answer from the FIRST source below (the live page); if the FIRST source does not state the answer, take it from the next sources in order. NEVER present older or dated content as the current answer.\n`
|
|
540
561
|
: '') +
|
|
541
562
|
`- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
|
|
542
563
|
`- Read the sources carefully before concluding anything is missing from them.\n` +
|
|
@@ -720,14 +720,16 @@ export class ChangeTracker extends EventEmitter {
|
|
|
720
720
|
significanceScore += Math.min(totalElements * 0.05, 1) *
|
|
721
721
|
(weights.additions + weights.removals + weights.modifications);
|
|
722
722
|
|
|
723
|
-
// Text changes impact
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
723
|
+
// Text changes impact. textChanges holds diff GROUPS — {type:'word_diff',
|
|
724
|
+
// changes:[{added, removed, value}]} — not flat parts, so reading
|
|
725
|
+
// `change.added.length` off a group was always 0 and this term never
|
|
726
|
+
// fired: a feed that grew by a whole record scored on similarity alone.
|
|
727
|
+
// The USGS all-hour earthquake feed gained an event (622 words, 84%
|
|
728
|
+
// similar) and compare reported hasChanges:false, "No significant
|
|
729
|
+
// changes detected" (R21, 2026-09-09).
|
|
730
|
+
const changedChars = this.changedTextChars(changeAnalysis.textChanges);
|
|
731
|
+
if (changedChars > 0) {
|
|
732
|
+
significanceScore += Math.min(changedChars / 1000, 1) * weights.textChanges;
|
|
731
733
|
}
|
|
732
734
|
|
|
733
735
|
// Determine significance level
|
|
@@ -1317,6 +1319,21 @@ export class ChangeTracker extends EventEmitter {
|
|
|
1317
1319
|
);
|
|
1318
1320
|
}
|
|
1319
1321
|
|
|
1322
|
+
/**
|
|
1323
|
+
* Characters added or removed at the text level, for significance scoring.
|
|
1324
|
+
* Same word-diff-then-line-diff choice as countTextChanges, and for the
|
|
1325
|
+
* same reason: the two describe one edit.
|
|
1326
|
+
*/
|
|
1327
|
+
changedTextChars(textChanges = []) {
|
|
1328
|
+
const diff = textChanges.find(c => c.type === 'word_diff')
|
|
1329
|
+
|| textChanges.find(c => c.type === 'line_diff');
|
|
1330
|
+
if (!diff) return 0;
|
|
1331
|
+
return diff.changes.reduce(
|
|
1332
|
+
(chars, part) => chars + ((part.added || part.removed) && typeof part.value === 'string' ? part.value.length : 0),
|
|
1333
|
+
0
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1320
1337
|
generateChangeSummary(changeAnalysis, significance) {
|
|
1321
1338
|
const { addedElements, removedElements, modifiedElements, similarity } = changeAnalysis;
|
|
1322
1339
|
|
|
@@ -12,6 +12,7 @@ import { robotsPreflight, RobotsDisallowedError } from '../../utils/robotsGate.j
|
|
|
12
12
|
import { throttleHost } from '../../utils/hostRateLimiter.js';
|
|
13
13
|
import { CRAWLFORGE_USER_AGENT, identityHeaders } from '../../utils/fetchIdentity.js';
|
|
14
14
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
15
|
+
import { extractMainContent, isThinMainContent } from '../../tools/scrape/_mainContent.js';
|
|
15
16
|
|
|
16
17
|
const logger = new Logger('BFSCrawler');
|
|
17
18
|
|
|
@@ -391,9 +392,27 @@ export class BFSCrawler {
|
|
|
391
392
|
// Extract title
|
|
392
393
|
const title = pageTitle($) || $('h1').first().text().trim() || '';
|
|
393
394
|
|
|
394
|
-
// Extract main content
|
|
395
|
+
// Extract main content. A page's chrome (header, mega-menu, footer)
|
|
396
|
+
// repeats on every page of a crawl and comes first in body order, so the
|
|
397
|
+
// 500-char preview showed the same "Explore Products" menu for all six
|
|
398
|
+
// Cessna pages, whose menu is plain <div>s outside any <nav> (R20,
|
|
399
|
+
// 2026-09-07). Use the same main-content pass scrape uses, fall back to
|
|
400
|
+
// the body minus its landmark chrome when Readability finds nothing or
|
|
401
|
+
// only a thin fragment, and to the whole body as a last resort.
|
|
395
402
|
$('script, style, noscript').remove();
|
|
396
|
-
const
|
|
403
|
+
const squash = (text) => text.replace(/\s+/g, ' ').trim();
|
|
404
|
+
let content = '';
|
|
405
|
+
try {
|
|
406
|
+
const main = extractMainContent(html, url);
|
|
407
|
+
if (main.html && !isThinMainContent(main.html, html)) {
|
|
408
|
+
content = squash(load(main.html)('body').text());
|
|
409
|
+
}
|
|
410
|
+
} catch { /* fall through to the body */ }
|
|
411
|
+
if (!content) {
|
|
412
|
+
const $body = $('body').clone();
|
|
413
|
+
$body.find('header, nav, footer, aside, [role="navigation"], [role="banner"], [role="contentinfo"]').remove();
|
|
414
|
+
content = squash($body.text()) || squash($('body').text());
|
|
415
|
+
}
|
|
397
416
|
|
|
398
417
|
// Extract metadata
|
|
399
418
|
const metadata = {
|
|
@@ -27,6 +27,70 @@ function endsMidJson(text) {
|
|
|
27
27
|
return !trimmed.endsWith('}') && !trimmed.endsWith(']');
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Recover the complete rows of a JSON response the model stopped writing.
|
|
32
|
+
*
|
|
33
|
+
* Walks the text tracking strings and the bracket stack, remembers the end of
|
|
34
|
+
* every complete element written directly inside an array, cuts there and
|
|
35
|
+
* closes what is still open. Returns null when no element completed, so a
|
|
36
|
+
* caller can still fall back. A schema asking for a table of rows routinely
|
|
37
|
+
* overruns a small model's output budget — the ECB key-rates table came back
|
|
38
|
+
* cut off at 1,800 and again at 3,600 tokens and the whole extraction failed,
|
|
39
|
+
* although dozens of rows were complete (R21, 2026-09-09).
|
|
40
|
+
*
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @returns {{ data: unknown, rows: number }|null}
|
|
43
|
+
*/
|
|
44
|
+
export function salvageTruncatedJson(text) {
|
|
45
|
+
const src = text.trim();
|
|
46
|
+
if (!src.startsWith('{') && !src.startsWith('[')) return null;
|
|
47
|
+
const stack = [];
|
|
48
|
+
let inString = false;
|
|
49
|
+
let cut = -1;
|
|
50
|
+
for (let i = 0; i < src.length; i++) {
|
|
51
|
+
const ch = src[i];
|
|
52
|
+
if (inString) {
|
|
53
|
+
if (ch === '\\') i++;
|
|
54
|
+
else if (ch === '"') {
|
|
55
|
+
inString = false;
|
|
56
|
+
// A string written directly into an array is a complete element.
|
|
57
|
+
if (stack[stack.length - 1] === '[') cut = i + 1;
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (ch === '"') inString = true;
|
|
62
|
+
else if (ch === '{' || ch === '[') stack.push(ch);
|
|
63
|
+
else if (ch === '}' || ch === ']') {
|
|
64
|
+
stack.pop();
|
|
65
|
+
if (stack[stack.length - 1] === '[') cut = i + 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (cut < 0) return null;
|
|
69
|
+
// Close everything still open at the cut, innermost first.
|
|
70
|
+
const open = [];
|
|
71
|
+
inString = false;
|
|
72
|
+
for (let i = 0; i < cut; i++) {
|
|
73
|
+
const ch = src[i];
|
|
74
|
+
if (inString) { if (ch === '\\') i++; else if (ch === '"') inString = false; continue; }
|
|
75
|
+
if (ch === '"') inString = true;
|
|
76
|
+
else if (ch === '{' || ch === '[') open.push(ch);
|
|
77
|
+
else if (ch === '}' || ch === ']') open.pop();
|
|
78
|
+
}
|
|
79
|
+
const closers = open.reverse().map((c) => (c === '{' ? '}' : ']')).join('');
|
|
80
|
+
let data;
|
|
81
|
+
try {
|
|
82
|
+
data = JSON.parse(src.slice(0, cut) + closers);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
let rows = 0;
|
|
87
|
+
(function count(v) {
|
|
88
|
+
if (Array.isArray(v)) { rows += v.length; v.forEach(count); }
|
|
89
|
+
else if (v && typeof v === 'object') Object.values(v).forEach(count);
|
|
90
|
+
})(data);
|
|
91
|
+
return rows > 0 ? { data, rows } : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
30
94
|
/**
|
|
31
95
|
* LLM Manager
|
|
32
96
|
* Manages multiple LLM providers and provides unified interface
|
|
@@ -987,12 +1051,25 @@ Extract the data and return valid JSON:`;
|
|
|
987
1051
|
try {
|
|
988
1052
|
parsed = JSON.parse(cleaned);
|
|
989
1053
|
} catch (parseError) {
|
|
1054
|
+
if (!endsMidJson(cleaned)) throw parseError;
|
|
990
1055
|
// Name the cause when the JSON simply stops: "unexpected end of
|
|
991
1056
|
// input at position 2608" tells a caller nothing they can act on,
|
|
992
1057
|
// whereas "cut off at the 1000-token limit" points at the schema.
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1058
|
+
const cutOff = `model response was cut off at the ${budget}-token output limit (${cleaned.length} chars) — the schema asks for more rows than fit`;
|
|
1059
|
+
// The doubled budget was the retry; when that is cut off too, the
|
|
1060
|
+
// rows it did complete are worth more than a fallback that has none.
|
|
1061
|
+
const salvaged = attempt > 0 ? salvageTruncatedJson(cleaned) : null;
|
|
1062
|
+
if (!salvaged) throw new Error(cutOff);
|
|
1063
|
+
const validation = this.validateAgainstSchema(salvaged.data, schema);
|
|
1064
|
+
this.logger.warn('LLM structured extraction salvaged a cut-off response', { budget, rows: salvaged.rows });
|
|
1065
|
+
return {
|
|
1066
|
+
data: salvaged.data,
|
|
1067
|
+
method: 'llm',
|
|
1068
|
+
valid: validation.valid,
|
|
1069
|
+
validationErrors: validation.errors,
|
|
1070
|
+
partial: true,
|
|
1071
|
+
warning: `${cutOff}; kept the ${salvaged.rows} complete row(s) it had written. Ask for fewer rows (a prompt naming the rows you need, or a narrower schema) to get the rest.`
|
|
1072
|
+
};
|
|
996
1073
|
}
|
|
997
1074
|
|
|
998
1075
|
const validation = this.validateAgainstSchema(parsed, schema);
|
|
@@ -1016,7 +1093,7 @@ Extract the data and return valid JSON:`;
|
|
|
1016
1093
|
// Report which path produced the data. Callers previously labelled this
|
|
1017
1094
|
// result "llm", so a failed LLM call was returned as a high-confidence
|
|
1018
1095
|
// LLM extraction.
|
|
1019
|
-
return { ...this.fallbackStructuredExtraction(content, schema), error: lastError.message };
|
|
1096
|
+
return { ...this.fallbackStructuredExtraction(content, schema, lastError.message), error: lastError.message };
|
|
1020
1097
|
}
|
|
1021
1098
|
|
|
1022
1099
|
/**
|
|
@@ -1036,7 +1113,7 @@ Extract the data and return valid JSON:`;
|
|
|
1036
1113
|
/**
|
|
1037
1114
|
* Fallback structured extraction without LLM — keyword/regex matching for primitives
|
|
1038
1115
|
*/
|
|
1039
|
-
fallbackStructuredExtraction(content, schema) {
|
|
1116
|
+
fallbackStructuredExtraction(content, schema, reason = 'no LLM provider available') {
|
|
1040
1117
|
const extracted = {};
|
|
1041
1118
|
const properties = schema.properties || {};
|
|
1042
1119
|
|
|
@@ -1065,7 +1142,10 @@ Extract the data and return valid JSON:`;
|
|
|
1065
1142
|
data: extracted,
|
|
1066
1143
|
method: 'keyword_fallback',
|
|
1067
1144
|
valid: false,
|
|
1068
|
-
|
|
1145
|
+
// Name the real reason: this fallback also runs after an LLM attempt
|
|
1146
|
+
// that failed, and "no LLM provider available" beside a working Ollama
|
|
1147
|
+
// sent a caller looking at the wrong thing (R21, 2026-09-09).
|
|
1148
|
+
validationErrors: [`Used fallback extraction — ${reason}`]
|
|
1069
1149
|
};
|
|
1070
1150
|
}
|
|
1071
1151
|
|
|
@@ -12,7 +12,9 @@ import { config } from '../../constants/config.js';
|
|
|
12
12
|
import { identityHeaders } from '../../utils/fetchIdentity.js';
|
|
13
13
|
|
|
14
14
|
const PDFProcessorSchema = z.object({
|
|
15
|
-
source:
|
|
15
|
+
// A Buffer is the 'buffer' source: process_document hands over a body it
|
|
16
|
+
// has already fetched and sniffed as a PDF (R21, 2026-09-09).
|
|
17
|
+
source: z.union([z.string().min(1), z.instanceof(Buffer)]),
|
|
16
18
|
sourceType: z.enum(['url', 'file', 'buffer']).default('url'),
|
|
17
19
|
options: z.object({
|
|
18
20
|
extractMetadata: z.boolean().default(true),
|
|
@@ -80,6 +80,9 @@ export function appendFallbackHint(toolName, result) {
|
|
|
80
80
|
// plain text
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// An error that already names its next step — a reddit.com refusal points
|
|
84
|
+
// at reddit_search — keeps it: the tool's generic hint would contradict it.
|
|
85
|
+
if (parsed?.next_step || /\bNext step:/.test(first.text)) return result;
|
|
83
86
|
const hint = hintFor(toolName, parsed);
|
|
84
87
|
if (!hint || first.text.includes(hint)) return result;
|
|
85
88
|
if (parsed) {
|
|
@@ -30,6 +30,9 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
|
|
|
30
30
|
extract_content: { textPaths: ['content.markdown', 'content.text', 'content.html', 'content.cleanedHTML'], truncate: true },
|
|
31
31
|
crawl_deep: { textPaths: [], truncate: true },
|
|
32
32
|
batch_scrape: { textPaths: [], truncate: true },
|
|
33
|
+
// A page of 25 markdown results is the same payload batch_scrape shapes;
|
|
34
|
+
// an async job's page came back as 111 KB whole (R20, 2026-09-07).
|
|
35
|
+
get_batch_results: { textPaths: [], truncate: true },
|
|
33
36
|
stealth_mode: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true, when: (params) => params?.operation === 'scrape' },
|
|
34
37
|
scrape_with_actions: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true },
|
|
35
38
|
process_document: { textPaths: ['content.text'], truncate: true },
|
|
@@ -69,6 +72,27 @@ export function resultTextView(resultObject, textPaths = []) {
|
|
|
69
72
|
return { view: 'json', view_path: null, text: JSON.stringify(resultObject, null, 2) };
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The non-text fields of `content` small enough to stay inline: everything
|
|
77
|
+
* except the text views (markdown, text, html, …) up to a quarter of the
|
|
78
|
+
* inline budget each. Null when there is nothing to keep.
|
|
79
|
+
*/
|
|
80
|
+
export function keepSmallContentFields(content, textPaths = [], maxInline = DEFAULT_MAX_INLINE_CHARS) {
|
|
81
|
+
if (!content || typeof content !== 'object' || Array.isArray(content)) return null;
|
|
82
|
+
const textLeaves = new Set(
|
|
83
|
+
textPaths.filter((p) => p.startsWith('content.')).map((p) => p.slice('content.'.length))
|
|
84
|
+
);
|
|
85
|
+
const cap = Math.max(1000, Math.floor(maxInline / 4));
|
|
86
|
+
const kept = {};
|
|
87
|
+
for (const [key, value] of Object.entries(content)) {
|
|
88
|
+
if (textLeaves.has(key) || value === undefined) continue;
|
|
89
|
+
if (typeof value === 'string' && value.length > cap) continue;
|
|
90
|
+
if (typeof value === 'object' && value !== null && JSON.stringify(value).length > cap) continue;
|
|
91
|
+
kept[key] = value;
|
|
92
|
+
}
|
|
93
|
+
return Object.keys(kept).length > 0 ? kept : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
72
96
|
function warningsOf(resultObject) {
|
|
73
97
|
return Array.isArray(resultObject.warnings) ? resultObject.warnings.filter((w) => typeof w === 'string') : [];
|
|
74
98
|
}
|
|
@@ -126,7 +150,9 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
|
|
|
126
150
|
}
|
|
127
151
|
|
|
128
152
|
const preview = text.slice(0, maxInline);
|
|
129
|
-
const
|
|
153
|
+
const keptFields = keepSmallContentFields(resultObject.content, config.textPaths, maxInline);
|
|
154
|
+
const keptDesc = keptFields ? `; content.${Object.keys(keptFields).join(', content.')} kept inline` : '';
|
|
155
|
+
const hint = `Result is ${json.length} chars as JSON, over the inline limit of ${maxInline}; preview holds the first ${preview.length} chars of ${viewDesc} (${text.length} chars in total)${keptDesc} and the full result is kept for 1 hour under result_handle ${handle}: ${readWith}.`;
|
|
130
156
|
|
|
131
157
|
const shaped = {};
|
|
132
158
|
for (const [key, value] of Object.entries(resultObject)) {
|
|
@@ -140,6 +166,13 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
|
|
|
140
166
|
if (resultObject.redaction && typeof resultObject.redaction === 'object') {
|
|
141
167
|
shaped.redaction = resultObject.redaction;
|
|
142
168
|
}
|
|
169
|
+
// The query-scoped formats live beside the page text under `content`
|
|
170
|
+
// (highlights, answer, json, metadata, links). They are the small, exact
|
|
171
|
+
// answer the caller paid for, and truncating the markdown must not drop
|
|
172
|
+
// them: an nhs.uk scrape with highlights and a question came back as a
|
|
173
|
+
// markdown preview and nothing else (R21, 2026-09-09). Keep every
|
|
174
|
+
// non-text `content` field that fits a quarter of the inline budget.
|
|
175
|
+
if (keptFields) shaped.content = keptFields;
|
|
143
176
|
Object.assign(shaped, {
|
|
144
177
|
preview,
|
|
145
178
|
result_handle: handle,
|
|
@@ -36,7 +36,7 @@ export function isInternalRequest() {
|
|
|
36
36
|
* reaches withAuth. It also survives both routes a refusal can take — thrown,
|
|
37
37
|
* or swallowed into an isError result.
|
|
38
38
|
*
|
|
39
|
-
* @param {string} code 'ROBOTS_DISALLOWED' | 'HOST_BLOCKED'
|
|
39
|
+
* @param {string} code 'ROBOTS_DISALLOWED' | 'HOST_BLOCKED' | 'USE_REDDIT_SEARCH'
|
|
40
40
|
*/
|
|
41
41
|
export function markPreflightRefusal(code) {
|
|
42
42
|
const store = requestContext.getStore();
|
|
@@ -73,6 +73,8 @@ completed or in-progress job. Cheap (1 credit) because the batch was already
|
|
|
73
73
|
paid for. Completed jobs are also exposed as `crawlforge://job/{jobId}`
|
|
74
74
|
resources. Stored batch results share the local 1-hour result store that
|
|
75
75
|
`read_result` reads, with the same eviction, so page through a batch within the hour.
|
|
76
|
+
Like `batch_scrape`, it takes `max_inline_chars` (default 40,000): a page over the
|
|
77
|
+
limit comes back as a `preview` plus a `result_handle` for `read_result`.
|
|
76
78
|
|
|
77
79
|
## scrape_with_actions — interact, then scrape (cost: 5)
|
|
78
80
|
|
|
@@ -13,6 +13,7 @@ import ExtractContentTool from '../extract/extractContent.js';
|
|
|
13
13
|
import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
|
|
14
14
|
import { elementText } from '../../utils/elementText.js';
|
|
15
15
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
16
|
+
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
16
17
|
|
|
17
18
|
// Recording / replay helpers
|
|
18
19
|
import {
|
|
@@ -726,6 +727,26 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
726
727
|
}
|
|
727
728
|
}
|
|
728
729
|
|
|
730
|
+
// extractContent hands back text alone when Readability finds no
|
|
731
|
+
// article. support.southwest.com's help centre is a JS shell: after an
|
|
732
|
+
// 8 s wait the body text was there, `html` came back empty and
|
|
733
|
+
// `markdown` was the "Content not available" placeholder, reported as
|
|
734
|
+
// success (R20, 2026-09-07). The post-action DOM is in hand, so serve
|
|
735
|
+
// the requested formats from it instead of a placeholder.
|
|
736
|
+
if (chainResult?.finalHtml) {
|
|
737
|
+
extractResult.content = extractResult.content || {};
|
|
738
|
+
if (params.formats?.includes('markdown') && !extractResult.content.markdown) {
|
|
739
|
+
const bodyMarkdown = htmlToMarkdown(chainResult.finalHtml);
|
|
740
|
+
if (bodyMarkdown) {
|
|
741
|
+
extractResult.content.markdown = bodyMarkdown;
|
|
742
|
+
extractResult.content.markdownSource = 'body';
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (params.formats?.includes('html') && !extractResult.content.html) {
|
|
746
|
+
extractResult.content.html = chainResult.finalHtml;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
729
750
|
return extractResult;
|
|
730
751
|
} catch (error) {
|
|
731
752
|
this.log('warn', `Final content extraction failed: ${error.message}`);
|
|
@@ -42,7 +42,9 @@ export async function extractLinksHandler({ url, filter_external, base_url, user
|
|
|
42
42
|
const href = $(element).attr('href');
|
|
43
43
|
const text = $(element).text().trim();
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
// A javascript: pseudo-link ("Cookie Settings") is a button, not a
|
|
46
|
+
// link; it was counted as an external link on boeing.com (R20).
|
|
47
|
+
if (!href || /^\s*javascript:/i.test(href)) return;
|
|
46
48
|
|
|
47
49
|
try {
|
|
48
50
|
const absoluteUrl = new URL(href, baseUrl).toString();
|