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