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.
- package/CLAUDE.md +6 -5
- package/README.md +19 -3
- package/package.json +10 -12
- package/server.js +315 -214
- package/src/core/ActionExecutor.js +117 -33
- package/src/core/AgentOrchestrator.js +8 -2
- package/src/core/AuthManager.js +51 -17
- package/src/core/ChangeTracker.js +26 -10
- package/src/core/JobManager.js +9 -1
- package/src/core/LocalizationManager.js +19 -6
- package/src/core/ResearchOrchestrator.js +173 -35
- package/src/core/SnapshotManager.js +162 -165
- package/src/core/StealthBrowserManager.js +25 -3
- package/src/core/WebhookDispatcher.js +19 -14
- package/src/core/analysis/ContentAnalyzer.js +52 -7
- package/src/core/crawlers/BFSCrawler.js +27 -3
- package/src/core/processing/BrowserProcessor.js +19 -1
- package/src/core/processing/PDFProcessor.js +129 -65
- package/src/core/queue/QueueManager.js +3 -2
- package/src/schemas/toolOutputSchemas.js +269 -0
- package/src/server/auth/oauth.js +37 -7
- package/src/server/specHygiene.js +192 -0
- package/src/server/taskSupport.js +233 -0
- package/src/server/toolFilter.js +98 -0
- package/src/server/transports/streamableHttp.js +148 -11
- package/src/server/withAuth.js +11 -4
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +15 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +43 -52
- package/src/tools/advanced/batchScrape/index.js +128 -27
- package/src/tools/advanced/batchScrape/worker.js +55 -5
- package/src/tools/advanced/scrapeWithActions/recorder.js +3 -0
- package/src/tools/basic/_fetch.js +125 -70
- package/src/tools/basic/extractLinks.js +14 -12
- package/src/tools/basic/scrapeStructured.js +21 -4
- package/src/tools/crawl/crawlDeep.js +110 -48
- package/src/tools/crawl/mapSite.js +25 -6
- package/src/tools/extract/_fetchAndParse.js +98 -1
- package/src/tools/extract/extractContent.js +7 -4
- package/src/tools/extract/extractStructured.js +125 -84
- package/src/tools/extract/extractWithLlm.js +10 -2
- package/src/tools/extract/processDocument.js +54 -6
- package/src/tools/extract/summarizeContent.js +7 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +8 -6
- package/src/tools/research/deepResearch.js +51 -31
- package/src/tools/scrape/_brandingExtractor.js +49 -11
- package/src/tools/scrape/unifiedScrape.js +27 -17
- package/src/tools/search/providers/searxng.js +5 -1
- package/src/tools/search/ranking/ResultDeduplicator.js +9 -1
- package/src/tools/search/ranking/ResultRanker.js +17 -2
- package/src/tools/search/searchWeb.js +31 -14
- package/src/tools/search/serpRank.js +23 -0
- package/src/tools/templates/TemplateRegistry.js +7 -1
- package/src/tools/tracking/trackChanges/index.js +87 -26
- package/src/tools/tracking/trackChanges/schema.js +2 -2
- package/src/utils/CircuitBreaker.js +11 -9
- package/src/utils/contentUtils.js +66 -53
- package/src/utils/secretMask.js +1 -1
- package/src/utils/sitemapParser.js +11 -9
- package/src/utils/ssrfGuard.js +212 -40
- 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
|
-
|
|
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
|
|
266
|
-
includeContextual:
|
|
267
|
-
includeSynonyms:
|
|
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:
|
|
280
|
-
enableSpellCheck:
|
|
307
|
+
enableSynonyms: qe.enableSynonyms !== false,
|
|
308
|
+
enableSpellCheck: qe.enableSpellCheck !== false,
|
|
281
309
|
enablePhraseDetection: true,
|
|
282
|
-
maxExpansions
|
|
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
|
-
|
|
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.
|
|
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.
|
|
481
|
-
const processedResults = searchResults.
|
|
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
|
|
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
|
-
|
|
1327
|
-
|
|
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([
|
|
1332
|
-
|
|
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
|
-
|
|
1336
|
-
|
|
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
|
|