crawlforge-mcp-server 5.6.3 → 5.6.5
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 +1 -1
- package/server.js +1 -1
- package/src/core/processing/ContentProcessor.js +19 -5
- package/src/tools/advanced/ScrapeWithActionsTool.js +6 -2
- package/src/tools/extract/extractContent.js +5 -0
- package/src/tools/scrape/_mainContent.js +60 -27
- package/src/utils/elementText.js +71 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.6.
|
|
3
|
+
"version": "5.6.5",
|
|
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.
|
|
109
|
+
version: "5.6.5",
|
|
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
|
|
84
|
-
textContent
|
|
85
|
-
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
|
|
@@ -10,6 +10,7 @@ import { load } from 'cheerio';
|
|
|
10
10
|
|
|
11
11
|
// Import existing tool for content extraction
|
|
12
12
|
import ExtractContentTool from '../extract/extractContent.js';
|
|
13
|
+
import { elementText } from '../../utils/elementText.js';
|
|
13
14
|
|
|
14
15
|
// Recording / replay helpers
|
|
15
16
|
import {
|
|
@@ -798,12 +799,15 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
798
799
|
try {
|
|
799
800
|
const elements = $(selector);
|
|
800
801
|
|
|
802
|
+
// elementText keeps table structure (one line per row, cells joined
|
|
803
|
+
// by " | "); cheerio's .text() ran CoinMarketCap's history table into
|
|
804
|
+
// "DateOpen*HighLowClose**…" (2026-09-04).
|
|
801
805
|
if (elements.length === 0) {
|
|
802
806
|
extracted[key] = null;
|
|
803
807
|
} else if (elements.length === 1) {
|
|
804
|
-
extracted[key] = elements.
|
|
808
|
+
extracted[key] = elementText($, elements.get(0));
|
|
805
809
|
} else {
|
|
806
|
-
extracted[key] = elements.map((_, el) =>
|
|
810
|
+
extracted[key] = elements.map((_, el) => elementText($, el)).get();
|
|
807
811
|
}
|
|
808
812
|
} catch (error) {
|
|
809
813
|
extracted[key] = { error: `Invalid selector: ${selector}` };
|
|
@@ -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,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* elementText — the text of a cheerio-matched element with table structure kept.
|
|
3
|
+
*
|
|
4
|
+
* cheerio's .text() runs every descendant's text together. For a table that
|
|
5
|
+
* is one string per row with no cell boundaries: scrape_with_actions'
|
|
6
|
+
* extractionOptions.selectors returned CoinMarketCap's historical-data table
|
|
7
|
+
* as "DateOpen*HighLowClose**VolumeMarket CapSep 03, 2026$77,300.17…"
|
|
8
|
+
* (2026-09-04). Here a table, a row group or a row renders one line per row
|
|
9
|
+
* with cells joined by " | " (the convention _mainContent.js uses for the text
|
|
10
|
+
* of a recovered table), an element that wraps a table renders each table that
|
|
11
|
+
* way in place, and anything else is .text().trim(), unchanged.
|
|
12
|
+
*
|
|
13
|
+
* batchScrape/worker.js extractStructuredData and scrapeStructured read
|
|
14
|
+
* matched elements with the same .text().trim() and have the same gap; they
|
|
15
|
+
* do not use this yet.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const ROW_GROUPS = new Set(['table', 'thead', 'tbody', 'tfoot']);
|
|
19
|
+
|
|
20
|
+
function tagOf(el) {
|
|
21
|
+
return String(el?.name || el?.tagName || '').toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function cellText($, cell) {
|
|
25
|
+
return $(cell).text().replace(/\s+/g, ' ').trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rowText($, row) {
|
|
29
|
+
return $(row).children('td, th').map((_, cell) => cellText($, cell)).get().join(' | ');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One line per row, cells joined by " | ". `el` is a table or one of its row
|
|
34
|
+
* groups; rows of a table nested inside a cell belong to that table, not to
|
|
35
|
+
* this one, so they are skipped here (their cell already carries their text).
|
|
36
|
+
*/
|
|
37
|
+
function tableText($, el) {
|
|
38
|
+
const table = tagOf(el) === 'table' ? el : $(el).closest('table').get(0);
|
|
39
|
+
return $(el).find('tr')
|
|
40
|
+
.filter((_, row) => $(row).closest('table').get(0) === table)
|
|
41
|
+
.map((_, row) => rowText($, row)).get()
|
|
42
|
+
.filter((line) => line.length > 0)
|
|
43
|
+
.join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {import('cheerio').CheerioAPI} $
|
|
48
|
+
* @param {object} el - a cheerio/domhandler element
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function elementText($, el) {
|
|
52
|
+
const tag = tagOf(el);
|
|
53
|
+
if (tag === 'tr') return rowText($, el);
|
|
54
|
+
if (ROW_GROUPS.has(tag)) return tableText($, el);
|
|
55
|
+
|
|
56
|
+
const $el = $(el);
|
|
57
|
+
if ($el.find('table').length === 0) return $el.text().trim();
|
|
58
|
+
|
|
59
|
+
// A wrapper around one or more tables: swap each outermost table for its
|
|
60
|
+
// line-per-row text (as a text node, so cell content is never re-parsed as
|
|
61
|
+
// markup), then read the wrapper as lines.
|
|
62
|
+
const $clone = $el.clone();
|
|
63
|
+
$clone.find('table')
|
|
64
|
+
.filter((_, table) => $(table).parents('table').length === 0)
|
|
65
|
+
.each((_, table) => {
|
|
66
|
+
$(table).replaceWith($('<div></div>').text(`\n${tableText($, table)}\n`));
|
|
67
|
+
});
|
|
68
|
+
return $clone.text().split('\n').map((line) => line.trim()).filter(Boolean).join('\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default elementText;
|