crawlforge-mcp-server 6.17.1 → 6.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -2
  3. package/server.js +18 -9
  4. package/src/core/ActionExecutor.js +57 -1
  5. package/src/core/AgentOrchestrator.js +58 -3
  6. package/src/core/AuthManager.js +12 -0
  7. package/src/core/crawlers/BFSCrawler.js +7 -0
  8. package/src/core/queue/QueueManager.js +3 -0
  9. package/src/schemas/toolOutputSchemas.js +6 -4
  10. package/src/server/fallbackHints.js +4 -4
  11. package/src/server/inlineThreshold.js +76 -10
  12. package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +1 -1
  13. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +2 -2
  14. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +4 -0
  15. package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +16 -2
  16. package/src/tools/advanced/BrowserSessionTool.js +13 -1
  17. package/src/tools/advanced/ScrapeWithActionsTool.js +14 -3
  18. package/src/tools/advanced/batchScrape/worker.js +8 -0
  19. package/src/tools/advanced/scrapeWithActions/recorder.js +2 -0
  20. package/src/tools/basic/_targetFailure.js +54 -0
  21. package/src/tools/basic/extractLinks.js +62 -42
  22. package/src/tools/basic/extractText.js +79 -14
  23. package/src/tools/crawl/crawlDeep.js +16 -5
  24. package/src/tools/crawl/mapSite.js +49 -41
  25. package/src/tools/extract/_fetchAndParse.js +15 -34
  26. package/src/tools/scrape/unifiedScrape.js +14 -2
  27. package/src/tools/tracking/trackChanges/index.js +33 -6
  28. package/src/tools/tracking/trackChanges/schema.js +2 -2
  29. package/src/utils/fetchLadder.js +243 -0
@@ -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';
@@ -58,6 +58,13 @@ async function readTextWithSizeCap(response) {
58
58
  return readBody(response, { maxBytes: maxBodySize });
59
59
  }
60
60
 
61
+ /** The refusal for a binary response; batch_scrape gives the same one (R24 1.5). */
62
+ export function unsupportedContentTypeError(contentType) {
63
+ return new Error(
64
+ `Unsupported content type "${contentType}" — this looks like binary content, not HTML/text. Use process_document for PDFs/documents/binary files.`
65
+ );
66
+ }
67
+
61
68
  /**
62
69
  * Classify a Content-Type header for the purposes of HTML parsing.
63
70
  * Missing header is treated as 'html' (permissive default — many servers,
@@ -65,7 +72,7 @@ async function readTextWithSizeCap(response) {
65
72
  * @param {string|null} contentType
66
73
  * @returns {'html'|'text'|'binary'}
67
74
  */
68
- function classifyContentType(contentType) {
75
+ export function classifyContentType(contentType) {
69
76
  if (!contentType) return 'html';
70
77
  const type = contentType.split(';')[0].trim().toLowerCase();
71
78
  if (
@@ -83,35 +90,11 @@ function classifyContentType(contentType) {
83
90
  }
84
91
 
85
92
  /**
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}
93
+ * Flatten a parsed document's <body> to text, one line per block element.
94
+ * The flattener moved to crawlforge-extractors (`flattenText`) so the REST
95
+ * extract_text route shares it; this name stays for extractStructured.
96
96
  */
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
- }
97
+ export { flattenText as flattenBodyText };
115
98
 
116
99
  /**
117
100
  * Fetch a URL and return parsed HTML via Cheerio.
@@ -170,9 +153,7 @@ export async function fetchAndParse(url, options = {}) {
170
153
  }
171
154
 
172
155
  if (classification === 'binary') {
173
- throw new Error(
174
- `Unsupported content type "${contentType}" — this looks like binary content, not HTML/text. Use process_document for PDFs/documents/binary files.`
175
- );
156
+ throw unsupportedContentTypeError(contentType);
176
157
  }
177
158
 
178
159
  const html = await readTextWithSizeCap(response);
@@ -190,7 +171,7 @@ export async function fetchAndParse(url, options = {}) {
190
171
  $(stripTags.join(', ')).remove();
191
172
  }
192
173
 
193
- const textContent = flattenBodyText($);
174
+ const textContent = flattenText($);
194
175
 
195
176
  return { html, $, textContent, finalUrl: response.url, warnings, status: response.status };
196
177
  }
@@ -699,16 +699,19 @@ export class UnifiedScrapeTool {
699
699
  case 'text':
700
700
  try {
701
701
  const { load } = await import('cheerio');
702
+ // cheerio parses a <noscript> body as raw text, so left in it
703
+ // reaches `text` as literal tags (lemonde.fr's challenge page,
704
+ // 2026-10-03); extract_text and the markdown format drop it too.
702
705
  if (onlyMainContent) {
703
706
  // Plain text from Readability main content via cheerio
704
707
  const $main = load(getMainHtml());
705
- $main('script, style').remove();
708
+ $main('script, style, noscript').remove();
706
709
  content.text = extractBlockText($main);
707
710
  } else {
708
711
  // Strip script/style on a clone, not the shared $, so other
709
712
  // formats reading $ later aren't affected by format ordering.
710
713
  const $clone = load($.html());
711
- $clone('script, style').remove();
714
+ $clone('script, style, noscript').remove();
712
715
  content.text = extractBlockText($clone);
713
716
  }
714
717
  } catch (err) {
@@ -790,6 +793,15 @@ export class UnifiedScrapeTool {
790
793
  if (queryScoped && !formats.includes('markdown')) {
791
794
  warnings.push('offsets index the "markdown" format of this call (same onlyMainContent); add "markdown" to formats to quote with a locator');
792
795
  }
796
+ // A success with nothing in it must say so: the caller otherwise cannot
797
+ // tell an empty page from a scrape that lost the content.
798
+ if (typeof content.markdown === 'string' && content.markdown.trim() === '' && !warnings.some((w) => w.startsWith('markdown:'))) {
799
+ warnings.push(
800
+ 'markdown: the page produced no markdown' +
801
+ (onlyMainContent ? '; retry with onlyMainContent:false if main-content extraction dropped it, or' : ';') +
802
+ ' use escalate:true if the page is rendered by JavaScript'
803
+ );
804
+ }
793
805
  if (!queryScoped && typeof content.markdown === 'string' && content.markdown.length > OVERSIZED_MARKDOWN_CHARS) {
794
806
  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`);
795
807
  }
@@ -308,6 +308,15 @@ export class TrackChangesTool extends EventEmitter {
308
308
  async setupMonitoring(params) {
309
309
  const { url, monitoringOptions, trackingOptions, storageOptions, notificationOptions } = params;
310
310
 
311
+ // enabled:false turns the URL's polling monitor off and starts nothing.
312
+ if (monitoringOptions.enabled === false) {
313
+ return {
314
+ success: true, operation: 'monitor', url,
315
+ monitoring: { enabled: false, stopped: this.stopMonitoring(url) },
316
+ timestamp: Date.now()
317
+ };
318
+ }
319
+
311
320
  if (this.activeMonitors.has(url)) {
312
321
  clearInterval(this.activeMonitors.get(url).timer);
313
322
  }
@@ -498,6 +507,15 @@ export class TrackChangesTool extends EventEmitter {
498
507
  async stopScheduledMonitor(params) {
499
508
  const { url, scheduledMonitorOptions } = params;
500
509
  const monitorId = scheduledMonitorOptions?.monitorId;
510
+ // A polling monitor (operation:"monitor") is listed as poll:<url>.
511
+ if (monitorId?.startsWith('poll:')) {
512
+ const stopped = this.stopMonitoring(monitorId.slice(5));
513
+ return {
514
+ success: stopped, operation: 'stop_scheduled_monitor', monitorId, stopped,
515
+ ...(stopped ? {} : { error: `No polling monitor found with id ${monitorId}` }),
516
+ timestamp: Date.now()
517
+ };
518
+ }
501
519
  if (monitorId) {
502
520
  if (!this.monitorStore._loaded) await this.monitorStore.load();
503
521
  if (this.monitorStore.get(monitorId)) {
@@ -517,6 +535,7 @@ export class TrackChangesTool extends EventEmitter {
517
535
  }
518
536
  if (!url) throw new Error('stop_scheduled_monitor requires a url or scheduledMonitorOptions.monitorId');
519
537
  const result = await this.scheduler.stopByUrl(url);
538
+ const stoppedPolling = this.stopMonitoring(url) ? 1 : 0;
520
539
  // Only a hosted monitor whose every target is this URL — never a
521
540
  // multi-target monitor that merely includes it.
522
541
  let stoppedHosted = 0;
@@ -533,7 +552,7 @@ export class TrackChangesTool extends EventEmitter {
533
552
  hostedError = error.message;
534
553
  }
535
554
  return {
536
- success: true, operation: 'stop_scheduled_monitor', url, stoppedMonitors: result.stopped, stoppedHosted,
555
+ success: true, operation: 'stop_scheduled_monitor', url, stoppedMonitors: result.stopped, stoppedPolling, stoppedHosted,
537
556
  ...(hostedError ? { hostedError } : {}),
538
557
  timestamp: Date.now()
539
558
  };
@@ -541,7 +560,10 @@ export class TrackChangesTool extends EventEmitter {
541
560
 
542
561
  async listScheduledMonitors() {
543
562
  if (!this.monitorStore._loaded) await this.monitorStore.load();
544
- const local = this.scheduler.list().map((m) => ({ ...m, hosted: false }));
563
+ const local = [
564
+ ...this._pollingMonitors().map((m) => ({ ...m, hosted: false })),
565
+ ...this.scheduler.list().map((m) => ({ ...m, hosted: false }))
566
+ ];
545
567
  // The local list never fails because the website is unreachable.
546
568
  let hosted = [];
547
569
  let hostedError = null;
@@ -560,16 +582,21 @@ export class TrackChangesTool extends EventEmitter {
560
582
  };
561
583
  }
562
584
 
585
+ /** The in-process polling monitors operation:"monitor" started, as the dashboard and the list show them. */
586
+ _pollingMonitors() {
587
+ return Array.from(this.activeMonitors.entries()).map(([url, m]) => ({
588
+ id: `poll:${url}`, url, kind: 'polling', status: 'active',
589
+ interval: m.options?.interval ?? null, stats: m.stats
590
+ }));
591
+ }
592
+
563
593
  async getMonitoringDashboard(params) {
564
594
  // The schema defaults every flag to true, but only inside a dashboardOptions
565
595
  // object the caller supplied; an omitted object must mean the same thing.
566
596
  const dashboardOptions = { includeRecentAlerts: true, includeTrends: true, includeMonitorStatus: true, ...(params.dashboardOptions || {}) };
567
597
  const dashboard = this.changeTracker.getMonitoringDashboard();
568
598
  // The tracker only knows tracked pages; the monitors that poll them live here.
569
- const polling = Array.from(this.activeMonitors.entries()).map(([url, m]) => ({
570
- id: `poll:${url}`, url, kind: 'polling', status: 'active',
571
- interval: m.options?.interval ?? null, stats: m.stats
572
- }));
599
+ const polling = this._pollingMonitors();
573
600
  if (!this.monitorStore._loaded) await this.monitorStore.load();
574
601
  const scheduled = this.scheduler.list().map(m => ({
575
602
  id: m.id, url: m.url, kind: 'scheduled',
@@ -57,7 +57,7 @@ export const TRACK_CHANGES_INPUT_SHAPE = {
57
57
  }).optional().prefault({}).describe("Options for how changes are tracked and compared"),
58
58
 
59
59
  monitoringOptions: z.object({
60
- enabled: z.boolean().default(false),
60
+ enabled: z.boolean().default(true).describe("operation:\"monitor\" starts polling the URL; false stops the URL's polling monitor and starts nothing"),
61
61
  interval: z.number().min(60000).max(24 * 60 * 60 * 1000).default(300000),
62
62
  maxRetries: z.number().min(0).max(5).default(3),
63
63
  retryDelay: z.number().min(1000).max(60000).default(5000),
@@ -113,7 +113,7 @@ export const TRACK_CHANGES_INPUT_SHAPE = {
113
113
  enabled: z.boolean().default(true),
114
114
  interval: z.number().min(60000).optional().describe("Polling interval in ms (default 1h)"),
115
115
  goal: z.string().optional().describe("Plain-English alert goal; an LLM judges whether a change matches (degrades to threshold if no LLM)"),
116
- monitorId: z.string().optional().describe("Monitor id for stop_scheduled_monitor"),
116
+ monitorId: z.string().optional().describe("Monitor id for stop_scheduled_monitor, as list_scheduled_monitors shows it (poll:<url> for a polling monitor)"),
117
117
  notificationThreshold: z.enum(['minor', 'moderate', 'major', 'critical']).optional(),
118
118
  hosted: z.boolean().default(false).describe("Run the monitor on CrawlForge's servers: it fires from the hosted scheduler whether or not this process is alive and sends email and signed webhooks. Each check bills 3 credits per compared target from the account; blocked and errored targets are free. Default false = local, in-process."),
119
119
  name: z.string().min(1).max(80).optional().describe("Display name for a hosted monitor (default: the URL host)")
@@ -0,0 +1,243 @@
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
+ * fetchWithTimeout, with undici's bare "fetch failed" (DNS, refused, reset)
96
+ * given the URL and the reason it keeps in `cause`, as the REST route's
97
+ * FETCH_FAILED "Could not reach <url>" does.
98
+ */
99
+ async function fetchOnce(url, options) {
100
+ try {
101
+ return await fetchWithTimeout(url, options);
102
+ } catch (error) {
103
+ if (!(error instanceof TypeError) || error.message !== 'fetch failed') throw error;
104
+ const reason = error.cause?.message || error.cause?.code;
105
+ throw new Error(`Could not reach ${url}${reason ? ` (${reason})` : ''}`, { cause: error });
106
+ }
107
+ }
108
+
109
+ /**
110
+ * The plain fetch, retried once on a 429/503 whose Retry-After is at most
111
+ * RETRY_AFTER_MAX_MS. fetchWithTimeout has already recorded the Retry-After
112
+ * (noteRetryAfter), and the second request's gate waits it out in the
113
+ * per-host throttle — so the wait happens once, there.
114
+ */
115
+ async function plainFetch(url, options) {
116
+ const response = await fetchOnce(url, options);
117
+ if (response.status !== 429 && response.status !== 503) return response;
118
+ const waitMs = retryAfterMs(response);
119
+ if (waitMs === null || waitMs > RETRY_AFTER_MAX_MS) return response;
120
+ return fetchOnce(url, options);
121
+ }
122
+
123
+ /**
124
+ * @param {string} url
125
+ * @param {{ tool: 'extract_text'|'extract_links', userAgent?: string, respectRobots?: boolean,
126
+ * escalate?: boolean, escalateEngine?: string,
127
+ * escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean }) => Promise<object> }} options
128
+ * @returns {Promise<
129
+ * { unsupported: string } |
130
+ * { error: string, url: string, status?: number|null, blocked?: object, warnings: string[], fields: object } |
131
+ * { html: string, url: string, type: string, warnings: string[], fields: object }>}
132
+ * `fields` carries `escalated` (only when escalate was asked for), `stealth`
133
+ * (when the stage ran) and `rendered: false` (a client-rendered shell)
134
+ */
135
+ export async function fetchLadder(url, { tool, userAgent, respectRobots, escalate = false, escalateEngine = 'auto', escalateFetch } = {}) {
136
+ let escalationRan = false;
137
+ try {
138
+ const response = await plainFetch(url, { timeout: LADDER_TIMEOUT_MS, userAgent, respectRobots, tool });
139
+ const type = mediaType(response);
140
+ if (response.ok && type && !TEXTUAL_TYPE.test(type)) return { unsupported: type };
141
+
142
+ const warnings = [...(response._warnings ?? [])];
143
+ // Reported only to a caller who asked to escalate.
144
+ const fields = escalate ? { escalated: false } : {};
145
+ const verdict = plainVerdict(response, url);
146
+ const failed = verdict ? !verdict.success : !response.ok;
147
+
148
+ // A 404 or a 5xx never escalates (aBrowserMightPass); a 444 is a refusal
149
+ // of our network, which a browser on another identity can pass. A host
150
+ // whose Retry-After is still open (over 10 s, or repeated on the retry)
151
+ // asked us to back off, and a stealth request now would override it (G6).
152
+ const backingOff = (response.status === 429 || response.status === 503) && retryAfterMs(response) !== null;
153
+ if (escalate && failed && !backingOff && (aBrowserMightPass(verdict, response.status) || response.status === 444)) {
154
+ const vendorDetected = verdict?.blocked?.vendor ?? null;
155
+ if (!escalateFetch) {
156
+ warnings.push('escalate: no stealth stage is wired into this server build');
157
+ } else {
158
+ try {
159
+ const stealth = await escalateFetch({ url: response.url || url, engine: escalateEngine, respectRobots });
160
+ escalationRan = true;
161
+ const engine = stealth.engine || escalateEngine;
162
+ // The stage's gate repeats the plain fetch's robots notes; say each once.
163
+ for (const w of stealth.warnings ?? []) if (!warnings.includes(w)) warnings.push(w);
164
+ const html = stealth.html || '';
165
+ const finalUrl = stealth.url || response.url || url;
166
+ const status = stealth.status ?? null;
167
+ const rendered = stealthDocumentVerdict(
168
+ { url: finalUrl, title: stealth.title || '', text: stealth.text || '', html, status },
169
+ { waitedMs: stealth.gracedMs || 0, fetcher: 'the stealth browser', rendered: true, contentReturned: false }
170
+ );
171
+ warnings.push(
172
+ `escalate: the plain fetch ${vendorDetected ? `was blocked by ${vendorDetected}` : 'did not return the page'}; ` +
173
+ `${engine === 'impit' ? 'a Chrome TLS handshake (impit, honest User-Agent)' : `the ${engine} stealth browser`} ${rendered.success ? 'returned it' : 'did not get it either'}`
174
+ );
175
+ fields.escalated = true;
176
+ fields.stealth = { engine, vendor_detected: vendorDetected };
177
+ if (!rendered.success) {
178
+ return { error: rendered.error, url: finalUrl, status, ...(rendered.blocked ? { blocked: rendered.blocked } : {}), warnings, fields };
179
+ }
180
+ return { html, url: finalUrl, type: 'text/html', warnings, fields };
181
+ } catch (err) {
182
+ // A robots refusal inside the gate stamps markPreflightRefusal, and
183
+ // withAuth then bills the whole call zero: nothing was rendered.
184
+ warnings.push(`escalate: the stealth retry did not run — ${err.message}`);
185
+ }
186
+ }
187
+ }
188
+
189
+ const failure = targetFailure(response, url, verdict);
190
+ if (failure) {
191
+ return { error: failure, url: response.url || url, status: response.status, ...(verdict?.blocked ? { blocked: verdict.blocked } : {}), warnings, fields };
192
+ }
193
+
194
+ const html = response._body ?? '';
195
+ if (!isJsonType(type) && isClientRenderedShell(html)) {
196
+ warnings.push(CLIENT_RENDERED_WARNING);
197
+ fields.rendered = false;
198
+ }
199
+ return { html, url: response.url || url, type, warnings, fields };
200
+ } finally {
201
+ // The projection is 1+5 when escalating; the stage's 5 is owed only if it ran.
202
+ if (escalate) setActualCost(EXTRACT_BASE_CREDITS + (escalationRan ? SCRAPE_ESCALATION_CREDITS : 0));
203
+ }
204
+ }
205
+
206
+ /**
207
+ * The error result for a ladder that did not return a page. Unescalated, the
208
+ * E1 text error; with escalate asked for, a JSON body carrying `escalated`,
209
+ * so withAuth's hint does not send the caller back to the stealth browser.
210
+ * @param {string} tool
211
+ * @param {string} prefix e.g. 'Failed to extract text: '
212
+ * @param {object} ladder fetchLadder's result
213
+ */
214
+ export function ladderErrorResult(tool, prefix, ladder) {
215
+ if (ladder.unsupported) {
216
+ return {
217
+ content: [{
218
+ type: 'text',
219
+ text: `${prefix}UNSUPPORTED_CONTENT_TYPE: Unsupported content type ${ladder.unsupported}: ${tool} reads ${tool === 'extract_links' ? 'HTML' : 'HTML and text'}\n` +
220
+ 'Next step: process_document reads PDF and DOCX documents from a URL (2 credits); an image or an archive has no text to extract.'
221
+ }],
222
+ isError: true
223
+ };
224
+ }
225
+ if (!('escalated' in ladder.fields)) {
226
+ return { content: [{ type: 'text', text: `${prefix}${ladder.error}` }], isError: true };
227
+ }
228
+ return {
229
+ content: [{
230
+ type: 'text',
231
+ text: JSON.stringify({
232
+ success: false,
233
+ url: ladder.url,
234
+ ...(typeof ladder.status === 'number' ? { status: ladder.status } : {}),
235
+ error: ladder.error,
236
+ ...(ladder.blocked ? { blocked: ladder.blocked } : {}),
237
+ ...ladder.fields,
238
+ ...(ladder.warnings.length > 0 ? { warnings: ladder.warnings } : {})
239
+ }, null, 2)
240
+ }],
241
+ isError: true
242
+ };
243
+ }