crawlforge-mcp-server 6.21.1 → 6.22.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 (52) hide show
  1. package/package.json +3 -2
  2. package/server.js +12 -12
  3. package/src/core/ActionExecutor.js +23 -4
  4. package/src/core/AgentOrchestrator.js +228 -21
  5. package/src/core/AuthManager.js +4 -1
  6. package/src/core/LLMsTxtAnalyzer.js +6 -7
  7. package/src/core/LocalizationManager.js +29 -4
  8. package/src/core/ResearchOrchestrator.js +76 -13
  9. package/src/core/SamplingClient.js +38 -8
  10. package/src/core/SnapshotManager.js +32 -13
  11. package/src/core/analysis/ContentAnalyzer.js +26 -25
  12. package/src/core/crawlers/BFSCrawler.js +48 -10
  13. package/src/core/processing/ContentProcessor.js +4 -2
  14. package/src/core/queue/QueueManager.js +16 -2
  15. package/src/resources/ResourceRegistry.js +19 -5
  16. package/src/schemas/toolOutputSchemas.js +22 -14
  17. package/src/server/fallbackHints.js +37 -7
  18. package/src/server/withAuth.js +27 -44
  19. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  20. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  21. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
  22. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  23. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
  24. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
  25. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +11 -14
  26. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  27. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  28. package/src/tools/advanced/BrowserSessionTool.js +11 -2
  29. package/src/tools/advanced/batchScrape/queue.js +4 -2
  30. package/src/tools/advanced/batchScrape/worker.js +50 -11
  31. package/src/tools/agent/agent.js +1 -1
  32. package/src/tools/basic/_fetch.js +2 -1
  33. package/src/tools/basic/extractMetadata.js +2 -1
  34. package/src/tools/basic/extractText.js +12 -8
  35. package/src/tools/crawl/mapSite.js +8 -0
  36. package/src/tools/extract/extractContent.js +2 -1
  37. package/src/tools/extract/extractStructured.js +4 -2
  38. package/src/tools/extract/extractWithLlm.js +8 -4
  39. package/src/tools/extract/processDocument.js +29 -1
  40. package/src/tools/extract/summarizeContent.js +5 -1
  41. package/src/tools/llmstxt/generateLLMsTxt.js +6 -2
  42. package/src/tools/scrape/_mainContent.js +54 -2
  43. package/src/tools/scrape/unifiedScrape.js +94 -6
  44. package/src/tools/search/providers/searxng.js +20 -3
  45. package/src/tools/search/ranking/ResultDeduplicator.js +51 -65
  46. package/src/tools/tracking/trackChanges/differ.js +2 -1
  47. package/src/tools/tracking/trackChanges/index.js +6 -1
  48. package/src/tools/tracking/trackChanges/schema.js +15 -1
  49. package/src/utils/fetchError.js +47 -0
  50. package/src/utils/fetchLadder.js +5 -5
  51. package/src/utils/htmlToMarkdown.js +36 -2
  52. package/src/utils/ssrfGuard.js +2 -1
@@ -758,7 +758,10 @@ class AuthManager {
758
758
  break;
759
759
  }
760
760
  case 'extract_with_llm':
761
- note = 'External LLM API call billed by your LLM provider, separate from the credit cost.';
761
+ // Only the cloud providers bill you; the default runs on local Ollama.
762
+ if (params?.provider === 'openai' || params?.provider === 'anthropic') {
763
+ note = 'External LLM API call billed by your LLM provider, separate from the credit cost.';
764
+ }
762
765
  break;
763
766
  case 'stealth_mode':
764
767
  note = projected === 1
@@ -236,7 +236,9 @@ export class LLMsTxtAnalyzer {
236
236
  const batch = commonPaths.slice(i, i + PROBE_CONCURRENCY);
237
237
  const results = await Promise.allSettled(
238
238
  batch.map(async (path) => {
239
- const apiUrl = `${baseUrl}${path}`;
239
+ // Resolved, not concatenated: the URL arrives as typed, and
240
+ // "https://site.com/" + "/json" was listed as "https://site.com//json" (R25 1.8).
241
+ const apiUrl = new URL(path, baseUrl).toString();
240
242
  const response = await this.fetchWithTimeout(apiUrl, { timeout: 5000 });
241
243
  if (response.ok) {
242
244
  const contentType = response.headers.get('content-type') || '';
@@ -367,7 +369,7 @@ export class LLMsTxtAnalyzer {
367
369
  const batch = sensitivePaths.slice(i, i + PROBE_CONCURRENCY);
368
370
  const results = await Promise.allSettled(
369
371
  batch.map(async (path) => {
370
- const testUrl = `${baseUrl}${path}`;
372
+ const testUrl = new URL(path, baseUrl).toString();
371
373
  const response = await this.fetchWithTimeout(testUrl, { timeout: 3000 });
372
374
  if (response.status === 200 || response.status === 302 || response.status === 401) {
373
375
  return {
@@ -571,7 +573,6 @@ export class LLMsTxtAnalyzer {
571
573
  */
572
574
  prioritizeUrls(urls, baseUrl, limit) {
573
575
  const sections = new Map();
574
- let sawAny = false;
575
576
 
576
577
  for (const url of urls) {
577
578
  let segments;
@@ -580,7 +581,6 @@ export class LLMsTxtAnalyzer {
580
581
  } catch {
581
582
  continue;
582
583
  }
583
- sawAny = true;
584
584
  // The root arrives as either "https://site.com" or "https://site.com/"
585
585
  // (normalizeUrl strips a trailing slash from every path but "/"), so it
586
586
  // is recognised by its path and re-added below in one canonical form.
@@ -590,10 +590,9 @@ export class LLMsTxtAnalyzer {
590
590
  sections.get(section).push({ url, depth: segments.length });
591
591
  }
592
592
 
593
- if (!sawAny) return [];
594
-
595
593
  // The site root leads: it is always fetched, and a site guide that omits
596
- // the homepage is a misleading one.
594
+ // the homepage is a misleading one. That holds with no other candidate
595
+ // too: a one-page site was analysed as 0 pages (R25 1.2).
597
596
  const ordered = [`${baseUrl}/`];
598
597
  const queues = [...sections.keys()].sort().map((name) =>
599
598
  sections.get(name).sort((a, b) => a.depth - b.depth || a.url.localeCompare(b.url)));
@@ -778,15 +778,40 @@ export class LocalizationManager extends EventEmitter {
778
778
  }
779
779
 
780
780
  getFirstDayOfWeek(countryCode) {
781
- // 0 = Sunday, 1 = Monday
781
+ // 0 = Sunday, 1 = Monday. From CLDR 48 supplemental weekData "firstDay";
782
+ // covers every SUPPORTED_COUNTRIES entry. The fallback is CLDR's world
783
+ // default (Monday). `??`, not `||`: Sunday is 0 (R25).
782
784
  const firstDays = {
783
785
  'US': 0,
784
786
  'GB': 1,
785
787
  'DE': 1,
786
- 'FR': 1
788
+ 'FR': 1,
789
+ 'JP': 0,
790
+ 'CN': 1,
791
+ 'AU': 1,
792
+ 'CA': 0,
793
+ 'IT': 1,
794
+ 'ES': 1,
795
+ 'RU': 1,
796
+ 'BR': 0,
797
+ 'IN': 0,
798
+ 'KR': 0,
799
+ 'MX': 0,
800
+ 'NL': 1,
801
+ 'SE': 1,
802
+ 'NO': 1,
803
+ 'SA': 0,
804
+ 'AE': 1,
805
+ 'TR': 1,
806
+ 'IL': 0,
807
+ 'TH': 0,
808
+ 'SG': 0,
809
+ 'PL': 1,
810
+ 'ZA': 0,
811
+ 'CH': 1
787
812
  };
788
-
789
- return firstDays[countryCode] || 1;
813
+
814
+ return firstDays[countryCode] ?? 1;
790
815
  }
791
816
 
792
817
  generateScreenProperties(countryCode) {
@@ -63,6 +63,63 @@ export function reportedFindings(keyFindings) {
63
63
  return material.length > 0 ? material : keyFindings;
64
64
  }
65
65
 
66
+ // Citation markers left in extracted text: "(3)", "[12]", "[3, 4]", "[2-5]".
67
+ // At most three digits, so a year such as "(2024)" stays.
68
+ const CITATION_MARKER = /\s*(?:\(\d{1,3}(?:\s*[,\u2013-]\s*\d{1,3})*\)|\[\d{1,3}(?:\s*[,\u2013-]\s*\d{1,3})*\])/g;
69
+ const LIST_MARKER = /^(?:[-*\u2022\u25AA\u2013]|\d{1,2}[.)])\s+/;
70
+ // A lowercase word run straight into a capitalized one: "glanceWhat". A
71
+ // camelCase name starts with a capital ("JavaScript", "DataDome") and never matches.
72
+ const WELDED_WORDS = /\b[a-z]{2,}[A-Z][a-z]/g;
73
+
74
+ /**
75
+ * A line that is a table row or headings welded together, not a statement.
76
+ * extract_content reads a table one row per line, cells joined by " | " (a
77
+ * two-cell row has just one), and crawlforge-extractors' flattenText joins
78
+ * inline elements with no separator, so a table of contents of `<a class="block">` links came back as "The six
79
+ * vendors at a glanceWhat the independent benchmarks showWhy your approach…"
80
+ * (R25 re-check, 2026-10-19). A sentence ends in punctuation; welded headings
81
+ * have none.
82
+ *
83
+ * @param {string} line
84
+ * @returns {boolean}
85
+ */
86
+ function isPageFragment(line) {
87
+ if (/\s\|\s/.test(line) || (line.match(/\|/g) || []).length >= 2) return true;
88
+ return line.length <= 300 && !/[.!?](?:\s|$)/.test(line) &&
89
+ (line.match(WELDED_WORDS) || []).length >= 2;
90
+ }
91
+
92
+ /**
93
+ * The statements in one extracted key point. A key point is a sentence as the
94
+ * splitter found it, so a heading or list item above it arrives in the same
95
+ * string ("Built-in encryption\nQUIC was built with security in mind.").
96
+ * Each line becomes its own statement, except a line starting in lowercase,
97
+ * which continues the one before it (wrapped text). List and citation markers
98
+ * are removed. Table rows and welded headings are dropped; heading-only lines
99
+ * are left for isAdmissibleClaim to reject.
100
+ *
101
+ * @param {string} point
102
+ * @returns {string[]}
103
+ */
104
+ export function claimStatements(point) {
105
+ const blocks = [];
106
+ let afterFragment = false;
107
+ for (const raw of String(point).split('\n')) {
108
+ const line = raw.trim();
109
+ if (!line) continue;
110
+ if (isPageFragment(line)) {
111
+ afterFragment = true;
112
+ continue;
113
+ }
114
+ if (blocks.length > 0 && !afterFragment && /^[a-z]/.test(line)) blocks[blocks.length - 1] += ` ${line}`;
115
+ else blocks.push(line);
116
+ afterFragment = false;
117
+ }
118
+ return blocks
119
+ .map(block => block.replace(LIST_MARKER, '').replace(CITATION_MARKER, '').replace(/\s+/g, ' ').trim())
120
+ .filter(Boolean);
121
+ }
122
+
66
123
  /**
67
124
  * Page text for the plain-fetch fallback, one line per block element. The
68
125
  * regex tag-strip it replaces ran every block together on one line.
@@ -1384,7 +1441,8 @@ export class ResearchOrchestrator extends EventEmitter {
1384
1441
  const keyPoints = summary.keypoints || summary.keyPoints || [];
1385
1442
  if (keyPoints.length > 0) {
1386
1443
  const relevance = this.sourceRelevance(source);
1387
- keyPoints.forEach((point, index) => {
1444
+ let claimIndex = 0;
1445
+ keyPoints.forEach((keyPoint, index) => claimStatements(keyPoint).forEach(point => {
1388
1446
  const credibility = source.overallCredibility || 0.65;
1389
1447
  // A recommendation is not evidence. Two routes to the same flag: a
1390
1448
  // page promoting itself, and — whoever published it — a claim whose
@@ -1392,7 +1450,7 @@ export class ResearchOrchestrator extends EventEmitter {
1392
1450
  const promotional = isVendorSelfPromotion(point, source.link) ||
1393
1451
  isProductRecommendation(point);
1394
1452
  claims.push({
1395
- id: `${source.link}_claim_${index}`,
1453
+ id: `${source.link}_claim_${claimIndex++}`,
1396
1454
  claim: point,
1397
1455
  source: source.link,
1398
1456
  sourceTitle: source.title,
@@ -1402,7 +1460,7 @@ export class ResearchOrchestrator extends EventEmitter {
1402
1460
  context: summary.supporting?.[index] || '',
1403
1461
  extractedAt: new Date().toISOString()
1404
1462
  });
1405
- });
1463
+ }));
1406
1464
  }
1407
1465
  } catch (error) {
1408
1466
  this.logger.warn('Claim extraction failed', {
@@ -1464,31 +1522,33 @@ export class ResearchOrchestrator extends EventEmitter {
1464
1522
  /**
1465
1523
  * Keep only claims that can be research findings: prose about the topic, not
1466
1524
  * document front matter (author/affiliation blocks, DOI stubs, "Retrieved
1467
- * from" lines), not from a source the relevance analysis scored below
1468
- * MIN_CLAIM_RELEVANCE, and not a sentence the LLM scored as barely about the
1469
- * topic. Each gate falls back to the previous claim set rather than returning
1470
- * nothing — no claims means no findings at all.
1525
+ * from" lines), and not a sentence the LLM scored as barely about the topic.
1526
+ * Those two gates fall back to the previous claim set rather than returning
1527
+ * nothing. A claim from an off-topic source — one the relevance analysis
1528
+ * scored below MIN_CLAIM_RELEVANCE — is always dropped: when every source is
1529
+ * off-topic, no findings is the honest result (R25 reported such claims as
1530
+ * findings through the fallback). A source with no score is kept.
1471
1531
  */
1472
1532
  admitClaims(claims) {
1473
1533
  if (claims.length === 0) return claims;
1474
1534
 
1475
- const substantive = claims.filter(claim => isAdmissibleClaim(claim.claim));
1476
- const relevant = substantive.filter(
1535
+ const relevant = claims.filter(
1477
1536
  claim => typeof claim.relevance !== 'number' || claim.relevance >= MIN_CLAIM_RELEVANCE
1478
1537
  );
1479
- const onTopic = relevant.filter(
1538
+ const substantive = relevant.filter(claim => isAdmissibleClaim(claim.claim));
1539
+ const onTopic = substantive.filter(
1480
1540
  claim => typeof claim.topicRelevance !== 'number' ||
1481
1541
  claim.topicRelevance >= MIN_CLAIM_TOPIC_RELEVANCE
1482
1542
  );
1483
1543
 
1484
1544
  const admitted = onTopic.length > 0
1485
1545
  ? onTopic
1486
- : (relevant.length > 0 ? relevant : (substantive.length > 0 ? substantive : claims));
1546
+ : (substantive.length > 0 ? substantive : relevant);
1487
1547
 
1488
1548
  this.logger.debug('Claim admission', {
1489
1549
  candidates: claims.length,
1490
- substantive: substantive.length,
1491
1550
  relevant: relevant.length,
1551
+ substantive: substantive.length,
1492
1552
  onTopic: onTopic.length,
1493
1553
  admitted: admitted.length
1494
1554
  });
@@ -2007,8 +2067,10 @@ export class ResearchOrchestrator extends EventEmitter {
2007
2067
  findings.push({
2008
2068
  finding: claim.claim,
2009
2069
  supportingClaims: group.claims.length,
2070
+ supportingSources: group.sourceCount,
2010
2071
  credibility: group.avgCredibility,
2011
- sources: group.claims.map(c => c.source),
2072
+ // One entry per source: a group holds several claims from one page.
2073
+ sources: [...new Set(group.claims.map(c => c.source))],
2012
2074
  ...(claim.promotional ? { promotional: true } : {}),
2013
2075
  ...(typeof claim.topicRelevance === 'number'
2014
2076
  ? { topicRelevance: claim.topicRelevance }
@@ -2032,6 +2094,7 @@ export class ResearchOrchestrator extends EventEmitter {
2032
2094
  title: source.title,
2033
2095
  url: source.link,
2034
2096
  credibility: source.overallCredibility,
2097
+ relevance: this.sourceRelevance(source),
2035
2098
  evidence: source.extractedContent?.substring(0, 300) + '...'
2036
2099
  }))
2037
2100
  .slice(0, 15);
@@ -15,13 +15,24 @@
15
15
 
16
16
  import { ollamaBaseUrl as OLLAMA_BASE_URL, ollamaHeaders, selectOllamaModel } from '../utils/ollamaConfig.js';
17
17
 
18
+ /**
19
+ * A provider call's own 30 s bound, cut short by the caller's time budget
20
+ * when there is one.
21
+ * @param {AbortSignal|null} [budget]
22
+ * @returns {AbortSignal}
23
+ */
24
+ function callSignal(budget) {
25
+ const own = AbortSignal.timeout(30_000);
26
+ return budget ? AbortSignal.any([own, budget]) : own;
27
+ }
28
+
18
29
  /**
19
30
  * Attempt an Ollama completion.
20
31
  * @param {string} prompt
21
32
  * @param {object} options
22
33
  * @returns {Promise<string>}
23
34
  */
24
- async function tryOllama(prompt, { model, maxTokens } = {}) {
35
+ async function tryOllama(prompt, { model, maxTokens, budget } = {}) {
25
36
  const ollamaModel = model || await selectOllamaModel();
26
37
  const url = `${OLLAMA_BASE_URL()}/api/generate`;
27
38
  const res = await fetch(url, {
@@ -33,7 +44,7 @@ async function tryOllama(prompt, { model, maxTokens } = {}) {
33
44
  stream: false,
34
45
  ...(maxTokens ? { options: { num_predict: maxTokens } } : {}),
35
46
  }),
36
- signal: AbortSignal.timeout(30_000),
47
+ signal: callSignal(budget),
37
48
  });
38
49
  if (!res.ok) throw new Error(`Ollama HTTP ${res.status}`);
39
50
  const data = await res.json();
@@ -47,7 +58,7 @@ async function tryOllama(prompt, { model, maxTokens } = {}) {
47
58
  * @param {object} options
48
59
  * @returns {Promise<string>}
49
60
  */
50
- async function tryOpenAI(prompt, { model, maxTokens } = {}) {
61
+ async function tryOpenAI(prompt, { model, maxTokens, budget } = {}) {
51
62
  const apiKey = process.env.OPENAI_API_KEY;
52
63
  if (!apiKey) throw new Error('OPENAI_API_KEY not set');
53
64
  const base = (process.env.OPENAI_BASE_URL || 'https://api.openai.com').replace(/\/$/, '');
@@ -62,7 +73,7 @@ async function tryOpenAI(prompt, { model, maxTokens } = {}) {
62
73
  messages: [{ role: 'user', content: prompt }],
63
74
  ...(maxTokens ? { max_tokens: maxTokens } : {}),
64
75
  }),
65
- signal: AbortSignal.timeout(30_000),
76
+ signal: callSignal(budget),
66
77
  });
67
78
  if (!res.ok) throw new Error(`OpenAI HTTP ${res.status}`);
68
79
  const data = await res.json();
@@ -75,7 +86,7 @@ async function tryOpenAI(prompt, { model, maxTokens } = {}) {
75
86
  * @param {object} options
76
87
  * @returns {Promise<string>}
77
88
  */
78
- async function tryAnthropic(prompt, { model, maxTokens } = {}) {
89
+ async function tryAnthropic(prompt, { model, maxTokens, budget } = {}) {
79
90
  const apiKey = process.env.ANTHROPIC_API_KEY;
80
91
  if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set');
81
92
  const base = (process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, '');
@@ -91,7 +102,7 @@ async function tryAnthropic(prompt, { model, maxTokens } = {}) {
91
102
  max_tokens: maxTokens || 1024,
92
103
  messages: [{ role: 'user', content: prompt }],
93
104
  }),
94
- signal: AbortSignal.timeout(30_000),
105
+ signal: callSignal(budget),
95
106
  });
96
107
  if (!res.ok) throw new Error(`Anthropic HTTP ${res.status}`);
97
108
  const data = await res.json();
@@ -116,12 +127,18 @@ export class SamplingClient {
116
127
  * @param {string} [options.model] - Override model name
117
128
  * @param {number} [options.maxTokens] - Max tokens for response
118
129
  * @param {string} [options.systemPrompt] - Optional system-level instruction
130
+ * @param {number} [options.timeoutMs] - Time budget for the whole chain. Each
131
+ * provider call stops at its own 30 s bound or when the budget runs out,
132
+ * whichever comes first, and the sampling fallback is raced against it.
133
+ * An exhausted budget throws an error with code 'LLM_TIMEOUT'.
119
134
  * @returns {Promise<{ text: string, provider: string }>}
120
135
  */
121
136
  async complete(prompt, options = {}) {
122
137
  const fullPrompt = options.systemPrompt
123
138
  ? `${options.systemPrompt}\n\n${prompt}`
124
139
  : prompt;
140
+ const budget = options.timeoutMs != null ? AbortSignal.timeout(Math.max(0, options.timeoutMs)) : null;
141
+ options = { ...options, budget };
125
142
 
126
143
  // 1. Try Ollama (local, no API key)
127
144
  try {
@@ -153,11 +170,19 @@ export class SamplingClient {
153
170
  // 3. Try MCP sampling (client-side LLM)
154
171
  if (this._mcpServer?.server?.createMessage) {
155
172
  try {
156
- const samplingResult = await this._mcpServer.server.createMessage({
173
+ const request = this._mcpServer.server.createMessage({
157
174
  messages: [{ role: 'user', content: { type: 'text', text: fullPrompt } }],
158
175
  maxTokens: options.maxTokens || 1024,
159
176
  includeContext: 'none',
160
- });
177
+ }, budget ? { signal: budget } : undefined);
178
+ // Raced as well: a client that ignores the cancellation would
179
+ // otherwise hold the caller past its budget.
180
+ const samplingResult = await (budget
181
+ ? Promise.race([request, new Promise((_, reject) => {
182
+ if (budget.aborted) reject(budget.reason);
183
+ else budget.addEventListener('abort', () => reject(budget.reason), { once: true });
184
+ })])
185
+ : request);
161
186
  const text = samplingResult?.content?.text || '';
162
187
  if (text) {
163
188
  // stderr, never stdout — stdout is the JSON-RPC stream on stdio.
@@ -170,6 +195,11 @@ export class SamplingClient {
170
195
  }
171
196
 
172
197
  // 4. All fallbacks exhausted
198
+ if (budget?.aborted) {
199
+ const err = new Error(`LLM time budget of ${options.timeoutMs} ms ran out before a provider answered.`);
200
+ err.code = 'LLM_TIMEOUT';
201
+ throw err;
202
+ }
173
203
  throw new Error(
174
204
  'No LLM available: Ollama is not running, no API keys set (OPENAI_API_KEY / ANTHROPIC_API_KEY), and the MCP client does not support sampling.'
175
205
  );
@@ -12,6 +12,7 @@ import { gzip, gunzip } from 'zlib';
12
12
  import { promisify } from 'util';
13
13
  import { z } from 'zod';
14
14
  import { EventEmitter } from 'events';
15
+ import { calculateSimilarity } from '../tools/tracking/trackChanges/differ.js';
15
16
 
16
17
  const gzipAsync = promisify(gzip);
17
18
  const gunzipAsync = promisify(gunzip);
@@ -362,9 +363,11 @@ export class SnapshotManager extends EventEmitter {
362
363
  if (metadata.compression && metadata.compression.enabled) {
363
364
  if (metadata.compression.algorithm === 'gzip') {
364
365
  content = await gunzipAsync(content);
365
- content = content.toString();
366
366
  }
367
367
  }
368
+ // readSnapshotFile returns a Buffer; an uncompressed snapshot was
369
+ // returned as one, not as its text.
370
+ content = content.toString();
368
371
 
369
372
  // Reconstruct from delta if needed
370
373
  if (metadata.delta && metadata.delta.enabled) {
@@ -502,14 +505,21 @@ export class SnapshotManager extends EventEmitter {
502
505
 
503
506
  const history = [];
504
507
  const snapshotList = snapshots.snapshots;
505
-
508
+
509
+ // Each snapshot's content is read once and carried to the next pair.
510
+ let currentContent = snapshotList.length > 1
511
+ ? await this.loadSnapshotContent(snapshotList[0].id)
512
+ : null;
513
+
506
514
  for (let i = 0; i < snapshotList.length - 1; i++) {
507
515
  const current = snapshotList[i];
508
516
  const previous = snapshotList[i + 1];
509
-
517
+ const previousContent = await this.loadSnapshotContent(previous.id);
518
+
510
519
  // Calculate change metrics
511
- const changeMetrics = await this.calculateChangeMetrics(previous, current);
512
-
520
+ const changeMetrics = await this.calculateChangeMetrics(previous, current, previousContent, currentContent);
521
+ currentContent = previousContent;
522
+
513
523
  history.push({
514
524
  timestamp: current.metadata.timestamp,
515
525
  snapshotId: current.id,
@@ -842,7 +852,17 @@ export class SnapshotManager extends EventEmitter {
842
852
  throw new Error('Cannot reconstruct legacy delta snapshot: no diff data was stored for it. Re-fetch or re-create this snapshot from the source.');
843
853
  }
844
854
 
845
- async calculateChangeMetrics(previousSnapshot, currentSnapshot) {
855
+ /** A stored snapshot's content, or null when it cannot be read back. */
856
+ async loadSnapshotContent(snapshotId) {
857
+ try {
858
+ const snapshot = await this.retrieveSnapshot(snapshotId);
859
+ return typeof snapshot.content === 'string' ? snapshot.content : null;
860
+ } catch {
861
+ return null;
862
+ }
863
+ }
864
+
865
+ async calculateChangeMetrics(previousSnapshot, currentSnapshot, previousContent = null, currentContent = null) {
846
866
  // Calculate various change metrics between snapshots
847
867
  const metrics = {
848
868
  sizeDelta: currentSnapshot.metadata.contentLength - previousSnapshot.metadata.contentLength,
@@ -852,14 +872,13 @@ export class SnapshotManager extends EventEmitter {
852
872
  similarity: 0
853
873
  };
854
874
 
855
- // Calculate content similarity if different hashes
875
+ // Content similarity, computed from the stored content. A size-based
876
+ // estimate scored two different pages of the same length 1.0. null when
877
+ // either snapshot's content could not be read back.
856
878
  if (metrics.hashChanged) {
857
- // This would require loading both snapshots' content
858
- // For now, estimate based on size difference
859
- metrics.similarity = 1 - Math.abs(metrics.sizeDelta) / Math.max(
860
- currentSnapshot.metadata.contentLength,
861
- previousSnapshot.metadata.contentLength
862
- );
879
+ metrics.similarity = typeof previousContent === 'string' && typeof currentContent === 'string'
880
+ ? calculateSimilarity(previousContent, currentContent)
881
+ : null;
863
882
  } else {
864
883
  metrics.similarity = 1.0;
865
884
  }
@@ -10,10 +10,13 @@ import {
10
10
  needsWordSegmentation
11
11
  } from '../../utils/languageDetection.js';
12
12
  import nlp from 'compromise';
13
+ import Sentiment from 'sentiment';
13
14
  import { z } from 'zod';
14
15
  import { splitSentences } from './sentenceUtils.js';
15
16
  import { LANGUAGE_STOP_WORDS } from './languageStopWords.js';
16
17
 
18
+ const sentimentAnalyzer = new Sentiment();
19
+
17
20
  const ContentAnalyzerSchema = z.object({
18
21
  text: z.string().min(1),
19
22
  options: z.object({
@@ -845,25 +848,20 @@ export class ContentAnalyzer {
845
848
  */
846
849
  async analyzeSentiment(text, options = {}) {
847
850
  try {
848
- const doc = nlp(text);
849
-
850
- // Simple sentiment analysis using compromise
851
- // This is basic - for production use a dedicated sentiment library
852
- const positiveWords = ['good', 'great', 'excellent', 'amazing', 'wonderful', 'fantastic', 'awesome', 'perfect', 'love', 'like', 'happy', 'pleased', 'satisfied'];
853
- const negativeWords = ['bad', 'terrible', 'awful', 'horrible', 'hate', 'dislike', 'angry', 'sad', 'disappointed', 'frustrated', 'annoyed', 'upset'];
854
-
855
- const words = doc.terms().out('array').map(w => w.toLowerCase());
856
-
857
- let positiveCount = 0;
858
- let negativeCount = 0;
859
-
860
- words.forEach(word => {
861
- if (positiveWords.includes(word)) positiveCount++;
862
- if (negativeWords.includes(word)) negativeCount++;
863
- });
851
+ // AFINN-165 lexicon (about 3,400 English words scored -5..+5) with
852
+ // negation ("not happy" scores negative). A 25-word list used to leave
853
+ // clearly positive text "neutral" (R25).
854
+ const { calculation, tokens } = sentimentAnalyzer.analyze(text);
855
+ // Drop hits that are function words of the detected language: German
856
+ // "die" is -3 in the English lexicon.
857
+ const foreignStopWords = LANGUAGE_STOP_WORDS[options.detectedLanguage];
858
+ const scores = calculation
859
+ .map(entry => Object.entries(entry)[0])
860
+ .filter(([word]) => !foreignStopWords?.has(word))
861
+ .map(([, score]) => score);
862
+
863
+ const totalSentimentWords = scores.length;
864
864
 
865
- const totalSentimentWords = positiveCount + negativeCount;
866
-
867
865
  if (totalSentimentWords === 0) {
868
866
  // The lexicon is English. Finding none of its words in German or
869
867
  // Japanese text is not evidence of neutrality, so say the measure does
@@ -879,22 +877,25 @@ export class ContentAnalyzer {
879
877
  notApplicable: 'sentiment-lexicon-is-english-only'
880
878
  };
881
879
  }
880
+ // No opinion words at all: neutral, but with no evidence behind it.
882
881
  return {
883
882
  polarity: 0,
884
883
  subjectivity: 0,
885
884
  label: 'neutral',
886
- confidence: 0.5
885
+ confidence: 0
887
886
  };
888
887
  }
889
888
 
890
- const polarity = (positiveCount - negativeCount) / Math.max(words.length, 1);
891
- const subjectivity = totalSentimentWords / Math.max(words.length, 1);
892
-
889
+ // Net lexicon score per word, clamped to -1..1.
890
+ const net = scores.reduce((sum, score) => sum + score, 0);
891
+ const polarity = Math.max(-1, Math.min(1, net / Math.max(tokens.length, 1)));
892
+ const subjectivity = totalSentimentWords / Math.max(tokens.length, 1);
893
+
893
894
  let label = 'neutral';
894
- if (polarity > 0.1) label = 'positive';
895
- else if (polarity < -0.1) label = 'negative';
895
+ if (polarity > 0.05) label = 'positive';
896
+ else if (polarity < -0.05) label = 'negative';
896
897
 
897
- const confidence = Math.min(1, totalSentimentWords / 10);
898
+ const confidence = Math.min(1, totalSentimentWords / 5);
898
899
 
899
900
  return {
900
901
  polarity: Math.round(polarity * 100) / 100,