crawlforge-mcp-server 5.1.0 → 5.2.1

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 (40) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +8 -4
  3. package/package.json +5 -4
  4. package/server.js +23 -15
  5. package/src/core/ActionExecutor.js +246 -66
  6. package/src/core/ChangeTracker.js +226 -54
  7. package/src/core/ResearchOrchestrator.js +9 -3
  8. package/src/core/SamplingClient.js +4 -5
  9. package/src/core/StealthBrowserManager.js +64 -18
  10. package/src/core/cache/CacheManager.js +7 -2
  11. package/src/core/crawlers/BFSCrawler.js +14 -6
  12. package/src/core/llm/LLMManager.js +61 -11
  13. package/src/core/llm/OllamaProvider.js +139 -0
  14. package/src/core/processing/BrowserProcessor.js +28 -2
  15. package/src/schemas/toolOutputSchemas.js +3 -1
  16. package/src/server/requestContext.js +26 -0
  17. package/src/server/transports/streamableHttp.js +54 -11
  18. package/src/server/withAuth.js +24 -6
  19. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +26 -3
  20. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +5 -4
  21. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -0
  22. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +6 -4
  23. package/src/skills/agent-skills/crawlforge-structured-extraction/references/templates.md +2 -1
  24. package/src/tools/advanced/ScrapeWithActionsTool.js +4 -1
  25. package/src/tools/basic/_fetch.js +12 -91
  26. package/src/tools/basic/fetchUrl.js +4 -1
  27. package/src/tools/crawl/crawlDeep.js +19 -5
  28. package/src/tools/extract/extractStructured.js +16 -4
  29. package/src/tools/extract/extractWithLlm.js +80 -10
  30. package/src/tools/extract/listOllamaModels.js +4 -6
  31. package/src/tools/scrape/_brandingExtractor.js +1 -1
  32. package/src/tools/scrape/unifiedScrape.js +71 -5
  33. package/src/tools/search/adapters/redditOfficialApi.js +196 -0
  34. package/src/tools/search/redditNormalize.js +95 -0
  35. package/src/tools/search/redditSearch.js +67 -91
  36. package/src/tools/templates/ScrapeTemplateTool.js +8 -3
  37. package/src/utils/hiddenContent.js +330 -0
  38. package/src/utils/htmlToMarkdown.js +12 -2
  39. package/src/utils/ollamaConfig.js +121 -0
  40. package/src/tools/templates/TemplateRegistry.js +0 -325
@@ -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 { structureSignature, structuralSimilarity } from 'crawlforge-extractors';
14
15
  import { calculateSimilarity as calculateContentSimilarity } from '../tools/tracking/trackChanges/differ.js';
15
16
 
16
17
  const ChangeTrackingSchema = z.object({
@@ -48,6 +49,60 @@ const ChangeComparisonSchema = z.object({
48
49
 
49
50
  const ChangeSignificance = z.enum(['none', 'minor', 'moderate', 'major', 'critical']);
50
51
 
52
+ // Bounds that keep a compare response usable. An unscoped Amazon product page
53
+ // produced a 5.6MB payload — 4MB of it a single line_diff holding the entire
54
+ // document twice — which overflows the MCP response limit on every comparison.
55
+ const MAX_DIFF_ENTRIES = 200;
56
+ const MAX_DIFF_VALUE_CHARS = 2000;
57
+
58
+ // Significance ordering, used to raise a level without ever lowering it.
59
+ const SIGNIFICANCE_ORDER = ['none', 'minor', 'moderate', 'major', 'critical'];
60
+
61
+ /**
62
+ * A monetary amount carries meaning that its size on the page does not.
63
+ * Significance is otherwise purely volumetric — how much of the document
64
+ * changed — so a price is scored by how many characters it occupies. Tracking
65
+ * a price block, a rise from $19.99 to $99.99 scored "minor", below the default
66
+ * "moderate" notification threshold; untracked, the same change did not
67
+ * register as a change at all.
68
+ *
69
+ * Only currency-tagged numbers count. Treating every number this way would fire
70
+ * on view counters, timestamps and review totals, which is the opposite failure.
71
+ */
72
+ const MONETARY_PATTERN =
73
+ /[$£€¥₹]\s?\d[\d,]*(?:\.\d{1,2})?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:USD|EUR|GBP|JPY|CAD|AUD|CHF|CNY|INR)\b/gi;
74
+
75
+ /** A price change at or above this fraction is major rather than moderate. */
76
+ const MAJOR_VALUE_CHANGE = 0.2;
77
+
78
+ /**
79
+ * Parse the numeric amount out of a matched monetary string.
80
+ * Commas are read as thousands separators; a European decimal comma is
81
+ * ambiguous here and is not guessed at, so such a value simply reads as
82
+ * changed rather than being scored by magnitude.
83
+ * @param {string} raw
84
+ * @returns {number|null}
85
+ */
86
+ function parseMonetaryAmount(raw) {
87
+ const amount = Number.parseFloat(raw.replace(/[^\d.,]/g, '').replace(/,/g, ''));
88
+ return Number.isFinite(amount) ? amount : null;
89
+ }
90
+
91
+ /**
92
+ * Monetary amounts in document order.
93
+ * @param {string} text
94
+ * @returns {Array<{raw: string, amount: number}>}
95
+ */
96
+ function extractMonetaryValues(text) {
97
+ if (!text) return [];
98
+ const values = [];
99
+ for (const match of String(text).matchAll(MONETARY_PATTERN)) {
100
+ const amount = parseMonetaryAmount(match[0]);
101
+ if (amount !== null) values.push({ raw: match[0].trim(), amount });
102
+ }
103
+ return values;
104
+ }
105
+
51
106
  export class ChangeTracker extends EventEmitter {
52
107
  constructor(options = {}) {
53
108
  super();
@@ -156,7 +211,8 @@ export class ChangeTracker extends EventEmitter {
156
211
  contentHash: contentAnalysis.hashes.page,
157
212
  sections: Object.keys(contentAnalysis.hashes.sections).length,
158
213
  elements: Object.keys(contentAnalysis.hashes.elements).length,
159
- createdAt: baseline.timestamp
214
+ createdAt: baseline.timestamp,
215
+ ...(contentAnalysis.warnings ? { warnings: contentAnalysis.warnings } : {})
160
216
  };
161
217
 
162
218
  } catch (error) {
@@ -294,13 +350,36 @@ export class ChangeTracker extends EventEmitter {
294
350
 
295
351
  try {
296
352
  // Parse HTML if available
297
- const $ = load(content);
298
-
353
+ let $ = load(content);
354
+
299
355
  // Remove excluded elements
300
356
  options.excludeSelectors?.forEach(selector => {
301
357
  $(selector).remove();
302
358
  });
303
-
359
+
360
+ // Narrow the working document to customSelectors so hashing, similarity
361
+ // and text diffs all operate on the same subtree. Previously these
362
+ // selectors only added extra section hashes while every comparison still
363
+ // ran over the whole page, so document-level churn (session tokens,
364
+ // CSP nonces, rotating ad ids) registered as changes no matter how
365
+ // tightly the caller scoped.
366
+ if (options.customSelectors?.length) {
367
+ const scoped = options.customSelectors
368
+ .flatMap(selector => $(selector).toArray().map(element => $.html(element)))
369
+ .join('\n');
370
+
371
+ if (scoped) {
372
+ $ = load(scoped);
373
+ analysis.originalContent = scoped;
374
+ } else {
375
+ // Falling back to the full document keeps a bad selector from
376
+ // silently tracking nothing, but the caller needs to know.
377
+ analysis.warnings = [
378
+ `customSelectors matched no elements (${options.customSelectors.join(', ')}); tracked the full document instead`
379
+ ];
380
+ }
381
+ }
382
+
304
383
  // Analyze at different granularities
305
384
  switch (options.granularity) {
306
385
  case 'element':
@@ -324,8 +403,8 @@ export class ChangeTracker extends EventEmitter {
324
403
  // Extract metadata
325
404
  analysis.metadata = this.extractMetadata($, options);
326
405
 
327
- // Calculate statistics
328
- analysis.statistics = this.calculateContentStatistics(content, $);
406
+ // Calculate statistics over the scoped content, matching what is hashed
407
+ analysis.statistics = this.calculateContentStatistics(analysis.originalContent, $);
329
408
 
330
409
  } catch (error) {
331
410
  // Fallback to plain text analysis
@@ -350,7 +429,10 @@ export class ChangeTracker extends EventEmitter {
350
429
  async detectChanges(baseline, current, options = {}) {
351
430
  const changes = {
352
431
  similarity: 0,
353
- structuralSimilarity: 0,
432
+ // null rather than 0: a structural score is only produced when
433
+ // trackStructure is on, and 0 is a real score meaning "the structure
434
+ // changed completely".
435
+ structuralSimilarity: null,
354
436
  addedElements: [],
355
437
  removedElements: [],
356
438
  modifiedElements: [],
@@ -413,6 +495,15 @@ export class ChangeTracker extends EventEmitter {
413
495
  );
414
496
  }
415
497
 
498
+ // Detect monetary value changes. Scored by magnitude rather than by how
499
+ // much of the page they occupy, so a price change is not diluted away.
500
+ if (options.trackText !== false) {
501
+ changes.valueChanges = this.detectValueChanges(
502
+ baseline.originalContent,
503
+ current.originalContent
504
+ );
505
+ }
506
+
416
507
  // Detect link changes
417
508
  if (options.trackLinks) {
418
509
  changes.linkChanges = this.detectLinkChanges(
@@ -432,6 +523,49 @@ export class ChangeTracker extends EventEmitter {
432
523
  return changes;
433
524
  }
434
525
 
526
+ /**
527
+ * Compare the monetary amounts in two versions of the tracked content.
528
+ *
529
+ * Amounts are paired in document order. When the two versions hold different
530
+ * counts the set of prices itself changed (an item sold out, a sale price
531
+ * appeared), which is reported as a change even though no single pair can be
532
+ * measured.
533
+ *
534
+ * @param {string} baselineText
535
+ * @param {string} currentText
536
+ * @returns {{changes: Array, countChanged: boolean, maxRelativeChange: number}|null}
537
+ * null when no monetary value changed
538
+ */
539
+ detectValueChanges(baselineText, currentText) {
540
+ const before = extractMonetaryValues(baselineText);
541
+ const after = extractMonetaryValues(currentText);
542
+ if (before.length === 0 && after.length === 0) return null;
543
+
544
+ const changes = [];
545
+ const pairs = Math.min(before.length, after.length);
546
+ for (let i = 0; i < pairs; i++) {
547
+ if (before[i].amount === after[i].amount) continue;
548
+ const base = Math.abs(before[i].amount);
549
+ const relativeChange = base > 0
550
+ ? Math.abs(after[i].amount - before[i].amount) / base
551
+ : 1;
552
+ changes.push({
553
+ before: before[i].raw,
554
+ after: after[i].raw,
555
+ relativeChange: Math.round(relativeChange * 1000) / 1000
556
+ });
557
+ }
558
+
559
+ const countChanged = before.length !== after.length;
560
+ if (changes.length === 0 && !countChanged) return null;
561
+
562
+ return {
563
+ changes: changes.slice(0, MAX_DIFF_ENTRIES),
564
+ countChanged,
565
+ maxRelativeChange: changes.reduce((max, c) => Math.max(max, c.relativeChange), 0)
566
+ };
567
+ }
568
+
435
569
  /**
436
570
  * Calculate change significance score
437
571
  * @param {Object} changeAnalysis - Change analysis results
@@ -482,17 +616,33 @@ export class ChangeTracker extends EventEmitter {
482
616
  }
483
617
 
484
618
  // Determine significance level
619
+ let level;
485
620
  if (significanceScore < thresholds.minor) {
486
- return 'none';
621
+ level = 'none';
487
622
  } else if (significanceScore < thresholds.moderate) {
488
- return 'minor';
623
+ level = 'minor';
489
624
  } else if (significanceScore < thresholds.major) {
490
- return 'moderate';
625
+ level = 'moderate';
491
626
  } else if (significanceScore < 0.9) {
492
- return 'major';
627
+ level = 'major';
493
628
  } else {
494
- return 'critical';
629
+ level = 'critical';
630
+ }
631
+
632
+ // The score above measures how much of the page changed. A price change is
633
+ // significant because of what it is, not how many characters it takes up,
634
+ // so a monetary change raises the level to at least "moderate" — the
635
+ // default notification threshold, which it previously fell below. This only
636
+ // ever raises the level; a large structural change stays major.
637
+ const valueChanges = changeAnalysis.valueChanges;
638
+ if (valueChanges) {
639
+ const floor = valueChanges.maxRelativeChange >= MAJOR_VALUE_CHANGE ? 'major' : 'moderate';
640
+ if (SIGNIFICANCE_ORDER.indexOf(floor) > SIGNIFICANCE_ORDER.indexOf(level)) {
641
+ level = floor;
642
+ }
495
643
  }
644
+
645
+ return level;
496
646
  }
497
647
 
498
648
  // Content Analysis Methods
@@ -571,7 +721,10 @@ export class ChangeTracker extends EventEmitter {
571
721
  extractStructure($, options) {
572
722
  const structure = {
573
723
  elements: [],
574
- hierarchy: {},
724
+ // Element count per nesting depth. This used to be an empty object that
725
+ // nothing ever wrote to, which made the hierarchy half of the structural
726
+ // score a constant.
727
+ hierarchy: structureSignature($).depths,
575
728
  semanticStructure: {}
576
729
  };
577
730
 
@@ -752,21 +905,65 @@ export class ChangeTracker extends EventEmitter {
752
905
  if (wordDiffChanges.length > 0) {
753
906
  textChanges.push({
754
907
  type: 'word_diff',
755
- changes: wordDiffChanges
908
+ changes: this.capDiffPayload(wordDiffChanges)
756
909
  });
757
910
  }
758
-
759
- // Line-level diff for structured content
760
- const lineDiff = diffLines(baselineContent, currentContent);
761
- if (lineDiff.some(part => part.added || part.removed)) {
762
- textChanges.push({
763
- type: 'line_diff',
764
- changes: lineDiff.filter(part => part.added || part.removed)
765
- });
911
+
912
+ // Line-level diff for structured content. With ignoreWhitespace (the
913
+ // default) the whole document collapses onto a single line, so diffLines
914
+ // degenerates into "remove everything, add everything" — a payload twice
915
+ // the page size describing what word_diff already pinpointed. Only run it
916
+ // when the content genuinely has line structure.
917
+ const hasLineStructure = baselineContent.includes('\n') || currentContent.includes('\n');
918
+ if (hasLineStructure) {
919
+ const lineDiff = diffLines(baselineContent, currentContent);
920
+ const lineDiffChanges = lineDiff.filter(part => part.added || part.removed);
921
+ if (lineDiffChanges.length > 0) {
922
+ textChanges.push({
923
+ type: 'line_diff',
924
+ changes: this.capDiffPayload(lineDiffChanges)
925
+ });
926
+ }
766
927
  }
767
-
928
+
768
929
  return textChanges;
769
930
  }
931
+
932
+ /**
933
+ * Bound a diff payload so a large page cannot produce a multi-megabyte
934
+ * response. Keeps the first maxEntries changes, truncates any oversized
935
+ * value, and appends a marker describing what was dropped so callers never
936
+ * mistake a truncated diff for a complete one.
937
+ * @param {Array} changes - Diff parts from diffWords/diffLines
938
+ * @param {Object} limits - Optional maxEntries / maxValueChars overrides
939
+ * @returns {Array} - Bounded diff parts
940
+ */
941
+ capDiffPayload(changes, limits = {}) {
942
+ const maxEntries = limits.maxEntries ?? MAX_DIFF_ENTRIES;
943
+ const maxValueChars = limits.maxValueChars ?? MAX_DIFF_VALUE_CHARS;
944
+
945
+ const capped = changes.slice(0, maxEntries).map(part => {
946
+ if (typeof part.value === 'string' && part.value.length > maxValueChars) {
947
+ return {
948
+ ...part,
949
+ value: part.value.slice(0, maxValueChars),
950
+ truncated: true,
951
+ omittedChars: part.value.length - maxValueChars
952
+ };
953
+ }
954
+ return part;
955
+ });
956
+
957
+ const omittedEntries = changes.length - capped.length;
958
+ if (omittedEntries > 0) {
959
+ capped.push({
960
+ omittedEntries,
961
+ note: `${omittedEntries} further changes omitted; scope the comparison with customSelectors to see them`
962
+ });
963
+ }
964
+
965
+ return capped;
966
+ }
770
967
 
771
968
  detectLinkChanges(baselineLinks, currentLinks) {
772
969
  const changes = {
@@ -901,38 +1098,13 @@ export class ChangeTracker extends EventEmitter {
901
1098
 
902
1099
  calculateStructuralSimilarity(baseline, current) {
903
1100
  if (!baseline || !current) return 0;
904
-
905
- const baselineElements = baseline.elements || [];
906
- const currentElements = current.elements || [];
907
-
908
- if (baselineElements.length === 0 && currentElements.length === 0) return 1;
909
- if (baselineElements.length === 0 || currentElements.length === 0) return 0;
910
-
911
- const tagSimilarity = this.calculateTagSimilarity(baselineElements, currentElements);
912
- const hierarchySimilarity = this.calculateHierarchySimilarity(baseline.hierarchy, current.hierarchy);
913
-
914
- // Clamp defensively — this is a 0-1 metric and must never leave that range.
915
- return Math.max(0, Math.min(1, (tagSimilarity + hierarchySimilarity) / 2));
916
- }
917
-
918
- calculateTagSimilarity(baselineElements, currentElements) {
919
- // Jaccard similarity: intersection and union must both operate on SETS.
920
- // The old code intersected the raw (duplicate-laden) tag list against a
921
- // set union, so repeated tags (div, p, ...) inflated the numerator and
922
- // produced impossible scores > 1 (e.g. the observed 1.05).
923
- const baselineTags = new Set(baselineElements.map(el => el.tag));
924
- const currentTags = new Set(currentElements.map(el => el.tag));
925
-
926
- const intersection = [...baselineTags].filter(tag => currentTags.has(tag));
927
- const union = new Set([...baselineTags, ...currentTags]);
928
1101
 
929
- return union.size === 0 ? 1 : intersection.length / union.size;
930
- }
931
-
932
- calculateHierarchySimilarity(baseline, current) {
933
- // Simple structural comparison - can be enhanced
934
- if (!baseline || !current) return 0;
935
- return Object.keys(baseline).length === Object.keys(current).length ? 1 : 0.5;
1102
+ // Scored in crawlforge-extractors so the REST API's track_changes reports
1103
+ // the same number for the same pair of pages.
1104
+ return structuralSimilarity(
1105
+ { tags: (baseline.elements || []).map(el => el.tag), depths: baseline.hierarchy },
1106
+ { tags: (current.elements || []).map(el => el.tag), depths: current.hierarchy }
1107
+ );
936
1108
  }
937
1109
 
938
1110
  hammingDistance(str1, str2) {
@@ -91,8 +91,9 @@ export class ResearchOrchestrator extends EventEmitter {
91
91
 
92
92
  // Initialize LLM Manager for AI-powered research
93
93
  this.llmManager = new LLMManager(options.llmConfig || {});
94
+ // Provisional: conductResearch() re-resolves this with an async probe.
94
95
  this.enableLLMFeatures = this.llmManager.isAvailable();
95
-
96
+
96
97
  if (this.enableLLMFeatures) {
97
98
  this.logger.info('LLM-powered research features enabled');
98
99
  } else {
@@ -146,10 +147,15 @@ export class ResearchOrchestrator extends EventEmitter {
146
147
  const startTime = Date.now();
147
148
 
148
149
  this.initializeResearchSession(sessionId, topic, startTime);
149
-
150
+
151
+ // Settle LLM availability before the first LLM-gated stage. The constructor
152
+ // can only read it synchronously, and Ollama — which needs no API key — can
153
+ // only be confirmed by a probe.
154
+ this.enableLLMFeatures = await this.llmManager.ready();
155
+
150
156
  try {
151
157
  this.logger.info('Starting deep research', { sessionId, topic, options });
152
-
158
+
153
159
  // Stage 1: Initial topic exploration and query expansion
154
160
  const expandedQueries = await this.expandResearchTopic(topic, options);
155
161
  this.researchState.currentDepth = 1;
@@ -11,8 +11,7 @@
11
11
  * 4. Error
12
12
  */
13
13
 
14
- const OLLAMA_DEFAULT_MODEL = 'llama3.2';
15
- const OLLAMA_BASE_URL = () => (process.env.OLLAMA_BASE_URL || 'http://localhost:11434').replace(/\/$/, '');
14
+ import { ollamaBaseUrl as OLLAMA_BASE_URL, ollamaHeaders, selectOllamaModel } from '../utils/ollamaConfig.js';
16
15
 
17
16
  /**
18
17
  * Attempt an Ollama completion.
@@ -21,11 +20,11 @@ const OLLAMA_BASE_URL = () => (process.env.OLLAMA_BASE_URL || 'http://localhost:
21
20
  * @returns {Promise<string>}
22
21
  */
23
22
  async function tryOllama(prompt, { model, maxTokens } = {}) {
24
- const ollamaModel = model || process.env.OLLAMA_DEFAULT_MODEL || OLLAMA_DEFAULT_MODEL;
23
+ const ollamaModel = model || await selectOllamaModel();
25
24
  const url = `${OLLAMA_BASE_URL()}/api/generate`;
26
25
  const res = await fetch(url, {
27
26
  method: 'POST',
28
- headers: { 'Content-Type': 'application/json' },
27
+ headers: ollamaHeaders({ 'Content-Type': 'application/json' }),
29
28
  body: JSON.stringify({
30
29
  model: ollamaModel,
31
30
  prompt,
@@ -178,7 +177,7 @@ export class SamplingClient {
178
177
  const result = { ollama: false, openai: false, anthropic: false, sampling: false };
179
178
 
180
179
  try {
181
- const res = await fetch(`${OLLAMA_BASE_URL()}/api/tags`, { signal: AbortSignal.timeout(3000) });
180
+ const res = await fetch(`${OLLAMA_BASE_URL()}/api/tags`, { headers: ollamaHeaders(), signal: AbortSignal.timeout(3000) });
182
181
  result.ollama = res.ok;
183
182
  } catch (_) { /* unavailable */ }
184
183
 
@@ -64,15 +64,7 @@ export class StealthBrowserManager {
64
64
  constructor(options = {}) {
65
65
  this.browser = null;
66
66
  this._maxContexts = parseInt(process.env.MAX_BROWSER_CONTEXTS || '10', 10);
67
- this.contexts = new BrowserContextPool({
68
- maxContexts: this._maxContexts,
69
- periodicRefreshAfter: 200,
70
- closeIdleAfterMs: 30 * 60 * 1000,
71
- waitTimeoutMs: 10_000,
72
- onContextExpired: (contextId) => {
73
- this.fingerprints.delete(contextId);
74
- }
75
- });
67
+ this.contexts = this._createContextPool();
76
68
  // D2.2: fingerprints Map is capped at _maxContexts to prevent unbounded growth.
77
69
  // Oldest entries are evicted when the cap is exceeded (insertion order via Map).
78
70
  this.fingerprints = new Map();
@@ -234,6 +226,22 @@ export class StealthBrowserManager {
234
226
  ];
235
227
  }
236
228
 
229
+ /**
230
+ * Build the context pool. Also used by cleanup(): the pool's destroy()
231
+ * permanently stops its idle timer, so a destroyed pool must be replaced.
232
+ */
233
+ _createContextPool() {
234
+ return new BrowserContextPool({
235
+ maxContexts: this._maxContexts,
236
+ periodicRefreshAfter: 200,
237
+ closeIdleAfterMs: 30 * 60 * 1000,
238
+ waitTimeoutMs: 10_000,
239
+ onContextExpired: (contextId) => {
240
+ this.fingerprints.delete(contextId);
241
+ }
242
+ });
243
+ }
244
+
237
245
  /**
238
246
  * Launch stealth browser with anti-detection configurations.
239
247
  * C2: honours config.engine — 'chromium' (default) or 'camoufox' (Firefox-based).
@@ -241,6 +249,12 @@ export class StealthBrowserManager {
241
249
  async launchStealthBrowser(config = {}) {
242
250
  const validatedConfig = StealthConfigSchema.parse({ ...this.defaultConfig, ...config });
243
251
 
252
+ // A Chromium that was OOM-killed or crashed doesn't error on reuse — its
253
+ // protocol calls hang. Detect the corpse and relaunch instead.
254
+ if (this.browser && !this.browser.isConnected()) {
255
+ this.browser = null;
256
+ }
257
+
244
258
  // C2: if the requested engine differs from the running browser, tear it down first.
245
259
  if (this.browser && this._launchedEngine && this._launchedEngine !== validatedConfig.engine) {
246
260
  await this.browser.close().catch(() => {});
@@ -368,8 +382,11 @@ export class StealthBrowserManager {
368
382
  stealthArgs.push(`--proxy-server=${currentProxy}`);
369
383
  }
370
384
 
371
- this.browser = await chromium.launch({
385
+ const browser = await chromium.launch({
372
386
  headless: true,
387
+ // Hosted images set this to their system Chromium (Playwright itself
388
+ // never reads it) — see Dockerfile.
389
+ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined,
373
390
  args: stealthArgs,
374
391
  ignoreDefaultArgs: [
375
392
  '--enable-blink-features=IdleDetection',
@@ -377,6 +394,16 @@ export class StealthBrowserManager {
377
394
  ]
378
395
  });
379
396
 
397
+ // If this Chromium dies (OOM kill, crash), drop the handle so the next
398
+ // call relaunches instead of reusing a corpse. The identity guard keeps a
399
+ // late event from an old instance from nulling a newer one.
400
+ browser.on('disconnected', () => {
401
+ if (this.browser === browser) {
402
+ this.browser = null;
403
+ }
404
+ });
405
+ this.browser = browser;
406
+
380
407
  return this.browser;
381
408
  }
382
409
 
@@ -1864,8 +1891,20 @@ export class StealthBrowserManager {
1864
1891
  * Close all contexts and browser
1865
1892
  */
1866
1893
  async cleanup() {
1867
- // Close all contexts via pool (handles idle timer cleanup + wait queue drain)
1868
- await this.contexts.destroy();
1894
+ // A wedged Chromium doesn't error on close() it hangs. Race each close
1895
+ // against a short deadline so cleanup always finishes inside callers'
1896
+ // timeout windows and works as a remote unwedge lever.
1897
+ const withDeadline = (promise, ms) =>
1898
+ Promise.race([
1899
+ promise.then(() => true, () => true),
1900
+ new Promise((resolve) => setTimeout(() => resolve(false), ms))
1901
+ ]);
1902
+
1903
+ // Close all contexts via pool (handles idle timer cleanup + wait queue
1904
+ // drain). destroy() permanently stops the pool's idle timer, so recreate
1905
+ // the pool afterwards or idle reaping is dead for the process lifetime.
1906
+ await withDeadline(this.contexts.destroy(), 5000);
1907
+ this.contexts = this._createContextPool();
1869
1908
  this.fingerprints.clear();
1870
1909
 
1871
1910
  // Reset human behavior simulator
@@ -1874,14 +1913,18 @@ export class StealthBrowserManager {
1874
1913
  this.humanBehaviorSimulator = null;
1875
1914
  }
1876
1915
 
1877
- // Close browser
1916
+ // Close browser; if close hangs, kill the process so the OS reclaims it.
1878
1917
  if (this.browser) {
1879
- try {
1880
- await this.browser.close();
1881
- } catch (error) {
1882
- console.warn('Failed to close browser:', error.message);
1883
- }
1918
+ const browser = this.browser;
1884
1919
  this.browser = null;
1920
+ const closed = await withDeadline(browser.close(), 5000);
1921
+ if (!closed) {
1922
+ try {
1923
+ browser.process()?.kill('SIGKILL');
1924
+ } catch {
1925
+ // Process already gone.
1926
+ }
1927
+ }
1885
1928
  }
1886
1929
  }
1887
1930
 
@@ -2128,6 +2171,9 @@ export class LocalPlaywrightBackend extends BrowserBackend {
2128
2171
  const { chromium } = await import('playwright');
2129
2172
  return chromium.launch({
2130
2173
  headless: config.headless !== false,
2174
+ // Hosted images set this to their system Chromium (Playwright itself
2175
+ // never reads it) — see Dockerfile.
2176
+ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined,
2131
2177
  ...config.launchOptions
2132
2178
  });
2133
2179
  }
@@ -3,6 +3,7 @@ import { promises as fs } from 'fs';
3
3
  import path from 'path';
4
4
  import crypto from 'crypto';
5
5
  import { EventEmitter } from 'events';
6
+ import { config } from '../../constants/config.js';
6
7
 
7
8
  export class CacheManager extends EventEmitter {
8
9
  constructor(options = {}) {
@@ -11,8 +12,12 @@ export class CacheManager extends EventEmitter {
11
12
  const {
12
13
  maxSize = 1000,
13
14
  ttl = 3600000, // 1 hour default
14
- diskCacheDir = './cache',
15
- enableDiskCache = true,
15
+ // CACHE_DIR and CACHE_ENABLE_DISK have always been documented knobs in
16
+ // constants/config.js, but nothing read them: the disk cache was hard-
17
+ // wired to ./cache and always on. Defaults are unchanged ('./cache',
18
+ // true) — the env vars now actually do what they say.
19
+ diskCacheDir = config.performance.cacheDir,
20
+ enableDiskCache = config.performance.cacheEnableDisk,
16
21
  enableCacheWarming = false,
17
22
  warmingBatchSize = 10,
18
23
  enableMonitoring = true,
@@ -24,7 +24,8 @@ export class BFSCrawler {
24
24
  domainFilter = null,
25
25
  enableLinkAnalysis = true,
26
26
  linkAnalyzerOptions = {},
27
- sessionContext = null
27
+ sessionContext = null,
28
+ cacheEnabled = true
28
29
  } = options;
29
30
 
30
31
  this.maxDepth = maxDepth;
@@ -46,7 +47,14 @@ export class BFSCrawler {
46
47
  this.linkAnalyzer = enableLinkAnalysis ? new LinkAnalyzer(linkAnalyzerOptions) : null;
47
48
 
48
49
  this.queue = new QueueManager({ concurrency, timeout });
49
- this.cache = new CacheManager({ ttl: 3600000 }); // 1 hour cache
50
+ // Page-body cache for this crawler only: it is destroyed with the
51
+ // instance, so it stays in memory. Persisting it made bodies outlive the
52
+ // crawl by an hour and cross process boundaries, which is neither what
53
+ // destroy() below implies nor what a caller passing cacheEnabled:false
54
+ // could switch off.
55
+ this.cache = cacheEnabled
56
+ ? new CacheManager({ ttl: 3600000, enableDiskCache: false })
57
+ : null;
50
58
  // C1: per-domain rate-limiter map — reuse existing limiter when
51
59
  // effectiveRateLimit hasn't changed, rather than recreating it on every URL.
52
60
  this.rateLimiter = new RateLimiter({ requestsPerSecond: 10 });
@@ -179,8 +187,8 @@ export class BFSCrawler {
179
187
 
180
188
  try {
181
189
  // Check cache first
182
- const cacheKey = this.cache.generateKey(normalizedUrl);
183
- let pageData = await this.cache.get(cacheKey);
190
+ const cacheKey = this.cache ? this.cache.generateKey(normalizedUrl) : null;
191
+ let pageData = cacheKey ? await this.cache.get(cacheKey) : null;
184
192
 
185
193
  if (!pageData) {
186
194
  // Apply domain-specific rate limiting
@@ -205,7 +213,7 @@ export class BFSCrawler {
205
213
  pageData = await this.fetchPage(normalizedUrl);
206
214
 
207
215
  // Cache the result
208
- await this.cache.set(cacheKey, pageData);
216
+ if (cacheKey) await this.cache.set(cacheKey, pageData);
209
217
  }
210
218
 
211
219
  // Process links for analysis
@@ -425,7 +433,7 @@ export class BFSCrawler {
425
433
  visited: this.visited.size,
426
434
  results: this.results.length,
427
435
  errors: this.errors.length,
428
- cacheStats: this.cache.getStats(),
436
+ cacheStats: this.cache ? this.cache.getStats() : null,
429
437
  queueStats: this.queue.getStats(),
430
438
  rateLimitStats: this.rateLimiter.getStats(),
431
439
  domainFilterStats: filterStats,