crawlforge-mcp-server 6.21.1 → 6.22.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 (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 +141 -19
  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 +48 -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
@@ -63,6 +63,35 @@ 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
+
71
+ /**
72
+ * The statements in one extracted key point. A key point is a sentence as the
73
+ * splitter found it, so a heading or list item above it arrives in the same
74
+ * string ("Built-in encryption\nQUIC was built with security in mind.").
75
+ * Each line becomes its own statement, except a line starting in lowercase,
76
+ * which continues the one before it (wrapped text). List and citation markers
77
+ * are removed. Heading-only lines are left for isAdmissibleClaim to reject.
78
+ *
79
+ * @param {string} point
80
+ * @returns {string[]}
81
+ */
82
+ export function claimStatements(point) {
83
+ const blocks = [];
84
+ for (const raw of String(point).split('\n')) {
85
+ const line = raw.trim();
86
+ if (!line) continue;
87
+ if (blocks.length > 0 && /^[a-z]/.test(line)) blocks[blocks.length - 1] += ` ${line}`;
88
+ else blocks.push(line);
89
+ }
90
+ return blocks
91
+ .map(block => block.replace(LIST_MARKER, '').replace(CITATION_MARKER, '').replace(/\s+/g, ' ').trim())
92
+ .filter(Boolean);
93
+ }
94
+
66
95
  /**
67
96
  * Page text for the plain-fetch fallback, one line per block element. The
68
97
  * regex tag-strip it replaces ran every block together on one line.
@@ -1384,7 +1413,8 @@ export class ResearchOrchestrator extends EventEmitter {
1384
1413
  const keyPoints = summary.keypoints || summary.keyPoints || [];
1385
1414
  if (keyPoints.length > 0) {
1386
1415
  const relevance = this.sourceRelevance(source);
1387
- keyPoints.forEach((point, index) => {
1416
+ let claimIndex = 0;
1417
+ keyPoints.forEach((keyPoint, index) => claimStatements(keyPoint).forEach(point => {
1388
1418
  const credibility = source.overallCredibility || 0.65;
1389
1419
  // A recommendation is not evidence. Two routes to the same flag: a
1390
1420
  // page promoting itself, and — whoever published it — a claim whose
@@ -1392,7 +1422,7 @@ export class ResearchOrchestrator extends EventEmitter {
1392
1422
  const promotional = isVendorSelfPromotion(point, source.link) ||
1393
1423
  isProductRecommendation(point);
1394
1424
  claims.push({
1395
- id: `${source.link}_claim_${index}`,
1425
+ id: `${source.link}_claim_${claimIndex++}`,
1396
1426
  claim: point,
1397
1427
  source: source.link,
1398
1428
  sourceTitle: source.title,
@@ -1402,7 +1432,7 @@ export class ResearchOrchestrator extends EventEmitter {
1402
1432
  context: summary.supporting?.[index] || '',
1403
1433
  extractedAt: new Date().toISOString()
1404
1434
  });
1405
- });
1435
+ }));
1406
1436
  }
1407
1437
  } catch (error) {
1408
1438
  this.logger.warn('Claim extraction failed', {
@@ -1464,31 +1494,33 @@ export class ResearchOrchestrator extends EventEmitter {
1464
1494
  /**
1465
1495
  * Keep only claims that can be research findings: prose about the topic, not
1466
1496
  * 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.
1497
+ * from" lines), and not a sentence the LLM scored as barely about the topic.
1498
+ * Those two gates fall back to the previous claim set rather than returning
1499
+ * nothing. A claim from an off-topic source — one the relevance analysis
1500
+ * scored below MIN_CLAIM_RELEVANCE — is always dropped: when every source is
1501
+ * off-topic, no findings is the honest result (R25 reported such claims as
1502
+ * findings through the fallback). A source with no score is kept.
1471
1503
  */
1472
1504
  admitClaims(claims) {
1473
1505
  if (claims.length === 0) return claims;
1474
1506
 
1475
- const substantive = claims.filter(claim => isAdmissibleClaim(claim.claim));
1476
- const relevant = substantive.filter(
1507
+ const relevant = claims.filter(
1477
1508
  claim => typeof claim.relevance !== 'number' || claim.relevance >= MIN_CLAIM_RELEVANCE
1478
1509
  );
1479
- const onTopic = relevant.filter(
1510
+ const substantive = relevant.filter(claim => isAdmissibleClaim(claim.claim));
1511
+ const onTopic = substantive.filter(
1480
1512
  claim => typeof claim.topicRelevance !== 'number' ||
1481
1513
  claim.topicRelevance >= MIN_CLAIM_TOPIC_RELEVANCE
1482
1514
  );
1483
1515
 
1484
1516
  const admitted = onTopic.length > 0
1485
1517
  ? onTopic
1486
- : (relevant.length > 0 ? relevant : (substantive.length > 0 ? substantive : claims));
1518
+ : (substantive.length > 0 ? substantive : relevant);
1487
1519
 
1488
1520
  this.logger.debug('Claim admission', {
1489
1521
  candidates: claims.length,
1490
- substantive: substantive.length,
1491
1522
  relevant: relevant.length,
1523
+ substantive: substantive.length,
1492
1524
  onTopic: onTopic.length,
1493
1525
  admitted: admitted.length
1494
1526
  });
@@ -2007,8 +2039,10 @@ export class ResearchOrchestrator extends EventEmitter {
2007
2039
  findings.push({
2008
2040
  finding: claim.claim,
2009
2041
  supportingClaims: group.claims.length,
2042
+ supportingSources: group.sourceCount,
2010
2043
  credibility: group.avgCredibility,
2011
- sources: group.claims.map(c => c.source),
2044
+ // One entry per source: a group holds several claims from one page.
2045
+ sources: [...new Set(group.claims.map(c => c.source))],
2012
2046
  ...(claim.promotional ? { promotional: true } : {}),
2013
2047
  ...(typeof claim.topicRelevance === 'number'
2014
2048
  ? { topicRelevance: claim.topicRelevance }
@@ -2032,6 +2066,7 @@ export class ResearchOrchestrator extends EventEmitter {
2032
2066
  title: source.title,
2033
2067
  url: source.link,
2034
2068
  credibility: source.overallCredibility,
2069
+ relevance: this.sourceRelevance(source),
2035
2070
  evidence: source.extractedContent?.substring(0, 300) + '...'
2036
2071
  }))
2037
2072
  .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,
@@ -44,6 +44,10 @@ export class BFSCrawler {
44
44
  this.sessionContext = sessionContext;
45
45
 
46
46
  this.visited = new Map(); // normalized key -> the URL as fetched
47
+ // Normalized keys of the pages fetched, by where each fetch landed after
48
+ // redirects. Kept apart from visited so the page a redirect lands on is
49
+ // not fetched again, without spending a second max_pages slot on it.
50
+ this.landed = new Set();
47
51
  this.results = [];
48
52
  this.errors = [];
49
53
  this.filterDecisions = []; // Track filtering decisions for analysis
@@ -153,7 +157,7 @@ export class BFSCrawler {
153
157
 
154
158
  // Check if already visited
155
159
  const normalizedUrl = normalizeUrl(url);
156
- if (this.visited.has(normalizedUrl)) {
160
+ if (this.visited.has(normalizedUrl) || this.landed.has(normalizedUrl)) {
157
161
  return;
158
162
  }
159
163
  // The form that goes on the wire: a bare origin gains its root "/",
@@ -215,7 +219,7 @@ export class BFSCrawler {
215
219
  // top of this task ran before the awaited robots lookup, so concurrent
216
220
  // queue tasks may have filled the budget (or claimed this URL) since.
217
221
  // This block is synchronous, so the cap is exact.
218
- if (this.visited.size >= this.maxPages || this.visited.has(normalizedUrl)) {
222
+ if (this.visited.size >= this.maxPages || this.visited.has(normalizedUrl) || this.landed.has(normalizedUrl)) {
219
223
  return;
220
224
  }
221
225
  this.visited.set(normalizedUrl, url);
@@ -263,17 +267,41 @@ export class BFSCrawler {
263
267
  if (cacheKey) await this.cache.set(cacheKey, pageData);
264
268
  }
265
269
 
270
+ // Links resolve against where the fetch landed, not the URL asked for:
271
+ // docs.python.org answers /3/tutorial with a redirect to /3/tutorial/,
272
+ // and appetite.html resolved against the first is /3/appetite.html, a
273
+ // 404 (R25). A seed that lands on another origin (http -> https, apex
274
+ // -> www) moves the crawl's origin with it, or every link it holds
275
+ // would read as external.
276
+ const pageUrl = pageData.finalUrl || url;
277
+ const landedKey = normalizeUrl(pageUrl);
278
+ if (isSeed && landedKey !== normalizedUrl) this.baseUrl = new URL(landedKey);
279
+ // Another link (one that redirects here, or this URL itself) reached the
280
+ // same page first: this copy is a duplicate.
281
+ if (this.landed.has(landedKey)) return;
282
+ this.landed.add(landedKey);
283
+
266
284
  // Process links for analysis
267
285
  if (this.enableLinkAnalysis && this.linkAnalyzer && pageData.links) {
268
286
  // Parse the page once and reuse for every link; re-parsing per link is
269
287
  // O(links × page size) and starves the event loop on link-dense pages.
270
288
  const $page = pageData.originalHtml ? load(pageData.originalHtml) : null;
271
289
  const pageBodyText = $page ? $page('body').text() : '';
290
+ // Index the anchors once by raw href, first one winning: a per-link
291
+ // `a[href="..."]` query scanned the whole page for every link (16,766
292
+ // scans of the 7.3 MB python changelog, R25) and blocked other calls.
293
+ const anchorsByHref = new Map();
294
+ if ($page) {
295
+ $page('a[href]').each((_, element) => {
296
+ const href = $page(element).attr('href');
297
+ if (!anchorsByHref.has(href)) anchorsByHref.set(href, element);
298
+ });
299
+ }
272
300
  for (const link of pageData.links) {
273
- const absoluteUrl = this.resolveUrl(link, url);
301
+ const absoluteUrl = this.resolveUrl(link, pageUrl);
274
302
  if (absoluteUrl) {
275
303
  // Extract anchor text and context from link
276
- const linkMetadata = this.extractLinkMetadata(link, $page, pageBodyText);
304
+ const linkMetadata = this.extractLinkMetadata(link, $page, pageBodyText, anchorsByHref);
277
305
  this.linkAnalyzer.addLink(normalizedUrl, normalizeUrl(absoluteUrl), linkMetadata);
278
306
  }
279
307
  }
@@ -301,8 +329,9 @@ export class BFSCrawler {
301
329
  for (const link of pageData.links) {
302
330
  if (this.visited.size >= this.maxPages) break;
303
331
 
304
- const absoluteUrl = this.resolveUrl(link, url);
305
- if (absoluteUrl && !this.visited.has(normalizeUrl(absoluteUrl))) {
332
+ const absoluteUrl = this.resolveUrl(link, pageUrl);
333
+ const key = absoluteUrl && normalizeUrl(absoluteUrl);
334
+ if (key && !this.visited.has(key) && !this.landed.has(key)) {
306
335
  // Not awaited: this task already holds a queue slot, so awaiting a
307
336
  // child would keep that slot pinned for the rest of the recursive
308
337
  // crawl (starving other tasks when concurrency <= depth, and making
@@ -395,7 +424,9 @@ export class BFSCrawler {
395
424
  // readBody decodes with the body's real charset — response.text() is
396
425
  // UTF-8-only and mangled ISO-8859-1 pages ("portugu�s" on lua.org).
397
426
  const html = await readBody(response);
398
- return this.parsePage(html, url);
427
+ // response.url is where the redirects ended; links resolve against it.
428
+ const finalUrl = response.url || url;
429
+ return { ...this.parsePage(html, finalUrl), finalUrl };
399
430
  } catch (error) {
400
431
  clearTimeout(timeoutId);
401
432
  throw error;
@@ -656,18 +687,21 @@ export class BFSCrawler {
656
687
  * @param {string} href - The href attribute value
657
688
  * @param {Object} $ - Cheerio document for the page (parsed once per page)
658
689
  * @param {string} bodyText - Pre-computed body text of the page
690
+ * @param {Map} anchorsByHref - Raw href -> first <a> element with it
659
691
  * @returns {Object} Link metadata
660
692
  */
661
- extractLinkMetadata(href, $, bodyText = '') {
693
+ extractLinkMetadata(href, $, bodyText = '', anchorsByHref) {
662
694
  if (!$) return {};
663
695
 
664
696
  try {
665
- const linkElement = $(`a[href="${href}"]`).first();
697
+ const element = anchorsByHref.get(href);
666
698
 
667
- if (linkElement.length === 0) {
699
+ if (!element) {
668
700
  return { href };
669
701
  }
670
702
 
703
+ const linkElement = $(element);
704
+
671
705
  const anchorText = linkElement.text().trim();
672
706
  const title = linkElement.attr('title');
673
707
  const rel = linkElement.attr('rel');
@@ -968,6 +1002,10 @@ export class BFSCrawler {
968
1002
  const segments = pathname.split('/').filter(s => s);
969
1003
 
970
1004
  if (segments.length === 0) return '/';
1005
+ // A file (bugs.html) is a page, not a directory: no trailing "/" or "/...".
1006
+ if (segments.length <= 2 && /.\.[a-z0-9]+$/i.test(segments[segments.length - 1])) {
1007
+ return `/${segments.join('/')}`;
1008
+ }
971
1009
  if (segments.length === 1) return `/${segments[0]}/`;
972
1010
 
973
1011
  // Return first two segments as pattern
@@ -8,7 +8,7 @@ import { JSDOM } from 'jsdom';
8
8
  import * as cheerio from 'cheerio';
9
9
  import { z } from 'zod';
10
10
  import { ContentQualityAssessor } from '../../utils/contentUtils.js';
11
- import { recoverDroppedTables } from '../../tools/scrape/_mainContent.js';
11
+ import { recoverDroppedTables, unwrapMediaWikiHeadings, tooLargeForReadability } from '../../tools/scrape/_mainContent.js';
12
12
  import { pageTitle } from '../../utils/pageTitle.js';
13
13
  import { flattenText } from 'crawlforge-extractors';
14
14
 
@@ -73,7 +73,9 @@ export class ContentProcessor {
73
73
  const document = dom.window.document;
74
74
 
75
75
  // Extract main content using Mozilla Readability
76
- if (processingOptions.removeBoilerplate) {
76
+ // A page too large for Readability keeps no `readability` block (R25).
77
+ if (processingOptions.removeBoilerplate && !tooLargeForReadability(html)) {
78
+ unwrapMediaWikiHeadings(document);
77
79
  const reader = new Readability(document, {
78
80
  debug: false,
79
81
  maxElemsToDivide: 300,
@@ -1,4 +1,4 @@
1
- import PQueue from 'p-queue';
1
+ import PQueue, { TimeoutError } from 'p-queue';
2
2
 
3
3
  export class QueueManager {
4
4
  constructor(options = {}) {
@@ -46,7 +46,21 @@ export class QueueManager {
46
46
 
47
47
  async add(fn, options = {}) {
48
48
  const { priority = 0 } = options;
49
- return this.queue.add(fn, { priority });
49
+ try {
50
+ return await this.queue.add(fn, { priority });
51
+ } catch (error) {
52
+ // p-queue's message gives the queue state when the task STARTED, which
53
+ // reads like a queue problem. The task itself ran too long, often
54
+ // because synchronous work elsewhere held the event loop.
55
+ if (error instanceof TimeoutError) {
56
+ throw new Error(
57
+ `Task ran over its ${this.queue.timeout / 1000} s time budget; ` +
58
+ 'it may have been starved by other work running in this process.',
59
+ { cause: error }
60
+ );
61
+ }
62
+ throw error;
63
+ }
50
64
  }
51
65
 
52
66
  async addAll(tasks, options = {}) {