crawlforge-mcp-server 5.5.1 → 5.5.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/package.json +1 -1
- package/server.js +1 -1
- package/src/tools/scrape/_mainContent.js +132 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.5.
|
|
3
|
+
"version": "5.5.2",
|
|
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.2",
|
|
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,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 };
|