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.
- package/CLAUDE.md +1 -1
- package/README.md +8 -4
- package/package.json +5 -4
- package/server.js +22 -14
- package/src/core/ActionExecutor.js +246 -66
- package/src/core/ChangeTracker.js +215 -22
- package/src/core/ResearchOrchestrator.js +9 -3
- package/src/core/SamplingClient.js +4 -5
- package/src/core/StealthBrowserManager.js +64 -18
- package/src/core/cache/CacheManager.js +7 -2
- package/src/core/crawlers/BFSCrawler.js +14 -6
- package/src/core/llm/LLMManager.js +61 -11
- package/src/core/llm/OllamaProvider.js +139 -0
- package/src/core/processing/BrowserProcessor.js +28 -2
- package/src/schemas/toolOutputSchemas.js +3 -1
- package/src/server/requestContext.js +26 -0
- package/src/server/transports/streamableHttp.js +54 -11
- package/src/server/withAuth.js +24 -6
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +26 -3
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +5 -4
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -0
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +6 -4
- package/src/skills/agent-skills/crawlforge-structured-extraction/references/templates.md +2 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +4 -1
- package/src/tools/basic/_fetch.js +8 -2
- package/src/tools/basic/fetchUrl.js +4 -1
- package/src/tools/crawl/crawlDeep.js +19 -5
- package/src/tools/extract/extractStructured.js +16 -4
- package/src/tools/extract/extractWithLlm.js +80 -10
- package/src/tools/extract/listOllamaModels.js +4 -6
- package/src/tools/scrape/_brandingExtractor.js +1 -1
- package/src/tools/scrape/unifiedScrape.js +71 -5
- package/src/tools/search/adapters/redditOfficialApi.js +196 -0
- package/src/tools/search/redditNormalize.js +95 -0
- package/src/tools/search/redditSearch.js +67 -91
- package/src/tools/templates/ScrapeTemplateTool.js +8 -3
- package/src/utils/hiddenContent.js +330 -0
- package/src/utils/htmlToMarkdown.js +12 -2
- package/src/utils/ollamaConfig.js +121 -0
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
620
|
+
level = 'none';
|
|
487
621
|
} else if (significanceScore < thresholds.moderate) {
|
|
488
|
-
|
|
622
|
+
level = 'minor';
|
|
489
623
|
} else if (significanceScore < thresholds.major) {
|
|
490
|
-
|
|
624
|
+
level = 'moderate';
|
|
491
625
|
} else if (significanceScore < 0.9) {
|
|
492
|
-
|
|
626
|
+
level = 'major';
|
|
493
627
|
} else {
|
|
494
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
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 ||
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
1868
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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,
|