crawlforge-mcp-server 5.6.3 → 5.6.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "5.6.3",
3
+ "version": "5.6.4",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 29 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",
package/server.js CHANGED
@@ -106,7 +106,7 @@ const taskStore = createTaskStore({ logger });
106
106
  // Create the server
107
107
  const server = new McpServer({
108
108
  name: "crawlforge",
109
- version: "5.6.3",
109
+ version: "5.6.4",
110
110
  description: "Production-ready MCP server with 29 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, embedded JavaScript state 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.",
111
111
  homepage: "https://www.crawlforge.dev",
112
112
  icon: "https://www.crawlforge.dev/icon.png",
@@ -8,6 +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
12
 
12
13
  const ContentProcessorSchema = z.object({
13
14
  html: z.string(),
@@ -30,7 +31,8 @@ const ReadabilityResult = z.object({
30
31
  byline: z.string().nullable(),
31
32
  dir: z.string().nullable(),
32
33
  siteName: z.string().nullable(),
33
- lang: z.string().nullable()
34
+ lang: z.string().nullable(),
35
+ tablesRecovered: z.number()
34
36
  });
35
37
 
36
38
  export class ContentProcessor {
@@ -78,16 +80,28 @@ export class ContentProcessor {
78
80
 
79
81
  const article = reader.parse();
80
82
  if (article) {
83
+ // Readability keeps one article candidate and drops every data
84
+ // table outside it — scrape_with_actions returned CoinMarketCap's
85
+ // historical-data page as its FAQ copy with no table (2026-09-04).
86
+ // Re-attach what it dropped, as scrape's main-content path does.
87
+ const recovered = recoverDroppedTables(html, url, article.textContent);
88
+ const content = recovered.length > 0
89
+ ? `${article.content}\n${recovered.map((table) => table.html).join('\n')}`
90
+ : article.content;
91
+ const textContent = recovered.length > 0
92
+ ? `${article.textContent}\n${recovered.map((table) => table.text).join('\n')}`
93
+ : article.textContent;
81
94
  result.readability = {
82
95
  title: article.title,
83
- content: article.content,
84
- textContent: article.textContent,
85
- length: article.length,
96
+ content,
97
+ textContent,
98
+ length: textContent.length,
86
99
  excerpt: article.excerpt,
87
100
  byline: article.byline,
88
101
  dir: article.dir,
89
102
  siteName: article.siteName,
90
- lang: article.lang
103
+ lang: article.lang,
104
+ tablesRecovered: recovered.length
91
105
  };
92
106
 
93
107
  // Calculate readability score
@@ -235,6 +235,11 @@ export class ExtractContentTool {
235
235
  result.extractionMethod = 'readability';
236
236
  result.confidence = 0.9;
237
237
  result.finalUrl = url;
238
+ if (processingResult.readability.tablesRecovered > 0) {
239
+ (result.warnings ||= []).push(
240
+ `mainContent: re-attached ${processingResult.readability.tablesRecovered} data table(s) that main-content extraction had dropped`
241
+ );
242
+ }
238
243
 
239
244
  // Convert to markdown if requested
240
245
  if (options.outputFormat === 'markdown') {
@@ -35,7 +35,10 @@
35
35
  * 12th sits inside a collapsed `<details>`, which Readability drops whole
36
36
  * and which this deliberately does not try to rescue.
37
37
  *
38
- * Shared by unifiedScrape (wants HTML) and extractStructured (wants text).
38
+ * Shared by unifiedScrape (wants HTML) and extractStructured (wants text);
39
+ * the table recovery alone is also used by ContentProcessor, which runs its
40
+ * own Readability pass for extract_content, scrape_with_actions and
41
+ * process_document.
39
42
  */
40
43
 
41
44
  import { JSDOM } from 'jsdom';
@@ -169,6 +172,61 @@ function isDataTable(table) {
169
172
  return rows >= 10 || columns > 4;
170
173
  }
171
174
 
175
+ /**
176
+ * A data table's text with its structure kept: one line per row, cells joined
177
+ * by " | ". A bare `textContent` runs every cell together
178
+ * ("DateOpen*HighLowClose**…"), which is what scrape_with_actions' selector
179
+ * extraction handed back for CoinMarketCap's history table.
180
+ * @param {HTMLTableElement} table
181
+ * @returns {string}
182
+ */
183
+ function tableText(table) {
184
+ return Array.from(table.rows)
185
+ .map((row) => Array.from(row.cells).map((cell) => normalizeWhitespace(cell.textContent || '')).join(' | '))
186
+ .filter((line) => line.length > 0)
187
+ .join('\n');
188
+ }
189
+
190
+ /**
191
+ * The data tables in `html` that a Readability pass left out of its article.
192
+ *
193
+ * Readability mutates the document it is handed — the dropped tables are
194
+ * already gone by the time parse() returns — so this parses the original HTML
195
+ * again. Shared by every caller that runs Readability itself: extractMainContent
196
+ * below, and ContentProcessor, whose consumers (extract_content,
197
+ * scrape_with_actions, process_document) lost tables the same way until
198
+ * 2026-09-04, when scrape_with_actions returned CoinMarketCap's historical-data
199
+ * page as its FAQ copy — the wait for the table rows had succeeded, and the
200
+ * markdown then had no table at all.
201
+ *
202
+ * @param {string} html - the page's HTML
203
+ * @param {string} [url] - document URL, used as the base for relative links
204
+ * @param {string} [keptText] - the article's textContent, so a table Readability
205
+ * did keep is not attached a second time
206
+ * @returns {{ html: string, text: string }[]} each dropped table's outerHTML and
207
+ * its row-per-line text; empty when there is nothing to recover
208
+ */
209
+ export function recoverDroppedTables(html, url, keptText = '') {
210
+ // Nothing to recover, and no reason to pay for a second parse.
211
+ if (!/<table[\s>]/i.test(html)) return [];
212
+ try {
213
+ const fresh = new JSDOM(html, { url });
214
+ const kept = normalizeWhitespace(keptText || '');
215
+ return Array.from(fresh.window.document.querySelectorAll('table'))
216
+ // A nested table travels with its parent; re-attaching it separately
217
+ // would duplicate it.
218
+ .filter((table) => !table.parentElement?.closest('table'))
219
+ .filter(isDataTable)
220
+ .filter((table) => {
221
+ const signature = normalizeWhitespace(table.textContent || '').slice(0, SIGNATURE_LENGTH);
222
+ return signature.length > 0 && !kept.includes(signature);
223
+ })
224
+ .map((table) => ({ html: table.outerHTML, text: tableText(table) }));
225
+ } catch {
226
+ return [];
227
+ }
228
+ }
229
+
172
230
  /**
173
231
  * Extract a page's main content, re-attaching data tables Readability dropped.
174
232
  *
@@ -193,32 +251,7 @@ export function extractMainContent(html, url) {
193
251
  if (!article || !article.content) return { html: null, title: '', tablesRecovered: 0 };
194
252
 
195
253
  const title = article.title || '';
196
-
197
- // Nothing to recover, and no reason to pay for a second parse.
198
- if (!/<table[\s>]/i.test(html)) {
199
- return { html: article.content, title, tablesRecovered: 0 };
200
- }
201
-
202
- // Readability mutates the document it is handed — the dropped tables are
203
- // already gone from `dom` by the time parse() returns — so recovery has to
204
- // parse the original HTML again.
205
- let recovered;
206
- try {
207
- const fresh = new JSDOM(html, { url });
208
- const kept = normalizeWhitespace(article.textContent || '');
209
- recovered = Array.from(fresh.window.document.querySelectorAll('table'))
210
- // A nested table travels with its parent; re-attaching it separately
211
- // would duplicate it.
212
- .filter((table) => !table.parentElement?.closest('table'))
213
- .filter(isDataTable)
214
- .filter((table) => {
215
- const signature = normalizeWhitespace(table.textContent || '').slice(0, SIGNATURE_LENGTH);
216
- return signature.length > 0 && !kept.includes(signature);
217
- })
218
- .map((table) => table.outerHTML);
219
- } catch {
220
- return { html: article.content, title, tablesRecovered: 0 };
221
- }
254
+ const recovered = recoverDroppedTables(html, url, article.textContent).map((table) => table.html);
222
255
 
223
256
  return {
224
257
  html: recovered.length > 0 ? `${article.content}\n${recovered.join('\n')}` : article.content,