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
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { load } from 'cheerio';
|
|
14
|
-
import { readBody } from 'crawlforge-extractors';
|
|
14
|
+
import { readBody, flattenText } from 'crawlforge-extractors';
|
|
15
15
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
16
16
|
import { config } from '../../constants/config.js';
|
|
17
17
|
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
@@ -83,35 +83,11 @@ function classifyContentType(contentType) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
|
-
* Flatten a parsed document's <body> to text
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* table rows / list items together ("1.Story title329 points") and starves
|
|
90
|
-
* downstream LLM extraction (AgentOrchestrator, extractStructured,
|
|
91
|
-
* extractWithLlm) of any structure to parse. Works on a detached clone so the
|
|
92
|
-
* caller's $ tree is untouched — unifiedScrape reuses $ for other formats.
|
|
93
|
-
*
|
|
94
|
-
* @param {import('cheerio').CheerioAPI} $
|
|
95
|
-
* @returns {string}
|
|
86
|
+
* Flatten a parsed document's <body> to text, one line per block element.
|
|
87
|
+
* The flattener moved to crawlforge-extractors (`flattenText`) so the REST
|
|
88
|
+
* extract_text route shares it; this name stays for extractStructured.
|
|
96
89
|
*/
|
|
97
|
-
export
|
|
98
|
-
// Block boundaries are marked with a U+E000 private-use sentinel so the
|
|
99
|
-
// HTML source's own insignificant newlines can be collapsed to spaces
|
|
100
|
-
// first, and only the sentinels become line breaks. (NUL won't survive:
|
|
101
|
-
// .after()/.replaceWith() parse their argument as HTML and the parser
|
|
102
|
-
// strips NUL; U+E000 passes through and never occurs in real page text.)
|
|
103
|
-
const $body = $('body').clone();
|
|
104
|
-
$body.find('br').replaceWith('\uE000');
|
|
105
|
-
$body.find('td, th').after(' ');
|
|
106
|
-
$body
|
|
107
|
-
.find('p, div, li, tr, h1, h2, h3, h4, h5, h6, blockquote, pre, table, ul, ol, dl, section, article, header, footer')
|
|
108
|
-
.after('\uE000');
|
|
109
|
-
return $body
|
|
110
|
-
.text()
|
|
111
|
-
.replace(/\s+/g, ' ')
|
|
112
|
-
.replace(/ ?(?:\uE000 ?)+/g, '\n')
|
|
113
|
-
.trim();
|
|
114
|
-
}
|
|
90
|
+
export { flattenText as flattenBodyText };
|
|
115
91
|
|
|
116
92
|
/**
|
|
117
93
|
* Fetch a URL and return parsed HTML via Cheerio.
|
|
@@ -190,7 +166,7 @@ export async function fetchAndParse(url, options = {}) {
|
|
|
190
166
|
$(stripTags.join(', ')).remove();
|
|
191
167
|
}
|
|
192
168
|
|
|
193
|
-
const textContent =
|
|
169
|
+
const textContent = flattenText($);
|
|
194
170
|
|
|
195
171
|
return { html, $, textContent, finalUrl: response.url, warnings, status: response.status };
|
|
196
172
|
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetchLadder — scrape's fetch ladder for extract_text and extract_links (fix
|
|
3
|
+
* plan Phase E2). Same rules and wording as the website's
|
|
4
|
+
* src/lib/tools/fetch-ladder.ts — keep the two in step.
|
|
5
|
+
*
|
|
6
|
+
* 1. A plain fetch (15 s), retried once when a 429/503 asks for at most
|
|
7
|
+
* 10 s with Retry-After.
|
|
8
|
+
* 2. A 2xx body that is not text (a PDF, an image, an archive) stops here as
|
|
9
|
+
* UNSUPPORTED_CONTENT_TYPE; it never escalates.
|
|
10
|
+
* 3. Only with escalate:true, and only when a browser could change the
|
|
11
|
+
* outcome (aBrowserMightPass, or a 444 refusal): the injected stealth
|
|
12
|
+
* stage — server.js `stealthEscalation`, the one scrape and
|
|
13
|
+
* extract_embedded_state run, behind the same compliance gate (impit
|
|
14
|
+
* first under "auto", then the browser).
|
|
15
|
+
* 4. Otherwise E1's verdict stands unchanged (targetFailure).
|
|
16
|
+
*
|
|
17
|
+
* scrape and extract_embedded_state keep their own copies of steps 3–4; this
|
|
18
|
+
* reuses their exported pieces rather than moving them.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { load } from 'cheerio';
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
import { fetchWithTimeout } from '../tools/basic/_fetch.js';
|
|
24
|
+
import { plainVerdict, targetFailure } from '../tools/basic/_targetFailure.js';
|
|
25
|
+
import { stealthDocumentVerdict } from './stealthVerdict.js';
|
|
26
|
+
import { parseRetryAfter } from './hostRateLimiter.js';
|
|
27
|
+
import { setActualCost } from '../server/requestContext.js';
|
|
28
|
+
import { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS, aBrowserMightPass } from '../tools/scrape/escalation.js';
|
|
29
|
+
|
|
30
|
+
/** extract_text's and extract_links's table price; escalation adds SCRAPE_ESCALATION_CREDITS. */
|
|
31
|
+
const EXTRACT_BASE_CREDITS = 1;
|
|
32
|
+
|
|
33
|
+
export const LADDER_TIMEOUT_MS = 15000;
|
|
34
|
+
|
|
35
|
+
/** A Retry-After longer than this is not waited out inside one call. */
|
|
36
|
+
export const RETRY_AFTER_MAX_MS = 10000;
|
|
37
|
+
|
|
38
|
+
export const CLIENT_RENDERED_WARNING = 'page renders client-side; use scrape';
|
|
39
|
+
|
|
40
|
+
/** The two public fields both tools take, in tools/list order. */
|
|
41
|
+
export const EXTRACT_ESCALATION_SHAPE = {
|
|
42
|
+
escalate: z.boolean().optional().default(false).describe('When the plain fetch comes back blocked (403/429/444/challenge page/empty shell), re-read the page once in the stealth browser and extract from the rendered document. Under escalate_engine "auto" a Chrome TLS handshake with the honest CrawlForge User-Agent (impit) is tried first, and the browser runs only when that does not get the page. A 404 or 5xx never escalates. Projected at 1+5; the actual charge stays at 1 when the plain fetch succeeded. Default: false'),
|
|
43
|
+
escalate_engine: SCRAPE_ESCALATION_SHAPE.escalate_engine
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Bodies these tools read. A missing Content-Type is read as HTML.
|
|
47
|
+
const TEXTUAL_TYPE = /^(?:text\/[\w.+-]+|application\/(?:xhtml\+xml|xml|json)|application\/[\w.+-]+\+(?:xml|json))$/;
|
|
48
|
+
const JSON_TYPE = /^application\/(?:[\w.+-]+\+)?json$/;
|
|
49
|
+
|
|
50
|
+
/** The media type without parameters, lowercased; '' when absent. */
|
|
51
|
+
function mediaType(response) {
|
|
52
|
+
return (response.headers?.get?.('content-type') ?? '').split(';')[0].trim().toLowerCase();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether a media type is JSON (application/json or a +json suffix). */
|
|
56
|
+
export function isJsonType(type) {
|
|
57
|
+
return JSON_TYPE.test(type);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A page that says it needs JavaScript (scrapingcourse.com's
|
|
61
|
+
// /javascript-rendering serves "Enable JavaScript to see products" over empty
|
|
62
|
+
// product cards, with no mount point to find).
|
|
63
|
+
// No \b before the verbs: cheerio's text() joins <h1>Challenge</h1><p>Enable…
|
|
64
|
+
// into one word.
|
|
65
|
+
const JS_REQUIRED = /(?:enable|requires?|turn on)\s+javascript\b|\bjavascript\s+(?:is\s+)?(?:required|disabled)\b/i;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A client-rendered shell: under 200 characters of visible text, and either an
|
|
69
|
+
* empty <div id="root">, "app" or "__next" for the framework to mount into or
|
|
70
|
+
* text saying the page needs JavaScript.
|
|
71
|
+
* @param {string} html
|
|
72
|
+
*/
|
|
73
|
+
export function isClientRenderedShell(html) {
|
|
74
|
+
const $ = load(html);
|
|
75
|
+
$('script, style, noscript').remove();
|
|
76
|
+
const text = $('body').text().replace(/\s+/g, ' ').trim();
|
|
77
|
+
if (text.length >= 200) return false;
|
|
78
|
+
if (JS_REQUIRED.test(text)) return true;
|
|
79
|
+
return $('div#root, div#app, div#__next').toArray().some((el) => $(el).text().trim() === '');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Retry-After in ms, or null when the header is absent or unreadable.
|
|
84
|
+
* @param {Response} response
|
|
85
|
+
*/
|
|
86
|
+
function retryAfterMs(response) {
|
|
87
|
+
const header = response.headers?.get?.('retry-after');
|
|
88
|
+
if (header == null) return null;
|
|
89
|
+
const raw = String(header).trim();
|
|
90
|
+
if (!/^\d+$/.test(raw) && Number.isNaN(Date.parse(raw))) return null;
|
|
91
|
+
return parseRetryAfter(raw);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The plain fetch, retried once on a 429/503 whose Retry-After is at most
|
|
96
|
+
* RETRY_AFTER_MAX_MS. fetchWithTimeout has already recorded the Retry-After
|
|
97
|
+
* (noteRetryAfter), and the second request's gate waits it out in the
|
|
98
|
+
* per-host throttle — so the wait happens once, there.
|
|
99
|
+
*/
|
|
100
|
+
async function plainFetch(url, options) {
|
|
101
|
+
const response = await fetchWithTimeout(url, options);
|
|
102
|
+
if (response.status !== 429 && response.status !== 503) return response;
|
|
103
|
+
const waitMs = retryAfterMs(response);
|
|
104
|
+
if (waitMs === null || waitMs > RETRY_AFTER_MAX_MS) return response;
|
|
105
|
+
return fetchWithTimeout(url, options);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {string} url
|
|
110
|
+
* @param {{ tool: 'extract_text'|'extract_links', userAgent?: string, respectRobots?: boolean,
|
|
111
|
+
* escalate?: boolean, escalateEngine?: string,
|
|
112
|
+
* escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean }) => Promise<object> }} options
|
|
113
|
+
* @returns {Promise<
|
|
114
|
+
* { unsupported: string } |
|
|
115
|
+
* { error: string, url: string, status?: number|null, blocked?: object, warnings: string[], fields: object } |
|
|
116
|
+
* { html: string, url: string, type: string, warnings: string[], fields: object }>}
|
|
117
|
+
* `fields` carries `escalated` (only when escalate was asked for), `stealth`
|
|
118
|
+
* (when the stage ran) and `rendered: false` (a client-rendered shell)
|
|
119
|
+
*/
|
|
120
|
+
export async function fetchLadder(url, { tool, userAgent, respectRobots, escalate = false, escalateEngine = 'auto', escalateFetch } = {}) {
|
|
121
|
+
let escalationRan = false;
|
|
122
|
+
try {
|
|
123
|
+
const response = await plainFetch(url, { timeout: LADDER_TIMEOUT_MS, userAgent, respectRobots, tool });
|
|
124
|
+
const type = mediaType(response);
|
|
125
|
+
if (response.ok && type && !TEXTUAL_TYPE.test(type)) return { unsupported: type };
|
|
126
|
+
|
|
127
|
+
const warnings = [...(response._warnings ?? [])];
|
|
128
|
+
// Reported only to a caller who asked to escalate.
|
|
129
|
+
const fields = escalate ? { escalated: false } : {};
|
|
130
|
+
const verdict = plainVerdict(response, url);
|
|
131
|
+
const failed = verdict ? !verdict.success : !response.ok;
|
|
132
|
+
|
|
133
|
+
// A 404 or a 5xx never escalates (aBrowserMightPass); a 444 is a refusal
|
|
134
|
+
// of our network, which a browser on another identity can pass. A host
|
|
135
|
+
// whose Retry-After is still open (over 10 s, or repeated on the retry)
|
|
136
|
+
// asked us to back off, and a stealth request now would override it (G6).
|
|
137
|
+
const backingOff = (response.status === 429 || response.status === 503) && retryAfterMs(response) !== null;
|
|
138
|
+
if (escalate && failed && !backingOff && (aBrowserMightPass(verdict, response.status) || response.status === 444)) {
|
|
139
|
+
const vendorDetected = verdict?.blocked?.vendor ?? null;
|
|
140
|
+
if (!escalateFetch) {
|
|
141
|
+
warnings.push('escalate: no stealth stage is wired into this server build');
|
|
142
|
+
} else {
|
|
143
|
+
try {
|
|
144
|
+
const stealth = await escalateFetch({ url: response.url || url, engine: escalateEngine, respectRobots });
|
|
145
|
+
escalationRan = true;
|
|
146
|
+
const engine = stealth.engine || escalateEngine;
|
|
147
|
+
// The stage's gate repeats the plain fetch's robots notes; say each once.
|
|
148
|
+
for (const w of stealth.warnings ?? []) if (!warnings.includes(w)) warnings.push(w);
|
|
149
|
+
const html = stealth.html || '';
|
|
150
|
+
const finalUrl = stealth.url || response.url || url;
|
|
151
|
+
const status = stealth.status ?? null;
|
|
152
|
+
const rendered = stealthDocumentVerdict(
|
|
153
|
+
{ url: finalUrl, title: stealth.title || '', text: stealth.text || '', html, status },
|
|
154
|
+
{ waitedMs: stealth.gracedMs || 0, fetcher: 'the stealth browser', rendered: true, contentReturned: false }
|
|
155
|
+
);
|
|
156
|
+
warnings.push(
|
|
157
|
+
`escalate: the plain fetch ${vendorDetected ? `was blocked by ${vendorDetected}` : 'did not return the page'}; ` +
|
|
158
|
+
`${engine === 'impit' ? 'a Chrome TLS handshake (impit, honest User-Agent)' : `the ${engine} stealth browser`} ${rendered.success ? 'returned it' : 'did not get it either'}`
|
|
159
|
+
);
|
|
160
|
+
fields.escalated = true;
|
|
161
|
+
fields.stealth = { engine, vendor_detected: vendorDetected };
|
|
162
|
+
if (!rendered.success) {
|
|
163
|
+
return { error: rendered.error, url: finalUrl, status, ...(rendered.blocked ? { blocked: rendered.blocked } : {}), warnings, fields };
|
|
164
|
+
}
|
|
165
|
+
return { html, url: finalUrl, type: 'text/html', warnings, fields };
|
|
166
|
+
} catch (err) {
|
|
167
|
+
// A robots refusal inside the gate stamps markPreflightRefusal, and
|
|
168
|
+
// withAuth then bills the whole call zero: nothing was rendered.
|
|
169
|
+
warnings.push(`escalate: the stealth retry did not run — ${err.message}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const failure = targetFailure(response, url, verdict);
|
|
175
|
+
if (failure) {
|
|
176
|
+
return { error: failure, url: response.url || url, status: response.status, ...(verdict?.blocked ? { blocked: verdict.blocked } : {}), warnings, fields };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const html = response._body ?? '';
|
|
180
|
+
if (!isJsonType(type) && isClientRenderedShell(html)) {
|
|
181
|
+
warnings.push(CLIENT_RENDERED_WARNING);
|
|
182
|
+
fields.rendered = false;
|
|
183
|
+
}
|
|
184
|
+
return { html, url: response.url || url, type, warnings, fields };
|
|
185
|
+
} finally {
|
|
186
|
+
// The projection is 1+5 when escalating; the stage's 5 is owed only if it ran.
|
|
187
|
+
if (escalate) setActualCost(EXTRACT_BASE_CREDITS + (escalationRan ? SCRAPE_ESCALATION_CREDITS : 0));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The error result for a ladder that did not return a page. Unescalated, the
|
|
193
|
+
* E1 text error; with escalate asked for, a JSON body carrying `escalated`,
|
|
194
|
+
* so withAuth's hint does not send the caller back to the stealth browser.
|
|
195
|
+
* @param {string} tool
|
|
196
|
+
* @param {string} prefix e.g. 'Failed to extract text: '
|
|
197
|
+
* @param {object} ladder fetchLadder's result
|
|
198
|
+
*/
|
|
199
|
+
export function ladderErrorResult(tool, prefix, ladder) {
|
|
200
|
+
if (ladder.unsupported) {
|
|
201
|
+
return {
|
|
202
|
+
content: [{
|
|
203
|
+
type: 'text',
|
|
204
|
+
text: `${prefix}UNSUPPORTED_CONTENT_TYPE: Unsupported content type ${ladder.unsupported}: ${tool} reads ${tool === 'extract_links' ? 'HTML' : 'HTML and text'}\n` +
|
|
205
|
+
'Next step: process_document reads PDF and DOCX documents from a URL (2 credits); an image or an archive has no text to extract.'
|
|
206
|
+
}],
|
|
207
|
+
isError: true
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (!('escalated' in ladder.fields)) {
|
|
211
|
+
return { content: [{ type: 'text', text: `${prefix}${ladder.error}` }], isError: true };
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
content: [{
|
|
215
|
+
type: 'text',
|
|
216
|
+
text: JSON.stringify({
|
|
217
|
+
success: false,
|
|
218
|
+
url: ladder.url,
|
|
219
|
+
...(typeof ladder.status === 'number' ? { status: ladder.status } : {}),
|
|
220
|
+
error: ladder.error,
|
|
221
|
+
...(ladder.blocked ? { blocked: ladder.blocked } : {}),
|
|
222
|
+
...ladder.fields,
|
|
223
|
+
...(ladder.warnings.length > 0 ? { warnings: ladder.warnings } : {})
|
|
224
|
+
}, null, 2)
|
|
225
|
+
}],
|
|
226
|
+
isError: true
|
|
227
|
+
};
|
|
228
|
+
}
|
package/src/utils/secretMask.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { redactPii } from 'crawlforge-extractors';
|
|
13
13
|
|
|
14
|
-
const SECRET_KEYS_RE = /api[_-]?key|apikey|x-api-key|password|passwd|secret|token|authorization|auth|credential|login|formautofill|private[_-]?key|access[_-]?key|proxy_url|proxyurl
|
|
14
|
+
const SECRET_KEYS_RE = /api[_-]?key|apikey|x-api-key|password|passwd|secret|token|authorization|auth|credential|login|formautofill|private[_-]?key|access[_-]?key|proxy_url|proxyurl|^proxies$|cookie/i;
|
|
15
15
|
|
|
16
16
|
const MASK = '[REDACTED]';
|
|
17
17
|
const PARTIAL_MASK_LEN = 4; // show last N chars of long secrets
|