crawlforge-mcp-server 6.21.0 → 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.
- package/package.json +3 -2
- package/server.js +12 -12
- package/src/core/ActionExecutor.js +23 -4
- package/src/core/AgentOrchestrator.js +141 -19
- package/src/core/AuthManager.js +4 -1
- package/src/core/LLMsTxtAnalyzer.js +6 -7
- package/src/core/LocalizationManager.js +29 -4
- package/src/core/ResearchOrchestrator.js +48 -13
- package/src/core/SamplingClient.js +38 -8
- package/src/core/SnapshotManager.js +32 -13
- package/src/core/analysis/ContentAnalyzer.js +26 -25
- package/src/core/crawlers/BFSCrawler.js +48 -10
- package/src/core/processing/ContentProcessor.js +4 -2
- package/src/core/queue/QueueManager.js +16 -2
- package/src/resources/ResourceRegistry.js +19 -5
- package/src/schemas/toolOutputSchemas.js +23 -15
- package/src/server/fallbackHints.js +37 -7
- package/src/server/withAuth.js +27 -44
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +11 -14
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
- package/src/tools/advanced/BrowserSessionTool.js +11 -2
- package/src/tools/advanced/batchScrape/queue.js +4 -2
- package/src/tools/advanced/batchScrape/worker.js +50 -11
- package/src/tools/agent/agent.js +1 -1
- package/src/tools/basic/_fetch.js +2 -1
- package/src/tools/basic/extractMetadata.js +2 -1
- package/src/tools/basic/extractText.js +12 -8
- package/src/tools/crawl/mapSite.js +8 -0
- package/src/tools/extract/extractContent.js +2 -1
- package/src/tools/extract/extractStructured.js +4 -2
- package/src/tools/extract/extractWithLlm.js +8 -4
- package/src/tools/extract/processDocument.js +29 -1
- package/src/tools/extract/summarizeContent.js +5 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +6 -2
- package/src/tools/scrape/_mainContent.js +54 -2
- package/src/tools/scrape/unifiedScrape.js +94 -6
- package/src/tools/search/adapters/braveSearch.js +27 -5
- package/src/tools/search/providers/searxng.js +20 -3
- package/src/tools/search/ranking/ResultDeduplicator.js +51 -65
- package/src/tools/search/searchWeb.js +2 -2
- package/src/tools/tracking/trackChanges/differ.js +2 -1
- package/src/tools/tracking/trackChanges/index.js +6 -1
- package/src/tools/tracking/trackChanges/schema.js +15 -1
- package/src/utils/fetchError.js +47 -0
- package/src/utils/fetchLadder.js +5 -5
- package/src/utils/htmlToMarkdown.js +36 -2
- 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
|
-
|
|
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_${
|
|
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
|
|
1468
|
-
*
|
|
1469
|
-
*
|
|
1470
|
-
*
|
|
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
|
|
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
|
|
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
|
-
: (
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
|
|
849
|
-
|
|
850
|
-
//
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
|
885
|
+
confidence: 0
|
|
887
886
|
};
|
|
888
887
|
}
|
|
889
888
|
|
|
890
|
-
|
|
891
|
-
const
|
|
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.
|
|
895
|
-
else if (polarity < -0.
|
|
895
|
+
if (polarity > 0.05) label = 'positive';
|
|
896
|
+
else if (polarity < -0.05) label = 'negative';
|
|
896
897
|
|
|
897
|
-
const confidence = Math.min(1, totalSentimentWords /
|
|
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,
|
|
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,
|
|
305
|
-
|
|
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
|
-
|
|
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
|
|
697
|
+
const element = anchorsByHref.get(href);
|
|
666
698
|
|
|
667
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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 = {}) {
|