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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
3
|
+
* content Readability drops.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 };
|