crawlforge-mcp-server 6.16.0 → 6.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
@@ -14,7 +14,7 @@ import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
14
14
  import { elementText } from '../../utils/elementText.js';
15
15
  import { pageTitle } from '../../utils/pageTitle.js';
16
16
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
17
- import { resolveStealthEngine } from '../../core/StealthBrowserManager.js';
17
+ import { resolveStealthEngine, redactProxyEntry, assertProxyEngineAllowed } from '../../core/StealthBrowserManager.js';
18
18
  import { actionQueue as sharedActionQueue } from '../../core/browser/actionQueue.js';
19
19
 
20
20
  // Recording / replay helpers
@@ -27,6 +27,20 @@ import {
27
27
  recordedEntryToAction
28
28
  } from './scrapeWithActions/recorder.js';
29
29
 
30
+ // metadata.browserOptions echoes the merged options, and a proxy entry carries
31
+ // its password.
32
+ function redactProxyCredentials(browserOptions) {
33
+ const proxies = browserOptions.proxyRotation?.proxies;
34
+ if (!proxies) return browserOptions;
35
+ return {
36
+ ...browserOptions,
37
+ proxyRotation: {
38
+ ...browserOptions.proxyRotation,
39
+ proxies: proxies.map(redactProxyEntry)
40
+ }
41
+ };
42
+ }
43
+
30
44
  // Action schemas (re-using from ActionExecutor but with tool-specific additions)
31
45
  const BaseActionSchema = z.object({
32
46
  type: z.string(),
@@ -203,7 +217,15 @@ const ScrapeWithActionsSchema = z.object({
203
217
  // Answer a cookie/consent wall (autoconsent) after the initial load and
204
218
  // after each navigate action: "reject" opts out, "accept" opts in. Off by
205
219
  // default so a chain that clicks the banner itself keeps working.
206
- consent: z.enum(['off', 'reject', 'accept']).default('off')
220
+ consent: z.enum(['off', 'reject', 'accept']).default('off'),
221
+ // The caller's own proxies for a stealth chain — the shape and parsing of
222
+ // stealth_mode's stealthConfig.proxyRotation. Refused without stealth:true
223
+ // (see executeSession); CrawlForge supplies none.
224
+ proxyRotation: z.object({
225
+ enabled: z.boolean().default(false),
226
+ proxies: z.array(z.string()).optional(),
227
+ rotationInterval: z.number().default(300000)
228
+ }).optional()
207
229
  }).optional(),
208
230
 
209
231
  // Content extraction options
@@ -425,6 +447,8 @@ export class ScrapeWithActionsTool extends EventEmitter {
425
447
  const warnings = [];
426
448
  if (browserOptions.stealth) {
427
449
  const resolved = await resolveStealthEngine(browserOptions.engine);
450
+ // Refused on the engine that will run, before the chain starts.
451
+ assertProxyEngineAllowed(resolved.engine, browserOptions);
428
452
  browserOptions.stealthMode = { enabled: true, engine: resolved.engine };
429
453
  if (resolved.fallbackWarning) warnings.push(resolved.fallbackWarning);
430
454
  } else if (browserOptions.engine === 'camoufox') {
@@ -439,6 +463,15 @@ export class ScrapeWithActionsTool extends EventEmitter {
439
463
  'to use the standard Chromium browser.'
440
464
  );
441
465
  }
466
+ if (browserOptions.proxyRotation?.enabled && !browserOptions.stealth) {
467
+ // Only the stealth browser takes a proxy; the standard pool would run
468
+ // the chain from this server's own address while the caller believes
469
+ // it is proxied.
470
+ throw new Error(
471
+ 'browserOptions.proxyRotation requires browserOptions.stealth:true — only the stealth ' +
472
+ 'browser routes through a proxy. Set stealth:true, or drop proxyRotation.'
473
+ );
474
+ }
442
475
 
443
476
  // The robots override rides with the browser options so ActionExecutor's
444
477
  // gate sees it on the initial load and on every `navigate` action.
@@ -534,7 +567,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
534
567
  text: finalContent?.content?.text || '',
535
568
  html: chainResult?.finalHtml || '',
536
569
  status: chainResult?.navigationStatus ?? null
537
- }, { allowEmpty: true });
570
+ }, { allowEmpty: true, fetcher: browserOptions.stealthMode?.enabled ? 'the stealth browser' : 'the browser' });
538
571
 
539
572
  return {
540
573
  success: chainResult.success && verdict.success,
@@ -553,6 +586,9 @@ export class ScrapeWithActionsTool extends EventEmitter {
553
586
  // The landing page's consent result; each navigate action's own is on
554
587
  // its result. Absent when consent handling is off.
555
588
  ...(chainResult.consent ? { consent: chainResult.consent } : {}),
589
+ // Every navigation of the reported attempt — [0] the initial load, then
590
+ // each navigate action — with its status and any wall it reached.
591
+ navigations: chainResult.navigations || [],
556
592
 
557
593
  actionResults,
558
594
  attempt: chainResult.attempt ?? attempts.length,
@@ -576,7 +612,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
576
612
  replayedFrom: params.replayRecording || undefined,
577
613
 
578
614
  metadata: {
579
- browserOptions,
615
+ browserOptions: redactProxyCredentials(browserOptions),
580
616
  formAutoFillApplied: !!params.formAutoFill,
581
617
  intermediateStatesCount: intermediateStates.length,
582
618
  screenshotsCount: sessionContext.screenshots.length,
@@ -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();