crawlforge-mcp-server 6.17.1 → 6.18.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 +2 -2
- package/package.json +2 -2
- package/server.js +17 -8
- package/src/core/ActionExecutor.js +4 -1
- package/src/core/AuthManager.js +12 -0
- package/src/server/fallbackHints.js +4 -4
- package/src/server/inlineThreshold.js +76 -10
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +2 -2
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +4 -0
- package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +16 -2
- package/src/tools/basic/_targetFailure.js +54 -0
- package/src/tools/basic/extractLinks.js +62 -42
- package/src/tools/basic/extractText.js +79 -14
- package/src/tools/extract/_fetchAndParse.js +6 -30
- package/src/utils/fetchLadder.js +228 -0
package/README.md
CHANGED
|
@@ -178,8 +178,8 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
|
|
|
178
178
|
| Tool | Credits | What it does |
|
|
179
179
|
|------|---------|--------------|
|
|
180
180
|
| `fetch_url` | 1 | Fetch content from any URL |
|
|
181
|
-
| `extract_text` | 1 | Extract clean text from web pages |
|
|
182
|
-
| `extract_links` | 1 | Get all links from a page |
|
|
181
|
+
| `extract_text` | 1 (6 projected with `escalate: true`, charged 1 when the plain fetch works) | Extract clean text from web pages |
|
|
182
|
+
| `extract_links` | 1 (6 projected with `escalate: true`, charged 1 when the plain fetch works) | Get all links from a page |
|
|
183
183
|
| `extract_metadata` | 1 | Extract page metadata (title, OG tags, schema.org) |
|
|
184
184
|
| `scrape_template` | 1 | Structured data from well-known sites (Amazon, GitHub, LinkedIn, YouTube, Reddit, Hacker News, npm, and more) without writing selectors |
|
|
185
185
|
| `list_ollama_models` | 1 | List the Ollama models installed locally (helps you pick a `model` for `extract_with_llm`) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.18.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 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",
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
"cheerio": "^1.1.2",
|
|
122
122
|
"commander": "^14.0.3",
|
|
123
123
|
"compromise": "^14.14.4",
|
|
124
|
-
"crawlforge-extractors": "^1.
|
|
124
|
+
"crawlforge-extractors": "^1.13.0",
|
|
125
125
|
"diff": "^9.0.0",
|
|
126
126
|
"dotenv": "^17.2.1",
|
|
127
127
|
"franc": "^6.2.0",
|
package/server.js
CHANGED
|
@@ -52,8 +52,9 @@ import { createOAuthProvider } from "./src/server/auth/oauth.js";
|
|
|
52
52
|
import { createMetricsRegistry } from "./src/observability/metrics.js";
|
|
53
53
|
// Basic tool handlers (extracted from server.js)
|
|
54
54
|
import { fetchUrlHandler } from "./src/tools/basic/fetchUrl.js";
|
|
55
|
-
import {
|
|
56
|
-
import {
|
|
55
|
+
import { createExtractTextHandler } from "./src/tools/basic/extractText.js";
|
|
56
|
+
import { createExtractLinksHandler } from "./src/tools/basic/extractLinks.js";
|
|
57
|
+
import { EXTRACT_ESCALATION_SHAPE } from "./src/utils/fetchLadder.js";
|
|
57
58
|
import { extractMetadataHandler } from "./src/tools/basic/extractMetadata.js";
|
|
58
59
|
import { scrapeStructuredHandler } from "./src/tools/basic/scrapeStructured.js";
|
|
59
60
|
import { createExtractEmbeddedStateHandler, EMBEDDED_STATE_INPUT_SHAPE } from "./src/tools/extract/extractEmbeddedState.js";
|
|
@@ -112,7 +113,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
112
113
|
// Create the server
|
|
113
114
|
const server = new McpServer({
|
|
114
115
|
name: "crawlforge",
|
|
115
|
-
version: "6.
|
|
116
|
+
version: "6.18.0",
|
|
116
117
|
description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, 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.",
|
|
117
118
|
homepage: "https://www.crawlforge.dev",
|
|
118
119
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -439,29 +440,37 @@ registerToolIfEnabled("fetch_url", {
|
|
|
439
440
|
|
|
440
441
|
// Tool: extract_text
|
|
441
442
|
registerToolIfEnabled("extract_text", {
|
|
442
|
-
description: "Use this for a page's plain text or markdown with tags, scripts and styles removed - the cheapest read of a static HTML page. Use output_format:\"markdown\" for RAG. Not for article pages (extract_content strips nav and boilerplate), JS-rendered pages (scrape), or when you also want links or metadata (scrape with several formats, one fetch). Cost: 1 credit. Example: extract_text({url: \"https://example.com/article\", output_format:\"markdown\"})",
|
|
443
|
+
description: "Use this for a page's plain text or markdown with tags, scripts and styles removed - the cheapest read of a static HTML page. Use output_format:\"markdown\" for RAG; selector reads only the matched elements, max_length caps the returned text. Not for article pages (extract_content strips nav and boilerplate), JS-rendered pages (scrape), or when you also want links or metadata (scrape with several formats, one fetch). A PDF or other binary is refused (process_document reads documents); an empty client-rendered shell succeeds with rendered:false and a warning. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page - projected at 6, charged 1 when the plain fetch worked. Cost: 1 credit. Example: extract_text({url: \"https://example.com/article\", output_format:\"markdown\"})",
|
|
443
444
|
annotations: { title: "Extract Text", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
444
445
|
inputSchema: {
|
|
445
446
|
url: z.string().url().describe("The URL to extract text from"),
|
|
446
447
|
remove_scripts: z.boolean().optional().default(true).describe("Remove script tags before extraction"),
|
|
447
448
|
remove_styles: z.boolean().optional().default(true).describe("Remove style tags before extraction"),
|
|
448
449
|
output_format: z.enum(["text", "markdown"]).optional().default("text").describe("Output format: \"text\" (default) or \"markdown\" — use markdown for RAG workflows"),
|
|
450
|
+
selector: z.string().optional().describe("CSS selector: read only the matched elements (nav/header/footer are then kept). No match is an error"),
|
|
451
|
+
max_length: z.number().int().min(1).max(1000000).optional().describe("Maximum characters of text or markdown to return; a longer result is cut and ends with \"...\""),
|
|
452
|
+
...EXTRACT_ESCALATION_SHAPE,
|
|
449
453
|
...COMPLIANCE_PARAMS,
|
|
450
454
|
...REDACT_PII_PARAM
|
|
451
455
|
}
|
|
452
|
-
}, withAuth("extract_text",
|
|
456
|
+
}, withAuth("extract_text", createExtractTextHandler({
|
|
457
|
+
escalateFetch: (args) => stealthEscalation({ ...args, tool: 'extract_text' })
|
|
458
|
+
})));
|
|
453
459
|
|
|
454
460
|
// Tool: extract_links
|
|
455
461
|
registerToolIfEnabled("extract_links", {
|
|
456
|
-
description: "Use this to list the hyperlinks on one page - a crawl seed list, a broken-link audit, related resources. filter_external:true
|
|
462
|
+
description: "Use this to list the hyperlinks on one page - a crawl seed list, a broken-link audit, related resources. filter_external:true drops internal links; mailto:, tel: and javascript: links are type \"other\". Not for a whole site (map_site), and not alongside a scrape of the same URL: scrape formats:[\"markdown\",\"links\"] returns both in one fetch. A PDF or other binary is refused (process_document reads documents); an empty client-rendered shell succeeds with rendered:false and a warning. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page - projected at 6, charged 1 when the plain fetch worked. Cost: 1 credit. Example: extract_links({url: \"https://example.com\", filter_external: true})",
|
|
457
463
|
annotations: { title: "Extract Links", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
458
464
|
inputSchema: {
|
|
459
465
|
url: z.string().url().describe("The URL to extract links from"),
|
|
460
|
-
filter_external: z.boolean().optional().default(false).describe("
|
|
466
|
+
filter_external: z.boolean().optional().default(false).describe("Drop internal (same-host) links"),
|
|
461
467
|
base_url: z.string().url().optional().describe("Base URL for resolving relative links"),
|
|
468
|
+
...EXTRACT_ESCALATION_SHAPE,
|
|
462
469
|
...COMPLIANCE_PARAMS
|
|
463
470
|
}
|
|
464
|
-
}, withAuth("extract_links",
|
|
471
|
+
}, withAuth("extract_links", createExtractLinksHandler({
|
|
472
|
+
escalateFetch: (args) => stealthEscalation({ ...args, tool: 'extract_links' })
|
|
473
|
+
})));
|
|
465
474
|
|
|
466
475
|
// Tool: extract_metadata
|
|
467
476
|
registerToolIfEnabled("extract_metadata", {
|
|
@@ -1581,9 +1581,12 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1581
1581
|
// hostname-based checks alone would miss DNS-rebinding/private-IP targets.
|
|
1582
1582
|
await assertUrlAllowed(url, { resolveDns: true });
|
|
1583
1583
|
|
|
1584
|
+
// The initial load and a retry's reload pass no timeout of their own, so
|
|
1585
|
+
// browserOptions.timeout bounds them as it bounds the actions (0.2); a
|
|
1586
|
+
// navigate action's own timeout comes first.
|
|
1584
1587
|
const response = await page.goto(url, {
|
|
1585
1588
|
waitUntil: options.waitUntil || 'domcontentloaded',
|
|
1586
|
-
timeout: options.timeout || 30000
|
|
1589
|
+
timeout: options.timeout || options.browserOptions?.timeout || 30000
|
|
1587
1590
|
});
|
|
1588
1591
|
|
|
1589
1592
|
// Re-validate where the navigation went: a redirect could have taken us
|
package/src/core/AuthManager.js
CHANGED
|
@@ -687,6 +687,12 @@ class AuthManager {
|
|
|
687
687
|
return costs.extract_embedded_state + scrapeEscalationSurcharge(params?.escalate) + redaction;
|
|
688
688
|
}
|
|
689
689
|
|
|
690
|
+
// extract_text and extract_links (fix plan Phase E2) run the same ladder:
|
|
691
|
+
// projected 1+5 when escalating, charged 1 when the plain fetch sufficed.
|
|
692
|
+
if (tool === 'extract_text' || tool === 'extract_links') {
|
|
693
|
+
return costs[tool] + scrapeEscalationSurcharge(params?.escalate) + redaction;
|
|
694
|
+
}
|
|
695
|
+
|
|
690
696
|
// search_web's batch form (5.1) runs one backend search per query, so it
|
|
691
697
|
// is priced as that many search_web calls. The count rule lives with the
|
|
692
698
|
// schema that declares `queries` (src/tools/search/batchSearch.js).
|
|
@@ -778,6 +784,12 @@ class AuthManager {
|
|
|
778
784
|
? 'Base 2; escalate:true adds 5 for the stealth browser it may need - the projection is the ceiling, and the actual charge drops back to 2 when the plain fetch succeeded and no escalation ran.'
|
|
779
785
|
: 'Fixed cost per invocation.';
|
|
780
786
|
break;
|
|
787
|
+
case 'extract_text':
|
|
788
|
+
case 'extract_links':
|
|
789
|
+
note = params?.escalate === true
|
|
790
|
+
? 'Base 1; escalate:true adds 5 for the stealth browser it may need - the projection is the ceiling, and the actual charge drops back to 1 when the plain fetch succeeded and no escalation ran.'
|
|
791
|
+
: 'Fixed cost per invocation.';
|
|
792
|
+
break;
|
|
781
793
|
case 'agent': {
|
|
782
794
|
const agentUrls = params?.maxUrls || 10;
|
|
783
795
|
const isPro = params?.model === 'pro';
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
|
|
12
12
|
export const FALLBACK_HINTS = Object.freeze({
|
|
13
13
|
fetch_url: 'For a JS-rendered page or an empty shell use scrape; after a 403/429/CAPTCHA/challenge page use stealth_mode operation:"scrape". Do not repeat the same fetch_url call.',
|
|
14
|
-
extract_text: '
|
|
15
|
-
extract_links: '
|
|
14
|
+
extract_text: 'After a 403/429/444/challenge page call extract_text again with escalate:true (projected 6, charged 1 if the plain fetch gets through); for a JS-rendered page use scrape formats:["markdown"]; for a PDF use process_document.',
|
|
15
|
+
extract_links: 'After a 403/429/444/challenge page call extract_links again with escalate:true (projected 6, charged 1 if the plain fetch gets through); for a JS-rendered page use scrape formats:["links"]; for a whole site use map_site.',
|
|
16
16
|
extract_metadata: 'Use scrape formats:["metadata"]; after a 403/429/challenge page use stealth_mode operation:"scrape".',
|
|
17
17
|
extract_content: 'Use scrape formats:["markdown"] (same clean output, renders more pages); after a 403/429/challenge page use stealth_mode operation:"scrape".',
|
|
18
18
|
extract_embedded_state: 'Call again without `path` to see the top-level keys, or use scrape formats:["markdown"] if the page has no framework payload.',
|
|
@@ -56,8 +56,8 @@ export const SCRAPE_ESCALATED_HINT =
|
|
|
56
56
|
'If the block is regional, set a country with localization and try once more; otherwise the block is final - ' +
|
|
57
57
|
'a TLS-level wall needs residential proxies, which CrawlForge does not offer. Get the content from another source.';
|
|
58
58
|
|
|
59
|
-
/** The tools whose escalate:true runs the stealth stage itself (Phase 3; extract_embedded_state since plan Phase 3.2). */
|
|
60
|
-
const ESCALATING_TOOLS = new Set(['scrape', 'extract_embedded_state']);
|
|
59
|
+
/** The tools whose escalate:true runs the stealth stage itself (Phase 3; extract_embedded_state since plan Phase 3.2; extract_text and extract_links since Phase E2). */
|
|
60
|
+
const ESCALATING_TOOLS = new Set(['scrape', 'extract_embedded_state', 'extract_text', 'extract_links']);
|
|
61
61
|
|
|
62
62
|
/** The hint for this result: the tool's, unless the call already escalated. */
|
|
63
63
|
function hintFor(toolName, parsed) {
|
|
@@ -20,8 +20,9 @@ export const MAX_INLINE_CHARS_PARAM = {
|
|
|
20
20
|
/**
|
|
21
21
|
* Per-tool rule. `textPaths` are dotted paths tried in order; the first one
|
|
22
22
|
* holding a string becomes the text view the preview and read_result work
|
|
23
|
-
* on. An empty list means the pretty-printed JSON is the view. `
|
|
24
|
-
*
|
|
23
|
+
* on. An empty list means the pretty-printed JSON is the view. `keep` names
|
|
24
|
+
* top-level fields carried inline past the scalar filter (keepNamedFields).
|
|
25
|
+
* `when` gates on params. extract_embedded_state has a shape of its own (shapeEmbeddedState
|
|
25
26
|
* below).
|
|
26
27
|
*/
|
|
27
28
|
/** The browser_session operations that hand back content worth shaping. */
|
|
@@ -37,7 +38,14 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
|
|
|
37
38
|
// an async job's page came back as 111 KB whole (R20, 2026-09-07).
|
|
38
39
|
get_batch_results: { textPaths: [], truncate: true },
|
|
39
40
|
stealth_mode: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true, when: (params) => params?.operation === 'scrape' },
|
|
40
|
-
|
|
41
|
+
// `keep` names the top-level reports that stay inline past the scalar
|
|
42
|
+
// filter (keepNamedFields): a walled Guardian chain over 40,000 chars lost
|
|
43
|
+
// `blocked`, `navigations` and every action's outcome (2026-10-03).
|
|
44
|
+
scrape_with_actions: {
|
|
45
|
+
textPaths: ['content.markdown', 'content.text', 'content.html'],
|
|
46
|
+
truncate: true,
|
|
47
|
+
keep: ['error', 'blocked', 'consent', 'navigations', 'actionResults']
|
|
48
|
+
},
|
|
41
49
|
// `read` hands back the same content shape scrape_with_actions does, and was
|
|
42
50
|
// the one content-returning tool with no cap: a read of the World War II
|
|
43
51
|
// article returned 541,308 characters inline where scrape returned 42,259
|
|
@@ -48,6 +56,7 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
|
|
|
48
56
|
browser_session: {
|
|
49
57
|
textPaths: ['content.markdown', 'content.text', 'content.html', 'snapshot.tree'],
|
|
50
58
|
truncate: true,
|
|
59
|
+
keep: ['error', 'blocked', 'actionResults'],
|
|
51
60
|
when: (params) => BROWSER_SESSION_CONTENT_OPERATIONS.has(params?.operation)
|
|
52
61
|
},
|
|
53
62
|
process_document: { textPaths: ['content.text'], truncate: true },
|
|
@@ -111,6 +120,42 @@ export function keepSmallContentFields(content, textPaths = [], maxInline = DEFA
|
|
|
111
120
|
return Object.keys(kept).length > 0 ? kept : null;
|
|
112
121
|
}
|
|
113
122
|
|
|
123
|
+
/**
|
|
124
|
+
* The top-level fields a tool names in `keep`, each carried inline when its
|
|
125
|
+
* JSON fits a quarter of the inline budget. An actionResults list over that
|
|
126
|
+
* keeps every entry without its `result` (a snapshot tree, an extracted
|
|
127
|
+
* value), so which action ran and whether it worked stays inline.
|
|
128
|
+
* @returns {{ kept: object|null, partial: string[], dropped: string[] }}
|
|
129
|
+
*/
|
|
130
|
+
export function keepNamedFields(resultObject, keys = [], maxInline = DEFAULT_MAX_INLINE_CHARS) {
|
|
131
|
+
const cap = Math.max(1000, Math.floor(maxInline / 4));
|
|
132
|
+
const kept = {};
|
|
133
|
+
const partial = [];
|
|
134
|
+
const dropped = [];
|
|
135
|
+
for (const key of keys) {
|
|
136
|
+
const value = resultObject[key];
|
|
137
|
+
if (value === undefined) continue;
|
|
138
|
+
if (JSON.stringify(value).length <= cap) {
|
|
139
|
+
kept[key] = value;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (key === 'actionResults' && Array.isArray(value)) {
|
|
143
|
+
const slim = value.map((entry) => {
|
|
144
|
+
if (!entry || typeof entry !== 'object') return entry;
|
|
145
|
+
const { result: _result, ...rest } = entry;
|
|
146
|
+
return rest;
|
|
147
|
+
});
|
|
148
|
+
if (JSON.stringify(slim).length <= cap) {
|
|
149
|
+
kept[key] = slim;
|
|
150
|
+
partial.push(key);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
dropped.push(key);
|
|
155
|
+
}
|
|
156
|
+
return { kept: Object.keys(kept).length > 0 ? kept : null, partial, dropped };
|
|
157
|
+
}
|
|
158
|
+
|
|
114
159
|
function warningsOf(resultObject) {
|
|
115
160
|
return Array.isArray(resultObject.warnings) ? resultObject.warnings.filter((w) => typeof w === 'string') : [];
|
|
116
161
|
}
|
|
@@ -156,7 +201,16 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
|
|
|
156
201
|
|
|
157
202
|
const preview = text.slice(0, maxInline);
|
|
158
203
|
const keptFields = keepSmallContentFields(resultObject.content, config.textPaths, maxInline);
|
|
159
|
-
const
|
|
204
|
+
const named = keepNamedFields(resultObject, config.keep, maxInline);
|
|
205
|
+
const keptNames = [
|
|
206
|
+
...(named.kept ? Object.keys(named.kept).filter((key) => !named.partial.includes(key)) : []),
|
|
207
|
+
...(keptFields ? Object.keys(keptFields).map((key) => `content.${key}`) : [])
|
|
208
|
+
];
|
|
209
|
+
const keptDesc = [
|
|
210
|
+
keptNames.length > 0 ? `; ${keptNames.join(', ')} kept inline` : '',
|
|
211
|
+
named.partial.includes('actionResults') ? '; actionResults kept without each action\'s result (json_path "actionResults[<i>].result" reads one)' : '',
|
|
212
|
+
named.dropped.length > 0 ? `; ${named.dropped.join(', ')} too large to keep inline (json_path "${named.dropped[0]}" reads it)` : ''
|
|
213
|
+
].join('');
|
|
160
214
|
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}.`;
|
|
161
215
|
|
|
162
216
|
const shaped = {};
|
|
@@ -171,6 +225,7 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
|
|
|
171
225
|
if (resultObject.redaction && typeof resultObject.redaction === 'object') {
|
|
172
226
|
shaped.redaction = resultObject.redaction;
|
|
173
227
|
}
|
|
228
|
+
if (named.kept) Object.assign(shaped, named.kept);
|
|
174
229
|
// The query-scoped formats live beside the page text under `content`
|
|
175
230
|
// (highlights, answer, json, metadata, links). They are the small, exact
|
|
176
231
|
// answer the caller paid for, and truncating the markdown must not drop
|
|
@@ -191,6 +246,14 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
|
|
|
191
246
|
return { result: shaped, stored: true };
|
|
192
247
|
}
|
|
193
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Room for the `_cost` block withAuth adds to the text after shaping:
|
|
251
|
+
* pretty-printed, projected/actual/remaining_credits and the projection
|
|
252
|
+
* note, whose longest form for extract_embedded_state (escalate:true) is
|
|
253
|
+
* about 200 chars, about 340 in all.
|
|
254
|
+
*/
|
|
255
|
+
export const COST_BLOCK_RESERVE = 400;
|
|
256
|
+
|
|
194
257
|
/** Object.keys(data) for a plain object, "array(<n>)" for an array, null otherwise. */
|
|
195
258
|
export function embeddedDataKeys(data) {
|
|
196
259
|
if (Array.isArray(data)) return `array(${data.length})`;
|
|
@@ -227,10 +290,12 @@ export function wholeLinePreview(text, budget) {
|
|
|
227
290
|
* everything that says what the page carries — url, path, bytes, the whole
|
|
228
291
|
* `found` list, data_keys, the escalation report and window_state's list —
|
|
229
292
|
* then a preview of the pretty-printed JSON cut at a line boundary. The
|
|
230
|
-
* preview gets whatever max_inline_chars leaves after the rest, measured
|
|
231
|
-
*
|
|
232
|
-
* the
|
|
233
|
-
*
|
|
293
|
+
* preview gets whatever max_inline_chars leaves after the rest, measured the
|
|
294
|
+
* way withAuth sends it — pretty-printed, with COST_BLOCK_RESERVE held back
|
|
295
|
+
* for the `_cost` block it adds after shaping — so the text the client
|
|
296
|
+
* receives stays within the limit whenever the rest fits. The full result is
|
|
297
|
+
* under the handle for read_result json_path. The REST route shapes it the
|
|
298
|
+
* same way, measured as the compact JSON it sends; keep the two in step.
|
|
234
299
|
*/
|
|
235
300
|
function shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith }) {
|
|
236
301
|
const dataKeys = embeddedDataKeys(resultObject.data);
|
|
@@ -257,8 +322,9 @@ function shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expir
|
|
|
257
322
|
expires_at,
|
|
258
323
|
warnings: [...warningsOf(resultObject), hint]
|
|
259
324
|
};
|
|
260
|
-
//
|
|
261
|
-
|
|
325
|
+
// The preview's value is one line of the pretty-printed text and escapes
|
|
326
|
+
// the same way there as in compact JSON; `""` is its empty value's quotes.
|
|
327
|
+
const envelope = JSON.stringify({ ...shaped, preview: '', ...tail }, null, 2).length + COST_BLOCK_RESERVE;
|
|
262
328
|
const preview = wholeLinePreview(text, Math.max(0, maxInline - envelope + 2));
|
|
263
329
|
return { ...shaped, preview, ...tail };
|
|
264
330
|
}
|
|
@@ -8,8 +8,8 @@ metered; there is no free tier. Tools marked "scales" cost more as work grows.
|
|
|
8
8
|
| Tool | Notes |
|
|
9
9
|
|------|-------|
|
|
10
10
|
| `fetch_url` | Raw HTTP fetch. |
|
|
11
|
-
| `extract_text` | Tag-stripped text/markdown. |
|
|
12
|
-
| `extract_links` | All links on a page. |
|
|
11
|
+
| `extract_text` | Tag-stripped text/markdown. `escalate:true` adds 5 as the projected ceiling for the stealth re-read; the actual charge drops back to 1 when the plain fetch succeeded and no escalation ran. |
|
|
12
|
+
| `extract_links` | All links on a page. `escalate:true` adds 5 as the projected ceiling for the stealth re-read; the actual charge drops back to 1 when the plain fetch succeeded and no escalation ran. |
|
|
13
13
|
| `extract_metadata` | Title / meta / OG / schema.org. |
|
|
14
14
|
| `scrape_template` | Pre-built site extractor. |
|
|
15
15
|
| `list_ollama_models` | List local LLMs. |
|
|
@@ -158,6 +158,10 @@ Returns headers + body. Good for JSON/XML APIs or as a first step before
|
|
|
158
158
|
`extract_text` (cost 1) = faster, strips tags; pass `output_format:"markdown"`
|
|
159
159
|
for RAG-friendly output.
|
|
160
160
|
|
|
161
|
+
`extract_text` and `extract_links` take `escalate:true` for a site known to
|
|
162
|
+
block: the plain fetch runs first and the stealth browser re-reads the page
|
|
163
|
+
only if it is walled (projected 6, charged 1 when the plain fetch worked).
|
|
164
|
+
|
|
161
165
|
## extract_links / extract_metadata (cost: 1 each)
|
|
162
166
|
|
|
163
167
|
```json
|
|
@@ -34,14 +34,28 @@ your provider).
|
|
|
34
34
|
| `remove_scripts` | boolean | `true` | Strip `script` tags. |
|
|
35
35
|
| `remove_styles` | boolean | `true` | Strip `style` tags. |
|
|
36
36
|
| `output_format` | enum | `text` | `text` or `markdown` (use markdown for RAG). |
|
|
37
|
+
| `selector` | string | — | CSS selector: read only the matched elements (nav/header/footer inside them are kept). No match is an error: `No elements found for selector: <sel>`. |
|
|
38
|
+
| `max_length` | integer | — | 1–1,000,000. Text or markdown longer than this is cut and ends with `...`; `word_count`/`char_count` count the cut result. |
|
|
39
|
+
| `escalate` | boolean | `false` | When the plain fetch is blocked (403/429/444/challenge page/empty shell), re-read the page once in the stealth browser (impit first under `auto`). A 404 or 5xx never escalates. Projected 6, charged 1 when the plain fetch worked. |
|
|
40
|
+
| `escalate_engine` | enum | `auto` | `auto`, `playwright` (Chromium) or `camoufox`. |
|
|
41
|
+
|
|
42
|
+
Text mode returns one line per block element (`<h1>Hi</h1><p>there</p>` reads `Hi\nthere`). The REST route (`/api/v1/tools/extract_text`) takes the same parameters plus `preserve_links` and `preserve_formatting`, which the MCP tool does not.
|
|
43
|
+
|
|
44
|
+
A PDF or other binary body fails with `UNSUPPORTED_CONTENT_TYPE` (use `process_document`). An empty client-rendered shell succeeds with `rendered:false` and the warning `page renders client-side; use scrape`.
|
|
37
45
|
|
|
38
46
|
## extract_links (cost: 1)
|
|
39
47
|
|
|
40
48
|
| Param | Type | Default | Notes |
|
|
41
49
|
|-------|------|---------|-------|
|
|
42
50
|
| `url` | string (URL) | — | Required. |
|
|
43
|
-
| `filter_external` | boolean | `false` |
|
|
44
|
-
| `base_url` | string (URL) | — | Resolve relative links against this. |
|
|
51
|
+
| `filter_external` | boolean | `false` | Drop internal (same-host) links; external and `other` links stay. |
|
|
52
|
+
| `base_url` | string (URL) | — | Resolve relative links against this. Without it, the page's `<base href>`, else the page URL. |
|
|
53
|
+
| `escalate` | boolean | `false` | When the plain fetch is blocked (403/429/444/challenge page/empty shell), re-read the page once in the stealth browser (impit first under `auto`). A 404 or 5xx never escalates. Projected 6, charged 1 when the plain fetch worked. |
|
|
54
|
+
| `escalate_engine` | enum | `auto` | `auto`, `playwright` (Chromium) or `camoufox`. |
|
|
55
|
+
|
|
56
|
+
Each link is `{ href, text, type, domain, rel, original_href }` (plus `title` when the link has one). `type` is `internal` (same host as the page), `external` or `other` (`mailto:`, `tel:`, `javascript:` and other non-web schemes; only `mailto:`/`tel:` keep an `href`, the rest are `null`). `#fragment`-only links are skipped, and links are deduplicated on the URL without its fragment or trailing slash. Counts: `total_count`, `internal_count`, `external_count`, `other_count`. The REST route returns the same records.
|
|
57
|
+
|
|
58
|
+
A PDF or other binary body fails with `UNSUPPORTED_CONTENT_TYPE` (use `process_document`). An empty client-rendered shell succeeds with `rendered:false` and the warning `page renders client-side; use scrape`.
|
|
45
59
|
|
|
46
60
|
## extract_metadata (cost: 1)
|
|
47
61
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a fetched document fails extract_links / extract_text, with the same
|
|
3
|
+
* verdict and wording as the REST routes (fix plan Phase E1).
|
|
4
|
+
*
|
|
5
|
+
* A named vendor's wall fails on any status: a challenge arrives as a 403 or
|
|
6
|
+
* as a 200 with the wall in the body. Any other non-2xx fails with its
|
|
7
|
+
* status. An empty shell or an error placeholder on a 2xx is not raised here;
|
|
8
|
+
* extraction proceeds as before.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { load } from 'cheerio';
|
|
12
|
+
import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
|
|
13
|
+
import { pageTitle } from '../../utils/pageTitle.js';
|
|
14
|
+
|
|
15
|
+
// Response bodies a verdict can be read from; anything else is a binary. A
|
|
16
|
+
// missing Content-Type is read, as _fetchAndParse.js treats it as HTML.
|
|
17
|
+
const TEXTUAL_BODY = /^(?:text\/(?:html|plain)|application\/xhtml\+xml)\b/i;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The plain fetch's verdict on a document, or null for a body a verdict
|
|
21
|
+
* cannot be read from (a binary, JSON). The fetch ladder reads it to decide
|
|
22
|
+
* whether escalation could help (src/utils/fetchLadder.js).
|
|
23
|
+
* @param {Response & { _body: string }} response - from fetchWithTimeout
|
|
24
|
+
* @param {string} url - the requested URL
|
|
25
|
+
*/
|
|
26
|
+
export function plainVerdict(response, url) {
|
|
27
|
+
const contentType = response.headers?.get?.('content-type');
|
|
28
|
+
if (contentType && !TEXTUAL_BODY.test(contentType)) return null;
|
|
29
|
+
// A parse of its own, scripts and styles removed, so the handler's
|
|
30
|
+
// document stays intact.
|
|
31
|
+
const $ = load(response._body ?? '');
|
|
32
|
+
$('script, style, noscript').remove();
|
|
33
|
+
return stealthDocumentVerdict(
|
|
34
|
+
{
|
|
35
|
+
url: response.url || url,
|
|
36
|
+
status: response.status,
|
|
37
|
+
title: pageTitle($),
|
|
38
|
+
text: $('body').text().replace(/\s+/g, ' ').trim(),
|
|
39
|
+
html: response._body
|
|
40
|
+
},
|
|
41
|
+
{ fetcher: 'a plain fetch', rendered: false, contentReturned: false }
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {Response & { _body: string }} response - from fetchWithTimeout
|
|
47
|
+
* @param {string} url - the requested URL
|
|
48
|
+
* @param {object|null} [verdict] - plainVerdict's, when the caller already has it
|
|
49
|
+
* @returns {string|null} the error message, or null when the document is usable
|
|
50
|
+
*/
|
|
51
|
+
export function targetFailure(response, url, verdict = plainVerdict(response, url)) {
|
|
52
|
+
if (verdict?.blocked) return `Target answered HTTP ${response.status}: ${verdict.error}`;
|
|
53
|
+
return response.ok ? null : `Target answered HTTP ${response.status}`;
|
|
54
|
+
}
|
|
@@ -1,76 +1,84 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* extract_links — Extract all links from a webpage with optional filtering.
|
|
3
3
|
* Extracted from server.js inline handler.
|
|
4
|
+
* E3: Link records come from crawlforge-extractors' extractLinkRecords, the
|
|
5
|
+
* reader the REST route uses, so both surfaces return the same links.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
import { load } from 'cheerio';
|
|
7
|
-
import {
|
|
9
|
+
import { extractLinkRecords } from 'crawlforge-extractors';
|
|
10
|
+
import { fetchLadder, ladderErrorResult, isJsonType } from '../../utils/fetchLadder.js';
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* @param {{ url: string, filter_external?: boolean, base_url?: string,
|
|
11
|
-
* user_agent?: string, respect_robots?: boolean
|
|
14
|
+
* user_agent?: string, respect_robots?: boolean,
|
|
15
|
+
* escalate?: boolean, escalate_engine?: string }} params
|
|
16
|
+
* @param {Function} [escalateFetch] the stealth escalation stage
|
|
12
17
|
*/
|
|
13
|
-
|
|
18
|
+
async function extractLinks({ url, filter_external, base_url, user_agent, respect_robots, escalate, escalate_engine }, escalateFetch) {
|
|
14
19
|
try {
|
|
15
|
-
const
|
|
20
|
+
const ladder = await fetchLadder(url, {
|
|
21
|
+
tool: 'extract_links',
|
|
16
22
|
userAgent: user_agent,
|
|
17
23
|
respectRobots: respect_robots,
|
|
18
|
-
|
|
24
|
+
escalate: escalate === true,
|
|
25
|
+
escalateEngine: escalate_engine,
|
|
26
|
+
escalateFetch
|
|
19
27
|
});
|
|
20
|
-
if (
|
|
21
|
-
|
|
28
|
+
if (ladder.html === undefined) return ladderErrorResult('extract_links', 'Failed to extract links: ', ladder);
|
|
29
|
+
|
|
30
|
+
// A JSON body carries no <a href>.
|
|
31
|
+
if (isJsonType(ladder.type)) {
|
|
32
|
+
return {
|
|
33
|
+
content: [{
|
|
34
|
+
type: 'text',
|
|
35
|
+
text: JSON.stringify({
|
|
36
|
+
links: [],
|
|
37
|
+
total_count: 0,
|
|
38
|
+
internal_count: 0,
|
|
39
|
+
external_count: 0,
|
|
40
|
+
other_count: 0,
|
|
41
|
+
base_url: base_url || ladder.url,
|
|
42
|
+
...ladder.fields,
|
|
43
|
+
warnings: [...ladder.warnings, 'the target returned application/json; it has no HTML links']
|
|
44
|
+
}, null, 2)
|
|
45
|
+
}]
|
|
46
|
+
};
|
|
22
47
|
}
|
|
23
48
|
|
|
24
|
-
const html =
|
|
49
|
+
const html = ladder.html;
|
|
25
50
|
const $ = load(html);
|
|
26
51
|
|
|
27
|
-
const finalUrl =
|
|
28
|
-
const pageUrl = new URL(finalUrl);
|
|
52
|
+
const finalUrl = ladder.url;
|
|
29
53
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
54
|
+
// Reported base: an explicit base_url, else <base href> resolved against
|
|
55
|
+
// the page, else the page URL (extractLinkRecords resolves the same way).
|
|
32
56
|
let docBase = finalUrl;
|
|
33
57
|
const baseHref = $('base[href]').first().attr('href');
|
|
34
58
|
if (baseHref) {
|
|
35
59
|
try { docBase = new URL(baseHref, finalUrl).toString(); } catch { /* ignore invalid <base href> */ }
|
|
36
60
|
}
|
|
37
|
-
|
|
38
61
|
const baseUrl = base_url || docBase;
|
|
39
|
-
const links = [];
|
|
40
|
-
|
|
41
|
-
$('a[href]').each((_, element) => {
|
|
42
|
-
const href = $(element).attr('href');
|
|
43
|
-
const text = $(element).text().trim();
|
|
44
|
-
|
|
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;
|
|
48
62
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
links.push({ href: absoluteUrl, text, is_external: isExternal, original_href: href });
|
|
56
|
-
} catch {
|
|
57
|
-
// skip invalid URLs
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
const uniqueLinks = links.filter((link, index, arr) =>
|
|
62
|
-
arr.findIndex(l => l.href === link.href) === index
|
|
63
|
-
);
|
|
63
|
+
// Records are deduplicated on the URL without fragment or trailing slash;
|
|
64
|
+
// mailto:/tel:/javascript: links are type "other". filter_external drops
|
|
65
|
+
// only the internal ones.
|
|
66
|
+
const records = extractLinkRecords($, { pageUrl: finalUrl, baseUrl: base_url });
|
|
67
|
+
const links = filter_external ? records.filter(l => l.type !== 'internal') : records;
|
|
68
|
+
const count = (type) => links.filter(l => l.type === type).length;
|
|
64
69
|
|
|
65
70
|
return {
|
|
66
71
|
content: [{
|
|
67
72
|
type: 'text',
|
|
68
73
|
text: JSON.stringify({
|
|
69
|
-
links
|
|
70
|
-
total_count:
|
|
71
|
-
internal_count:
|
|
72
|
-
external_count:
|
|
73
|
-
|
|
74
|
+
links,
|
|
75
|
+
total_count: links.length,
|
|
76
|
+
internal_count: count('internal'),
|
|
77
|
+
external_count: count('external'),
|
|
78
|
+
other_count: count('other'),
|
|
79
|
+
base_url: baseUrl,
|
|
80
|
+
...ladder.fields,
|
|
81
|
+
...(ladder.warnings.length > 0 ? { warnings: ladder.warnings } : {})
|
|
74
82
|
}, null, 2)
|
|
75
83
|
}]
|
|
76
84
|
};
|
|
@@ -81,3 +89,15 @@ export async function extractLinksHandler({ url, filter_external, base_url, user
|
|
|
81
89
|
};
|
|
82
90
|
}
|
|
83
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {{ escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean }) => Promise<object> }} [deps]
|
|
95
|
+
* the stealth escalation stage (server.js `stealthEscalation`); without it
|
|
96
|
+
* an escalation that would run is reported as unavailable
|
|
97
|
+
*/
|
|
98
|
+
export function createExtractLinksHandler({ escalateFetch } = {}) {
|
|
99
|
+
return (params) => extractLinks(params, escalateFetch);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The handler with no escalation stage wired (tests; server.js wires its own). */
|
|
103
|
+
export const extractLinksHandler = createExtractLinksHandler();
|
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
* Extracted from server.js inline handler.
|
|
4
4
|
* D3.1: Added output_format:"markdown" option backed by Turndown.
|
|
5
5
|
* B1: Preserve block structure for text mode; use Readability + GFM for markdown mode.
|
|
6
|
+
* E3: Text mode reads through crawlforge-extractors' flattenText, as the REST
|
|
7
|
+
* route does; `selector` and `max_length` take the REST route's meaning.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import { load } from 'cheerio';
|
|
11
|
+
import { flattenText } from 'crawlforge-extractors';
|
|
9
12
|
import { JSDOM } from 'jsdom';
|
|
10
13
|
import { Readability } from '@mozilla/readability';
|
|
11
|
-
import {
|
|
14
|
+
import { fetchLadder, ladderErrorResult, isJsonType } from '../../utils/fetchLadder.js';
|
|
12
15
|
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
13
16
|
|
|
14
17
|
// Block-level elements whose boundaries should become paragraph breaks
|
|
@@ -73,22 +76,61 @@ export function readabilityToMarkdown(html, pageUrl) {
|
|
|
73
76
|
return htmlToMarkdown(articleHtml);
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Cut text to max_length characters and mark the cut with '...' (the REST rule).
|
|
81
|
+
* @param {string} text
|
|
82
|
+
* @param {number} [maxLength]
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
function truncate(text, maxLength) {
|
|
86
|
+
return maxLength && text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
|
|
87
|
+
}
|
|
88
|
+
|
|
76
89
|
/**
|
|
77
90
|
* @param {{ url: string, remove_scripts?: boolean, remove_styles?: boolean,
|
|
78
|
-
* output_format?: "text"|"markdown",
|
|
91
|
+
* output_format?: "text"|"markdown", selector?: string, max_length?: number,
|
|
92
|
+
* user_agent?: string, respect_robots?: boolean,
|
|
93
|
+
* escalate?: boolean, escalate_engine?: string }} params
|
|
94
|
+
* @param {Function} [escalateFetch] the stealth escalation stage
|
|
79
95
|
*/
|
|
80
|
-
|
|
96
|
+
async function extractText({ url, remove_scripts, remove_styles, output_format, selector, max_length, user_agent, respect_robots, escalate, escalate_engine }, escalateFetch) {
|
|
81
97
|
try {
|
|
82
|
-
const
|
|
98
|
+
const ladder = await fetchLadder(url, {
|
|
99
|
+
tool: 'extract_text',
|
|
83
100
|
userAgent: user_agent,
|
|
84
101
|
respectRobots: respect_robots,
|
|
85
|
-
|
|
102
|
+
escalate: escalate === true,
|
|
103
|
+
escalateEngine: escalate_engine,
|
|
104
|
+
escalateFetch
|
|
86
105
|
});
|
|
87
|
-
if (
|
|
88
|
-
|
|
106
|
+
if (ladder.html === undefined) return ladderErrorResult('extract_text', 'Failed to extract text: ', ladder);
|
|
107
|
+
|
|
108
|
+
const extras = {
|
|
109
|
+
...ladder.fields,
|
|
110
|
+
...(ladder.warnings.length > 0 ? { warnings: ladder.warnings } : {})
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// A JSON body has no markup to strip: it is returned as it came.
|
|
114
|
+
if (isJsonType(ladder.type)) {
|
|
115
|
+
const body = truncate(ladder.html, max_length);
|
|
116
|
+
const format = output_format === 'markdown' ? 'markdown' : 'text';
|
|
117
|
+
return {
|
|
118
|
+
content: [{
|
|
119
|
+
type: 'text',
|
|
120
|
+
text: JSON.stringify({
|
|
121
|
+
url: ladder.url,
|
|
122
|
+
[format]: body,
|
|
123
|
+
output_format: format,
|
|
124
|
+
word_count: body.split(/\s+/).filter(w => w.length > 0).length,
|
|
125
|
+
char_count: body.length,
|
|
126
|
+
...ladder.fields,
|
|
127
|
+
warnings: [...ladder.warnings, 'the target returned application/json; its body is returned as text']
|
|
128
|
+
}, null, 2)
|
|
129
|
+
}]
|
|
130
|
+
};
|
|
89
131
|
}
|
|
90
132
|
|
|
91
|
-
const html =
|
|
133
|
+
const html = ladder.html;
|
|
92
134
|
const $ = load(html);
|
|
93
135
|
|
|
94
136
|
if (remove_scripts !== false) $('script').remove();
|
|
@@ -101,21 +143,32 @@ export async function extractTextHandler({ url, remove_scripts, remove_styles, o
|
|
|
101
143
|
// enabled never render noscript content, so always strip it.
|
|
102
144
|
$('noscript').remove();
|
|
103
145
|
|
|
104
|
-
|
|
146
|
+
// A selector names the content, so the boilerplate strip is skipped.
|
|
147
|
+
let $target;
|
|
148
|
+
if (selector) {
|
|
149
|
+
$target = $(selector);
|
|
150
|
+
if ($target.length === 0) throw new Error(`No elements found for selector: ${selector}`);
|
|
151
|
+
} else {
|
|
152
|
+
$('nav, header, footer, aside, .advertisement, .ad, .sidebar').remove();
|
|
153
|
+
}
|
|
105
154
|
|
|
106
155
|
const result = {
|
|
107
|
-
url:
|
|
156
|
+
url: ladder.url
|
|
108
157
|
};
|
|
109
158
|
|
|
110
159
|
if (output_format === 'markdown') {
|
|
111
|
-
// Run Readability first to get main content, then convert to GFM markdown
|
|
112
|
-
|
|
160
|
+
// Run Readability first to get main content, then convert to GFM markdown;
|
|
161
|
+
// a selector's matches are converted as they are.
|
|
162
|
+
const markdown = $target
|
|
163
|
+
? htmlToMarkdown($target.toArray().map(el => $.html(el)).join('\n'))
|
|
164
|
+
: readabilityToMarkdown(html, ladder.url);
|
|
165
|
+
result.markdown = truncate(markdown, max_length);
|
|
113
166
|
result.output_format = 'markdown';
|
|
114
167
|
const plainText = result.markdown.replace(/[#*`_\[\]]/g, '').replace(/\s+/g, ' ').trim();
|
|
115
168
|
result.word_count = plainText.split(/\s+/).filter(w => w.length > 0).length;
|
|
116
169
|
result.char_count = plainText.length;
|
|
117
170
|
} else {
|
|
118
|
-
const text =
|
|
171
|
+
const text = truncate(flattenText($, $target), max_length);
|
|
119
172
|
result.text = text;
|
|
120
173
|
result.output_format = 'text';
|
|
121
174
|
result.word_count = text.split(/\s+/).filter(w => w.length > 0).length;
|
|
@@ -125,7 +178,7 @@ export async function extractTextHandler({ url, remove_scripts, remove_styles, o
|
|
|
125
178
|
return {
|
|
126
179
|
content: [{
|
|
127
180
|
type: 'text',
|
|
128
|
-
text: JSON.stringify(result, null, 2)
|
|
181
|
+
text: JSON.stringify({ ...result, ...extras }, null, 2)
|
|
129
182
|
}]
|
|
130
183
|
};
|
|
131
184
|
} catch (error) {
|
|
@@ -135,3 +188,15 @@ export async function extractTextHandler({ url, remove_scripts, remove_styles, o
|
|
|
135
188
|
};
|
|
136
189
|
}
|
|
137
190
|
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {{ escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean }) => Promise<object> }} [deps]
|
|
194
|
+
* the stealth escalation stage (server.js `stealthEscalation`); without it
|
|
195
|
+
* an escalation that would run is reported as unavailable
|
|
196
|
+
*/
|
|
197
|
+
export function createExtractTextHandler({ escalateFetch } = {}) {
|
|
198
|
+
return (params) => extractText(params, escalateFetch);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The handler with no escalation stage wired (tests; server.js wires its own). */
|
|
202
|
+
export const extractTextHandler = createExtractTextHandler();
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { load } from 'cheerio';
|
|
14
|
-
import { readBody } from 'crawlforge-extractors';
|
|
14
|
+
import { readBody, flattenText } from 'crawlforge-extractors';
|
|
15
15
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
16
16
|
import { config } from '../../constants/config.js';
|
|
17
17
|
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
@@ -83,35 +83,11 @@ function classifyContentType(contentType) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
|
-
* Flatten a parsed document's <body> to text
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* table rows / list items together ("1.Story title329 points") and starves
|
|
90
|
-
* downstream LLM extraction (AgentOrchestrator, extractStructured,
|
|
91
|
-
* extractWithLlm) of any structure to parse. Works on a detached clone so the
|
|
92
|
-
* caller's $ tree is untouched — unifiedScrape reuses $ for other formats.
|
|
93
|
-
*
|
|
94
|
-
* @param {import('cheerio').CheerioAPI} $
|
|
95
|
-
* @returns {string}
|
|
86
|
+
* Flatten a parsed document's <body> to text, one line per block element.
|
|
87
|
+
* The flattener moved to crawlforge-extractors (`flattenText`) so the REST
|
|
88
|
+
* extract_text route shares it; this name stays for extractStructured.
|
|
96
89
|
*/
|
|
97
|
-
export
|
|
98
|
-
// Block boundaries are marked with a U+E000 private-use sentinel so the
|
|
99
|
-
// HTML source's own insignificant newlines can be collapsed to spaces
|
|
100
|
-
// first, and only the sentinels become line breaks. (NUL won't survive:
|
|
101
|
-
// .after()/.replaceWith() parse their argument as HTML and the parser
|
|
102
|
-
// strips NUL; U+E000 passes through and never occurs in real page text.)
|
|
103
|
-
const $body = $('body').clone();
|
|
104
|
-
$body.find('br').replaceWith('\uE000');
|
|
105
|
-
$body.find('td, th').after(' ');
|
|
106
|
-
$body
|
|
107
|
-
.find('p, div, li, tr, h1, h2, h3, h4, h5, h6, blockquote, pre, table, ul, ol, dl, section, article, header, footer')
|
|
108
|
-
.after('\uE000');
|
|
109
|
-
return $body
|
|
110
|
-
.text()
|
|
111
|
-
.replace(/\s+/g, ' ')
|
|
112
|
-
.replace(/ ?(?:\uE000 ?)+/g, '\n')
|
|
113
|
-
.trim();
|
|
114
|
-
}
|
|
90
|
+
export { flattenText as flattenBodyText };
|
|
115
91
|
|
|
116
92
|
/**
|
|
117
93
|
* Fetch a URL and return parsed HTML via Cheerio.
|
|
@@ -190,7 +166,7 @@ export async function fetchAndParse(url, options = {}) {
|
|
|
190
166
|
$(stripTags.join(', ')).remove();
|
|
191
167
|
}
|
|
192
168
|
|
|
193
|
-
const textContent =
|
|
169
|
+
const textContent = flattenText($);
|
|
194
170
|
|
|
195
171
|
return { html, $, textContent, finalUrl: response.url, warnings, status: response.status };
|
|
196
172
|
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetchLadder — scrape's fetch ladder for extract_text and extract_links (fix
|
|
3
|
+
* plan Phase E2). Same rules and wording as the website's
|
|
4
|
+
* src/lib/tools/fetch-ladder.ts — keep the two in step.
|
|
5
|
+
*
|
|
6
|
+
* 1. A plain fetch (15 s), retried once when a 429/503 asks for at most
|
|
7
|
+
* 10 s with Retry-After.
|
|
8
|
+
* 2. A 2xx body that is not text (a PDF, an image, an archive) stops here as
|
|
9
|
+
* UNSUPPORTED_CONTENT_TYPE; it never escalates.
|
|
10
|
+
* 3. Only with escalate:true, and only when a browser could change the
|
|
11
|
+
* outcome (aBrowserMightPass, or a 444 refusal): the injected stealth
|
|
12
|
+
* stage — server.js `stealthEscalation`, the one scrape and
|
|
13
|
+
* extract_embedded_state run, behind the same compliance gate (impit
|
|
14
|
+
* first under "auto", then the browser).
|
|
15
|
+
* 4. Otherwise E1's verdict stands unchanged (targetFailure).
|
|
16
|
+
*
|
|
17
|
+
* scrape and extract_embedded_state keep their own copies of steps 3–4; this
|
|
18
|
+
* reuses their exported pieces rather than moving them.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { load } from 'cheerio';
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
import { fetchWithTimeout } from '../tools/basic/_fetch.js';
|
|
24
|
+
import { plainVerdict, targetFailure } from '../tools/basic/_targetFailure.js';
|
|
25
|
+
import { stealthDocumentVerdict } from './stealthVerdict.js';
|
|
26
|
+
import { parseRetryAfter } from './hostRateLimiter.js';
|
|
27
|
+
import { setActualCost } from '../server/requestContext.js';
|
|
28
|
+
import { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS, aBrowserMightPass } from '../tools/scrape/escalation.js';
|
|
29
|
+
|
|
30
|
+
/** extract_text's and extract_links's table price; escalation adds SCRAPE_ESCALATION_CREDITS. */
|
|
31
|
+
const EXTRACT_BASE_CREDITS = 1;
|
|
32
|
+
|
|
33
|
+
export const LADDER_TIMEOUT_MS = 15000;
|
|
34
|
+
|
|
35
|
+
/** A Retry-After longer than this is not waited out inside one call. */
|
|
36
|
+
export const RETRY_AFTER_MAX_MS = 10000;
|
|
37
|
+
|
|
38
|
+
export const CLIENT_RENDERED_WARNING = 'page renders client-side; use scrape';
|
|
39
|
+
|
|
40
|
+
/** The two public fields both tools take, in tools/list order. */
|
|
41
|
+
export const EXTRACT_ESCALATION_SHAPE = {
|
|
42
|
+
escalate: z.boolean().optional().default(false).describe('When the plain fetch comes back blocked (403/429/444/challenge page/empty shell), re-read the page once in the stealth browser and extract from the rendered document. Under escalate_engine "auto" a Chrome TLS handshake with the honest CrawlForge User-Agent (impit) is tried first, and the browser runs only when that does not get the page. A 404 or 5xx never escalates. Projected at 1+5; the actual charge stays at 1 when the plain fetch succeeded. Default: false'),
|
|
43
|
+
escalate_engine: SCRAPE_ESCALATION_SHAPE.escalate_engine
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Bodies these tools read. A missing Content-Type is read as HTML.
|
|
47
|
+
const TEXTUAL_TYPE = /^(?:text\/[\w.+-]+|application\/(?:xhtml\+xml|xml|json)|application\/[\w.+-]+\+(?:xml|json))$/;
|
|
48
|
+
const JSON_TYPE = /^application\/(?:[\w.+-]+\+)?json$/;
|
|
49
|
+
|
|
50
|
+
/** The media type without parameters, lowercased; '' when absent. */
|
|
51
|
+
function mediaType(response) {
|
|
52
|
+
return (response.headers?.get?.('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether a media type is JSON (application/json or a +json suffix). */
|
|
56
|
+
export function isJsonType(type) {
|
|
57
|
+
return JSON_TYPE.test(type);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A page that says it needs JavaScript (scrapingcourse.com's
|
|
61
|
+
// /javascript-rendering serves "Enable JavaScript to see products" over empty
|
|
62
|
+
// product cards, with no mount point to find).
|
|
63
|
+
// No \b before the verbs: cheerio's text() joins <h1>Challenge</h1><p>Enable…
|
|
64
|
+
// into one word.
|
|
65
|
+
const JS_REQUIRED = /(?:enable|requires?|turn on)\s+javascript\b|\bjavascript\s+(?:is\s+)?(?:required|disabled)\b/i;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A client-rendered shell: under 200 characters of visible text, and either an
|
|
69
|
+
* empty <div id="root">, "app" or "__next" for the framework to mount into or
|
|
70
|
+
* text saying the page needs JavaScript.
|
|
71
|
+
* @param {string} html
|
|
72
|
+
*/
|
|
73
|
+
export function isClientRenderedShell(html) {
|
|
74
|
+
const $ = load(html);
|
|
75
|
+
$('script, style, noscript').remove();
|
|
76
|
+
const text = $('body').text().replace(/\s+/g, ' ').trim();
|
|
77
|
+
if (text.length >= 200) return false;
|
|
78
|
+
if (JS_REQUIRED.test(text)) return true;
|
|
79
|
+
return $('div#root, div#app, div#__next').toArray().some((el) => $(el).text().trim() === '');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Retry-After in ms, or null when the header is absent or unreadable.
|
|
84
|
+
* @param {Response} response
|
|
85
|
+
*/
|
|
86
|
+
function retryAfterMs(response) {
|
|
87
|
+
const header = response.headers?.get?.('retry-after');
|
|
88
|
+
if (header == null) return null;
|
|
89
|
+
const raw = String(header).trim();
|
|
90
|
+
if (!/^\d+$/.test(raw) && Number.isNaN(Date.parse(raw))) return null;
|
|
91
|
+
return parseRetryAfter(raw);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The plain fetch, retried once on a 429/503 whose Retry-After is at most
|
|
96
|
+
* RETRY_AFTER_MAX_MS. fetchWithTimeout has already recorded the Retry-After
|
|
97
|
+
* (noteRetryAfter), and the second request's gate waits it out in the
|
|
98
|
+
* per-host throttle — so the wait happens once, there.
|
|
99
|
+
*/
|
|
100
|
+
async function plainFetch(url, options) {
|
|
101
|
+
const response = await fetchWithTimeout(url, options);
|
|
102
|
+
if (response.status !== 429 && response.status !== 503) return response;
|
|
103
|
+
const waitMs = retryAfterMs(response);
|
|
104
|
+
if (waitMs === null || waitMs > RETRY_AFTER_MAX_MS) return response;
|
|
105
|
+
return fetchWithTimeout(url, options);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {string} url
|
|
110
|
+
* @param {{ tool: 'extract_text'|'extract_links', userAgent?: string, respectRobots?: boolean,
|
|
111
|
+
* escalate?: boolean, escalateEngine?: string,
|
|
112
|
+
* escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean }) => Promise<object> }} options
|
|
113
|
+
* @returns {Promise<
|
|
114
|
+
* { unsupported: string } |
|
|
115
|
+
* { error: string, url: string, status?: number|null, blocked?: object, warnings: string[], fields: object } |
|
|
116
|
+
* { html: string, url: string, type: string, warnings: string[], fields: object }>}
|
|
117
|
+
* `fields` carries `escalated` (only when escalate was asked for), `stealth`
|
|
118
|
+
* (when the stage ran) and `rendered: false` (a client-rendered shell)
|
|
119
|
+
*/
|
|
120
|
+
export async function fetchLadder(url, { tool, userAgent, respectRobots, escalate = false, escalateEngine = 'auto', escalateFetch } = {}) {
|
|
121
|
+
let escalationRan = false;
|
|
122
|
+
try {
|
|
123
|
+
const response = await plainFetch(url, { timeout: LADDER_TIMEOUT_MS, userAgent, respectRobots, tool });
|
|
124
|
+
const type = mediaType(response);
|
|
125
|
+
if (response.ok && type && !TEXTUAL_TYPE.test(type)) return { unsupported: type };
|
|
126
|
+
|
|
127
|
+
const warnings = [...(response._warnings ?? [])];
|
|
128
|
+
// Reported only to a caller who asked to escalate.
|
|
129
|
+
const fields = escalate ? { escalated: false } : {};
|
|
130
|
+
const verdict = plainVerdict(response, url);
|
|
131
|
+
const failed = verdict ? !verdict.success : !response.ok;
|
|
132
|
+
|
|
133
|
+
// A 404 or a 5xx never escalates (aBrowserMightPass); a 444 is a refusal
|
|
134
|
+
// of our network, which a browser on another identity can pass. A host
|
|
135
|
+
// whose Retry-After is still open (over 10 s, or repeated on the retry)
|
|
136
|
+
// asked us to back off, and a stealth request now would override it (G6).
|
|
137
|
+
const backingOff = (response.status === 429 || response.status === 503) && retryAfterMs(response) !== null;
|
|
138
|
+
if (escalate && failed && !backingOff && (aBrowserMightPass(verdict, response.status) || response.status === 444)) {
|
|
139
|
+
const vendorDetected = verdict?.blocked?.vendor ?? null;
|
|
140
|
+
if (!escalateFetch) {
|
|
141
|
+
warnings.push('escalate: no stealth stage is wired into this server build');
|
|
142
|
+
} else {
|
|
143
|
+
try {
|
|
144
|
+
const stealth = await escalateFetch({ url: response.url || url, engine: escalateEngine, respectRobots });
|
|
145
|
+
escalationRan = true;
|
|
146
|
+
const engine = stealth.engine || escalateEngine;
|
|
147
|
+
// The stage's gate repeats the plain fetch's robots notes; say each once.
|
|
148
|
+
for (const w of stealth.warnings ?? []) if (!warnings.includes(w)) warnings.push(w);
|
|
149
|
+
const html = stealth.html || '';
|
|
150
|
+
const finalUrl = stealth.url || response.url || url;
|
|
151
|
+
const status = stealth.status ?? null;
|
|
152
|
+
const rendered = stealthDocumentVerdict(
|
|
153
|
+
{ url: finalUrl, title: stealth.title || '', text: stealth.text || '', html, status },
|
|
154
|
+
{ waitedMs: stealth.gracedMs || 0, fetcher: 'the stealth browser', rendered: true, contentReturned: false }
|
|
155
|
+
);
|
|
156
|
+
warnings.push(
|
|
157
|
+
`escalate: the plain fetch ${vendorDetected ? `was blocked by ${vendorDetected}` : 'did not return the page'}; ` +
|
|
158
|
+
`${engine === 'impit' ? 'a Chrome TLS handshake (impit, honest User-Agent)' : `the ${engine} stealth browser`} ${rendered.success ? 'returned it' : 'did not get it either'}`
|
|
159
|
+
);
|
|
160
|
+
fields.escalated = true;
|
|
161
|
+
fields.stealth = { engine, vendor_detected: vendorDetected };
|
|
162
|
+
if (!rendered.success) {
|
|
163
|
+
return { error: rendered.error, url: finalUrl, status, ...(rendered.blocked ? { blocked: rendered.blocked } : {}), warnings, fields };
|
|
164
|
+
}
|
|
165
|
+
return { html, url: finalUrl, type: 'text/html', warnings, fields };
|
|
166
|
+
} catch (err) {
|
|
167
|
+
// A robots refusal inside the gate stamps markPreflightRefusal, and
|
|
168
|
+
// withAuth then bills the whole call zero: nothing was rendered.
|
|
169
|
+
warnings.push(`escalate: the stealth retry did not run — ${err.message}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const failure = targetFailure(response, url, verdict);
|
|
175
|
+
if (failure) {
|
|
176
|
+
return { error: failure, url: response.url || url, status: response.status, ...(verdict?.blocked ? { blocked: verdict.blocked } : {}), warnings, fields };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const html = response._body ?? '';
|
|
180
|
+
if (!isJsonType(type) && isClientRenderedShell(html)) {
|
|
181
|
+
warnings.push(CLIENT_RENDERED_WARNING);
|
|
182
|
+
fields.rendered = false;
|
|
183
|
+
}
|
|
184
|
+
return { html, url: response.url || url, type, warnings, fields };
|
|
185
|
+
} finally {
|
|
186
|
+
// The projection is 1+5 when escalating; the stage's 5 is owed only if it ran.
|
|
187
|
+
if (escalate) setActualCost(EXTRACT_BASE_CREDITS + (escalationRan ? SCRAPE_ESCALATION_CREDITS : 0));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The error result for a ladder that did not return a page. Unescalated, the
|
|
193
|
+
* E1 text error; with escalate asked for, a JSON body carrying `escalated`,
|
|
194
|
+
* so withAuth's hint does not send the caller back to the stealth browser.
|
|
195
|
+
* @param {string} tool
|
|
196
|
+
* @param {string} prefix e.g. 'Failed to extract text: '
|
|
197
|
+
* @param {object} ladder fetchLadder's result
|
|
198
|
+
*/
|
|
199
|
+
export function ladderErrorResult(tool, prefix, ladder) {
|
|
200
|
+
if (ladder.unsupported) {
|
|
201
|
+
return {
|
|
202
|
+
content: [{
|
|
203
|
+
type: 'text',
|
|
204
|
+
text: `${prefix}UNSUPPORTED_CONTENT_TYPE: Unsupported content type ${ladder.unsupported}: ${tool} reads ${tool === 'extract_links' ? 'HTML' : 'HTML and text'}\n` +
|
|
205
|
+
'Next step: process_document reads PDF and DOCX documents from a URL (2 credits); an image or an archive has no text to extract.'
|
|
206
|
+
}],
|
|
207
|
+
isError: true
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (!('escalated' in ladder.fields)) {
|
|
211
|
+
return { content: [{ type: 'text', text: `${prefix}${ladder.error}` }], isError: true };
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
content: [{
|
|
215
|
+
type: 'text',
|
|
216
|
+
text: JSON.stringify({
|
|
217
|
+
success: false,
|
|
218
|
+
url: ladder.url,
|
|
219
|
+
...(typeof ladder.status === 'number' ? { status: ladder.status } : {}),
|
|
220
|
+
error: ladder.error,
|
|
221
|
+
...(ladder.blocked ? { blocked: ladder.blocked } : {}),
|
|
222
|
+
...ladder.fields,
|
|
223
|
+
...(ladder.warnings.length > 0 ? { warnings: ladder.warnings } : {})
|
|
224
|
+
}, null, 2)
|
|
225
|
+
}],
|
|
226
|
+
isError: true
|
|
227
|
+
};
|
|
228
|
+
}
|