crawlforge-mcp-server 4.9.0 → 5.0.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 (60) hide show
  1. package/CLAUDE.md +6 -5
  2. package/README.md +19 -3
  3. package/package.json +10 -12
  4. package/server.js +315 -214
  5. package/src/core/ActionExecutor.js +117 -33
  6. package/src/core/AgentOrchestrator.js +8 -2
  7. package/src/core/AuthManager.js +51 -17
  8. package/src/core/ChangeTracker.js +26 -10
  9. package/src/core/JobManager.js +9 -1
  10. package/src/core/LocalizationManager.js +19 -6
  11. package/src/core/ResearchOrchestrator.js +173 -35
  12. package/src/core/SnapshotManager.js +162 -165
  13. package/src/core/StealthBrowserManager.js +25 -3
  14. package/src/core/WebhookDispatcher.js +19 -14
  15. package/src/core/analysis/ContentAnalyzer.js +52 -7
  16. package/src/core/crawlers/BFSCrawler.js +27 -3
  17. package/src/core/processing/BrowserProcessor.js +19 -1
  18. package/src/core/processing/PDFProcessor.js +129 -65
  19. package/src/core/queue/QueueManager.js +3 -2
  20. package/src/schemas/toolOutputSchemas.js +269 -0
  21. package/src/server/auth/oauth.js +37 -7
  22. package/src/server/specHygiene.js +192 -0
  23. package/src/server/taskSupport.js +233 -0
  24. package/src/server/toolFilter.js +98 -0
  25. package/src/server/transports/streamableHttp.js +148 -11
  26. package/src/server/withAuth.js +11 -4
  27. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +15 -0
  28. package/src/tools/advanced/ScrapeWithActionsTool.js +43 -52
  29. package/src/tools/advanced/batchScrape/index.js +128 -27
  30. package/src/tools/advanced/batchScrape/worker.js +55 -5
  31. package/src/tools/advanced/scrapeWithActions/recorder.js +3 -0
  32. package/src/tools/basic/_fetch.js +125 -70
  33. package/src/tools/basic/extractLinks.js +14 -12
  34. package/src/tools/basic/scrapeStructured.js +21 -4
  35. package/src/tools/crawl/crawlDeep.js +110 -48
  36. package/src/tools/crawl/mapSite.js +25 -6
  37. package/src/tools/extract/_fetchAndParse.js +98 -1
  38. package/src/tools/extract/extractContent.js +7 -4
  39. package/src/tools/extract/extractStructured.js +125 -84
  40. package/src/tools/extract/extractWithLlm.js +10 -2
  41. package/src/tools/extract/processDocument.js +54 -6
  42. package/src/tools/extract/summarizeContent.js +7 -1
  43. package/src/tools/llmstxt/generateLLMsTxt.js +8 -6
  44. package/src/tools/research/deepResearch.js +51 -31
  45. package/src/tools/scrape/_brandingExtractor.js +49 -11
  46. package/src/tools/scrape/unifiedScrape.js +27 -17
  47. package/src/tools/search/providers/searxng.js +5 -1
  48. package/src/tools/search/ranking/ResultDeduplicator.js +9 -1
  49. package/src/tools/search/ranking/ResultRanker.js +17 -2
  50. package/src/tools/search/searchWeb.js +31 -14
  51. package/src/tools/search/serpRank.js +23 -0
  52. package/src/tools/templates/TemplateRegistry.js +7 -1
  53. package/src/tools/tracking/trackChanges/index.js +87 -26
  54. package/src/tools/tracking/trackChanges/schema.js +2 -2
  55. package/src/utils/CircuitBreaker.js +11 -9
  56. package/src/utils/contentUtils.js +66 -53
  57. package/src/utils/secretMask.js +1 -1
  58. package/src/utils/sitemapParser.js +11 -9
  59. package/src/utils/ssrfGuard.js +212 -40
  60. package/src/utils/urlNormalizer.js +2 -2
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  import { EventEmitter } from 'events';
17
+ import os from 'os';
18
+ import path from 'path';
17
19
  import ChangeTracker from '../../../core/ChangeTracker.js';
18
20
  import SnapshotManager from '../../../core/SnapshotManager.js';
19
21
  import CacheManager from '../../../core/cache/CacheManager.js';
@@ -31,7 +33,12 @@ export class TrackChangesTool extends EventEmitter {
31
33
  this.options = {
32
34
  cacheEnabled: true,
33
35
  cacheTTL: 3600000,
34
- snapshotStorageDir: './snapshots',
36
+ // Rooted in a stable, non-cwd-dependent base (~/.crawlforge — same
37
+ // convention as ~/.crawlforge/config.json) rather than process.cwd(),
38
+ // since MCP clients (e.g. Claude Desktop) may launch the server with a
39
+ // cwd the process cannot write to (e.g. '/'), which previously made
40
+ // every snapshot write fail silently.
41
+ snapshotStorageDir: path.join(os.homedir(), '.crawlforge', 'snapshots'),
35
42
  enableRealTimeMonitoring: true,
36
43
  maxConcurrentMonitors: 50,
37
44
  defaultPollingInterval: 300000,
@@ -64,7 +71,15 @@ export class TrackChangesTool extends EventEmitter {
64
71
  this.monitorStore = new MonitorStore({ storageDir: this.options.monitorStorageDir || './monitors' });
65
72
  this.scheduler = new MonitorScheduler({ tool: this, store: this.monitorStore });
66
73
 
67
- this.initialize();
74
+ // Wired synchronously (no I/O) so no 'error' event emitted by
75
+ // changeTracker/snapshotManager during the lazy initialize() below (see
76
+ // ensureInitialized()) can ever be emitted before a listener exists.
77
+ this._setupEventHandlers();
78
+
79
+ // Not started here — construction stays synchronous and side-effect
80
+ // free. ensureInitialized() lazily creates and memoizes this on first
81
+ // real use (called from execute()/startScheduler()/runDueOnce()).
82
+ this._initPromise = null;
68
83
  }
69
84
 
70
85
  /** Wire the MCP server so the goal-judge can use SamplingClient (Ollama-first). */
@@ -74,6 +89,7 @@ export class TrackChangesTool extends EventEmitter {
74
89
 
75
90
  /** Start the in-process scheduler (called once, by the server). */
76
91
  async startScheduler() {
92
+ await this.ensureInitialized();
77
93
  if (this._mcpServer && !this.scheduler.samplingClient) {
78
94
  try {
79
95
  const { SamplingClient } = await import('../../../core/SamplingClient.js');
@@ -87,6 +103,7 @@ export class TrackChangesTool extends EventEmitter {
87
103
 
88
104
  /** Fire every due monitor once and exit (the external-cron one-shot path). */
89
105
  async runDueOnce() {
106
+ await this.ensureInitialized();
90
107
  if (this._mcpServer && !this.scheduler.samplingClient) {
91
108
  try {
92
109
  const { SamplingClient } = await import('../../../core/SamplingClient.js');
@@ -96,15 +113,19 @@ export class TrackChangesTool extends EventEmitter {
96
113
  return this.scheduler.runDueOnce();
97
114
  }
98
115
 
99
- async initialize() {
100
- try {
101
- await this.snapshotManager.initialize();
102
- this._setupEventHandlers();
103
- this.emit('initialized');
104
- } catch (error) {
105
- this.emit('error', { operation: 'initialize', error: error.message });
106
- throw error;
116
+ /**
117
+ * Lazily runs (and memoizes) storage initialization, awaited by execute()
118
+ * and the other top-level entry points below. Replaces the old pattern of
119
+ * firing initialize() unawaited from the constructor, which could turn a
120
+ * snapshot-directory failure into an opaque unhandled rejection.
121
+ */
122
+ async ensureInitialized() {
123
+ if (!this._initPromise) {
124
+ this._initPromise = this.snapshotManager.ensureInitialized().then(() => {
125
+ this.emit('initialized');
126
+ });
107
127
  }
128
+ return this._initPromise;
108
129
  }
109
130
 
110
131
  _setupEventHandlers() {
@@ -129,6 +150,8 @@ export class TrackChangesTool extends EventEmitter {
129
150
 
130
151
  async execute(params) {
131
152
  try {
153
+ await this.ensureInitialized();
154
+
132
155
  const validated = TrackChangesSchema.parse(params);
133
156
  const { operation } = validated;
134
157
 
@@ -169,7 +192,7 @@ export class TrackChangesTool extends EventEmitter {
169
192
  const baseline = await this.changeTracker.createBaseline(url, sourceContent, trackingOptions);
170
193
  let snapshotInfo = null;
171
194
  if (enableSnapshots) {
172
- snapshotInfo = await this.snapshotManager.storeSnapshot(url, sourceContent, { ...fetchMeta, baseline: true, trackingOptions });
195
+ snapshotInfo = await this.snapshotManager.storeSnapshot(url, sourceContent, { ...fetchMeta, baseline: true, trackingOptions }, { enableCompression: storageOptions.compressionEnabled });
173
196
  }
174
197
 
175
198
  return {
@@ -199,13 +222,13 @@ export class TrackChangesTool extends EventEmitter {
199
222
  }
200
223
  if (!currentContent || typeof currentContent !== 'string') throw new Error('Invalid content');
201
224
 
202
- const comparisonResult = await this.changeTracker.compareWithBaseline(url, currentContent, trackingOptions);
225
+ const comparisonResult = await this.changeTracker.compareWithBaseline(url, currentContent, trackingOptions, storageOptions);
203
226
 
204
227
  let snapshotInfo = null;
205
228
  if (comparisonResult.hasChanges && enableSnapshots) {
206
229
  snapshotInfo = await this.snapshotManager.storeSnapshot(url, currentContent, {
207
230
  ...fetchMeta, changes: comparisonResult.summary, significance: comparisonResult.significance
208
- });
231
+ }, { enableCompression: storageOptions.compressionEnabled });
209
232
  }
210
233
 
211
234
  if (comparisonResult.hasChanges && notificationOptions) {
@@ -421,6 +444,17 @@ export class TrackChangesTool extends EventEmitter {
421
444
  }
422
445
 
423
446
  async shutdown() {
447
+ // ensureInitialized() is lazy (see above) and awaits
448
+ // this.snapshotManager.ensureInitialized(), which is itself lazy. If a
449
+ // caller triggered initialization and then immediately calls shutdown(),
450
+ // we must wait for that in-flight init to finish first — otherwise its
451
+ // cleanup timer could start after snapshotManager.shutdown() already
452
+ // tried to stop it, leaking a live timer. If nothing ever triggered
453
+ // initialization, _initPromise is still null and there's nothing to
454
+ // wait for.
455
+ if (this._initPromise) {
456
+ await this._initPromise.catch(() => {});
457
+ }
424
458
  this.stopAllMonitoring();
425
459
  this.scheduler?.stopAll();
426
460
  await this.snapshotManager.shutdown();
@@ -469,17 +503,44 @@ export class TrackChangesTool extends EventEmitter {
469
503
 
470
504
  export default TrackChangesTool;
471
505
 
472
- // Singleton instance — kept for backward-compat with any code that imports it directly
473
- export const trackChangesTool = new TrackChangesTool();
474
- trackChangesTool.name = 'track_changes';
475
- trackChangesTool.validateParameters = (params) => TrackChangesSchema.parse(params);
476
- trackChangesTool.description = 'Track and analyze content changes with baseline capture, comparison, and monitoring capabilities';
477
- trackChangesTool.inputSchema = {
478
- type: 'object',
479
- properties: {
480
- url: { type: 'string', description: 'URL to track for changes' },
481
- operation: { type: 'string', description: 'Operation to perform: create_baseline, compare, monitor, get_history, get_stats' },
482
- content: { type: 'string', description: 'Content to analyze or compare' }
506
+ // Singleton instance — kept for backward-compat with any code that imports it
507
+ // directly. Built lazily, on first property access, rather than eagerly at
508
+ // module-import time: the server (server.js) constructs and owns its own
509
+ // TrackChangesTool instance, so an eager `new TrackChangesTool()` here spun
510
+ // up a second, never-shut-down ChangeTracker/SnapshotManager/CacheManager/
511
+ // MonitorStore/MonitorScheduler (and touched the filesystem) merely by
512
+ // importing this module. Nothing in this codebase currently imports this
513
+ // named export directly, but it's preserved for backward-compat.
514
+ let _singleton = null;
515
+ function _getTrackChangesToolSingleton() {
516
+ if (!_singleton) {
517
+ _singleton = new TrackChangesTool();
518
+ _singleton.name = 'track_changes';
519
+ _singleton.validateParameters = (params) => TrackChangesSchema.parse(params);
520
+ _singleton.description = 'Track and analyze content changes with baseline capture, comparison, and monitoring capabilities';
521
+ _singleton.inputSchema = {
522
+ type: 'object',
523
+ properties: {
524
+ url: { type: 'string', description: 'URL to track for changes' },
525
+ operation: { type: 'string', description: 'Operation to perform: create_baseline, compare, monitor, get_history, get_stats' },
526
+ content: { type: 'string', description: 'Content to analyze or compare' }
527
+ },
528
+ required: ['url']
529
+ };
530
+ }
531
+ return _singleton;
532
+ }
533
+
534
+ export const trackChangesTool = new Proxy({}, {
535
+ get(_target, prop) {
536
+ const instance = _getTrackChangesToolSingleton();
537
+ const value = Reflect.get(instance, prop, instance);
538
+ return typeof value === 'function' ? value.bind(instance) : value;
483
539
  },
484
- required: ['url']
485
- };
540
+ set(_target, prop, value) {
541
+ return Reflect.set(_getTrackChangesToolSingleton(), prop, value);
542
+ },
543
+ has(_target, prop) {
544
+ return Reflect.has(_getTrackChangesToolSingleton(), prop);
545
+ }
546
+ });
@@ -56,7 +56,7 @@ export const TrackChangesSchema = z.object({
56
56
  enableWebhook: z.boolean().default(false),
57
57
  webhookUrl: z.string().url().optional(),
58
58
  webhookSecret: z.string().optional()
59
- }).optional(),
59
+ }).optional().default({}),
60
60
 
61
61
  storageOptions: z.object({
62
62
  enableSnapshots: z.boolean().default(true),
@@ -73,7 +73,7 @@ export const TrackChangesSchema = z.object({
73
73
  endTime: z.number().optional(),
74
74
  includeContent: z.boolean().default(false),
75
75
  significanceFilter: z.enum(['all', 'minor', 'moderate', 'major', 'critical']).optional()
76
- }).optional(),
76
+ }).optional().default({}),
77
77
 
78
78
  notificationOptions: z.object({
79
79
  email: z.object({
@@ -26,9 +26,11 @@ export class CircuitBreaker {
26
26
  this.monitoringWindow = monitoringWindow;
27
27
  this.errorThresholdPercentage = errorThresholdPercentage;
28
28
  this.minimumThroughput = minimumThroughput;
29
- this.onStateChange = onStateChange;
30
- this.onFailure = onFailure;
31
- this.onSuccess = onSuccess;
29
+ // Stored under distinct names so they don't shadow the onStateChange/
30
+ // onFailure/onSuccess prototype methods below (execute() calls those).
31
+ this.onStateChangeCallback = onStateChange;
32
+ this.onFailureCallback = onFailure;
33
+ this.onSuccessCallback = onSuccess;
32
34
  this.name = name;
33
35
 
34
36
  // Circuit state per service endpoint
@@ -138,8 +140,8 @@ export class CircuitBreaker {
138
140
  }
139
141
 
140
142
  // Call success callback
141
- if (this.onSuccess) {
142
- this.onSuccess(serviceId, duration);
143
+ if (this.onSuccessCallback) {
144
+ this.onSuccessCallback(serviceId, duration);
143
145
  }
144
146
  }
145
147
 
@@ -166,8 +168,8 @@ export class CircuitBreaker {
166
168
  }
167
169
 
168
170
  // Call failure callback
169
- if (this.onFailure) {
170
- this.onFailure(serviceId, error, duration);
171
+ if (this.onFailureCallback) {
172
+ this.onFailureCallback(serviceId, error, duration);
171
173
  }
172
174
  }
173
175
 
@@ -221,8 +223,8 @@ export class CircuitBreaker {
221
223
  }
222
224
 
223
225
  // Call state change callback
224
- if (this.onStateChange) {
225
- this.onStateChange(serviceId, oldState, newState, circuit);
226
+ if (this.onStateChangeCallback) {
227
+ this.onStateChangeCallback(serviceId, oldState, newState, circuit);
226
228
  }
227
229
 
228
230
  // Start health monitoring for open circuits
@@ -93,61 +93,74 @@ export class HTMLCleaner {
93
93
 
94
94
  let text = '';
95
95
 
96
- $('body').find('*').each((_, element) => {
97
- const $element = $(element);
98
- const tagName = element.tagName.toLowerCase();
99
-
100
- switch (tagName) {
101
- case 'p':
102
- case 'div':
103
- if (extractOptions.preserveParagraphs) {
104
- text += '\n\n' + $element.text().trim();
105
- } else {
106
- text += ' ' + $element.text().trim();
107
- }
108
- break;
109
- case 'br':
110
- if (extractOptions.preserveLineBreaks) {
111
- text += '\n';
112
- }
113
- break;
114
- case 'h1':
115
- case 'h2':
116
- case 'h3':
117
- case 'h4':
118
- case 'h5':
119
- case 'h6':
120
- text += '\n\n' + $element.text().trim().toUpperCase() + '\n';
121
- break;
122
- case 'a':
123
- if (extractOptions.includeLinks) {
124
- const href = $element.attr('href');
125
- const linkText = $element.text().trim();
126
- text += ` ${linkText}${href ? ` (${href})` : ''}`;
127
- } else {
128
- text += ' ' + $element.text().trim();
129
- }
130
- break;
131
- case 'img':
132
- if (extractOptions.includeImageAlt) {
133
- const alt = $element.attr('alt');
134
- if (alt) {
135
- text += ` [Image: ${alt}]`;
96
+ // Walk direct children recursively (rather than $('body').find('*'),
97
+ // which flattens every descendant) so a block element's text isn't
98
+ // captured once via $element.text() (which includes nested content) and
99
+ // then again when the walker separately visits its nested elements.
100
+ function walk($el) {
101
+ $el.contents().each((_, node) => {
102
+ if (node.type === 'text') {
103
+ const value = (node.data || '').trim();
104
+ if (value) text += ' ' + value;
105
+ return;
106
+ }
107
+ if (node.type !== 'tag') return;
108
+
109
+ const $node = $(node);
110
+ const tagName = node.tagName.toLowerCase();
111
+
112
+ switch (tagName) {
113
+ case 'p':
114
+ case 'div':
115
+ text += extractOptions.preserveParagraphs ? '\n\n' : ' ';
116
+ walk($node);
117
+ break;
118
+ case 'br':
119
+ if (extractOptions.preserveLineBreaks) {
120
+ text += '\n';
136
121
  }
137
- }
138
- break;
139
- case 'li':
140
- text += '\n• ' + $element.text().trim();
141
- break;
142
- default:
143
- // For other elements, just extract text
144
- if ($element.children().length === 0) {
145
- text += ' ' + $element.text().trim();
146
- }
147
- }
148
- });
122
+ break;
123
+ case 'h1':
124
+ case 'h2':
125
+ case 'h3':
126
+ case 'h4':
127
+ case 'h5':
128
+ case 'h6':
129
+ text += '\n\n' + $node.text().trim().toUpperCase() + '\n';
130
+ break;
131
+ case 'a':
132
+ if (extractOptions.includeLinks) {
133
+ const href = $node.attr('href');
134
+ const linkText = $node.text().trim();
135
+ text += ` ${linkText}${href ? ` (${href})` : ''}`;
136
+ } else {
137
+ walk($node);
138
+ }
139
+ break;
140
+ case 'img':
141
+ if (extractOptions.includeImageAlt) {
142
+ const alt = $node.attr('alt');
143
+ if (alt) {
144
+ text += ` [Image: ${alt}]`;
145
+ }
146
+ }
147
+ break;
148
+ case 'li':
149
+ text += '\n• ';
150
+ walk($node);
151
+ break;
152
+ default:
153
+ walk($node);
154
+ }
155
+ });
156
+ }
157
+
158
+ walk($('body'));
149
159
 
150
- return text.replace(/\s+/g, ' ').replace(/\n\s+/g, '\n').trim();
160
+ // Collapse horizontal whitespace only — collapsing all whitespace
161
+ // (including newlines) here would erase the line breaks/paragraphs the
162
+ // options above were just asked to preserve.
163
+ return text.replace(/[ \t]+/g, ' ').replace(/[ \t]*\n[ \t]*/g, '\n').trim();
151
164
  }
152
165
  }
153
166
 
@@ -6,7 +6,7 @@
6
6
  * logger.error('fetch failed', maskSecrets({ apiKey, url, error }));
7
7
  */
8
8
 
9
- const SECRET_KEYS_RE = /api[_-]?key|apikey|x-api-key|password|passwd|secret|token|authorization|auth|credential|private[_-]?key|access[_-]?key|proxy_url|proxyurl/i;
9
+ const SECRET_KEYS_RE = /api[_-]?key|apikey|x-api-key|password|passwd|secret|token|authorization|auth|credential|private[_-]?key|access[_-]?key|proxy_url|proxyurl|cookie/i;
10
10
 
11
11
  const MASK = '[REDACTED]';
12
12
  const PARTIAL_MASK_LEN = 4; // show last N chars of long secrets
@@ -392,19 +392,21 @@ export class SitemapParser {
392
392
  return null;
393
393
  }
394
394
 
395
- const contentType = response.headers.get('content-type') || '';
396
- const contentEncoding = response.headers.get('content-encoding') || '';
397
-
395
+ // fetch (undici) transparently decompresses a gzip Content-Encoding
396
+ // while leaving the response header intact, so that header can't be
397
+ // trusted to decide whether the body still needs gunzipping. Sniff the
398
+ // actual bytes instead: a real gzip payload starts with the 0x1f 0x8b
399
+ // magic number regardless of what the headers claim.
400
+ const buffer = Buffer.from(await response.arrayBuffer());
401
+ const isGzipped = buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b;
402
+
398
403
  let content;
399
-
400
- // Handle compressed content
401
- if (url.endsWith('.gz') || contentEncoding.includes('gzip')) {
402
- const buffer = await response.arrayBuffer();
403
- const decompressed = await gunzip(Buffer.from(buffer));
404
+ if (isGzipped) {
405
+ const decompressed = await gunzip(buffer);
404
406
  content = decompressed.toString('utf8');
405
407
  this.stats.compressionSavings += buffer.byteLength - decompressed.length;
406
408
  } else {
407
- content = await response.text();
409
+ content = buffer.toString('utf8');
408
410
  }
409
411
 
410
412
  return content;