crawlforge-mcp-server 6.16.0 → 6.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +2 -2
- package/server.js +28 -12
- package/src/core/ActionExecutor.js +83 -2
- package/src/core/AuthManager.js +12 -0
- package/src/core/StealthBrowserManager.js +93 -10
- package/src/core/processing/BrowserProcessor.js +8 -1
- package/src/server/fallbackHints.js +4 -4
- package/src/server/inlineThreshold.js +76 -10
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +2 -2
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +4 -0
- package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +16 -2
- package/src/tools/advanced/ScrapeWithActionsTool.js +40 -4
- package/src/tools/basic/_targetFailure.js +54 -0
- package/src/tools/basic/extractLinks.js +62 -42
- package/src/tools/basic/extractText.js +79 -14
- package/src/tools/extract/_fetchAndParse.js +6 -30
- package/src/utils/fetchLadder.js +228 -0
- package/src/utils/secretMask.js +1 -1
|
@@ -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. `
|
|
24
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
231
|
-
*
|
|
232
|
-
* the
|
|
233
|
-
*
|
|
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
|
-
//
|
|
261
|
-
|
|
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` |
|
|
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 {
|
|
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
|
|
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
|
-
|
|
18
|
+
async function extractLinks({ url, filter_external, base_url, user_agent, respect_robots, escalate, escalate_engine }, escalateFetch) {
|
|
14
19
|
try {
|
|
15
|
-
const
|
|
20
|
+
const ladder = await fetchLadder(url, {
|
|
21
|
+
tool: 'extract_links',
|
|
16
22
|
userAgent: user_agent,
|
|
17
23
|
respectRobots: respect_robots,
|
|
18
|
-
|
|
24
|
+
escalate: escalate === true,
|
|
25
|
+
escalateEngine: escalate_engine,
|
|
26
|
+
escalateFetch
|
|
19
27
|
});
|
|
20
|
-
if (
|
|
21
|
-
|
|
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 =
|
|
49
|
+
const html = ladder.html;
|
|
25
50
|
const $ = load(html);
|
|
26
51
|
|
|
27
|
-
const finalUrl =
|
|
28
|
-
const pageUrl = new URL(finalUrl);
|
|
52
|
+
const finalUrl = ladder.url;
|
|
29
53
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
70
|
-
total_count:
|
|
71
|
-
internal_count:
|
|
72
|
-
external_count:
|
|
73
|
-
|
|
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 {
|
|
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",
|
|
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
|
-
|
|
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
|
|
98
|
+
const ladder = await fetchLadder(url, {
|
|
99
|
+
tool: 'extract_text',
|
|
83
100
|
userAgent: user_agent,
|
|
84
101
|
respectRobots: respect_robots,
|
|
85
|
-
|
|
102
|
+
escalate: escalate === true,
|
|
103
|
+
escalateEngine: escalate_engine,
|
|
104
|
+
escalateFetch
|
|
86
105
|
});
|
|
87
|
-
if (
|
|
88
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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();
|