crawlforge-mcp-server 6.16.0 → 6.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +2 -2
- package/server.js +28 -12
- package/src/core/ActionExecutor.js +83 -2
- package/src/core/AuthManager.js +12 -0
- package/src/core/StealthBrowserManager.js +93 -10
- package/src/core/processing/BrowserProcessor.js +8 -1
- package/src/server/fallbackHints.js +4 -4
- package/src/server/inlineThreshold.js +76 -10
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +2 -2
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +4 -0
- package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +16 -2
- package/src/tools/advanced/ScrapeWithActionsTool.js +40 -4
- package/src/tools/basic/_targetFailure.js +54 -0
- package/src/tools/basic/extractLinks.js +62 -42
- package/src/tools/basic/extractText.js +79 -14
- package/src/tools/extract/_fetchAndParse.js +6 -30
- package/src/utils/fetchLadder.js +228 -0
- package/src/utils/secretMask.js +1 -1
package/README.md
CHANGED
|
@@ -178,8 +178,8 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
|
|
|
178
178
|
| Tool | Credits | What it does |
|
|
179
179
|
|------|---------|--------------|
|
|
180
180
|
| `fetch_url` | 1 | Fetch content from any URL |
|
|
181
|
-
| `extract_text` | 1 | Extract clean text from web pages |
|
|
182
|
-
| `extract_links` | 1 | Get all links from a page |
|
|
181
|
+
| `extract_text` | 1 (6 projected with `escalate: true`, charged 1 when the plain fetch works) | Extract clean text from web pages |
|
|
182
|
+
| `extract_links` | 1 (6 projected with `escalate: true`, charged 1 when the plain fetch works) | Get all links from a page |
|
|
183
183
|
| `extract_metadata` | 1 | Extract page metadata (title, OG tags, schema.org) |
|
|
184
184
|
| `scrape_template` | 1 | Structured data from well-known sites (Amazon, GitHub, LinkedIn, YouTube, Reddit, Hacker News, npm, and more) without writing selectors |
|
|
185
185
|
| `list_ollama_models` | 1 | List the Ollama models installed locally (helps you pick a `model` for `extract_with_llm`) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.18.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
|
|
6
6
|
"main": "server.js",
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
"cheerio": "^1.1.2",
|
|
122
122
|
"commander": "^14.0.3",
|
|
123
123
|
"compromise": "^14.14.4",
|
|
124
|
-
"crawlforge-extractors": "^1.
|
|
124
|
+
"crawlforge-extractors": "^1.13.0",
|
|
125
125
|
"diff": "^9.0.0",
|
|
126
126
|
"dotenv": "^17.2.1",
|
|
127
127
|
"franc": "^6.2.0",
|
package/server.js
CHANGED
|
@@ -29,7 +29,7 @@ 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";
|
|
@@ -52,8 +52,9 @@ import { createOAuthProvider } from "./src/server/auth/oauth.js";
|
|
|
52
52
|
import { createMetricsRegistry } from "./src/observability/metrics.js";
|
|
53
53
|
// Basic tool handlers (extracted from server.js)
|
|
54
54
|
import { fetchUrlHandler } from "./src/tools/basic/fetchUrl.js";
|
|
55
|
-
import {
|
|
56
|
-
import {
|
|
55
|
+
import { createExtractTextHandler } from "./src/tools/basic/extractText.js";
|
|
56
|
+
import { createExtractLinksHandler } from "./src/tools/basic/extractLinks.js";
|
|
57
|
+
import { EXTRACT_ESCALATION_SHAPE } from "./src/utils/fetchLadder.js";
|
|
57
58
|
import { extractMetadataHandler } from "./src/tools/basic/extractMetadata.js";
|
|
58
59
|
import { scrapeStructuredHandler } from "./src/tools/basic/scrapeStructured.js";
|
|
59
60
|
import { createExtractEmbeddedStateHandler, EMBEDDED_STATE_INPUT_SHAPE } from "./src/tools/extract/extractEmbeddedState.js";
|
|
@@ -112,7 +113,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
112
113
|
// Create the server
|
|
113
114
|
const server = new McpServer({
|
|
114
115
|
name: "crawlforge",
|
|
115
|
-
version: "6.
|
|
116
|
+
version: "6.18.0",
|
|
116
117
|
description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
117
118
|
homepage: "https://www.crawlforge.dev",
|
|
118
119
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -439,29 +440,37 @@ registerToolIfEnabled("fetch_url", {
|
|
|
439
440
|
|
|
440
441
|
// Tool: extract_text
|
|
441
442
|
registerToolIfEnabled("extract_text", {
|
|
442
|
-
description: "Use this for a page's plain text or markdown with tags, scripts and styles removed - the cheapest read of a static HTML page. Use output_format:\"markdown\" for RAG. Not for article pages (extract_content strips nav and boilerplate), JS-rendered pages (scrape), or when you also want links or metadata (scrape with several formats, one fetch). Cost: 1 credit. Example: extract_text({url: \"https://example.com/article\", output_format:\"markdown\"})",
|
|
443
|
+
description: "Use this for a page's plain text or markdown with tags, scripts and styles removed - the cheapest read of a static HTML page. Use output_format:\"markdown\" for RAG; selector reads only the matched elements, max_length caps the returned text. Not for article pages (extract_content strips nav and boilerplate), JS-rendered pages (scrape), or when you also want links or metadata (scrape with several formats, one fetch). A PDF or other binary is refused (process_document reads documents); an empty client-rendered shell succeeds with rendered:false and a warning. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page - projected at 6, charged 1 when the plain fetch worked. Cost: 1 credit. Example: extract_text({url: \"https://example.com/article\", output_format:\"markdown\"})",
|
|
443
444
|
annotations: { title: "Extract Text", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
444
445
|
inputSchema: {
|
|
445
446
|
url: z.string().url().describe("The URL to extract text from"),
|
|
446
447
|
remove_scripts: z.boolean().optional().default(true).describe("Remove script tags before extraction"),
|
|
447
448
|
remove_styles: z.boolean().optional().default(true).describe("Remove style tags before extraction"),
|
|
448
449
|
output_format: z.enum(["text", "markdown"]).optional().default("text").describe("Output format: \"text\" (default) or \"markdown\" — use markdown for RAG workflows"),
|
|
450
|
+
selector: z.string().optional().describe("CSS selector: read only the matched elements (nav/header/footer are then kept). No match is an error"),
|
|
451
|
+
max_length: z.number().int().min(1).max(1000000).optional().describe("Maximum characters of text or markdown to return; a longer result is cut and ends with \"...\""),
|
|
452
|
+
...EXTRACT_ESCALATION_SHAPE,
|
|
449
453
|
...COMPLIANCE_PARAMS,
|
|
450
454
|
...REDACT_PII_PARAM
|
|
451
455
|
}
|
|
452
|
-
}, withAuth("extract_text",
|
|
456
|
+
}, withAuth("extract_text", createExtractTextHandler({
|
|
457
|
+
escalateFetch: (args) => stealthEscalation({ ...args, tool: 'extract_text' })
|
|
458
|
+
})));
|
|
453
459
|
|
|
454
460
|
// Tool: extract_links
|
|
455
461
|
registerToolIfEnabled("extract_links", {
|
|
456
|
-
description: "Use this to list the hyperlinks on one page - a crawl seed list, a broken-link audit, related resources. filter_external:true
|
|
462
|
+
description: "Use this to list the hyperlinks on one page - a crawl seed list, a broken-link audit, related resources. filter_external:true drops internal links; mailto:, tel: and javascript: links are type \"other\". Not for a whole site (map_site), and not alongside a scrape of the same URL: scrape formats:[\"markdown\",\"links\"] returns both in one fetch. A PDF or other binary is refused (process_document reads documents); an empty client-rendered shell succeeds with rendered:false and a warning. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page - projected at 6, charged 1 when the plain fetch worked. Cost: 1 credit. Example: extract_links({url: \"https://example.com\", filter_external: true})",
|
|
457
463
|
annotations: { title: "Extract Links", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
458
464
|
inputSchema: {
|
|
459
465
|
url: z.string().url().describe("The URL to extract links from"),
|
|
460
|
-
filter_external: z.boolean().optional().default(false).describe("
|
|
466
|
+
filter_external: z.boolean().optional().default(false).describe("Drop internal (same-host) links"),
|
|
461
467
|
base_url: z.string().url().optional().describe("Base URL for resolving relative links"),
|
|
468
|
+
...EXTRACT_ESCALATION_SHAPE,
|
|
462
469
|
...COMPLIANCE_PARAMS
|
|
463
470
|
}
|
|
464
|
-
}, withAuth("extract_links",
|
|
471
|
+
}, withAuth("extract_links", createExtractLinksHandler({
|
|
472
|
+
escalateFetch: (args) => stealthEscalation({ ...args, tool: 'extract_links' })
|
|
473
|
+
})));
|
|
465
474
|
|
|
466
475
|
// Tool: extract_metadata
|
|
467
476
|
registerToolIfEnabled("extract_metadata", {
|
|
@@ -962,7 +971,7 @@ registerToolIfEnabled("read_result", {
|
|
|
962
971
|
|
|
963
972
|
// Tool: scrape_with_actions
|
|
964
973
|
registerToolIfEnabled("scrape_with_actions", {
|
|
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. 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\"}]})",
|
|
974
|
+
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\"}]})",
|
|
966
975
|
annotations: { title: "Scrape with Browser Actions", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
967
976
|
inputSchema: {
|
|
968
977
|
url: z.string().url().describe("The URL to scrape"),
|
|
@@ -1036,7 +1045,12 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
1036
1045
|
timeout: z.number().min(10000).max(120000).default(30000),
|
|
1037
1046
|
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."),
|
|
1038
1047
|
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."),
|
|
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.")
|
|
1048
|
+
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."),
|
|
1049
|
+
proxyRotation: z.object({
|
|
1050
|
+
enabled: z.boolean().default(false),
|
|
1051
|
+
proxies: z.array(z.string()).optional(),
|
|
1052
|
+
rotationInterval: z.number().default(300000)
|
|
1053
|
+
}).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.")
|
|
1040
1054
|
}).optional().describe("Browser configuration options"),
|
|
1041
1055
|
extractionOptions: z.object({
|
|
1042
1056
|
selectors: z.record(z.string()).optional(),
|
|
@@ -1426,7 +1440,7 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1426
1440
|
enabled: z.boolean().default(false),
|
|
1427
1441
|
proxies: z.array(z.string()).optional(),
|
|
1428
1442
|
rotationInterval: z.number().default(300000)
|
|
1429
|
-
}).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."),
|
|
1443
|
+
}).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."),
|
|
1430
1444
|
antiDetection: z.object({
|
|
1431
1445
|
cloudflareBypass: z.boolean().default(true),
|
|
1432
1446
|
recaptchaHandling: z.boolean().default(true),
|
|
@@ -1466,6 +1480,7 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1466
1480
|
// is missing; "playwright" is this tool's old public name for chromium.
|
|
1467
1481
|
// The resolver owns both, and reports a fallback the caller can see.
|
|
1468
1482
|
const resolvedEngine = await resolveStealthEngine(engine);
|
|
1483
|
+
assertProxyEngineAllowed(resolvedEngine.engine, stealthConfig);
|
|
1469
1484
|
if (resolvedEngine.fallbackWarning) warnings.push(resolvedEngine.fallbackWarning);
|
|
1470
1485
|
|
|
1471
1486
|
recordStealthEscalation({
|
|
@@ -1533,6 +1548,7 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1533
1548
|
// without it, create_context always ran on chromium whatever the caller
|
|
1534
1549
|
// asked for. Resolved, so "auto" reaches the manager as a real engine.
|
|
1535
1550
|
const contextEngine = await resolveStealthEngine(engine);
|
|
1551
|
+
assertProxyEngineAllowed(contextEngine.engine, stealthConfig);
|
|
1536
1552
|
const contextData = await stealthBrowserManager.createStealthContext({
|
|
1537
1553
|
...(stealthConfig || {}),
|
|
1538
1554
|
engine: contextEngine.engine
|
|
@@ -12,6 +12,7 @@ import { browserPreflight, redirectGate, pageMoveGate, gateRefusalCode } from '.
|
|
|
12
12
|
import { isRef, resolveRef, captureSnapshot } from './browser/snapshot.js';
|
|
13
13
|
import { settlePage } from './browser/settle.js';
|
|
14
14
|
import { handleConsent } from './browser/consent.js';
|
|
15
|
+
import { stealthDocumentVerdict } from '../utils/stealthVerdict.js';
|
|
15
16
|
|
|
16
17
|
// executeJavaScript hardening limits (only relevant when the deploy-time flag
|
|
17
18
|
// ALLOW_JAVASCRIPT_EXECUTION=true is set; JS execution stays off by default).
|
|
@@ -31,6 +32,13 @@ const ACTION_TIMEOUT_GRACE_MS = 2000;
|
|
|
31
32
|
// to work cost several times its stated timeout.
|
|
32
33
|
const RECOVERY_TIMEOUT_MS = 3000;
|
|
33
34
|
|
|
35
|
+
// How long a navigation whose document reads as a wall is watched for the wall
|
|
36
|
+
// replacing itself before it is reported, re-read every NAVIGATION_WALL_POLL_MS.
|
|
37
|
+
// AWS WAF's interstitial reloads into amazon.com's homepage about 0.4 s after
|
|
38
|
+
// domcontentloaded (measured 2026-10-03); nothing here acts on the page.
|
|
39
|
+
const NAVIGATION_WALL_GRACE_MS = 3000;
|
|
40
|
+
const NAVIGATION_WALL_POLL_MS = 500;
|
|
41
|
+
|
|
34
42
|
// The only states locator.waitFor()/page.waitForSelector() accept. The rest of
|
|
35
43
|
// the wait-action enum (enabled/disabled/stable) are ElementHandle states and
|
|
36
44
|
// have to go through waitForElementState instead — passing them here is
|
|
@@ -312,6 +320,10 @@ export class ActionExecutor extends EventEmitter {
|
|
|
312
320
|
executionContext.consent = await handleConsent(page, browserOptions.consent);
|
|
313
321
|
}
|
|
314
322
|
|
|
323
|
+
// navigations[0] is the initial load; each `navigate` action appends
|
|
324
|
+
// its own (executeNavigateAction).
|
|
325
|
+
executionContext.navigations = [await this.checkNavigation(page, url, browserOptions)];
|
|
326
|
+
|
|
315
327
|
// Execute chain with potential retries
|
|
316
328
|
chainResult = await this.executeChainWithRetries(executionContext);
|
|
317
329
|
|
|
@@ -419,6 +431,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
419
431
|
finalUrl: executionContext.finalUrl || url,
|
|
420
432
|
finalHtml: executionContext.finalHtml,
|
|
421
433
|
navigationStatus: executionContext.page?.__crawlforgeNavigation?.status ?? null,
|
|
434
|
+
navigations: executionContext.navigations || [],
|
|
422
435
|
consent: executionContext.consent,
|
|
423
436
|
executionTime: Date.now() - startTime,
|
|
424
437
|
results: executionContext.results,
|
|
@@ -446,6 +459,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
446
459
|
// results, the error screenshot, and any intermediate-state
|
|
447
460
|
// captures) instead of discarding them.
|
|
448
461
|
results: executionContext?.results || [],
|
|
462
|
+
navigations: executionContext?.navigations || [],
|
|
449
463
|
attempt: executionContext?.attempt ?? 0,
|
|
450
464
|
attempts: executionContext?.attempts || [],
|
|
451
465
|
screenshots: executionContext?.screenshots || [],
|
|
@@ -478,6 +492,11 @@ export class ActionExecutor extends EventEmitter {
|
|
|
478
492
|
await this.navigateToUrl(page, executionContext.url, {
|
|
479
493
|
browserOptions: executionContext.browserOptions
|
|
480
494
|
});
|
|
495
|
+
// Only the last attempt's navigations are reported, so the reload
|
|
496
|
+
// that starts this one is navigations[0].
|
|
497
|
+
executionContext.navigations = [
|
|
498
|
+
await this.checkNavigation(page, executionContext.url, executionContext.browserOptions)
|
|
499
|
+
];
|
|
481
500
|
}
|
|
482
501
|
|
|
483
502
|
// Execute actions in sequence
|
|
@@ -1346,14 +1365,73 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1346
1365
|
? await handleConsent(page, consentMode, { timeout: Math.min(2000, timeout - (Date.now() - startedAt)) })
|
|
1347
1366
|
: undefined;
|
|
1348
1367
|
|
|
1368
|
+
// Reporting only: the navigation happened, so the action succeeds whether
|
|
1369
|
+
// or not the document it reached is a wall. The wait for a wall to clear
|
|
1370
|
+
// gets what is left of the deadline, like consent above.
|
|
1371
|
+
const navigation = await this.checkNavigation(page, action.url, executionContext?.browserOptions, {
|
|
1372
|
+
graceMs: Math.min(NAVIGATION_WALL_GRACE_MS, timeout - (Date.now() - startedAt))
|
|
1373
|
+
});
|
|
1374
|
+
executionContext?.navigations?.push(navigation);
|
|
1375
|
+
|
|
1349
1376
|
return {
|
|
1350
1377
|
url: action.url,
|
|
1351
|
-
finalUrl:
|
|
1378
|
+
finalUrl: navigation.finalUrl,
|
|
1352
1379
|
waitUntil: action.waitUntil || 'domcontentloaded',
|
|
1380
|
+
httpStatus: navigation.httpStatus,
|
|
1381
|
+
...(navigation.blocked ? { blocked: navigation.blocked } : {}),
|
|
1353
1382
|
...(consent ? { consent } : {})
|
|
1354
1383
|
};
|
|
1355
1384
|
}
|
|
1356
1385
|
|
|
1386
|
+
/**
|
|
1387
|
+
* What the navigation that just finished reached: the page, or a wall. The
|
|
1388
|
+
* same verdict the chain's final document gets, run on this document with
|
|
1389
|
+
* the status of this navigation. A document that reads as a wall is re-read
|
|
1390
|
+
* for up to `graceMs` first, because some walls replace themselves (AWS WAF's
|
|
1391
|
+
* interstitial reloads into the page); one that cannot be read at all is
|
|
1392
|
+
* reported without a verdict rather than as a wall.
|
|
1393
|
+
* @param {Page} page - Playwright page, just navigated
|
|
1394
|
+
* @param {string} url - the URL the navigation was sent to
|
|
1395
|
+
* @param {Object} [browserOptions] - Browser options (`respectRobots` for the read's gate)
|
|
1396
|
+
* @param {{ graceMs?: number }} [options]
|
|
1397
|
+
* @returns {Promise<{ url: string, finalUrl: string, httpStatus: number|null, blocked?: { vendor: string, evidence: string } }>}
|
|
1398
|
+
*/
|
|
1399
|
+
async checkNavigation(page, url, browserOptions, { graceMs = NAVIGATION_WALL_GRACE_MS } = {}) {
|
|
1400
|
+
const httpStatus = page.__crawlforgeNavigation?.status ?? null;
|
|
1401
|
+
const read = () => this.readGated(page, browserOptions, async () => {
|
|
1402
|
+
try {
|
|
1403
|
+
const [title, html, text] = await Promise.all([
|
|
1404
|
+
page.title(),
|
|
1405
|
+
page.content(),
|
|
1406
|
+
page.evaluate(() => (document.body ? document.body.innerText : ''))
|
|
1407
|
+
]);
|
|
1408
|
+
return stealthDocumentVerdict(
|
|
1409
|
+
{ url: page.url(), title, html, text, status: httpStatus },
|
|
1410
|
+
{ allowEmpty: true, fetcher: browserOptions?.stealthMode?.enabled ? 'the stealth browser' : 'the browser' }
|
|
1411
|
+
);
|
|
1412
|
+
} catch {
|
|
1413
|
+
return null; // mid-navigation, or a page that cannot be read
|
|
1414
|
+
}
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
let verdict = await read();
|
|
1418
|
+
const deadline = Date.now() + graceMs;
|
|
1419
|
+
while (verdict?.blocked && Date.now() + NAVIGATION_WALL_POLL_MS <= deadline) {
|
|
1420
|
+
await this.delay(NAVIGATION_WALL_POLL_MS);
|
|
1421
|
+
const next = await read();
|
|
1422
|
+
// A read that fails mid-reload says nothing yet; keep the wall until a
|
|
1423
|
+
// document answers.
|
|
1424
|
+
if (next) verdict = next;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
return {
|
|
1428
|
+
url,
|
|
1429
|
+
finalUrl: page.url(),
|
|
1430
|
+
httpStatus,
|
|
1431
|
+
...(verdict?.blocked ? { blocked: verdict.blocked } : {})
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1357
1435
|
/**
|
|
1358
1436
|
* Execute screenshot action
|
|
1359
1437
|
* @param {Page} page - Playwright page
|
|
@@ -1503,9 +1581,12 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1503
1581
|
// hostname-based checks alone would miss DNS-rebinding/private-IP targets.
|
|
1504
1582
|
await assertUrlAllowed(url, { resolveDns: true });
|
|
1505
1583
|
|
|
1584
|
+
// The initial load and a retry's reload pass no timeout of their own, so
|
|
1585
|
+
// browserOptions.timeout bounds them as it bounds the actions (0.2); a
|
|
1586
|
+
// navigate action's own timeout comes first.
|
|
1506
1587
|
const response = await page.goto(url, {
|
|
1507
1588
|
waitUntil: options.waitUntil || 'domcontentloaded',
|
|
1508
|
-
timeout: options.timeout || 30000
|
|
1589
|
+
timeout: options.timeout || options.browserOptions?.timeout || 30000
|
|
1509
1590
|
});
|
|
1510
1591
|
|
|
1511
1592
|
// Re-validate where the navigation went: a redirect could have taken us
|
package/src/core/AuthManager.js
CHANGED
|
@@ -687,6 +687,12 @@ class AuthManager {
|
|
|
687
687
|
return costs.extract_embedded_state + scrapeEscalationSurcharge(params?.escalate) + redaction;
|
|
688
688
|
}
|
|
689
689
|
|
|
690
|
+
// extract_text and extract_links (fix plan Phase E2) run the same ladder:
|
|
691
|
+
// projected 1+5 when escalating, charged 1 when the plain fetch sufficed.
|
|
692
|
+
if (tool === 'extract_text' || tool === 'extract_links') {
|
|
693
|
+
return costs[tool] + scrapeEscalationSurcharge(params?.escalate) + redaction;
|
|
694
|
+
}
|
|
695
|
+
|
|
690
696
|
// search_web's batch form (5.1) runs one backend search per query, so it
|
|
691
697
|
// is priced as that many search_web calls. The count rule lives with the
|
|
692
698
|
// schema that declares `queries` (src/tools/search/batchSearch.js).
|
|
@@ -778,6 +784,12 @@ class AuthManager {
|
|
|
778
784
|
? 'Base 2; escalate:true adds 5 for the stealth browser it may need - the projection is the ceiling, and the actual charge drops back to 2 when the plain fetch succeeded and no escalation ran.'
|
|
779
785
|
: 'Fixed cost per invocation.';
|
|
780
786
|
break;
|
|
787
|
+
case 'extract_text':
|
|
788
|
+
case 'extract_links':
|
|
789
|
+
note = params?.escalate === true
|
|
790
|
+
? 'Base 1; escalate:true adds 5 for the stealth browser it may need - the projection is the ceiling, and the actual charge drops back to 1 when the plain fetch succeeded and no escalation ran.'
|
|
791
|
+
: 'Fixed cost per invocation.';
|
|
792
|
+
break;
|
|
781
793
|
case 'agent': {
|
|
782
794
|
const agentUrls = params?.maxUrls || 10;
|
|
783
795
|
const isPro = params?.model === 'pro';
|
|
@@ -17,7 +17,7 @@ import path from 'path';
|
|
|
17
17
|
import { createRequire } from 'module';
|
|
18
18
|
import HumanBehaviorSimulator from '../utils/HumanBehaviorSimulator.js';
|
|
19
19
|
import { BrowserContextPool } from './BrowserContextPool.js';
|
|
20
|
-
import { safeGoto, assertNavigationAllowed } from '../utils/ssrfGuard.js';
|
|
20
|
+
import { safeGoto, assertNavigationAllowed, assertUrlAllowed } from '../utils/ssrfGuard.js';
|
|
21
21
|
import { looksLikeInterstitial, detectChallengePage } from '../utils/challengeDetection.js';
|
|
22
22
|
import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
|
|
23
23
|
import { serverStealthProxies } from '../constants/config.js';
|
|
@@ -159,6 +159,39 @@ function isChromium(page) {
|
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* A proxy entry with its credentials removed. Everything before the last "@"
|
|
164
|
+
* of the authority goes, with or without a scheme: parseProxyEntry accepts a
|
|
165
|
+
* bare `user:pass@host:port`, which a rule anchored on "//" left whole.
|
|
166
|
+
* @param {string} entry
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
export function redactProxyEntry(entry) {
|
|
170
|
+
return String(entry).replace(/^([a-z][a-z0-9+.-]*:\/\/)?[^/]*@/i, '$1');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Refuse a per-call proxy list on camoufox. camoufox takes its proxy at
|
|
175
|
+
* browser launch and that one browser serves every later camoufox call, so a
|
|
176
|
+
* caller's proxy would carry other callers' traffic, on the caller's
|
|
177
|
+
* credentials, or be ignored when a browser is already up. Chromium sets the
|
|
178
|
+
* proxy per context. Called with the engine that will actually run, so an
|
|
179
|
+
* "auto" that lands on camoufox is refused too. The operator's
|
|
180
|
+
* CRAWLFORGE_STEALTH_PROXIES is not a per-call list and is not refused.
|
|
181
|
+
* @param {string} engine - the resolved engine
|
|
182
|
+
* @param {{ proxyRotation?: { enabled?: boolean, proxies?: string[] } }} config
|
|
183
|
+
* @throws {Error}
|
|
184
|
+
*/
|
|
185
|
+
export function assertProxyEngineAllowed(engine, config) {
|
|
186
|
+
const requested = config?.proxyRotation?.enabled ? (config.proxyRotation.proxies || []) : [];
|
|
187
|
+
if (engine !== 'camoufox' || !requested.length) return;
|
|
188
|
+
throw new Error(
|
|
189
|
+
'proxyRotation is refused on the camoufox engine: camoufox applies its proxy at browser launch ' +
|
|
190
|
+
'and shares that browser across calls, so a per-call proxy cannot be kept to this call. Use ' +
|
|
191
|
+
'engine:"chromium" (proxies are per context there), or set the operator list CRAWLFORGE_STEALTH_PROXIES.'
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
162
195
|
/**
|
|
163
196
|
* Resolve a requested engine to the one that will actually launch.
|
|
164
197
|
*
|
|
@@ -422,12 +455,19 @@ export class StealthBrowserManager {
|
|
|
422
455
|
*/
|
|
423
456
|
async launchStealthBrowser(config = {}) {
|
|
424
457
|
const validatedConfig = StealthConfigSchema.parse({ ...this.defaultConfig, ...config });
|
|
458
|
+
// Every path to a browser comes through here, createStealthContext's
|
|
459
|
+
// included, so this is where a caller's proxy list is held to the SSRF
|
|
460
|
+
// guard before either engine is handed it.
|
|
461
|
+
await this.assertCallerProxiesAllowed(validatedConfig);
|
|
425
462
|
// 'auto' has to become a concrete engine before the comparison below: an
|
|
426
463
|
// unresolved 'auto' never equals _launchedEngine, so every call would park
|
|
427
464
|
// a perfectly good browser and launch a second one beside it. This is the
|
|
428
465
|
// call that decides the engine — every other path reaches a browser
|
|
429
466
|
// through it — so it is the one that records the outcome.
|
|
430
467
|
await this._resolveConfigEngine(validatedConfig, true);
|
|
468
|
+
// The tools refuse this up front; this is the backstop for every other
|
|
469
|
+
// path, decided on the engine 'auto' resolved to, before any launch.
|
|
470
|
+
assertProxyEngineAllowed(validatedConfig.engine, validatedConfig);
|
|
431
471
|
|
|
432
472
|
// A Chromium that was OOM-killed or crashed doesn't error on reuse — its
|
|
433
473
|
// protocol calls hang. Detect the corpse and relaunch instead.
|
|
@@ -528,7 +568,11 @@ export class StealthBrowserManager {
|
|
|
528
568
|
// leave camoufox reporting the first proxy's city behind the second
|
|
529
569
|
// proxy's exit IP, which is a worse signal than not rotating at all.
|
|
530
570
|
// A rotation takes effect on the next launch, after cleanup().
|
|
531
|
-
|
|
571
|
+
//
|
|
572
|
+
// Only the operator's CRAWLFORGE_STEALTH_PROXIES, never a caller's list:
|
|
573
|
+
// this browser outlives the call, and every later camoufox context
|
|
574
|
+
// inherits its proxy (assertProxyEngineAllowed refuses the caller's).
|
|
575
|
+
const proxy = this.resolveProxy({ ...validatedConfig, proxyRotation: undefined });
|
|
532
576
|
// The caller's locale goes to the launcher, not to the context. camoufox
|
|
533
577
|
// sets language, Accept-Language and Intl together below the JS layer,
|
|
534
578
|
// where a Worker reads the same answer as the document; Playwright's
|
|
@@ -2387,18 +2431,23 @@ export class StealthBrowserManager {
|
|
|
2387
2431
|
// Wait for potential challenge page
|
|
2388
2432
|
await page.waitForTimeout(2000);
|
|
2389
2433
|
|
|
2390
|
-
// Check for CloudFlare challenge indicators
|
|
2434
|
+
// Check for CloudFlare challenge indicators. Cloudflare's current
|
|
2435
|
+
// interstitial reads "Performing security verification" under the title
|
|
2436
|
+
// "Just a moment..." (doordash.com, 2026-10-03); none of the older
|
|
2437
|
+
// phrases below appear on it, so the wait further down ended at once.
|
|
2391
2438
|
const challengeDetected = await page.evaluate(() => {
|
|
2392
2439
|
const indicators = [
|
|
2393
2440
|
'cf-browser-verification',
|
|
2394
2441
|
'cf-challenge-running',
|
|
2395
2442
|
'Checking your browser',
|
|
2443
|
+
'Performing security verification',
|
|
2396
2444
|
'DDoS protection by Cloudflare',
|
|
2397
2445
|
'Ray ID'
|
|
2398
2446
|
];
|
|
2399
2447
|
|
|
2400
2448
|
const pageText = document.body.innerText;
|
|
2401
|
-
return
|
|
2449
|
+
return /^just a moment/i.test(document.title.trim()) ||
|
|
2450
|
+
indicators.some(indicator => pageText.includes(indicator));
|
|
2402
2451
|
});
|
|
2403
2452
|
|
|
2404
2453
|
if (challengeDetected) {
|
|
@@ -2420,16 +2469,25 @@ export class StealthBrowserManager {
|
|
|
2420
2469
|
}
|
|
2421
2470
|
}
|
|
2422
2471
|
|
|
2423
|
-
// Wait for challenge to complete
|
|
2472
|
+
// Wait for the challenge to complete, up to 10 s. Only waits for
|
|
2473
|
+
// Cloudflare's own script to finish; nothing is clicked. A challenge
|
|
2474
|
+
// that passes clears in seconds; one that will not pass (an
|
|
2475
|
+
// interactive check, or this IP's reputation) never clears, and 30 s
|
|
2476
|
+
// there put a walled chain past the REST window (doordash.com stealth
|
|
2477
|
+
// Chromium, 3 of 3 runs walled at 39 s with the 30 s cap, 2026-10-03).
|
|
2424
2478
|
await page.waitForFunction(() => {
|
|
2425
2479
|
const indicators = [
|
|
2426
2480
|
'cf-browser-verification',
|
|
2427
2481
|
'cf-challenge-running',
|
|
2428
|
-
'Checking your browser'
|
|
2482
|
+
'Checking your browser',
|
|
2483
|
+
'Performing security verification'
|
|
2429
2484
|
];
|
|
2430
|
-
const pageText = document.body.innerText;
|
|
2431
|
-
return
|
|
2432
|
-
|
|
2485
|
+
const pageText = document.body ? document.body.innerText : '';
|
|
2486
|
+
return !/^just a moment/i.test(document.title.trim()) &&
|
|
2487
|
+
!indicators.some(indicator => pageText.includes(indicator));
|
|
2488
|
+
// Options are the third argument; in the second they were the page
|
|
2489
|
+
// function's argument and the wait ran Playwright's 30 s default.
|
|
2490
|
+
}, undefined, { timeout: 10000 }).catch(() => {});
|
|
2433
2491
|
|
|
2434
2492
|
this.performanceMetrics.successfulBypasses++;
|
|
2435
2493
|
return true;
|
|
@@ -2550,9 +2608,34 @@ export class StealthBrowserManager {
|
|
|
2550
2608
|
return proxy;
|
|
2551
2609
|
}
|
|
2552
2610
|
|
|
2611
|
+
/**
|
|
2612
|
+
* Refuse a caller's proxy whose host the SSRF guard would refuse as a page:
|
|
2613
|
+
* a proxy on loopback, link-local or a metadata address (RFC 1918 too under
|
|
2614
|
+
* SSRF_STRICT, and ALLOWED_DOMAINS exempt, as everywhere else) would have
|
|
2615
|
+
* the browser send the call's traffic into this server's own network. Only
|
|
2616
|
+
* the per-call list is checked — CRAWLFORGE_STEALTH_PROXIES is the
|
|
2617
|
+
* operator's, who may well run a proxy on localhost. The message names the
|
|
2618
|
+
* host, never the entry, which carries the password.
|
|
2619
|
+
* @param {{ proxyRotation?: { enabled?: boolean, proxies?: string[] } }} config
|
|
2620
|
+
* @returns {Promise<void>}
|
|
2621
|
+
* @throws {Error} code SSRF_BLOCKED
|
|
2622
|
+
*/
|
|
2623
|
+
async assertCallerProxiesAllowed(config) {
|
|
2624
|
+
const requested = config.proxyRotation?.enabled ? (config.proxyRotation.proxies || []) : [];
|
|
2625
|
+
for (const entry of requested) {
|
|
2626
|
+
// url.host of socks4/socks5 parses the same way; the guard only takes http(s).
|
|
2627
|
+
const { host } = new URL(this.parseProxyEntry(entry).server);
|
|
2628
|
+
try {
|
|
2629
|
+
await assertUrlAllowed(`http://${host}/`, { resolveDns: true });
|
|
2630
|
+
} catch (err) {
|
|
2631
|
+
throw Object.assign(new Error(`proxyRotation: proxy host ${host} refused — ${err.message}`), { code: err.code });
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2553
2636
|
/** A proxy entry with its credentials removed, for logs and get_stats. */
|
|
2554
2637
|
redactProxy(entry) {
|
|
2555
|
-
return
|
|
2638
|
+
return redactProxyEntry(entry);
|
|
2556
2639
|
}
|
|
2557
2640
|
|
|
2558
2641
|
/**
|
|
@@ -373,6 +373,11 @@ export class BrowserProcessor {
|
|
|
373
373
|
// createStealthContext re-validates the config it is given — so a context
|
|
374
374
|
// asked for without it would relaunch on the default engine underneath.
|
|
375
375
|
const engine = options.stealthMode.engine || 'chromium';
|
|
376
|
+
// The caller's own proxies (scrape_with_actions browserOptions.proxyRotation),
|
|
377
|
+
// to both calls for the same reason as the engine: camoufox takes its proxy
|
|
378
|
+
// at launch, Chromium per context. Absent unless the caller sent one, so
|
|
379
|
+
// CRAWLFORGE_STEALTH_PROXIES still applies when nobody did.
|
|
380
|
+
const proxy = options.proxyRotation ? { proxyRotation: options.proxyRotation } : {};
|
|
376
381
|
|
|
377
382
|
// Launch stealth browser
|
|
378
383
|
await this.stealthManager.launchStealthBrowser({
|
|
@@ -381,12 +386,14 @@ export class BrowserProcessor {
|
|
|
381
386
|
randomizeFingerprint: options.stealthMode.randomizeFingerprint,
|
|
382
387
|
hideWebDriver: options.stealthMode.hideWebDriver,
|
|
383
388
|
blockWebRTC: options.stealthMode.blockWebRTC,
|
|
384
|
-
customUserAgent: options.stealthMode.customUserAgent || options.userAgent
|
|
389
|
+
customUserAgent: options.stealthMode.customUserAgent || options.userAgent,
|
|
390
|
+
...proxy
|
|
385
391
|
});
|
|
386
392
|
|
|
387
393
|
// Create stealth context
|
|
388
394
|
const { context, contextId } = await this.stealthManager.createStealthContext({
|
|
389
395
|
engine,
|
|
396
|
+
...proxy,
|
|
390
397
|
level: options.stealthMode.level,
|
|
391
398
|
customViewport: {
|
|
392
399
|
width: options.viewportWidth || 1280,
|
|
@@ -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) {
|