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
@@ -7,6 +7,7 @@ import { z } from 'zod';
7
7
  import BrowserProcessor from './processing/BrowserProcessor.js';
8
8
  import { EventEmitter } from 'events';
9
9
  import { createHash } from 'node:crypto';
10
+ import { assertUrlAllowed } from '../utils/ssrfGuard.js';
10
11
 
11
12
  // executeJavaScript hardening limits (only relevant when the deploy-time flag
12
13
  // ALLOW_JAVASCRIPT_EXECUTION=true is set; JS execution stays off by default).
@@ -19,7 +20,11 @@ const BaseActionSchema = z.object({
19
20
  timeout: z.number().optional(),
20
21
  description: z.string().optional(),
21
22
  continueOnError: z.boolean().default(false),
22
- retries: z.number().min(0).max(5).default(0)
23
+ retries: z.number().min(0).max(5).default(0),
24
+ // When true, capture page state (page.content()/page.url()) natively right
25
+ // after this action executes. Does not use in-page JS execution, so it
26
+ // works regardless of the ALLOW_JAVASCRIPT_EXECUTION flag.
27
+ captureAfter: z.boolean().default(false)
23
28
  });
24
29
 
25
30
  const WaitActionSchema = BaseActionSchema.extend({
@@ -162,7 +167,10 @@ export class ActionExecutor extends EventEmitter {
162
167
  async executeActionChain(url, chainConfig, browserOptions = {}) {
163
168
  const startTime = Date.now();
164
169
  const chainId = this.generateChainId();
165
-
170
+ // Declared here (not inside the try block below) so the outer catch can
171
+ // still report partial results/screenshots/capturedStates on failure.
172
+ let executionContext = null;
173
+
166
174
  try {
167
175
  // Handle simplified signature: executeActionChain(url, actionsArray)
168
176
  let actualChainConfig;
@@ -186,7 +194,7 @@ export class ActionExecutor extends EventEmitter {
186
194
  this.stats.totalChains++;
187
195
 
188
196
  // Create execution context
189
- const executionContext = {
197
+ executionContext = {
190
198
  id: chainId,
191
199
  url,
192
200
  chain: validatedChain,
@@ -253,11 +261,20 @@ export class ActionExecutor extends EventEmitter {
253
261
 
254
262
  throw error;
255
263
  } finally {
256
- // D2.4: always close page to prevent leaks
264
+ // D2.4: always close page to prevent leaks. Also close the owning
265
+ // context for non-stealth pages — createPage() gives each call its
266
+ // own dedicated BrowserContext that is never tracked/closed
267
+ // elsewhere, so leaving it open here leaks it until server shutdown.
268
+ // Stealth contexts are pooled/reused (see StealthBrowserManager) and
269
+ // must not be closed here.
257
270
  if (page) {
271
+ const ctx = !browserOptions.stealthMode?.enabled ? page.context() : null;
258
272
  try { await page.close(); } catch (_) { /* ignore close errors */ }
273
+ if (ctx) {
274
+ try { await ctx.close(); } catch (_) { /* ignore close errors */ }
275
+ }
259
276
  }
260
-
277
+
261
278
  // Update execution time
262
279
  const executionTime = Date.now() - startTime;
263
280
  executionContext.executionTime = executionTime;
@@ -266,10 +283,25 @@ export class ActionExecutor extends EventEmitter {
266
283
  // Remove from active chains
267
284
  this.activeChains.delete(chainId);
268
285
 
269
- // Add to execution history
286
+ // Add to execution history. Strip finalHtml (full post-action page
287
+ // HTML, often 100KB-2MB), screenshots (base64 PNGs), capturedStates
288
+ // (full intermediate-page HTML), and each screenshot action's base64
289
+ // payload inside results — getExecutionHistory() only reads scalar
290
+ // fields and results[].success, so retaining the heavy payloads just
291
+ // pins them in memory for the life of the 100-entry history.
270
292
  this.executionHistory.push({
271
293
  ...executionContext,
272
- page: undefined // Don't store page in history
294
+ page: undefined, // Don't store page in history
295
+ finalHtml: undefined,
296
+ screenshots: undefined,
297
+ screenshotCount: executionContext.screenshots.length,
298
+ capturedStates: undefined,
299
+ capturedStateCount: (executionContext.capturedStates || []).length,
300
+ results: executionContext.results.map(r => (
301
+ r?.result?.data !== undefined
302
+ ? { ...r, result: { ...r.result, data: undefined, dataBytes: typeof r.result.data === 'string' ? r.result.data.length : undefined } }
303
+ : r
304
+ ))
273
305
  });
274
306
 
275
307
  // Keep only last 100 executions in history
@@ -289,6 +321,7 @@ export class ActionExecutor extends EventEmitter {
289
321
  executionTime: Date.now() - startTime,
290
322
  results: executionContext.results,
291
323
  screenshots: executionContext.screenshots,
324
+ capturedStates: executionContext.capturedStates || [],
292
325
  metadata: executionContext.metadata,
293
326
  stats: {
294
327
  totalActions: executionContext.results.length,
@@ -305,8 +338,12 @@ export class ActionExecutor extends EventEmitter {
305
338
  url,
306
339
  executionTime: Date.now() - startTime,
307
340
  error: error.message,
308
- results: [],
309
- screenshots: []
341
+ // Preserve whatever was captured before the failure (per-action
342
+ // results, the error screenshot, and any intermediate-state
343
+ // captures) instead of discarding them.
344
+ results: executionContext?.results || [],
345
+ screenshots: executionContext?.screenshots || [],
346
+ capturedStates: executionContext?.capturedStates || []
310
347
  };
311
348
  }
312
349
  }
@@ -325,6 +362,7 @@ export class ActionExecutor extends EventEmitter {
325
362
  if (attempt > 0) {
326
363
  this.log('info', 'Retrying chain execution, attempt ' + (attempt + 1));
327
364
  executionContext.results = []; // Clear previous results on retry
365
+ executionContext.capturedStates = []; // Clear previous captures on retry
328
366
  }
329
367
 
330
368
  // Execute actions in sequence
@@ -358,6 +396,26 @@ export class ActionExecutor extends EventEmitter {
358
396
  }
359
397
  }
360
398
 
399
+ // Native intermediate-state capture: page.content()/page.url()
400
+ // directly (no in-page JS execution), so it works regardless of
401
+ // the ALLOW_JAVASCRIPT_EXECUTION flag and doesn't add phantom
402
+ // actions to the chain's failure/success counts.
403
+ if (action.captureAfter) {
404
+ try {
405
+ const capturedHtml = await page.content();
406
+ executionContext.capturedStates = executionContext.capturedStates || [];
407
+ executionContext.capturedStates.push({
408
+ afterActionIndex: i,
409
+ afterActionId: actionResult.id,
410
+ url: page.url(),
411
+ html: capturedHtml,
412
+ timestamp: Date.now()
413
+ });
414
+ } catch (captureErr) {
415
+ this.log('warn', 'Failed to capture intermediate state: ' + captureErr.message);
416
+ }
417
+ }
418
+
361
419
  // Add delay between actions
362
420
  if (i < chain.actions.length - 1 && this.actionDelay > 0) {
363
421
  await this.delay(this.actionDelay);
@@ -814,33 +872,59 @@ export class ActionExecutor extends EventEmitter {
814
872
  * @returns {Promise<Page>} Playwright page
815
873
  */
816
874
  async initializePage(url, browserOptions) {
875
+ // SSRF guard: validate before any page/context creation or navigation.
876
+ // resolveDns:true because Playwright does its own DNS resolution, so
877
+ // hostname-based checks alone would miss DNS-rebinding/private-IP targets.
878
+ await assertUrlAllowed(url, { resolveDns: true });
879
+
880
+ const isStealth = !!browserOptions.stealthMode?.enabled;
881
+
817
882
  // Use the enhanced BrowserProcessor initialization that supports stealth mode
818
883
  const page = await this.browserProcessor.initializePage(browserOptions);
819
-
820
- // Apply CloudFlare and reCAPTCHA detection if stealth mode is enabled
821
- if (browserOptions.stealthMode?.enabled && this.browserProcessor.stealthManager) {
822
- // Initialize human behavior simulator for the page
823
- await this.browserProcessor.stealthManager.initializeHumanBehaviorSimulator();
824
- }
825
-
826
- // Navigate to URL
827
- await page.goto(url, {
828
- waitUntil: 'domcontentloaded',
829
- timeout: 30000
830
- });
831
-
832
- // Handle CloudFlare challenges and reCAPTCHA if stealth mode is enabled
833
- if (browserOptions.stealthMode?.enabled && this.browserProcessor.stealthManager) {
834
- await this.browserProcessor.stealthManager.bypassCloudflareChallenge(page);
835
- await this.browserProcessor.stealthManager.handleRecaptcha(page);
836
-
837
- // Simulate initial human behavior on page load
838
- if (browserOptions.humanBehavior?.enabled) {
839
- await this.simulateInitialPageInteraction(page);
884
+
885
+ try {
886
+ // Apply CloudFlare and reCAPTCHA detection if stealth mode is enabled
887
+ if (isStealth && this.browserProcessor.stealthManager) {
888
+ // Initialize human behavior simulator for the page
889
+ await this.browserProcessor.stealthManager.initializeHumanBehaviorSimulator();
840
890
  }
841
- }
842
891
 
843
- return page;
892
+ // Navigate to URL
893
+ await page.goto(url, {
894
+ waitUntil: 'domcontentloaded',
895
+ timeout: 30000
896
+ });
897
+
898
+ // Re-validate the landed URL: a redirect during navigation could have
899
+ // taken us into a blocked range even though the original URL was safe.
900
+ const landedUrl = page.url();
901
+ if (/^https?:\/\//i.test(landedUrl)) {
902
+ await assertUrlAllowed(landedUrl, { resolveDns: true });
903
+ }
904
+
905
+ // Handle CloudFlare challenges and reCAPTCHA if stealth mode is enabled
906
+ if (isStealth && this.browserProcessor.stealthManager) {
907
+ await this.browserProcessor.stealthManager.bypassCloudflareChallenge(page);
908
+ await this.browserProcessor.stealthManager.handleRecaptcha(page);
909
+
910
+ // Simulate initial human behavior on page load
911
+ if (browserOptions.humanBehavior?.enabled) {
912
+ await this.simulateInitialPageInteraction(page);
913
+ }
914
+ }
915
+
916
+ return page;
917
+ } catch (error) {
918
+ // Any failure between page creation and return (navigation, SSRF
919
+ // re-check, stealth challenge handling) must not leak the page it
920
+ // already created — close it, and its dedicated context for
921
+ // non-stealth pages (stealth contexts are pooled/reused elsewhere),
922
+ // before rethrowing.
923
+ const ctx = !isStealth ? page.context() : null;
924
+ await page.close().catch(() => {});
925
+ if (ctx) await ctx.close().catch(() => {});
926
+ throw error;
927
+ }
844
928
  }
845
929
 
846
930
  /**
@@ -1142,7 +1226,7 @@ export class ActionExecutor extends EventEmitter {
1142
1226
  // Cancel active chains
1143
1227
  for (const context of this.activeChains.values()) {
1144
1228
  if (context.page) {
1145
- await context.page.close();
1229
+ try { await context.page.close(); } catch (_) { /* ignore close errors */ }
1146
1230
  }
1147
1231
  }
1148
1232
 
@@ -209,18 +209,24 @@ export class AgentOrchestrator {
209
209
  }
210
210
 
211
211
  // ── ACT loop ──────────────────────────────────────────────────────────────
212
+ // urlsFetched (capUrls) and step (capSteps) are deliberately decoupled:
213
+ // urlsFetched counts every fetch attempt (gates how many URLs we try),
214
+ // while step counts only attempts that yielded usable evidence (gates
215
+ // genuine progress). Coupling them 1:1 made capSteps the always-binding
216
+ // cap whenever it was smaller than capUrls, leaving maxUrls unreachable
217
+ // at its default. Both caps remain fully enforced (neither is weakened).
212
218
  const evidence = [];
213
219
  let urlsFetched = 0;
214
220
  let step = 0;
215
221
 
216
222
  for (const url of urlQueue) {
217
- if (step >= capSteps || urlsFetched >= capUrls || deadline()) break;
218
- step++;
223
+ if (urlsFetched >= capUrls || step >= capSteps || deadline()) break;
219
224
  urlsFetched++;
220
225
 
221
226
  try {
222
227
  const { textContent, finalUrl } = await fetchAndParse(url, { timeoutMs: 10000 });
223
228
  if (!isRelevant(textContent, prompt)) continue;
229
+ step++;
224
230
  evidence.push({
225
231
  url: finalUrl,
226
232
  text: truncate(textContent),
@@ -10,6 +10,7 @@ import { randomUUID } from 'crypto';
10
10
  import { isCreatorModeVerified } from './creatorMode.js';
11
11
  import { resolveApiEndpoint } from './endpointGuard.js';
12
12
  import { logger } from '../utils/Logger.js';
13
+ import { maskSecrets } from '../utils/secretMask.js';
13
14
  // D1.4: Elicitation for low-credit warnings (lazy import to avoid circular dep)
14
15
  let _ElicitationHelper = null;
15
16
  function getElicitationHelper() {
@@ -166,30 +167,30 @@ class AuthManager {
166
167
  * Setup wizard for first-time users
167
168
  */
168
169
  async runSetup(apiKey) {
169
- console.log('🔧 Setting up CrawlForge MCP Server...\n');
170
-
170
+ console.error('🔧 Setting up CrawlForge MCP Server...\n');
171
+
171
172
  if (!apiKey) {
172
- console.log('❌ API key is required for setup');
173
- console.log('Get your API key from: https://www.crawlforge.dev/dashboard/api-keys');
173
+ console.error('❌ API key is required for setup');
174
+ console.error('Get your API key from: https://www.crawlforge.dev/dashboard/api-keys');
174
175
  return false;
175
176
  }
176
177
 
177
178
  // Validate API key with backend
178
179
  const validation = await this.validateApiKey(apiKey);
179
-
180
+
180
181
  if (!validation.valid) {
181
- console.log(`❌ Invalid API key: ${validation.error}`);
182
+ console.error(`❌ Invalid API key: ${validation.error}`);
182
183
  return false;
183
184
  }
184
185
 
185
186
  // Save configuration
186
187
  await this.saveConfig(apiKey, validation.userId, validation.email);
187
-
188
- console.log('✅ Setup complete!');
189
- console.log(`📧 Account: ${validation.email}`);
190
- console.log(`💳 Credits remaining: ${validation.creditsRemaining}`);
191
- console.log(`📦 Plan: ${validation.planId}`);
192
-
188
+
189
+ console.error('✅ Setup complete!');
190
+ console.error(`📧 Account: ${validation.email}`);
191
+ console.error(`💳 Credits remaining: ${validation.creditsRemaining}`);
192
+ console.error(`📦 Plan: ${validation.planId}`);
193
+
193
194
  return true;
194
195
  }
195
196
 
@@ -286,7 +287,31 @@ class AuthManager {
286
287
 
287
288
  return data.creditsRemaining >= estimatedCredits;
288
289
  }
290
+
291
+ // Non-OK response: distinguish an invalid/revoked key from a transient
292
+ // backend problem so callers don't tell a locked-out user to buy credits.
293
+ if (response.status === 401 || response.status === 403) {
294
+ let message = 'API key is invalid or has been revoked.';
295
+ try {
296
+ const errBody = await response.json();
297
+ if (errBody?.message) message = errBody.message;
298
+ } catch {
299
+ // body not JSON / unreadable — keep default message
300
+ }
301
+ const invalidKeyError = new Error(
302
+ `CrawlForge API key rejected (${response.status}): ${message} Run \`npm run setup\` with a current key.`
303
+ );
304
+ invalidKeyError.code = 'CRAWLFORGE_KEY_INVALID';
305
+ throw invalidKeyError;
306
+ }
307
+
308
+ // Other non-OK statuses (5xx etc.) — treat like a network failure below.
309
+ throw new Error(`CrawlForge backend returned ${response.status} while checking credits.`);
289
310
  } catch (error) {
311
+ if (error?.code === 'CRAWLFORGE_KEY_INVALID') {
312
+ throw error;
313
+ }
314
+
290
315
  console.error('Failed to check credits:', error.message);
291
316
 
292
317
  const lastOk = this.lastSuccessfulCreditCheck.get(this.config.userId) ?? 0;
@@ -335,7 +360,9 @@ class AuthManager {
335
360
  const payload = {
336
361
  tool,
337
362
  creditsUsed,
338
- requestData,
363
+ // Never send raw tool params to the backend — they can carry third-party
364
+ // API keys, auth headers, or webhook secrets. Mask before it leaves the process.
365
+ requestData: maskSecrets(requestData),
339
366
  responseStatus,
340
367
  processingTime,
341
368
  timestamp: new Date().toISOString(),
@@ -345,7 +372,7 @@ class AuthManager {
345
372
  };
346
373
 
347
374
  try {
348
- await fetch(`${this.apiEndpoint}/api/v1/usage`, {
375
+ const response = await fetch(`${this.apiEndpoint}/api/v1/usage`, {
349
376
  method: 'POST',
350
377
  headers: {
351
378
  'Content-Type': 'application/json',
@@ -356,6 +383,10 @@ class AuthManager {
356
383
  signal: AbortSignal.timeout(5000)
357
384
  });
358
385
 
386
+ if (!response.ok) {
387
+ throw new Error(`Usage report rejected by backend: HTTP ${response.status}`);
388
+ }
389
+
359
390
  await this._flushPendingUsage();
360
391
  } catch (error) {
361
392
  // Log but don't throw - usage reporting should not break tool execution
@@ -449,7 +480,7 @@ class AuthManager {
449
480
  for (const entry of entries) {
450
481
  try {
451
482
  const idempotencyKey = entry.idempotencyKey || randomUUID();
452
- await fetch(`${this.apiEndpoint}/api/v1/usage`, {
483
+ const response = await fetch(`${this.apiEndpoint}/api/v1/usage`, {
453
484
  method: 'POST',
454
485
  headers: {
455
486
  'Content-Type': 'application/json',
@@ -466,6 +497,9 @@ class AuthManager {
466
497
  }),
467
498
  signal: AbortSignal.timeout(5000)
468
499
  });
500
+ if (!response.ok) {
501
+ throw new Error(`Usage report rejected by backend: HTTP ${response.status}`);
502
+ }
469
503
  flushedIds.push(entry.requestId);
470
504
  } catch (err) {
471
505
  failedIds.push(entry.requestId);
@@ -599,7 +633,7 @@ class AuthManager {
599
633
  break;
600
634
  }
601
635
  case 'crawl_deep': {
602
- const maxPages = params?.maxPages || params?.options?.maxPages || 10;
636
+ const maxPages = params?.max_pages || params?.maxPages || params?.options?.maxPages || 10;
603
637
  projected = Math.max(base, Math.ceil(maxPages / 20) * base);
604
638
  note = `Lower-bound estimate. crawl_deep cost grows with page count (${maxPages} max).`;
605
639
  break;
@@ -657,7 +691,7 @@ class AuthManager {
657
691
  await fs.unlink(this.configPath);
658
692
  this.config = null;
659
693
  this.creditCache.clear();
660
- console.log('Configuration cleared.');
694
+ console.error('Configuration cleared.');
661
695
  } catch (error) {
662
696
  console.error('Failed to clear configuration:', error.message);
663
697
  }
@@ -11,6 +11,7 @@ import { z } from 'zod';
11
11
  import { EventEmitter } from 'events';
12
12
  import { load } from 'cheerio';
13
13
  import { diffWords, diffLines, diffChars } from 'diff';
14
+ import { calculateSimilarity as calculateContentSimilarity } from '../tools/tracking/trackChanges/differ.js';
14
15
 
15
16
  const ChangeTrackingSchema = z.object({
16
17
  url: z.string().url(),
@@ -169,9 +170,13 @@ export class ChangeTracker extends EventEmitter {
169
170
  * @param {string} url - URL to compare
170
171
  * @param {string} currentContent - Current content
171
172
  * @param {Object} options - Comparison options
173
+ * @param {Object} storageOptions - History retention overrides for this
174
+ * call: retainHistory (boolean) and maxHistoryEntries (number), as
175
+ * forwarded from TrackChangesSchema.storageOptions. Both optional —
176
+ * falls back to this.options.maxHistoryLength when omitted.
172
177
  * @returns {Object} - Change analysis results
173
178
  */
174
- async compareWithBaseline(url, currentContent, options = {}) {
179
+ async compareWithBaseline(url, currentContent, options = {}, storageOptions = {}) {
175
180
  const startTime = Date.now();
176
181
 
177
182
  // Expected no-baseline case: return a clean error WITHOUT emitting an
@@ -226,10 +231,19 @@ export class ChangeTracker extends EventEmitter {
226
231
 
227
232
  changeRecord.processingTime = Date.now() - startTime;
228
233
 
229
- // Store change record
230
- const changeHistory = this.changeHistory.get(url);
231
- changeHistory.push(changeRecord);
232
-
234
+ // Store change record, trimmed to maxHistoryEntries/maxHistoryLength so
235
+ // a long-running monitor doesn't accumulate a full diff record
236
+ // (word/line-level diff arrays included) per check for the life of the
237
+ // process. retainHistory:false skips storage entirely.
238
+ if (storageOptions.retainHistory !== false) {
239
+ const changeHistory = this.changeHistory.get(url);
240
+ changeHistory.push(changeRecord);
241
+ const maxHistoryLength = storageOptions.maxHistoryEntries ?? this.options.maxHistoryLength;
242
+ if (maxHistoryLength && changeHistory.length > maxHistoryLength) {
243
+ changeHistory.splice(0, changeHistory.length - maxHistoryLength);
244
+ }
245
+ }
246
+
233
247
  // Update statistics
234
248
  this.updateStats(changeRecord);
235
249
 
@@ -345,11 +359,13 @@ export class ChangeTracker extends EventEmitter {
345
359
  linkChanges: []
346
360
  };
347
361
 
348
- // Calculate overall content similarity
349
- changes.similarity = this.calculateSimilarity(
350
- baseline.hashes.page,
351
- current.hashes.page
352
- );
362
+ // Calculate overall content similarity. Hash equality is used only as the
363
+ // fast identical/changed test — the hashes themselves are not comparable
364
+ // (a single-character edit changes ~every hex digit), so an actual
365
+ // similarity score is computed against the original content.
366
+ changes.similarity = baseline.hashes.page === current.hashes.page
367
+ ? 1
368
+ : calculateContentSimilarity(baseline.originalContent, current.originalContent);
353
369
 
354
370
  // Detect structural changes
355
371
  if (options.trackStructure) {
@@ -317,8 +317,16 @@ export class JobManager extends EventEmitter {
317
317
 
318
318
  try {
319
319
  const result = await executor(job);
320
+
321
+ // A cancelJob() call can flip status to 'cancelled' while the executor
322
+ // await above was still in flight (cancelJob can't interrupt it
323
+ // directly). Don't clobber that terminal state back to 'completed'.
324
+ if (job.status === this.JOB_STATES.CANCELLED) {
325
+ return result;
326
+ }
327
+
320
328
  await this.updateJobStatus(jobId, this.JOB_STATES.COMPLETED, { result });
321
-
329
+
322
330
  // Calculate execution time
323
331
  const executionTime = job.completedAt - job.startedAt;
324
332
  this.updateExecutionTime(executionTime);
@@ -984,19 +984,31 @@ export class LocalizationManager extends EventEmitter {
984
984
  * Setup periodic health checks for proxies and services
985
985
  */
986
986
  setupHealthChecks() {
987
- // Proxy health checks every 5 minutes
988
- setInterval(async () => {
987
+ // Proxy health checks every 5 minutes. Handles are recorded so cleanup()
988
+ // can actually clear them — previously they were never stored, so the
989
+ // `clearInterval` in cleanup() had nothing to clear and both intervals
990
+ // kept firing (and kept the process alive) after cleanup().
991
+ const proxyInterval = setInterval(async () => {
989
992
  if (this.proxyManager.activeProxies.size > 0) {
990
993
  await this.performProxyHealthChecks();
991
994
  }
992
995
  }, 300000);
993
-
996
+
994
997
  // Translation service health checks every 10 minutes
995
- setInterval(async () => {
998
+ const translationInterval = setInterval(async () => {
996
999
  if (this.translationProviders.size > 0) {
997
1000
  await this.checkTranslationServiceHealth();
998
1001
  }
999
1002
  }, 600000);
1003
+
1004
+ this.healthCheckIntervals = [proxyInterval, translationInterval];
1005
+
1006
+ // Background health checks must never be the only thing keeping the
1007
+ // process alive — unref() so an otherwise-idle process (tests, CLI
1008
+ // one-shots that construct a tool without calling cleanup()) can exit.
1009
+ for (const interval of this.healthCheckIntervals) {
1010
+ if (typeof interval.unref === 'function') interval.unref();
1011
+ }
1000
1012
  }
1001
1013
 
1002
1014
  /**
@@ -1497,8 +1509,9 @@ export class LocalizationManager extends EventEmitter {
1497
1509
  this.resetStats();
1498
1510
 
1499
1511
  // Clear all health check intervals
1500
- if (this.healthCheckInterval) {
1501
- clearInterval(this.healthCheckInterval);
1512
+ if (this.healthCheckIntervals) {
1513
+ this.healthCheckIntervals.forEach(clearInterval);
1514
+ this.healthCheckIntervals = null;
1502
1515
  }
1503
1516
 
1504
1517
  // Reset proxy manager