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
@@ -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(actionResults, params) : [];
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.metadata?.finalUrl,
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
- const modifiedActions = [];
527
-
528
- actions.forEach((action, index) => {
529
- modifiedActions.push(action);
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
- modifiedActions.push({
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(actionResults, params) {
560
+ async extractIntermediateStates(capturedStates, params) {
567
561
  const states = [];
568
562
 
569
- for (const result of actionResults) {
570
- if (result.type === 'executeJavaScript' && result.jsResult && result.jsResult.html) {
571
- try {
572
- const stateData = result.jsResult;
573
- const $ = load(stateData.html);
574
-
575
- const state = {
576
- capturePoint: stateData.capturePoint,
577
- url: stateData.url,
578
- title: stateData.title,
579
- timestamp: stateData.timestamp,
580
- content: {}
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
- if (params.formats.includes('text')) {
584
- state.content.text = $('body').text().replace(/\s+/g, ' ').trim();
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
- this.activeBatches.set(batchId, { id: batchId, mode: 'sync', startTime, total: urlConfigs.length, completed: 0 });
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.batchResults.set(batchId, { results: processedResults, timestamp: Date.now(), ttl: 3600000 });
178
+ this._cacheBatchResult(batchId, processedResults);
155
179
  }
156
180
 
157
181
  this.stats.completedBatches++;
158
- this.stats.totalUrls += urlConfigs.length;
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
- const webhookStatus = await sendWebhookNotification('batch_completed', batchResult, webhookConfig, this.webhookDispatcher, this.enableWebhookNotifications);
166
- if (webhookStatus) batchResult.webhookDelivery = webhookStatus;
167
- this.emit('batchCompleted', batchResult);
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: `batch_scrape_status?jobId=${job.id}`,
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 && Date.now() - cached.timestamp < cached.ttl) {
210
- const offset = (page - 1) * pageSize;
211
- return {
212
- batchId, success: true,
213
- results: paginateResults(cached.results, offset, pageSize),
214
- pagination: { page, pageSize, totalResults: cached.results.length, totalPages: Math.ceil(cached.results.length / pageSize) },
215
- cached: true, timestamp: cached.timestamp
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
- if (this.activeBatches.has(batchId)) {
241
- this.activeBatches.delete(batchId);
242
- return { success: true, message: `Active batch ${batchId} cancelled` };
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.batchResults.set(batchId, { results: processedResults, timestamp: Date.now(), ttl: 3600000 });
433
+ this._cacheBatchResult(batchId, processedResults);
340
434
  }
341
435
 
342
- this.stats.completedBatches++;
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
- clearTimeout(timeoutId);
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
  }