crawlforge-mcp-server 5.2.0 → 5.2.2

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.0
65
+ **Current Version:** 5.2.2
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.0",
3
+ "version": "5.2.2",
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.0.0",
116
+ "crawlforge-extractors": "^1.1.0",
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.0",
103
+ version: "5.2.2",
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",
@@ -1402,7 +1402,7 @@ registerToolIfEnabled("localization", {
1402
1402
 
1403
1403
  // Tool: scrape_template (D3.3 — pre-built site templates)
1404
1404
  registerToolIfEnabled("scrape_template", {
1405
- description: "Use this when you want structured data from a well-known site without writing custom selectors. Pass template:\"list\" to see all available templates. Supports: amazon-product, linkedin-profile, github-repo, youtube-video, tweet, reddit-thread, hacker-news-front-page, producthunt-launch, stackoverflow-question, npm-package. Example: scrape_template({template:\"github-repo\", url:\"https://github.com/user/repo\"})",
1405
+ description: "Use this when you want structured data from a well-known site without writing custom selectors. Pass template:\"list\" to see all available templates. Supports: shopify-product (any Shopify storefront, read from the store's own /products/<handle>.json rather than the rendered page), amazon-product, linkedin-profile, github-repo, youtube-video, tweet, reddit-thread, hacker-news-front-page, producthunt-launch, stackoverflow-question, npm-package. Example: scrape_template({template:\"github-repo\", url:\"https://github.com/user/repo\"})",
1406
1406
  annotations: { title: "Scrape Template", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1407
1407
  inputSchema: {
1408
1408
  template: z.string().describe("Template ID (e.g. github-repo) or list to enumerate available templates"),
@@ -11,6 +11,7 @@ import { z } from 'zod';
11
11
  import { EventEmitter } from 'events';
12
12
  import { load } from 'cheerio';
13
13
  import { diffWords, diffLines, diffChars } from 'diff';
14
+ import { structureSignature, structuralSimilarity } from 'crawlforge-extractors';
14
15
  import { calculateSimilarity as calculateContentSimilarity } from '../tools/tracking/trackChanges/differ.js';
15
16
 
16
17
  const ChangeTrackingSchema = z.object({
@@ -720,7 +721,10 @@ export class ChangeTracker extends EventEmitter {
720
721
  extractStructure($, options) {
721
722
  const structure = {
722
723
  elements: [],
723
- hierarchy: {},
724
+ // Element count per nesting depth. This used to be an empty object that
725
+ // nothing ever wrote to, which made the hierarchy half of the structural
726
+ // score a constant.
727
+ hierarchy: structureSignature($).depths,
724
728
  semanticStructure: {}
725
729
  };
726
730
 
@@ -1094,38 +1098,13 @@ export class ChangeTracker extends EventEmitter {
1094
1098
 
1095
1099
  calculateStructuralSimilarity(baseline, current) {
1096
1100
  if (!baseline || !current) return 0;
1097
-
1098
- const baselineElements = baseline.elements || [];
1099
- const currentElements = current.elements || [];
1100
-
1101
- if (baselineElements.length === 0 && currentElements.length === 0) return 1;
1102
- if (baselineElements.length === 0 || currentElements.length === 0) return 0;
1103
-
1104
- const tagSimilarity = this.calculateTagSimilarity(baselineElements, currentElements);
1105
- const hierarchySimilarity = this.calculateHierarchySimilarity(baseline.hierarchy, current.hierarchy);
1106
-
1107
- // Clamp defensively — this is a 0-1 metric and must never leave that range.
1108
- return Math.max(0, Math.min(1, (tagSimilarity + hierarchySimilarity) / 2));
1109
- }
1110
-
1111
- calculateTagSimilarity(baselineElements, currentElements) {
1112
- // Jaccard similarity: intersection and union must both operate on SETS.
1113
- // The old code intersected the raw (duplicate-laden) tag list against a
1114
- // set union, so repeated tags (div, p, ...) inflated the numerator and
1115
- // produced impossible scores > 1 (e.g. the observed 1.05).
1116
- const baselineTags = new Set(baselineElements.map(el => el.tag));
1117
- const currentTags = new Set(currentElements.map(el => el.tag));
1118
1101
 
1119
- const intersection = [...baselineTags].filter(tag => currentTags.has(tag));
1120
- const union = new Set([...baselineTags, ...currentTags]);
1121
-
1122
- return union.size === 0 ? 1 : intersection.length / union.size;
1123
- }
1124
-
1125
- calculateHierarchySimilarity(baseline, current) {
1126
- // Simple structural comparison - can be enhanced
1127
- if (!baseline || !current) return 0;
1128
- return Object.keys(baseline).length === Object.keys(current).length ? 1 : 0.5;
1102
+ // Scored in crawlforge-extractors so the REST API's track_changes reports
1103
+ // the same number for the same pair of pages.
1104
+ return structuralSimilarity(
1105
+ { tags: (baseline.elements || []).map(el => el.tag), depths: baseline.hierarchy },
1106
+ { tags: (current.elements || []).map(el => el.tag), depths: current.hierarchy }
1107
+ );
1129
1108
  }
1130
1109
 
1131
1110
  hammingDistance(str1, str2) {
@@ -106,24 +106,25 @@ const LANGUAGE_NAMES = {
106
106
  'rus': 'Russian',
107
107
  'jpn': 'Japanese',
108
108
  'kor': 'Korean',
109
- 'chi': 'Chinese',
110
- 'ara': 'Arabic',
109
+ 'cmn': 'Chinese',
110
+ 'arb': 'Arabic',
111
111
  'hin': 'Hindi',
112
112
  'nld': 'Dutch',
113
113
  'swe': 'Swedish',
114
- 'nor': 'Norwegian',
114
+ 'nob': 'Norwegian',
115
115
  'dan': 'Danish',
116
116
  'fin': 'Finnish',
117
117
  'pol': 'Polish',
118
118
  'ces': 'Czech',
119
119
  'hun': 'Hungarian',
120
120
  'tur': 'Turkish',
121
- 'gre': 'Greek',
121
+ 'ell': 'Greek',
122
122
  'heb': 'Hebrew',
123
123
  'tha': 'Thai',
124
124
  'vie': 'Vietnamese',
125
125
  'ind': 'Indonesian',
126
- 'msa': 'Malay',
126
+ 'zlm': 'Malay',
127
+ 'zsm': 'Malay',
127
128
  'tgl': 'Tagalog',
128
129
  'ukr': 'Ukrainian',
129
130
  'bul': 'Bulgarian',
@@ -282,6 +283,28 @@ export class ContentAnalyzer {
282
283
  */
283
284
  async detectLanguage(text, options = {}) {
284
285
  try {
286
+ // franc scores the single most common script, so a Chinese, Japanese or
287
+ // Korean page carrying the usual run of English product names and code
288
+ // samples is detected as English. Those scripts never appear in
289
+ // Latin-script prose, so a meaningful share of them settles the question
290
+ // before trigram scoring gets a say.
291
+ const letters = (text.match(/\p{L}/gu) || []).length;
292
+ 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) {
297
+ const code = kana > 0 ? 'jpn' : hangul > han ? 'kor' : 'cmn';
298
+ return {
299
+ code,
300
+ name: LANGUAGE_NAMES[code],
301
+ confidence: 0.9,
302
+ alternative: [],
303
+ detectionMethod: 'script'
304
+ };
305
+ }
306
+ }
307
+
285
308
  // Use franc for language detection
286
309
  const detected = franc(text, {
287
310
  minLength: 10,
@@ -3,6 +3,7 @@
3
3
  * Applies an AbortController timeout and a default User-Agent.
4
4
  */
5
5
 
6
+ import { readBody } from 'crawlforge-extractors';
6
7
  import { config } from '../../constants/config.js';
7
8
  import { createRequire } from 'module';
8
9
  import { ssrfGuard, isSsrfError } from '../../utils/ssrfGuard.js';
@@ -13,35 +14,6 @@ const _require = createRequire(import.meta.url);
13
14
  const _pkg = _require('../../../package.json');
14
15
  const CRAWLFORGE_UA = `CrawlForge/${_pkg.version} (+https://crawlforge.dev)`;
15
16
 
16
- /**
17
- * Determine the charset to decode a response body with: Content-Type header
18
- * first, then a <meta charset> sniff of the first bytes, defaulting to utf-8.
19
- * @param {Response} response
20
- * @param {Uint8Array} bytes
21
- * @returns {string}
22
- */
23
- function detectCharset(response, bytes) {
24
- const contentType = response.headers?.get?.('content-type') || '';
25
- const headerMatch = /charset=["']?([\w-]+)/i.exec(contentType);
26
- if (headerMatch) {
27
- return headerMatch[1].trim().toLowerCase();
28
- }
29
-
30
- // <meta charset> tags must appear within the first 1024 bytes per the
31
- // HTML5 spec's prescan algorithm; ASCII-range bytes decode identically
32
- // under latin1 regardless of the document's real encoding.
33
- const sniffLength = Math.min(bytes.byteLength, 1024);
34
- const sniffText = new TextDecoder('latin1').decode(bytes.subarray(0, sniffLength));
35
- const metaMatch =
36
- /<meta[^>]+charset=["']?([\w-]+)/i.exec(sniffText) ||
37
- /<meta[^>]+http-equiv=["']?content-type["']?[^>]*content=["'][^"']*charset=([\w-]+)/i.exec(sniffText);
38
- if (metaMatch) {
39
- return metaMatch[1].trim().toLowerCase();
40
- }
41
-
42
- return 'utf-8';
43
- }
44
-
45
17
  /**
46
18
  * Fetch a URL with a configurable timeout and body-size cap.
47
19
  *
@@ -100,46 +72,11 @@ export async function fetchWithTimeout(url, options = {}) {
100
72
  throw error;
101
73
  }
102
74
 
103
- // --- Body-size cap ---
104
-
105
- // Early rejection via Content-Length (servers may omit or lie — guard below
106
- // handles that case). Optional-chained so non-standard responses (e.g. test
107
- // mocks) without a Headers object don't throw.
108
- const contentLengthHeader = response.headers?.get?.('content-length') ?? null;
109
- if (contentLengthHeader !== null) {
110
- const declared = parseInt(contentLengthHeader, 10);
111
- if (!isNaN(declared) && declared > maxBodySize) {
112
- throw new Error(
113
- `Response body too large: Content-Length ${declared} exceeds limit of ${maxBodySize} bytes`
114
- );
115
- }
116
- }
117
-
118
- // Only the streaming byte-count guard requires a readable body. Responses
119
- // without a ReadableStream body (already-buffered responses, test mocks)
120
- // are returned unchanged so callers' native .text()/.json() still work.
121
- if (!response.body || typeof response.body.getReader !== 'function') {
122
- return Object.assign(response, { _responseTime: Date.now() - startedAt });
123
- }
124
-
125
- // Stream the body and abort if accumulated bytes exceed the cap.
126
- const reader = response.body.getReader();
127
- const chunks = [];
128
- let totalBytes = 0;
129
-
75
+ // Reading is delegated to crawlforge-extractors so the REST API applies
76
+ // the same cap and the same charset handling to the same page.
77
+ let bodyText;
130
78
  try {
131
- while (true) {
132
- const { done, value } = await reader.read();
133
- if (done) break;
134
- totalBytes += value.byteLength;
135
- if (totalBytes > maxBodySize) {
136
- reader.cancel();
137
- throw new Error(
138
- `Response body too large: exceeded limit of ${maxBodySize} bytes`
139
- );
140
- }
141
- chunks.push(value);
142
- }
79
+ bodyText = await readBody(response, { maxBytes: maxBodySize });
143
80
  } catch (error) {
144
81
  if (error.name === 'AbortError') {
145
82
  throw new Error(`Request timeout after ${timeout}ms`);
@@ -147,29 +84,7 @@ export async function fetchWithTimeout(url, options = {}) {
147
84
  throw error;
148
85
  }
149
86
 
150
- // Reassemble the raw bytes in a single pass (totalBytes is already known,
151
- // so this is one allocation + one copy per chunk, not the O(n^2) cost of
152
- // reallocating/copying the whole buffer on every chunk), then decode using
153
- // the response's actual charset (Content-Type header, falling back to a
154
- // <meta charset> sniff) instead of always assuming UTF-8.
155
- const mergedBytes = new Uint8Array(totalBytes);
156
- let offset = 0;
157
- for (const chunk of chunks) {
158
- mergedBytes.set(chunk, offset);
159
- offset += chunk.byteLength;
160
- }
161
-
162
- const charset = detectCharset(response, mergedBytes);
163
- let bodyText;
164
- try {
165
- bodyText = new TextDecoder(charset).decode(mergedBytes);
166
- } catch {
167
- // Unrecognized charset label — fall back to UTF-8 rather than throwing.
168
- bodyText = new TextDecoder().decode(mergedBytes);
169
- }
170
-
171
87
  // Attach the pre-read text so callers can call .text() on the result.
172
- // We wrap it in a minimal compatible object.
173
88
  return Object.assign(response, {
174
89
  text: () => Promise.resolve(bodyText),
175
90
  json: () => Promise.resolve(JSON.parse(bodyText)),
@@ -27,7 +27,7 @@ const TOKEN_URL = 'https://www.reddit.com/api/v1/access_token';
27
27
  const API_BASE = 'https://oauth.reddit.com';
28
28
 
29
29
  /** Reddit requires a descriptive, unique User-Agent; generic ones are throttled. */
30
- const DEFAULT_USER_AGENT = 'CrawlForge-MCP/5.2.0 (+https://www.crawlforge.dev)';
30
+ const DEFAULT_USER_AGENT = 'CrawlForge-MCP/5.2.1 (+https://www.crawlforge.dev)';
31
31
 
32
32
  /** Our sort is asc/desc by post date; Reddit listings/search only go newest-first. */
33
33
  const REDDIT_SORT = 'new';
@@ -47,7 +47,7 @@ const PULLPUSH_BASE = 'https://api.pullpush.io';
47
47
  * into a shared bucket (422 "Timeout. Maybe slow down a bit" while curl got
48
48
  * 200 for the same URL); with a descriptive UA it answers instantly.
49
49
  */
50
- const USER_AGENT = 'CrawlForge-MCP/5.2.0 (+https://www.crawlforge.dev)';
50
+ const USER_AGENT = 'CrawlForge-MCP/5.2.1 (+https://www.crawlforge.dev)';
51
51
 
52
52
  const RedditSearchSchema = z.object({
53
53
  query: z.string().min(1).optional(),
@@ -20,6 +20,38 @@ import { stripHiddenHtml } from './hiddenContent.js';
20
20
 
21
21
  let _td = null;
22
22
 
23
+ // Mirrors turndown-plugin-gfm's own heading-row test, which is what decides
24
+ // whether it converts a table or keeps it as raw HTML.
25
+ function isHeadingRow(tr) {
26
+ const parent = tr.parentNode;
27
+ if (!parent) return false;
28
+ if (parent.nodeName === 'THEAD') return true;
29
+ const firstTbody =
30
+ parent.nodeName === 'TBODY' &&
31
+ (!parent.previousSibling ||
32
+ (parent.previousSibling.nodeName === 'THEAD' &&
33
+ /^\s*$/.test(parent.previousSibling.textContent)));
34
+ return (
35
+ parent.firstChild === tr &&
36
+ (parent.nodeName === 'TABLE' || firstTbody) &&
37
+ Array.prototype.every.call(tr.childNodes, n => n.nodeName === 'TH')
38
+ );
39
+ }
40
+
41
+ function isLayoutTable(node) {
42
+ return (
43
+ node.nodeName === 'TABLE' &&
44
+ !(node.rows && node.rows[0] && isHeadingRow(node.rows[0]))
45
+ );
46
+ }
47
+
48
+ function isInLayoutTable(node) {
49
+ for (let p = node.parentNode; p; p = p.parentNode) {
50
+ if (p.nodeName === 'TABLE') return isLayoutTable(p);
51
+ }
52
+ return false;
53
+ }
54
+
23
55
  function getTurndown() {
24
56
  if (_td === null) {
25
57
  _td = new TurndownService({
@@ -35,6 +67,26 @@ function getTurndown() {
35
67
  // Enable GFM extensions (tables, strikethrough, task lists)
36
68
  _td.use(gfm);
37
69
 
70
+ // turndown-plugin-gfm only converts a table whose first row is all <th>;
71
+ // every other table is passed through its `keep` filter as raw HTML. Pages
72
+ // we scrape are full of layout tables (Hacker News, older sites), so that
73
+ // leaks <table> markup into a field the caller asked for as markdown.
74
+ // Rules added here are matched before keep filters, so these reclaim the
75
+ // tables the plugin skipped and flatten them to their cell content, while
76
+ // real data tables still reach the plugin and render as pipe tables.
77
+ _td.addRule('layoutTable', {
78
+ filter: isLayoutTable,
79
+ replacement: content => '\n\n' + content.replace(/\n{3,}/g, '\n\n').trim() + '\n\n'
80
+ });
81
+ _td.addRule('layoutTableCell', {
82
+ filter: node => (node.nodeName === 'TH' || node.nodeName === 'TD') && isInLayoutTable(node),
83
+ replacement: content => (content.trim() ? content.trim() + ' ' : '')
84
+ });
85
+ _td.addRule('layoutTableRow', {
86
+ filter: node => node.nodeName === 'TR' && isInLayoutTable(node),
87
+ replacement: content => (content.trim() ? content.trim() + '\n' : '')
88
+ });
89
+
38
90
  // Remove boilerplate elements before converting
39
91
  _td.remove(['script', 'style', 'nav', 'footer', 'aside', 'noscript']);
40
92
  }