crawlforge-mcp-server 5.8.0 → 5.9.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 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.8.0",
3
+ "version": "5.9.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",
package/server.js CHANGED
@@ -109,7 +109,7 @@ const taskStore = createTaskStore({ logger });
109
109
  // Create the server
110
110
  const server = new McpServer({
111
111
  name: "crawlforge",
112
- version: "5.8.0",
112
+ version: "5.9.0",
113
113
  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
114
  homepage: "https://www.crawlforge.dev",
115
115
  icon: "https://www.crawlforge.dev/icon.png",
@@ -121,7 +121,7 @@ const server = new McpServer({
121
121
  "- 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
122
  "- 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
123
  "- 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.",
124
+ "- 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
125
  "- 2-50 known URLs -> one batch_scrape (5), never a loop of scrape calls.",
126
126
  "- A site's URL list -> map_site (2); many pages of one site -> crawl_deep (4).",
127
127
  "- Exact values from a Next.js/Nuxt/Redux payload -> extract_embedded_state (2) with a path.",
@@ -165,6 +165,7 @@ server.registerPrompt("getting-started", {
165
165
  "\n" +
166
166
  "Hard pages\n" +
167
167
  "- stealth_mode (5): after a 403/429/CAPTCHA/challenge page or an empty shell - never first.\n" +
168
+ "- 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
169
  "- scrape_with_actions (5): click, log in, scroll or wait, then scrape.\n" +
169
170
  "- localization (2): country and locale context for geo-specific content.\n" +
170
171
  "\n" +
@@ -236,7 +237,22 @@ const deepResearchTool = new DeepResearchTool();
236
237
  const trackChangesTool = new TrackChangesTool();
237
238
  const generateLLMsTxtTool = new GenerateLLMsTxtTool();
238
239
  const scrapeTemplateTool = new ScrapeTemplateTool(); // D3.3
239
- const unifiedScrapeTool = new UnifiedScrapeTool({ actionExecutor: scrapeWithActionsTool.actionExecutor }); // D4 D1 (+v4.8 screenshot reuses the shared browser pool)
240
+ const unifiedScrapeTool = new UnifiedScrapeTool({
241
+ actionExecutor: scrapeWithActionsTool.actionExecutor, // D4 D1 (+v4.8 screenshot reuses the shared browser pool)
242
+ // Phase 3: the escalation stage for `scrape`'s escalate:true, injected so
243
+ // the tool module never imports StealthBrowserManager (that would pull a
244
+ // browser dependency into every unit test that loads `scrape`). Same gate
245
+ // and same browser the stealth_mode tool drives — no new evasion, and the
246
+ // "playwright" → chromium engine mapping stays here with its sibling.
247
+ escalateScrape: async ({ url, engine, respectRobots }) => {
248
+ const warnings = await stealthComplianceGate(url, respectRobots);
249
+ const scraped = await stealthBrowserManager.scrapeWithStealth({
250
+ url,
251
+ engine: engine === 'camoufox' ? 'camoufox' : 'chromium'
252
+ });
253
+ return { ...scraped, engine, warnings };
254
+ }
255
+ });
240
256
  const agentTool = new AgentTool(); // D4 D2
241
257
  const stealthBrowserManager = new StealthBrowserManager();
242
258
  const localizationManager = new LocalizationManager();
@@ -1066,7 +1082,7 @@ if (toolFilter.isEnabled("deep_research")) {
1066
1082
 
1067
1083
  // Tool: scrape (D4 D1 — unified multi-format single-fetch)
1068
1084
  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 blocked site (stealth_mode), 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\"]})",
1085
+ 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
1086
  annotations: { title: "Scrape (Multi-Format)", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1071
1087
  // Claude Code tool search loads only names + instructions at session start; this flag
1072
1088
  // ships the full definition too, so the first call needs no ToolSearch round-trip.
@@ -13,6 +13,7 @@ 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';
16
17
  // Stamped on every usage report so support can tell which client version
17
18
  // made a call; it read '3.0.3' from 3.0.3 to 5.6.10 (the website stores it
18
19
  // per record from its Phase 1.3 on).
@@ -626,11 +627,13 @@ class AuthManager {
626
627
  }
627
628
 
628
629
  // The query-scoped formats (highlights, question) add 1 once per call,
629
- // and mode:"model" adds 3 once; the base price in the table is what the
630
- // backend sees. The rule lives with the format schemas.
630
+ // and mode:"model" adds 3 once; escalate:true adds the stealth browser's
631
+ // own 5, since that is the ceiling the call may reach. The base price in
632
+ // the table is what the backend sees. Both rules live with the schemas
633
+ // that declare the params.
631
634
  if (tool === 'scrape') {
632
635
  const { query, model } = scrapeFormatSurcharge(params?.formats);
633
- return costs.scrape + query + model;
636
+ return costs.scrape + query + model + scrapeEscalationSurcharge(params?.escalate);
634
637
  }
635
638
 
636
639
  return costs[tool] ?? 1;
@@ -689,7 +692,9 @@ class AuthManager {
689
692
  break;
690
693
  case 'scrape': {
691
694
  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). json format may incur external LLM cost (billed by your provider).';
695
+ 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). ' +
696
+ '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. ' +
697
+ 'json format may incur external LLM cost (billed by your provider).';
693
698
  break;
694
699
  }
695
700
  case 'agent': {
@@ -124,6 +124,11 @@ const scrapeShape = {
124
124
  vendor: z.string().optional(),
125
125
  evidence: z.string().optional()
126
126
  }).passthrough().optional().describe('Present when a bot-defence vendor served a challenge page; the fallback hint names the tool to try next'),
127
+ 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'),
128
+ stealth: z.object({
129
+ engine: z.string().optional(),
130
+ vendor_detected: z.string().nullable().optional()
131
+ }).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
132
  content: z.object(scrapeFormatShapes).passthrough().optional().describe('One key per requested format'),
128
133
  warnings: z.array(z.string()).optional().describe('Per-format warnings; partial success never fails the whole call'),
129
134
  ...resultHandleShape,
@@ -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
- const hint = FALLBACK_HINTS[toolName];
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
- if (first.text.includes(hint)) return result;
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 parsed = JSON.parse(first.text);
57
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
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
  }
@@ -90,6 +90,7 @@ Do not suggest adding API keys — local Ollama is the intended zero-cost defaul
90
90
  | Symptom | Try next |
91
91
  |---------|----------|
92
92
  | `scrape` / `fetch_url` returns 403, 429, CAPTCHA, or empty JS shell | `stealth_mode` (crawlforge-stealth-browsing) |
93
+ | A site you already know blocks | `scrape` with `escalate: true` — plain fetch first, stealth browser only if it is walled; never `stealth_mode` first |
93
94
  | No template for a known site | `scrape_structured` → `extract_structured` → `extract_with_llm` |
94
95
  | LLM extraction unavailable (no Ollama/keys) | `scrape_structured` with CSS selectors |
95
96
  | Single page too slow / many pages | `batch_scrape` (async + webhook) |
@@ -20,7 +20,7 @@ metered; there is no free tier. Tools marked "scales" cost more as work grows.
20
20
 
21
21
  | Tool | Notes |
22
22
  |------|-------|
23
- | `scrape` | Unified multi-format single fetch. A `highlights` or `question` format adds 1 once per call; `mode:"model"` on one of them adds 3 once. |
23
+ | `scrape` | Unified multi-format single fetch. A `highlights` or `question` format adds 1 once per call; `mode:"model"` on one of them adds 3 once. `escalate:true` adds 5 as the projected ceiling; the actual charge drops back to the base when the plain fetch succeeded and no escalation ran. |
24
24
  | `scrape_structured` | CSS-selector extraction. |
25
25
  | `extract_embedded_state` | Embedded JS state (`__NEXT_DATA__`, RSC, Nuxt, Apollo, Redux). |
26
26
  | `extract_content` | Readability-cleaned article. |
@@ -77,4 +77,8 @@ metered; there is no free tier. Tools marked "scales" cost more as work grows.
77
77
  - `get_batch_results` (1) is cheap — submit a batch once, page through results.
78
78
  - A result over `max_inline_chars` costs nothing extra; read the stored copy with
79
79
  `read_result` (1) instead of fetching again.
80
+ - On a site that blocks, one `scrape` with `escalate:true` costs at most 7 and
81
+ only 2 when the plain fetch works; `scrape` then `stealth_mode` costs 7 either
82
+ way. Do not set it on a site that reads fine — it raises the projection your
83
+ balance is checked against.
80
84
  - Errors are charged at half the tool cost; creator mode is unlimited.
@@ -24,6 +24,11 @@ Escalate from a normal `scrape` / `fetch_url` (see crawlforge-web-scraping) when
24
24
  `stealth_mode` drives a real browser with randomized fingerprints, human
25
25
  behavior simulation, and WebRTC/canvas/WebGL spoofing.
26
26
 
27
+ When you already know the site blocks, `scrape` with `escalate: true` does both
28
+ steps in one call: the plain fetch first, then the same stealth browser only if
29
+ that fetch is walled. Projected at 7, charged 2 when the plain fetch worked.
30
+ Still never reach for `stealth_mode` first.
31
+
27
32
  ## stealth_mode (cost: 5)
28
33
 
29
34
  `stealth_mode` is operation-based. Typical flow: create a context, then create a
@@ -36,7 +36,9 @@ site discovery (sitemaps and URL maps), and whole-site crawling.
36
36
  5. **`crawl_deep`** to walk an entire site and (optionally) extract content.
37
37
 
38
38
  If a page returns 403/429, a CAPTCHA, or empty "enable JavaScript" content,
39
- switch to the **crawlforge-stealth-browsing** skill (`stealth_mode`).
39
+ switch to the **crawlforge-stealth-browsing** skill (`stealth_mode`). When the
40
+ site is *known* to block, use `scrape` with `escalate: true` instead of two
41
+ calls — still never `stealth_mode` first.
40
42
 
41
43
  ## scrape — unified multi-format (cost: 2)
42
44
 
@@ -59,6 +61,33 @@ Get markdown + links + metadata in a single call:
59
61
  a failing format adds a `warnings[]` entry instead of failing the whole call.
60
62
  `onlyMainContent` (default `true`) strips boilerplate via Readability.
61
63
 
64
+ ### escalate — one call for a site that blocks (projected 7, charged 2 when it doesn't)
65
+
66
+ `escalate: true` keeps the plain fetch first and retries in the stealth browser
67
+ only if that fetch comes back walled (403/429, a challenge page, an empty
68
+ shell). The escalated page goes through the same formats, so `markdown`,
69
+ `highlights` and the rest work exactly as they do on a plain read.
70
+
71
+ ```json
72
+ {
73
+ "tool": "scrape",
74
+ "params": {
75
+ "url": "https://www.producthunt.com/",
76
+ "formats": ["markdown"],
77
+ "escalate": true,
78
+ "escalate_engine": "playwright"
79
+ }
80
+ }
81
+ ```
82
+
83
+ The result adds `escalated: true|false`, and `stealth: { engine,
84
+ vendor_detected }` when the browser ran. The projection is `2 + 5`; the charge
85
+ falls back to the base when the plain fetch succeeded and nothing escalated.
86
+ `escalate_engine` is `"playwright"` (default) or `"camoufox"`. robots.txt is
87
+ respected on the escalated path too, and a second call to a host that blocked
88
+ within the last 24 hours skips the doomed plain fetch and says so in
89
+ `warnings[]`.
90
+
62
91
  Query-scoped formats return only the parts of the page that match, verbatim,
63
92
  with offsets into the `markdown` of the same call:
64
93
 
@@ -0,0 +1,62 @@
1
+ /**
2
+ * escalation.js — the one declaration of `scrape`'s opt-in auto-escalation.
3
+ *
4
+ * `escalate: true` asks the tool to run the stealth browser itself when the
5
+ * plain fetch comes back walled (Phase 3). The plain fetch always runs first
6
+ * (G1: never start with stealth), so the escalation is a second stage, not a
7
+ * different tool: it reuses the same compliance gate and the same
8
+ * StealthBrowserManager `stealth_mode` uses, and adds no new evasion.
9
+ *
10
+ * The two fields and the price rule live here so `server.js`, the tool
11
+ * module, `AuthManager.getToolCost` and the output schema read one
12
+ * declaration (G5) — the same arrangement `formats.js` already has for the
13
+ * query-format surcharge.
14
+ */
15
+
16
+ import { z } from 'zod';
17
+
18
+ /** What an escalated call adds to `scrape`'s price: the stealth browser's own. */
19
+ export const SCRAPE_ESCALATION_CREDITS = 5;
20
+
21
+ /** The two public fields, in tools/list order, with the text the client sees. */
22
+ export const SCRAPE_ESCALATION_SHAPE = {
23
+ escalate: z.boolean().optional().default(false).describe('When the plain fetch comes back blocked (403/429/challenge page/empty shell), retry once in the stealth browser and return its content instead of the block. Projected at 2+5; the actual charge stays at the base price when the plain fetch succeeded. Default: false'),
24
+ escalate_engine: z.enum(['playwright', 'camoufox']).optional().default('playwright').describe('Stealth engine for the escalated retry (default: "playwright")')
25
+ };
26
+
27
+ /**
28
+ * What escalation adds to `scrape`'s base price: 5 when the caller asked for
29
+ * it, nothing otherwise. Reads the RAW param — getToolCost runs before
30
+ * validation, so anything that is not exactly `true` prices as 0.
31
+ *
32
+ * @param {unknown} escalate the raw `escalate` param
33
+ * @returns {0 | 5}
34
+ */
35
+ export function scrapeEscalationSurcharge(escalate) {
36
+ return escalate === true ? SCRAPE_ESCALATION_CREDITS : 0;
37
+ }
38
+
39
+ /**
40
+ * Whether a second, browser-backed attempt could plausibly change this
41
+ * outcome.
42
+ *
43
+ * A named vendor's wall qualifies, and so does a bare 403 or 429 — an
44
+ * IP-reputation, WAF or User-Agent block, which a real browser and fingerprint
45
+ * often do pass (travel.state.gov answers some networks with exactly that). So
46
+ * does any failure that arrived with a 2xx: an empty shell, a JavaScript-only
47
+ * document or a soft-error placeholder is precisely what rendering fixes.
48
+ *
49
+ * A 404 is not a wall and a 5xx is the server's own failure. Escalating either
50
+ * would spend the browser's 5 credits to be told the same thing, so `escalate`
51
+ * leaves them as the plain fetch reported them (G4 — never charge for work
52
+ * that cannot help).
53
+ *
54
+ * @param {{ blocked?: unknown } | null | undefined} verdict
55
+ * @param {number | null | undefined} status
56
+ * @returns {boolean}
57
+ */
58
+ export function aBrowserMightPass(verdict, status) {
59
+ if (verdict && verdict.blocked) return true;
60
+ if (status === 403 || status === 429) return true;
61
+ return status === null || status === undefined || (status >= 200 && status < 300);
62
+ }
@@ -20,6 +20,7 @@ import {
20
20
  SCRAPE_STRING_FORMATS, JsonFormatSchema, HighlightsFormatSchema, QuestionFormatSchema, FormatSchema,
21
21
  scrapeFormatSurcharge
22
22
  } from './formats.js';
23
+ import { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS, aBrowserMightPass } from './escalation.js';
23
24
  import { toPublicUnit, parseChosenIndexes, groundingCheck } from './_highlights.js';
24
25
  import { setActualCost } from '../../server/requestContext.js';
25
26
  import { fenceUntrusted } from '../../utils/untrustedContent.js';
@@ -29,11 +30,15 @@ import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
29
30
  import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
30
31
  import { extractBlockText } from '../basic/extractText.js';
31
32
  import { pageTitle } from '../../utils/pageTitle.js';
32
- import { noteHostBlocked, clearHostBlocked } from '../../utils/hostRateLimiter.js';
33
+ import { noteHostBlocked, clearHostBlocked, getHostBlock } from '../../utils/hostRateLimiter.js';
33
34
 
34
35
  // ── Schema ────────────────────────────────────────────────────────────────────
35
36
 
36
37
  export { SCRAPE_STRING_FORMATS, JsonFormatSchema, HighlightsFormatSchema, QuestionFormatSchema, FormatSchema };
38
+ export { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS };
39
+
40
+ /** scrape's base price, as AuthManager's table spells it. */
41
+ const SCRAPE_BASE_CREDITS = 2;
37
42
 
38
43
  // Above this, a markdown result is the whole page in the context window;
39
44
  // the warning names the format that returns only the matching units (1.4).
@@ -61,7 +66,9 @@ export const SCRAPE_INPUT_SHAPE = {
61
66
  fullPage: z.boolean().optional().default(false).describe('Capture the full scrollable page'),
62
67
  format: z.enum(['png', 'jpeg']).optional().default('png'),
63
68
  quality: z.number().min(0).max(100).optional().describe('JPEG quality (jpeg only)')
64
- }).optional().describe('Options for the "screenshot" format')
69
+ }).optional().describe('Options for the "screenshot" format'),
70
+ // Opt-in second stage (Phase 3): the plain fetch still runs first.
71
+ ...SCRAPE_ESCALATION_SHAPE
65
72
  };
66
73
 
67
74
  export const UnifiedScrapeSchema = z.object({
@@ -194,6 +201,15 @@ export class UnifiedScrapeTool {
194
201
  // Optional shared ActionExecutor (injected from server.js so we reuse the
195
202
  // existing browser pool rather than spinning up a second one).
196
203
  this._actionExecutor = options.actionExecutor || null;
204
+ // Optional escalation stage (Phase 3), injected from server.js the same
205
+ // way. The tool module must never import StealthBrowserManager itself:
206
+ // that would pull a browser dependency into every unit test that loads
207
+ // `scrape`. Signature:
208
+ // ({ url, engine, respectRobots }) =>
209
+ // { html, url, title, text, status, engine, warnings? }
210
+ // The injected function owns the compliance gate (robots + blocklist) and
211
+ // the engine-name mapping, exactly as the stealth_mode tool does.
212
+ this._escalateScrape = options.escalateScrape || null;
197
213
  this._mcpServer = null;
198
214
  }
199
215
 
@@ -278,59 +294,183 @@ export class UnifiedScrapeTool {
278
294
  const validated = UnifiedScrapeSchema.parse(params);
279
295
  const { url, formats, onlyMainContent, timeoutMs, brandingOptions, screenshotOptions, resolveHiddenContent } = validated;
280
296
 
297
+ // The gate's warnings (e.g. a respect_robots override) travel with the
298
+ // per-format ones, so the caller sees the decision in the response.
299
+ const warnings = [];
300
+ // Whether a model completed for a mode:"model" format, and whether the
301
+ // stealth stage ran; the surcharge for each is charged only then.
302
+ let modelUsed = false;
303
+ let escalationRan = false;
304
+ let stealthEngine = null;
305
+
306
+ // One charge computation for every return path (3.2). The projection is
307
+ // the ceiling a caller saw before the call, so each stage that did not
308
+ // run only ever lowers the bill (G4): the model surcharge when nothing
309
+ // completed, the escalation surcharge when the plain fetch sufficed.
310
+ const surcharge = scrapeFormatSurcharge(formats);
311
+ const reportCost = () => setActualCost(
312
+ SCRAPE_BASE_CREDITS +
313
+ surcharge.query +
314
+ (modelUsed ? surcharge.model : 0) +
315
+ (escalationRan ? SCRAPE_ESCALATION_CREDITS : 0)
316
+ );
317
+
318
+ // Opt-in second stage (Phase 3): the plain fetch runs first and the
319
+ // stealth browser only follows a blocked verdict.
320
+ const escalate = validated.escalate === true;
321
+ // 3.3: a host that walled us within the last 24 hours (0.6's memory) has
322
+ // a doomed plain fetch ahead of it. Read ONLY when escalating — an
323
+ // ordinary scrape must behave exactly as it did before.
324
+ const remembered = escalate ? getHostBlock(url) : null;
325
+
281
326
  // Single fetch
282
- let html, $, finalUrl, fetchWarnings, status;
283
- try {
284
- ({ html, $, finalUrl, warnings: fetchWarnings, status } = await fetchAndParse(url, {
285
- timeoutMs,
286
- userAgent: validated.user_agent,
287
- respectRobots: validated.respect_robots,
288
- tool: 'scrape',
289
- stripTags: [], // we handle boilerplate ourselves
290
- // A real wall reaches a plain fetch as a 403 with the challenge in
291
- // the body; the verdict below needs that body and the status.
292
- errorDocuments: true
293
- }));
294
- } catch (err) {
295
- throw new Error(`scrape: fetch failed for ${url}: ${err.message}`);
327
+ let html, $, fetchWarnings, status, title, verdict;
328
+ let finalUrl = url;
329
+ if (remembered) {
330
+ warnings.push(
331
+ `escalate: ${remembered.vendor || 'a bot wall'} walled this host within the last 24 hours; the plain fetch was skipped and the stealth browser ran first`
332
+ );
333
+ } else {
334
+ try {
335
+ ({ html, $, finalUrl, warnings: fetchWarnings, status } = await fetchAndParse(url, {
336
+ timeoutMs,
337
+ userAgent: validated.user_agent,
338
+ respectRobots: validated.respect_robots,
339
+ tool: 'scrape',
340
+ stripTags: [], // we handle boilerplate ourselves
341
+ // A real wall reaches a plain fetch as a 403 with the challenge in
342
+ // the body; the verdict below needs that body and the status.
343
+ errorDocuments: true
344
+ }));
345
+ } catch (err) {
346
+ throw new Error(`scrape: fetch failed for ${url}: ${err.message}`);
347
+ }
348
+ warnings.push(...fetchWarnings);
349
+
350
+ // A bot wall, an HTTP error page, an empty shell or an error placeholder
351
+ // arrives with a title and prose of its own; reported as a successful
352
+ // scrape it hid the block for three rounds (producthunt.com, R10 Q1 →
353
+ // R15). The stealth path has named these since 5.6.2; this path never
354
+ // looked, and threw away every non-2xx body before it could (Phase 0,
355
+ // 0.1). Scripts stay in $ for the rawHtml and metadata formats,
356
+ // so the text is measured on a copy without them — the vendor tables
357
+ // count the characters a reader would see, as the browser path does.
358
+ // Nothing below runs on a failed verdict: a screenshot would launch a
359
+ // browser on the wall and branding would fetch its stylesheets.
360
+ title = pageTitle($);
361
+ const $visible = load(html);
362
+ $visible('script, style, noscript, template').remove();
363
+ verdict = documentVerdict(
364
+ { url: finalUrl, title, text: $visible('body').text(), html, status },
365
+ { fetcher: 'a plain fetch', rendered: false, contentReturned: false }
366
+ );
367
+ // Host memory (0.6): remember the vendor that walled this host, keyed by
368
+ // the URL the caller passed as well as the final one when a redirect
369
+ // moved hosts, since a later call looks it up by what it was given.
370
+ const hosts = [finalUrl, url];
371
+ if (verdict.blocked) {
372
+ for (const h of hosts) noteHostBlocked(h, verdict.blocked.vendor);
373
+ } else if (verdict.success) {
374
+ for (const h of hosts) clearHostBlocked(h);
375
+ }
296
376
  }
297
377
 
298
- // A bot wall, an HTTP error page, an empty shell or an error placeholder
299
- // arrives with a title and prose of its own; reported as a successful
300
- // scrape it hid the block for three rounds (producthunt.com, R10 Q1 →
301
- // R15). The stealth path has named these since 5.6.2; this path never
302
- // looked, and threw away every non-2xx body before it could (Phase 0,
303
- // 0.1). Scripts stay in $ for the rawHtml and metadata formats,
304
- // so the text is measured on a copy without them — the vendor tables
305
- // count the characters a reader would see, as the browser path does.
306
- // Nothing below runs on a failed verdict: a screenshot would launch a
307
- // browser on the wall and branding would fetch its stylesheets.
308
- const title = pageTitle($);
309
- const $visible = load(html);
310
- $visible('script, style, noscript, template').remove();
311
- const verdict = documentVerdict(
312
- { url: finalUrl, title, text: $visible('body').text(), html, status },
313
- { fetcher: 'a plain fetch', rendered: false, contentReturned: false }
314
- );
315
- // Host memory (0.6): remember the vendor that walled this host, keyed by
316
- // the URL the caller passed as well as the final one when a redirect
317
- // moved hosts, since a later call looks it up by what it was given.
318
- const hosts = [finalUrl, url];
319
- if (verdict.blocked) {
320
- for (const h of hosts) noteHostBlocked(h, verdict.blocked.vendor);
321
- } else if (verdict.success) {
322
- for (const h of hosts) clearHostBlocked(h);
378
+ // ── Escalation (Phase 3) ─────────────────────────────────────────────
379
+ // Opt-in, and second by construction: the plain fetch above has already
380
+ // run and only a blocked verdict gets here (G1 — never start with
381
+ // stealth). The stage reuses the compliance gate and the browser
382
+ // `stealth_mode` already drives, so it adds no evasion of its own.
383
+ // The vendor the plain fetch hit, or the one this host is remembered for
384
+ // when that fetch was skipped.
385
+ const vendorDetected = remembered ? (remembered.vendor ?? null) : (verdict?.blocked?.vendor ?? null);
386
+ // Set when the stage was asked for and could not run at all; it is the
387
+ // only thing left to report when the plain fetch was skipped as well.
388
+ let escalationError = null;
389
+
390
+ // Only a failure a browser could plausibly change is worth the second
391
+ // stage: a wall or a bare 403/429, or a page that failed with a 2xx (an
392
+ // empty shell renders). A 404 or a 5xx escalates nothing — see
393
+ // aBrowserMightPass.
394
+ if (escalate && (remembered || (!verdict.success && aBrowserMightPass(verdict, status)))) {
395
+ if (!this._escalateScrape) {
396
+ escalationError = 'no stealth stage is wired into this server build';
397
+ warnings.push(`escalate: ${escalationError}`);
398
+ } else {
399
+ try {
400
+ const stealth = await this._escalateScrape({
401
+ url: remembered ? url : finalUrl,
402
+ engine: validated.escalate_engine,
403
+ respectRobots: validated.respect_robots
404
+ });
405
+ escalationRan = true;
406
+ stealthEngine = stealth.engine || validated.escalate_engine;
407
+ if (Array.isArray(stealth.warnings)) warnings.push(...stealth.warnings);
408
+
409
+ // The stealth render replaces the document every format below is
410
+ // built from, so an escalated page goes through the SAME formats
411
+ // loop a plain fetch does — highlights and question included.
412
+ html = stealth.html || '';
413
+ finalUrl = stealth.url || finalUrl;
414
+ status = stealth.status ?? null;
415
+ $ = load(html);
416
+ title = stealth.title || pageTitle($);
417
+ const $rendered = load(html);
418
+ $rendered('script, style, noscript, template').remove();
419
+ // Re-run the verdict on what the browser rendered: a wall that
420
+ // survives the stealth pass must not come back as a success.
421
+ verdict = documentVerdict(
422
+ { url: finalUrl, title, text: stealth.text || $rendered('body').text(), html, status },
423
+ { waitedMs: stealth.gracedMs || 0, fetcher: 'the stealth browser', rendered: true, contentReturned: false }
424
+ );
425
+ // Escalation fires on ANY failed verdict, not only a named vendor —
426
+ // an empty shell and an error placeholder are exactly the cases a
427
+ // browser fixes — so the wording must not call every one a block.
428
+ warnings.push(
429
+ `escalate: the plain fetch ${vendorDetected ? `was blocked by ${vendorDetected}` : 'did not return the page'}; ` +
430
+ `the ${stealthEngine} stealth browser ${verdict.success ? 'returned it' : 'did not get it either'}`
431
+ );
432
+ // The host memory is deliberately left alone here. It records what
433
+ // a PLAIN fetch met, which is what 3.3 reads before deciding to
434
+ // skip one; a stealth success says nothing about that, and only a
435
+ // clean plain fetch clears the entry (0.6).
436
+ } catch (err) {
437
+ // The gate refuses a disallowed URL by throwing, after stamping
438
+ // markPreflightRefusal('ROBOTS_DISALLOWED') on the request context.
439
+ // That flag makes withAuth bill the WHOLE call zero — plain fetch
440
+ // included — which is the intended outcome: we never rendered the
441
+ // page we were refused. Caught here so a refusal is a warning on
442
+ // the verdict we already have rather than a throw out of the tool.
443
+ escalationError = err.message;
444
+ warnings.push(`escalate: the stealth retry did not run — ${escalationError}`);
445
+ }
446
+ }
323
447
  }
324
- if (!verdict.success) {
448
+
449
+ // `escalated` is reported only to a caller who asked to escalate: a call
450
+ // that never asked keeps exactly the result shape it had before.
451
+ const escalationFields = escalate
452
+ ? {
453
+ escalated: escalationRan,
454
+ ...(escalationRan ? { stealth: { engine: stealthEngine, vendor_detected: vendorDetected } } : {})
455
+ }
456
+ : {};
457
+
458
+ if (!verdict || !verdict.success) {
459
+ reportCost();
325
460
  return {
326
461
  success: false,
327
462
  url: finalUrl,
328
- status,
463
+ // A stealth render reports no status when nothing navigated, and a
464
+ // skipped plain fetch reports none at all.
465
+ ...(typeof status === 'number' ? { status } : {}),
329
466
  title,
330
- error: verdict.error,
331
- ...(verdict.blocked ? { blocked: verdict.blocked } : {}),
467
+ error: verdict
468
+ ? verdict.error
469
+ : `The plain fetch was skipped because ${vendorDetected || 'a bot wall'} walled this host within the last 24 hours, and the stealth retry did not run: ${escalationError}`,
470
+ ...(verdict?.blocked ? { blocked: verdict.blocked } : {}),
471
+ ...escalationFields,
332
472
  content: {},
333
- warnings: fetchWarnings.length > 0 ? fetchWarnings : undefined
473
+ warnings: warnings.length > 0 ? warnings : undefined
334
474
  };
335
475
  }
336
476
 
@@ -380,14 +520,7 @@ export class UnifiedScrapeTool {
380
520
  if (units === null) units = segmentUnits(getMarkdown());
381
521
  return units;
382
522
  }
383
- // Whether a model completed for a mode:"model" format; the model
384
- // surcharge is charged only then.
385
- let modelUsed = false;
386
-
387
523
  const content = {};
388
- // The gate's warnings (e.g. a respect_robots override) travel with the
389
- // per-format ones, so the caller sees the decision in the response.
390
- const warnings = [...fetchWarnings];
391
524
 
392
525
  // Kept for the rawHtml format, which must survive the strip below.
393
526
  const pristineHtml = html;
@@ -652,15 +785,15 @@ export class UnifiedScrapeTool {
652
785
  if (!queryScoped && typeof content.markdown === 'string' && content.markdown.length > OVERSIZED_MARKDOWN_CHARS) {
653
786
  warnings.push(`markdown: ${content.markdown.length} characters; ask for {type:"highlights", query} (1 extra credit, no model) to get only the matching sentences, table rows and code blocks`);
654
787
  }
655
- // The projection charged the model step; nothing ran, so the charge
656
- // drops to the extractive price (2 is scrape's base in AuthManager;
657
- // withAuth clamps to the projection either way).
658
- const surcharge = scrapeFormatSurcharge(formats);
659
- if (surcharge.model > 0 && !modelUsed) setActualCost(2 + surcharge.query);
788
+ // What this call actually spent: the model step and the escalation each
789
+ // drop out when they did not run (withAuth clamps to the projection
790
+ // either way, so this can only lower the charge).
791
+ reportCost();
660
792
 
661
793
  return {
662
794
  success: true,
663
795
  url: finalUrl,
796
+ ...escalationFields,
664
797
  content,
665
798
  warnings: warnings.length > 0 ? warnings : undefined
666
799
  };