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
@@ -151,7 +151,7 @@ export class ResearchOrchestrator extends EventEmitter {
151
151
  this.logger.info('Starting deep research', { sessionId, topic, options });
152
152
 
153
153
  // Stage 1: Initial topic exploration and query expansion
154
- const expandedQueries = await this.expandResearchTopic(topic);
154
+ const expandedQueries = await this.expandResearchTopic(topic, options);
155
155
  this.researchState.currentDepth = 1;
156
156
  this.logActivity('topic_expansion', { originalTopic: topic, expandedQueries });
157
157
 
@@ -172,7 +172,22 @@ export class ResearchOrchestrator extends EventEmitter {
172
172
  this.logActivity('source_verification', { verifiedCount: verifiedSources.length });
173
173
 
174
174
  // Stage 5: Information synthesis and conflict detection
175
- const synthesizedResults = await this.synthesizeInformation(verifiedSources, topic);
175
+ // enableSynthesis:false (deep_research schema) previously reached no code
176
+ // path at all — honor it the same way the no-LLM case already degrades:
177
+ // hand back raw evidence for the caller to synthesize instead.
178
+ const synthesizedResults = options.enableSynthesis === false
179
+ ? {
180
+ keyFindings: [],
181
+ supportingEvidence: this.compileSupportingEvidence(verifiedSources),
182
+ conflicts: [],
183
+ consensus: [],
184
+ gaps: [],
185
+ recommendations: [],
186
+ llmSynthesis: null,
187
+ rawEvidence: this.buildRawEvidence(verifiedSources),
188
+ synthesisMode: 'raw_evidence'
189
+ }
190
+ : await this.synthesizeInformation(verifiedSources, topic);
176
191
  this.researchState.currentDepth = 5;
177
192
  this.logActivity('information_synthesis', { conflictsFound: synthesizedResults.conflicts.length });
178
193
 
@@ -223,10 +238,18 @@ export class ResearchOrchestrator extends EventEmitter {
223
238
  credibilityScores: new Map(),
224
239
  conflictMap: new Map(),
225
240
  activityLog: [],
241
+ llmAnalysis: new Map(),
242
+ semanticSimilarities: new Map(),
243
+ relevanceScores: new Map(),
244
+ synthesisHistory: [],
226
245
  // D2.3 token budget tracking
227
246
  tokenBudgetChars: TOKEN_BUDGET_CHARS,
228
247
  tokenBudgetUsed: 0,
229
- tokenBudgetExceeded: false
248
+ tokenBudgetExceeded: false,
249
+ // Single wall-clock deadline shared by every processWithTimeLimit()
250
+ // call in this session, so a `timeLimit` budget is spent once across
251
+ // all stages instead of being handed out fresh per stage.
252
+ deadline: startTime + this.timeLimit
230
253
  };
231
254
 
232
255
  // Reset metrics
@@ -241,12 +264,17 @@ export class ResearchOrchestrator extends EventEmitter {
241
264
  /**
242
265
  * Expand research topic into multiple targeted queries with LLM enhancement
243
266
  */
244
- async expandResearchTopic(topic) {
267
+ async expandResearchTopic(topic, options = {}) {
245
268
  const startTime = Date.now();
246
-
269
+ // Caller-supplied query-expansion tuning (deep_research's `queryExpansion`
270
+ // param) — previously ignored entirely; the hardcoded defaults below were
271
+ // used regardless of what the caller requested.
272
+ const qe = options.queryExpansion || {};
273
+ const maxExpansions = qe.maxVariations || 8;
274
+
247
275
  try {
248
- const cacheKey = this.cache ? this.cache.generateKey('topic_expansion_v2', { topic, llm: this.enableLLMFeatures }) : null;
249
-
276
+ const cacheKey = this.cache ? this.cache.generateKey('topic_expansion_v2', { topic, llm: this.enableLLMFeatures, qe }) : null;
277
+
250
278
  if (this.cache && cacheKey) {
251
279
  const cached = await this.cache.get(cacheKey);
252
280
  if (cached) {
@@ -256,15 +284,15 @@ export class ResearchOrchestrator extends EventEmitter {
256
284
  }
257
285
 
258
286
  let expandedQueries = [];
259
-
287
+
260
288
  // LLM-powered query expansion (preferred)
261
289
  if (this.enableLLMFeatures) {
262
290
  try {
263
291
  this.logger.info('Using LLM for intelligent query expansion');
264
292
  expandedQueries = await this.llmManager.expandQuery(topic, {
265
- maxExpansions: 8,
266
- includeContextual: true,
267
- includeSynonyms: true,
293
+ maxExpansions,
294
+ includeContextual: qe.enableContextual !== false,
295
+ includeSynonyms: qe.enableSynonyms !== false,
268
296
  includeRelated: true
269
297
  });
270
298
  this.metrics.llmAnalysisCalls++;
@@ -272,14 +300,14 @@ export class ResearchOrchestrator extends EventEmitter {
272
300
  this.logger.warn('LLM query expansion failed, falling back to traditional methods', { error: llmError.message });
273
301
  }
274
302
  }
275
-
303
+
276
304
  // Fallback to traditional expansion if LLM failed or unavailable
277
305
  if (expandedQueries.length === 0) {
278
306
  expandedQueries = await this.queryExpander.expandQuery(topic, {
279
- enableSynonyms: true,
280
- enableSpellCheck: true,
307
+ enableSynonyms: qe.enableSynonyms !== false,
308
+ enableSpellCheck: qe.enableSpellCheck !== false,
281
309
  enablePhraseDetection: true,
282
- maxExpansions: 8
310
+ maxExpansions
283
311
  });
284
312
  }
285
313
 
@@ -464,21 +492,20 @@ export class ResearchOrchestrator extends EventEmitter {
464
492
  const allSources = [];
465
493
  const searchErrors = [];
466
494
  const attemptedQueries = queries.slice(0, 5);
467
- const maxSourcesPerQuery = Math.ceil(this.maxUrls / queries.length);
495
+ // SearchWebSchema caps `limit` at 100 per call — fan a query out across
496
+ // multiple paged searches instead of requesting more than that in one
497
+ // call (previously maxUrls > 500, or > 100 with a single surviving
498
+ // query, made every internal search throw a zod 'too_big' error).
499
+ const desiredSourcesPerQuery = Math.ceil(this.maxUrls / queries.length);
468
500
 
469
501
  await this.processWithTimeLimit(async () => {
470
502
  const searchPromises = attemptedQueries.map(async (query) => {
471
503
  try {
472
- const searchResults = await this.searchTool.execute({
473
- query,
474
- limit: maxSourcesPerQuery,
475
- enable_ranking: true,
476
- enable_deduplication: true
477
- });
504
+ const searchResults = await this.searchWithPaging(query, desiredSourcesPerQuery);
478
505
  this.metrics.searchQueries++;
479
506
 
480
- if (searchResults.results && searchResults.results.length > 0) {
481
- const processedResults = searchResults.results.map(result => ({
507
+ if (searchResults.length > 0) {
508
+ const processedResults = searchResults.map(result => ({
482
509
  ...result,
483
510
  sourceQuery: query,
484
511
  discoveredAt: new Date().toISOString(),
@@ -509,13 +536,103 @@ export class ResearchOrchestrator extends EventEmitter {
509
536
  );
510
537
  }
511
538
 
512
- // Deduplicate and rank sources
539
+ // Deduplicate, apply the caller's source preferences, and rank sources
513
540
  const uniqueSources = this.deduplicateSources(allSources);
514
- const rankedSources = await this.rankSourcesByResearchValue(uniqueSources);
515
-
541
+ const filteredSources = this.applySourcePreferences(uniqueSources, options);
542
+ const rankedSources = await this.rankSourcesByResearchValue(filteredSources);
543
+
516
544
  return rankedSources.slice(0, this.maxUrls);
517
545
  }
518
546
 
547
+ /**
548
+ * Fan a single query out across multiple paged searches when the desired
549
+ * source count exceeds SearchWebSchema's per-call `limit` cap (100). Stops
550
+ * early once a page returns fewer items than requested (source exhausted).
551
+ */
552
+ async searchWithPaging(query, desiredCount) {
553
+ const results = [];
554
+ let offset = 0;
555
+
556
+ while (results.length < desiredCount) {
557
+ const limit = Math.min(100, desiredCount - results.length);
558
+ const page = await this.searchTool.execute({
559
+ query,
560
+ limit,
561
+ offset,
562
+ enable_ranking: true,
563
+ enable_deduplication: true
564
+ });
565
+ const items = page.results || [];
566
+ results.push(...items);
567
+ if (items.length < limit) break; // no more results available
568
+ offset += limit;
569
+ }
570
+
571
+ return results;
572
+ }
573
+
574
+ /**
575
+ * Apply the caller's sourceTypes / includeRecentOnly preferences (part of
576
+ * conductResearch's options argument — previously read nowhere in the
577
+ * orchestrator despite being documented deep_research parameters). Lenient
578
+ * by design: a source whose type or date can't be confidently determined
579
+ * is kept rather than dropped, so an imprecise classification can't zero
580
+ * out a research run.
581
+ */
582
+ applySourcePreferences(sources, options = {}) {
583
+ let filtered = sources;
584
+
585
+ if (Array.isArray(options.sourceTypes) && options.sourceTypes.length > 0 && !options.sourceTypes.includes('any')) {
586
+ filtered = filtered.filter(source => {
587
+ const type = this.classifySourceType(source.link);
588
+ return type === null || options.sourceTypes.includes(type);
589
+ });
590
+ }
591
+
592
+ if (options.includeRecentOnly) {
593
+ filtered = filtered.filter(source => {
594
+ const ageMonths = this.estimateSourceAgeMonths(source);
595
+ return ageMonths === null || ageMonths <= 12;
596
+ });
597
+ }
598
+
599
+ return filtered;
600
+ }
601
+
602
+ /**
603
+ * Best-effort classification of a source URL into one of the deep_research
604
+ * sourceTypes categories. Returns null when the domain gives no signal.
605
+ */
606
+ classifySourceType(url) {
607
+ try {
608
+ const domain = new URL(url).hostname.toLowerCase();
609
+ if (domain.endsWith('.gov')) return 'government';
610
+ if (domain.endsWith('.edu') || domain.includes('scholar.')) return 'academic';
611
+ if (domain.endsWith('wikipedia.org')) return 'wiki';
612
+ if (['medium.com', 'blogspot.com', 'wordpress.com', 'substack.com'].some(d => domain.endsWith(d))) return 'blog';
613
+ if (['reuters.com', 'apnews.com', 'bbc.com', 'nytimes.com', 'cnn.com'].some(d => domain.endsWith(d)) || domain.includes('news')) return 'news';
614
+ if (domain.endsWith('.com') || domain.endsWith('.io') || domain.endsWith('.co')) return 'commercial';
615
+ return null;
616
+ } catch {
617
+ return null;
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Approximate a source's age in months from search-result date metadata.
623
+ * Returns null when no usable date is present.
624
+ */
625
+ estimateSourceAgeMonths(source) {
626
+ const dateString = source.pagemap?.metatags?.publishedTime
627
+ || source.pagemap?.metatags?.modifiedTime
628
+ || source.publishedDate
629
+ || source.pubDate;
630
+ if (!dateString) return null;
631
+ const date = new Date(dateString);
632
+ if (isNaN(date.getTime())) return null;
633
+ return (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24 * 30.44);
634
+ }
635
+
519
636
  /**
520
637
  * Explore promising sources in depth with LLM-powered relevance analysis
521
638
  */
@@ -524,8 +641,9 @@ export class ResearchOrchestrator extends EventEmitter {
524
641
  const batchSize = Math.min(this.concurrency, 10);
525
642
  const { topic } = this.researchState;
526
643
 
527
- await this.processWithTimeLimit(async () => {
644
+ await this.processWithTimeLimit(async (signal) => {
528
645
  for (let i = 0; i < sources.length; i += batchSize) {
646
+ if (signal?.aborted) break; // budget exhausted — stop starting new batches
529
647
  const batch = sources.slice(i, i + batchSize);
530
648
 
531
649
  const batchPromises = batch.map(async (source) => {
@@ -1323,18 +1441,38 @@ export class ResearchOrchestrator extends EventEmitter {
1323
1441
  * Utility methods for research workflow
1324
1442
  */
1325
1443
  async processWithTimeLimit(asyncFunction) {
1326
- const timeoutPromise = new Promise((_, reject) => {
1327
- setTimeout(() => reject(new Error('Research time limit exceeded')), this.timeLimit);
1444
+ // Budget is the remaining time against the session's shared deadline
1445
+ // (set once in initializeResearchSession), not a fresh this.timeLimit
1446
+ // per stage — otherwise gatherInitialSources and exploreSourcesInDepth
1447
+ // could each legally run the full budget, doubling the advertised limit.
1448
+ const deadline = this.researchState?.deadline ?? (Date.now() + this.timeLimit);
1449
+ const remaining = Math.max(0, deadline - Date.now());
1450
+
1451
+ const controller = new AbortController();
1452
+ let timeoutId;
1453
+ let timedOut = false;
1454
+ const timeoutPromise = new Promise((resolve) => {
1455
+ timeoutId = setTimeout(() => {
1456
+ timedOut = true;
1457
+ controller.abort();
1458
+ resolve();
1459
+ }, remaining);
1328
1460
  });
1329
1461
 
1462
+ const workPromise = asyncFunction(controller.signal);
1463
+
1330
1464
  try {
1331
- await Promise.race([asyncFunction(), timeoutPromise]);
1332
- } catch (error) {
1333
- if (error.message === 'Research time limit exceeded') {
1465
+ await Promise.race([workPromise, timeoutPromise]);
1466
+ if (timedOut) {
1334
1467
  this.logger.warn('Research time limit reached, returning partial results');
1335
- } else {
1336
- throw error;
1468
+ // Let the aborted work actually unwind (batch loops check the signal
1469
+ // between iterations) before returning, so callers don't proceed
1470
+ // — e.g. sorting detailedFindings or closing the stealth browser —
1471
+ // while the abandoned stage is still pushing into shared state.
1472
+ await workPromise.catch(() => {});
1337
1473
  }
1474
+ } finally {
1475
+ clearTimeout(timeoutId);
1338
1476
  }
1339
1477
  }
1340
1478