crawlforge-mcp-server 6.22.2 → 6.23.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 +10 -1
- package/package.json +1 -1
- package/server.js +14 -7
- package/src/core/ActionExecutor.js +117 -12
- package/src/core/AgentOrchestrator.js +133 -23
- package/src/core/AuthManager.js +7 -1
- package/src/core/ChangeTracker.js +52 -4
- package/src/core/LLMsTxtAnalyzer.js +21 -9
- package/src/core/LocalizationManager.js +20 -4
- package/src/core/ResearchOrchestrator.js +29 -3
- package/src/core/StealthBrowserManager.js +10 -5
- package/src/core/analysis/ContentAnalyzer.js +157 -29
- package/src/core/browser/guardedProxy.js +286 -0
- package/src/core/browser/snapshot.js +22 -0
- package/src/core/crawlers/BFSCrawler.js +10 -4
- package/src/core/processing/BrowserProcessor.js +5 -3
- package/src/core/processing/ContentProcessor.js +2 -1
- package/src/core/research/claimFilters.js +20 -1
- package/src/core/workers/worker.js +2 -0
- package/src/schemas/toolOutputSchemas.js +3 -2
- package/src/server/fallbackHints.js +5 -1
- package/src/server/inlineThreshold.js +19 -11
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +9 -2
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +35 -9
- package/src/tools/agent/sourceChecks.js +103 -0
- package/src/tools/basic/extractMetadata.js +9 -4
- package/src/tools/basic/extractText.js +6 -4
- package/src/tools/basic/fetchUrl.js +3 -0
- package/src/tools/basic/scrapeStructured.js +66 -21
- package/src/tools/crawl/mapSite.js +18 -3
- package/src/tools/extract/extractStructured.js +134 -1
- package/src/tools/extract/processDocument.js +48 -5
- package/src/tools/extract/summarizeContent.js +123 -5
- package/src/tools/llmstxt/generateLLMsTxt.js +6 -5
- package/src/tools/result/readResult.js +79 -10
- package/src/tools/scrape/_mainContent.js +97 -6
- package/src/tools/search/adapters/dataforseoSearch.js +4 -1
- package/src/tools/search/searchWeb.js +46 -15
- package/src/tools/search/serpRank.js +1 -1
- package/src/tools/tracking/trackChanges/index.js +1 -0
- package/src/tools/tracking/trackChanges/schema.js +2 -1
- package/src/utils/htmlToMarkdown.js +77 -1
- package/src/utils/localFilePolicy.js +65 -0
- package/src/utils/provenance.js +102 -1
- package/src/utils/ssrfGuard.js +4 -3
- package/src/utils/ssrfProtection.js +3 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A local forward proxy that every browser context goes out through, so the
|
|
3
|
+
* SSRF policy of the fetch path (src/utils/ssrfGuard.js) holds for a browser
|
|
4
|
+
* at connect time, on every connection a page opens.
|
|
5
|
+
*
|
|
6
|
+
* A browser follows redirects inside its own network stack, and Playwright's
|
|
7
|
+
* route() is called neither for a redirect hop nor for a WebSocket (measured
|
|
8
|
+
* 2026-10-20 on Chromium and camoufox: a 302 to http://127.0.0.1 and a
|
|
9
|
+
* `new WebSocket('ws://127.0.0.1')` both reached the listener with a
|
|
10
|
+
* `context.route('**\/*')` handler installed). The browser also resolves the
|
|
11
|
+
* host itself, after safeGoto's own check, which is the DNS-rebinding window.
|
|
12
|
+
* A proxy closes all of it: the browser resolves nothing and connects nowhere
|
|
13
|
+
* but here, and each CONNECT tunnel or plain-HTTP request is checked with the
|
|
14
|
+
* same pre-flight (assertUrlAllowed) and the same connect-time lookup
|
|
15
|
+
* (ssrfLookup) as a fetch, then connected to the address that was checked.
|
|
16
|
+
* Redirect hops, iframes, subresources, fetch/XHR and WebSockets are all just
|
|
17
|
+
* connections here.
|
|
18
|
+
*
|
|
19
|
+
* Both engines send loopback through a context proxy: Playwright gives
|
|
20
|
+
* Chromium `<-loopback>`, and camoufox's Firefox hijacks localhost (measured).
|
|
21
|
+
*
|
|
22
|
+
* An upstream proxy (a caller's proxyRotation, CRAWLFORGE_STEALTH_PROXIES, a
|
|
23
|
+
* localization proxy) is chained: the browser talks to this proxy, which keeps
|
|
24
|
+
* the upstream's credentials and opens the tunnel through it (http, https,
|
|
25
|
+
* socks4, socks5). The target is still checked here first, but by hostname:
|
|
26
|
+
* the upstream resolves it in its own network, so the exit's geo-DNS answer is
|
|
27
|
+
* kept, and a rebinding answer there reaches the upstream's network, not this
|
|
28
|
+
* one. An upstream's `bypass` list is not honoured; nothing may bypass the
|
|
29
|
+
* check.
|
|
30
|
+
*
|
|
31
|
+
* Listens on 127.0.0.1 only, one server per distinct upstream, for the life of
|
|
32
|
+
* the process (unref'd).
|
|
33
|
+
*/
|
|
34
|
+
import http from 'node:http';
|
|
35
|
+
import https from 'node:https';
|
|
36
|
+
import net from 'node:net';
|
|
37
|
+
import tls from 'node:tls';
|
|
38
|
+
import { config } from '../../constants/config.js';
|
|
39
|
+
import { assertUrlAllowed, isSsrfError, ssrfLookup } from '../../utils/ssrfGuard.js';
|
|
40
|
+
|
|
41
|
+
/** upstream key → Promise<{ server: string }> */
|
|
42
|
+
const servers = new Map();
|
|
43
|
+
|
|
44
|
+
const hostForUrl = (host) => (net.isIPv6(host) ? `[${host}]` : host);
|
|
45
|
+
|
|
46
|
+
/** `host:port` (CONNECT authority, brackets allowed) → { host, port } */
|
|
47
|
+
function splitAuthority(authority) {
|
|
48
|
+
const url = new URL(`http://${authority}`);
|
|
49
|
+
return { host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port) || 80 };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The fetch path's pre-flight on a target (protocol aside: no scheme reaches a
|
|
54
|
+
* proxy). With an upstream, also what the name resolves to here, since the
|
|
55
|
+
* connection itself is made by the upstream.
|
|
56
|
+
*/
|
|
57
|
+
function checkTarget(host, port, upstream) {
|
|
58
|
+
return assertUrlAllowed(`http://${hostForUrl(host)}:${port}/`, { resolveDns: !!upstream });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A direct connection, resolved through the fetch path's connect-time lookup:
|
|
63
|
+
* ssrfLookup refuses a blocked answer and hands back only what it checked, so
|
|
64
|
+
* nothing resolves the name a second time. IP literals never reach a lookup;
|
|
65
|
+
* checkTarget has already held them to the same rules.
|
|
66
|
+
*/
|
|
67
|
+
function directConnect(host, port) {
|
|
68
|
+
const sec = config.security?.ssrfProtection;
|
|
69
|
+
const lookup = sec && sec.enabled !== false ? { lookup: ssrfLookup } : {};
|
|
70
|
+
return connected(net.connect({ host, port, autoSelectFamily: true, ...lookup }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function onceReadable(socket, bytes) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
let buf = Buffer.alloc(0);
|
|
76
|
+
const onData = (chunk) => {
|
|
77
|
+
buf = Buffer.concat([buf, chunk]);
|
|
78
|
+
if (buf.length < bytes) return;
|
|
79
|
+
cleanup();
|
|
80
|
+
if (buf.length > bytes) socket.unshift(buf.subarray(bytes));
|
|
81
|
+
resolve(buf.subarray(0, bytes));
|
|
82
|
+
};
|
|
83
|
+
const onError = (err) => { cleanup(); reject(err); };
|
|
84
|
+
const onEnd = () => { cleanup(); reject(new Error('upstream proxy closed the connection')); };
|
|
85
|
+
// Paused between reads, so nothing that arrives in between is dropped.
|
|
86
|
+
const cleanup = () => {
|
|
87
|
+
socket.pause();
|
|
88
|
+
socket.off('data', onData);
|
|
89
|
+
socket.off('error', onError);
|
|
90
|
+
socket.off('end', onEnd);
|
|
91
|
+
};
|
|
92
|
+
socket.on('data', onData);
|
|
93
|
+
socket.on('error', onError);
|
|
94
|
+
socket.on('end', onEnd);
|
|
95
|
+
socket.resume();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function connected(socket, event = 'connect') {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
socket.once(event, () => { socket.off('error', reject); resolve(socket); });
|
|
102
|
+
socket.once('error', reject);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A SOCKS5 (RFC 1928/1929) or SOCKS4a CONNECT through `upstream`. */
|
|
107
|
+
async function socksTunnel(upstream, host, port) {
|
|
108
|
+
const socket = await connected(net.connect(upstream.port, upstream.host));
|
|
109
|
+
const portBytes = [port >> 8, port & 0xff];
|
|
110
|
+
if (upstream.protocol === 'socks4:') {
|
|
111
|
+
const user = Buffer.from(upstream.username || '');
|
|
112
|
+
socket.write(Buffer.concat([
|
|
113
|
+
Buffer.from([4, 1, ...portBytes, 0, 0, 0, 1]), user, Buffer.from([0]), Buffer.from(host), Buffer.from([0])
|
|
114
|
+
]));
|
|
115
|
+
const reply = await onceReadable(socket, 8);
|
|
116
|
+
if (reply[1] !== 0x5a) throw new Error(`socks4 upstream refused the connection (${reply[1]})`);
|
|
117
|
+
return socket;
|
|
118
|
+
}
|
|
119
|
+
const auth = upstream.username ? 2 : 0;
|
|
120
|
+
socket.write(Buffer.from([5, 1, auth]));
|
|
121
|
+
const method = await onceReadable(socket, 2);
|
|
122
|
+
if (method[1] !== auth) throw new Error('socks5 upstream refused the authentication method');
|
|
123
|
+
if (auth) {
|
|
124
|
+
const user = Buffer.from(upstream.username);
|
|
125
|
+
const pass = Buffer.from(upstream.password || '');
|
|
126
|
+
socket.write(Buffer.concat([Buffer.from([1, user.length]), user, Buffer.from([pass.length]), pass]));
|
|
127
|
+
const ok = await onceReadable(socket, 2);
|
|
128
|
+
if (ok[1] !== 0) throw new Error('socks5 upstream refused the credentials');
|
|
129
|
+
}
|
|
130
|
+
const name = Buffer.from(host);
|
|
131
|
+
socket.write(Buffer.concat([Buffer.from([5, 1, 0, 3, name.length]), name, Buffer.from(portBytes)]));
|
|
132
|
+
const head = await onceReadable(socket, 5);
|
|
133
|
+
if (head[1] !== 0) throw new Error(`socks5 upstream refused the connection (${head[1]})`);
|
|
134
|
+
// The bound address that follows: IPv4, a name of head[4] bytes, or IPv6; then the port.
|
|
135
|
+
const rest = head[3] === 1 ? 5 : head[3] === 4 ? 17 : head[4] + 2;
|
|
136
|
+
await onceReadable(socket, rest);
|
|
137
|
+
return socket;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function proxyAuthorization(upstream) {
|
|
141
|
+
if (!upstream.username) return {};
|
|
142
|
+
const pair = `${upstream.username}:${upstream.password || ''}`;
|
|
143
|
+
return { 'Proxy-Authorization': `Basic ${Buffer.from(pair).toString('base64')}` };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A checked raw TCP stream to host:port, through the upstream when there is one. */
|
|
147
|
+
async function openTunnel(upstream, host, port) {
|
|
148
|
+
await checkTarget(host, port, upstream);
|
|
149
|
+
if (!upstream) return directConnect(host, port);
|
|
150
|
+
if (upstream.protocol.startsWith('socks')) return socksTunnel(upstream, host, port);
|
|
151
|
+
const authority = `${hostForUrl(host)}:${port}`;
|
|
152
|
+
const request = (upstream.protocol === 'https:' ? https : http).request({
|
|
153
|
+
host: upstream.host,
|
|
154
|
+
port: upstream.port,
|
|
155
|
+
method: 'CONNECT',
|
|
156
|
+
path: authority,
|
|
157
|
+
headers: { Host: authority, ...proxyAuthorization(upstream) }
|
|
158
|
+
});
|
|
159
|
+
request.end();
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
request.once('error', reject);
|
|
162
|
+
request.once('connect', (res, socket, head) => {
|
|
163
|
+
if (res.statusCode !== 200) {
|
|
164
|
+
socket.destroy();
|
|
165
|
+
return reject(new Error(`upstream proxy answered CONNECT with ${res.statusCode}`));
|
|
166
|
+
}
|
|
167
|
+
if (head.length) socket.unshift(head);
|
|
168
|
+
resolve(socket);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function refusalText(err) {
|
|
174
|
+
return isSsrfError(err) ? (err.cause?.message || err.message) : 'Bad Gateway';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function handleConnect(upstream) {
|
|
178
|
+
return async (req, client, head) => {
|
|
179
|
+
client.on('error', () => {});
|
|
180
|
+
let remote;
|
|
181
|
+
try {
|
|
182
|
+
const { host, port } = splitAuthority(req.url);
|
|
183
|
+
remote = await openTunnel(upstream, host, port);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
const status = isSsrfError(err) ? '403 Forbidden' : '502 Bad Gateway';
|
|
186
|
+
client.end(`HTTP/1.1 ${status}\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n${refusalText(err)}`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
remote.on('error', () => client.destroy());
|
|
190
|
+
client.on('close', () => remote.destroy());
|
|
191
|
+
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
192
|
+
if (head.length) remote.write(head);
|
|
193
|
+
remote.pipe(client);
|
|
194
|
+
client.pipe(remote);
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function handleRequest(upstream) {
|
|
199
|
+
return async (req, res) => {
|
|
200
|
+
const fail = (status, text) => {
|
|
201
|
+
if (res.headersSent) return res.destroy();
|
|
202
|
+
res.writeHead(status, { 'Content-Type': 'text/plain', Connection: 'close' });
|
|
203
|
+
res.end(text);
|
|
204
|
+
};
|
|
205
|
+
let target;
|
|
206
|
+
let options;
|
|
207
|
+
try {
|
|
208
|
+
target = new URL(req.url);
|
|
209
|
+
if (target.protocol !== 'http:') throw new Error(`protocol ${target.protocol} is not proxied`);
|
|
210
|
+
const port = Number(target.port) || 80;
|
|
211
|
+
const host = target.hostname.replace(/^\[|\]$/g, '');
|
|
212
|
+
const headers = { ...req.headers };
|
|
213
|
+
delete headers['proxy-connection'];
|
|
214
|
+
delete headers['proxy-authorization'];
|
|
215
|
+
options = { method: req.method, headers, path: `${target.pathname}${target.search}` };
|
|
216
|
+
if (upstream && !upstream.protocol.startsWith('socks')) {
|
|
217
|
+
// An HTTP proxy takes the absolute URI and resolves it itself.
|
|
218
|
+
await checkTarget(host, port, upstream);
|
|
219
|
+
Object.assign(options, {
|
|
220
|
+
host: upstream.host,
|
|
221
|
+
port: upstream.port,
|
|
222
|
+
path: target.href,
|
|
223
|
+
headers: { ...headers, ...proxyAuthorization(upstream) }
|
|
224
|
+
});
|
|
225
|
+
options.agent = false;
|
|
226
|
+
} else {
|
|
227
|
+
const socket = await openTunnel(upstream, host, port);
|
|
228
|
+
options.createConnection = () => socket;
|
|
229
|
+
}
|
|
230
|
+
} catch (err) {
|
|
231
|
+
return fail(isSsrfError(err) ? 403 : 502, refusalText(err));
|
|
232
|
+
}
|
|
233
|
+
const lib = upstream?.protocol === 'https:' ? https : http;
|
|
234
|
+
const out = lib.request(options, (upstreamRes) => {
|
|
235
|
+
res.writeHead(upstreamRes.statusCode, upstreamRes.statusMessage, upstreamRes.rawHeaders);
|
|
236
|
+
upstreamRes.pipe(res);
|
|
237
|
+
});
|
|
238
|
+
out.on('error', () => fail(502, 'Bad Gateway'));
|
|
239
|
+
req.pipe(out);
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** A Playwright proxy option ({server, username?, password?}) → the upstream, or null. */
|
|
244
|
+
function upstreamUrl(proxy) {
|
|
245
|
+
if (!proxy?.server) return null;
|
|
246
|
+
const raw = /^[a-z][a-z0-9+.-]*:\/\//i.test(proxy.server) ? proxy.server : `http://${proxy.server}`;
|
|
247
|
+
const url = new URL(raw);
|
|
248
|
+
return {
|
|
249
|
+
protocol: url.protocol,
|
|
250
|
+
host: url.hostname.replace(/^\[|\]$/g, ''),
|
|
251
|
+
// URL drops a scheme's default port; SOCKS has none of its own there.
|
|
252
|
+
port: Number(url.port) || { 'http:': 80, 'https:': 443 }[url.protocol] || 1080,
|
|
253
|
+
// Credentials ride beside the server in Playwright's shape, or in the URL.
|
|
254
|
+
username: proxy.username ?? (decodeURIComponent(url.username) || undefined),
|
|
255
|
+
password: proxy.password ?? (decodeURIComponent(url.password) || undefined)
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The proxy option a browser context is created with: the local guarded
|
|
261
|
+
* proxy, chained to `upstream` when one is given.
|
|
262
|
+
* @param {{ server: string, username?: string, password?: string }|null} [upstream]
|
|
263
|
+
* the proxy the context would otherwise have used, in Playwright's shape
|
|
264
|
+
* @returns {Promise<{ server: string }>}
|
|
265
|
+
*/
|
|
266
|
+
export function guardedProxyFor(upstream = null) {
|
|
267
|
+
const up = upstreamUrl(upstream);
|
|
268
|
+
const key = up ? JSON.stringify(up) : 'direct';
|
|
269
|
+
if (!servers.has(key)) {
|
|
270
|
+
const started = new Promise((resolve, reject) => {
|
|
271
|
+
const server = http.createServer(handleRequest(up));
|
|
272
|
+
server.on('connect', handleConnect(up));
|
|
273
|
+
server.on('clientError', (_err, socket) => socket.destroy());
|
|
274
|
+
server.once('error', reject);
|
|
275
|
+
server.listen(0, '127.0.0.1', () => {
|
|
276
|
+
server.unref();
|
|
277
|
+
resolve({ server: `http://127.0.0.1:${server.address().port}` });
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
started.catch(() => servers.delete(key));
|
|
281
|
+
servers.set(key, started);
|
|
282
|
+
}
|
|
283
|
+
return servers.get(key);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export default guardedProxyFor;
|
|
@@ -511,6 +511,28 @@ export function resolveRef(page, selector) {
|
|
|
511
511
|
return state.refs.get(id).selector;
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Point a ref whose element has left the document at the element that took
|
|
516
|
+
* its place: the one element with the role and name the snapshot recorded.
|
|
517
|
+
*
|
|
518
|
+
* A page can swap an element out without navigating — Wikipedia replaces its
|
|
519
|
+
* search box after load — and the ref then names a detached node that no
|
|
520
|
+
* action can reach (live sweep 2026-10-20, 3.2). This is not a guess: an
|
|
521
|
+
* unnamed ref, or a role and name that match none or several elements, is left
|
|
522
|
+
* as it was and the action fails as before.
|
|
523
|
+
* @returns {Promise<boolean>} true when the ref now names the replacement
|
|
524
|
+
*/
|
|
525
|
+
export async function reresolveRef(page, selector) {
|
|
526
|
+
const entry = isRef(selector) ? pageState.get(page)?.refs?.get(selector.slice(1)) : null;
|
|
527
|
+
if (!entry?.role || !entry.name) return false;
|
|
528
|
+
// `s`: the name matches exactly and case-sensitively, not as a substring.
|
|
529
|
+
const candidate = `role=${entry.role}[name=${JSON.stringify(entry.name)}s]`;
|
|
530
|
+
const count = await page.locator(candidate).count().catch(() => 0);
|
|
531
|
+
if (count !== 1) return false;
|
|
532
|
+
entry.selector = candidate;
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
|
|
514
536
|
/** Register the navigation listener that invalidates this page's refs. Idempotent. */
|
|
515
537
|
export function attachRefTracking(page) {
|
|
516
538
|
const state = stateFor(page);
|
|
@@ -7,7 +7,7 @@ import { DomainFilter } from '../../utils/domainFilter.js';
|
|
|
7
7
|
import { LinkAnalyzer } from '../analysis/LinkAnalyzer.js';
|
|
8
8
|
import { normalizeUrl, extractLinks, isValidUrl } from '../../utils/urlNormalizer.js';
|
|
9
9
|
import { Logger } from '../../utils/Logger.js';
|
|
10
|
-
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
10
|
+
import { safeFetch, isSsrfError } from '../../utils/ssrfGuard.js';
|
|
11
11
|
import { robotsPreflight, redirectGate, RobotsDisallowedError } from '../../utils/robotsGate.js';
|
|
12
12
|
import { throttleHost } from '../../utils/hostRateLimiter.js';
|
|
13
13
|
import { CRAWLFORGE_USER_AGENT, identityHeaders } from '../../utils/fetchIdentity.js';
|
|
@@ -169,11 +169,12 @@ export class BFSCrawler {
|
|
|
169
169
|
|
|
170
170
|
// Check domain filter (replaces old pattern checking)
|
|
171
171
|
const filterDecision = this.domainFilter.isAllowed(url, { isSeed });
|
|
172
|
-
|
|
172
|
+
const decisionEntry = {
|
|
173
173
|
url: normalizedUrl,
|
|
174
174
|
decision: filterDecision,
|
|
175
175
|
timestamp: new Date().toISOString()
|
|
176
|
-
}
|
|
176
|
+
};
|
|
177
|
+
this.filterDecisions.push(decisionEntry);
|
|
177
178
|
|
|
178
179
|
if (!filterDecision.allowed) {
|
|
179
180
|
logger.debug(`Domain filter blocks: ${normalizedUrl} - ${filterDecision.reason}`);
|
|
@@ -349,6 +350,11 @@ export class BFSCrawler {
|
|
|
349
350
|
}
|
|
350
351
|
}
|
|
351
352
|
} catch (error) {
|
|
353
|
+
// The domain filter passed this URL, but the SSRF guard refused it: it
|
|
354
|
+
// was blocked, not allowed (live sweep 2026-10-20).
|
|
355
|
+
if (isSsrfError(error)) {
|
|
356
|
+
decisionEntry.decision = { allowed: false, reason: 'SSRF protection' };
|
|
357
|
+
}
|
|
352
358
|
this.errors.push({
|
|
353
359
|
url,
|
|
354
360
|
depth,
|
|
@@ -437,7 +443,7 @@ export class BFSCrawler {
|
|
|
437
443
|
const $ = load(html);
|
|
438
444
|
|
|
439
445
|
// Extract title
|
|
440
|
-
const title = pageTitle($) || $('h1').first().text().trim() || '';
|
|
446
|
+
const title = (pageTitle($) || $('h1').first().text().trim() || '').replace(/\s+/g, ' ');
|
|
441
447
|
|
|
442
448
|
// Extract main content. A page's chrome (header, mega-menu, footer)
|
|
443
449
|
// repeats on every page of a crawl and comes first in body order, so the
|
|
@@ -11,6 +11,7 @@ import HumanBehaviorSimulator from '../../utils/HumanBehaviorSimulator.js';
|
|
|
11
11
|
import LocalizationManager from '../LocalizationManager.js';
|
|
12
12
|
import { safeGoto, assertNavigationAllowed } from '../../utils/ssrfGuard.js';
|
|
13
13
|
import { browserUserAgent } from '../../utils/fetchIdentity.js';
|
|
14
|
+
import { guardedProxyFor } from '../browser/guardedProxy.js';
|
|
14
15
|
|
|
15
16
|
const BrowserProcessorSchema = z.object({
|
|
16
17
|
url: z.string().url(),
|
|
@@ -622,9 +623,10 @@ export class BrowserProcessor {
|
|
|
622
623
|
if (options.geolocation) {
|
|
623
624
|
contextOptions.geolocation = options.geolocation;
|
|
624
625
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
626
|
+
// Every connection the page opens — redirect hops, iframes, WebSockets —
|
|
627
|
+
// goes through the SSRF-guarded proxy, chained to the caller's proxy when
|
|
628
|
+
// there is one (src/core/browser/guardedProxy.js).
|
|
629
|
+
contextOptions.proxy = await guardedProxyFor(options.proxy || null);
|
|
628
630
|
|
|
629
631
|
const context = await this.browser.newContext(contextOptions);
|
|
630
632
|
const page = await context.newPage();
|
|
@@ -8,7 +8,7 @@ import { JSDOM } from 'jsdom';
|
|
|
8
8
|
import * as cheerio from 'cheerio';
|
|
9
9
|
import { z } from 'zod';
|
|
10
10
|
import { ContentQualityAssessor } from '../../utils/contentUtils.js';
|
|
11
|
-
import { recoverDroppedTables, unwrapMediaWikiHeadings, tooLargeForReadability } from '../../tools/scrape/_mainContent.js';
|
|
11
|
+
import { recoverDroppedTables, unwrapMediaWikiHeadings, keepListItemBylines, tooLargeForReadability } from '../../tools/scrape/_mainContent.js';
|
|
12
12
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
13
13
|
import { flattenText } from 'crawlforge-extractors';
|
|
14
14
|
|
|
@@ -76,6 +76,7 @@ export class ContentProcessor {
|
|
|
76
76
|
// A page too large for Readability keeps no `readability` block (R25).
|
|
77
77
|
if (processingOptions.removeBoilerplate && !tooLargeForReadability(html)) {
|
|
78
78
|
unwrapMediaWikiHeadings(document);
|
|
79
|
+
keepListItemBylines(document);
|
|
79
80
|
const reader = new Readability(document, {
|
|
80
81
|
debug: false,
|
|
81
82
|
maxElemsToDivide: 300,
|
|
@@ -40,6 +40,21 @@ const FRONT_MATTER_PATTERNS = [
|
|
|
40
40
|
/\bperformance (?:&|and) security by\b/i
|
|
41
41
|
];
|
|
42
42
|
|
|
43
|
+
// Lines that are not statements about the topic, all three key findings in the
|
|
44
|
+
// R25 re-check (2026-10-19, "What anti-bot systems do major websites use in
|
|
45
|
+
// 2026"): a question, which is an FAQ heading ("What makes a good anti-bot
|
|
46
|
+
// solution?"); an opening hook addressed to the reader ("If you've ever
|
|
47
|
+
// rage-clicked on a … puzzle, you already understand the problem with
|
|
48
|
+
// CAPTCHAs."); and a scoring rubric's weight ("Detection technology
|
|
49
|
+
// (signatures, ML, rules, session/event models): 25%"). Its live re-run added
|
|
50
|
+
// "What you are experiencing is the cat-and-mouse game…" and the signpost
|
|
51
|
+
// "Here's how Cloudflare, Akamai, Kasada, and PerimeterX each weigh…".
|
|
52
|
+
const NON_STATEMENT_PATTERNS = [
|
|
53
|
+
/\?["'”’)]*\s*$/,
|
|
54
|
+
/^(?:if you(?:['’]ve| have) ever|have you ever|ever (?:wondered|tried|noticed)|imagine|picture this|let['’]s|what you(?:['’]re| are)|here['’]s (?:how|what|why|where))\b/i,
|
|
55
|
+
/^[^.!?]{2,150}:\s*\d{1,3}(?:\.\d+)?\s*%\.?$/
|
|
56
|
+
];
|
|
57
|
+
|
|
43
58
|
// Tokens that mark an affiliation line rather than a sentence about the topic.
|
|
44
59
|
const AFFILIATION_TOKENS = new Set([
|
|
45
60
|
'university', 'universities', 'institute', 'institut', 'department', 'dept',
|
|
@@ -159,10 +174,14 @@ export function properNounDensity(text) {
|
|
|
159
174
|
// author or affiliation block rather than a sentence about the topic.
|
|
160
175
|
const PROPER_NOUN_DENSITY_MAX = 0.5;
|
|
161
176
|
|
|
162
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* A candidate claim is admissible when it is a verb-bearing, non-front-matter
|
|
179
|
+
* statement: not a question, a hook addressed to the reader, or a rubric weight.
|
|
180
|
+
*/
|
|
163
181
|
export function isAdmissibleClaim(text) {
|
|
164
182
|
if (typeof text !== 'string' || text.trim().length === 0) return false;
|
|
165
183
|
if (isFrontMatter(text)) return false;
|
|
184
|
+
if (NON_STATEMENT_PATTERNS.some(pattern => pattern.test(text.trim()))) return false;
|
|
166
185
|
if (!hasFiniteVerb(text)) return false;
|
|
167
186
|
return properNounDensity(text) <= PROPER_NOUN_DENSITY_MAX;
|
|
168
187
|
}
|
|
@@ -10,6 +10,7 @@ import { Readability } from '@mozilla/readability';
|
|
|
10
10
|
import compromise from 'compromise';
|
|
11
11
|
import { franc } from 'franc';
|
|
12
12
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
13
|
+
import { keepListItemBylines } from '../../tools/scrape/_mainContent.js';
|
|
13
14
|
|
|
14
15
|
// Task handlers
|
|
15
16
|
const taskHandlers = {
|
|
@@ -162,6 +163,7 @@ async function handleExtractContent(data) {
|
|
|
162
163
|
|
|
163
164
|
// Use Readability for main content extraction
|
|
164
165
|
if (removeBoilerplate) {
|
|
166
|
+
keepListItemBylines(document);
|
|
165
167
|
const reader = new Readability(document, {
|
|
166
168
|
debug: false,
|
|
167
169
|
maxElemsToDivide: 300,
|
|
@@ -314,7 +314,7 @@ const searchWebShape = {
|
|
|
314
314
|
search_time: z.number().optional(),
|
|
315
315
|
offset: z.number().optional(),
|
|
316
316
|
limit: z.number().optional(),
|
|
317
|
-
next_offset: z.number().optional().describe('The offset to pass for the next page. Duplicates removed from this page are replaced from further down the provider\'s results, so it can be larger than offset + limit'),
|
|
317
|
+
next_offset: z.number().optional().describe('The offset to pass for the next page. Duplicates removed from this page are replaced from further down the provider\'s results, so it can be larger than offset + limit. For provider "searxng", offsets count items in SearXNG\'s result list'),
|
|
318
318
|
cached: z.boolean().optional(),
|
|
319
319
|
provider: z.object({
|
|
320
320
|
name: z.string().optional(),
|
|
@@ -347,7 +347,8 @@ const searchWebShape = {
|
|
|
347
347
|
const extractStructuredShape = {
|
|
348
348
|
success: z.boolean().optional().describe('False when the extraction errored, or a required field came back missing, empty, or in the wrong shape'),
|
|
349
349
|
url: z.string().optional(),
|
|
350
|
-
data: z.record(z.unknown()).optional().describe('Extracted fields matching the requested schema'),
|
|
350
|
+
data: z.record(z.unknown()).optional().describe('Extracted fields matching the requested schema; every requested field is present, null when not found'),
|
|
351
|
+
missing_fields: z.array(z.string()).optional().describe('Requested schema fields no extraction method found on the page; each is present in data as null'),
|
|
351
352
|
extraction_method: z.string().optional().describe('"llm" | "css_fallback" | "keyword_fallback" | "none"'),
|
|
352
353
|
provider: z.string().optional().describe('LLM provider that produced the data ("ollama" | "openai" | "anthropic"); present when extraction_method is "llm"'),
|
|
353
354
|
model: z.string().optional().describe('Model that produced the data, e.g. "gemma3:12b"; present when extraction_method is "llm"'),
|
|
@@ -88,8 +88,12 @@ export const CLASS_HINTS = Object.freeze({
|
|
|
88
88
|
: 'This tool has no override; get the content from another source.'),
|
|
89
89
|
host_blocked: () =>
|
|
90
90
|
'This host is on CrawlForge\'s permanent blocklist (a site-owner opt-out). No CrawlForge tool will fetch it and there is no override - get the content from another source.',
|
|
91
|
+
// Names what the active mode refuses (ssrfGuard.js): local Stage 1 lets private networks through.
|
|
91
92
|
ssrf: () =>
|
|
92
|
-
|
|
93
|
+
(process.env.SSRF_STRICT === 'true'
|
|
94
|
+
? 'The URL points at a private or internal address (localhost/loopback, link-local, cloud metadata, or a private network), which every CrawlForge tool refuses. '
|
|
95
|
+
: 'The URL points at an internal address (localhost/loopback, link-local, or cloud metadata), which every CrawlForge tool refuses; private-network addresses are allowed unless SSRF_STRICT=true is set, which refuses them too. ') +
|
|
96
|
+
'Do not retry it with this or any other tool; use a public URL.',
|
|
93
97
|
not_found: () =>
|
|
94
98
|
'The URL does not exist (HTTP 404, or the host does not resolve). Check it for typos, or find the right one with map_site on the site or search_web. Retrying it, or switching to stealth_mode, will not help.',
|
|
95
99
|
model_not_pulled: () =>
|
|
@@ -31,16 +31,20 @@ export const MAX_INLINE_CHARS_PARAM = {
|
|
|
31
31
|
const BROWSER_SESSION_CONTENT_OPERATIONS = new Set(['snapshot', 'act', 'read']);
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* The track_changes operations that return a list or a
|
|
35
|
-
* return one baseline
|
|
34
|
+
* The track_changes operations that return a list, a report or a diff; the
|
|
35
|
+
* others return one baseline or one monitor's id and stay whole. compare was
|
|
36
|
+
* left out until a Hacker News compare with max_inline_chars:4000 came back
|
|
37
|
+
* as about 15 KB inline (live sweep 4.2, 2026-10-20).
|
|
36
38
|
*/
|
|
37
|
-
const
|
|
38
|
-
'get_history', 'export_history', 'list_scheduled_monitors', 'get_dashboard', 'generate_trend_report'
|
|
39
|
+
const TRACK_CHANGES_SHAPED_OPERATIONS = new Set([
|
|
40
|
+
'compare', 'get_history', 'export_history', 'list_scheduled_monitors', 'get_dashboard', 'generate_trend_report'
|
|
39
41
|
]);
|
|
40
42
|
|
|
41
43
|
export const INLINE_THRESHOLD_TOOLS = Object.freeze({
|
|
42
44
|
scrape: { textPaths: ['content.markdown', 'content.text', 'content.html', 'content.rawHtml'], truncate: true },
|
|
43
|
-
|
|
45
|
+
// The headers object is what a status/header check asked for; the scalar
|
|
46
|
+
// filter dropped it from every truncated result (live sweep 6.9).
|
|
47
|
+
fetch_url: { textPaths: ['body'], truncate: true, keep: ['headers'] },
|
|
44
48
|
extract_content: { textPaths: ['content.markdown', 'content.text', 'content.html', 'content.cleanedHTML'], truncate: true },
|
|
45
49
|
crawl_deep: { textPaths: [], truncate: true },
|
|
46
50
|
batch_scrape: { textPaths: [], truncate: true },
|
|
@@ -84,22 +88,26 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
|
|
|
84
88
|
scrape_template: { textPaths: [], truncate: true },
|
|
85
89
|
map_site: { textPaths: [], truncate: true, keep: ['site_map', 'statistics'] },
|
|
86
90
|
// A prose answer is the text view; a structured or missing one leaves the
|
|
87
|
-
// JSON view, and the sources stay inline when they fit.
|
|
88
|
-
|
|
91
|
+
// JSON view, and the sources stay inline when they fit. `reason` too: a
|
|
92
|
+
// degraded run's reason runs past the 200 characters kept unasked, and a
|
|
93
|
+
// degraded:true with no reason says nothing (R25 follow-up, 2026-10-20).
|
|
94
|
+
agent: { textPaths: ['answer'], truncate: true, keep: ['reason', 'evidence', 'search_results', 'provenance'] },
|
|
89
95
|
// llms-full.txt is the large file and the view; llms.txt stays inline under
|
|
90
|
-
// `files` when it fits.
|
|
91
|
-
//
|
|
96
|
+
// `files` when it fits. A json_path names either with brackets, e.g.
|
|
97
|
+
// files["llms.txt"] (the key holds a ".").
|
|
92
98
|
generate_llms_txt: {
|
|
93
99
|
textPaths: ['files.llms-full.txt', 'files.llms.txt'],
|
|
94
100
|
truncate: true,
|
|
95
101
|
contentKey: 'files',
|
|
96
102
|
keep: ['analysisStats', 'recommendations']
|
|
97
103
|
},
|
|
104
|
+
// A compare keeps what says whether and how the page changed; the diff
|
|
105
|
+
// itself (details) is read with json_path. `operation` defaults to compare.
|
|
98
106
|
track_changes: {
|
|
99
107
|
textPaths: ['export.csv'],
|
|
100
108
|
truncate: true,
|
|
101
|
-
keep: ['pagination', 'timespan'],
|
|
102
|
-
when: (params) =>
|
|
109
|
+
keep: ['pagination', 'timespan', 'summary', 'metrics', 'alerts', 'snapshot'],
|
|
110
|
+
when: (params) => TRACK_CHANGES_SHAPED_OPERATIONS.has(params?.operation ?? 'compare')
|
|
103
111
|
}
|
|
104
112
|
});
|
|
105
113
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-batch-automation
|
|
3
3
|
description: "Automates large scraping jobs and browser interactions with CrawlForge's batch_scrape, get_batch_results, scrape_with_actions, and generate_llms_txt tools. Use when the user wants to scrape many URLs at once, batch-scrape a list of pages, collect dozens of product, news, or competitor pages, run browser actions (click, type, scroll, wait) before scraping, log in or fill a form before extracting, or generate an llms.txt for a site. Use sync mode for up to about 25 URLs and async with a webhook for large batches; retrieve paginated output with get_batch_results."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.23.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-browser-sessions
|
|
3
3
|
description: "Keeps one browser page alive across several tool calls with CrawlForge's browser_session tool. Use when the user needs to log in and then read pages behind that login, work through a multi-step flow (search, filter, paginate, fill a wizard), or look at a page before deciding what to click. The loop is: open a session on a URL, snapshot it to list the interactive elements with stable refs (@e1, @e2), act on those refs, read the content, close. Unlike scrape_with_actions, which is one-shot and closes its browser when the call returns, a session survives between calls, so one login is paid for once and a wrong selector costs one call instead of a whole chain."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.23.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-change-tracking
|
|
3
3
|
description: "Monitors web pages for changes over time with CrawlForge's track_changes tool. Use when the user wants to track changes to a page, watch a URL, monitor competitor pricing, detect when content updates, get notified of regulation or product-availability changes, or diff a page against a saved baseline. Workflow: create a baseline with operation create_baseline, then periodically compare with operation compare to get a change percentage and a diff; supports CSS-selector scoping, webhooks, and scheduled monitoring."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.23.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -49,7 +49,7 @@ Use `trackingOptions` to ignore noise and focus on what matters:
|
|
|
49
49
|
"tool": "track_changes",
|
|
50
50
|
"params": {
|
|
51
51
|
"url": "https://example.com/pricing",
|
|
52
|
-
"operation": "
|
|
52
|
+
"operation": "create_baseline",
|
|
53
53
|
"trackingOptions": {
|
|
54
54
|
"granularity": "element",
|
|
55
55
|
"customSelectors": [".price", ".plan-name"],
|
|
@@ -64,6 +64,13 @@ Use `trackingOptions` to ignore noise and focus on what matters:
|
|
|
64
64
|
`trackText`, `trackStructure`, `trackLinks`, `trackImages`. Set
|
|
65
65
|
`significanceThresholds` (`minor`/`moderate`/`major`) to classify change size.
|
|
66
66
|
|
|
67
|
+
`granularity`, `customSelectors` and `excludeSelectors` are fixed when the
|
|
68
|
+
baseline is created: every `compare` diffs with the baseline's values, so
|
|
69
|
+
pass them on `create_baseline`. A `compare` that asks for different values
|
|
70
|
+
still runs at the baseline's, and its result carries
|
|
71
|
+
`ignoredOptions: [{option, requested, applied}]` plus a `warnings` entry.
|
|
72
|
+
To change them, create a new baseline.
|
|
73
|
+
|
|
67
74
|
CLI: `crawlforge track https://example.com --selector ".price" --threshold 1`.
|
|
68
75
|
|
|
69
76
|
## Scheduled monitoring & notifications
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-deep-research
|
|
3
3
|
description: "Runs multi-source web research and autonomous question-answering with CrawlForge's deep_research, agent, search_web, and reddit_search tools. Use when the user wants to research a topic, do a deep dive, compare competitors, gather facts with citations, answer a question from the web, search the web, or get a synthesized report from many sources, or search Reddit posts, comments, and threads. deep_research expands queries, fetches and dedupes sources, then synthesizes; agent autonomously plans searches and navigation from a plain-English prompt with no URLs needed; search_web returns ranked results; reddit_search searches Reddit via community archives (reddit.com blocks scrapers). Caps costs via max_urls and confirms before expensive runs."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.23.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-getting-started
|
|
3
3
|
description: "Orientation and tool-selection guide for the CrawlForge MCP server's 31 web tools. Use when the user is getting started with CrawlForge, asks which CrawlForge tool to use, how to set up the API key, how skills or the CLI work, what a tool costs in credits, or when one tool fails and a fallback is needed. Routes requests to the right specialized skill (web scraping, deep research, stealth, structured extraction, change tracking, batch automation, browser sessions), and explains MCP-tools-vs-CLI, the Ollama-first LLM fallback chain, and per-tool credit costs."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.23.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-stealth-browsing
|
|
3
3
|
description: "Bypasses bot detection with CrawlForge's stealth_mode tool and looks up a country's locale settings with localization. Use when a site returns 403 or 429, CAPTCHAs, 'please enable JavaScript', or empty content, or is protected by Cloudflare, DataDome, or PerimeterX, or when the user needs a page in a specific locale, language, or timezone. stealth_mode runs a stealth browser (engine auto by default: camoufox/Firefox for advanced fingerprinting, playwright/Chromium when pinned for speed) and can screenshot; localization returns a country's Accept-Language, timezone and currency for you to pass to fetch_url or stealth_mode - it applies nothing itself and uses no proxy. Explains when to escalate from a normal scrape to stealth, and why a hard block needs a residential proxy."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.23.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-structured-extraction
|
|
3
3
|
description: "Extracts structured JSON and analyzes content with CrawlForge's extract_structured, extract_with_llm, extract_embedded_state, scrape_structured, scrape_template, process_document, analyze_content, summarize_content, and list_ollama_models tools. Use when the user wants to extract specific fields, pull data into a JSON schema, extract by natural-language prompt, scrape with CSS selectors, read a page's embedded JavaScript state (__NEXT_DATA__, React Server Components, Nuxt, Apollo, Redux), get product, profile, or repo data from known sites (Amazon, LinkedIn, GitHub, YouTube, Reddit, and more), parse a PDF or DOCX, summarize a page, or analyze sentiment, entities, or keywords. Defaults to local Ollama for LLM extraction; OpenAI and Anthropic optional."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.23.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|