crawlforge-mcp-server 5.1.0 → 5.2.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 (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 +22 -14
  5. package/src/core/ActionExecutor.js +246 -66
  6. package/src/core/ChangeTracker.js +215 -22
  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 +8 -2
  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
@@ -48,6 +48,60 @@ const ChangeComparisonSchema = z.object({
48
48
 
49
49
  const ChangeSignificance = z.enum(['none', 'minor', 'moderate', 'major', 'critical']);
50
50
 
51
+ // Bounds that keep a compare response usable. An unscoped Amazon product page
52
+ // produced a 5.6MB payload — 4MB of it a single line_diff holding the entire
53
+ // document twice — which overflows the MCP response limit on every comparison.
54
+ const MAX_DIFF_ENTRIES = 200;
55
+ const MAX_DIFF_VALUE_CHARS = 2000;
56
+
57
+ // Significance ordering, used to raise a level without ever lowering it.
58
+ const SIGNIFICANCE_ORDER = ['none', 'minor', 'moderate', 'major', 'critical'];
59
+
60
+ /**
61
+ * A monetary amount carries meaning that its size on the page does not.
62
+ * Significance is otherwise purely volumetric — how much of the document
63
+ * changed — so a price is scored by how many characters it occupies. Tracking
64
+ * a price block, a rise from $19.99 to $99.99 scored "minor", below the default
65
+ * "moderate" notification threshold; untracked, the same change did not
66
+ * register as a change at all.
67
+ *
68
+ * Only currency-tagged numbers count. Treating every number this way would fire
69
+ * on view counters, timestamps and review totals, which is the opposite failure.
70
+ */
71
+ const MONETARY_PATTERN =
72
+ /[$£€¥₹]\s?\d[\d,]*(?:\.\d{1,2})?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:USD|EUR|GBP|JPY|CAD|AUD|CHF|CNY|INR)\b/gi;
73
+
74
+ /** A price change at or above this fraction is major rather than moderate. */
75
+ const MAJOR_VALUE_CHANGE = 0.2;
76
+
77
+ /**
78
+ * Parse the numeric amount out of a matched monetary string.
79
+ * Commas are read as thousands separators; a European decimal comma is
80
+ * ambiguous here and is not guessed at, so such a value simply reads as
81
+ * changed rather than being scored by magnitude.
82
+ * @param {string} raw
83
+ * @returns {number|null}
84
+ */
85
+ function parseMonetaryAmount(raw) {
86
+ const amount = Number.parseFloat(raw.replace(/[^\d.,]/g, '').replace(/,/g, ''));
87
+ return Number.isFinite(amount) ? amount : null;
88
+ }
89
+
90
+ /**
91
+ * Monetary amounts in document order.
92
+ * @param {string} text
93
+ * @returns {Array<{raw: string, amount: number}>}
94
+ */
95
+ function extractMonetaryValues(text) {
96
+ if (!text) return [];
97
+ const values = [];
98
+ for (const match of String(text).matchAll(MONETARY_PATTERN)) {
99
+ const amount = parseMonetaryAmount(match[0]);
100
+ if (amount !== null) values.push({ raw: match[0].trim(), amount });
101
+ }
102
+ return values;
103
+ }
104
+
51
105
  export class ChangeTracker extends EventEmitter {
52
106
  constructor(options = {}) {
53
107
  super();
@@ -156,7 +210,8 @@ export class ChangeTracker extends EventEmitter {
156
210
  contentHash: contentAnalysis.hashes.page,
157
211
  sections: Object.keys(contentAnalysis.hashes.sections).length,
158
212
  elements: Object.keys(contentAnalysis.hashes.elements).length,
159
- createdAt: baseline.timestamp
213
+ createdAt: baseline.timestamp,
214
+ ...(contentAnalysis.warnings ? { warnings: contentAnalysis.warnings } : {})
160
215
  };
161
216
 
162
217
  } catch (error) {
@@ -294,13 +349,36 @@ export class ChangeTracker extends EventEmitter {
294
349
 
295
350
  try {
296
351
  // Parse HTML if available
297
- const $ = load(content);
298
-
352
+ let $ = load(content);
353
+
299
354
  // Remove excluded elements
300
355
  options.excludeSelectors?.forEach(selector => {
301
356
  $(selector).remove();
302
357
  });
303
-
358
+
359
+ // Narrow the working document to customSelectors so hashing, similarity
360
+ // and text diffs all operate on the same subtree. Previously these
361
+ // selectors only added extra section hashes while every comparison still
362
+ // ran over the whole page, so document-level churn (session tokens,
363
+ // CSP nonces, rotating ad ids) registered as changes no matter how
364
+ // tightly the caller scoped.
365
+ if (options.customSelectors?.length) {
366
+ const scoped = options.customSelectors
367
+ .flatMap(selector => $(selector).toArray().map(element => $.html(element)))
368
+ .join('\n');
369
+
370
+ if (scoped) {
371
+ $ = load(scoped);
372
+ analysis.originalContent = scoped;
373
+ } else {
374
+ // Falling back to the full document keeps a bad selector from
375
+ // silently tracking nothing, but the caller needs to know.
376
+ analysis.warnings = [
377
+ `customSelectors matched no elements (${options.customSelectors.join(', ')}); tracked the full document instead`
378
+ ];
379
+ }
380
+ }
381
+
304
382
  // Analyze at different granularities
305
383
  switch (options.granularity) {
306
384
  case 'element':
@@ -324,8 +402,8 @@ export class ChangeTracker extends EventEmitter {
324
402
  // Extract metadata
325
403
  analysis.metadata = this.extractMetadata($, options);
326
404
 
327
- // Calculate statistics
328
- analysis.statistics = this.calculateContentStatistics(content, $);
405
+ // Calculate statistics over the scoped content, matching what is hashed
406
+ analysis.statistics = this.calculateContentStatistics(analysis.originalContent, $);
329
407
 
330
408
  } catch (error) {
331
409
  // Fallback to plain text analysis
@@ -350,7 +428,10 @@ export class ChangeTracker extends EventEmitter {
350
428
  async detectChanges(baseline, current, options = {}) {
351
429
  const changes = {
352
430
  similarity: 0,
353
- structuralSimilarity: 0,
431
+ // null rather than 0: a structural score is only produced when
432
+ // trackStructure is on, and 0 is a real score meaning "the structure
433
+ // changed completely".
434
+ structuralSimilarity: null,
354
435
  addedElements: [],
355
436
  removedElements: [],
356
437
  modifiedElements: [],
@@ -413,6 +494,15 @@ export class ChangeTracker extends EventEmitter {
413
494
  );
414
495
  }
415
496
 
497
+ // Detect monetary value changes. Scored by magnitude rather than by how
498
+ // much of the page they occupy, so a price change is not diluted away.
499
+ if (options.trackText !== false) {
500
+ changes.valueChanges = this.detectValueChanges(
501
+ baseline.originalContent,
502
+ current.originalContent
503
+ );
504
+ }
505
+
416
506
  // Detect link changes
417
507
  if (options.trackLinks) {
418
508
  changes.linkChanges = this.detectLinkChanges(
@@ -432,6 +522,49 @@ export class ChangeTracker extends EventEmitter {
432
522
  return changes;
433
523
  }
434
524
 
525
+ /**
526
+ * Compare the monetary amounts in two versions of the tracked content.
527
+ *
528
+ * Amounts are paired in document order. When the two versions hold different
529
+ * counts the set of prices itself changed (an item sold out, a sale price
530
+ * appeared), which is reported as a change even though no single pair can be
531
+ * measured.
532
+ *
533
+ * @param {string} baselineText
534
+ * @param {string} currentText
535
+ * @returns {{changes: Array, countChanged: boolean, maxRelativeChange: number}|null}
536
+ * null when no monetary value changed
537
+ */
538
+ detectValueChanges(baselineText, currentText) {
539
+ const before = extractMonetaryValues(baselineText);
540
+ const after = extractMonetaryValues(currentText);
541
+ if (before.length === 0 && after.length === 0) return null;
542
+
543
+ const changes = [];
544
+ const pairs = Math.min(before.length, after.length);
545
+ for (let i = 0; i < pairs; i++) {
546
+ if (before[i].amount === after[i].amount) continue;
547
+ const base = Math.abs(before[i].amount);
548
+ const relativeChange = base > 0
549
+ ? Math.abs(after[i].amount - before[i].amount) / base
550
+ : 1;
551
+ changes.push({
552
+ before: before[i].raw,
553
+ after: after[i].raw,
554
+ relativeChange: Math.round(relativeChange * 1000) / 1000
555
+ });
556
+ }
557
+
558
+ const countChanged = before.length !== after.length;
559
+ if (changes.length === 0 && !countChanged) return null;
560
+
561
+ return {
562
+ changes: changes.slice(0, MAX_DIFF_ENTRIES),
563
+ countChanged,
564
+ maxRelativeChange: changes.reduce((max, c) => Math.max(max, c.relativeChange), 0)
565
+ };
566
+ }
567
+
435
568
  /**
436
569
  * Calculate change significance score
437
570
  * @param {Object} changeAnalysis - Change analysis results
@@ -482,17 +615,33 @@ export class ChangeTracker extends EventEmitter {
482
615
  }
483
616
 
484
617
  // Determine significance level
618
+ let level;
485
619
  if (significanceScore < thresholds.minor) {
486
- return 'none';
620
+ level = 'none';
487
621
  } else if (significanceScore < thresholds.moderate) {
488
- return 'minor';
622
+ level = 'minor';
489
623
  } else if (significanceScore < thresholds.major) {
490
- return 'moderate';
624
+ level = 'moderate';
491
625
  } else if (significanceScore < 0.9) {
492
- return 'major';
626
+ level = 'major';
493
627
  } else {
494
- return 'critical';
628
+ level = 'critical';
495
629
  }
630
+
631
+ // The score above measures how much of the page changed. A price change is
632
+ // significant because of what it is, not how many characters it takes up,
633
+ // so a monetary change raises the level to at least "moderate" — the
634
+ // default notification threshold, which it previously fell below. This only
635
+ // ever raises the level; a large structural change stays major.
636
+ const valueChanges = changeAnalysis.valueChanges;
637
+ if (valueChanges) {
638
+ const floor = valueChanges.maxRelativeChange >= MAJOR_VALUE_CHANGE ? 'major' : 'moderate';
639
+ if (SIGNIFICANCE_ORDER.indexOf(floor) > SIGNIFICANCE_ORDER.indexOf(level)) {
640
+ level = floor;
641
+ }
642
+ }
643
+
644
+ return level;
496
645
  }
497
646
 
498
647
  // Content Analysis Methods
@@ -752,21 +901,65 @@ export class ChangeTracker extends EventEmitter {
752
901
  if (wordDiffChanges.length > 0) {
753
902
  textChanges.push({
754
903
  type: 'word_diff',
755
- changes: wordDiffChanges
904
+ changes: this.capDiffPayload(wordDiffChanges)
756
905
  });
757
906
  }
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
- });
907
+
908
+ // Line-level diff for structured content. With ignoreWhitespace (the
909
+ // default) the whole document collapses onto a single line, so diffLines
910
+ // degenerates into "remove everything, add everything" — a payload twice
911
+ // the page size describing what word_diff already pinpointed. Only run it
912
+ // when the content genuinely has line structure.
913
+ const hasLineStructure = baselineContent.includes('\n') || currentContent.includes('\n');
914
+ if (hasLineStructure) {
915
+ const lineDiff = diffLines(baselineContent, currentContent);
916
+ const lineDiffChanges = lineDiff.filter(part => part.added || part.removed);
917
+ if (lineDiffChanges.length > 0) {
918
+ textChanges.push({
919
+ type: 'line_diff',
920
+ changes: this.capDiffPayload(lineDiffChanges)
921
+ });
922
+ }
766
923
  }
767
-
924
+
768
925
  return textChanges;
769
926
  }
927
+
928
+ /**
929
+ * Bound a diff payload so a large page cannot produce a multi-megabyte
930
+ * response. Keeps the first maxEntries changes, truncates any oversized
931
+ * value, and appends a marker describing what was dropped so callers never
932
+ * mistake a truncated diff for a complete one.
933
+ * @param {Array} changes - Diff parts from diffWords/diffLines
934
+ * @param {Object} limits - Optional maxEntries / maxValueChars overrides
935
+ * @returns {Array} - Bounded diff parts
936
+ */
937
+ capDiffPayload(changes, limits = {}) {
938
+ const maxEntries = limits.maxEntries ?? MAX_DIFF_ENTRIES;
939
+ const maxValueChars = limits.maxValueChars ?? MAX_DIFF_VALUE_CHARS;
940
+
941
+ const capped = changes.slice(0, maxEntries).map(part => {
942
+ if (typeof part.value === 'string' && part.value.length > maxValueChars) {
943
+ return {
944
+ ...part,
945
+ value: part.value.slice(0, maxValueChars),
946
+ truncated: true,
947
+ omittedChars: part.value.length - maxValueChars
948
+ };
949
+ }
950
+ return part;
951
+ });
952
+
953
+ const omittedEntries = changes.length - capped.length;
954
+ if (omittedEntries > 0) {
955
+ capped.push({
956
+ omittedEntries,
957
+ note: `${omittedEntries} further changes omitted; scope the comparison with customSelectors to see them`
958
+ });
959
+ }
960
+
961
+ return capped;
962
+ }
770
963
 
771
964
  detectLinkChanges(baselineLinks, currentLinks) {
772
965
  const changes = {
@@ -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,