crawlforge-mcp-server 6.14.0 → 6.17.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 +1 -1
- package/package.json +3 -3
- package/server.js +74 -22
- package/src/cli/commands/stealth.js +2 -2
- package/src/core/ActionExecutor.js +219 -39
- package/src/core/AuthManager.js +12 -0
- package/src/core/LLMsTxtAnalyzer.js +1 -0
- package/src/core/ResearchOrchestrator.js +10 -4
- package/src/core/StealthBrowserManager.js +117 -13
- package/src/core/browser/actionQueue.js +4 -1
- package/src/core/browser/windowState.js +88 -0
- package/src/core/crawlers/BFSCrawler.js +12 -3
- package/src/core/processing/BrowserProcessor.js +27 -8
- package/src/core/processing/PDFProcessor.js +15 -6
- package/src/server/fallbackHints.js +7 -4
- package/src/server/inlineThreshold.js +81 -19
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +25 -6
- package/src/tools/advanced/BrowserSessionTool.js +38 -13
- package/src/tools/advanced/ScrapeWithActionsTool.js +40 -4
- package/src/tools/advanced/batchScrape/worker.js +3 -1
- package/src/tools/basic/_fetch.js +3 -1
- package/src/tools/crawl/_sessionContext.js +2 -1
- package/src/tools/crawl/mapSite.js +2 -1
- package/src/tools/extract/_fetchAndParse.js +1 -0
- package/src/tools/extract/extractContent.js +2 -1
- package/src/tools/extract/extractEmbeddedState.js +320 -71
- package/src/tools/extract/processDocument.js +5 -3
- package/src/tools/llmstxt/generateLLMsTxt.js +2 -1
- package/src/tools/templates/ScrapeTemplateTool.js +11 -3
- package/src/tools/tracking/trackChanges/differ.js +1 -0
- package/src/utils/complianceAudit.js +35 -5
- package/src/utils/impitRung.js +16 -5
- package/src/utils/resignedFetch.js +165 -0
- package/src/utils/robotsChecker.js +6 -1
- package/src/utils/robotsGate.js +124 -11
- package/src/utils/secretMask.js +1 -1
- package/src/utils/sitemapParser.js +2 -1
- package/src/utils/ssrfGuard.js +88 -11
package/README.md
CHANGED
|
@@ -187,7 +187,7 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
|
|
|
187
187
|
| `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 |
|
|
188
188
|
| `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) |
|
|
189
189
|
| `scrape_structured` | 2 | Extract structured data with CSS selectors |
|
|
190
|
-
| `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 |
|
|
190
|
+
| `extract_embedded_state` | 2 (7 projected with `escalate: true`, charged 2 when the plain fetch works) | Read a page's embedded JavaScript state — `__NEXT_DATA__`, React Server Component payloads (references between rows resolved, plus a `data_rows` index), Nuxt (Nuxt 3's `__NUXT_DATA__` decoded), SvelteKit, Apollo, Redux, `ytInitialData`, Inertia, Shopify, JSON `data-*` attributes, `<script type="application/json">` — with a `path` to scope the result, `keys_only: true` to see its first two levels of keys, or `find: "<key>"` to get every path where a field of that name lives. `escalate: true` re-reads a walled page in the stealth browser, runs the same parser and also reads the framework globals off `window` (`window_state`). No LLM in the extraction path |
|
|
191
191
|
| `extract_content` | 2 | Enhanced content extraction |
|
|
192
192
|
| `map_site` | 2 | Discover and map website structure (optional `search=` ranks the discovered URLs) |
|
|
193
193
|
| `process_document` | 2 | Multi-format document processing |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.17.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",
|
|
@@ -118,17 +118,17 @@
|
|
|
118
118
|
"@modelcontextprotocol/node": "^2.0.0",
|
|
119
119
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
120
120
|
"@mozilla/readability": "^0.6.0",
|
|
121
|
-
"mammoth": "^1.12.2",
|
|
122
121
|
"cheerio": "^1.1.2",
|
|
123
122
|
"commander": "^14.0.3",
|
|
124
123
|
"compromise": "^14.14.4",
|
|
125
|
-
"crawlforge-extractors": "^1.
|
|
124
|
+
"crawlforge-extractors": "^1.12.0",
|
|
126
125
|
"diff": "^9.0.0",
|
|
127
126
|
"dotenv": "^17.2.1",
|
|
128
127
|
"franc": "^6.2.0",
|
|
129
128
|
"isomorphic-dompurify": "^3.9.0",
|
|
130
129
|
"jsdom": "^29.0.2",
|
|
131
130
|
"lru-cache": "^11.1.0",
|
|
131
|
+
"mammoth": "^1.12.2",
|
|
132
132
|
"p-queue": "^9.3.3",
|
|
133
133
|
"pdf-parse": "2.4.5",
|
|
134
134
|
"playwright": "^1.54.2",
|
package/server.js
CHANGED
|
@@ -29,14 +29,15 @@ import { GenerateLLMsTxtTool } from "./src/tools/llmstxt/generateLLMsTxt.js";
|
|
|
29
29
|
import { ScrapeTemplateTool } from "./src/tools/templates/ScrapeTemplateTool.js"; // D3.3
|
|
30
30
|
import { UnifiedScrapeTool, SCRAPE_INPUT_SHAPE } from "./src/tools/scrape/unifiedScrape.js"; // D4 D1
|
|
31
31
|
import { AgentTool } from "./src/tools/agent/agent.js"; // D4 D2
|
|
32
|
-
import { StealthBrowserManager, resolveStealthEngine } from "./src/core/StealthBrowserManager.js";
|
|
32
|
+
import { StealthBrowserManager, resolveStealthEngine, assertProxyEngineAllowed } from "./src/core/StealthBrowserManager.js";
|
|
33
33
|
import { LocalizationManager } from "./src/core/LocalizationManager.js";
|
|
34
34
|
// Stealth scrape: format conversion + the pre-fetch compliance gate (G5/G6/G7)
|
|
35
35
|
import * as cheerio from "cheerio";
|
|
36
36
|
import { htmlToMarkdown } from "./src/utils/htmlToMarkdown.js";
|
|
37
37
|
import { detectChallengePage } from "./src/utils/challengeDetection.js";
|
|
38
38
|
import { stealthDocumentVerdict } from "./src/utils/stealthVerdict.js";
|
|
39
|
-
import { browserPreflight } from "./src/utils/robotsGate.js";
|
|
39
|
+
import { browserPreflight, redirectGate } from "./src/utils/robotsGate.js";
|
|
40
|
+
import { safeGoto } from "./src/utils/ssrfGuard.js";
|
|
40
41
|
import { memoryMonitor } from "./src/utils/MemoryMonitor.js";
|
|
41
42
|
import { config, validateConfig, getToolConfig } from "./src/constants/config.js";
|
|
42
43
|
import AuthManager from "./src/core/AuthManager.js";
|
|
@@ -55,12 +56,12 @@ import { extractTextHandler } from "./src/tools/basic/extractText.js";
|
|
|
55
56
|
import { extractLinksHandler } from "./src/tools/basic/extractLinks.js";
|
|
56
57
|
import { extractMetadataHandler } from "./src/tools/basic/extractMetadata.js";
|
|
57
58
|
import { scrapeStructuredHandler } from "./src/tools/basic/scrapeStructured.js";
|
|
58
|
-
import {
|
|
59
|
+
import { createExtractEmbeddedStateHandler, EMBEDDED_STATE_INPUT_SHAPE } from "./src/tools/extract/extractEmbeddedState.js";
|
|
59
60
|
import { READ_RESULT_INPUT_SHAPE, readResultHandler } from "./src/tools/result/readResult.js"; // Phase 2
|
|
60
61
|
import { MAX_INLINE_CHARS_PARAM } from "./src/server/inlineThreshold.js"; // Phase 2
|
|
61
62
|
import { REDACT_PII_PARAM } from "./src/server/redaction.js"; // Phase 5 (5.3)
|
|
62
63
|
import { SEARCH_QUERIES_PARAM, EXACTLY_ONE_QUERY_MESSAGE } from "./src/tools/search/batchSearch.js"; // Phase 5 (5.1)
|
|
63
|
-
import { markPreflightRefusal, internalOwnerToken } from "./src/server/requestContext.js";
|
|
64
|
+
import { markPreflightRefusal, preflightRefusal, internalOwnerToken } from "./src/server/requestContext.js";
|
|
64
65
|
import { recordStealthEscalation } from "./src/utils/complianceAudit.js";
|
|
65
66
|
import { loadImpit, impitEnabled, impitFetchPage, IMPIT_ENGINE } from "./src/utils/impitRung.js"; // stealth review Phase 7
|
|
66
67
|
// D1.1 Resources + D1.2 Prompts + D1.4 Elicitation
|
|
@@ -111,7 +112,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
111
112
|
// Create the server
|
|
112
113
|
const server = new McpServer({
|
|
113
114
|
name: "crawlforge",
|
|
114
|
-
version: "6.
|
|
115
|
+
version: "6.17.1",
|
|
115
116
|
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.",
|
|
116
117
|
homepage: "https://www.crawlforge.dev",
|
|
117
118
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -246,16 +247,18 @@ const deepResearchTool = new DeepResearchTool();
|
|
|
246
247
|
const trackChangesTool = new TrackChangesTool();
|
|
247
248
|
const generateLLMsTxtTool = new GenerateLLMsTxtTool();
|
|
248
249
|
const scrapeTemplateTool = new ScrapeTemplateTool(); // D3.3
|
|
249
|
-
// Phase 3: the escalation stage for `scrape`'s escalate:true
|
|
250
|
-
// automatic stealth retry (stealth review Phase 3)
|
|
251
|
-
//
|
|
250
|
+
// Phase 3: the escalation stage for `scrape`'s escalate:true, the agent's
|
|
251
|
+
// automatic stealth retry (stealth review Phase 3) and extract_embedded_state's
|
|
252
|
+
// escalate:true (plan Phase 3.2) — one function, so all three go through the
|
|
253
|
+
// same gate, engine resolver and server-level proxy list.
|
|
252
254
|
// Injected so the tool modules never import StealthBrowserManager (that would pull a
|
|
253
255
|
// browser dependency into every unit test that loads `scrape`). Same gate
|
|
254
256
|
// and same browser the stealth_mode tool drives, plus the impit TLS try, and the
|
|
255
257
|
// engine name is resolved here, beside its sibling, by the one resolver
|
|
256
258
|
// every stealth entry point shares.
|
|
257
|
-
const stealthEscalation = async ({ url, engine, respectRobots, tool }) => {
|
|
259
|
+
const stealthEscalation = async ({ url, engine, respectRobots, tool, waitFor = 0, readWindowState = false }) => {
|
|
258
260
|
const warnings = await stealthComplianceGate(url, respectRobots);
|
|
261
|
+
const onRedirect = stealthRedirectGate(url, respectRobots);
|
|
259
262
|
const auditIdentity = { apiKey: AuthManager.getConfig()?.apiKey, ownerToken: internalOwnerToken() };
|
|
260
263
|
// Stealth review Phase 7: under "auto", a Chrome TLS handshake with the
|
|
261
264
|
// honest User-Agent is tried before any browser launches. A caller who
|
|
@@ -263,7 +266,7 @@ const stealthEscalation = async ({ url, engine, respectRobots, tool }) => {
|
|
|
263
266
|
// CRAWLFORGE_IMPIT=off skips it for a deployment whose exit IP it cannot help.
|
|
264
267
|
if (engine === 'auto' && impitEnabled() && await loadImpit()) {
|
|
265
268
|
recordStealthEscalation({ url, tool, engine: IMPIT_ENGINE, ...auditIdentity });
|
|
266
|
-
const page = await impitFetchPage(url);
|
|
269
|
+
const page = await impitFetchPage(url, { onRedirect });
|
|
267
270
|
if (page) return { ...page, warnings };
|
|
268
271
|
}
|
|
269
272
|
// "auto" prefers camoufox and falls back to chromium when its binary is
|
|
@@ -275,9 +278,14 @@ const stealthEscalation = async ({ url, engine, respectRobots, tool }) => {
|
|
|
275
278
|
// Audit row: the request is about to go out under a browser identity. After
|
|
276
279
|
// the gate (a refusal presents none) and the resolver (the engine that runs).
|
|
277
280
|
recordStealthEscalation({ url, tool, engine: resolved.engine, ...auditIdentity });
|
|
281
|
+
// waitFor and readWindowState come from extract_embedded_state only; scrape
|
|
282
|
+
// and agent pass neither, so their stage is unchanged.
|
|
278
283
|
const scraped = await stealthBrowserManager.scrapeWithStealth({
|
|
279
284
|
url,
|
|
280
|
-
engine: resolved.engine
|
|
285
|
+
engine: resolved.engine,
|
|
286
|
+
wait_for: waitFor,
|
|
287
|
+
readWindowState,
|
|
288
|
+
onRedirect
|
|
281
289
|
});
|
|
282
290
|
// The RESOLVED engine: `scrape` reports it as stealth.engine, where the
|
|
283
291
|
// requested "auto" would tell the caller nothing about what ran.
|
|
@@ -404,6 +412,18 @@ const VERIFY_NUMBERS_PARAM = {
|
|
|
404
412
|
};
|
|
405
413
|
|
|
406
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Whether a browser tool's result is a chain the compliance gate refused.
|
|
417
|
+
*
|
|
418
|
+
* scrape_with_actions and browser_session report a failed chain as
|
|
419
|
+
* `{ success: false, error }` rather than by throwing, so the actions that did
|
|
420
|
+
* run are still in the result. withAuth waives the charge for a refusal only
|
|
421
|
+
* on an error result, so without `isError` a caller paid in full for a chain
|
|
422
|
+
* we refused to finish: a load redirected into a disallowed path, or a click
|
|
423
|
+
* that landed on one.
|
|
424
|
+
*/
|
|
425
|
+
const refusedByGate = (result) => result?.success === false && preflightRefusal() !== null;
|
|
426
|
+
|
|
407
427
|
// Tool: fetch_url
|
|
408
428
|
registerToolIfEnabled("fetch_url", {
|
|
409
429
|
description: "Use this for a raw HTTP body - JSON, XML, plain text, an API response - or for the status code, headers and response time. Returns the body unprocessed. Not for HTML you intend to read: scrape returns markdown from one fetch, so fetch_url followed by extract_* is a double fetch. Not for JS-rendered or bot-protected pages (scrape, then stealth_mode). Supports custom headers (e.g. auth tokens) and a timeout. Cost: 1 credit. Example: fetch_url({url: \"https://api.example.com/v1/items\", timeout: 15000})",
|
|
@@ -456,15 +476,16 @@ registerToolIfEnabled("extract_metadata", {
|
|
|
456
476
|
|
|
457
477
|
// Tool: extract_embedded_state
|
|
458
478
|
registerToolIfEnabled("extract_embedded_state", {
|
|
459
|
-
description: "Use this when a page's data lives in its embedded JavaScript state rather than its rendered HTML - Next.js (__NEXT_DATA__ and React Server Component payloads), Nuxt, Apollo, Redux (__INITIAL_STATE__, __PRELOADED_STATE__), and <script type=\"application/json\"> blocks. One fetch, exact values, no LLM in the extraction path, so nothing can be fabricated. Payloads are routinely over a megabyte - pass `path` to return one subtree
|
|
479
|
+
description: "Use this when a page's data lives in its embedded JavaScript state rather than its rendered HTML - Next.js (__NEXT_DATA__, and React Server Component payloads with references between rows resolved plus a data_rows index of the rows carrying data), Nuxt (Nuxt 3's __NUXT_DATA__ decoded), SvelteKit, Apollo, Redux (__INITIAL_STATE__, __PRELOADED_STATE__), ytInitialData and ytInitialPlayerResponse, Inertia, Shopify, JSON data-* attributes and <script type=\"application/json\"> blocks. One fetch, exact values, no LLM in the extraction path, so nothing can be fabricated. Payloads are routinely over a megabyte - pass `path` to return one subtree, keys_only:true to see the first two levels of keys before choosing one, or find:\"<key>\" to get every path where a field of that name lives, each ready to pass back as `path`. A result over max_inline_chars comes back as a preview (whole lines of the JSON), data_keys and a result_handle; read the rest with read_result json_path, e.g. \"data.next_data.props\". 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, run the same parser and also read the globals off window (window_state: __NEXT_DATA__, __NUXT__, ytInitialData and others, read after JavaScript ran) - projected at 7, charged 2 when the plain fetch worked. Not for the rendered text of a page (scrape) or for sites built without a framework payload. Cost: 2 credits. Example: extract_embedded_state({url: \"https://www.ticketmaster.com/discover/concerts\", path: \"next_data.props.pageProps\"})",
|
|
460
480
|
annotations: { title: "Extract Embedded State", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
461
481
|
inputSchema: {
|
|
462
|
-
|
|
463
|
-
path: z.string().optional().describe("Return only this subtree instead of the whole payload. Dotted keys and array indexes, e.g. \"next_data.props.pageProps\" or \"next_f[0].f\" — not JSONPath (no wildcards, filters or recursion). State payloads are routinely over a megabyte; scope them."),
|
|
482
|
+
...EMBEDDED_STATE_INPUT_SHAPE,
|
|
464
483
|
...COMPLIANCE_PARAMS,
|
|
465
484
|
...MAX_INLINE_CHARS_PARAM
|
|
466
485
|
}
|
|
467
|
-
}, withAuth("extract_embedded_state",
|
|
486
|
+
}, withAuth("extract_embedded_state", createExtractEmbeddedStateHandler({
|
|
487
|
+
escalateFetch: (args) => stealthEscalation({ ...args, tool: 'extract_embedded_state' })
|
|
488
|
+
})));
|
|
468
489
|
|
|
469
490
|
// Tool: scrape_structured
|
|
470
491
|
registerToolIfEnabled("scrape_structured", {
|
|
@@ -941,7 +962,7 @@ registerToolIfEnabled("read_result", {
|
|
|
941
962
|
|
|
942
963
|
// Tool: scrape_with_actions
|
|
943
964
|
registerToolIfEnabled("scrape_with_actions", {
|
|
944
|
-
description: "Use this when you must interact with a page before scraping - login, click buttons, fill forms, scroll, or wait for dynamic content to load - for SPAs, login-gated content, or multi-step flows. Actions: snapshot, wait, click, type, press, scroll, screenshot, executeJavaScript (disabled unless the server runs with ALLOW_JAVASCRIPT_EXECUTION=true; refused on the hosted API), select (dropdowns), hover, navigate. Start a chain with {type:\"snapshot\"} to list the page's interactive elements, including those in open shadow roots and iframes, with stable refs (@e1, @e2 ... in document order), then target those refs in later actions instead of guessing CSS selectors; navigation invalidates refs, so snapshot again after one. Set browserOptions.consent:\"reject\" (or \"accept\") to answer a cookie/consent banner before the first action; it is off by default. Set browserOptions.stealth:true to run the chain in the stealth browser, and browserOptions.engine to pick its engine (\"auto\" by default - camoufox when it is installed, Chromium otherwise, and the result says which ran). robots.txt is respected on every navigation. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Not for pages that render without interaction (scrape) and not as the first attempt on a blocked site (stealth_mode operation:\"scrape\"). Cost: 5 credits. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"snapshot\"},{type:\"type\",selector:\"@e2\",text:\"user@a.com\"},{type:\"click\",selector:\"@e4\"}]})",
|
|
965
|
+
description: "Use this when you must interact with a page before scraping - login, click buttons, fill forms, scroll, or wait for dynamic content to load - for SPAs, login-gated content, or multi-step flows. Actions: snapshot, wait, click, type, press, scroll, screenshot, executeJavaScript (disabled unless the server runs with ALLOW_JAVASCRIPT_EXECUTION=true; refused on the hosted API), select (dropdowns), hover, navigate. Start a chain with {type:\"snapshot\"} to list the page's interactive elements, including those in open shadow roots and iframes, with stable refs (@e1, @e2 ... in document order), then target those refs in later actions instead of guessing CSS selectors; navigation invalidates refs, so snapshot again after one. Set browserOptions.consent:\"reject\" (or \"accept\") to answer a cookie/consent banner before the first action; it is off by default. Set browserOptions.stealth:true to run the chain in the stealth browser, and browserOptions.engine to pick its engine (\"auto\" by default - camoufox when it is installed, Chromium otherwise, and the result says which ran). robots.txt is respected on every navigation, and each navigation is checked: a bot wall is reported as `blocked` with the vendor named, per navigation in `navigations` and for the final page at the top level. browserOptions.proxyRotation routes a stealth chain through your own proxies. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Not for pages that render without interaction (scrape) and not as the first attempt on a blocked site (stealth_mode operation:\"scrape\"). Cost: 5 credits. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"snapshot\"},{type:\"type\",selector:\"@e2\",text:\"user@a.com\"},{type:\"click\",selector:\"@e4\"}]})",
|
|
945
966
|
annotations: { title: "Scrape with Browser Actions", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
946
967
|
inputSchema: {
|
|
947
968
|
url: z.string().url().describe("The URL to scrape"),
|
|
@@ -1015,7 +1036,12 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
1015
1036
|
timeout: z.number().min(10000).max(120000).default(30000),
|
|
1016
1037
|
stealth: z.boolean().default(false).describe("Run the action chain in the stealth browser (randomized fingerprint, WebRTC/canvas spoofing) instead of the standard browser pool. Renders JavaScript; it does not solve challenges."),
|
|
1017
1038
|
engine: z.enum(["auto", "chromium", "camoufox", "playwright"]).default("auto").describe("Stealth engine for the chain, with stealth:true. \"auto\" (default) runs camoufox when it is installed and Chromium otherwise; the result's `engine` says which ran. \"camoufox\" is Firefox-based with a higher anti-detect score; \"chromium\" (= \"playwright\") forces Chromium. Refused without stealth:true, where the browser is always Chromium."),
|
|
1018
|
-
consent: z.enum(["off", "reject", "accept"]).default("off").describe("Cookie/consent banner handling, using DuckDuckGo autoconsent's rules for known consent platforms (OneTrust, Sourcepoint, Didomi ...). \"reject\" declines non-essential cookies and \"accept\" accepts them, once after the initial page load and again after each navigate action, for at most 2s each; the result's `consent` ({cmp, action, ms}) and each navigate result say what was found and done. A banner no rule matches is left in place (action \"none\") and never fails the chain. Default \"off\": the page is left as it loads, so a chain that clicks the banner itself works unchanged.")
|
|
1039
|
+
consent: z.enum(["off", "reject", "accept"]).default("off").describe("Cookie/consent banner handling, using DuckDuckGo autoconsent's rules for known consent platforms (OneTrust, Sourcepoint, Didomi ...). \"reject\" declines non-essential cookies and \"accept\" accepts them, once after the initial page load and again after each navigate action, for at most 2s each; the result's `consent` ({cmp, action, ms}) and each navigate result say what was found and done. A banner no rule matches is left in place (action \"none\") and never fails the chain. Default \"off\": the page is left as it loads, so a chain that clicks the banner itself works unchanged."),
|
|
1040
|
+
proxyRotation: z.object({
|
|
1041
|
+
enabled: z.boolean().default(false),
|
|
1042
|
+
proxies: z.array(z.string()).optional(),
|
|
1043
|
+
rotationInterval: z.number().default(300000)
|
|
1044
|
+
}).optional().describe("Route a stealth chain through your own proxies; requires stealth:true and the Chromium engine, and is refused without stealth or on camoufox (including \"auto\" when it resolves to camoufox), which shares one launch-time proxy across calls. Each entry is a proxy URL — \"http://user:pass@host:port\" (percent-encode a password containing @ : or /), or a bare \"host:port\" for an unauthenticated HTTP proxy; http, https, socks4 and socks5 are accepted. rotationInterval is the minimum ms on one proxy before the list advances. A proxy on a loopback, link-local or cloud-metadata address is refused, and credentials are removed from the result. Cloudflare scores the IP before it serves a challenge, so a residential proxy is what gets past a block that no fingerprint fixes. CrawlForge supplies no proxies.")
|
|
1019
1045
|
}).optional().describe("Browser configuration options"),
|
|
1020
1046
|
extractionOptions: z.object({
|
|
1021
1047
|
selectors: z.record(z.string()).optional(),
|
|
@@ -1044,7 +1070,10 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
1044
1070
|
// array (Phase 0, 0.4).
|
|
1045
1071
|
stripScreenshotData(result, (actionId, data) => resourceRegistry.storeScreenshot(actionId, data));
|
|
1046
1072
|
|
|
1047
|
-
return {
|
|
1073
|
+
return {
|
|
1074
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
1075
|
+
...(refusedByGate(result) ? { isError: true } : {})
|
|
1076
|
+
};
|
|
1048
1077
|
} catch (error) {
|
|
1049
1078
|
return { content: [{ type: "text", text: `Scrape with actions failed: ${error.message}` }], isError: true };
|
|
1050
1079
|
}
|
|
@@ -1110,7 +1139,10 @@ registerToolIfEnabled("browser_session", {
|
|
|
1110
1139
|
if (result.screenshot) result.screenshot = publish(result.screenshot);
|
|
1111
1140
|
if (Array.isArray(result.screenshots)) result.screenshots = result.screenshots.map(publish);
|
|
1112
1141
|
|
|
1113
|
-
return {
|
|
1142
|
+
return {
|
|
1143
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
1144
|
+
...(refusedByGate(result) ? { isError: true } : {})
|
|
1145
|
+
};
|
|
1114
1146
|
} catch (error) {
|
|
1115
1147
|
return { content: [{ type: "text", text: `Browser session failed: ${error.message}` }], isError: true };
|
|
1116
1148
|
}
|
|
@@ -1317,6 +1349,17 @@ async function stealthComplianceGate(url, respectRobots) {
|
|
|
1317
1349
|
return browserPreflight(url, { respectRobots, tool: 'stealth_mode' });
|
|
1318
1350
|
}
|
|
1319
1351
|
|
|
1352
|
+
/**
|
|
1353
|
+
* The same gate for wherever a navigation to `url` is redirected to. The
|
|
1354
|
+
* pre-flight above decides about the URL the caller named; a redirect can lead
|
|
1355
|
+
* anywhere, so each stealth entry point hands this to its navigation.
|
|
1356
|
+
*
|
|
1357
|
+
* @returns {(to: string) => Promise<void>} refuses a hop by throwing
|
|
1358
|
+
*/
|
|
1359
|
+
function stealthRedirectGate(url, respectRobots) {
|
|
1360
|
+
return redirectGate(url, { respectRobots, tool: 'stealth_mode' });
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1320
1363
|
/**
|
|
1321
1364
|
* Build the requested formats from one stealth render. The browser already
|
|
1322
1365
|
* returned the rendered HTML and visible text, so nothing here refetches;
|
|
@@ -1388,7 +1431,7 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1388
1431
|
enabled: z.boolean().default(false),
|
|
1389
1432
|
proxies: z.array(z.string()).optional(),
|
|
1390
1433
|
rotationInterval: z.number().default(300000)
|
|
1391
|
-
}).optional().describe("Route the browser through your own proxies. Each entry is a proxy URL — \"http://user:pass@host:port\" (percent-encode a password containing @ : or /), or a bare \"host:port\" for an unauthenticated HTTP proxy; http, https, socks4 and socks5 are accepted. rotationInterval is the minimum ms on one proxy before the list advances. Cloudflare scores the IP before it serves a challenge, so a residential proxy is what gets past a block that no fingerprint fixes. CrawlForge supplies no proxies."),
|
|
1434
|
+
}).optional().describe("Route the browser through your own proxies. Each entry is a proxy URL — \"http://user:pass@host:port\" (percent-encode a password containing @ : or /), or a bare \"host:port\" for an unauthenticated HTTP proxy; http, https, socks4 and socks5 are accepted. rotationInterval is the minimum ms on one proxy before the list advances. Cloudflare scores the IP before it serves a challenge, so a residential proxy is what gets past a block that no fingerprint fixes. Requires the Chromium engine: refused on camoufox (including \"auto\" when it resolves to camoufox), which shares one launch-time proxy across calls. CrawlForge supplies no proxies."),
|
|
1392
1435
|
antiDetection: z.object({
|
|
1393
1436
|
cloudflareBypass: z.boolean().default(true),
|
|
1394
1437
|
recaptchaHandling: z.boolean().default(true),
|
|
@@ -1428,6 +1471,7 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1428
1471
|
// is missing; "playwright" is this tool's old public name for chromium.
|
|
1429
1472
|
// The resolver owns both, and reports a fallback the caller can see.
|
|
1430
1473
|
const resolvedEngine = await resolveStealthEngine(engine);
|
|
1474
|
+
assertProxyEngineAllowed(resolvedEngine.engine, stealthConfig);
|
|
1431
1475
|
if (resolvedEngine.fallbackWarning) warnings.push(resolvedEngine.fallbackWarning);
|
|
1432
1476
|
|
|
1433
1477
|
recordStealthEscalation({
|
|
@@ -1441,7 +1485,8 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1441
1485
|
engine: resolvedEngine.engine,
|
|
1442
1486
|
wait_for: wait_for || 0,
|
|
1443
1487
|
screenshot: wantsScreenshot,
|
|
1444
|
-
stealthConfig
|
|
1488
|
+
stealthConfig,
|
|
1489
|
+
onRedirect: stealthRedirectGate(url, respect_robots)
|
|
1445
1490
|
});
|
|
1446
1491
|
|
|
1447
1492
|
// A bot-wall interstitial arrives as HTTP 200 with a title and prose
|
|
@@ -1494,6 +1539,7 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1494
1539
|
// without it, create_context always ran on chromium whatever the caller
|
|
1495
1540
|
// asked for. Resolved, so "auto" reaches the manager as a real engine.
|
|
1496
1541
|
const contextEngine = await resolveStealthEngine(engine);
|
|
1542
|
+
assertProxyEngineAllowed(contextEngine.engine, stealthConfig);
|
|
1497
1543
|
const contextData = await stealthBrowserManager.createStealthContext({
|
|
1498
1544
|
...(stealthConfig || {}),
|
|
1499
1545
|
engine: contextEngine.engine
|
|
@@ -1532,7 +1578,13 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1532
1578
|
// JSON-serializable — extract just the useful navigation details.
|
|
1533
1579
|
// Explicit timeout keeps navigation inside every caller's window
|
|
1534
1580
|
// (Playwright's default is 30s, longer than some proxy budgets).
|
|
1535
|
-
|
|
1581
|
+
// safeGoto, not page.goto: the SSRF guard on the URL and on where
|
|
1582
|
+
// it lands, and the gate above again for every redirect on the way.
|
|
1583
|
+
const response = await safeGoto(page, urlToTest, {
|
|
1584
|
+
waitUntil: 'domcontentloaded',
|
|
1585
|
+
timeout: 20000,
|
|
1586
|
+
onRedirect: stealthRedirectGate(urlToTest, respect_robots)
|
|
1587
|
+
});
|
|
1536
1588
|
navigation = {
|
|
1537
1589
|
requestedUrl: urlToTest,
|
|
1538
1590
|
finalUrl: page.url(),
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* stealth command — scrape a URL using stealth mode.
|
|
3
3
|
*/
|
|
4
4
|
import { StealthBrowserManager } from '../../core/StealthBrowserManager.js';
|
|
5
|
-
import { browserPreflight } from '../../utils/robotsGate.js';
|
|
5
|
+
import { browserPreflight, redirectGate } from '../../utils/robotsGate.js';
|
|
6
6
|
import { getToolConfig } from '../../constants/config.js';
|
|
7
7
|
import { runTool } from '../lib/runTool.js';
|
|
8
8
|
|
|
@@ -22,7 +22,7 @@ export function register(program) {
|
|
|
22
22
|
// a different door, not by a different set of rules.
|
|
23
23
|
execute: async (p) => {
|
|
24
24
|
await browserPreflight(p.url, { tool: 'stealth_mode' });
|
|
25
|
-
return mgr.scrapeWithStealth(p);
|
|
25
|
+
return mgr.scrapeWithStealth({ ...p, onRedirect: redirectGate(p.url, { tool: 'stealth_mode' }) });
|
|
26
26
|
}
|
|
27
27
|
};
|
|
28
28
|
await runTool(wrapperTool, {
|