crawlforge-mcp-server 5.5.9 → 5.6.0

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.5.9",
3
+ "version": "5.6.0",
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",
@@ -114,7 +114,7 @@
114
114
  "cheerio": "^1.1.2",
115
115
  "commander": "^14.0.3",
116
116
  "compromise": "^14.14.4",
117
- "crawlforge-extractors": "^1.5.3",
117
+ "crawlforge-extractors": "^1.6.0",
118
118
  "diff": "^9.0.0",
119
119
  "dotenv": "^17.2.1",
120
120
  "franc": "^6.2.0",
package/server.js CHANGED
@@ -105,7 +105,7 @@ const taskStore = createTaskStore({ logger });
105
105
  // Create the server
106
106
  const server = new McpServer({
107
107
  name: "crawlforge",
108
- version: "5.5.9",
108
+ version: "5.6.0",
109
109
  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.",
110
110
  homepage: "https://www.crawlforge.dev",
111
111
  icon: "https://www.crawlforge.dev/icon.png",
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { load } from 'cheerio';
3
+ import { readBody } from 'crawlforge-extractors';
3
4
  import { MapSiteTool } from '../tools/crawl/mapSite.js';
4
5
  import { CrawlDeepTool } from '../tools/crawl/crawlDeep.js';
5
6
  import { normalizeUrl, getBaseUrl } from '../utils/urlNormalizer.js';
@@ -240,7 +241,7 @@ export class LLMsTxtAnalyzer {
240
241
  // Look for API documentation references
241
242
  const mainPageResponse = await this.fetchWithTimeout(baseUrl);
242
243
  if (mainPageResponse.ok) {
243
- const html = await mainPageResponse.text();
244
+ const html = await readBody(mainPageResponse);
244
245
  const $ = load(html);
245
246
 
246
247
  // Find API documentation links
@@ -490,7 +491,7 @@ export class LLMsTxtAnalyzer {
490
491
  const robotsUrl = `${baseUrl}/robots.txt`;
491
492
  const response = await this.fetchWithTimeout(robotsUrl);
492
493
  if (response.ok) {
493
- return await response.text();
494
+ return await readBody(response);
494
495
  }
495
496
  } catch {
496
497
  // No robots.txt found
@@ -677,7 +678,9 @@ export class LLMsTxtAnalyzer {
677
678
  }
678
679
 
679
680
  const contentType = response.headers.get('content-type') || '';
680
- const html = await response.text();
681
+ // Charset-aware read; .text() decodes UTF-8 only and mangled
682
+ // ISO-8859-1 page titles in the generated llms.txt.
683
+ const html = await readBody(response);
681
684
  const $ = load(html);
682
685
 
683
686
  // Check for forms
@@ -1,4 +1,5 @@
1
1
  import { load } from 'cheerio';
2
+ import { readBody } from 'crawlforge-extractors';
2
3
  import { QueueManager } from '../queue/QueueManager.js';
3
4
  import { CacheManager } from '../cache/CacheManager.js';
4
5
  import { RateLimiter } from '../../utils/rateLimiter.js';
@@ -366,7 +367,9 @@ export class BFSCrawler {
366
367
  throw new Error(`Non-HTML content type: ${contentType}`);
367
368
  }
368
369
 
369
- const html = await response.text();
370
+ // readBody decodes with the body's real charset — response.text() is
371
+ // UTF-8-only and mangled ISO-8859-1 pages ("portugu�s" on lua.org).
372
+ const html = await readBody(response);
370
373
  return this.parsePage(html, url);
371
374
  } catch (error) {
372
375
  clearTimeout(timeoutId);
@@ -12,7 +12,7 @@ to enumerate at runtime. Cost: 1 credit per call.
12
12
  | `youtube-video` | YouTube watch page | title, channel, views, likes, published date, description |
13
13
  | `reddit-thread` | Reddit post (read from the Arctic Shift archive, not reddit.com, which blocks plain fetchers) | id, title, subreddit, author, score, upvote_ratio, num_comments, posted, body, link_url, flair, removed. Pass `id` to `reddit_search` with `mode:"thread"` for the comment tree |
14
14
  | `hacker-news-front-page` | HN front page | ranked stories: title, URL, points, author, comment count |
15
- | `producthunt-launch` | Product Hunt launch | name, tagline, upvotes, maker, description |
15
+ | `producthunt-launch` | Product Hunt product page (`/products/...`; old `/posts/...` links redirect there) | name, tagline, description, topics, website, followers, reviews_count, reviews_rating. The /products layout has no product-level vote count — followers and reviews are its engagement numbers |
16
16
  | `stackoverflow-question` | Stack Overflow question (read from the keyless Stack Exchange API, not the page) | title, body, votes, views, tags, author, asked, answered, accepted_answer_id, answer_count, top 5 answers (accepted first: votes, author, posted, body) |
17
17
  | `npm-package` | npm package page | name, version, description, weekly downloads, license, repo link |
18
18
 
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { load } from 'cheerio';
8
+ import { readBody } from 'crawlforge-extractors';
8
9
  import { config as appConfig } from '../../../constants/config.js';
9
10
  import { ssrfGuard, isSsrfError } from '../../../utils/ssrfGuard.js';
10
11
  import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
@@ -36,7 +37,10 @@ export async function fetchUrl(url, options = {}) {
36
37
  if (response.status === 429 || response.status === 503) {
37
38
  noteRetryAfter(url, response.headers?.get?.('retry-after'));
38
39
  }
39
- const html = await readBodyCapped(response, maxBodySize);
40
+ // crawlforge-extractors' readBody: same size cap, but decoded with the
41
+ // body's real charset — the local reader this replaces decoded everything
42
+ // as UTF-8, which turned lua.org's ISO-8859-1 "português" into "portugu�s".
43
+ const html = await readBody(response, { maxBytes: maxBodySize });
40
44
  return { response, html, warnings: gate.warnings };
41
45
  } catch (error) {
42
46
  if (isSsrfError(error)) throw new Error(error.cause?.message || error.message);
@@ -50,47 +54,6 @@ export async function fetchUrl(url, options = {}) {
50
54
  }
51
55
  }
52
56
 
53
- /**
54
- * Read a Response body as text, enforcing maxBodySize (Content-Length
55
- * pre-check, then a running byte count while streaming). Falls back to plain
56
- * .text() for responses without a streamable body (e.g. test mocks).
57
- */
58
- async function readBodyCapped(response, maxBodySize) {
59
- const contentLengthHeader = response.headers?.get?.('content-length') ?? null;
60
- if (contentLengthHeader !== null) {
61
- const declared = parseInt(contentLengthHeader, 10);
62
- if (!isNaN(declared) && declared > maxBodySize) {
63
- throw new Error(`Response body too large: Content-Length ${declared} exceeds limit of ${maxBodySize} bytes`);
64
- }
65
- }
66
-
67
- if (!response.body || typeof response.body.getReader !== 'function') {
68
- return response.text();
69
- }
70
-
71
- const reader = response.body.getReader();
72
- const chunks = [];
73
- let totalBytes = 0;
74
- while (true) {
75
- const { done, value } = await reader.read();
76
- if (done) break;
77
- totalBytes += value.byteLength;
78
- if (totalBytes > maxBodySize) {
79
- reader.cancel();
80
- throw new Error(`Response body too large: exceeded limit of ${maxBodySize} bytes`);
81
- }
82
- chunks.push(value);
83
- }
84
-
85
- const merged = new Uint8Array(totalBytes);
86
- let offset = 0;
87
- for (const chunk of chunks) {
88
- merged.set(chunk, offset);
89
- offset += chunk.byteLength;
90
- }
91
- return new TextDecoder().decode(merged);
92
- }
93
-
94
57
  /**
95
58
  * Scrape a single URL and return a result object.
96
59
  */
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { load } from 'cheerio';
3
+ import { readBody } from 'crawlforge-extractors';
3
4
  import { LLMsTxtAnalyzer } from '../../core/LLMsTxtAnalyzer.js';
4
5
  import { Logger } from '../../utils/Logger.js';
5
6
  import { getBaseUrl } from '../../utils/urlNormalizer.js';
@@ -382,7 +383,8 @@ export class GenerateLLMsTxtTool {
382
383
  headers: { ...gate.headers }
383
384
  });
384
385
  if (!response.ok) return null;
385
- return this.extractHomePageMetadata(await response.text());
386
+ // Charset-aware read; .text() decodes UTF-8 only.
387
+ return this.extractHomePageMetadata(await readBody(response));
386
388
  } catch {
387
389
  return null;
388
390
  } finally {
@@ -364,12 +364,15 @@ export class RedditSearchTool {
364
364
  try {
365
365
  data = await this.#get(`${this.arcticBaseUrl}${path}`, query);
366
366
  } catch (error) {
367
- // Arctic Shift sheds a scoped comment keyword search over its whole
368
- // history with the same 422 "Timeout. Maybe slow down a bit" it uses for
369
- // throttling (observed live 2026-08-30: r/webdev body=react failed
370
- // unbounded and at 7d, answered at 3d). When the caller set no window,
371
- // narrow it step by step rather than hand the caller the 422.
372
- const narrowable = v.mode === 'comments' && Boolean(v.query) && !v.after;
367
+ // Arctic Shift sheds a scoped keyword search over its whole history
368
+ // with the same 422 "Timeout. Maybe slow down a bit" it uses for
369
+ // throttling (observed live 2026-08-30: r/webdev comments body=react
370
+ // failed unbounded and at 7d, answered at 3d; 2026-09-01: an
371
+ // r/selfhosted posts search failed unbounded and answered at 7d). When
372
+ // the caller set no window, narrow it step by step rather than hand the
373
+ // caller the 422. Thread mode returned above, so both remaining modes
374
+ // (posts, comments) narrow.
375
+ const narrowable = Boolean(v.query) && !v.after;
373
376
  if (!error.retryable || !narrowable) throw error;
374
377
  let lastError = error;
375
378
  for (const window of ['7d', '3d', '1d']) {
@@ -386,7 +389,7 @@ export class RedditSearchTool {
386
389
  if (!data) {
387
390
  throw new Error(
388
391
  `${lastError.message} — Arctic Shift timed out at every window (full history, 7d, 3d, 1d), ` +
389
- 'so its comment search is under load right now. Retry later, pass a narrower after/before, or scope to one post with link_id.'
392
+ 'so its search is under load right now. Retry later, pass a narrower after/before, or scope to one post with link_id.'
390
393
  );
391
394
  }
392
395
  notes.push(`Arctic Shift timed out searching the full history; results are limited to the last ${windowApplied}. Pass after/before to choose the window.`);
@@ -3,6 +3,7 @@
3
3
  * URL content fetching and history/stat helper functions.
4
4
  */
5
5
 
6
+ import { readBody } from 'crawlforge-extractors';
6
7
  import { safeFetch } from '../../../utils/ssrfGuard.js';
7
8
  import { preflightFetch } from '../../../utils/robotsGate.js';
8
9
  import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
@@ -81,7 +82,10 @@ export async function fetchContent(url, options = {}) {
81
82
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
82
83
  }
83
84
 
84
- const content = await response.text();
85
+ // Charset-aware read; .text() decodes UTF-8 only, and mojibake here
86
+ // would both corrupt the reported diff and fake changes on a page whose
87
+ // bytes never moved.
88
+ const content = await readBody(response);
85
89
 
86
90
  return {
87
91
  content,
@@ -96,7 +100,11 @@ export async function fetchContent(url, options = {}) {
96
100
  }
97
101
  };
98
102
  } catch (error) {
99
- throw new Error(`Failed to fetch content: ${error.message}`);
103
+ // undici reports every network failure as bare "fetch failed" and puts
104
+ // the real reason (ENOTFOUND, ECONNREFUSED, timeout) in error.cause —
105
+ // without it, a dead host and a typoed domain read identically.
106
+ const cause = error.cause?.message ? ` (${error.cause.message})` : '';
107
+ throw new Error(`Failed to fetch content: ${error.message}${cause}`);
100
108
  }
101
109
  }
102
110
 
@@ -112,6 +112,61 @@ function specificity(selector) {
112
112
  return ids * 10000 + classes * 100 + elements;
113
113
  }
114
114
 
115
+ /**
116
+ * Iterate a stylesheet's top-level rules as {selector, body}, skipping any
117
+ * block nested inside a rule's body.
118
+ *
119
+ * The flat regex this replaces — ([^{}]+)\{([^{}]*)\} — had no concept of
120
+ * native CSS nesting, so it read a nested rule as a top-level one under its
121
+ * inner selector. legalblogs.wolterskluwer.com ships
122
+ * .cg-splitpane-aside-container.cg-is-closed .cg-splitpane-aside-header-cstm{
123
+ * ... h6{display:none;} a{display:none;} }
124
+ * — links hidden only in a closed sidebar pane — which the stripper read as a
125
+ * bare a{display:none} and deleted 56 of an article's 68 anchors. Nested rules
126
+ * are skipped rather than resolved: expanding them into full descendant
127
+ * selectors needs a real CSS parser, and skipping errs on keeping content.
128
+ * A rule left unclosed at end of input (stylesheets are size-capped upstream,
129
+ * so truncation mid-rule is real) is dropped for the same reason.
130
+ */
131
+ function* topLevelRules(css) {
132
+ const n = css.length;
133
+ let i = 0;
134
+ let selStart = 0;
135
+ while (i < n) {
136
+ const ch = css[i];
137
+ if (ch === '{') {
138
+ const selector = css.slice(selStart, i);
139
+ let depth = 1;
140
+ let j = i + 1;
141
+ let segStart = j;
142
+ let body = '';
143
+ while (j < n && depth > 0) {
144
+ const c = css[j];
145
+ if (c === '{') {
146
+ if (depth === 1) {
147
+ // The text since the last ';' is the nested rule's selector, not a
148
+ // declaration of this rule — keep only the completed declarations.
149
+ const seg = css.slice(segStart, j);
150
+ body += seg.slice(0, seg.lastIndexOf(';') + 1);
151
+ }
152
+ depth++;
153
+ } else if (c === '}') {
154
+ depth--;
155
+ if (depth === 1) segStart = j + 1;
156
+ else if (depth === 0) body += css.slice(segStart, j);
157
+ }
158
+ j++;
159
+ }
160
+ if (depth === 0) yield { selector, body };
161
+ i = j;
162
+ selStart = j;
163
+ } else {
164
+ if (ch === '}') selStart = i + 1; // stray close — resync
165
+ i++;
166
+ }
167
+ }
168
+ }
169
+
115
170
  /**
116
171
  * Parse CSS into rules that hide content and rules that re-show it, each
117
172
  * carrying the specificity and source order a browser would use to resolve
@@ -125,13 +180,9 @@ export function collectVisibilitySelectors(css) {
125
180
  if (!css) return { hide, show };
126
181
 
127
182
  const flat = stripConditionalBlocks(css);
128
- const rule = /([^{}]+)\{([^{}]*)\}/g;
129
- let m;
130
183
  let order = 0;
131
184
 
132
- while ((m = rule.exec(flat)) !== null) {
133
- const selectorList = m[1];
134
- const body = m[2];
185
+ for (const { selector: selectorList, body } of topLevelRules(flat)) {
135
186
  order++;
136
187
 
137
188
  const display = /(?:^|[;{\s])display\s*:\s*([a-z-]+)/i.exec(body);
@@ -214,12 +214,17 @@ export function detectLanguage(text, options = {}) {
214
214
 
215
215
  const confidence = Math.min(1, 0.5 + (text.length / 500) * 0.5);
216
216
 
217
+ // francAll scores are similarity relative to the winner (whose own score
218
+ // is 1), while `confidence` above is length-based — two different scales.
219
+ // Printed raw, a runner-up could read 0.82 against a winner shown at 0.8.
220
+ // Scaling by the winner's confidence keeps the ranking and the invariant
221
+ // that no alternative outranks the pick.
217
222
  const alternative = francAll(text, { minLength, whitelist: Object.keys(LANGUAGE_NAMES) })
218
223
  .slice(1, 4)
219
224
  .map(([code, score]) => ({
220
225
  code,
221
226
  name: LANGUAGE_NAMES[code] || code,
222
- confidence: Math.round(score * 100) / 100
227
+ confidence: Math.round(score * confidence * 100) / 100
223
228
  }));
224
229
 
225
230
  return {