crawlforge-mcp-server 6.17.1 → 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 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.17.1",
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.12.0",
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
@@ -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 { extractTextHandler } from "./src/tools/basic/extractText.js";
56
- import { extractLinksHandler } from "./src/tools/basic/extractLinks.js";
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.17.1",
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", extractTextHandler));
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 returns only outbound links. 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. Cost: 1 credit. Example: extract_links({url: \"https://example.com\", 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("Only return external links"),
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", extractLinksHandler));
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", {
@@ -1581,9 +1581,12 @@ export class ActionExecutor extends EventEmitter {
1581
1581
  // hostname-based checks alone would miss DNS-rebinding/private-IP targets.
1582
1582
  await assertUrlAllowed(url, { resolveDns: true });
1583
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.
1584
1587
  const response = await page.goto(url, {
1585
1588
  waitUntil: options.waitUntil || 'domcontentloaded',
1586
- timeout: options.timeout || 30000
1589
+ timeout: options.timeout || options.browserOptions?.timeout || 30000
1587
1590
  });
1588
1591
 
1589
1592
  // Re-validate where the navigation went: a redirect could have taken us
@@ -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';
@@ -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: 'Use scrape formats:["markdown"] (renders more pages); after a 403/429/challenge page use stealth_mode operation:"scrape".',
15
- extract_links: 'Use scrape formats:["links"]; for a whole site use map_site.',
14
+ extract_text: 'After a 403/429/444/challenge page call extract_text again with escalate:true (projected 6, charged 1 if the plain fetch gets through); for a JS-rendered page use scrape formats:["markdown"]; for a PDF use process_document.',
15
+ extract_links: 'After a 403/429/444/challenge page call extract_links again with escalate:true (projected 6, charged 1 if the plain fetch gets through); for a JS-rendered page use scrape formats:["links"]; for a whole site use map_site.',
16
16
  extract_metadata: 'Use scrape formats:["metadata"]; after a 403/429/challenge page use stealth_mode operation:"scrape".',
17
17
  extract_content: 'Use scrape formats:["markdown"] (same clean output, renders more pages); after a 403/429/challenge page use stealth_mode operation:"scrape".',
18
18
  extract_embedded_state: 'Call again without `path` to see the top-level keys, or use scrape formats:["markdown"] if the page has no framework payload.',
@@ -56,8 +56,8 @@ export const SCRAPE_ESCALATED_HINT =
56
56
  'If the block is regional, set a country with localization and try once more; otherwise the block is final - ' +
57
57
  'a TLS-level wall needs residential proxies, which CrawlForge does not offer. Get the content from another source.';
58
58
 
59
- /** The tools whose escalate:true runs the stealth stage itself (Phase 3; extract_embedded_state since plan Phase 3.2). */
60
- const ESCALATING_TOOLS = new Set(['scrape', 'extract_embedded_state']);
59
+ /** The tools whose escalate:true runs the stealth stage itself (Phase 3; extract_embedded_state since plan Phase 3.2; extract_text and extract_links since Phase E2). */
60
+ const ESCALATING_TOOLS = new Set(['scrape', 'extract_embedded_state', 'extract_text', 'extract_links']);
61
61
 
62
62
  /** The hint for this result: the tool's, unless the call already escalated. */
63
63
  function hintFor(toolName, parsed) {
@@ -20,8 +20,9 @@ export const MAX_INLINE_CHARS_PARAM = {
20
20
  /**
21
21
  * Per-tool rule. `textPaths` are dotted paths tried in order; the first one
22
22
  * holding a string becomes the text view the preview and read_result work
23
- * on. An empty list means the pretty-printed JSON is the view. `when` gates
24
- * on params. extract_embedded_state has a shape of its own (shapeEmbeddedState
23
+ * on. An empty list means the pretty-printed JSON is the view. `keep` names
24
+ * top-level fields carried inline past the scalar filter (keepNamedFields).
25
+ * `when` gates on params. extract_embedded_state has a shape of its own (shapeEmbeddedState
25
26
  * below).
26
27
  */
27
28
  /** The browser_session operations that hand back content worth shaping. */
@@ -37,7 +38,14 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
37
38
  // an async job's page came back as 111 KB whole (R20, 2026-09-07).
38
39
  get_batch_results: { textPaths: [], truncate: true },
39
40
  stealth_mode: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true, when: (params) => params?.operation === 'scrape' },
40
- scrape_with_actions: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true },
41
+ // `keep` names the top-level reports that stay inline past the scalar
42
+ // filter (keepNamedFields): a walled Guardian chain over 40,000 chars lost
43
+ // `blocked`, `navigations` and every action's outcome (2026-10-03).
44
+ scrape_with_actions: {
45
+ textPaths: ['content.markdown', 'content.text', 'content.html'],
46
+ truncate: true,
47
+ keep: ['error', 'blocked', 'consent', 'navigations', 'actionResults']
48
+ },
41
49
  // `read` hands back the same content shape scrape_with_actions does, and was
42
50
  // the one content-returning tool with no cap: a read of the World War II
43
51
  // article returned 541,308 characters inline where scrape returned 42,259
@@ -48,6 +56,7 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
48
56
  browser_session: {
49
57
  textPaths: ['content.markdown', 'content.text', 'content.html', 'snapshot.tree'],
50
58
  truncate: true,
59
+ keep: ['error', 'blocked', 'actionResults'],
51
60
  when: (params) => BROWSER_SESSION_CONTENT_OPERATIONS.has(params?.operation)
52
61
  },
53
62
  process_document: { textPaths: ['content.text'], truncate: true },
@@ -111,6 +120,42 @@ export function keepSmallContentFields(content, textPaths = [], maxInline = DEFA
111
120
  return Object.keys(kept).length > 0 ? kept : null;
112
121
  }
113
122
 
123
+ /**
124
+ * The top-level fields a tool names in `keep`, each carried inline when its
125
+ * JSON fits a quarter of the inline budget. An actionResults list over that
126
+ * keeps every entry without its `result` (a snapshot tree, an extracted
127
+ * value), so which action ran and whether it worked stays inline.
128
+ * @returns {{ kept: object|null, partial: string[], dropped: string[] }}
129
+ */
130
+ export function keepNamedFields(resultObject, keys = [], maxInline = DEFAULT_MAX_INLINE_CHARS) {
131
+ const cap = Math.max(1000, Math.floor(maxInline / 4));
132
+ const kept = {};
133
+ const partial = [];
134
+ const dropped = [];
135
+ for (const key of keys) {
136
+ const value = resultObject[key];
137
+ if (value === undefined) continue;
138
+ if (JSON.stringify(value).length <= cap) {
139
+ kept[key] = value;
140
+ continue;
141
+ }
142
+ if (key === 'actionResults' && Array.isArray(value)) {
143
+ const slim = value.map((entry) => {
144
+ if (!entry || typeof entry !== 'object') return entry;
145
+ const { result: _result, ...rest } = entry;
146
+ return rest;
147
+ });
148
+ if (JSON.stringify(slim).length <= cap) {
149
+ kept[key] = slim;
150
+ partial.push(key);
151
+ continue;
152
+ }
153
+ }
154
+ dropped.push(key);
155
+ }
156
+ return { kept: Object.keys(kept).length > 0 ? kept : null, partial, dropped };
157
+ }
158
+
114
159
  function warningsOf(resultObject) {
115
160
  return Array.isArray(resultObject.warnings) ? resultObject.warnings.filter((w) => typeof w === 'string') : [];
116
161
  }
@@ -156,7 +201,16 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
156
201
 
157
202
  const preview = text.slice(0, maxInline);
158
203
  const keptFields = keepSmallContentFields(resultObject.content, config.textPaths, maxInline);
159
- const keptDesc = keptFields ? `; content.${Object.keys(keptFields).join(', content.')} kept inline` : '';
204
+ const named = keepNamedFields(resultObject, config.keep, maxInline);
205
+ const keptNames = [
206
+ ...(named.kept ? Object.keys(named.kept).filter((key) => !named.partial.includes(key)) : []),
207
+ ...(keptFields ? Object.keys(keptFields).map((key) => `content.${key}`) : [])
208
+ ];
209
+ const keptDesc = [
210
+ keptNames.length > 0 ? `; ${keptNames.join(', ')} kept inline` : '',
211
+ named.partial.includes('actionResults') ? '; actionResults kept without each action\'s result (json_path "actionResults[<i>].result" reads one)' : '',
212
+ named.dropped.length > 0 ? `; ${named.dropped.join(', ')} too large to keep inline (json_path "${named.dropped[0]}" reads it)` : ''
213
+ ].join('');
160
214
  const hint = `Result is ${json.length} chars as JSON, over the inline limit of ${maxInline}; preview holds the first ${preview.length} chars of ${viewDesc} (${text.length} chars in total)${keptDesc} and the full result is kept for 1 hour under result_handle ${handle}: ${readWith}.`;
161
215
 
162
216
  const shaped = {};
@@ -171,6 +225,7 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
171
225
  if (resultObject.redaction && typeof resultObject.redaction === 'object') {
172
226
  shaped.redaction = resultObject.redaction;
173
227
  }
228
+ if (named.kept) Object.assign(shaped, named.kept);
174
229
  // The query-scoped formats live beside the page text under `content`
175
230
  // (highlights, answer, json, metadata, links). They are the small, exact
176
231
  // answer the caller paid for, and truncating the markdown must not drop
@@ -191,6 +246,14 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
191
246
  return { result: shaped, stored: true };
192
247
  }
193
248
 
249
+ /**
250
+ * Room for the `_cost` block withAuth adds to the text after shaping:
251
+ * pretty-printed, projected/actual/remaining_credits and the projection
252
+ * note, whose longest form for extract_embedded_state (escalate:true) is
253
+ * about 200 chars, about 340 in all.
254
+ */
255
+ export const COST_BLOCK_RESERVE = 400;
256
+
194
257
  /** Object.keys(data) for a plain object, "array(<n>)" for an array, null otherwise. */
195
258
  export function embeddedDataKeys(data) {
196
259
  if (Array.isArray(data)) return `array(${data.length})`;
@@ -227,10 +290,12 @@ export function wholeLinePreview(text, budget) {
227
290
  * everything that says what the page carries — url, path, bytes, the whole
228
291
  * `found` list, data_keys, the escalation report and window_state's list —
229
292
  * then a preview of the pretty-printed JSON cut at a line boundary. The
230
- * preview gets whatever max_inline_chars leaves after the rest, measured as
231
- * compact JSON, so the whole inline result stays within the limit whenever
232
- * the rest fits. The full result is under the handle for read_result
233
- * json_path. The REST route shapes it the same way; keep the two in step.
293
+ * preview gets whatever max_inline_chars leaves after the rest, measured the
294
+ * way withAuth sends it — pretty-printed, with COST_BLOCK_RESERVE held back
295
+ * for the `_cost` block it adds after shaping — so the text the client
296
+ * receives stays within the limit whenever the rest fits. The full result is
297
+ * under the handle for read_result json_path. The REST route shapes it the
298
+ * same way, measured as the compact JSON it sends; keep the two in step.
234
299
  */
235
300
  function shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith }) {
236
301
  const dataKeys = embeddedDataKeys(resultObject.data);
@@ -257,8 +322,9 @@ function shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expir
257
322
  expires_at,
258
323
  warnings: [...warningsOf(resultObject), hint]
259
324
  };
260
- // `"preview":"",` is what the key itself adds once the value is in.
261
- const envelope = JSON.stringify({ ...shaped, preview: '', ...tail }).length;
325
+ // The preview's value is one line of the pretty-printed text and escapes
326
+ // the same way there as in compact JSON; `""` is its empty value's quotes.
327
+ const envelope = JSON.stringify({ ...shaped, preview: '', ...tail }, null, 2).length + COST_BLOCK_RESERVE;
262
328
  const preview = wholeLinePreview(text, Math.max(0, maxInline - envelope + 2));
263
329
  return { ...shaped, preview, ...tail };
264
330
  }
@@ -8,8 +8,8 @@ metered; there is no free tier. Tools marked "scales" cost more as work grows.
8
8
  | Tool | Notes |
9
9
  |------|-------|
10
10
  | `fetch_url` | Raw HTTP fetch. |
11
- | `extract_text` | Tag-stripped text/markdown. |
12
- | `extract_links` | All links on a page. |
11
+ | `extract_text` | Tag-stripped text/markdown. `escalate:true` adds 5 as the projected ceiling for the stealth re-read; the actual charge drops back to 1 when the plain fetch succeeded and no escalation ran. |
12
+ | `extract_links` | All links on a page. `escalate:true` adds 5 as the projected ceiling for the stealth re-read; the actual charge drops back to 1 when the plain fetch succeeded and no escalation ran. |
13
13
  | `extract_metadata` | Title / meta / OG / schema.org. |
14
14
  | `scrape_template` | Pre-built site extractor. |
15
15
  | `list_ollama_models` | List local LLMs. |
@@ -158,6 +158,10 @@ Returns headers + body. Good for JSON/XML APIs or as a first step before
158
158
  `extract_text` (cost 1) = faster, strips tags; pass `output_format:"markdown"`
159
159
  for RAG-friendly output.
160
160
 
161
+ `extract_text` and `extract_links` take `escalate:true` for a site known to
162
+ block: the plain fetch runs first and the stealth browser re-reads the page
163
+ only if it is walled (projected 6, charged 1 when the plain fetch worked).
164
+
161
165
  ## extract_links / extract_metadata (cost: 1 each)
162
166
 
163
167
  ```json
@@ -34,14 +34,28 @@ your provider).
34
34
  | `remove_scripts` | boolean | `true` | Strip `script` tags. |
35
35
  | `remove_styles` | boolean | `true` | Strip `style` tags. |
36
36
  | `output_format` | enum | `text` | `text` or `markdown` (use markdown for RAG). |
37
+ | `selector` | string | — | CSS selector: read only the matched elements (nav/header/footer inside them are kept). No match is an error: `No elements found for selector: <sel>`. |
38
+ | `max_length` | integer | — | 1–1,000,000. Text or markdown longer than this is cut and ends with `...`; `word_count`/`char_count` count the cut result. |
39
+ | `escalate` | boolean | `false` | When the plain fetch is blocked (403/429/444/challenge page/empty shell), re-read the page once in the stealth browser (impit first under `auto`). A 404 or 5xx never escalates. Projected 6, charged 1 when the plain fetch worked. |
40
+ | `escalate_engine` | enum | `auto` | `auto`, `playwright` (Chromium) or `camoufox`. |
41
+
42
+ Text mode returns one line per block element (`<h1>Hi</h1><p>there</p>` reads `Hi\nthere`). The REST route (`/api/v1/tools/extract_text`) takes the same parameters plus `preserve_links` and `preserve_formatting`, which the MCP tool does not.
43
+
44
+ A PDF or other binary body fails with `UNSUPPORTED_CONTENT_TYPE` (use `process_document`). An empty client-rendered shell succeeds with `rendered:false` and the warning `page renders client-side; use scrape`.
37
45
 
38
46
  ## extract_links (cost: 1)
39
47
 
40
48
  | Param | Type | Default | Notes |
41
49
  |-------|------|---------|-------|
42
50
  | `url` | string (URL) | — | Required. |
43
- | `filter_external` | boolean | `false` | Only return outbound links. |
44
- | `base_url` | string (URL) | — | Resolve relative links against this. |
51
+ | `filter_external` | boolean | `false` | Drop internal (same-host) links; external and `other` links stay. |
52
+ | `base_url` | string (URL) | — | Resolve relative links against this. Without it, the page's `<base href>`, else the page URL. |
53
+ | `escalate` | boolean | `false` | When the plain fetch is blocked (403/429/444/challenge page/empty shell), re-read the page once in the stealth browser (impit first under `auto`). A 404 or 5xx never escalates. Projected 6, charged 1 when the plain fetch worked. |
54
+ | `escalate_engine` | enum | `auto` | `auto`, `playwright` (Chromium) or `camoufox`. |
55
+
56
+ Each link is `{ href, text, type, domain, rel, original_href }` (plus `title` when the link has one). `type` is `internal` (same host as the page), `external` or `other` (`mailto:`, `tel:`, `javascript:` and other non-web schemes; only `mailto:`/`tel:` keep an `href`, the rest are `null`). `#fragment`-only links are skipped, and links are deduplicated on the URL without its fragment or trailing slash. Counts: `total_count`, `internal_count`, `external_count`, `other_count`. The REST route returns the same records.
57
+
58
+ A PDF or other binary body fails with `UNSUPPORTED_CONTENT_TYPE` (use `process_document`). An empty client-rendered shell succeeds with `rendered:false` and the warning `page renders client-side; use scrape`.
45
59
 
46
60
  ## extract_metadata (cost: 1)
47
61
 
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Why a fetched document fails extract_links / extract_text, with the same
3
+ * verdict and wording as the REST routes (fix plan Phase E1).
4
+ *
5
+ * A named vendor's wall fails on any status: a challenge arrives as a 403 or
6
+ * as a 200 with the wall in the body. Any other non-2xx fails with its
7
+ * status. An empty shell or an error placeholder on a 2xx is not raised here;
8
+ * extraction proceeds as before.
9
+ */
10
+
11
+ import { load } from 'cheerio';
12
+ import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
13
+ import { pageTitle } from '../../utils/pageTitle.js';
14
+
15
+ // Response bodies a verdict can be read from; anything else is a binary. A
16
+ // missing Content-Type is read, as _fetchAndParse.js treats it as HTML.
17
+ const TEXTUAL_BODY = /^(?:text\/(?:html|plain)|application\/xhtml\+xml)\b/i;
18
+
19
+ /**
20
+ * The plain fetch's verdict on a document, or null for a body a verdict
21
+ * cannot be read from (a binary, JSON). The fetch ladder reads it to decide
22
+ * whether escalation could help (src/utils/fetchLadder.js).
23
+ * @param {Response & { _body: string }} response - from fetchWithTimeout
24
+ * @param {string} url - the requested URL
25
+ */
26
+ export function plainVerdict(response, url) {
27
+ const contentType = response.headers?.get?.('content-type');
28
+ if (contentType && !TEXTUAL_BODY.test(contentType)) return null;
29
+ // A parse of its own, scripts and styles removed, so the handler's
30
+ // document stays intact.
31
+ const $ = load(response._body ?? '');
32
+ $('script, style, noscript').remove();
33
+ return stealthDocumentVerdict(
34
+ {
35
+ url: response.url || url,
36
+ status: response.status,
37
+ title: pageTitle($),
38
+ text: $('body').text().replace(/\s+/g, ' ').trim(),
39
+ html: response._body
40
+ },
41
+ { fetcher: 'a plain fetch', rendered: false, contentReturned: false }
42
+ );
43
+ }
44
+
45
+ /**
46
+ * @param {Response & { _body: string }} response - from fetchWithTimeout
47
+ * @param {string} url - the requested URL
48
+ * @param {object|null} [verdict] - plainVerdict's, when the caller already has it
49
+ * @returns {string|null} the error message, or null when the document is usable
50
+ */
51
+ export function targetFailure(response, url, verdict = plainVerdict(response, url)) {
52
+ if (verdict?.blocked) return `Target answered HTTP ${response.status}: ${verdict.error}`;
53
+ return response.ok ? null : `Target answered HTTP ${response.status}`;
54
+ }
@@ -1,76 +1,84 @@
1
1
  /**
2
2
  * extract_links — Extract all links from a webpage with optional filtering.
3
3
  * Extracted from server.js inline handler.
4
+ * E3: Link records come from crawlforge-extractors' extractLinkRecords, the
5
+ * reader the REST route uses, so both surfaces return the same links.
4
6
  */
5
7
 
6
8
  import { load } from 'cheerio';
7
- import { fetchWithTimeout } from './_fetch.js';
9
+ import { extractLinkRecords } from 'crawlforge-extractors';
10
+ import { fetchLadder, ladderErrorResult, isJsonType } from '../../utils/fetchLadder.js';
8
11
 
9
12
  /**
10
13
  * @param {{ url: string, filter_external?: boolean, base_url?: string,
11
- * user_agent?: string, respect_robots?: boolean }} params
14
+ * user_agent?: string, respect_robots?: boolean,
15
+ * escalate?: boolean, escalate_engine?: string }} params
16
+ * @param {Function} [escalateFetch] the stealth escalation stage
12
17
  */
13
- export async function extractLinksHandler({ url, filter_external, base_url, user_agent, respect_robots }) {
18
+ async function extractLinks({ url, filter_external, base_url, user_agent, respect_robots, escalate, escalate_engine }, escalateFetch) {
14
19
  try {
15
- const response = await fetchWithTimeout(url, {
20
+ const ladder = await fetchLadder(url, {
21
+ tool: 'extract_links',
16
22
  userAgent: user_agent,
17
23
  respectRobots: respect_robots,
18
- tool: 'extract_links'
24
+ escalate: escalate === true,
25
+ escalateEngine: escalate_engine,
26
+ escalateFetch
19
27
  });
20
- if (!response.ok) {
21
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
28
+ if (ladder.html === undefined) return ladderErrorResult('extract_links', 'Failed to extract links: ', ladder);
29
+
30
+ // A JSON body carries no <a href>.
31
+ if (isJsonType(ladder.type)) {
32
+ return {
33
+ content: [{
34
+ type: 'text',
35
+ text: JSON.stringify({
36
+ links: [],
37
+ total_count: 0,
38
+ internal_count: 0,
39
+ external_count: 0,
40
+ other_count: 0,
41
+ base_url: base_url || ladder.url,
42
+ ...ladder.fields,
43
+ warnings: [...ladder.warnings, 'the target returned application/json; it has no HTML links']
44
+ }, null, 2)
45
+ }]
46
+ };
22
47
  }
23
48
 
24
- const html = await response.text();
49
+ const html = ladder.html;
25
50
  const $ = load(html);
26
51
 
27
- const finalUrl = response.url || url;
28
- const pageUrl = new URL(finalUrl);
52
+ const finalUrl = ladder.url;
29
53
 
30
- // <base href>, if present, overrides the page URL as the resolution base
31
- // for relative links (but an explicit base_url override wins over both).
54
+ // Reported base: an explicit base_url, else <base href> resolved against
55
+ // the page, else the page URL (extractLinkRecords resolves the same way).
32
56
  let docBase = finalUrl;
33
57
  const baseHref = $('base[href]').first().attr('href');
34
58
  if (baseHref) {
35
59
  try { docBase = new URL(baseHref, finalUrl).toString(); } catch { /* ignore invalid <base href> */ }
36
60
  }
37
-
38
61
  const baseUrl = base_url || docBase;
39
- const links = [];
40
-
41
- $('a[href]').each((_, element) => {
42
- const href = $(element).attr('href');
43
- const text = $(element).text().trim();
44
-
45
- // A javascript: pseudo-link ("Cookie Settings") is a button, not a
46
- // link; it was counted as an external link on boeing.com (R20).
47
- if (!href || /^\s*javascript:/i.test(href)) return;
48
62
 
49
- try {
50
- const absoluteUrl = new URL(href, baseUrl).toString();
51
- const isExternal = new URL(absoluteUrl).origin !== pageUrl.origin;
52
-
53
- if (filter_external && !isExternal) return;
54
-
55
- links.push({ href: absoluteUrl, text, is_external: isExternal, original_href: href });
56
- } catch {
57
- // skip invalid URLs
58
- }
59
- });
60
-
61
- const uniqueLinks = links.filter((link, index, arr) =>
62
- arr.findIndex(l => l.href === link.href) === index
63
- );
63
+ // Records are deduplicated on the URL without fragment or trailing slash;
64
+ // mailto:/tel:/javascript: links are type "other". filter_external drops
65
+ // only the internal ones.
66
+ const records = extractLinkRecords($, { pageUrl: finalUrl, baseUrl: base_url });
67
+ const links = filter_external ? records.filter(l => l.type !== 'internal') : records;
68
+ const count = (type) => links.filter(l => l.type === type).length;
64
69
 
65
70
  return {
66
71
  content: [{
67
72
  type: 'text',
68
73
  text: JSON.stringify({
69
- links: uniqueLinks,
70
- total_count: uniqueLinks.length,
71
- internal_count: uniqueLinks.filter(l => !l.is_external).length,
72
- external_count: uniqueLinks.filter(l => l.is_external).length,
73
- base_url: baseUrl
74
+ links,
75
+ total_count: links.length,
76
+ internal_count: count('internal'),
77
+ external_count: count('external'),
78
+ other_count: count('other'),
79
+ base_url: baseUrl,
80
+ ...ladder.fields,
81
+ ...(ladder.warnings.length > 0 ? { warnings: ladder.warnings } : {})
74
82
  }, null, 2)
75
83
  }]
76
84
  };
@@ -81,3 +89,15 @@ export async function extractLinksHandler({ url, filter_external, base_url, user
81
89
  };
82
90
  }
83
91
  }
92
+
93
+ /**
94
+ * @param {{ escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean }) => Promise<object> }} [deps]
95
+ * the stealth escalation stage (server.js `stealthEscalation`); without it
96
+ * an escalation that would run is reported as unavailable
97
+ */
98
+ export function createExtractLinksHandler({ escalateFetch } = {}) {
99
+ return (params) => extractLinks(params, escalateFetch);
100
+ }
101
+
102
+ /** The handler with no escalation stage wired (tests; server.js wires its own). */
103
+ export const extractLinksHandler = createExtractLinksHandler();
@@ -3,12 +3,15 @@
3
3
  * Extracted from server.js inline handler.
4
4
  * D3.1: Added output_format:"markdown" option backed by Turndown.
5
5
  * B1: Preserve block structure for text mode; use Readability + GFM for markdown mode.
6
+ * E3: Text mode reads through crawlforge-extractors' flattenText, as the REST
7
+ * route does; `selector` and `max_length` take the REST route's meaning.
6
8
  */
7
9
 
8
10
  import { load } from 'cheerio';
11
+ import { flattenText } from 'crawlforge-extractors';
9
12
  import { JSDOM } from 'jsdom';
10
13
  import { Readability } from '@mozilla/readability';
11
- import { fetchWithTimeout } from './_fetch.js';
14
+ import { fetchLadder, ladderErrorResult, isJsonType } from '../../utils/fetchLadder.js';
12
15
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
13
16
 
14
17
  // Block-level elements whose boundaries should become paragraph breaks
@@ -73,22 +76,61 @@ export function readabilityToMarkdown(html, pageUrl) {
73
76
  return htmlToMarkdown(articleHtml);
74
77
  }
75
78
 
79
+ /**
80
+ * Cut text to max_length characters and mark the cut with '...' (the REST rule).
81
+ * @param {string} text
82
+ * @param {number} [maxLength]
83
+ * @returns {string}
84
+ */
85
+ function truncate(text, maxLength) {
86
+ return maxLength && text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
87
+ }
88
+
76
89
  /**
77
90
  * @param {{ url: string, remove_scripts?: boolean, remove_styles?: boolean,
78
- * output_format?: "text"|"markdown", user_agent?: string, respect_robots?: boolean }} params
91
+ * output_format?: "text"|"markdown", selector?: string, max_length?: number,
92
+ * user_agent?: string, respect_robots?: boolean,
93
+ * escalate?: boolean, escalate_engine?: string }} params
94
+ * @param {Function} [escalateFetch] the stealth escalation stage
79
95
  */
80
- export async function extractTextHandler({ url, remove_scripts, remove_styles, output_format, user_agent, respect_robots }) {
96
+ async function extractText({ url, remove_scripts, remove_styles, output_format, selector, max_length, user_agent, respect_robots, escalate, escalate_engine }, escalateFetch) {
81
97
  try {
82
- const response = await fetchWithTimeout(url, {
98
+ const ladder = await fetchLadder(url, {
99
+ tool: 'extract_text',
83
100
  userAgent: user_agent,
84
101
  respectRobots: respect_robots,
85
- tool: 'extract_text'
102
+ escalate: escalate === true,
103
+ escalateEngine: escalate_engine,
104
+ escalateFetch
86
105
  });
87
- if (!response.ok) {
88
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
106
+ if (ladder.html === undefined) return ladderErrorResult('extract_text', 'Failed to extract text: ', ladder);
107
+
108
+ const extras = {
109
+ ...ladder.fields,
110
+ ...(ladder.warnings.length > 0 ? { warnings: ladder.warnings } : {})
111
+ };
112
+
113
+ // A JSON body has no markup to strip: it is returned as it came.
114
+ if (isJsonType(ladder.type)) {
115
+ const body = truncate(ladder.html, max_length);
116
+ const format = output_format === 'markdown' ? 'markdown' : 'text';
117
+ return {
118
+ content: [{
119
+ type: 'text',
120
+ text: JSON.stringify({
121
+ url: ladder.url,
122
+ [format]: body,
123
+ output_format: format,
124
+ word_count: body.split(/\s+/).filter(w => w.length > 0).length,
125
+ char_count: body.length,
126
+ ...ladder.fields,
127
+ warnings: [...ladder.warnings, 'the target returned application/json; its body is returned as text']
128
+ }, null, 2)
129
+ }]
130
+ };
89
131
  }
90
132
 
91
- const html = await response.text();
133
+ const html = ladder.html;
92
134
  const $ = load(html);
93
135
 
94
136
  if (remove_scripts !== false) $('script').remove();
@@ -101,21 +143,32 @@ export async function extractTextHandler({ url, remove_scripts, remove_styles, o
101
143
  // enabled never render noscript content, so always strip it.
102
144
  $('noscript').remove();
103
145
 
104
- $('nav, header, footer, aside, .advertisement, .ad, .sidebar').remove();
146
+ // A selector names the content, so the boilerplate strip is skipped.
147
+ let $target;
148
+ if (selector) {
149
+ $target = $(selector);
150
+ if ($target.length === 0) throw new Error(`No elements found for selector: ${selector}`);
151
+ } else {
152
+ $('nav, header, footer, aside, .advertisement, .ad, .sidebar').remove();
153
+ }
105
154
 
106
155
  const result = {
107
- url: response.url
156
+ url: ladder.url
108
157
  };
109
158
 
110
159
  if (output_format === 'markdown') {
111
- // Run Readability first to get main content, then convert to GFM markdown
112
- result.markdown = readabilityToMarkdown(html, response.url);
160
+ // Run Readability first to get main content, then convert to GFM markdown;
161
+ // a selector's matches are converted as they are.
162
+ const markdown = $target
163
+ ? htmlToMarkdown($target.toArray().map(el => $.html(el)).join('\n'))
164
+ : readabilityToMarkdown(html, ladder.url);
165
+ result.markdown = truncate(markdown, max_length);
113
166
  result.output_format = 'markdown';
114
167
  const plainText = result.markdown.replace(/[#*`_\[\]]/g, '').replace(/\s+/g, ' ').trim();
115
168
  result.word_count = plainText.split(/\s+/).filter(w => w.length > 0).length;
116
169
  result.char_count = plainText.length;
117
170
  } else {
118
- const text = extractBlockText($);
171
+ const text = truncate(flattenText($, $target), max_length);
119
172
  result.text = text;
120
173
  result.output_format = 'text';
121
174
  result.word_count = text.split(/\s+/).filter(w => w.length > 0).length;
@@ -125,7 +178,7 @@ export async function extractTextHandler({ url, remove_scripts, remove_styles, o
125
178
  return {
126
179
  content: [{
127
180
  type: 'text',
128
- text: JSON.stringify(result, null, 2)
181
+ text: JSON.stringify({ ...result, ...extras }, null, 2)
129
182
  }]
130
183
  };
131
184
  } catch (error) {
@@ -135,3 +188,15 @@ export async function extractTextHandler({ url, remove_scripts, remove_styles, o
135
188
  };
136
189
  }
137
190
  }
191
+
192
+ /**
193
+ * @param {{ escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean }) => Promise<object> }} [deps]
194
+ * the stealth escalation stage (server.js `stealthEscalation`); without it
195
+ * an escalation that would run is reported as unavailable
196
+ */
197
+ export function createExtractTextHandler({ escalateFetch } = {}) {
198
+ return (params) => extractText(params, escalateFetch);
199
+ }
200
+
201
+ /** The handler with no escalation stage wired (tests; server.js wires its own). */
202
+ export const extractTextHandler = createExtractTextHandler();
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { load } from 'cheerio';
14
- import { readBody } from 'crawlforge-extractors';
14
+ import { readBody, flattenText } from 'crawlforge-extractors';
15
15
  import { safeFetch } from '../../utils/ssrfGuard.js';
16
16
  import { config } from '../../constants/config.js';
17
17
  import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
@@ -83,35 +83,11 @@ function classifyContentType(contentType) {
83
83
  }
84
84
 
85
85
  /**
86
- * Flatten a parsed document's <body> to text while preserving line structure.
87
- *
88
- * Cheerio's .text() joins adjacent elements with no separator, which welds
89
- * table rows / list items together ("1.Story title329 points") and starves
90
- * downstream LLM extraction (AgentOrchestrator, extractStructured,
91
- * extractWithLlm) of any structure to parse. Works on a detached clone so the
92
- * caller's $ tree is untouched — unifiedScrape reuses $ for other formats.
93
- *
94
- * @param {import('cheerio').CheerioAPI} $
95
- * @returns {string}
86
+ * Flatten a parsed document's <body> to text, one line per block element.
87
+ * The flattener moved to crawlforge-extractors (`flattenText`) so the REST
88
+ * extract_text route shares it; this name stays for extractStructured.
96
89
  */
97
- export function flattenBodyText($) {
98
- // Block boundaries are marked with a U+E000 private-use sentinel so the
99
- // HTML source's own insignificant newlines can be collapsed to spaces
100
- // first, and only the sentinels become line breaks. (NUL won't survive:
101
- // .after()/.replaceWith() parse their argument as HTML and the parser
102
- // strips NUL; U+E000 passes through and never occurs in real page text.)
103
- const $body = $('body').clone();
104
- $body.find('br').replaceWith('\uE000');
105
- $body.find('td, th').after(' ');
106
- $body
107
- .find('p, div, li, tr, h1, h2, h3, h4, h5, h6, blockquote, pre, table, ul, ol, dl, section, article, header, footer')
108
- .after('\uE000');
109
- return $body
110
- .text()
111
- .replace(/\s+/g, ' ')
112
- .replace(/ ?(?:\uE000 ?)+/g, '\n')
113
- .trim();
114
- }
90
+ export { flattenText as flattenBodyText };
115
91
 
116
92
  /**
117
93
  * Fetch a URL and return parsed HTML via Cheerio.
@@ -190,7 +166,7 @@ export async function fetchAndParse(url, options = {}) {
190
166
  $(stripTags.join(', ')).remove();
191
167
  }
192
168
 
193
- const textContent = flattenBodyText($);
169
+ const textContent = flattenText($);
194
170
 
195
171
  return { html, $, textContent, finalUrl: response.url, warnings, status: response.status };
196
172
  }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * fetchLadder — scrape's fetch ladder for extract_text and extract_links (fix
3
+ * plan Phase E2). Same rules and wording as the website's
4
+ * src/lib/tools/fetch-ladder.ts — keep the two in step.
5
+ *
6
+ * 1. A plain fetch (15 s), retried once when a 429/503 asks for at most
7
+ * 10 s with Retry-After.
8
+ * 2. A 2xx body that is not text (a PDF, an image, an archive) stops here as
9
+ * UNSUPPORTED_CONTENT_TYPE; it never escalates.
10
+ * 3. Only with escalate:true, and only when a browser could change the
11
+ * outcome (aBrowserMightPass, or a 444 refusal): the injected stealth
12
+ * stage — server.js `stealthEscalation`, the one scrape and
13
+ * extract_embedded_state run, behind the same compliance gate (impit
14
+ * first under "auto", then the browser).
15
+ * 4. Otherwise E1's verdict stands unchanged (targetFailure).
16
+ *
17
+ * scrape and extract_embedded_state keep their own copies of steps 3–4; this
18
+ * reuses their exported pieces rather than moving them.
19
+ */
20
+
21
+ import { load } from 'cheerio';
22
+ import { z } from 'zod';
23
+ import { fetchWithTimeout } from '../tools/basic/_fetch.js';
24
+ import { plainVerdict, targetFailure } from '../tools/basic/_targetFailure.js';
25
+ import { stealthDocumentVerdict } from './stealthVerdict.js';
26
+ import { parseRetryAfter } from './hostRateLimiter.js';
27
+ import { setActualCost } from '../server/requestContext.js';
28
+ import { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS, aBrowserMightPass } from '../tools/scrape/escalation.js';
29
+
30
+ /** extract_text's and extract_links's table price; escalation adds SCRAPE_ESCALATION_CREDITS. */
31
+ const EXTRACT_BASE_CREDITS = 1;
32
+
33
+ export const LADDER_TIMEOUT_MS = 15000;
34
+
35
+ /** A Retry-After longer than this is not waited out inside one call. */
36
+ export const RETRY_AFTER_MAX_MS = 10000;
37
+
38
+ export const CLIENT_RENDERED_WARNING = 'page renders client-side; use scrape';
39
+
40
+ /** The two public fields both tools take, in tools/list order. */
41
+ export const EXTRACT_ESCALATION_SHAPE = {
42
+ escalate: z.boolean().optional().default(false).describe('When the plain fetch comes back blocked (403/429/444/challenge page/empty shell), re-read the page once in the stealth browser and extract from the rendered document. Under escalate_engine "auto" a Chrome TLS handshake with the honest CrawlForge User-Agent (impit) is tried first, and the browser runs only when that does not get the page. A 404 or 5xx never escalates. Projected at 1+5; the actual charge stays at 1 when the plain fetch succeeded. Default: false'),
43
+ escalate_engine: SCRAPE_ESCALATION_SHAPE.escalate_engine
44
+ };
45
+
46
+ // Bodies these tools read. A missing Content-Type is read as HTML.
47
+ const TEXTUAL_TYPE = /^(?:text\/[\w.+-]+|application\/(?:xhtml\+xml|xml|json)|application\/[\w.+-]+\+(?:xml|json))$/;
48
+ const JSON_TYPE = /^application\/(?:[\w.+-]+\+)?json$/;
49
+
50
+ /** The media type without parameters, lowercased; '' when absent. */
51
+ function mediaType(response) {
52
+ return (response.headers?.get?.('content-type') ?? '').split(';')[0].trim().toLowerCase();
53
+ }
54
+
55
+ /** Whether a media type is JSON (application/json or a +json suffix). */
56
+ export function isJsonType(type) {
57
+ return JSON_TYPE.test(type);
58
+ }
59
+
60
+ // A page that says it needs JavaScript (scrapingcourse.com's
61
+ // /javascript-rendering serves "Enable JavaScript to see products" over empty
62
+ // product cards, with no mount point to find).
63
+ // No \b before the verbs: cheerio's text() joins <h1>Challenge</h1><p>Enable…
64
+ // into one word.
65
+ const JS_REQUIRED = /(?:enable|requires?|turn on)\s+javascript\b|\bjavascript\s+(?:is\s+)?(?:required|disabled)\b/i;
66
+
67
+ /**
68
+ * A client-rendered shell: under 200 characters of visible text, and either an
69
+ * empty <div id="root">, "app" or "__next" for the framework to mount into or
70
+ * text saying the page needs JavaScript.
71
+ * @param {string} html
72
+ */
73
+ export function isClientRenderedShell(html) {
74
+ const $ = load(html);
75
+ $('script, style, noscript').remove();
76
+ const text = $('body').text().replace(/\s+/g, ' ').trim();
77
+ if (text.length >= 200) return false;
78
+ if (JS_REQUIRED.test(text)) return true;
79
+ return $('div#root, div#app, div#__next').toArray().some((el) => $(el).text().trim() === '');
80
+ }
81
+
82
+ /**
83
+ * Retry-After in ms, or null when the header is absent or unreadable.
84
+ * @param {Response} response
85
+ */
86
+ function retryAfterMs(response) {
87
+ const header = response.headers?.get?.('retry-after');
88
+ if (header == null) return null;
89
+ const raw = String(header).trim();
90
+ if (!/^\d+$/.test(raw) && Number.isNaN(Date.parse(raw))) return null;
91
+ return parseRetryAfter(raw);
92
+ }
93
+
94
+ /**
95
+ * The plain fetch, retried once on a 429/503 whose Retry-After is at most
96
+ * RETRY_AFTER_MAX_MS. fetchWithTimeout has already recorded the Retry-After
97
+ * (noteRetryAfter), and the second request's gate waits it out in the
98
+ * per-host throttle — so the wait happens once, there.
99
+ */
100
+ async function plainFetch(url, options) {
101
+ const response = await fetchWithTimeout(url, options);
102
+ if (response.status !== 429 && response.status !== 503) return response;
103
+ const waitMs = retryAfterMs(response);
104
+ if (waitMs === null || waitMs > RETRY_AFTER_MAX_MS) return response;
105
+ return fetchWithTimeout(url, options);
106
+ }
107
+
108
+ /**
109
+ * @param {string} url
110
+ * @param {{ tool: 'extract_text'|'extract_links', userAgent?: string, respectRobots?: boolean,
111
+ * escalate?: boolean, escalateEngine?: string,
112
+ * escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean }) => Promise<object> }} options
113
+ * @returns {Promise<
114
+ * { unsupported: string } |
115
+ * { error: string, url: string, status?: number|null, blocked?: object, warnings: string[], fields: object } |
116
+ * { html: string, url: string, type: string, warnings: string[], fields: object }>}
117
+ * `fields` carries `escalated` (only when escalate was asked for), `stealth`
118
+ * (when the stage ran) and `rendered: false` (a client-rendered shell)
119
+ */
120
+ export async function fetchLadder(url, { tool, userAgent, respectRobots, escalate = false, escalateEngine = 'auto', escalateFetch } = {}) {
121
+ let escalationRan = false;
122
+ try {
123
+ const response = await plainFetch(url, { timeout: LADDER_TIMEOUT_MS, userAgent, respectRobots, tool });
124
+ const type = mediaType(response);
125
+ if (response.ok && type && !TEXTUAL_TYPE.test(type)) return { unsupported: type };
126
+
127
+ const warnings = [...(response._warnings ?? [])];
128
+ // Reported only to a caller who asked to escalate.
129
+ const fields = escalate ? { escalated: false } : {};
130
+ const verdict = plainVerdict(response, url);
131
+ const failed = verdict ? !verdict.success : !response.ok;
132
+
133
+ // A 404 or a 5xx never escalates (aBrowserMightPass); a 444 is a refusal
134
+ // of our network, which a browser on another identity can pass. A host
135
+ // whose Retry-After is still open (over 10 s, or repeated on the retry)
136
+ // asked us to back off, and a stealth request now would override it (G6).
137
+ const backingOff = (response.status === 429 || response.status === 503) && retryAfterMs(response) !== null;
138
+ if (escalate && failed && !backingOff && (aBrowserMightPass(verdict, response.status) || response.status === 444)) {
139
+ const vendorDetected = verdict?.blocked?.vendor ?? null;
140
+ if (!escalateFetch) {
141
+ warnings.push('escalate: no stealth stage is wired into this server build');
142
+ } else {
143
+ try {
144
+ const stealth = await escalateFetch({ url: response.url || url, engine: escalateEngine, respectRobots });
145
+ escalationRan = true;
146
+ const engine = stealth.engine || escalateEngine;
147
+ // The stage's gate repeats the plain fetch's robots notes; say each once.
148
+ for (const w of stealth.warnings ?? []) if (!warnings.includes(w)) warnings.push(w);
149
+ const html = stealth.html || '';
150
+ const finalUrl = stealth.url || response.url || url;
151
+ const status = stealth.status ?? null;
152
+ const rendered = stealthDocumentVerdict(
153
+ { url: finalUrl, title: stealth.title || '', text: stealth.text || '', html, status },
154
+ { waitedMs: stealth.gracedMs || 0, fetcher: 'the stealth browser', rendered: true, contentReturned: false }
155
+ );
156
+ warnings.push(
157
+ `escalate: the plain fetch ${vendorDetected ? `was blocked by ${vendorDetected}` : 'did not return the page'}; ` +
158
+ `${engine === 'impit' ? 'a Chrome TLS handshake (impit, honest User-Agent)' : `the ${engine} stealth browser`} ${rendered.success ? 'returned it' : 'did not get it either'}`
159
+ );
160
+ fields.escalated = true;
161
+ fields.stealth = { engine, vendor_detected: vendorDetected };
162
+ if (!rendered.success) {
163
+ return { error: rendered.error, url: finalUrl, status, ...(rendered.blocked ? { blocked: rendered.blocked } : {}), warnings, fields };
164
+ }
165
+ return { html, url: finalUrl, type: 'text/html', warnings, fields };
166
+ } catch (err) {
167
+ // A robots refusal inside the gate stamps markPreflightRefusal, and
168
+ // withAuth then bills the whole call zero: nothing was rendered.
169
+ warnings.push(`escalate: the stealth retry did not run — ${err.message}`);
170
+ }
171
+ }
172
+ }
173
+
174
+ const failure = targetFailure(response, url, verdict);
175
+ if (failure) {
176
+ return { error: failure, url: response.url || url, status: response.status, ...(verdict?.blocked ? { blocked: verdict.blocked } : {}), warnings, fields };
177
+ }
178
+
179
+ const html = response._body ?? '';
180
+ if (!isJsonType(type) && isClientRenderedShell(html)) {
181
+ warnings.push(CLIENT_RENDERED_WARNING);
182
+ fields.rendered = false;
183
+ }
184
+ return { html, url: response.url || url, type, warnings, fields };
185
+ } finally {
186
+ // The projection is 1+5 when escalating; the stage's 5 is owed only if it ran.
187
+ if (escalate) setActualCost(EXTRACT_BASE_CREDITS + (escalationRan ? SCRAPE_ESCALATION_CREDITS : 0));
188
+ }
189
+ }
190
+
191
+ /**
192
+ * The error result for a ladder that did not return a page. Unescalated, the
193
+ * E1 text error; with escalate asked for, a JSON body carrying `escalated`,
194
+ * so withAuth's hint does not send the caller back to the stealth browser.
195
+ * @param {string} tool
196
+ * @param {string} prefix e.g. 'Failed to extract text: '
197
+ * @param {object} ladder fetchLadder's result
198
+ */
199
+ export function ladderErrorResult(tool, prefix, ladder) {
200
+ if (ladder.unsupported) {
201
+ return {
202
+ content: [{
203
+ type: 'text',
204
+ text: `${prefix}UNSUPPORTED_CONTENT_TYPE: Unsupported content type ${ladder.unsupported}: ${tool} reads ${tool === 'extract_links' ? 'HTML' : 'HTML and text'}\n` +
205
+ 'Next step: process_document reads PDF and DOCX documents from a URL (2 credits); an image or an archive has no text to extract.'
206
+ }],
207
+ isError: true
208
+ };
209
+ }
210
+ if (!('escalated' in ladder.fields)) {
211
+ return { content: [{ type: 'text', text: `${prefix}${ladder.error}` }], isError: true };
212
+ }
213
+ return {
214
+ content: [{
215
+ type: 'text',
216
+ text: JSON.stringify({
217
+ success: false,
218
+ url: ladder.url,
219
+ ...(typeof ladder.status === 'number' ? { status: ladder.status } : {}),
220
+ error: ladder.error,
221
+ ...(ladder.blocked ? { blocked: ladder.blocked } : {}),
222
+ ...ladder.fields,
223
+ ...(ladder.warnings.length > 0 ? { warnings: ladder.warnings } : {})
224
+ }, null, 2)
225
+ }],
226
+ isError: true
227
+ };
228
+ }