crawlforge-mcp-server 5.8.0 → 5.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +2 -2
- package/server.js +55 -20
- package/src/core/AuthManager.js +37 -5
- package/src/schemas/toolOutputSchemas.js +30 -0
- package/src/server/fallbackHints.js +34 -9
- package/src/server/inlineThreshold.js +5 -0
- package/src/server/redaction.js +469 -0
- package/src/server/withAuth.js +42 -2
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -0
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +5 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +5 -0
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +30 -1
- package/src/tools/scrape/escalation.js +62 -0
- package/src/tools/scrape/unifiedScrape.js +191 -58
- package/src/tools/search/batchSearch.js +50 -0
- package/src/tools/search/searchWeb.js +44 -0
- package/src/utils/secretMask.js +11 -7
package/README.md
CHANGED
|
@@ -179,7 +179,7 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
|
|
|
179
179
|
| `list_ollama_models` | 1 | List the Ollama models installed locally (helps you pick a `model` for `extract_with_llm`) |
|
|
180
180
|
| `get_batch_results` | 1 | Retrieve paginated results for a `batch_scrape` job by `batchId` |
|
|
181
181
|
| `read_result` | 1 | Search, slice, read lines or a JSON path from a result a tool returned with `truncated: true` and a `result_handle` (kept 1 hour on your own machine) — never fetch the page again |
|
|
182
|
-
| `scrape` | 2 | **Unified single-fetch, multi-format extraction.** Pass a `formats` array (markdown/html/rawHtml/text/links/metadata/screenshot/json-schema, plus `{type:"highlights",query}` and `{type:"question",question}` for only the matching sentences, table rows and code blocks, verbatim with offsets into the markdown: +1 credit once per call, `mode:"model"` +3) plus `onlyMainContent`; one fetch serves every requested format with per-format partial-success warnings |
|
|
182
|
+
| `scrape` | 2 | **Unified single-fetch, multi-format extraction.** Pass a `formats` array (markdown/html/rawHtml/text/links/metadata/screenshot/json-schema, plus `{type:"highlights",query}` and `{type:"question",question}` for only the matching sentences, table rows and code blocks, verbatim with offsets into the markdown: +1 credit once per call, `mode:"model"` +3) plus `onlyMainContent`; one fetch serves every requested format with per-format partial-success warnings. `escalate:true` retries a blocked plain fetch once in the stealth browser and returns the page instead of the block (+5 as the projected ceiling; the charge drops back to 2 when the plain fetch succeeded and no escalation ran) |
|
|
183
183
|
| `scrape_structured` | 2 | Extract structured data with CSS selectors |
|
|
184
184
|
| `extract_embedded_state` | 2 | Read a page's embedded JavaScript state — `__NEXT_DATA__`, React Server Component payloads, Nuxt, Apollo, Redux, `<script type="application/json">` — with a `path` to scope the result. No LLM in the extraction path |
|
|
185
185
|
| `extract_content` | 2 | Enhanced content extraction |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.10.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 30 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
|
|
6
6
|
"main": "server.js",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"cheerio": "^1.1.2",
|
|
117
117
|
"commander": "^14.0.3",
|
|
118
118
|
"compromise": "^14.14.4",
|
|
119
|
-
"crawlforge-extractors": "^1.
|
|
119
|
+
"crawlforge-extractors": "^1.9.0",
|
|
120
120
|
"diff": "^9.0.0",
|
|
121
121
|
"dotenv": "^17.2.1",
|
|
122
122
|
"franc": "^6.2.0",
|
package/server.js
CHANGED
|
@@ -57,6 +57,9 @@ import { scrapeStructuredHandler } from "./src/tools/basic/scrapeStructured.js";
|
|
|
57
57
|
import { extractEmbeddedStateHandler } from "./src/tools/extract/extractEmbeddedState.js";
|
|
58
58
|
import { READ_RESULT_INPUT_SHAPE, readResultHandler } from "./src/tools/result/readResult.js"; // Phase 2
|
|
59
59
|
import { MAX_INLINE_CHARS_PARAM } from "./src/server/inlineThreshold.js"; // Phase 2
|
|
60
|
+
import { REDACT_PII_PARAM } from "./src/server/redaction.js"; // Phase 5 (5.3)
|
|
61
|
+
import { SEARCH_QUERIES_PARAM, EXACTLY_ONE_QUERY_MESSAGE } from "./src/tools/search/batchSearch.js"; // Phase 5 (5.1)
|
|
62
|
+
import { markPreflightRefusal } from "./src/server/requestContext.js";
|
|
60
63
|
// D1.1 Resources + D1.2 Prompts + D1.4 Elicitation
|
|
61
64
|
import { ResourceRegistry } from "./src/resources/ResourceRegistry.js";
|
|
62
65
|
import { PROMPTS, getPromptMessages } from "./src/prompts/PromptRegistry.js";
|
|
@@ -109,7 +112,7 @@ const taskStore = createTaskStore({ logger });
|
|
|
109
112
|
// Create the server
|
|
110
113
|
const server = new McpServer({
|
|
111
114
|
name: "crawlforge",
|
|
112
|
-
version: "5.
|
|
115
|
+
version: "5.10.0",
|
|
113
116
|
description: "Production-ready MCP server with 30 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
114
117
|
homepage: "https://www.crawlforge.dev",
|
|
115
118
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -121,7 +124,7 @@ const server = new McpServer({
|
|
|
121
124
|
"- Read one page whose URL you have -> scrape (2). Ask for every format you need in that call: markdown, links, metadata, html, screenshot, json. Default for any page.",
|
|
122
125
|
"- Raw JSON/XML/API body, headers or status -> fetch_url (1). Not for HTML you will read: scrape returns markdown from the same fetch.",
|
|
123
126
|
"- Find pages for a query -> search_web (5); snippets often answer without a scrape. Google organic position -> serp_rank (5). Reddit -> reddit_search (5); reddit.com blocks direct scraping.",
|
|
124
|
-
"- Blocked (403/429/CAPTCHA/challenge page/empty shell) -> stealth_mode operation:\"scrape\" (5). Needs a click, login or scroll -> scrape_with_actions (5). Never start with these.",
|
|
127
|
+
"- Blocked (403/429/CAPTCHA/challenge page/empty shell) -> stealth_mode operation:\"scrape\" (5). scrape with escalate:true when a site is known to block (projected 7, charged 2 when the plain fetch works); still never stealth_mode first. Needs a click, login or scroll -> scrape_with_actions (5). Never start with these.",
|
|
125
128
|
"- 2-50 known URLs -> one batch_scrape (5), never a loop of scrape calls.",
|
|
126
129
|
"- A site's URL list -> map_site (2); many pages of one site -> crawl_deep (4).",
|
|
127
130
|
"- Exact values from a Next.js/Nuxt/Redux payload -> extract_embedded_state (2) with a path.",
|
|
@@ -165,6 +168,7 @@ server.registerPrompt("getting-started", {
|
|
|
165
168
|
"\n" +
|
|
166
169
|
"Hard pages\n" +
|
|
167
170
|
"- stealth_mode (5): after a 403/429/CAPTCHA/challenge page or an empty shell - never first.\n" +
|
|
171
|
+
"- scrape with escalate:true (projected 7): when a site is known to block, one call that reads the page and only falls back to the stealth browser if the plain fetch is walled - charged 2 when it is not. Still never stealth_mode first.\n" +
|
|
168
172
|
"- scrape_with_actions (5): click, log in, scroll or wait, then scrape.\n" +
|
|
169
173
|
"- localization (2): country and locale context for geo-specific content.\n" +
|
|
170
174
|
"\n" +
|
|
@@ -198,7 +202,7 @@ const metricsEnabled =
|
|
|
198
202
|
const metrics = metricsEnabled ? createMetricsRegistry() : null;
|
|
199
203
|
|
|
200
204
|
// Tool-handler wrapper: auth + credit tracking + structured invocation logging + observability.
|
|
201
|
-
const withAuth = makeWithAuth({ authManager: AuthManager, logger, metrics });
|
|
205
|
+
const withAuth = makeWithAuth({ authManager: AuthManager, logger, metrics, mcpServer: server });
|
|
202
206
|
|
|
203
207
|
// Initialize tools
|
|
204
208
|
// search_web falls back to AuthManager's stored key (~/.crawlforge/config.json)
|
|
@@ -236,7 +240,22 @@ const deepResearchTool = new DeepResearchTool();
|
|
|
236
240
|
const trackChangesTool = new TrackChangesTool();
|
|
237
241
|
const generateLLMsTxtTool = new GenerateLLMsTxtTool();
|
|
238
242
|
const scrapeTemplateTool = new ScrapeTemplateTool(); // D3.3
|
|
239
|
-
const unifiedScrapeTool = new UnifiedScrapeTool({
|
|
243
|
+
const unifiedScrapeTool = new UnifiedScrapeTool({
|
|
244
|
+
actionExecutor: scrapeWithActionsTool.actionExecutor, // D4 D1 (+v4.8 screenshot reuses the shared browser pool)
|
|
245
|
+
// Phase 3: the escalation stage for `scrape`'s escalate:true, injected so
|
|
246
|
+
// the tool module never imports StealthBrowserManager (that would pull a
|
|
247
|
+
// browser dependency into every unit test that loads `scrape`). Same gate
|
|
248
|
+
// and same browser the stealth_mode tool drives — no new evasion, and the
|
|
249
|
+
// "playwright" → chromium engine mapping stays here with its sibling.
|
|
250
|
+
escalateScrape: async ({ url, engine, respectRobots }) => {
|
|
251
|
+
const warnings = await stealthComplianceGate(url, respectRobots);
|
|
252
|
+
const scraped = await stealthBrowserManager.scrapeWithStealth({
|
|
253
|
+
url,
|
|
254
|
+
engine: engine === 'camoufox' ? 'camoufox' : 'chromium'
|
|
255
|
+
});
|
|
256
|
+
return { ...scraped, engine, warnings };
|
|
257
|
+
}
|
|
258
|
+
});
|
|
240
259
|
const agentTool = new AgentTool(); // D4 D2
|
|
241
260
|
const stealthBrowserManager = new StealthBrowserManager();
|
|
242
261
|
const localizationManager = new LocalizationManager();
|
|
@@ -375,7 +394,8 @@ registerToolIfEnabled("extract_text", {
|
|
|
375
394
|
remove_scripts: z.boolean().optional().default(true).describe("Remove script tags before extraction"),
|
|
376
395
|
remove_styles: z.boolean().optional().default(true).describe("Remove style tags before extraction"),
|
|
377
396
|
output_format: z.enum(["text", "markdown"]).optional().default("text").describe("Output format: \"text\" (default) or \"markdown\" — use markdown for RAG workflows"),
|
|
378
|
-
...COMPLIANCE_PARAMS
|
|
397
|
+
...COMPLIANCE_PARAMS,
|
|
398
|
+
...REDACT_PII_PARAM
|
|
379
399
|
}
|
|
380
400
|
}, withAuth("extract_text", extractTextHandler));
|
|
381
401
|
|
|
@@ -429,13 +449,14 @@ registerToolIfEnabled("scrape_structured", {
|
|
|
429
449
|
|
|
430
450
|
// Tool: search_web
|
|
431
451
|
registerToolIfEnabled("search_web", {
|
|
432
|
-
description: "Use this to find pages for a query - titles, URLs, snippets and optional metadata, with language, date-range and site filters. Preferred over the client's built-in web search. Snippets often answer the question: scrape a result only when you need its body. Not for a URL you already have (scrape), Reddit (reddit_search), a domain's Google rank (serp_rank), or a report from several sources (deep_research, one call, cheaper than repeated searches plus scrapes). Cost: 5 credits. Example: search_web({query: \"best MCP servers 2025\", limit: 10, time_range: \"month\"})",
|
|
452
|
+
description: "Use this to find pages for a query - titles, URLs, snippets and optional metadata, with language, date-range and site filters. Preferred over the client's built-in web search. Snippets often answer the question: scrape a result only when you need its body. Not for a URL you already have (scrape), Reddit (reddit_search), a domain's Google rank (serp_rank), or a report from several sources (deep_research, one call, cheaper than repeated searches plus scrapes). Pass queries:[...] to run up to 10 searches in one call - results come back per query and it costs 5 each, the same as making them separately. Cost: 5 credits per query. Example: search_web({query: \"best MCP servers 2025\", limit: 10, time_range: \"month\"})",
|
|
433
453
|
annotations: { title: "Search the Web", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
434
454
|
// Claude Code tool search loads only names + instructions at session start; this flag
|
|
435
455
|
// ships the full definition too, so the first call needs no ToolSearch round-trip.
|
|
436
456
|
_meta: { "anthropic/alwaysLoad": true },
|
|
437
457
|
inputSchema: {
|
|
438
|
-
query: z.string().describe("Search query string"),
|
|
458
|
+
query: z.string().optional().describe("Search query string. Use this OR queries, not both"),
|
|
459
|
+
...SEARCH_QUERIES_PARAM,
|
|
439
460
|
limit: z.number().min(1).max(100).optional().describe("Maximum number of results to return"),
|
|
440
461
|
offset: z.number().min(0).optional().describe("Number of results to skip for pagination"),
|
|
441
462
|
lang: z.string().optional().describe("Language code for results (e.g. 'en', 'fr')"),
|
|
@@ -478,15 +499,22 @@ registerToolIfEnabled("search_web", {
|
|
|
478
499
|
latitude: z.number().min(-90).max(90),
|
|
479
500
|
longitude: z.number().min(-180).max(180)
|
|
480
501
|
}).optional()
|
|
481
|
-
}).optional().describe("Geo/locale targeting for results")
|
|
502
|
+
}).optional().describe("Geo/locale targeting for results"),
|
|
503
|
+
...REDACT_PII_PARAM
|
|
482
504
|
},
|
|
483
505
|
outputSchema: OUTPUT_SCHEMAS.search_web
|
|
484
|
-
}, withAuth("search_web", async ({ query, limit, offset, lang, safe_search, time_range, site, file_type, provider, expand_query, expansion_options, enable_ranking, ranking_weights, enable_deduplication, deduplication_thresholds, include_ranking_details, include_deduplication_details, localization }) => {
|
|
506
|
+
}, withAuth("search_web", async ({ query, queries, limit, offset, lang, safe_search, time_range, site, file_type, provider, expand_query, expansion_options, enable_ranking, ranking_weights, enable_deduplication, deduplication_thresholds, include_ranking_details, include_deduplication_details, localization }) => {
|
|
485
507
|
try {
|
|
486
|
-
if (!query) {
|
|
487
|
-
|
|
508
|
+
if (!query && !queries) {
|
|
509
|
+
// `query` stopped being a required field when `queries` arrived, so the
|
|
510
|
+
// SDK no longer rejects this call for us — it reaches the handler, and
|
|
511
|
+
// without this it would be billed the half-credit error rate for a
|
|
512
|
+
// search that never ran. Same reasoning as ScrapeTemplateTool's
|
|
513
|
+
// badRequest: we fetched nothing, so it costs nothing (G4).
|
|
514
|
+
markPreflightRefusal('BAD_REQUEST');
|
|
515
|
+
return { content: [{ type: "text", text: EXACTLY_ONE_QUERY_MESSAGE }], isError: true };
|
|
488
516
|
}
|
|
489
|
-
const result = await searchWebTool.execute({ query, limit, offset, lang, safe_search, time_range, site, file_type, provider, expand_query, expansion_options, enable_ranking, ranking_weights, enable_deduplication, deduplication_thresholds, include_ranking_details, include_deduplication_details, localization });
|
|
517
|
+
const result = await searchWebTool.execute({ query, queries, limit, offset, lang, safe_search, time_range, site, file_type, provider, expand_query, expansion_options, enable_ranking, ranking_weights, enable_deduplication, deduplication_thresholds, include_ranking_details, include_deduplication_details, localization });
|
|
490
518
|
return dualOutput(result);
|
|
491
519
|
} catch (error) {
|
|
492
520
|
return { content: [{ type: "text", text: `Search failed: ${error.message}` }], isError: true };
|
|
@@ -584,7 +612,8 @@ if (toolFilter.isEnabled("crawl_deep")) {
|
|
|
584
612
|
body: z.string().optional()
|
|
585
613
|
}).optional()
|
|
586
614
|
}).optional().describe("Shared cookie-jar/session for login-then-crawl workflows"),
|
|
587
|
-
...MAX_INLINE_CHARS_PARAM
|
|
615
|
+
...MAX_INLINE_CHARS_PARAM,
|
|
616
|
+
...REDACT_PII_PARAM
|
|
588
617
|
},
|
|
589
618
|
outputSchema: OUTPUT_SCHEMAS.crawl_deep,
|
|
590
619
|
execution: TASK_EXECUTION
|
|
@@ -647,7 +676,8 @@ registerToolIfEnabled("extract_content", {
|
|
|
647
676
|
url: z.string().url().describe("The URL to extract content from"),
|
|
648
677
|
options: z.object({}).passthrough().optional().describe("Additional extraction options"),
|
|
649
678
|
...COMPLIANCE_PARAMS,
|
|
650
|
-
...MAX_INLINE_CHARS_PARAM
|
|
679
|
+
...MAX_INLINE_CHARS_PARAM,
|
|
680
|
+
...REDACT_PII_PARAM
|
|
651
681
|
}
|
|
652
682
|
}, withAuth("extract_content", async (params) => {
|
|
653
683
|
try {
|
|
@@ -672,7 +702,8 @@ registerToolIfEnabled("process_document", {
|
|
|
672
702
|
// extractText, outputFormat, etc.) reach the tool instead of being stripped.
|
|
673
703
|
options: z.object({}).passthrough().optional().describe("Additional processing options (maxPages, pageRange:{start,end}, extractText, extractMetadata, outputFormat, ...)"),
|
|
674
704
|
...COMPLIANCE_PARAMS,
|
|
675
|
-
...MAX_INLINE_CHARS_PARAM
|
|
705
|
+
...MAX_INLINE_CHARS_PARAM,
|
|
706
|
+
...REDACT_PII_PARAM
|
|
676
707
|
}
|
|
677
708
|
}, withAuth("process_document", async (params) => {
|
|
678
709
|
try {
|
|
@@ -838,7 +869,8 @@ if (toolFilter.isEnabled("batch_scrape")) {
|
|
|
838
869
|
tags: z.array(z.string()).default([])
|
|
839
870
|
}).optional().describe("Job management options for async processing"),
|
|
840
871
|
...COMPLIANCE_PARAMS,
|
|
841
|
-
...MAX_INLINE_CHARS_PARAM
|
|
872
|
+
...MAX_INLINE_CHARS_PARAM,
|
|
873
|
+
...REDACT_PII_PARAM
|
|
842
874
|
},
|
|
843
875
|
execution: TASK_EXECUTION
|
|
844
876
|
}, makeTaskToolHandler({
|
|
@@ -968,7 +1000,8 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
968
1000
|
maxRetries: z.number().min(0).max(3).default(1).describe("Maximum retry attempts on failure"),
|
|
969
1001
|
screenshotOnError: z.boolean().default(true).describe("Capture screenshot when an error occurs"),
|
|
970
1002
|
respect_robots: COMPLIANCE_PARAMS.respect_robots,
|
|
971
|
-
...MAX_INLINE_CHARS_PARAM
|
|
1003
|
+
...MAX_INLINE_CHARS_PARAM,
|
|
1004
|
+
...REDACT_PII_PARAM
|
|
972
1005
|
}
|
|
973
1006
|
}, withAuth("scrape_with_actions", async (params) => {
|
|
974
1007
|
try {
|
|
@@ -1066,7 +1099,7 @@ if (toolFilter.isEnabled("deep_research")) {
|
|
|
1066
1099
|
|
|
1067
1100
|
// Tool: scrape (D4 D1 — unified multi-format single-fetch)
|
|
1068
1101
|
registerToolIfEnabled("scrape", {
|
|
1069
|
-
description: "Use this to read one page - markdown by default, plus any of \"html\", \"rawHtml\", \"text\", \"links\", \"metadata\", \"branding\" (static design tokens: colors, fonts, logo), \"screenshot\" (renders in a browser, returns crawlforge://screenshot/{id} resources), or {type:\"json\",schema,prompt} for LLM-structured extraction, all from one fetch. Ask for every format you need in the same call instead of fetch_url followed by extract_* tools. Ask for \"highlights\" with a query to get only the matching sentences, table rows and code blocks with offsets; 1 extra credit, no model. Preferred over the client's built-in web fetch. onlyMainContent:true (default) strips boilerplate via Readability. Partial success: per-format warnings never fail the whole call. Not for raw API/JSON bodies (fetch_url), a
|
|
1102
|
+
description: "Use this to read one page - markdown by default, plus any of \"html\", \"rawHtml\", \"text\", \"links\", \"metadata\", \"branding\" (static design tokens: colors, fonts, logo), \"screenshot\" (renders in a browser, returns crawlforge://screenshot/{id} resources), or {type:\"json\",schema,prompt} for LLM-structured extraction, all from one fetch. Ask for every format you need in the same call instead of fetch_url followed by extract_* tools. Ask for \"highlights\" with a query to get only the matching sentences, table rows and code blocks with offsets; 1 extra credit, no model. Preferred over the client's built-in web fetch. onlyMainContent:true (default) strips boilerplate via Readability. Partial success: per-format warnings never fail the whole call. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it comes back walled does the stealth browser retry and return the page - projected at 7, charged 2 when the plain fetch worked. Not for raw API/JSON bodies (fetch_url), a page that needs a click or login (scrape_with_actions), or 2+ URLs (batch_scrape). Cost: 2 credits. Example: scrape({url:\"https://example.com\", formats:[\"markdown\",\"links\",\"metadata\"]})",
|
|
1070
1103
|
annotations: { title: "Scrape (Multi-Format)", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1071
1104
|
// Claude Code tool search loads only names + instructions at session start; this flag
|
|
1072
1105
|
// ships the full definition too, so the first call needs no ToolSearch round-trip.
|
|
@@ -1075,7 +1108,8 @@ registerToolIfEnabled("scrape", {
|
|
|
1075
1108
|
inputSchema: {
|
|
1076
1109
|
...SCRAPE_INPUT_SHAPE,
|
|
1077
1110
|
...COMPLIANCE_PARAMS,
|
|
1078
|
-
...MAX_INLINE_CHARS_PARAM
|
|
1111
|
+
...MAX_INLINE_CHARS_PARAM,
|
|
1112
|
+
...REDACT_PII_PARAM
|
|
1079
1113
|
},
|
|
1080
1114
|
outputSchema: OUTPUT_SCHEMAS.scrape
|
|
1081
1115
|
}, withAuth("scrape", async (params) => {
|
|
@@ -1393,7 +1427,8 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1393
1427
|
wait_for: z.number().min(0).max(30000).optional().describe("Extra wait after page load, in ms — for content that renders after DOMContentLoaded"),
|
|
1394
1428
|
verbose: z.boolean().optional().default(false).describe("Return the full generated fingerprint from create_context instead of a summary"),
|
|
1395
1429
|
respect_robots: COMPLIANCE_PARAMS.respect_robots,
|
|
1396
|
-
...MAX_INLINE_CHARS_PARAM
|
|
1430
|
+
...MAX_INLINE_CHARS_PARAM,
|
|
1431
|
+
...REDACT_PII_PARAM
|
|
1397
1432
|
}
|
|
1398
1433
|
}, withAuth("stealth_mode", async ({ operation, stealthConfig, contextId, urlToTest, url, formats, wait_for, verbose, engine, respect_robots }) => {
|
|
1399
1434
|
try {
|
package/src/core/AuthManager.js
CHANGED
|
@@ -13,6 +13,9 @@ import { resolveApiEndpoint } from './endpointGuard.js';
|
|
|
13
13
|
import { logger } from '../utils/Logger.js';
|
|
14
14
|
import { maskSecrets } from '../utils/secretMask.js';
|
|
15
15
|
import { scrapeFormatSurcharge } from '../tools/scrape/formats.js';
|
|
16
|
+
import { scrapeEscalationSurcharge } from '../tools/scrape/escalation.js';
|
|
17
|
+
import { searchQueryCount } from '../tools/search/batchSearch.js';
|
|
18
|
+
import { redactionSurcharge, REDACT_PII_MODEL_CREDITS } from '../server/redaction.js';
|
|
16
19
|
// Stamped on every usage report so support can tell which client version
|
|
17
20
|
// made a call; it read '3.0.3' from 3.0.3 to 5.6.10 (the website stores it
|
|
18
21
|
// per record from its Phase 1.3 on).
|
|
@@ -625,15 +628,29 @@ class AuthManager {
|
|
|
625
628
|
return costs.search_web;
|
|
626
629
|
}
|
|
627
630
|
|
|
631
|
+
// redact_pii's regex pass is free; its opt-in model pass adds 3 once per
|
|
632
|
+
// call, on every tool that offers the param. The rule lives with the
|
|
633
|
+
// schema that declares it (src/server/redaction.js).
|
|
634
|
+
const redaction = redactionSurcharge(tool, params);
|
|
635
|
+
|
|
628
636
|
// The query-scoped formats (highlights, question) add 1 once per call,
|
|
629
|
-
// and mode:"model" adds 3 once;
|
|
630
|
-
//
|
|
637
|
+
// and mode:"model" adds 3 once; escalate:true adds the stealth browser's
|
|
638
|
+
// own 5, since that is the ceiling the call may reach. The base price in
|
|
639
|
+
// the table is what the backend sees. Both rules live with the schemas
|
|
640
|
+
// that declare the params.
|
|
631
641
|
if (tool === 'scrape') {
|
|
632
642
|
const { query, model } = scrapeFormatSurcharge(params?.formats);
|
|
633
|
-
return costs.scrape + query + model;
|
|
643
|
+
return costs.scrape + query + model + scrapeEscalationSurcharge(params?.escalate) + redaction;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// search_web's batch form (5.1) runs one backend search per query, so it
|
|
647
|
+
// is priced as that many search_web calls. The count rule lives with the
|
|
648
|
+
// schema that declares `queries` (src/tools/search/batchSearch.js).
|
|
649
|
+
if (tool === 'search_web') {
|
|
650
|
+
return costs.search_web * searchQueryCount(params?.queries) + redaction;
|
|
634
651
|
}
|
|
635
652
|
|
|
636
|
-
return costs[tool] ?? 1;
|
|
653
|
+
return (costs[tool] ?? 1) + redaction;
|
|
637
654
|
}
|
|
638
655
|
|
|
639
656
|
/**
|
|
@@ -674,6 +691,13 @@ class AuthManager {
|
|
|
674
691
|
note = `Lower-bound estimate. crawl_deep cost grows with page count (${maxPages} max).`;
|
|
675
692
|
break;
|
|
676
693
|
}
|
|
694
|
+
case 'search_web': {
|
|
695
|
+
const queryCount = searchQueryCount(params?.queries);
|
|
696
|
+
note = queryCount > 1
|
|
697
|
+
? `5 per query, ${queryCount} queries — exactly what ${queryCount} separate calls cost. A query that fails before it reaches a backend is not billed.`
|
|
698
|
+
: 'Fixed cost per invocation.';
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
677
701
|
case 'extract_with_llm':
|
|
678
702
|
note = 'External LLM API call billed by your LLM provider, separate from the credit cost.';
|
|
679
703
|
break;
|
|
@@ -689,7 +713,9 @@ class AuthManager {
|
|
|
689
713
|
break;
|
|
690
714
|
case 'scrape': {
|
|
691
715
|
projected = base;
|
|
692
|
-
note = 'Base 2; a highlights or question format adds 1 once per call, mode:"model" adds 3 once (charged at the extractive price when no LLM route exists).
|
|
716
|
+
note = 'Base 2; a highlights or question format adds 1 once per call, mode:"model" adds 3 once (charged at the extractive price when no LLM route exists). ' +
|
|
717
|
+
'escalate:true adds 5 for the stealth browser it may need — the projection is the ceiling, and the actual charge drops back to the base when the plain fetch succeeded and no escalation ran. ' +
|
|
718
|
+
'json format may incur external LLM cost (billed by your provider).';
|
|
693
719
|
break;
|
|
694
720
|
}
|
|
695
721
|
case 'agent': {
|
|
@@ -703,6 +729,12 @@ class AuthManager {
|
|
|
703
729
|
note = 'Fixed cost per invocation.';
|
|
704
730
|
}
|
|
705
731
|
|
|
732
|
+
// redact_pii applies to nine different tools, so its note is appended
|
|
733
|
+
// rather than owned by any one case.
|
|
734
|
+
if (redactionSurcharge(toolName, params) > 0) {
|
|
735
|
+
note += ` redact_pii mode:"model" adds ${REDACT_PII_MODEL_CREDITS} once for the PERSON/LOCATION pass; the charge drops back when no LLM route answered.`;
|
|
736
|
+
}
|
|
737
|
+
|
|
706
738
|
return { projected, note };
|
|
707
739
|
}
|
|
708
740
|
|
|
@@ -42,6 +42,16 @@ const resultHandleShape = {
|
|
|
42
42
|
expires_at: z.string().optional().describe('When the stored result is dropped (ISO 8601)')
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
// ── redact_pii (Phase 5, 5.3) ───────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
// What the redaction stage reports back on every tool that offers redact_pii.
|
|
48
|
+
const redactionShape = z.object({
|
|
49
|
+
entities: z.record(z.number()).optional().describe('How many spans were replaced, by entity class; a class with no hits is omitted'),
|
|
50
|
+
count: z.number().optional().describe('Total spans replaced'),
|
|
51
|
+
mode: z.enum(['fast', 'model']).optional().describe('"fast" is the free regex pass; "model" added an Ollama NER pass for PERSON and LOCATION'),
|
|
52
|
+
model_ran: z.boolean().optional().describe('mode "model" only: whether a model actually answered. False means no LLM route existed and the model surcharge was not charged')
|
|
53
|
+
}).passthrough().optional().describe('Present when redact_pii was set: what was redacted from the text of this result');
|
|
54
|
+
|
|
45
55
|
// ── scrape ──────────────────────────────────────────────────────────────────
|
|
46
56
|
|
|
47
57
|
const scrapeLinkShape = z.object({
|
|
@@ -124,8 +134,14 @@ const scrapeShape = {
|
|
|
124
134
|
vendor: z.string().optional(),
|
|
125
135
|
evidence: z.string().optional()
|
|
126
136
|
}).passthrough().optional().describe('Present when a bot-defence vendor served a challenge page; the fallback hint names the tool to try next'),
|
|
137
|
+
escalated: z.boolean().optional().describe('Present only when escalate:true was passed: whether the blocked plain fetch was retried in the stealth browser. False means the plain fetch sufficed and the call is charged at the base price'),
|
|
138
|
+
stealth: z.object({
|
|
139
|
+
engine: z.string().optional(),
|
|
140
|
+
vendor_detected: z.string().nullable().optional()
|
|
141
|
+
}).passthrough().optional().describe('Present when escalated is true: the stealth engine that ran, and the bot-defence vendor the plain fetch hit (null when the block named none)'),
|
|
127
142
|
content: z.object(scrapeFormatShapes).passthrough().optional().describe('One key per requested format'),
|
|
128
143
|
warnings: z.array(z.string()).optional().describe('Per-format warnings; partial success never fails the whole call'),
|
|
144
|
+
redaction: redactionShape,
|
|
129
145
|
...resultHandleShape,
|
|
130
146
|
_cost: costShape
|
|
131
147
|
};
|
|
@@ -267,8 +283,20 @@ const searchWebResultShape = z.object({
|
|
|
267
283
|
metadata: z.record(z.unknown()).optional()
|
|
268
284
|
}).passthrough();
|
|
269
285
|
|
|
286
|
+
// The batch form (5.1): one entry per query, in the order they were given.
|
|
287
|
+
// Each carries the same fields a single-query call returns, plus the query
|
|
288
|
+
// itself — or `error` when that one query failed and the others did not.
|
|
289
|
+
const searchWebBatchEntryShape = z.object({
|
|
290
|
+
query: z.string().optional(),
|
|
291
|
+
error: z.string().optional().describe('Present when this query failed; the other queries in the batch are unaffected'),
|
|
292
|
+
results: z.array(searchWebResultShape).optional()
|
|
293
|
+
}).passthrough();
|
|
294
|
+
|
|
270
295
|
const searchWebShape = {
|
|
271
296
|
query: z.string().optional(),
|
|
297
|
+
queries: z.array(z.string()).optional().describe('Batch form: the queries that ran, in order'),
|
|
298
|
+
count: z.number().optional().describe('Batch form: how many queries ran'),
|
|
299
|
+
results_by_query: z.array(searchWebBatchEntryShape).optional().describe('Batch form: one entry per query, in order'),
|
|
272
300
|
effective_query: z.string().optional().describe('Present when query expansion changed the query actually used'),
|
|
273
301
|
expanded_queries: z.array(z.string()).optional(),
|
|
274
302
|
results: z.array(searchWebResultShape).optional(),
|
|
@@ -297,6 +325,7 @@ const searchWebShape = {
|
|
|
297
325
|
query_expansion: z.record(z.unknown()).nullable().optional(),
|
|
298
326
|
localization_applied: z.boolean().optional()
|
|
299
327
|
}).passthrough().optional(),
|
|
328
|
+
redaction: redactionShape,
|
|
300
329
|
_cost: costShape
|
|
301
330
|
};
|
|
302
331
|
|
|
@@ -375,6 +404,7 @@ const crawlDeepShape = {
|
|
|
375
404
|
}).passthrough().optional(),
|
|
376
405
|
crawled_at: z.string().optional().describe('When the pages were actually fetched (ISO 8601)'),
|
|
377
406
|
cached: z.boolean().optional().describe('True when this response was replayed from an earlier crawl rather than crawled now; crawled_at gives its age'),
|
|
407
|
+
redaction: redactionShape,
|
|
378
408
|
...resultHandleShape,
|
|
379
409
|
_cost: costShape
|
|
380
410
|
};
|
|
@@ -42,26 +42,51 @@ export const FALLBACK_HINTS = Object.freeze({
|
|
|
42
42
|
scrape_template: 'Use template:"list" to see valid template ids, template:"auto" to pick from the URL, or scrape for a site without a template.'
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The second-stage hint (Phase 3, 3.4). A `scrape` that came back
|
|
47
|
+
* `escalated: true` has ALREADY run the stealth browser on this URL, so the
|
|
48
|
+
* ordinary hint would send the model back to a tool that has just failed.
|
|
49
|
+
* What is left is a region-specific block, which `localization` can change,
|
|
50
|
+
* or a block that is final: an Akamai-style TLS-level wall is beaten with
|
|
51
|
+
* residential proxies, which CrawlForge does not offer.
|
|
52
|
+
*/
|
|
53
|
+
export const SCRAPE_ESCALATED_HINT =
|
|
54
|
+
'The stealth browser has already run on this URL (escalate:true) - do not call stealth_mode or repeat this call. ' +
|
|
55
|
+
'If the block is regional, set a country with localization and try once more; otherwise the block is final - ' +
|
|
56
|
+
'a TLS-level wall needs residential proxies, which CrawlForge does not offer. Get the content from another source.';
|
|
57
|
+
|
|
58
|
+
/** The hint for this result: the tool's, unless a scrape already escalated. */
|
|
59
|
+
function hintFor(toolName, parsed) {
|
|
60
|
+
if (toolName === 'scrape' && parsed?.escalated === true) return SCRAPE_ESCALATED_HINT;
|
|
61
|
+
return FALLBACK_HINTS[toolName];
|
|
62
|
+
}
|
|
63
|
+
|
|
45
64
|
/**
|
|
46
65
|
* Append the hint for `toolName` to an error result in place. No-op for
|
|
47
66
|
* success results, unknown tools, non-text content, or a hint already present.
|
|
48
67
|
*/
|
|
49
68
|
export function appendFallbackHint(toolName, result) {
|
|
50
|
-
|
|
51
|
-
if (!hint || result?.isError !== true || !Array.isArray(result.content)) return result;
|
|
69
|
+
if (result?.isError !== true || !Array.isArray(result.content)) return result;
|
|
52
70
|
const first = result.content[0];
|
|
53
71
|
if (first?.type !== 'text' || typeof first.text !== 'string') return result;
|
|
54
|
-
|
|
72
|
+
|
|
73
|
+
// Parsed first: which hint a `scrape` failure gets depends on whether the
|
|
74
|
+
// body says the escalation already ran.
|
|
75
|
+
let parsed = null;
|
|
55
76
|
try {
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
58
|
-
parsed.next_step = hint;
|
|
59
|
-
first.text = JSON.stringify(parsed, null, 2);
|
|
60
|
-
return result;
|
|
61
|
-
}
|
|
77
|
+
const candidate = JSON.parse(first.text);
|
|
78
|
+
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) parsed = candidate;
|
|
62
79
|
} catch {
|
|
63
80
|
// plain text
|
|
64
81
|
}
|
|
82
|
+
|
|
83
|
+
const hint = hintFor(toolName, parsed);
|
|
84
|
+
if (!hint || first.text.includes(hint)) return result;
|
|
85
|
+
if (parsed) {
|
|
86
|
+
parsed.next_step = hint;
|
|
87
|
+
first.text = JSON.stringify(parsed, null, 2);
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
65
90
|
first.text = `${first.text}\nNext step: ${hint}`;
|
|
66
91
|
return result;
|
|
67
92
|
}
|
|
@@ -135,6 +135,11 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
|
|
|
135
135
|
shaped[key] = value;
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
|
+
// redact_pii's report is an object, so the scalar filter above drops it —
|
|
139
|
+
// and the caller was charged for it. Carry it through like warnings.
|
|
140
|
+
if (resultObject.redaction && typeof resultObject.redaction === 'object') {
|
|
141
|
+
shaped.redaction = resultObject.redaction;
|
|
142
|
+
}
|
|
138
143
|
Object.assign(shaped, {
|
|
139
144
|
preview,
|
|
140
145
|
result_handle: handle,
|