crawlforge-mcp-server 6.17.1 → 6.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +2 -2
- package/server.js +18 -9
- package/src/core/ActionExecutor.js +57 -1
- package/src/core/AgentOrchestrator.js +58 -3
- package/src/core/AuthManager.js +12 -0
- package/src/core/crawlers/BFSCrawler.js +7 -0
- package/src/core/queue/QueueManager.js +3 -0
- package/src/schemas/toolOutputSchemas.js +6 -4
- package/src/server/fallbackHints.js +4 -4
- package/src/server/inlineThreshold.js +76 -10
- package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +1 -1
- 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/advanced/BrowserSessionTool.js +13 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +14 -3
- package/src/tools/advanced/batchScrape/worker.js +8 -0
- package/src/tools/advanced/scrapeWithActions/recorder.js +2 -0
- 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/crawl/crawlDeep.js +16 -5
- package/src/tools/crawl/mapSite.js +49 -41
- package/src/tools/extract/_fetchAndParse.js +15 -34
- package/src/tools/scrape/unifiedScrape.js +14 -2
- package/src/tools/tracking/trackChanges/index.js +33 -6
- package/src/tools/tracking/trackChanges/schema.js +2 -2
- package/src/utils/fetchLadder.js +243 -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.1",
|
|
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.15.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.1",
|
|
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", {
|
|
@@ -1022,7 +1031,7 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
1022
1031
|
fields: z.array(z.object({
|
|
1023
1032
|
selector: z.string(),
|
|
1024
1033
|
value: z.string(),
|
|
1025
|
-
type: z.enum(['text', 'select', 'checkbox', 'radio', 'file']).default('text'),
|
|
1034
|
+
type: z.enum(['text', 'select', 'checkbox', 'radio', 'file']).default('text').describe("How the field is filled: text types the value, select picks the option by value or label, checkbox/radio check the input (a selector matching a group checks the member whose value attribute equals value; a checkbox with value \"false\" is unchecked). file is not supported and fails that action"),
|
|
1026
1035
|
waitAfter: z.number().min(0).max(5000).default(100)
|
|
1027
1036
|
})),
|
|
1028
1037
|
submitSelector: z.string().optional(),
|
|
@@ -129,6 +129,17 @@ const SelectActionSchema = BaseActionSchema.extend({
|
|
|
129
129
|
message: 'Select action requires value or values'
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
// Not on any tool's public action list: scrape_with_actions' formAutoFill emits
|
|
133
|
+
// it for a field declared `type: "checkbox"` or `type: "radio"`. `fieldType`
|
|
134
|
+
// is a plain string so that a field type nothing here can fill ("file")
|
|
135
|
+
// arrives as an action that fails, instead of being typed into as text.
|
|
136
|
+
const CheckActionSchema = BaseActionSchema.extend({
|
|
137
|
+
type: z.literal('check'),
|
|
138
|
+
selector: z.string(),
|
|
139
|
+
value: z.string(),
|
|
140
|
+
fieldType: z.string()
|
|
141
|
+
});
|
|
142
|
+
|
|
132
143
|
const HoverActionSchema = BaseActionSchema.extend({
|
|
133
144
|
type: z.literal('hover'),
|
|
134
145
|
selector: z.string(),
|
|
@@ -175,6 +186,7 @@ const ActionSchema = z.union([
|
|
|
175
186
|
PressActionSchema,
|
|
176
187
|
ScrollActionSchema,
|
|
177
188
|
SelectActionSchema,
|
|
189
|
+
CheckActionSchema,
|
|
178
190
|
HoverActionSchema,
|
|
179
191
|
NavigateActionSchema,
|
|
180
192
|
ScreenshotActionSchema,
|
|
@@ -1029,6 +1041,8 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1029
1041
|
return await this.executeScrollAction(page, action);
|
|
1030
1042
|
case 'select':
|
|
1031
1043
|
return await this.executeSelectAction(page, action);
|
|
1044
|
+
case 'check':
|
|
1045
|
+
return await this.executeCheckAction(page, action);
|
|
1032
1046
|
case 'hover':
|
|
1033
1047
|
return await this.executeHoverAction(page, action);
|
|
1034
1048
|
case 'navigate':
|
|
@@ -1309,6 +1323,45 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1309
1323
|
};
|
|
1310
1324
|
}
|
|
1311
1325
|
|
|
1326
|
+
/**
|
|
1327
|
+
* Execute check action — a formAutoFill checkbox or radio field.
|
|
1328
|
+
*
|
|
1329
|
+
* A selector that names a whole group (`input[name="size"]`) is narrowed to
|
|
1330
|
+
* the member whose `value` attribute is the field's value, the way the form
|
|
1331
|
+
* itself pairs name and value; `.first()` would check whichever came first.
|
|
1332
|
+
* A selector that names one input checks that input, except a checkbox with
|
|
1333
|
+
* value "false", which is unchecked.
|
|
1334
|
+
* @param {Page} page - Playwright page
|
|
1335
|
+
* @param {Object} action - Check action
|
|
1336
|
+
* @returns {Promise<Object>} Check result
|
|
1337
|
+
*/
|
|
1338
|
+
async executeCheckAction(page, action) {
|
|
1339
|
+
if (action.fieldType !== 'checkbox' && action.fieldType !== 'radio') {
|
|
1340
|
+
throw new Error(
|
|
1341
|
+
`formAutoFill field type "${action.fieldType}" is not supported ` +
|
|
1342
|
+
'(supported: text, select, checkbox, radio)'
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const timeout = this.actionTimeout(action);
|
|
1347
|
+
const matches = page.locator(this.resolveSelector(page, action.selector));
|
|
1348
|
+
await matches.first().waitFor({ state: 'attached', timeout });
|
|
1349
|
+
|
|
1350
|
+
const isGroup = (await matches.count()) > 1;
|
|
1351
|
+
const target = isGroup
|
|
1352
|
+
? matches.and(page.locator(`[value=${JSON.stringify(action.value)}]`)).first()
|
|
1353
|
+
: matches.first();
|
|
1354
|
+
const checked = isGroup || action.fieldType === 'radio' || action.value !== 'false';
|
|
1355
|
+
|
|
1356
|
+
await target.setChecked(checked, { timeout });
|
|
1357
|
+
|
|
1358
|
+
return {
|
|
1359
|
+
selector: action.selector,
|
|
1360
|
+
value: action.value,
|
|
1361
|
+
checked
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1312
1365
|
/**
|
|
1313
1366
|
* Execute hover action
|
|
1314
1367
|
* @param {Page} page - Playwright page
|
|
@@ -1581,9 +1634,12 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1581
1634
|
// hostname-based checks alone would miss DNS-rebinding/private-IP targets.
|
|
1582
1635
|
await assertUrlAllowed(url, { resolveDns: true });
|
|
1583
1636
|
|
|
1637
|
+
// The initial load and a retry's reload pass no timeout of their own, so
|
|
1638
|
+
// browserOptions.timeout bounds them as it bounds the actions (0.2); a
|
|
1639
|
+
// navigate action's own timeout comes first.
|
|
1584
1640
|
const response = await page.goto(url, {
|
|
1585
1641
|
waitUntil: options.waitUntil || 'domcontentloaded',
|
|
1586
|
-
timeout: options.timeout || 30000
|
|
1642
|
+
timeout: options.timeout || options.browserOptions?.timeout || 30000
|
|
1587
1643
|
});
|
|
1588
1644
|
|
|
1589
1645
|
// Re-validate where the navigation went: a redirect could have taken us
|
|
@@ -172,6 +172,40 @@ export function isCurrentStateTask(prompt) {
|
|
|
172
172
|
return CURRENT_STATE_RE.test(prompt || '');
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* The names a prompt uses: words carrying a capital letter that is not just
|
|
177
|
+
* the capital opening a sentence ("TypeScript", "Hacker", "News", "NASA"; not
|
|
178
|
+
* "What"). These are what the task is about, so a planned query that drops
|
|
179
|
+
* one has lost its subject.
|
|
180
|
+
*/
|
|
181
|
+
export function namedTerms(prompt) {
|
|
182
|
+
const words = (prompt || '').split(/\s+/);
|
|
183
|
+
const terms = [];
|
|
184
|
+
words.forEach((raw, i) => {
|
|
185
|
+
if (raw.includes('://')) return;
|
|
186
|
+
const word = raw.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '').replace(/['’]s$/, '');
|
|
187
|
+
if (word.length < 2) return;
|
|
188
|
+
const opensSentence = i === 0 || /[.?!:]$/.test(words[i - 1]);
|
|
189
|
+
if (!/\p{Lu}/u.test(opensSentence ? word.slice(1) : word)) return;
|
|
190
|
+
if (!terms.some(t => t.toLowerCase() === word.toLowerCase())) terms.push(word);
|
|
191
|
+
});
|
|
192
|
+
return terms;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A task scoped to its seed URLs: the caller passed `urls` and the prompt
|
|
197
|
+
* points at them ("this page", "these URLs", "the given site"). The answer is
|
|
198
|
+
* then on those pages and nowhere else, so GATHER runs no web search — search
|
|
199
|
+
* results for the task's words are other people's pages. Asked how many books
|
|
200
|
+
* "this page" lists, the agent searched "count books on webpage" and merged
|
|
201
|
+
* Reddit threads about word counts into the evidence (R24, 2026-10-03).
|
|
202
|
+
*/
|
|
203
|
+
const SEED_SCOPE_RE = /\b(?:this|these|the (?:given|provided|above|following|seed))\s+(?:web\s?)?(?:pages?|urls?|sites?|websites?|links?|documents?|articles?)\b/i;
|
|
204
|
+
|
|
205
|
+
export function isSeedScopedTask(prompt, seedUrls) {
|
|
206
|
+
return Array.isArray(seedUrls) && seedUrls.length > 0 && SEED_SCOPE_RE.test(prompt || '');
|
|
207
|
+
}
|
|
208
|
+
|
|
175
209
|
// ── Orchestrator ──────────────────────────────────────────────────────────────
|
|
176
210
|
|
|
177
211
|
export class AgentOrchestrator {
|
|
@@ -358,6 +392,17 @@ export class AgentOrchestrator {
|
|
|
358
392
|
);
|
|
359
393
|
}
|
|
360
394
|
// A current-state plan is told to make its first query the bare entity
|
|
395
|
+
// name, and for a prompt naming two things a small model can keep the
|
|
396
|
+
// wrong one: "latest stable version of the TypeScript npm package" was
|
|
397
|
+
// searched as the single word "npm" (R24, 2026-10-03). The first query
|
|
398
|
+
// carries every name the prompt uses; what the planner wrote stays as the
|
|
399
|
+
// qualifier ("TypeScript npm").
|
|
400
|
+
if (currentState) {
|
|
401
|
+
const first = searchQueries[0].toLowerCase();
|
|
402
|
+
const dropped = namedTerms(prompt).filter(t => !first.includes(t.toLowerCase()));
|
|
403
|
+
if (dropped.length > 0) searchQueries[0] = `${dropped.join(' ')} ${searchQueries[0]}`;
|
|
404
|
+
}
|
|
405
|
+
// A current-state plan is told to make its first query the bare entity
|
|
361
406
|
// name, and a small model often stops there. The bare query surfaces the
|
|
362
407
|
// live front page, which rarely states the fact asked for: "what does
|
|
363
408
|
// Southwest charge for a first checked bag" fetched southwest.com's home,
|
|
@@ -397,7 +442,7 @@ export class AgentOrchestrator {
|
|
|
397
442
|
/** url -> { title, text }: the engine's excerpt, evidence of last resort for a page that cannot be fetched. */
|
|
398
443
|
const excerpts = new Map();
|
|
399
444
|
|
|
400
|
-
if (urlQueue.length < capUrls) {
|
|
445
|
+
if (urlQueue.length < capUrls && !isSeedScopedTask(prompt, seedUrls)) {
|
|
401
446
|
try {
|
|
402
447
|
const searchTool = await this._getSearchTool();
|
|
403
448
|
for (const q of searchQueries) {
|
|
@@ -607,14 +652,24 @@ export class AgentOrchestrator {
|
|
|
607
652
|
schema,
|
|
608
653
|
provider: 'auto'
|
|
609
654
|
});
|
|
655
|
+
// A required field left null is an incomplete answer, not a
|
|
656
|
+
// structured success (R24, 2026-10-03).
|
|
657
|
+
const missingRequired = result.success && Array.isArray(schema.required)
|
|
658
|
+
? schema.required.filter(key => result.data?.[key] == null || result.data[key] === '')
|
|
659
|
+
: [];
|
|
660
|
+
const missingReason = missingRequired.length > 0
|
|
661
|
+
? `Required ${missingRequired.length === 1 ? 'field' : 'fields'} ${missingRequired.map(k => `"${k}"`).join(', ')} ` +
|
|
662
|
+
`${missingRequired.length === 1 ? 'is' : 'are'} null: the fetched sources do not state ${missingRequired.length === 1 ? 'it' : 'them'}.`
|
|
663
|
+
: undefined;
|
|
664
|
+
if (missingReason) warnings.push(missingReason);
|
|
610
665
|
return {
|
|
611
666
|
success: result.success,
|
|
612
667
|
answer: result.success ? result.data : null,
|
|
613
668
|
structured: true,
|
|
614
669
|
search_results: searchResults,
|
|
615
670
|
evidence: evidence.map(e => (e.via ? { url: e.url, via: e.via } : { url: e.url })),
|
|
616
|
-
degraded: !result.success,
|
|
617
|
-
reason: result.success ?
|
|
671
|
+
degraded: !result.success || missingRequired.length > 0,
|
|
672
|
+
reason: result.success ? missingReason : result.error,
|
|
618
673
|
steps: step,
|
|
619
674
|
urls_fetched: urlsFetched,
|
|
620
675
|
stealth_retries: usage.escalations,
|
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';
|
|
@@ -219,6 +219,12 @@ export class BFSCrawler {
|
|
|
219
219
|
return;
|
|
220
220
|
}
|
|
221
221
|
this.visited.set(normalizedUrl, url);
|
|
222
|
+
// Budget spent: drop what is still waiting. Each of those tasks would only
|
|
223
|
+
// return at the cap check above, but the queue releases 10 a second, so a
|
|
224
|
+
// seed with 2,290 links held a max_pages:3 crawl open for 232 s (R24).
|
|
225
|
+
if (this.visited.size >= this.maxPages) {
|
|
226
|
+
this.queue.clear();
|
|
227
|
+
}
|
|
222
228
|
|
|
223
229
|
try {
|
|
224
230
|
// Check cache first
|
|
@@ -318,6 +324,7 @@ export class BFSCrawler {
|
|
|
318
324
|
url,
|
|
319
325
|
depth,
|
|
320
326
|
error: error.message,
|
|
327
|
+
...(error.code ? { code: error.code } : {}),
|
|
321
328
|
timestamp: new Date().toISOString()
|
|
322
329
|
});
|
|
323
330
|
}
|
|
@@ -77,6 +77,9 @@ export class QueueManager {
|
|
|
77
77
|
getStats() {
|
|
78
78
|
return {
|
|
79
79
|
...this.stats,
|
|
80
|
+
// Read live: the 'active' event fires when a task starts, never when the
|
|
81
|
+
// last one ends, so the stored value stayed at 8 on a finished crawl.
|
|
82
|
+
active: this.queue.pending,
|
|
80
83
|
size: this.queue.size,
|
|
81
84
|
pending: this.queue.pending,
|
|
82
85
|
isPaused: this.queue.isPaused
|
|
@@ -181,6 +181,7 @@ const mapSiteShape = {
|
|
|
181
181
|
url: z.string().optional(),
|
|
182
182
|
score: z.number().optional()
|
|
183
183
|
}).passthrough()).optional().describe('Present only when the `search` param was set'),
|
|
184
|
+
warnings: z.array(z.string()).optional().describe('Present when the map is partial, e.g. the start page could not be read and the URLs come from the sitemap only'),
|
|
184
185
|
_cost: costShape
|
|
185
186
|
};
|
|
186
187
|
|
|
@@ -378,16 +379,17 @@ const crawlDeepShape = {
|
|
|
378
379
|
error: z.string().optional(),
|
|
379
380
|
url: z.string().optional(),
|
|
380
381
|
crawl_depth: z.number().optional(),
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
382
|
+
pages_attempted: z.number().optional().describe('URLs the crawl tried: pages_crawled + error_count'),
|
|
383
|
+
pages_crawled: z.number().optional().describe('Pages fetched and returned in results'),
|
|
384
|
+
pages_found: z.number().optional().describe('Same as pages_crawled; kept for existing clients'),
|
|
385
|
+
error_count: z.number().optional().describe('URLs that failed; one entry each in errors[]'),
|
|
384
386
|
duration_ms: z.number().optional(),
|
|
385
387
|
pages_per_second: z.number().optional(),
|
|
386
388
|
results: z.array(crawlDeepPageShape).optional(),
|
|
387
389
|
errors: z.array(z.unknown()).optional(),
|
|
388
390
|
stats: z.unknown().optional(),
|
|
389
391
|
site_structure: z.object({
|
|
390
|
-
total_pages: z.number().optional(),
|
|
392
|
+
total_pages: z.number().optional().describe('Equals pages_crawled; failed URLs are not counted'),
|
|
391
393
|
depth_distribution: z.record(z.number()).optional()
|
|
392
394
|
.describe('Pages per crawl depth (links from the start URL)'),
|
|
393
395
|
path_depth_distribution: z.record(z.number()).optional()
|
|
@@ -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
|
}
|
|
@@ -154,7 +154,7 @@ main frame only — elements inside iframes and shadow DOM get no refs.
|
|
|
154
154
|
| `formats` | `["json"]` | `markdown`, `html`, `json`, `text`, `screenshots`. |
|
|
155
155
|
| `captureIntermediateStates` | `false` | Snapshot after each action. |
|
|
156
156
|
| `captureScreenshots` | `true` | Screenshot during execution. |
|
|
157
|
-
| `formAutoFill` | — | Declarative form fill: `fields[]` + `submitSelector`. |
|
|
157
|
+
| `formAutoFill` | — | Declarative form fill: `fields[]` + `submitSelector`. Each field's `type` picks how it is filled: `text` (default) types, `select` picks the option by value or label, `checkbox`/`radio` check the input (a group selector such as `input[name=size]` checks the member whose value attribute equals `value`). `file` is not supported and fails that action. |
|
|
158
158
|
| `browserOptions` | — | `headless`, `userAgent`, `viewportWidth/Height`, `timeout`. |
|
|
159
159
|
| `continueOnActionError` | `false` | Keep going if one action fails. |
|
|
160
160
|
| `maxRetries` | `0` | 0–3 whole-chain retries on failure. A retry re-navigates to the starting URL and replays every action; each run is reported under `attempts[]`. |
|
|
@@ -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
|
|