crawlforge-mcp-server 5.2.5 → 5.2.7

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 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.5
65
+ **Current Version:** 5.2.7
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.5",
3
+ "version": "5.2.7",
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.1",
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.5",
103
+ version: "5.2.7",
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` +
@@ -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
- finding: group.keywords.join(' '),
1520
- supportingClaims: group.claims.length,
1521
- credibility: group.avgCredibility,
1522
- sources: group.claims.map(c => c.source)
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.match(/\p{L}/gu) || []).length;
340
+ const { letters, han, kana, hangul, share } = this.cjkScriptCounts(text);
292
341
  if (letters > 0) {
293
- const han = (text.match(/\p{Script=Han}/gu) || []).length;
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
- const words = text.split(/\s+/).filter(w => w.length > 0);
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
- return JSON.parse(response);
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
@@ -180,7 +180,7 @@ const redditCommentShape = z.object({
180
180
  }).passthrough();
181
181
 
182
182
  const redditSearchShape = {
183
- source: z.enum(['arctic_shift', 'pullpush']).optional().describe('Which community archive served this result'),
183
+ source: z.enum(['arctic_shift', 'pullpush', 'web_discovery']).optional().describe('Which backend served this result — an archive, or web discovery (site-restricted web search hydrated from the archive) for Reddit-wide keyword search'),
184
184
  mode: z.string().optional(),
185
185
  query: z.string().nullable().optional(),
186
186
  subreddit: z.string().nullable().optional(),
@@ -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
- $('meta[property^="og:"]').each((_, el) => {
90
- const property = $(el).attr('property');
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: text.substring(0, 500) + (text.length > 500 ? '...' : ''),
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
- options.apiBaseUrl || 'https://api.crawlforge.dev'
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
  }