crawlforge-mcp-server 5.2.4 → 5.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -1
- package/package.json +2 -2
- package/server.js +2 -1
- package/src/core/AgentOrchestrator.js +59 -0
- package/src/core/ChangeTracker.js +62 -4
- package/src/core/ResearchOrchestrator.js +16 -6
- package/src/core/analysis/ContentAnalyzer.js +129 -8
- package/src/core/llm/LLMManager.js +26 -2
- package/src/core/processing/PDFProcessor.js +205 -0
- package/src/tools/basic/extractMetadata.js +5 -3
- package/src/tools/extract/processDocument.js +14 -0
- package/src/tools/extract/summarizeContent.js +49 -2
- package/src/tools/research/deepResearch.js +4 -0
- package/src/tools/search/adapters/searchProviderFactory.js +2 -1
- package/src/tools/search/redditSearch.js +4 -1
- package/src/tools/tracking/trackChanges/index.js +1 -0
package/CLAUDE.md
CHANGED
|
@@ -62,7 +62,7 @@ These guidelines are working if: fewer unnecessary changes in diffs, fewer rewri
|
|
|
62
62
|
|
|
63
63
|
CrawlForge MCP Server - A professional MCP (Model Context Protocol) server providing 28 web scraping, crawling, and content processing tools (5 inline + 23 advanced).
|
|
64
64
|
|
|
65
|
-
**Current Version:** 5.2.
|
|
65
|
+
**Current Version:** 5.2.6
|
|
66
66
|
|
|
67
67
|
## Development Commands
|
|
68
68
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.2.
|
|
3
|
+
"version": "5.2.6",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 28 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
|
|
6
6
|
"main": "server.js",
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
"cheerio": "^1.1.2",
|
|
114
114
|
"commander": "^14.0.3",
|
|
115
115
|
"compromise": "^14.14.4",
|
|
116
|
-
"crawlforge-extractors": "^1.2.
|
|
116
|
+
"crawlforge-extractors": "^1.2.2",
|
|
117
117
|
"diff": "^9.0.0",
|
|
118
118
|
"dotenv": "^17.2.1",
|
|
119
119
|
"franc": "^6.2.0",
|
package/server.js
CHANGED
|
@@ -100,7 +100,7 @@ const taskStore = createTaskStore({ logger });
|
|
|
100
100
|
// Create the server
|
|
101
101
|
const server = new McpServer({
|
|
102
102
|
name: "crawlforge",
|
|
103
|
-
version: "5.2.
|
|
103
|
+
version: "5.2.6",
|
|
104
104
|
description: "Production-ready MCP server with 28 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
105
105
|
homepage: "https://www.crawlforge.dev",
|
|
106
106
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -191,6 +191,7 @@ const serpRankTool = new SerpRankTool();
|
|
|
191
191
|
// provider search_web uses, then reads those posts from the archive.
|
|
192
192
|
const redditSearchTool = new RedditSearchTool({
|
|
193
193
|
searchApiKey: searchWebToolConfig.apiKey,
|
|
194
|
+
searchApiBaseUrl: searchWebToolConfig.apiBaseUrl,
|
|
194
195
|
});
|
|
195
196
|
const crawlDeepTool = new CrawlDeepTool(getToolConfig('crawl_deep'));
|
|
196
197
|
const mapSiteTool = new MapSiteTool(getToolConfig('map_site'));
|
|
@@ -44,6 +44,21 @@ function truncate(text, maxChars = 8000) {
|
|
|
44
44
|
return text.slice(0, maxChars) + '\n[...truncated]';
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Current-state task gate: prompts about the live "now" ("right now",
|
|
49
|
+
* "currently", "today", "latest", "#1 … now") go stale through search alone —
|
|
50
|
+
* dated articles rank for the task's words while the live page does not
|
|
51
|
+
* (live repro 2026-08-26: "#1 story on Hacker News right now" answered from
|
|
52
|
+
* a January thread; news.ycombinator.com was never fetched). Detected
|
|
53
|
+
* deterministically here (never LLM-trusted) so PLAN steers search toward
|
|
54
|
+
* the live entity page and SHAPE answers from it, not from dated results.
|
|
55
|
+
*/
|
|
56
|
+
const CURRENT_STATE_RE = /\b(right now|currently|today|tonight|at the moment|as of (now|today)|latest|this (week|month|morning))\b|#\d+[^.?!]*\bnow\b/i;
|
|
57
|
+
|
|
58
|
+
export function isCurrentStateTask(prompt) {
|
|
59
|
+
return CURRENT_STATE_RE.test(prompt || '');
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
// ── Orchestrator ──────────────────────────────────────────────────────────────
|
|
48
63
|
|
|
49
64
|
export class AgentOrchestrator {
|
|
@@ -172,10 +187,17 @@ export class AgentOrchestrator {
|
|
|
172
187
|
}
|
|
173
188
|
|
|
174
189
|
// ── PLAN ──────────────────────────────────────────────────────────────────
|
|
190
|
+
const currentState = isCurrentStateTask(prompt);
|
|
175
191
|
let searchQueries = [prompt]; // fallback: use raw prompt as query
|
|
176
192
|
try {
|
|
177
193
|
const planPrompt =
|
|
178
194
|
`Decompose this research task into 1-3 concise web search queries. ` +
|
|
195
|
+
// Current-state tasks: a query made of the task's words surfaces dated
|
|
196
|
+
// articles ABOUT the topic; the bare entity name surfaces the live
|
|
197
|
+
// official page as the top result, which GATHER then prioritizes.
|
|
198
|
+
(currentState
|
|
199
|
+
? `The task asks about a CURRENT live state: the FIRST query must be ONLY the name of the site or thing whose current state is asked, nothing else. `
|
|
200
|
+
: '') +
|
|
179
201
|
`Output ONLY the queries, one per line, no preamble or numbering:\n\n${prompt}`;
|
|
180
202
|
const { text } = await this._getSamplingClient().complete(planPrompt, { maxTokens: 200 });
|
|
181
203
|
const lines = text.split('\n')
|
|
@@ -231,6 +253,38 @@ export class AgentOrchestrator {
|
|
|
231
253
|
} catch { /* search tool init failed */ }
|
|
232
254
|
}
|
|
233
255
|
|
|
256
|
+
// Current-state tasks: search results are LEADS, not answers — the answer
|
|
257
|
+
// must come from the authoritative live page. The raw top result is NOT a
|
|
258
|
+
// safe proxy (live retest 2026-08-26: CSE ranks thehackernews.com above
|
|
259
|
+
// news.ycombinator.com for "Hacker News"), so vote by domain across all
|
|
260
|
+
// results — PLAN's bare entity query makes the official site dominate —
|
|
261
|
+
// and put that domain's root (its live front page) first in the fetch
|
|
262
|
+
// queue and first at SHAPE time so synthesis answers from it, never from
|
|
263
|
+
// a dated article. A wrong root self-heals: it fails the relevance gate
|
|
264
|
+
// and never enters evidence.
|
|
265
|
+
if (currentState && searchResults.length > 0) {
|
|
266
|
+
const originCounts = new Map();
|
|
267
|
+
for (const s of searchResults) {
|
|
268
|
+
try {
|
|
269
|
+
const origin = new URL(s.url).origin;
|
|
270
|
+
originCounts.set(origin, (originCounts.get(origin) || 0) + 1);
|
|
271
|
+
} catch { /* unparsable result url */ }
|
|
272
|
+
}
|
|
273
|
+
let bestOrigin = null;
|
|
274
|
+
let bestCount = 0;
|
|
275
|
+
for (const [origin, count] of originCounts) {
|
|
276
|
+
// Strict > keeps the earliest-seen (top-ranked) origin on ties.
|
|
277
|
+
if (count > bestCount) { bestOrigin = origin; bestCount = count; }
|
|
278
|
+
}
|
|
279
|
+
if (bestOrigin) {
|
|
280
|
+
const liveRoot = `${bestOrigin}/`;
|
|
281
|
+
if (!priorityUrls.includes(liveRoot)) priorityUrls.push(liveRoot);
|
|
282
|
+
const qi = urlQueue.findIndex(u => u === liveRoot || `${u}/` === liveRoot);
|
|
283
|
+
if (qi > 0) urlQueue.splice(qi, 1);
|
|
284
|
+
if (qi !== 0) urlQueue.unshift(liveRoot);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
234
288
|
// ── ACT loop ──────────────────────────────────────────────────────────────
|
|
235
289
|
// urlsFetched (capUrls) and step (capSteps) are deliberately decoupled:
|
|
236
290
|
// urlsFetched counts every fetch attempt (gates how many URLs we try),
|
|
@@ -336,6 +390,11 @@ export class AgentOrchestrator {
|
|
|
336
390
|
`Task: ${prompt}\n\n` +
|
|
337
391
|
`${combinedText}\n\n` +
|
|
338
392
|
`Rules:\n` +
|
|
393
|
+
// Short and imperative on purpose: the executing model is a small
|
|
394
|
+
// local one (gemma3:4b-class) and ignores hedged phrasing.
|
|
395
|
+
(currentState
|
|
396
|
+
? `- The task asks about the CURRENT state. Answer from the FIRST source below (the live page). NEVER present older or dated content as the current answer.\n`
|
|
397
|
+
: '') +
|
|
339
398
|
`- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
|
|
340
399
|
`- Read the sources carefully before concluding anything is missing from them.\n` +
|
|
341
400
|
`- Cite the exact source URL(s) you used.\n` +
|
|
@@ -312,14 +312,22 @@ export class ChangeTracker extends EventEmitter {
|
|
|
312
312
|
|
|
313
313
|
this.emit('changeDetected', changeRecord);
|
|
314
314
|
|
|
315
|
+
const ignoredOptions = this.findIgnoredCompareOptions(options, baseline.options);
|
|
316
|
+
|
|
315
317
|
return {
|
|
316
318
|
hasChanges: significance !== 'none',
|
|
317
319
|
significance,
|
|
318
320
|
changeType: changeRecord.changeType,
|
|
319
|
-
summary: this.generateChangeSummary(changeAnalysis),
|
|
321
|
+
summary: this.generateChangeSummary(changeAnalysis, significance),
|
|
320
322
|
details: changeAnalysis,
|
|
321
323
|
metrics: changeRecord.metrics,
|
|
322
|
-
recommendations: this.generateChangeRecommendations(changeRecord)
|
|
324
|
+
recommendations: this.generateChangeRecommendations(changeRecord),
|
|
325
|
+
...(ignoredOptions.length ? {
|
|
326
|
+
warnings: [
|
|
327
|
+
`${ignoredOptions.join(', ')} passed to this compare ${ignoredOptions.length === 1 ? 'was' : 'were'} ignored — ` +
|
|
328
|
+
`the baseline's options are applied to both sides of the diff. Recreate the baseline to change them.`
|
|
329
|
+
]
|
|
330
|
+
} : {})
|
|
323
331
|
};
|
|
324
332
|
|
|
325
333
|
} catch (error) {
|
|
@@ -328,6 +336,27 @@ export class ChangeTracker extends EventEmitter {
|
|
|
328
336
|
}
|
|
329
337
|
}
|
|
330
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Report analysis options supplied at compare time that differ from the
|
|
341
|
+
* baseline's and were therefore not applied.
|
|
342
|
+
*
|
|
343
|
+
* Ignoring them is deliberate — both sides of a diff have to be analyzed
|
|
344
|
+
* identically, and once customSelectors scoped the baseline it no longer
|
|
345
|
+
* holds the full document to re-scope. But doing it silently let a caller
|
|
346
|
+
* scope a compare and read the resulting whole-page churn as real change:
|
|
347
|
+
* the result is byte-identical to an unscoped run, with nothing saying so.
|
|
348
|
+
*
|
|
349
|
+
* @param {Object} callerOptions - trackingOptions passed to this compare
|
|
350
|
+
* @param {Object} baselineOptions - options stored with the baseline
|
|
351
|
+
* @returns {string[]} - names of the ignored options
|
|
352
|
+
*/
|
|
353
|
+
findIgnoredCompareOptions(callerOptions = {}, baselineOptions = {}) {
|
|
354
|
+
return ['granularity', 'customSelectors', 'excludeSelectors'].filter(key => {
|
|
355
|
+
if (callerOptions[key] === undefined) return false;
|
|
356
|
+
return JSON.stringify(callerOptions[key]) !== JSON.stringify(baselineOptions[key]);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
331
360
|
/**
|
|
332
361
|
* Analyze content structure and create hierarchical hashes
|
|
333
362
|
* @param {string} content - Content to analyze
|
|
@@ -701,6 +730,29 @@ export class ChangeTracker extends EventEmitter {
|
|
|
701
730
|
}
|
|
702
731
|
});
|
|
703
732
|
});
|
|
733
|
+
|
|
734
|
+
// Scoping to a tag outside that list (address, td, li, tr, dd) otherwise
|
|
735
|
+
// indexes ZERO elements: the scoped document is hashed, but nothing in it
|
|
736
|
+
// matches the allowlist, so every compare sees an empty element map and
|
|
737
|
+
// can never report an element-level change. Hash the scoped elements
|
|
738
|
+
// themselves for any tag the loop above does not already cover.
|
|
739
|
+
if (options.customSelectors?.length) {
|
|
740
|
+
const alreadyHashed = new Set(importantElements);
|
|
741
|
+
options.customSelectors.forEach((selector, selectorIndex) => {
|
|
742
|
+
$(selector).each((index, element) => {
|
|
743
|
+
const tag = element.tagName?.toLowerCase();
|
|
744
|
+
if (!tag || alreadyHashed.has(tag)) return;
|
|
745
|
+
|
|
746
|
+
const elementKey = `custom_${selectorIndex}_${index}`;
|
|
747
|
+
analysis.hashes.elements[elementKey] = this.hashContent($(element).html() || '');
|
|
748
|
+
|
|
749
|
+
if (options.trackAttributes) {
|
|
750
|
+
const attributes = element.attribs || {};
|
|
751
|
+
analysis.hashes.elements[`${elementKey}_attr`] = this.hashContent(JSON.stringify(attributes));
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
}
|
|
704
756
|
}
|
|
705
757
|
|
|
706
758
|
async analyzeTextLevel($, analysis, options) {
|
|
@@ -1160,7 +1212,7 @@ export class ChangeTracker extends EventEmitter {
|
|
|
1160
1212
|
return 'text_change';
|
|
1161
1213
|
}
|
|
1162
1214
|
|
|
1163
|
-
generateChangeSummary(changeAnalysis) {
|
|
1215
|
+
generateChangeSummary(changeAnalysis, significance) {
|
|
1164
1216
|
const { addedElements, removedElements, modifiedElements, similarity } = changeAnalysis;
|
|
1165
1217
|
|
|
1166
1218
|
const total = addedElements.length + removedElements.length + modifiedElements.length;
|
|
@@ -1171,7 +1223,13 @@ export class ChangeTracker extends EventEmitter {
|
|
|
1171
1223
|
added: addedElements.length,
|
|
1172
1224
|
removed: removedElements.length,
|
|
1173
1225
|
modified: modifiedElements.length,
|
|
1174
|
-
|
|
1226
|
+
// Sub-threshold text noise (a rotating session token, a base64 timestamp)
|
|
1227
|
+
// still lands in textChanges, so the description read "Text content
|
|
1228
|
+
// changed" on a compare that reported hasChanges:false and
|
|
1229
|
+
// totalChanges:0. Defer to the verdict the caller is given.
|
|
1230
|
+
changeDescription: significance === 'none'
|
|
1231
|
+
? 'No significant changes detected'
|
|
1232
|
+
: this.generateChangeDescription(changeAnalysis)
|
|
1175
1233
|
};
|
|
1176
1234
|
}
|
|
1177
1235
|
|
|
@@ -1515,12 +1515,22 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1515
1515
|
.filter(group => group.avgCredibility >= this.credibilityThreshold)
|
|
1516
1516
|
.sort((a, b) => b.consensusStrength - a.consensusStrength)
|
|
1517
1517
|
.slice(0, 10)
|
|
1518
|
-
.map(group =>
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1518
|
+
.map(group => {
|
|
1519
|
+
// Findings must be readable prose. Claims are extractive sentences
|
|
1520
|
+
// from the source content, so surface the most credible one verbatim
|
|
1521
|
+
// — joining the group's keywords here produced stopword-stripped
|
|
1522
|
+
// gibberish ("scraping server model context protocol server that...").
|
|
1523
|
+
const representative = group.claims.reduce(
|
|
1524
|
+
(best, c) => ((c.credibility || 0) > (best.credibility || 0) ? c : best),
|
|
1525
|
+
group.claims[0]
|
|
1526
|
+
);
|
|
1527
|
+
return {
|
|
1528
|
+
finding: representative.claim,
|
|
1529
|
+
supportingClaims: group.claims.length,
|
|
1530
|
+
credibility: group.avgCredibility,
|
|
1531
|
+
sources: group.claims.map(c => c.source)
|
|
1532
|
+
};
|
|
1533
|
+
});
|
|
1524
1534
|
}
|
|
1525
1535
|
|
|
1526
1536
|
compileSupportingEvidence(sources) {
|
|
@@ -195,6 +195,55 @@ export class ContentAnalyzer {
|
|
|
195
195
|
};
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Count CJK-script letters and their share of all letters. This is the
|
|
200
|
+
* script signal language detection uses; the tokenizer reuses it to decide
|
|
201
|
+
* when whitespace splitting cannot work.
|
|
202
|
+
* @param {string} text - Text to analyze
|
|
203
|
+
* @returns {Object} - { letters, han, kana, hangul, share }
|
|
204
|
+
*/
|
|
205
|
+
cjkScriptCounts(text) {
|
|
206
|
+
const letters = (text.match(/\p{L}/gu) || []).length;
|
|
207
|
+
const han = (text.match(/\p{Script=Han}/gu) || []).length;
|
|
208
|
+
const kana = (text.match(/[\p{Script=Hiragana}\p{Script=Katakana}]/gu) || []).length;
|
|
209
|
+
const hangul = (text.match(/\p{Script=Hangul}/gu) || []).length;
|
|
210
|
+
return {
|
|
211
|
+
letters,
|
|
212
|
+
han,
|
|
213
|
+
kana,
|
|
214
|
+
hangul,
|
|
215
|
+
share: letters > 0 ? (han + kana + hangul) / letters : 0
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* True when a meaningful share of the text is in a CJK script, i.e. words
|
|
221
|
+
* are not whitespace-delimited and must be segmented by dictionary.
|
|
222
|
+
* @param {string} text - Text to analyze
|
|
223
|
+
* @returns {boolean}
|
|
224
|
+
*/
|
|
225
|
+
isCjkText(text) {
|
|
226
|
+
return this.cjkScriptCounts(text).share >= 0.1;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Tokenize text into words with Intl.Segmenter (dictionary-based for CJK
|
|
231
|
+
* scripts, whitespace/boundary-based otherwise). Only word-like segments
|
|
232
|
+
* are returned — punctuation and whitespace segments are dropped.
|
|
233
|
+
* @param {string} text - Text to tokenize
|
|
234
|
+
* @returns {string[]} - Word tokens
|
|
235
|
+
*/
|
|
236
|
+
segmentWords(text) {
|
|
237
|
+
if (!this._wordSegmenter) {
|
|
238
|
+
this._wordSegmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
|
|
239
|
+
}
|
|
240
|
+
const words = [];
|
|
241
|
+
for (const seg of this._wordSegmenter.segment(text)) {
|
|
242
|
+
if (seg.isWordLike) words.push(seg.segment);
|
|
243
|
+
}
|
|
244
|
+
return words;
|
|
245
|
+
}
|
|
246
|
+
|
|
198
247
|
/**
|
|
199
248
|
* Analyze text content with multiple NLP techniques
|
|
200
249
|
* @param {Object} params - Analysis parameters
|
|
@@ -288,12 +337,9 @@ export class ContentAnalyzer {
|
|
|
288
337
|
// samples is detected as English. Those scripts never appear in
|
|
289
338
|
// Latin-script prose, so a meaningful share of them settles the question
|
|
290
339
|
// before trigram scoring gets a say.
|
|
291
|
-
const letters = (text
|
|
340
|
+
const { letters, han, kana, hangul, share } = this.cjkScriptCounts(text);
|
|
292
341
|
if (letters > 0) {
|
|
293
|
-
|
|
294
|
-
const kana = (text.match(/[\p{Script=Hiragana}\p{Script=Katakana}]/gu) || []).length;
|
|
295
|
-
const hangul = (text.match(/\p{Script=Hangul}/gu) || []).length;
|
|
296
|
-
if ((han + kana + hangul) / letters >= 0.1) {
|
|
342
|
+
if (share >= 0.1) {
|
|
297
343
|
const code = kana > 0 ? 'jpn' : hangul > han ? 'kor' : 'cmn';
|
|
298
344
|
return {
|
|
299
345
|
code,
|
|
@@ -532,8 +578,25 @@ export class ContentAnalyzer {
|
|
|
532
578
|
*/
|
|
533
579
|
async extractTopics(text, options = {}) {
|
|
534
580
|
try {
|
|
581
|
+
// compromise noun-phrase matching is English-only: on CJK text it emits
|
|
582
|
+
// whole multi-sentence runs as one "phrase". Use dictionary-segmented
|
|
583
|
+
// content words, ranked by RAKE-style relative salience like below.
|
|
584
|
+
if (this.isCjkText(text)) {
|
|
585
|
+
const termFreq = this.cjkContentWordFrequencies(text);
|
|
586
|
+
const maxFreq = Math.max(1, ...Object.values(termFreq));
|
|
587
|
+
return Object.entries(termFreq)
|
|
588
|
+
.map(([topic, frequency]) => ({
|
|
589
|
+
topic,
|
|
590
|
+
confidence: Math.round((frequency / maxFreq) * 100) / 100,
|
|
591
|
+
keywords: [topic]
|
|
592
|
+
}))
|
|
593
|
+
.filter(topic => topic.confidence >= options.minConfidence)
|
|
594
|
+
.sort((a, b) => b.confidence - a.confidence)
|
|
595
|
+
.slice(0, options.maxTopics);
|
|
596
|
+
}
|
|
597
|
+
|
|
535
598
|
const doc = nlp(text);
|
|
536
|
-
|
|
599
|
+
|
|
537
600
|
// Extract noun phrases as potential topics
|
|
538
601
|
const nounPhrases = doc.nouns().out('array');
|
|
539
602
|
const adjNounPhrases = doc.match('#Adjective+ #Noun+').out('array');
|
|
@@ -700,8 +763,24 @@ export class ContentAnalyzer {
|
|
|
700
763
|
*/
|
|
701
764
|
async extractKeywords(text, options = {}) {
|
|
702
765
|
try {
|
|
766
|
+
// compromise is English-only: on CJK text it returns whole multi-sentence
|
|
767
|
+
// runs as single "terms". Rank dictionary-segmented content words instead.
|
|
768
|
+
if (this.isCjkText(text)) {
|
|
769
|
+
const termFreq = this.cjkContentWordFrequencies(text);
|
|
770
|
+
const totalTerms = Object.values(termFreq).reduce((sum, freq) => sum + freq, 0);
|
|
771
|
+
return Object.entries(termFreq)
|
|
772
|
+
.map(([keyword, frequency]) => ({
|
|
773
|
+
keyword,
|
|
774
|
+
frequency,
|
|
775
|
+
relevance: totalTerms > 0 ? frequency / totalTerms : 0,
|
|
776
|
+
type: 'word'
|
|
777
|
+
}))
|
|
778
|
+
.sort((a, b) => b.relevance - a.relevance)
|
|
779
|
+
.slice(0, options.maxKeywords);
|
|
780
|
+
}
|
|
781
|
+
|
|
703
782
|
const doc = nlp(text);
|
|
704
|
-
|
|
783
|
+
|
|
705
784
|
// Extract different types of terms
|
|
706
785
|
const nouns = doc.nouns().out('array');
|
|
707
786
|
const verbs = doc.verbs().out('array');
|
|
@@ -860,7 +939,10 @@ export class ContentAnalyzer {
|
|
|
860
939
|
calculateStatistics(text) {
|
|
861
940
|
const characters = text.length;
|
|
862
941
|
const charactersNoSpaces = text.replace(/\s/g, '').length;
|
|
863
|
-
|
|
942
|
+
// CJK text has no whitespace between words — segment by dictionary instead
|
|
943
|
+
const words = this.isCjkText(text)
|
|
944
|
+
? this.segmentWords(text)
|
|
945
|
+
: text.split(/\s+/).filter(w => w.length > 0);
|
|
864
946
|
const sentences = splitSentences(text);
|
|
865
947
|
const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim().length > 0);
|
|
866
948
|
|
|
@@ -1034,6 +1116,45 @@ export class ContentAnalyzer {
|
|
|
1034
1116
|
return orgHeadedAlias.test(text);
|
|
1035
1117
|
}
|
|
1036
1118
|
|
|
1119
|
+
/**
|
|
1120
|
+
* Frequency table of content words for CJK text, built from dictionary
|
|
1121
|
+
* segmentation. Single-character CJK tokens are dropped (overwhelmingly
|
|
1122
|
+
* particles: 的, 是, 了…), as are short/stop-worded Latin tokens mixed in
|
|
1123
|
+
* and common two-character CJK function words.
|
|
1124
|
+
* @param {string} text - Text to analyze
|
|
1125
|
+
* @returns {Object} - Map of word -> frequency
|
|
1126
|
+
*/
|
|
1127
|
+
cjkContentWordFrequencies(text) {
|
|
1128
|
+
const freq = {};
|
|
1129
|
+
for (const raw of this.segmentWords(text)) {
|
|
1130
|
+
const word = raw.toLowerCase();
|
|
1131
|
+
const isCjkWord = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(word);
|
|
1132
|
+
const keep = isCjkWord
|
|
1133
|
+
? word.length >= 2 && !this.isCjkStopWord(word)
|
|
1134
|
+
: word.length > 2 && !this.isStopWord(word);
|
|
1135
|
+
if (keep) {
|
|
1136
|
+
freq[word] = (freq[word] || 0) + 1;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return freq;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Check if a segmented CJK word is a common function word
|
|
1144
|
+
* @param {string} word - Word to check
|
|
1145
|
+
* @returns {boolean} - True if stop word
|
|
1146
|
+
*/
|
|
1147
|
+
isCjkStopWord(word) {
|
|
1148
|
+
const cjkStopWords = [
|
|
1149
|
+
'也是', '就是', '我们', '你们', '他们', '她们', '它们', '这个', '那个',
|
|
1150
|
+
'这些', '那些', '一个', '一些', '以及', '或者', '但是', '因为', '所以',
|
|
1151
|
+
'如果', '没有', '可以', '已经', '通过', '由于', '对于', '其中', '并且',
|
|
1152
|
+
'而且', '虽然', '什么', '自己', '这里', '那里'
|
|
1153
|
+
];
|
|
1154
|
+
|
|
1155
|
+
return cjkStopWords.includes(word);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1037
1158
|
/**
|
|
1038
1159
|
* Check if word is a stop word
|
|
1039
1160
|
* @param {string} word - Word to check
|
|
@@ -320,13 +320,37 @@ ${findingsText}
|
|
|
320
320
|
Synthesize these findings into a comprehensive analysis:`;
|
|
321
321
|
|
|
322
322
|
try {
|
|
323
|
+
// Constrain the output shape (Ollama structured outputs; other providers
|
|
324
|
+
// ignore `format`). Small local models otherwise wrap the JSON in
|
|
325
|
+
// markdown fences, drift the key names, or emit an empty object — any of
|
|
326
|
+
// which blanked the synthesis users see.
|
|
327
|
+
const synthesisSchema = {
|
|
328
|
+
type: 'object',
|
|
329
|
+
properties: {
|
|
330
|
+
summary: { type: 'string' },
|
|
331
|
+
keyInsights: { type: 'array', items: { type: 'string' } },
|
|
332
|
+
themes: { type: 'array', items: { type: 'string' } },
|
|
333
|
+
confidence: { type: 'number' },
|
|
334
|
+
gaps: { type: 'array', items: { type: 'string' } },
|
|
335
|
+
recommendations: { type: 'array', items: { type: 'string' } }
|
|
336
|
+
},
|
|
337
|
+
required: ['summary', 'keyInsights', 'themes', 'confidence']
|
|
338
|
+
};
|
|
339
|
+
|
|
323
340
|
const response = await this.generateCompletion(prompt, {
|
|
324
341
|
systemPrompt,
|
|
325
342
|
maxTokens: 800,
|
|
326
|
-
temperature: 0.4
|
|
343
|
+
temperature: 0.4,
|
|
344
|
+
format: synthesisSchema
|
|
327
345
|
});
|
|
328
346
|
|
|
329
|
-
|
|
347
|
+
// Strip markdown code fences if present
|
|
348
|
+
const cleaned = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
|
|
349
|
+
const parsed = JSON.parse(cleaned);
|
|
350
|
+
if (!parsed || typeof parsed.summary !== 'string' || parsed.summary.length === 0) {
|
|
351
|
+
throw new Error('Synthesis response missing summary');
|
|
352
|
+
}
|
|
353
|
+
return parsed;
|
|
330
354
|
} catch (error) {
|
|
331
355
|
this.logger.warn('LLM synthesis failed, using fallback', { error: error.message });
|
|
332
356
|
return this.fallbackSynthesis(findings, topic);
|
|
@@ -16,6 +16,8 @@ const PDFProcessorSchema = z.object({
|
|
|
16
16
|
options: z.object({
|
|
17
17
|
extractMetadata: z.boolean().default(true),
|
|
18
18
|
extractText: z.boolean().default(true),
|
|
19
|
+
// Detect grid tables from the text layer (positioned text items).
|
|
20
|
+
extractTables: z.boolean().default(false),
|
|
19
21
|
maxPages: z.number().min(1).max(1000).default(100),
|
|
20
22
|
// For decrypting password-protected PDFs (pdf-parse 2.x / pdfjs-dist honors this).
|
|
21
23
|
password: z.string().optional(),
|
|
@@ -36,6 +38,10 @@ const PDFResult = z.object({
|
|
|
36
38
|
source: z.string(),
|
|
37
39
|
sourceType: z.string(),
|
|
38
40
|
text: z.string().optional(),
|
|
41
|
+
tables: z.array(z.object({
|
|
42
|
+
page: z.number(),
|
|
43
|
+
rows: z.array(z.array(z.string()))
|
|
44
|
+
})).optional(),
|
|
39
45
|
metadata: z.object({
|
|
40
46
|
title: z.string().nullable(),
|
|
41
47
|
author: z.string().nullable(),
|
|
@@ -161,6 +167,16 @@ export class PDFProcessor {
|
|
|
161
167
|
}
|
|
162
168
|
}
|
|
163
169
|
|
|
170
|
+
// Extract tables from the text layer. getInfo() above already loaded
|
|
171
|
+
// the document, so pdf-parse's parser.doc (a plain property in the
|
|
172
|
+
// compiled build) holds the pdfjs PDFDocumentProxy — reuse it instead
|
|
173
|
+
// of parsing the buffer a second time.
|
|
174
|
+
if (processingOptions.extractTables) {
|
|
175
|
+
const tableStart = pageRange?.start || 1;
|
|
176
|
+
const tableEnd = Math.min(pageRange?.end || processingOptions.maxPages, totalPages);
|
|
177
|
+
result.tables = await this.extractTablesFromDocument(parser.doc, tableStart, tableEnd);
|
|
178
|
+
}
|
|
179
|
+
|
|
164
180
|
// Extract metadata
|
|
165
181
|
if (processingOptions.extractMetadata) {
|
|
166
182
|
result.metadata = this.extractPDFMetadata(info);
|
|
@@ -435,6 +451,195 @@ export class PDFProcessor {
|
|
|
435
451
|
.trim();
|
|
436
452
|
}
|
|
437
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Extract tables from a loaded pdfjs document's text layer.
|
|
456
|
+
* Walks the requested pages (1-based, inclusive), reads positioned text
|
|
457
|
+
* items via getTextContent(), and runs layout-based table detection on each.
|
|
458
|
+
* @param {Object} doc - pdfjs PDFDocumentProxy (pdf-parse's parser.doc)
|
|
459
|
+
* @param {number} startPage - First page to scan
|
|
460
|
+
* @param {number} endPage - Last page to scan
|
|
461
|
+
* @returns {Promise<Array>} - Detected tables as {page, rows: [[cell, ...], ...]}
|
|
462
|
+
*/
|
|
463
|
+
async extractTablesFromDocument(doc, startPage, endPage) {
|
|
464
|
+
const tables = [];
|
|
465
|
+
const last = Math.min(endPage, doc.numPages);
|
|
466
|
+
for (let pageNum = Math.max(1, startPage); pageNum <= last; pageNum++) {
|
|
467
|
+
const page = await doc.getPage(pageNum);
|
|
468
|
+
const textContent = await page.getTextContent();
|
|
469
|
+
tables.push(...this.detectTablesFromTextItems(textContent.items, pageNum));
|
|
470
|
+
}
|
|
471
|
+
return tables;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Detect grid tables in one page's positioned text items (pdfjs
|
|
476
|
+
* getTextContent() shape: {str, transform, width, height}; transform[4]/[5]
|
|
477
|
+
* carry x/y). Pure layout analysis:
|
|
478
|
+
* 1. cluster items into rows by y proximity (sub/superscripts sit ~0.35em
|
|
479
|
+
* off the baseline, so they fold into their base row),
|
|
480
|
+
* 2. split each row into cell segments wherever the x-gap exceeds a
|
|
481
|
+
* threshold well above word spacing,
|
|
482
|
+
* 3. take runs of >= 3 consecutive multi-cell rows and derive column
|
|
483
|
+
* boundaries from x-regions that almost no row's text crosses.
|
|
484
|
+
* Ordinary paragraphs yield single-segment rows (word gaps stay under the
|
|
485
|
+
* threshold), so they never form a run; coincidental misaligned gaps leave
|
|
486
|
+
* no shared low-coverage x-region, so no columns emerge.
|
|
487
|
+
* @param {Array} items - pdfjs text items for one page
|
|
488
|
+
* @param {number} pageNumber - 1-based page number for the emitted tables
|
|
489
|
+
* @returns {Array} - Tables as {page, rows: [[cell, ...], ...]}
|
|
490
|
+
*/
|
|
491
|
+
detectTablesFromTextItems(items, pageNumber) {
|
|
492
|
+
const texts = (items || [])
|
|
493
|
+
.filter(item => item.str && item.str.trim().length > 0 && Array.isArray(item.transform))
|
|
494
|
+
.map(item => ({
|
|
495
|
+
str: item.str,
|
|
496
|
+
x: item.transform[4],
|
|
497
|
+
y: item.transform[5],
|
|
498
|
+
width: item.width || 0,
|
|
499
|
+
height: item.height || 10
|
|
500
|
+
}));
|
|
501
|
+
if (texts.length === 0) {
|
|
502
|
+
return [];
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const heights = texts.map(t => t.height).sort((a, b) => a - b);
|
|
506
|
+
const medianHeight = heights[Math.floor(heights.length / 2)] || 10;
|
|
507
|
+
const yTolerance = Math.max(2, medianHeight * 0.4);
|
|
508
|
+
const gapThreshold = Math.max(3, medianHeight * 0.5);
|
|
509
|
+
|
|
510
|
+
// 1. Cluster items into rows, top to bottom.
|
|
511
|
+
texts.sort((a, b) => b.y - a.y || a.x - b.x);
|
|
512
|
+
const rows = [];
|
|
513
|
+
let currentRow = null;
|
|
514
|
+
let previousY;
|
|
515
|
+
for (const t of texts) {
|
|
516
|
+
if (currentRow && previousY - t.y <= yTolerance) {
|
|
517
|
+
currentRow.items.push(t);
|
|
518
|
+
} else {
|
|
519
|
+
currentRow = { y: t.y, items: [t] };
|
|
520
|
+
rows.push(currentRow);
|
|
521
|
+
}
|
|
522
|
+
previousY = t.y;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// 2. Split each row into cell segments on x-gaps. PDF text items are often
|
|
526
|
+
// fragments ("1", ".", "4", "·", "10"), so fragments closer than the gap
|
|
527
|
+
// threshold join one segment, with a space when they don't visually touch.
|
|
528
|
+
const spaceGap = medianHeight * 0.12;
|
|
529
|
+
const segmentedRows = rows.map(row => {
|
|
530
|
+
const sorted = [...row.items].sort((a, b) => a.x - b.x);
|
|
531
|
+
const segments = [];
|
|
532
|
+
let segment = null;
|
|
533
|
+
for (const t of sorted) {
|
|
534
|
+
if (segment && t.x - segment.xEnd < gapThreshold) {
|
|
535
|
+
if (t.x - segment.xEnd > spaceGap) {
|
|
536
|
+
segment.text += ' ';
|
|
537
|
+
}
|
|
538
|
+
segment.text += t.str;
|
|
539
|
+
segment.xEnd = Math.max(segment.xEnd, t.x + t.width);
|
|
540
|
+
} else {
|
|
541
|
+
segment = { xStart: t.x, xEnd: t.x + t.width, text: t.str };
|
|
542
|
+
segments.push(segment);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return { y: row.y, segments };
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// 3. Collect runs of consecutive multi-cell rows with table-like spacing.
|
|
549
|
+
const maxRowGap = medianHeight * 2.5;
|
|
550
|
+
const runs = [];
|
|
551
|
+
let run = null;
|
|
552
|
+
for (const row of segmentedRows) {
|
|
553
|
+
if (row.segments.length >= 2) {
|
|
554
|
+
const previous = run && run[run.length - 1];
|
|
555
|
+
if (previous && previous.y - row.y <= maxRowGap) {
|
|
556
|
+
run.push(row);
|
|
557
|
+
} else {
|
|
558
|
+
run = [row];
|
|
559
|
+
runs.push(run);
|
|
560
|
+
}
|
|
561
|
+
} else {
|
|
562
|
+
run = null;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const tables = [];
|
|
567
|
+
for (const runRows of runs) {
|
|
568
|
+
if (runRows.length < 3) {
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const tableRows = this.buildTableFromRun(runRows, gapThreshold);
|
|
572
|
+
if (tableRows) {
|
|
573
|
+
tables.push({ page: pageNumber, rows: tableRows });
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return tables;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Turn a run of multi-cell rows into a rows-of-cells grid, or null when the
|
|
581
|
+
* rows don't align into at least two shared columns.
|
|
582
|
+
* Column separators are x-regions at least gapThreshold wide that at most
|
|
583
|
+
* ~20% of the rows' text crosses — a lone spanning header can't merge two
|
|
584
|
+
* otherwise-separate columns, while misaligned prose yields no separator at
|
|
585
|
+
* all (the genuine-alignment requirement).
|
|
586
|
+
* @param {Array} runRows - Rows of {y, segments: [{xStart, xEnd, text}]}
|
|
587
|
+
* @param {number} gapThreshold - Minimum column-separator width
|
|
588
|
+
* @returns {Array|null} - Rows as arrays of cell strings, or null
|
|
589
|
+
*/
|
|
590
|
+
buildTableFromRun(runRows, gapThreshold) {
|
|
591
|
+
// Sweep segment x-extents to find low-coverage separator regions.
|
|
592
|
+
const events = [];
|
|
593
|
+
for (const row of runRows) {
|
|
594
|
+
for (const segment of row.segments) {
|
|
595
|
+
events.push({ x: segment.xStart, delta: 1 });
|
|
596
|
+
events.push({ x: segment.xEnd, delta: -1 });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
events.sort((a, b) => a.x - b.x);
|
|
600
|
+
|
|
601
|
+
const maxBridgingRows = Math.floor(runRows.length * 0.2);
|
|
602
|
+
const separators = [];
|
|
603
|
+
let coverage = 0;
|
|
604
|
+
let openStart = null;
|
|
605
|
+
let i = 0;
|
|
606
|
+
while (i < events.length) {
|
|
607
|
+
const x = events[i].x;
|
|
608
|
+
while (i < events.length && events[i].x === x) {
|
|
609
|
+
coverage += events[i].delta;
|
|
610
|
+
i++;
|
|
611
|
+
}
|
|
612
|
+
if (coverage <= maxBridgingRows) {
|
|
613
|
+
if (openStart === null) {
|
|
614
|
+
openStart = x;
|
|
615
|
+
}
|
|
616
|
+
} else {
|
|
617
|
+
if (openStart !== null && x - openStart >= gapThreshold) {
|
|
618
|
+
separators.push((openStart + x) / 2);
|
|
619
|
+
}
|
|
620
|
+
openStart = null;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// A trailing open region past the last segment lies outside the table.
|
|
624
|
+
|
|
625
|
+
if (separators.length === 0) {
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return runRows.map(row => {
|
|
630
|
+
const cells = new Array(separators.length + 1).fill('');
|
|
631
|
+
for (const segment of row.segments) {
|
|
632
|
+
const center = (segment.xStart + segment.xEnd) / 2;
|
|
633
|
+
let col = 0;
|
|
634
|
+
while (col < separators.length && center > separators[col]) {
|
|
635
|
+
col++;
|
|
636
|
+
}
|
|
637
|
+
cells[col] = cells[col] ? `${cells[col]} ${segment.text}` : segment.text;
|
|
638
|
+
}
|
|
639
|
+
return cells.map(cell => cell.trim());
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
438
643
|
/**
|
|
439
644
|
* Process multiple PDFs concurrently
|
|
440
645
|
* @param {Array} sources - Array of PDF sources
|
|
@@ -73,9 +73,10 @@ export async function extractMetadataHandler({ url }) {
|
|
|
73
73
|
const $ = load(html);
|
|
74
74
|
|
|
75
75
|
// Stronger title fallback: og:title → <title> → h1
|
|
76
|
+
// 'head > title' (not bare 'title') so inline <svg><title> elements are not matched
|
|
76
77
|
const title =
|
|
77
78
|
$('meta[property="og:title"]').attr('content') ||
|
|
78
|
-
$('title').text().trim() ||
|
|
79
|
+
$('head > title').first().text().trim() ||
|
|
79
80
|
$('h1').first().text().trim() ||
|
|
80
81
|
'';
|
|
81
82
|
|
|
@@ -86,8 +87,9 @@ export async function extractMetadataHandler({ url }) {
|
|
|
86
87
|
const canonical = $('link[rel="canonical"]').attr('href') || '';
|
|
87
88
|
|
|
88
89
|
const ogTags = {};
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
// Some sites (e.g. MDN) emit OG tags with name= instead of the standard property=
|
|
91
|
+
$('meta[property^="og:"], meta[name^="og:"]').each((_, el) => {
|
|
92
|
+
const property = $(el).attr('property') || $(el).attr('name');
|
|
91
93
|
const content = $(el).attr('content');
|
|
92
94
|
if (property && content) ogTags[property.replace('og:', '')] = content;
|
|
93
95
|
});
|
|
@@ -20,6 +20,9 @@ const ProcessDocumentSchema = z.object({
|
|
|
20
20
|
// PDF processing options
|
|
21
21
|
extractText: z.boolean().default(true),
|
|
22
22
|
extractMetadata: z.boolean().default(true),
|
|
23
|
+
// Detect grid tables in PDFs from the text layer; the result then always
|
|
24
|
+
// carries a top-level `tables` array (empty when none are found).
|
|
25
|
+
extractTables: z.boolean().default(false),
|
|
23
26
|
maxPages: z.number().min(1).max(500).default(100),
|
|
24
27
|
// C3: extract a specific 1-based, inclusive page range from a PDF
|
|
25
28
|
pageRange: z.object({
|
|
@@ -54,6 +57,10 @@ const ProcessDocumentResult = z.object({
|
|
|
54
57
|
html: z.string().optional(),
|
|
55
58
|
extractedContent: z.string().optional()
|
|
56
59
|
}),
|
|
60
|
+
tables: z.array(z.object({
|
|
61
|
+
page: z.number(),
|
|
62
|
+
rows: z.array(z.array(z.string()))
|
|
63
|
+
})).optional(),
|
|
57
64
|
metadata: z.object({
|
|
58
65
|
// Common metadata
|
|
59
66
|
title: z.string().nullable(),
|
|
@@ -204,6 +211,7 @@ export class ProcessDocumentTool {
|
|
|
204
211
|
options: {
|
|
205
212
|
extractText: options.extractText,
|
|
206
213
|
extractMetadata: options.extractMetadata,
|
|
214
|
+
extractTables: options.extractTables,
|
|
207
215
|
maxPages: options.maxPages,
|
|
208
216
|
...(options.pageRange ? { pageRange: options.pageRange } : {})
|
|
209
217
|
}
|
|
@@ -218,6 +226,12 @@ export class ProcessDocumentTool {
|
|
|
218
226
|
text: pdfResult.text || ''
|
|
219
227
|
};
|
|
220
228
|
|
|
229
|
+
// When table extraction was requested, always answer with a tables array —
|
|
230
|
+
// honestly empty when the detector found none.
|
|
231
|
+
if (options.extractTables) {
|
|
232
|
+
result.tables = pdfResult.tables || [];
|
|
233
|
+
}
|
|
234
|
+
|
|
221
235
|
// Set title
|
|
222
236
|
result.title = pdfResult.metadata?.title || null;
|
|
223
237
|
|
|
@@ -106,10 +106,15 @@ export class SummarizeContentTool {
|
|
|
106
106
|
|
|
107
107
|
try {
|
|
108
108
|
const validated = SummarizeContentSchema.parse(params);
|
|
109
|
-
const { text, options } = validated;
|
|
109
|
+
const { text: rawText, options } = validated;
|
|
110
|
+
|
|
111
|
+
// Page text often opens with navigation chrome ("Jump to content",
|
|
112
|
+
// "From Wikipedia, the free encyclopedia") that otherwise leads the
|
|
113
|
+
// summary and key points — strip it before summarizing.
|
|
114
|
+
const text = this.stripLeadingBoilerplate(rawText);
|
|
110
115
|
|
|
111
116
|
const result = {
|
|
112
|
-
originalText:
|
|
117
|
+
originalText: rawText.substring(0, 500) + (rawText.length > 500 ? '...' : ''),
|
|
113
118
|
summarizedAt: new Date().toISOString(),
|
|
114
119
|
success: false,
|
|
115
120
|
processingTime: 0
|
|
@@ -254,6 +259,48 @@ export class SummarizeContentTool {
|
|
|
254
259
|
}
|
|
255
260
|
}
|
|
256
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Strip leading navigation boilerplate from extracted page text.
|
|
264
|
+
*
|
|
265
|
+
* Conservative rule: scan only the leading lines, dropping each line that
|
|
266
|
+
* looks navigation-ish — short (≤ 60 chars), few words (≤ 8), and free of
|
|
267
|
+
* sentence-ending punctuation (.!?…。!?) — and stop at the first line of
|
|
268
|
+
* real prose (anything longer, wordier, or punctuated). Prose that starts
|
|
269
|
+
* mid-sentence survives: it is either punctuated, longer than the caps, or
|
|
270
|
+
* protected by the size guard — if the strip would remove more than
|
|
271
|
+
* min(600 chars, 20% of the text), nothing is stripped at all.
|
|
272
|
+
* @param {string} text - Text to clean
|
|
273
|
+
* @returns {string} - Text without leading navigation chrome
|
|
274
|
+
*/
|
|
275
|
+
stripLeadingBoilerplate(text) {
|
|
276
|
+
const lines = text.split('\n');
|
|
277
|
+
const maxStrip = Math.min(600, Math.floor(text.length * 0.2));
|
|
278
|
+
let index = 0;
|
|
279
|
+
let strippedChars = 0;
|
|
280
|
+
let strippedLines = 0;
|
|
281
|
+
|
|
282
|
+
while (index < lines.length) {
|
|
283
|
+
const line = lines[index].trim();
|
|
284
|
+
if (line.length === 0) {
|
|
285
|
+
index++;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const wordCount = line.split(/\s+/).length;
|
|
289
|
+
const hasSentencePunctuation = /[.!?…。!?]/.test(line);
|
|
290
|
+
const isBoilerplate = line.length <= 60 && wordCount <= 8 && !hasSentencePunctuation;
|
|
291
|
+
if (!isBoilerplate) break;
|
|
292
|
+
|
|
293
|
+
strippedChars += line.length;
|
|
294
|
+
if (strippedChars > maxStrip) {
|
|
295
|
+
return text; // would eat too much — leave the text untouched
|
|
296
|
+
}
|
|
297
|
+
strippedLines++;
|
|
298
|
+
index++;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return strippedLines > 0 ? lines.slice(index).join('\n') : text;
|
|
302
|
+
}
|
|
303
|
+
|
|
257
304
|
/**
|
|
258
305
|
* Extract key points from original text and summary
|
|
259
306
|
* @param {string} originalText - Original text
|
|
@@ -499,6 +499,9 @@ export class DeepResearchTool {
|
|
|
499
499
|
return {
|
|
500
500
|
...formatted,
|
|
501
501
|
findings: results.findings,
|
|
502
|
+
// LLM synthesis (aiSummary/intelligentInsights) — without this the
|
|
503
|
+
// llmEnhanced flag was true but the LLM output never reached users.
|
|
504
|
+
insights: results.insights,
|
|
502
505
|
supportingEvidence: results.supportingEvidence,
|
|
503
506
|
consensus: results.consensus,
|
|
504
507
|
conflicts: results.conflicts,
|
|
@@ -514,6 +517,7 @@ export class DeepResearchTool {
|
|
|
514
517
|
return {
|
|
515
518
|
...formatted,
|
|
516
519
|
keyFindings: results.findings.slice(0, 5),
|
|
520
|
+
aiSummary: results.insights?.aiSummary,
|
|
517
521
|
topSources: results.supportingEvidence.slice(0, 5),
|
|
518
522
|
mainConflicts: results.conflicts.slice(0, 3),
|
|
519
523
|
primaryRecommendations: results.recommendations.slice(0, 3),
|
|
@@ -48,7 +48,8 @@ export class SearchProviderFactory {
|
|
|
48
48
|
|
|
49
49
|
return new CrawlForgeSearchAdapter(
|
|
50
50
|
apiKey,
|
|
51
|
-
|
|
51
|
+
// www is the live API host — api.crawlforge.dev does not resolve.
|
|
52
|
+
options.apiBaseUrl || 'https://www.crawlforge.dev'
|
|
52
53
|
);
|
|
53
54
|
}
|
|
54
55
|
|
|
@@ -101,12 +101,15 @@ export class RedditSearchTool {
|
|
|
101
101
|
// and PullPush stopped serving automated clients in August 2026.
|
|
102
102
|
this.searchAdapter = options.searchAdapter || null;
|
|
103
103
|
this.searchApiKey = options.searchApiKey || null;
|
|
104
|
+
this.searchApiBaseUrl = options.searchApiBaseUrl || null;
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
/** The web-search adapter used to discover posts, built once on first use. */
|
|
107
108
|
#search() {
|
|
108
109
|
if (!this.searchAdapter) {
|
|
109
|
-
this.searchAdapter = SearchProviderFactory.createAdapter(this.searchApiKey
|
|
110
|
+
this.searchAdapter = SearchProviderFactory.createAdapter(this.searchApiKey, {
|
|
111
|
+
apiBaseUrl: this.searchApiBaseUrl,
|
|
112
|
+
});
|
|
110
113
|
}
|
|
111
114
|
return this.searchAdapter;
|
|
112
115
|
}
|
|
@@ -281,6 +281,7 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
281
281
|
details: comparisonResult.details,
|
|
282
282
|
metrics: comparisonResult.metrics,
|
|
283
283
|
recommendations: comparisonResult.recommendations,
|
|
284
|
+
...(comparisonResult.warnings ? { warnings: comparisonResult.warnings } : {}),
|
|
284
285
|
snapshot: snapshotInfo, timestamp: Date.now()
|
|
285
286
|
};
|
|
286
287
|
}
|