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.
Files changed (54) hide show
  1. package/README.md +10 -1
  2. package/package.json +1 -1
  3. package/server.js +14 -7
  4. package/src/core/ActionExecutor.js +117 -12
  5. package/src/core/AgentOrchestrator.js +133 -23
  6. package/src/core/AuthManager.js +7 -1
  7. package/src/core/ChangeTracker.js +52 -4
  8. package/src/core/LLMsTxtAnalyzer.js +21 -9
  9. package/src/core/LocalizationManager.js +20 -4
  10. package/src/core/ResearchOrchestrator.js +29 -3
  11. package/src/core/StealthBrowserManager.js +10 -5
  12. package/src/core/analysis/ContentAnalyzer.js +157 -29
  13. package/src/core/browser/guardedProxy.js +286 -0
  14. package/src/core/browser/snapshot.js +22 -0
  15. package/src/core/crawlers/BFSCrawler.js +10 -4
  16. package/src/core/processing/BrowserProcessor.js +5 -3
  17. package/src/core/processing/ContentProcessor.js +2 -1
  18. package/src/core/research/claimFilters.js +20 -1
  19. package/src/core/workers/worker.js +2 -0
  20. package/src/schemas/toolOutputSchemas.js +3 -2
  21. package/src/server/fallbackHints.js +5 -1
  22. package/src/server/inlineThreshold.js +19 -11
  23. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  24. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  25. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +9 -2
  26. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  27. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
  28. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
  29. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  30. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  31. package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -1
  32. package/src/tools/advanced/ScrapeWithActionsTool.js +35 -9
  33. package/src/tools/agent/sourceChecks.js +103 -0
  34. package/src/tools/basic/extractMetadata.js +9 -4
  35. package/src/tools/basic/extractText.js +6 -4
  36. package/src/tools/basic/fetchUrl.js +3 -0
  37. package/src/tools/basic/scrapeStructured.js +66 -21
  38. package/src/tools/crawl/mapSite.js +18 -3
  39. package/src/tools/extract/extractStructured.js +134 -1
  40. package/src/tools/extract/processDocument.js +48 -5
  41. package/src/tools/extract/summarizeContent.js +123 -5
  42. package/src/tools/llmstxt/generateLLMsTxt.js +6 -5
  43. package/src/tools/result/readResult.js +79 -10
  44. package/src/tools/scrape/_mainContent.js +97 -6
  45. package/src/tools/search/adapters/dataforseoSearch.js +4 -1
  46. package/src/tools/search/searchWeb.js +46 -15
  47. package/src/tools/search/serpRank.js +1 -1
  48. package/src/tools/tracking/trackChanges/index.js +1 -0
  49. package/src/tools/tracking/trackChanges/schema.js +2 -1
  50. package/src/utils/htmlToMarkdown.js +77 -1
  51. package/src/utils/localFilePolicy.js +65 -0
  52. package/src/utils/provenance.js +102 -1
  53. package/src/utils/ssrfGuard.js +4 -3
  54. 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
- this.filterDecisions.push({
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
- if (options.proxy) {
626
- contextOptions.proxy = options.proxy;
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
- /** A candidate claim is admissible when it is a verb-bearing, non-front-matter sentence. */
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
- 'The URL points at a private or internal address (localhost, a private network, cloud metadata), which every CrawlForge tool refuses. Do not retry it with this or any other tool; use a public URL.',
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 report; the others
35
- * return one baseline, one diff or one monitor's id and stay whole.
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 TRACK_CHANGES_LIST_OPERATIONS = new Set([
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
- fetch_url: { textPaths: ['body'], truncate: true },
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
- agent: { textPaths: ['answer'], truncate: true, keep: ['evidence', 'search_results', 'provenance'] },
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. Neither key can be named in a json_path (the "."),
91
- // so the view is the way to read the long one.
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) => TRACK_CHANGES_LIST_OPERATIONS.has(params?.operation)
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.22.2
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.22.2
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.22.2
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": "compare",
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.22.2
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.22.2
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.22.2
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.22.2
5
+ version: 6.23.0
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8