crawlforge-mcp-server 5.5.1 → 5.5.3

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.1",
3
+ "version": "5.5.3",
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
@@ -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.1",
108
+ version: "5.5.3",
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",
@@ -1412,7 +1412,13 @@ registerToolIfEnabled("stealth_mode", {
1412
1412
  result = { disabled: true };
1413
1413
  break;
1414
1414
  case 'create_context': {
1415
- const contextData = await stealthBrowserManager.createStealthContext(stealthConfig);
1415
+ // Forward the tool-level engine the same way operation:"scrape" does —
1416
+ // without it, create_context always ran on chromium whatever the caller
1417
+ // asked for ("playwright" is this tool's public name for chromium).
1418
+ const contextData = await stealthBrowserManager.createStealthContext({
1419
+ ...(stealthConfig || {}),
1420
+ engine: engine === 'camoufox' ? 'camoufox' : 'chromium'
1421
+ });
1416
1422
  // The full fingerprint is ~4 KB of canvas noise arrays and WebGL
1417
1423
  // extension lists no caller acts on. Summarise by default; verbose:true
1418
1424
  // still returns all of it for debugging a detection failure.
@@ -434,9 +434,11 @@ export class StealthBrowserManager {
434
434
  async createStealthContext(config = {}) {
435
435
  const validatedConfig = StealthConfigSchema.parse({ ...this.defaultConfig, ...config });
436
436
 
437
- if (!this.browser) {
438
- await this.launchStealthBrowser(validatedConfig);
439
- }
437
+ // Always go through launchStealthBrowser: it returns the running browser
438
+ // when the engine matches, and closes + relaunches on an engine mismatch.
439
+ // Guarding on `!this.browser` here skipped that mismatch check, so a
440
+ // camoufox request silently reused an already-running chromium browser.
441
+ await this.launchStealthBrowser(validatedConfig);
440
442
 
441
443
  // Generate fingerprint for this context
442
444
  const fingerprint = this.generateAdvancedFingerprint(validatedConfig);
@@ -192,6 +192,14 @@ export class ExtractStructuredTool {
192
192
  // the model was shown would null every correct price.
193
193
  const fullSource = `${html}\n${textContent}`;
194
194
 
195
+ // A model that saw no answer sometimes fills a required field with a
196
+ // placeholder ("N/A", "unknown") instead of null. That carries no digits,
197
+ // so the provenance guard has nothing to check and the retry below never
198
+ // fired — git-scm.com is the case that found this: Readability drops the
199
+ // version box, the model answered "N/A", and the page plainly says 2.55.0.
200
+ const isPlaceholder = (v) => v == null ||
201
+ (typeof v === 'string' && (v.trim() === '' || /^(n\/?a|none|null|unknown|not (available|found|specified))$/i.test(v.trim())));
202
+
195
203
  /** Run the guard over one extraction and describe what it did. */
196
204
  const applyGuard = (result) => {
197
205
  const checked = verifyNumericProvenance(result.data || {}, fullSource);
@@ -201,9 +209,14 @@ export class ExtractStructuredTool {
201
209
  const nulledRequired = checked.unverified
202
210
  .map((entry) => entry.path)
203
211
  .filter((path) => (schema.required || []).includes(path));
212
+ // Retry currency: required fields with no usable answer, whether the
213
+ // guard nulled them or the model handed back a placeholder.
214
+ const missingRequired = (schema.required || [])
215
+ .filter((path) => nulledRequired.includes(path) || isPlaceholder(checked.data?.[path]));
204
216
  return {
205
217
  checked,
206
218
  nulledRequired,
219
+ missingRequired,
207
220
  result: {
208
221
  ...result,
209
222
  data: checked.data,
@@ -234,7 +247,7 @@ export class ExtractStructuredTool {
234
247
  // which is exactly what the guard will accept. Only a strictly better
235
248
  // result is kept, so a retry can never make the answer worse.
236
249
  const shownText = mainContentText($, html, url) || textContent;
237
- if (guarded.nulledRequired.length > 0 && shownText !== textContent && textContent) {
250
+ if (guarded.missingRequired.length > 0 && shownText !== textContent && textContent) {
238
251
  try {
239
252
  const llm = this._ensureLLMManager(llmConfig || {});
240
253
  const retry = await llm.extractStructured(textContent, schema, {
@@ -243,7 +256,7 @@ export class ExtractStructuredTool {
243
256
  });
244
257
  if (retry?.method === 'llm') {
245
258
  const retryGuarded = applyGuard(retry);
246
- if (retryGuarded.nulledRequired.length < guarded.nulledRequired.length) {
259
+ if (retryGuarded.missingRequired.length < guarded.missingRequired.length) {
247
260
  guarded = retryGuarded;
248
261
  }
249
262
  }
@@ -1,14 +1,39 @@
1
1
  /**
2
2
  * _mainContent.js — Readability main-content extraction, plus recovery of the
3
- * data tables Readability drops.
3
+ * content Readability drops.
4
4
  *
5
- * Readability keeps one article candidate and discards everything outside it.
6
- * On a table-led page that silently loses the payload: Wikipedia's *List of
7
- * S&P 500 companies* came back with zero table rows at scrape's default
8
- * onlyMainContent:true, and 505 pipe-table lines with it off. No Readability
9
- * option recovers them (charThreshold:100 and nbTopCandidates:20 were both
10
- * probed) — the tables are simply not in the candidate — so they are
11
- * re-attached afterwards.
5
+ * Two independent losses are repaired here.
6
+ *
7
+ * 1. Readability keeps one article candidate and discards everything outside
8
+ * it. On a table-led page that silently loses the payload: Wikipedia's
9
+ * *List of S&P 500 companies* came back with zero table rows at scrape's
10
+ * default onlyMainContent:true, and 505 pipe-table lines with it off. No
11
+ * Readability option recovers them (charThreshold:100 and nbTopCandidates:20
12
+ * were both probed) — the tables are simply not in the candidate — so they
13
+ * are re-attached afterwards.
14
+ *
15
+ * 2. Readability's class regexes are unanchored, so they fire on words their
16
+ * author never meant. Two collisions between them deleted every code
17
+ * example on next-intl.dev/docs/routing/setup:
18
+ *
19
+ * - `unlikelyCandidates` contains `extra`, which matches inside `nextra-*`
20
+ * — the class prefix on every element Nextra emits, and Nextra builds a
21
+ * large share of the JS ecosystem's documentation. None of those classes
22
+ * match `okMaybeItsACandidate`, so the elements are deleted before
23
+ * scoring runs: all 96 inline `<code>` spans and the "setRequestLocale
24
+ * is legacy" callout went with them.
25
+ * - `negative` contains `hidden`, which matches Tailwind's
26
+ * `overflow-hidden`. That is only -25 class weight, but it is enough for
27
+ * `_cleanConditionally` to drop the wrapper around all 12 `<pre>` blocks.
28
+ *
29
+ * Both losses were silent, and the second is the dangerous one: the prose
30
+ * survived, so the page read as complete while every code example and the
31
+ * warning callout were gone, and sentences came back with holes where their
32
+ * inline code had been ("In order to use unique pathnames …, can be used to
33
+ * handle"). `sanitizeAccidentalClasses` strips just those class tokens
34
+ * before Readability sees the document, restoring 11 of the 12 blocks — the
35
+ * 12th sits inside a collapsed `<details>`, which Readability drops whole
36
+ * and which this deliberately does not try to rescue.
12
37
  *
13
38
  * Shared by unifiedScrape (wants HTML) and extractStructured (wants text).
14
39
  */
@@ -20,10 +45,108 @@ import { Readability } from '@mozilla/readability';
20
45
  // Readability already kept it. Long enough to be unique to that table.
21
46
  const SIGNATURE_LENGTH = 120;
22
47
 
48
+ // Copied verbatim from @mozilla/readability's REGEXPS. Two of them delete
49
+ // content, and both are unanchored: `unlikelyCandidates` removes an element
50
+ // outright, `negative` docks 25 from its class weight, which is enough for
51
+ // `_cleanConditionally` to drop it. The unit test asserts the real library
52
+ // still behaves this way, so a Readability upgrade that fixes it upstream
53
+ // fails loudly here rather than leaving a no-op pass behind.
54
+ const UNLIKELY_SOURCE =
55
+ '-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote';
56
+ const NEGATIVE_SOURCE =
57
+ '-ad-|hidden|^hid$| hid$| hid |^hidden|banner|combx|comment|com-|contact|footer|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|widget';
58
+ const REMOVAL_SOURCES = [UNLIKELY_SOURCE, NEGATIVE_SOURCE];
59
+ // Readability's `positive` regex. A token matching it carries a signal worth
60
+ // keeping, so it is never stripped even if it also matches mid-word.
61
+ const POSITIVE_SIGNAL =
62
+ /article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/i;
63
+
64
+ // Utility classes that collide with the `negative` regex on a whole word while
65
+ // meaning something else entirely. Tailwind's `overflow-hidden` clips overflow;
66
+ // it does not mark the element hidden. The mid-word rule below cannot catch
67
+ // these — `hidden` really is a whole segment of `overflow-hidden` — and the
68
+ // cost is real: -25 class weight is enough for `_cleanConditionally` to delete
69
+ // the element, which is what removed every code block on next-intl.dev after
70
+ // the `nextra-` collision had already been handled. Listed explicitly rather
71
+ // than inferred, because `page-footer` and `main-sidebar` are the same shape
72
+ // and *are* genuine.
73
+ const UTILITY_FALSE_POSITIVES = new Set([
74
+ 'overflow-hidden',
75
+ 'overflow-x-hidden',
76
+ 'overflow-y-hidden',
77
+ 'overflow-scroll',
78
+ 'overflow-x-scroll',
79
+ 'overflow-y-scroll',
80
+ 'scroll-smooth',
81
+ 'scroll-auto'
82
+ ]);
83
+
23
84
  function normalizeWhitespace(text) {
24
85
  return text.replace(/\s+/g, ' ').trim();
25
86
  }
26
87
 
88
+ /**
89
+ * True when `token` only ever trips Readability's removal regexes inside a
90
+ * longer word — `extra` within `nextra`, `hidden` within Tailwind's
91
+ * `overflow-hidden`.
92
+ *
93
+ * Only a *preceding* letter or digit counts as mid-word. A trailing one does
94
+ * not, because that is how English suffixes attach: rescuing `comments`,
95
+ * `banners` or `footers` would defeat the regexes on exactly the clutter they
96
+ * were written to catch, whereas a letter run in front of the match means a
97
+ * different word entirely.
98
+ *
99
+ * @param {string} token - one class name
100
+ * @returns {boolean}
101
+ */
102
+ function isAccidentalToken(token) {
103
+ if (UTILITY_FALSE_POSITIVES.has(token.toLowerCase())) return true;
104
+ if (POSITIVE_SIGNAL.test(token)) return false;
105
+ let sawMatch = false;
106
+ for (const source of REMOVAL_SOURCES) {
107
+ const scan = new RegExp(source, 'gi');
108
+ let match;
109
+ while ((match = scan.exec(token)) !== null) {
110
+ if (match[0].length === 0) {
111
+ scan.lastIndex += 1;
112
+ continue;
113
+ }
114
+ sawMatch = true;
115
+ const before = token[match.index - 1];
116
+ if (before === undefined || !/[a-z0-9]/i.test(before)) return false;
117
+ }
118
+ }
119
+ return sawMatch;
120
+ }
121
+
122
+ /**
123
+ * Strip the class tokens that would make Readability discard an element over a
124
+ * word the class does not actually contain.
125
+ *
126
+ * Only whole tokens that are accidental on every match are removed, so a class
127
+ * carrying a real signal survives: an element still genuinely marked `sidebar`
128
+ * or `hidden` is still dropped, as it should be. Ids are left alone — they
129
+ * anchor in-page links, and the class attribute is what these frameworks put
130
+ * their names on.
131
+ *
132
+ * @param {Document} document - mutated in place
133
+ * @returns {number} how many elements were sanitized
134
+ */
135
+ export function sanitizeAccidentalClasses(document) {
136
+ let sanitized = 0;
137
+ for (const el of Array.from(document.querySelectorAll('[class]'))) {
138
+ const className = el.getAttribute('class') || '';
139
+ const tokens = className.split(/\s+/).filter(Boolean);
140
+ const kept = tokens.filter((token) => !isAccidentalToken(token));
141
+ if (kept.length === tokens.length) continue;
142
+
143
+ if (kept.length > 0) el.setAttribute('class', kept.join(' '));
144
+ else el.removeAttribute('class');
145
+ sanitized += 1;
146
+ }
147
+ return sanitized;
148
+ }
149
+
27
150
  /**
28
151
  * Readability's own data-table test (`_markDataTables`): 10+ rows or more than
29
152
  * 4 columns means the table carries data rather than layout, counting
@@ -61,6 +184,7 @@ export function extractMainContent(html, url) {
61
184
  let article;
62
185
  try {
63
186
  const dom = new JSDOM(html, { url });
187
+ sanitizeAccidentalClasses(dom.window.document);
64
188
  article = new Readability(dom.window.document).parse();
65
189
  } catch {
66
190
  return { html: null, title: '', tablesRecovered: 0 };