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 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.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.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.1.0",
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.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 queries free community archives (Arctic Shift / PullPush)
189
- // no credentials, no getToolConfig needed. reddit.com itself blocks scrapers.
190
- const redditSearchTool = new RedditSearchTool();
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 queries the Arctic Shift and PullPush community archives instead (free, no Reddit credentials). Modes: 'posts' (default) and 'comments' search; 'thread' returns a post plus its nested comment tree by link_id. Keyword search across ALL of Reddit routes to PullPush; subreddit/author-scoped searches use Arctic Shift (near-real-time) with PullPush fallback. Example: reddit_search({query: \"best mechanical keyboard\", subreddit: \"MechanicalKeyboards\", limit: 10})",
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 === 'reddit_api') {
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', 'pullpush']
152
- : ['pullpush'];
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
- // Unscoped keyword searches have no Arctic Shift fallback (it requires a
176
- // scope verified live: HTTP 400 without one), so point at the fix.
177
- const hint = order.length === 1 && order[0] === 'pullpush' && v.source === 'auto'
178
- ? ' Tip: add a subreddit or author filter to route to the more reliable Arctic Shift archive.'
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).',