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
|
@@ -392,7 +392,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
392
392
|
// Process action results
|
|
393
393
|
const actionResults = this.processActionResults(chainResult.results);
|
|
394
394
|
const intermediateStates = params.captureIntermediateStates ?
|
|
395
|
-
await this.extractIntermediateStates(
|
|
395
|
+
await this.extractIntermediateStates(chainResult.capturedStates || [], params) : [];
|
|
396
396
|
|
|
397
397
|
// Get final page content after all actions (reads the post-action live page
|
|
398
398
|
// captured by ActionExecutor, falling back to a fresh fetch only if missing).
|
|
@@ -440,7 +440,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
440
440
|
formAutoFillApplied: !!params.formAutoFill,
|
|
441
441
|
intermediateStatesCount: intermediateStates.length,
|
|
442
442
|
screenshotsCount: sessionContext.screenshots.length,
|
|
443
|
-
finalUrl: chainResult
|
|
443
|
+
finalUrl: chainResult?.finalUrl,
|
|
444
444
|
timestamp: Date.now()
|
|
445
445
|
},
|
|
446
446
|
|
|
@@ -523,23 +523,17 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
523
523
|
}
|
|
524
524
|
|
|
525
525
|
insertCaptureActions(actions) {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
actions
|
|
529
|
-
|
|
530
|
-
|
|
526
|
+
// Mark actions for native capture (page.content()/page.url() in
|
|
527
|
+
// ActionExecutor) instead of injecting synthetic executeJavaScript
|
|
528
|
+
// actions — avoids depending on ALLOW_JAVASCRIPT_EXECUTION (off by
|
|
529
|
+
// default) and keeps action/failure counts matching the user's own
|
|
530
|
+
// actions rather than being inflated by injected steps.
|
|
531
|
+
return actions.map(action => {
|
|
531
532
|
if (this.shouldCaptureAfterAction(action) || action.captureAfter) {
|
|
532
|
-
|
|
533
|
-
type: 'executeJavaScript',
|
|
534
|
-
script: `return {url: window.location.href, title: document.title, html: document.documentElement.outerHTML, timestamp: Date.now(), capturePoint: ${index + 1}};`,
|
|
535
|
-
description: `Capture state after action ${index + 1}`,
|
|
536
|
-
returnResult: true,
|
|
537
|
-
continueOnError: true
|
|
538
|
-
});
|
|
533
|
+
return { ...action, captureAfter: true };
|
|
539
534
|
}
|
|
535
|
+
return action;
|
|
540
536
|
});
|
|
541
|
-
|
|
542
|
-
return modifiedActions;
|
|
543
537
|
}
|
|
544
538
|
|
|
545
539
|
shouldCaptureAfterAction(action) {
|
|
@@ -563,47 +557,44 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
563
557
|
}));
|
|
564
558
|
}
|
|
565
559
|
|
|
566
|
-
async extractIntermediateStates(
|
|
560
|
+
async extractIntermediateStates(capturedStates, params) {
|
|
567
561
|
const states = [];
|
|
568
562
|
|
|
569
|
-
for (const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
563
|
+
for (const stateData of capturedStates) {
|
|
564
|
+
try {
|
|
565
|
+
const $ = load(stateData.html);
|
|
566
|
+
|
|
567
|
+
const state = {
|
|
568
|
+
capturePoint: stateData.afterActionIndex + 1,
|
|
569
|
+
url: stateData.url,
|
|
570
|
+
title: $('title').text().trim(),
|
|
571
|
+
timestamp: stateData.timestamp,
|
|
572
|
+
content: {}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
if (params.formats.includes('text')) {
|
|
576
|
+
state.content.text = $('body').text().replace(/\s+/g, ' ').trim();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (params.formats.includes('html')) {
|
|
580
|
+
state.content.html = stateData.html;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (params.formats.includes('json')) {
|
|
584
|
+
state.content.json = {
|
|
585
|
+
title: state.title,
|
|
586
|
+
headings: this.extractHeadings($),
|
|
587
|
+
links: this.extractLinks($)
|
|
581
588
|
};
|
|
589
|
+
}
|
|
582
590
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
if (params.formats.includes('html')) {
|
|
588
|
-
state.content.html = stateData.html;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
if (params.formats.includes('json')) {
|
|
592
|
-
state.content.json = {
|
|
593
|
-
title: stateData.title,
|
|
594
|
-
headings: this.extractHeadings($),
|
|
595
|
-
links: this.extractLinks($)
|
|
596
|
-
};
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
if (params.extractionOptions?.selectors) {
|
|
600
|
-
state.content.extracted = this.extractWithSelectors($, params.extractionOptions.selectors);
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
states.push(state);
|
|
604
|
-
} catch (error) {
|
|
605
|
-
this.log('warn', `Failed to process intermediate state: ${error.message}`);
|
|
591
|
+
if (params.extractionOptions?.selectors) {
|
|
592
|
+
state.content.extracted = this.extractWithSelectors($, params.extractionOptions.selectors);
|
|
606
593
|
}
|
|
594
|
+
|
|
595
|
+
states.push(state);
|
|
596
|
+
} catch (error) {
|
|
597
|
+
this.log('warn', `Failed to process intermediate state: ${error.message}`);
|
|
607
598
|
}
|
|
608
599
|
}
|
|
609
600
|
|
|
@@ -34,7 +34,8 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
34
34
|
defaultTimeout = 15000,
|
|
35
35
|
maxBatchSize = 50,
|
|
36
36
|
enableResultCaching = true,
|
|
37
|
-
enableLogging = true
|
|
37
|
+
enableLogging = true,
|
|
38
|
+
maxCachedBatches = 20
|
|
38
39
|
} = options;
|
|
39
40
|
|
|
40
41
|
this.jobManager = jobManager || new JobManager({
|
|
@@ -54,9 +55,20 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
54
55
|
|
|
55
56
|
this.activeBatches = new Map();
|
|
56
57
|
this.batchResults = new Map();
|
|
58
|
+
this.maxCachedBatches = maxCachedBatches;
|
|
59
|
+
this.resultCacheTtl = 3600000; // 1 hour — matches the ttl stored per cache entry
|
|
57
60
|
// D1.4: Elicitation helper (set mcpServer after instantiation if desired)
|
|
58
61
|
this._elicitation = new ElicitationHelper({});
|
|
59
62
|
|
|
63
|
+
// Bound batchResults' lifetime: sweep expired entries periodically (in
|
|
64
|
+
// addition to the on-read eviction in getBatchResults) so cached results —
|
|
65
|
+
// including full HTML bodies when formats includes 'html' — don't
|
|
66
|
+
// accumulate in memory for the life of the process.
|
|
67
|
+
// .unref() so this timer never blocks process exit on its own — matches
|
|
68
|
+
// SnapshotManager's cleanupTimer.
|
|
69
|
+
this._resultsSweepTimer = setInterval(() => this._sweepBatchResults(), 10 * 60 * 1000);
|
|
70
|
+
if (typeof this._resultsSweepTimer.unref === 'function') this._resultsSweepTimer.unref();
|
|
71
|
+
|
|
60
72
|
this.stats = {
|
|
61
73
|
totalBatches: 0,
|
|
62
74
|
completedBatches: 0,
|
|
@@ -128,15 +140,27 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
128
140
|
|
|
129
141
|
async _processBatchSync(batchId, urlConfigs, validated, webhookConfig, startTime) {
|
|
130
142
|
try {
|
|
131
|
-
|
|
143
|
+
// Process in maxConcurrency-sized chunks (rather than one call covering
|
|
144
|
+
// all URLs) so `completed` can be updated for progress polling and so
|
|
145
|
+
// cancelBatch's `cancelled` flag is actually observed between chunks.
|
|
146
|
+
const activeEntry = { id: batchId, mode: 'sync', startTime, total: urlConfigs.length, completed: 0, cancelled: false };
|
|
147
|
+
this.activeBatches.set(batchId, activeEntry);
|
|
148
|
+
|
|
149
|
+
const rawResults = [];
|
|
150
|
+
for (let i = 0; i < urlConfigs.length; i += validated.maxConcurrency) {
|
|
151
|
+
if (activeEntry.cancelled) break;
|
|
152
|
+
const chunk = urlConfigs.slice(i, i + validated.maxConcurrency);
|
|
153
|
+
rawResults.push(...await scrapeUrlsBatch(chunk, validated, this.defaultTimeout));
|
|
154
|
+
activeEntry.completed = rawResults.length;
|
|
155
|
+
}
|
|
156
|
+
const wasCancelled = activeEntry.cancelled;
|
|
132
157
|
|
|
133
|
-
const rawResults = await scrapeUrlsBatch(urlConfigs, validated, this.defaultTimeout);
|
|
134
158
|
const processedResults = processResults(rawResults, validated);
|
|
135
159
|
const executionTime = Date.now() - startTime;
|
|
136
160
|
this._updateAverageBatchTime(executionTime);
|
|
137
161
|
|
|
138
162
|
const batchResult = {
|
|
139
|
-
batchId, mode: 'sync', success: true, executionTime,
|
|
163
|
+
batchId, mode: 'sync', success: true, cancelled: wasCancelled || undefined, executionTime,
|
|
140
164
|
totalUrls: urlConfigs.length,
|
|
141
165
|
successfulUrls: processedResults.filter(r => r.success).length,
|
|
142
166
|
failedUrls: processedResults.filter(r => !r.success).length,
|
|
@@ -151,20 +175,23 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
151
175
|
};
|
|
152
176
|
|
|
153
177
|
if (this.enableResultCaching) {
|
|
154
|
-
this.
|
|
178
|
+
this._cacheBatchResult(batchId, processedResults);
|
|
155
179
|
}
|
|
156
180
|
|
|
157
181
|
this.stats.completedBatches++;
|
|
158
|
-
this.stats.totalUrls +=
|
|
182
|
+
this.stats.totalUrls += rawResults.length;
|
|
159
183
|
this.stats.successfulUrls += batchResult.successfulUrls;
|
|
160
184
|
this.stats.failedUrls += batchResult.failedUrls;
|
|
161
185
|
this.stats.lastUpdated = Date.now();
|
|
162
186
|
this.activeBatches.delete(batchId);
|
|
163
187
|
|
|
164
|
-
// C3: include webhook delivery status in the result
|
|
165
|
-
|
|
166
|
-
if (
|
|
167
|
-
|
|
188
|
+
// C3: include webhook delivery status in the result (skip when cancelled
|
|
189
|
+
// early — a 'batch_completed' notification would be misleading).
|
|
190
|
+
if (!wasCancelled) {
|
|
191
|
+
const webhookStatus = await sendWebhookNotification('batch_completed', batchResult, webhookConfig, this.webhookDispatcher, this.enableWebhookNotifications);
|
|
192
|
+
if (webhookStatus) batchResult.webhookDelivery = webhookStatus;
|
|
193
|
+
this.emit('batchCompleted', batchResult);
|
|
194
|
+
}
|
|
168
195
|
return batchResult;
|
|
169
196
|
} catch (error) {
|
|
170
197
|
this.stats.failedBatches++;
|
|
@@ -195,7 +222,7 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
195
222
|
batchId, mode: 'async', jobId: job.id, status: 'queued',
|
|
196
223
|
totalUrls: urlConfigs.length, createdAt: job.createdAt,
|
|
197
224
|
estimatedCompletion: new Date(job.createdAt + (urlConfigs.length * 2000)),
|
|
198
|
-
statusCheckUrl: `
|
|
225
|
+
statusCheckUrl: `get_batch_results({batchId: "${batchId}"})`,
|
|
199
226
|
webhook: webhookConfig ? { url: webhookConfig.url, events: webhookConfig.events } : null
|
|
200
227
|
};
|
|
201
228
|
} catch (error) {
|
|
@@ -206,14 +233,19 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
206
233
|
|
|
207
234
|
async getBatchResults(batchId, page = 1, pageSize = 25) {
|
|
208
235
|
const cached = this.batchResults.get(batchId);
|
|
209
|
-
if (cached
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
236
|
+
if (cached) {
|
|
237
|
+
if (Date.now() - cached.timestamp < cached.ttl) {
|
|
238
|
+
const offset = (page - 1) * pageSize;
|
|
239
|
+
return {
|
|
240
|
+
batchId, success: true,
|
|
241
|
+
results: paginateResults(cached.results, offset, pageSize),
|
|
242
|
+
pagination: { page, pageSize, totalResults: cached.results.length, totalPages: Math.ceil(cached.results.length / pageSize) },
|
|
243
|
+
cached: true, timestamp: cached.timestamp
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
// Expired — evict now instead of waiting for the periodic sweep, and
|
|
247
|
+
// fall through to the activeBatches/jobManager lookups below.
|
|
248
|
+
this.batchResults.delete(batchId);
|
|
217
249
|
}
|
|
218
250
|
|
|
219
251
|
const active = this.activeBatches.get(batchId);
|
|
@@ -225,6 +257,33 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
225
257
|
};
|
|
226
258
|
}
|
|
227
259
|
|
|
260
|
+
// Async batches are never added to activeBatches; look the underlying job
|
|
261
|
+
// up by its batchId tag so pending/running (and completed-but-uncached)
|
|
262
|
+
// batches are still reported instead of a misleading "not found".
|
|
263
|
+
const jobs = this.jobManager.getJobsByTag(batchId);
|
|
264
|
+
if (jobs.length > 0) {
|
|
265
|
+
const job = jobs[0];
|
|
266
|
+
|
|
267
|
+
if (job.status === 'completed' && job.result) {
|
|
268
|
+
const results = job.result.results || [];
|
|
269
|
+
const offset = (page - 1) * pageSize;
|
|
270
|
+
return {
|
|
271
|
+
batchId, success: true, jobId: job.id,
|
|
272
|
+
results: paginateResults(results, offset, pageSize),
|
|
273
|
+
pagination: { page, pageSize, totalResults: results.length, totalPages: Math.ceil(results.length / pageSize) },
|
|
274
|
+
cached: false, timestamp: job.completedAt
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
batchId, jobId: job.id, status: job.status, mode: 'async',
|
|
280
|
+
progress: { percentage: job.progress, total: job.metadata?.urlCount },
|
|
281
|
+
startTime: job.startedAt || job.createdAt,
|
|
282
|
+
runningTime: Date.now() - (job.startedAt || job.createdAt),
|
|
283
|
+
error: job.error || undefined
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
228
287
|
throw new Error(`Batch ${batchId} not found`);
|
|
229
288
|
}
|
|
230
289
|
|
|
@@ -237,9 +296,14 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
237
296
|
}
|
|
238
297
|
|
|
239
298
|
async cancelBatch(batchId) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
299
|
+
const active = this.activeBatches.get(batchId);
|
|
300
|
+
if (active) {
|
|
301
|
+
// Signal the running _processBatchSync loop to stop dispatching further
|
|
302
|
+
// chunks (the in-flight chunk still finishes — there is no per-request
|
|
303
|
+
// abort). Deleting the entry outright, as before, only hid the batch
|
|
304
|
+
// from getBatchResults; the scrape loop kept running to completion.
|
|
305
|
+
active.cancelled = true;
|
|
306
|
+
return { success: true, message: `Batch ${batchId} cancellation requested; processing stops after the in-flight chunk completes` };
|
|
243
307
|
}
|
|
244
308
|
const jobs = this.jobManager.getJobsByTag(batchId);
|
|
245
309
|
if (jobs.length > 0) {
|
|
@@ -266,6 +330,7 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
266
330
|
}
|
|
267
331
|
this.activeBatches.clear();
|
|
268
332
|
this.batchResults.clear();
|
|
333
|
+
if (this._resultsSweepTimer) clearInterval(this._resultsSweepTimer);
|
|
269
334
|
this.jobManager?.destroy();
|
|
270
335
|
this.webhookDispatcher?.destroy();
|
|
271
336
|
this.removeAllListeners();
|
|
@@ -297,6 +362,25 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
297
362
|
return this.webhookDispatcher.registerWebhook(webhookConfig.url, config);
|
|
298
363
|
}
|
|
299
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Cache a batch's results, capped to maxCachedBatches. Map preserves
|
|
367
|
+
* insertion order, so the oldest entry is evicted first (LRU by write time).
|
|
368
|
+
*/
|
|
369
|
+
_cacheBatchResult(batchId, results) {
|
|
370
|
+
this.batchResults.set(batchId, { results, timestamp: Date.now(), ttl: this.resultCacheTtl });
|
|
371
|
+
while (this.batchResults.size > this.maxCachedBatches) {
|
|
372
|
+
const oldestKey = this.batchResults.keys().next().value;
|
|
373
|
+
this.batchResults.delete(oldestKey);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
_sweepBatchResults() {
|
|
378
|
+
const now = Date.now();
|
|
379
|
+
for (const [id, entry] of this.batchResults) {
|
|
380
|
+
if (now - entry.timestamp >= entry.ttl) this.batchResults.delete(id);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
300
384
|
_updateAverageBatchTime(batchTime) {
|
|
301
385
|
const n = this.stats.completedBatches;
|
|
302
386
|
this.stats.averageBatchTime = n === 1 ? batchTime : ((this.stats.averageBatchTime * (n - 1)) + batchTime) / n;
|
|
@@ -315,8 +399,18 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
315
399
|
|
|
316
400
|
const results = [];
|
|
317
401
|
const total = urlConfigs.length;
|
|
402
|
+
let wasCancelled = false;
|
|
318
403
|
|
|
319
404
|
for (let i = 0; i < total; i += validated.maxConcurrency) {
|
|
405
|
+
// Check job.status between slices so JobManager.cancelJob (which
|
|
406
|
+
// only flips status — it can't interrupt an in-flight await) is
|
|
407
|
+
// actually honored instead of the loop running to completion anyway.
|
|
408
|
+
const currentJob = this.jobManager.getJob(job.id);
|
|
409
|
+
if (currentJob?.status === 'cancelled') {
|
|
410
|
+
wasCancelled = true;
|
|
411
|
+
this._log('info', `Batch job ${job.id} cancelled; stopping after ${i}/${total} URLs`);
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
320
414
|
const batch = urlConfigs.slice(i, i + validated.maxConcurrency);
|
|
321
415
|
results.push(...await scrapeUrlsBatch(batch, validated, this.defaultTimeout));
|
|
322
416
|
const progress = Math.round(((i + batch.length) / total) * 100);
|
|
@@ -327,7 +421,7 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
327
421
|
const executionTime = Date.now() - startTime;
|
|
328
422
|
|
|
329
423
|
const batchResult = {
|
|
330
|
-
batchId, mode: 'async', success: true, executionTime,
|
|
424
|
+
batchId, mode: 'async', success: true, cancelled: wasCancelled || undefined, executionTime,
|
|
331
425
|
totalUrls: urlConfigs.length,
|
|
332
426
|
successfulUrls: processedResults.filter(r => r.success).length,
|
|
333
427
|
failedUrls: processedResults.filter(r => !r.success).length,
|
|
@@ -336,16 +430,23 @@ export class BatchScrapeTool extends EventEmitter {
|
|
|
336
430
|
};
|
|
337
431
|
|
|
338
432
|
if (this.enableResultCaching) {
|
|
339
|
-
this.
|
|
433
|
+
this._cacheBatchResult(batchId, processedResults);
|
|
340
434
|
}
|
|
341
435
|
|
|
342
|
-
this.stats.
|
|
343
|
-
this.stats.totalUrls += urlConfigs.length;
|
|
436
|
+
this.stats.totalUrls += results.length;
|
|
344
437
|
this.stats.successfulUrls += batchResult.successfulUrls;
|
|
345
438
|
this.stats.failedUrls += batchResult.failedUrls;
|
|
346
|
-
this._updateAverageBatchTime(executionTime);
|
|
347
439
|
this.stats.lastUpdated = Date.now();
|
|
348
440
|
|
|
441
|
+
if (wasCancelled) {
|
|
442
|
+
// Job status is already 'cancelled' (set by cancelJob); returning
|
|
443
|
+
// normally here would otherwise let JobManager.executeJob overwrite
|
|
444
|
+
// it back to 'completed'. See the JobManager.js guard below.
|
|
445
|
+
return batchResult;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
this.stats.completedBatches++;
|
|
449
|
+
this._updateAverageBatchTime(executionTime);
|
|
349
450
|
await sendWebhookNotification('batch_completed', batchResult, webhookConfig, this.webhookDispatcher, this.enableWebhookNotifications);
|
|
350
451
|
this.emit('batchCompleted', batchResult);
|
|
351
452
|
return batchResult;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { load } from 'cheerio';
|
|
8
|
+
import { config as appConfig } from '../../../constants/config.js';
|
|
8
9
|
import { ssrfGuard, isSsrfError } from '../../../utils/ssrfGuard.js';
|
|
9
10
|
import { throttleHost } from '../../../utils/hostRateLimiter.js';
|
|
10
11
|
|
|
@@ -12,9 +13,14 @@ const USER_AGENT = 'MCP-WebScraper-BatchTool/1.0.0';
|
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Fetch a URL with AbortController timeout (SSRF-guarded + per-host throttled).
|
|
16
|
+
* The timeout stays live through the body read (not just until headers
|
|
17
|
+
* arrive), and the body is size-capped, so a server that sends headers then
|
|
18
|
+
* drips the body can't hold a semaphore slot indefinitely or exhaust memory.
|
|
19
|
+
* Returns { response, html } rather than the raw Response.
|
|
15
20
|
*/
|
|
16
21
|
export async function fetchUrl(url, options = {}) {
|
|
17
22
|
const { timeout = 15000, headers = {} } = options;
|
|
23
|
+
const maxBodySize = appConfig.fetch.maxBodySize;
|
|
18
24
|
const guard = ssrfGuard(url); // SSRF pre-flight (throws before connecting)
|
|
19
25
|
await throttleHost(url);
|
|
20
26
|
const controller = new AbortController();
|
|
@@ -25,14 +31,59 @@ export async function fetchUrl(url, options = {}) {
|
|
|
25
31
|
headers: { 'User-Agent': USER_AGENT, ...headers },
|
|
26
32
|
...guard
|
|
27
33
|
});
|
|
28
|
-
|
|
29
|
-
return response;
|
|
34
|
+
const html = await readBodyCapped(response, maxBodySize);
|
|
35
|
+
return { response, html };
|
|
30
36
|
} catch (error) {
|
|
31
|
-
clearTimeout(timeoutId);
|
|
32
37
|
if (isSsrfError(error)) throw new Error(error.cause?.message || error.message);
|
|
33
38
|
if (error.name === 'AbortError') throw new Error(`Request timeout after ${timeout}ms`);
|
|
34
39
|
throw error;
|
|
40
|
+
} finally {
|
|
41
|
+
// Cleared only after the body read finishes (or the fetch itself fails),
|
|
42
|
+
// so the abort timer keeps protecting against a trickling body, not just
|
|
43
|
+
// the initial headers.
|
|
44
|
+
clearTimeout(timeoutId);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read a Response body as text, enforcing maxBodySize (Content-Length
|
|
50
|
+
* pre-check, then a running byte count while streaming). Falls back to plain
|
|
51
|
+
* .text() for responses without a streamable body (e.g. test mocks).
|
|
52
|
+
*/
|
|
53
|
+
async function readBodyCapped(response, maxBodySize) {
|
|
54
|
+
const contentLengthHeader = response.headers?.get?.('content-length') ?? null;
|
|
55
|
+
if (contentLengthHeader !== null) {
|
|
56
|
+
const declared = parseInt(contentLengthHeader, 10);
|
|
57
|
+
if (!isNaN(declared) && declared > maxBodySize) {
|
|
58
|
+
throw new Error(`Response body too large: Content-Length ${declared} exceeds limit of ${maxBodySize} bytes`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!response.body || typeof response.body.getReader !== 'function') {
|
|
63
|
+
return response.text();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const reader = response.body.getReader();
|
|
67
|
+
const chunks = [];
|
|
68
|
+
let totalBytes = 0;
|
|
69
|
+
while (true) {
|
|
70
|
+
const { done, value } = await reader.read();
|
|
71
|
+
if (done) break;
|
|
72
|
+
totalBytes += value.byteLength;
|
|
73
|
+
if (totalBytes > maxBodySize) {
|
|
74
|
+
reader.cancel();
|
|
75
|
+
throw new Error(`Response body too large: exceeded limit of ${maxBodySize} bytes`);
|
|
76
|
+
}
|
|
77
|
+
chunks.push(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const merged = new Uint8Array(totalBytes);
|
|
81
|
+
let offset = 0;
|
|
82
|
+
for (const chunk of chunks) {
|
|
83
|
+
merged.set(chunk, offset);
|
|
84
|
+
offset += chunk.byteLength;
|
|
35
85
|
}
|
|
86
|
+
return new TextDecoder().decode(merged);
|
|
36
87
|
}
|
|
37
88
|
|
|
38
89
|
/**
|
|
@@ -41,14 +92,13 @@ export async function fetchUrl(url, options = {}) {
|
|
|
41
92
|
export async function scrapeUrl(config, options, defaultTimeout) {
|
|
42
93
|
const startTime = Date.now();
|
|
43
94
|
try {
|
|
44
|
-
const response = await fetchUrl(config.url, {
|
|
95
|
+
const { response, html } = await fetchUrl(config.url, {
|
|
45
96
|
headers: config.headers,
|
|
46
97
|
timeout: config.timeout || defaultTimeout
|
|
47
98
|
});
|
|
48
99
|
|
|
49
100
|
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
50
101
|
|
|
51
|
-
const html = await response.text();
|
|
52
102
|
const $ = load(html);
|
|
53
103
|
|
|
54
104
|
const result = {
|
|
@@ -171,6 +171,9 @@ export function buildRecordedEntry(action, timestampMsSinceStart) {
|
|
|
171
171
|
if (action.direction !== undefined) entry.direction = action.direction;
|
|
172
172
|
if (action.distance !== undefined) entry.distance = action.distance;
|
|
173
173
|
if (action.description !== undefined) entry.description = action.description;
|
|
174
|
+
// executeJavaScript actions require `script` to replay (ActionExecutor's
|
|
175
|
+
// ActionChainSchema rejects the entry otherwise) — preserve it.
|
|
176
|
+
if (action.script !== undefined) entry.script = action.script;
|
|
174
177
|
|
|
175
178
|
return entry;
|
|
176
179
|
}
|