crawlforge-mcp-server 5.2.2 → 5.2.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/CLAUDE.md +1 -1
- package/package.json +2 -2
- package/server.js +10 -6
- package/src/tools/search/redditSearch.js +110 -8
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.
|
|
65
|
+
**Current Version:** 5.2.3
|
|
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.
|
|
3
|
+
"version": "5.2.3",
|
|
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.
|
|
116
|
+
"crawlforge-extractors": "^1.2.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.
|
|
103
|
+
version: "5.2.3",
|
|
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",
|
|
@@ -185,9 +185,13 @@ const searchWebTool = new SearchWebTool(searchWebToolConfig);
|
|
|
185
185
|
// separate from CrawlForge billing — no getToolConfig needed. Degrades gracefully
|
|
186
186
|
// when unconfigured (returns { configured: false } instead of throwing).
|
|
187
187
|
const serpRankTool = new SerpRankTool();
|
|
188
|
-
// reddit_search
|
|
189
|
-
//
|
|
190
|
-
|
|
188
|
+
// reddit_search reads the free Arctic Shift community archive — reddit.com
|
|
189
|
+
// itself blocks scrapers. Arctic Shift cannot keyword-search across all of
|
|
190
|
+
// Reddit, so a Reddit-wide search discovers posts through the same search
|
|
191
|
+
// provider search_web uses, then reads those posts from the archive.
|
|
192
|
+
const redditSearchTool = new RedditSearchTool({
|
|
193
|
+
searchApiKey: searchWebToolConfig.apiKey,
|
|
194
|
+
});
|
|
191
195
|
const crawlDeepTool = new CrawlDeepTool(getToolConfig('crawl_deep'));
|
|
192
196
|
const mapSiteTool = new MapSiteTool(getToolConfig('map_site'));
|
|
193
197
|
const extractContentTool = new ExtractContentTool();
|
|
@@ -450,7 +454,7 @@ registerToolIfEnabled("serp_rank", {
|
|
|
450
454
|
|
|
451
455
|
// Tool: reddit_search — search Reddit posts/comments or read a full thread (via community archives)
|
|
452
456
|
registerToolIfEnabled("reddit_search", {
|
|
453
|
-
description: "Use this to search Reddit posts or comments, or read a full comment thread — reddit.com blocks direct scraping, so this
|
|
457
|
+
description: "Use this to search Reddit posts or comments, or read a full comment thread — reddit.com blocks direct scraping, so this reads the Arctic Shift community archive instead (free, no Reddit credentials). Modes: 'posts' (default) and 'comments' search; 'thread' returns a post plus its nested comment tree by link_id. A subreddit/author-scoped search queries the archive directly. A keyword search across ALL of Reddit finds posts with a site-restricted web search and then reads those posts from the archive, because Arctic Shift can only keyword-search within a scope; results come back as real archive rows, ordered by search relevance. An unscoped COMMENT search has no backend — scope it or search posts. Example: reddit_search({query: \"best mechanical keyboard\", subreddit: \"MechanicalKeyboards\", limit: 10})",
|
|
454
458
|
annotations: { title: "Reddit Search", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
455
459
|
inputSchema: {
|
|
456
460
|
query: z.string().optional().describe("Keyword search. Posts: matches title+selftext; comments: matches body. Supports \"quoted phrases\", OR, -exclusion"),
|
|
@@ -1402,7 +1406,7 @@ registerToolIfEnabled("localization", {
|
|
|
1402
1406
|
|
|
1403
1407
|
// Tool: scrape_template (D3.3 — pre-built site templates)
|
|
1404
1408
|
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: 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\"})",
|
|
1409
|
+
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 (read from the npm registry API rather than the npmjs.com page, which blocks plain fetches). Example: scrape_template({template:\"github-repo\", url:\"https://github.com/user/repo\"})",
|
|
1406
1410
|
annotations: { title: "Scrape Template", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1407
1411
|
inputSchema: {
|
|
1408
1412
|
template: z.string().describe("Template ID (e.g. github-repo) or list to enumerate available templates"),
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
stripNamePrefix,
|
|
39
39
|
} from './redditNormalize.js';
|
|
40
40
|
import { RedditOfficialApiAdapter } from './adapters/redditOfficialApi.js';
|
|
41
|
+
import { SearchProviderFactory } from './adapters/searchProviderFactory.js';
|
|
41
42
|
|
|
42
43
|
const ARCTIC_SHIFT_BASE = 'https://arctic-shift.photon-reddit.com';
|
|
43
44
|
const PULLPUSH_BASE = 'https://api.pullpush.io';
|
|
@@ -59,7 +60,7 @@ const RedditSearchSchema = z.object({
|
|
|
59
60
|
before: z.string().min(1).optional(),
|
|
60
61
|
limit: z.number().int().min(1).max(100).optional().default(25),
|
|
61
62
|
sort: z.enum(['asc', 'desc']).optional().default('desc'),
|
|
62
|
-
source: z.enum(['auto', 'arctic_shift', 'pullpush', 'reddit_api']).optional().default('auto'),
|
|
63
|
+
source: z.enum(['auto', 'arctic_shift', 'pullpush', 'reddit_api', 'web_discovery']).optional().default('auto'),
|
|
63
64
|
});
|
|
64
65
|
|
|
65
66
|
/**
|
|
@@ -94,6 +95,20 @@ export class RedditSearchTool {
|
|
|
94
95
|
this.officialConfigured = Boolean(this.redditClientId && this.redditClientSecret);
|
|
95
96
|
// Lazily constructed on first official-path use; overridable for tests.
|
|
96
97
|
this._officialAdapter = options.officialAdapter || null;
|
|
98
|
+
|
|
99
|
+
// Web discovery serves the one shape no archive can: a keyword search
|
|
100
|
+
// across all of Reddit. Arctic Shift requires a subreddit or author scope,
|
|
101
|
+
// and PullPush stopped serving automated clients in August 2026.
|
|
102
|
+
this.searchAdapter = options.searchAdapter || null;
|
|
103
|
+
this.searchApiKey = options.searchApiKey || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The web-search adapter used to discover posts, built once on first use. */
|
|
107
|
+
#search() {
|
|
108
|
+
if (!this.searchAdapter) {
|
|
109
|
+
this.searchAdapter = SearchProviderFactory.createAdapter(this.searchApiKey);
|
|
110
|
+
}
|
|
111
|
+
return this.searchAdapter;
|
|
97
112
|
}
|
|
98
113
|
|
|
99
114
|
/** The official-API adapter, built once from the configured credentials. */
|
|
@@ -127,8 +142,17 @@ export class RedditSearchTool {
|
|
|
127
142
|
// comment full-text search, so `comments` mode always uses the archives.
|
|
128
143
|
const officialPossible = this.officialConfigured && (v.mode === 'thread' || v.mode === 'posts');
|
|
129
144
|
|
|
145
|
+
// A Reddit-wide keyword search for posts can be discovered through a web
|
|
146
|
+
// search and then read out of the archive by ID.
|
|
147
|
+
const discoveryPossible = v.mode === 'posts' && Boolean(v.query) && !subreddit && !author;
|
|
148
|
+
|
|
130
149
|
let order; // backends to try, in order
|
|
131
|
-
if (v.source === '
|
|
150
|
+
if (v.source === 'web_discovery') {
|
|
151
|
+
if (!discoveryPossible) {
|
|
152
|
+
throw new Error('web_discovery only serves unscoped keyword searches in posts mode — it finds posts through a web search and then reads them from the archive');
|
|
153
|
+
}
|
|
154
|
+
order = ['web_discovery'];
|
|
155
|
+
} else if (v.source === 'reddit_api') {
|
|
132
156
|
if (!this.officialConfigured) {
|
|
133
157
|
throw new Error('source:"reddit_api" needs Reddit app credentials — set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET (create a "script" app at https://www.reddit.com/prefs/apps)');
|
|
134
158
|
}
|
|
@@ -147,12 +171,24 @@ export class RedditSearchTool {
|
|
|
147
171
|
} else {
|
|
148
172
|
// auto: prefer the user's own official API (live, authoritative) when it
|
|
149
173
|
// can serve this request, then fall back to the community archives.
|
|
174
|
+
// PullPush is no longer tried automatically: every request now returns
|
|
175
|
+
// 429 "This website does not provide free scraping resources for agents",
|
|
176
|
+
// or a Cloudflare 403 challenge. It stays available on explicit request.
|
|
150
177
|
const archives = v.mode === 'thread' ? ['arctic_shift']
|
|
151
|
-
: arcticPossible ? ['arctic_shift'
|
|
152
|
-
: ['
|
|
178
|
+
: arcticPossible ? ['arctic_shift']
|
|
179
|
+
: discoveryPossible ? ['web_discovery']
|
|
180
|
+
: [];
|
|
153
181
|
order = officialPossible ? ['reddit_api', ...archives] : archives;
|
|
154
182
|
}
|
|
155
183
|
|
|
184
|
+
if (order.length === 0) {
|
|
185
|
+
// Unscoped comment search was PullPush-only, and PullPush no longer
|
|
186
|
+
// serves automated clients. Web discovery identifies posts, not comments.
|
|
187
|
+
throw new Error(
|
|
188
|
+
`An unscoped keyword search of ${v.mode} has no available backend: Arctic Shift requires a subreddit or author scope, and PullPush no longer serves automated clients. Add a subreddit or author filter, or search mode:"posts", which finds posts through a web search and reads them from the archive.`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
156
192
|
const errors = [];
|
|
157
193
|
for (const source of order) {
|
|
158
194
|
try {
|
|
@@ -161,6 +197,8 @@ export class RedditSearchTool {
|
|
|
161
197
|
result = v.mode === 'thread'
|
|
162
198
|
? await this.#official().getThread(v)
|
|
163
199
|
: await this.#official().searchPosts(v, { subreddit, author });
|
|
200
|
+
} else if (source === 'web_discovery') {
|
|
201
|
+
result = await this.#searchWebDiscovery(v);
|
|
164
202
|
} else if (source === 'arctic_shift') {
|
|
165
203
|
result = await this.#searchArcticShift(v, { subreddit, author });
|
|
166
204
|
} else {
|
|
@@ -172,14 +210,78 @@ export class RedditSearchTool {
|
|
|
172
210
|
errors.push(`${source}: ${error.message}`);
|
|
173
211
|
}
|
|
174
212
|
}
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
const hint = order.
|
|
178
|
-
? ' Tip: add a subreddit or author filter to
|
|
213
|
+
// Arctic Shift requires a scope (verified live: HTTP 400 without one), so an
|
|
214
|
+
// unscoped query that got this far has exhausted its only route.
|
|
215
|
+
const hint = order.includes('web_discovery')
|
|
216
|
+
? ' Tip: add a subreddit or author filter to search the Arctic Shift archive directly.'
|
|
179
217
|
: '';
|
|
180
218
|
throw new Error(`All Reddit sources failed — ${errors.join('; ')}.${hint}`);
|
|
181
219
|
}
|
|
182
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Reddit-wide keyword search, in two steps: find matching posts with a
|
|
223
|
+
* site-restricted web search, then read those posts out of the Arctic Shift
|
|
224
|
+
* archive by ID. Arctic Shift cannot keyword-search across all of Reddit and
|
|
225
|
+
* reddit.com blocks scrapers, so discovery has to come from somewhere else —
|
|
226
|
+
* but what comes back are real archive rows, the same shape a scoped search
|
|
227
|
+
* returns, not scraped search-engine snippets.
|
|
228
|
+
*/
|
|
229
|
+
async #searchWebDiscovery(v) {
|
|
230
|
+
let found;
|
|
231
|
+
try {
|
|
232
|
+
found = await this.#search().search({
|
|
233
|
+
query: `site:reddit.com ${v.query}`,
|
|
234
|
+
num: Math.min(Math.max(v.limit, 1), 10), // one call caps at 10 results
|
|
235
|
+
start: 1,
|
|
236
|
+
});
|
|
237
|
+
} catch (error) {
|
|
238
|
+
throw new Error(`web search failed: ${error.message}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// /r/<sub>/comments/<id>/<slug> is the only Reddit URL shape naming a post.
|
|
242
|
+
const ids = [];
|
|
243
|
+
for (const item of found?.items ?? []) {
|
|
244
|
+
const id = /\/comments\/([a-z0-9]+)/i.exec(item?.link ?? item?.url ?? '')?.[1];
|
|
245
|
+
if (id && !ids.includes(id)) ids.push(id);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const notes = [
|
|
249
|
+
'Reddit-wide keyword search: posts were found with a site-restricted web search, then read from the Arctic Shift archive by ID.',
|
|
250
|
+
'Results are ordered by web-search relevance, not by score or date.',
|
|
251
|
+
'Arctic Shift cannot keyword-search across all of Reddit, and PullPush no longer serves automated clients — scope the search to a subreddit or author to query the archive directly.',
|
|
252
|
+
];
|
|
253
|
+
if (v.after || v.before) {
|
|
254
|
+
// Silently dropping a date filter would return results the caller
|
|
255
|
+
// believes were filtered.
|
|
256
|
+
notes.push('after/before were NOT applied: discovery runs through a web search, which cannot filter by post date. Scope the search to a subreddit or author to use date filters.');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (ids.length === 0) {
|
|
260
|
+
return {
|
|
261
|
+
source: 'web_discovery', mode: 'posts',
|
|
262
|
+
query: v.query ?? null, subreddit: null, author: null,
|
|
263
|
+
count: 0, results: [],
|
|
264
|
+
notes, checkedAt: new Date().toISOString(),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const data = await this.#get(`${this.arcticBaseUrl}/api/posts/ids`, {
|
|
269
|
+
ids: ids.slice(0, v.limit).join(','),
|
|
270
|
+
});
|
|
271
|
+
const rows = Array.isArray(data.data) ? data.data : [];
|
|
272
|
+
// Restore the web-search ordering; the archive answers in its own order.
|
|
273
|
+
const byId = new Map(rows.map(row => [stripIdPrefix(String(row.id ?? '')), row]));
|
|
274
|
+
const ordered = ids.map(id => byId.get(id)).filter(Boolean);
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
source: 'web_discovery', mode: 'posts',
|
|
278
|
+
query: v.query ?? null, subreddit: null, author: null,
|
|
279
|
+
count: ordered.length, results: ordered.map(normalizePost),
|
|
280
|
+
discovered: ids.length,
|
|
281
|
+
notes, checkedAt: new Date().toISOString(),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
183
285
|
async #searchArcticShift(v, { subreddit, author }) {
|
|
184
286
|
const notes = [
|
|
185
287
|
'Data from the Arctic Shift community archive (arctic-shift.photon-reddit.com), not reddit.com (which blocks scrapers).',
|