crawlforge-mcp-server 5.6.4 → 5.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "5.6.4",
3
+ "version": "5.6.6",
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.4",
109
+ version: "5.6.6",
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",
@@ -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.text().trim();
808
+ extracted[key] = elementText($, elements.get(0));
805
809
  } else {
806
- extracted[key] = elements.map((_, el) => $(el).text().trim()).get();
810
+ extracted[key] = elements.map((_, el) => elementText($, el)).get();
807
811
  }
808
812
  } catch (error) {
809
813
  extracted[key] = { error: `Invalid selector: ${selector}` };
@@ -11,6 +11,7 @@ import { ssrfGuard, isSsrfError } from '../../../utils/ssrfGuard.js';
11
11
  import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
12
12
  import { preflightFetch } from '../../../utils/robotsGate.js';
13
13
  import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
14
+ import { elementText } from '../../../utils/elementText.js';
14
15
 
15
16
  /**
16
17
  * Fetch a URL with AbortController timeout (SSRF-guarded + per-host throttled).
@@ -111,8 +112,8 @@ function extractStructuredData($, selectors) {
111
112
  try {
112
113
  const elements = $(selector);
113
114
  if (elements.length === 0) extracted[key] = null;
114
- else if (elements.length === 1) extracted[key] = elements.text().trim();
115
- else extracted[key] = elements.map((_, el) => $(el).text().trim()).get();
115
+ else if (elements.length === 1) extracted[key] = elementText($, elements.get(0));
116
+ else extracted[key] = elements.map((_, el) => elementText($, el)).get();
116
117
  } catch {
117
118
  extracted[key] = { error: `Invalid selector: ${selector}` };
118
119
  }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { load } from 'cheerio';
11
11
  import { fetchWithTimeout } from './_fetch.js';
12
+ import { elementText } from '../../utils/elementText.js';
12
13
 
13
14
  // Matches a trailing "@attr" suffix (e.g. "@href", "@data-id") — the attribute
14
15
  // name must look like a real attribute, not the "@" of a CSS attribute-value
@@ -73,7 +74,7 @@ function extractRows($, { row_selector, selectors, max_results }) {
73
74
  record[field] = null;
74
75
  continue;
75
76
  }
76
- record[field] = attribute ? (el.attr(attribute) ?? null) : el.text().trim();
77
+ record[field] = attribute ? (el.attr(attribute) ?? null) : elementText($, el.get(0));
77
78
  matchCounts[field] += 1;
78
79
  } catch (selectorError) {
79
80
  record[field] = {
@@ -145,7 +146,7 @@ export async function scrapeStructuredHandler({ url, selectors, row_selector, ma
145
146
  if (attribute) {
146
147
  return $(el).attr(attribute) ?? null;
147
148
  }
148
- return $(el).text().trim();
149
+ return elementText($, el);
149
150
  };
150
151
 
151
152
  if (elements.length === 1) {
@@ -0,0 +1,70 @@
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
+ * batch_scrape's selector extraction (batchScrape/worker.js) and
14
+ * scrape_structured read matched elements through this helper too (5.6.6).
15
+ */
16
+
17
+ const ROW_GROUPS = new Set(['table', 'thead', 'tbody', 'tfoot']);
18
+
19
+ function tagOf(el) {
20
+ return String(el?.name || el?.tagName || '').toLowerCase();
21
+ }
22
+
23
+ function cellText($, cell) {
24
+ return $(cell).text().replace(/\s+/g, ' ').trim();
25
+ }
26
+
27
+ function rowText($, row) {
28
+ return $(row).children('td, th').map((_, cell) => cellText($, cell)).get().join(' | ');
29
+ }
30
+
31
+ /**
32
+ * One line per row, cells joined by " | ". `el` is a table or one of its row
33
+ * groups; rows of a table nested inside a cell belong to that table, not to
34
+ * this one, so they are skipped here (their cell already carries their text).
35
+ */
36
+ function tableText($, el) {
37
+ const table = tagOf(el) === 'table' ? el : $(el).closest('table').get(0);
38
+ return $(el).find('tr')
39
+ .filter((_, row) => $(row).closest('table').get(0) === table)
40
+ .map((_, row) => rowText($, row)).get()
41
+ .filter((line) => line.length > 0)
42
+ .join('\n');
43
+ }
44
+
45
+ /**
46
+ * @param {import('cheerio').CheerioAPI} $
47
+ * @param {object} el - a cheerio/domhandler element
48
+ * @returns {string}
49
+ */
50
+ export function elementText($, el) {
51
+ const tag = tagOf(el);
52
+ if (tag === 'tr') return rowText($, el);
53
+ if (ROW_GROUPS.has(tag)) return tableText($, el);
54
+
55
+ const $el = $(el);
56
+ if ($el.find('table').length === 0) return $el.text().trim();
57
+
58
+ // A wrapper around one or more tables: swap each outermost table for its
59
+ // line-per-row text (as a text node, so cell content is never re-parsed as
60
+ // markup), then read the wrapper as lines.
61
+ const $clone = $el.clone();
62
+ $clone.find('table')
63
+ .filter((_, table) => $(table).parents('table').length === 0)
64
+ .each((_, table) => {
65
+ $(table).replaceWith($('<div></div>').text(`\n${tableText($, table)}\n`));
66
+ });
67
+ return $clone.text().split('\n').map((line) => line.trim()).filter(Boolean).join('\n');
68
+ }
69
+
70
+ export default elementText;