crawlforge-mcp-server 5.0.5 → 5.2.0
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 +9 -6
- package/README.md +28 -8
- package/package.json +6 -5
- package/server.js +56 -16
- package/src/core/ActionExecutor.js +246 -66
- package/src/core/AuthManager.js +1 -0
- package/src/core/ChangeTracker.js +215 -22
- package/src/core/ResearchOrchestrator.js +9 -3
- package/src/core/SamplingClient.js +4 -5
- package/src/core/StealthBrowserManager.js +64 -18
- package/src/core/cache/CacheManager.js +7 -2
- package/src/core/crawlers/BFSCrawler.js +14 -6
- package/src/core/llm/LLMManager.js +61 -11
- package/src/core/llm/OllamaProvider.js +139 -0
- package/src/core/processing/BrowserProcessor.js +28 -2
- package/src/schemas/toolOutputSchemas.js +53 -1
- package/src/server/requestContext.js +26 -0
- package/src/server/transports/streamableHttp.js +54 -11
- package/src/server/withAuth.js +24 -6
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +26 -3
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +5 -4
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -0
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +6 -4
- package/src/skills/agent-skills/crawlforge-structured-extraction/references/templates.md +2 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +4 -1
- package/src/tools/basic/_fetch.js +8 -2
- package/src/tools/basic/fetchUrl.js +4 -1
- package/src/tools/crawl/crawlDeep.js +19 -5
- package/src/tools/extract/extractStructured.js +16 -4
- package/src/tools/extract/extractWithLlm.js +80 -10
- package/src/tools/extract/listOllamaModels.js +4 -6
- package/src/tools/scrape/_brandingExtractor.js +1 -1
- package/src/tools/scrape/unifiedScrape.js +71 -5
- package/src/tools/search/adapters/redditOfficialApi.js +196 -0
- package/src/tools/search/redditNormalize.js +95 -0
- package/src/tools/search/redditSearch.js +326 -0
- package/src/tools/templates/ScrapeTemplateTool.js +8 -3
- package/src/utils/hiddenContent.js +330 -0
- package/src/utils/htmlToMarkdown.js +12 -2
- package/src/utils/ollamaConfig.js +121 -0
- package/src/tools/templates/TemplateRegistry.js +0 -325
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reddit Official Data API adapter (app-only OAuth)
|
|
3
|
+
*
|
|
4
|
+
* The optional, fully ToS-compliant live path for reddit_search. When a user
|
|
5
|
+
* supplies THEIR OWN Reddit app credentials (REDDIT_CLIENT_ID /
|
|
6
|
+
* REDDIT_CLIENT_SECRET), reddit_search can read Reddit's own API directly
|
|
7
|
+
* instead of a community archive — giving live scores, complete comment trees,
|
|
8
|
+
* and up-to-the-minute listings, on the user's own 100-QPM free quota.
|
|
9
|
+
*
|
|
10
|
+
* Auth: "Application Only OAuth" (client_credentials grant). This needs only a
|
|
11
|
+
* client id + secret from a Reddit "script" or "web app" registered at
|
|
12
|
+
* https://www.reddit.com/prefs/apps — no Reddit username/password, because we
|
|
13
|
+
* only read public data. The token endpoint (www.reddit.com/api/v1/access_token)
|
|
14
|
+
* is the OAuth server and is NOT behind Reddit's anti-scraper wall; all data
|
|
15
|
+
* calls then go to https://oauth.reddit.com with the bearer token.
|
|
16
|
+
*
|
|
17
|
+
* Deliberate scope: the official API can serve `posts` search/listings and
|
|
18
|
+
* `thread` reads. It has NO comment full-text search (that was Pushshift's
|
|
19
|
+
* superpower), and its listings/search cannot filter by an arbitrary date
|
|
20
|
+
* range — only coarse `t` buckets. Those two cases are surfaced as errors so
|
|
21
|
+
* the caller falls back to the archives, which do support them.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { normalizePost, normalizeTreeNodes, stripIdPrefix, stripNamePrefix } from '../redditNormalize.js';
|
|
25
|
+
|
|
26
|
+
const TOKEN_URL = 'https://www.reddit.com/api/v1/access_token';
|
|
27
|
+
const API_BASE = 'https://oauth.reddit.com';
|
|
28
|
+
|
|
29
|
+
/** Reddit requires a descriptive, unique User-Agent; generic ones are throttled. */
|
|
30
|
+
const DEFAULT_USER_AGENT = 'CrawlForge-MCP/5.2.0 (+https://www.crawlforge.dev)';
|
|
31
|
+
|
|
32
|
+
/** Our sort is asc/desc by post date; Reddit listings/search only go newest-first. */
|
|
33
|
+
const REDDIT_SORT = 'new';
|
|
34
|
+
|
|
35
|
+
export class RedditOfficialApiAdapter {
|
|
36
|
+
constructor(clientId, clientSecret, options = {}) {
|
|
37
|
+
if (!clientId || !clientSecret) {
|
|
38
|
+
throw new Error('Reddit API credentials are required (client id + secret).');
|
|
39
|
+
}
|
|
40
|
+
this.clientId = clientId;
|
|
41
|
+
this.clientSecret = clientSecret;
|
|
42
|
+
this.userAgent = options.userAgent || process.env.REDDIT_USER_AGENT || DEFAULT_USER_AGENT;
|
|
43
|
+
this.tokenUrl = options.tokenUrl || TOKEN_URL;
|
|
44
|
+
this.apiBaseUrl = options.apiBaseUrl || API_BASE;
|
|
45
|
+
this.timeoutMs = options.timeoutMs ?? 30000;
|
|
46
|
+
this.authHeader = 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
|
47
|
+
// Cached app-only token: { value, expiresAt(ms epoch) }.
|
|
48
|
+
this._token = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Fetch (or reuse) an app-only bearer token. Refreshed a minute before expiry. */
|
|
52
|
+
async #getToken(force = false) {
|
|
53
|
+
if (!force && this._token && Date.now() < this._token.expiresAt) {
|
|
54
|
+
return this._token.value;
|
|
55
|
+
}
|
|
56
|
+
let response;
|
|
57
|
+
try {
|
|
58
|
+
response = await fetch(this.tokenUrl, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: {
|
|
61
|
+
Authorization: this.authHeader,
|
|
62
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
63
|
+
'User-Agent': this.userAgent,
|
|
64
|
+
},
|
|
65
|
+
body: 'grant_type=client_credentials',
|
|
66
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
67
|
+
});
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
|
|
70
|
+
throw new Error(`Reddit token request timed out after ${this.timeoutMs}ms`);
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`Reddit token network error: ${error.message}`);
|
|
73
|
+
}
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
// 401 here means the client id/secret are wrong or the app was revoked.
|
|
76
|
+
const hint = response.status === 401
|
|
77
|
+
? ' — check REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET (and that the app is a "script" or "web app")'
|
|
78
|
+
: '';
|
|
79
|
+
throw new Error(`Reddit OAuth failed: HTTP ${response.status} ${response.statusText}${hint}`);
|
|
80
|
+
}
|
|
81
|
+
const data = await response.json();
|
|
82
|
+
if (!data.access_token) throw new Error('Reddit OAuth returned no access_token');
|
|
83
|
+
const ttlMs = (Number(data.expires_in) || 3600) * 1000;
|
|
84
|
+
this._token = { value: data.access_token, expiresAt: Date.now() + ttlMs - 60000 };
|
|
85
|
+
return this._token.value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Authenticated GET against oauth.reddit.com, with one token-refresh retry on 401. */
|
|
89
|
+
async #get(path, queryParams) {
|
|
90
|
+
const url = `${this.apiBaseUrl}${path}?${new URLSearchParams({ raw_json: '1', ...queryParams })}`;
|
|
91
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
92
|
+
const token = await this.#getToken(attempt > 0); // force refresh on the retry
|
|
93
|
+
let response;
|
|
94
|
+
try {
|
|
95
|
+
response = await fetch(url, {
|
|
96
|
+
headers: { Authorization: `Bearer ${token}`, 'User-Agent': this.userAgent },
|
|
97
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
98
|
+
});
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
|
|
101
|
+
throw new Error(`Reddit API request timed out after ${this.timeoutMs}ms`);
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`Reddit API network error: ${error.message}`);
|
|
104
|
+
}
|
|
105
|
+
if (response.status === 401 && attempt === 0) continue; // stale token → refresh + retry once
|
|
106
|
+
if (response.status === 429) {
|
|
107
|
+
const reset = response.headers?.get?.('x-ratelimit-reset');
|
|
108
|
+
throw new Error(`Reddit API rate limited (429)${reset ? `, retry in ${reset}s` : ''} — you are over your app's 100 QPM quota`);
|
|
109
|
+
}
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
throw new Error(`Reddit API HTTP ${response.status} ${response.statusText}`);
|
|
112
|
+
}
|
|
113
|
+
return response.json();
|
|
114
|
+
}
|
|
115
|
+
// Unreachable in practice: the loop returns or throws on each path.
|
|
116
|
+
throw new Error('Reddit API authorization failed after token refresh');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Data is authoritative and live — say so, and drop the archive caveats. */
|
|
120
|
+
#notes() {
|
|
121
|
+
return [
|
|
122
|
+
'Data from the official Reddit Data API (oauth.reddit.com) via your configured Reddit app credentials — live and authoritative (real scores, complete comment trees).',
|
|
123
|
+
'Consumes your Reddit app\'s own 100 QPM free-tier quota, not CrawlForge credits.',
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Search/list posts. Mirrors the archive path's contract; validation of the
|
|
129
|
+
* required-scope rules happens in redditSearch.js before we get here.
|
|
130
|
+
*/
|
|
131
|
+
async searchPosts(v, { subreddit, author }) {
|
|
132
|
+
// The official API cannot honor an arbitrary date range (only coarse `t`
|
|
133
|
+
// buckets). Reject so `auto` mode falls back to the archives, which can.
|
|
134
|
+
if (v.after || v.before) {
|
|
135
|
+
throw new Error('official Reddit API cannot filter by date range — unset after/before, or use source:"arctic_shift"/"pullpush"');
|
|
136
|
+
}
|
|
137
|
+
const sr = stripNamePrefix(subreddit);
|
|
138
|
+
const au = stripNamePrefix(author);
|
|
139
|
+
const limit = String(v.limit);
|
|
140
|
+
|
|
141
|
+
let path;
|
|
142
|
+
let query;
|
|
143
|
+
if (v.query) {
|
|
144
|
+
const q = au ? `author:${au} ${v.query}` : v.query;
|
|
145
|
+
if (sr) {
|
|
146
|
+
path = `/r/${encodeURIComponent(sr)}/search`;
|
|
147
|
+
query = { q, restrict_sr: 'true', sort: REDDIT_SORT, type: 'link', limit };
|
|
148
|
+
} else {
|
|
149
|
+
path = '/search';
|
|
150
|
+
query = { q, sort: REDDIT_SORT, type: 'link', limit };
|
|
151
|
+
}
|
|
152
|
+
} else if (au && !sr) {
|
|
153
|
+
path = `/user/${encodeURIComponent(au)}/submitted`;
|
|
154
|
+
query = { sort: REDDIT_SORT, limit };
|
|
155
|
+
} else if (sr && au) {
|
|
156
|
+
path = `/r/${encodeURIComponent(sr)}/search`;
|
|
157
|
+
query = { q: `author:${au}`, restrict_sr: 'true', sort: REDDIT_SORT, type: 'link', limit };
|
|
158
|
+
} else {
|
|
159
|
+
path = `/r/${encodeURIComponent(sr)}/new`;
|
|
160
|
+
query = { limit };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const data = await this.#get(path, query);
|
|
164
|
+
const children = Array.isArray(data?.data?.children) ? data.data.children : [];
|
|
165
|
+
let results = children
|
|
166
|
+
.filter((c) => c?.kind === 't3' && c.data)
|
|
167
|
+
.map((c) => normalizePost(c.data));
|
|
168
|
+
// Reddit only returns newest-first; approximate ascending by reversing the page.
|
|
169
|
+
if (v.sort === 'asc') results = results.reverse();
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
source: 'reddit_api', mode: 'posts',
|
|
173
|
+
query: v.query ?? null, subreddit: sr ?? null, author: au ?? null,
|
|
174
|
+
count: results.length, results,
|
|
175
|
+
after_cursor: data?.data?.after ?? null,
|
|
176
|
+
notes: this.#notes(), checkedAt: new Date().toISOString(),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Read a post plus its nested comment tree from the official API. */
|
|
181
|
+
async getThread(v) {
|
|
182
|
+
const id = stripIdPrefix(v.link_id);
|
|
183
|
+
const data = await this.#get(`/comments/${encodeURIComponent(id)}`, { limit: String(v.limit) });
|
|
184
|
+
// Reddit returns [postListing, commentsListing].
|
|
185
|
+
const postChild = data?.[0]?.data?.children?.[0];
|
|
186
|
+
const post = postChild?.data ? normalizePost(postChild.data) : null;
|
|
187
|
+
const comments = normalizeTreeNodes(data?.[1]?.data?.children);
|
|
188
|
+
return {
|
|
189
|
+
source: 'reddit_api', mode: 'thread', link_id: id,
|
|
190
|
+
post, comments, comment_count: comments.length,
|
|
191
|
+
notes: this.#notes(), checkedAt: new Date().toISOString(),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export default RedditOfficialApiAdapter;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Reddit result normalizers.
|
|
3
|
+
*
|
|
4
|
+
* Both the community-archive path (redditSearch.js) and the official Reddit
|
|
5
|
+
* Data API adapter (adapters/redditOfficialApi.js) reduce raw Reddit post,
|
|
6
|
+
* comment, and comment-tree objects to one stable output shape. Keeping the
|
|
7
|
+
* normalizers here lets both reuse them without a circular import.
|
|
8
|
+
*
|
|
9
|
+
* All three sources (Arctic Shift, PullPush, and Reddit's own API) store the
|
|
10
|
+
* same raw Reddit object fields, and Arctic Shift deliberately mirrors Reddit's
|
|
11
|
+
* `/comments` tree shape — so a single set of normalizers covers all of them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Cap selftext/body length so a 100-result payload stays LLM-friendly. */
|
|
15
|
+
export const TEXT_MAX = 2000;
|
|
16
|
+
|
|
17
|
+
/** "t3_abc123" / "t1_abc123" → "abc123" (all backends accept bare IDs). */
|
|
18
|
+
export function stripIdPrefix(id) {
|
|
19
|
+
return String(id).replace(/^t[13]_/, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** "r/Foo" → "Foo", "u/bar" → "bar". */
|
|
23
|
+
export function stripNamePrefix(name) {
|
|
24
|
+
return name == null ? name : String(name).replace(/^[ru]\//, '');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function truncate(text) {
|
|
28
|
+
if (typeof text !== 'string' || text.length <= TEXT_MAX) {
|
|
29
|
+
return { text: text ?? null, truncated: false };
|
|
30
|
+
}
|
|
31
|
+
return { text: text.slice(0, TEXT_MAX), truncated: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function toIso(epochSeconds) {
|
|
35
|
+
return typeof epochSeconds === 'number'
|
|
36
|
+
? new Date(epochSeconds * 1000).toISOString()
|
|
37
|
+
: null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Raw Reddit post object → the stable subset that matters. */
|
|
41
|
+
export function normalizePost(raw) {
|
|
42
|
+
const { text: selftext, truncated } = truncate(raw.selftext);
|
|
43
|
+
return {
|
|
44
|
+
id: raw.id ?? null,
|
|
45
|
+
title: raw.title ?? null,
|
|
46
|
+
author: raw.author ?? null,
|
|
47
|
+
subreddit: raw.subreddit ?? null,
|
|
48
|
+
created_utc: raw.created_utc ?? null,
|
|
49
|
+
created_iso: toIso(raw.created_utc),
|
|
50
|
+
score: raw.score ?? null,
|
|
51
|
+
num_comments: raw.num_comments ?? null,
|
|
52
|
+
upvote_ratio: raw.upvote_ratio ?? null,
|
|
53
|
+
flair: raw.link_flair_text ?? null,
|
|
54
|
+
over_18: raw.over_18 ?? null,
|
|
55
|
+
selftext,
|
|
56
|
+
selftext_truncated: truncated,
|
|
57
|
+
url: raw.url ?? null,
|
|
58
|
+
permalink: raw.permalink ? `https://www.reddit.com${raw.permalink}` : null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function normalizeComment(raw) {
|
|
63
|
+
const { text: body, truncated } = truncate(raw.body);
|
|
64
|
+
return {
|
|
65
|
+
id: raw.id ?? null,
|
|
66
|
+
author: raw.author ?? null,
|
|
67
|
+
subreddit: raw.subreddit ?? null,
|
|
68
|
+
created_utc: raw.created_utc ?? null,
|
|
69
|
+
created_iso: toIso(raw.created_utc),
|
|
70
|
+
score: raw.score ?? null,
|
|
71
|
+
body,
|
|
72
|
+
body_truncated: truncated,
|
|
73
|
+
link_id: raw.link_id ? stripIdPrefix(raw.link_id) : null,
|
|
74
|
+
parent_id: raw.parent_id ?? null,
|
|
75
|
+
permalink: raw.permalink ? `https://www.reddit.com${raw.permalink}` : null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reddit-API-style comment tree → nested {..comment, replies:[...]} shape.
|
|
81
|
+
* Nodes look like {kind:"t1", data:{...comment, replies:{data:{children:[...]}}}}
|
|
82
|
+
* and {kind:"more", data:{count, children:[ids]}} for collapsed branches.
|
|
83
|
+
* Both Arctic Shift's /api/comments/tree and Reddit's /comments/{id} use this.
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeTreeNodes(nodes) {
|
|
86
|
+
if (!Array.isArray(nodes)) return [];
|
|
87
|
+
return nodes.map((node) => {
|
|
88
|
+
if (node?.kind === 'more') {
|
|
89
|
+
return { more_count: node.data?.count ?? null, more_ids: node.data?.children ?? [] };
|
|
90
|
+
}
|
|
91
|
+
const data = node?.data ?? {};
|
|
92
|
+
const children = data.replies?.data?.children;
|
|
93
|
+
return { ...normalizeComment(data), replies: normalizeTreeNodes(children) };
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reddit_search tool
|
|
3
|
+
*
|
|
4
|
+
* Searches Reddit posts and comments, or reads a full comment thread.
|
|
5
|
+
*
|
|
6
|
+
* reddit.com hard-blocks non-browser clients (403 on fetch, stealth browsers
|
|
7
|
+
* included — the block is IP/TLS-reputation based), so this tool never touches
|
|
8
|
+
* reddit.com. It queries the two community-run Reddit archives instead:
|
|
9
|
+
*
|
|
10
|
+
* - Arctic Shift (https://arctic-shift.photon-reddit.com) — near-real-time
|
|
11
|
+
* ingestion, comment trees, richer endpoints. Constraint from its API docs:
|
|
12
|
+
* keyword search (`query`/`body`) only works when scoped to a subreddit,
|
|
13
|
+
* author, or post — NOT across all of Reddit.
|
|
14
|
+
* - PullPush (https://api.pullpush.io) — Pushshift-compatible, supports
|
|
15
|
+
* cross-subreddit full-text search, but has known post-2023 archive gaps
|
|
16
|
+
* and recurring outages.
|
|
17
|
+
*
|
|
18
|
+
* Routing: scoped searches go to Arctic Shift (fresher) with PullPush as an
|
|
19
|
+
* error-only fallback; unscoped keyword searches can only go to PullPush.
|
|
20
|
+
* Both services are free and need no credentials.
|
|
21
|
+
*
|
|
22
|
+
* Optional official-API path: if the user sets REDDIT_CLIENT_ID and
|
|
23
|
+
* REDDIT_CLIENT_SECRET (their own Reddit app), posts/thread requests can read
|
|
24
|
+
* Reddit's official Data API (live scores, complete comment trees) on their own
|
|
25
|
+
* free quota, preferred in `auto` mode with the archives as fallback. Absent
|
|
26
|
+
* those vars — the default — this tool never touches reddit.com. Comment
|
|
27
|
+
* full-text search and date-range filters always use the archives (the official
|
|
28
|
+
* API supports neither). See adapters/redditOfficialApi.js and
|
|
29
|
+
* docs/reddit-access-and-oauth.md.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { z } from 'zod';
|
|
33
|
+
import {
|
|
34
|
+
normalizePost,
|
|
35
|
+
normalizeComment,
|
|
36
|
+
normalizeTreeNodes,
|
|
37
|
+
stripIdPrefix,
|
|
38
|
+
stripNamePrefix,
|
|
39
|
+
} from './redditNormalize.js';
|
|
40
|
+
import { RedditOfficialApiAdapter } from './adapters/redditOfficialApi.js';
|
|
41
|
+
|
|
42
|
+
const ARCTIC_SHIFT_BASE = 'https://arctic-shift.photon-reddit.com';
|
|
43
|
+
const PULLPUSH_BASE = 'https://api.pullpush.io';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Identify ourselves. Verified live: Arctic Shift throttles UA-less clients
|
|
47
|
+
* into a shared bucket (422 "Timeout. Maybe slow down a bit" while curl got
|
|
48
|
+
* 200 for the same URL); with a descriptive UA it answers instantly.
|
|
49
|
+
*/
|
|
50
|
+
const USER_AGENT = 'CrawlForge-MCP/5.2.0 (+https://www.crawlforge.dev)';
|
|
51
|
+
|
|
52
|
+
const RedditSearchSchema = z.object({
|
|
53
|
+
query: z.string().min(1).optional(),
|
|
54
|
+
subreddit: z.string().min(1).optional(),
|
|
55
|
+
author: z.string().min(1).optional(),
|
|
56
|
+
mode: z.enum(['posts', 'comments', 'thread']).optional().default('posts'),
|
|
57
|
+
link_id: z.string().min(1).optional(), // post ID — required for thread mode
|
|
58
|
+
after: z.string().min(1).optional(),
|
|
59
|
+
before: z.string().min(1).optional(),
|
|
60
|
+
limit: z.number().int().min(1).max(100).optional().default(25),
|
|
61
|
+
sort: z.enum(['asc', 'desc']).optional().default('desc'),
|
|
62
|
+
source: z.enum(['auto', 'arctic_shift', 'pullpush', 'reddit_api']).optional().default('auto'),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* PullPush (Pushshift schema) wants epoch seconds for after/before. Pass
|
|
67
|
+
* through epoch and offset forms ("7d"); convert ISO dates. Arctic Shift
|
|
68
|
+
* accepts all of these natively, so this is only used on the PullPush path.
|
|
69
|
+
*/
|
|
70
|
+
function toEpochSeconds(value) {
|
|
71
|
+
if (/^\d+$/.test(value) || /^\d+[a-z]+$/i.test(value)) return value;
|
|
72
|
+
const ms = Date.parse(value);
|
|
73
|
+
if (Number.isNaN(ms)) {
|
|
74
|
+
throw new Error(`Unparseable date "${value}" — use ISO 8601, epoch seconds, or an offset like "7d"`);
|
|
75
|
+
}
|
|
76
|
+
return String(Math.floor(ms / 1000));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class RedditSearchTool {
|
|
80
|
+
constructor(options = {}) {
|
|
81
|
+
// Overridable for tests / self-hosted mirrors.
|
|
82
|
+
this.arcticBaseUrl = options.arcticBaseUrl || ARCTIC_SHIFT_BASE;
|
|
83
|
+
this.pullpushBaseUrl = options.pullpushBaseUrl || PULLPUSH_BASE;
|
|
84
|
+
// Community services with no SLA — generous but bounded.
|
|
85
|
+
this.timeoutMs = options.timeoutMs ?? (Number(process.env.REDDIT_SEARCH_TIMEOUT_MS) || 30000);
|
|
86
|
+
// Pause before the single retry of a transient throttle response.
|
|
87
|
+
this.retryDelayMs = options.retryDelayMs ?? 3000;
|
|
88
|
+
|
|
89
|
+
// Optional official-API path. When the user supplies THEIR OWN Reddit app
|
|
90
|
+
// credentials, reddit_search can read Reddit's own API (live, authoritative)
|
|
91
|
+
// instead of the archives. Absent (the default), the tool is unchanged.
|
|
92
|
+
this.redditClientId = options.redditClientId || process.env.REDDIT_CLIENT_ID || null;
|
|
93
|
+
this.redditClientSecret = options.redditClientSecret || process.env.REDDIT_CLIENT_SECRET || null;
|
|
94
|
+
this.officialConfigured = Boolean(this.redditClientId && this.redditClientSecret);
|
|
95
|
+
// Lazily constructed on first official-path use; overridable for tests.
|
|
96
|
+
this._officialAdapter = options.officialAdapter || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The official-API adapter, built once from the configured credentials. */
|
|
100
|
+
#official() {
|
|
101
|
+
if (!this._officialAdapter) {
|
|
102
|
+
this._officialAdapter = new RedditOfficialApiAdapter(
|
|
103
|
+
this.redditClientId,
|
|
104
|
+
this.redditClientSecret,
|
|
105
|
+
{ timeoutMs: this.timeoutMs },
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return this._officialAdapter;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async execute(params) {
|
|
112
|
+
const v = RedditSearchSchema.parse(params);
|
|
113
|
+
|
|
114
|
+
// Cross-field validation zod can't express per-mode.
|
|
115
|
+
if (v.mode === 'thread') {
|
|
116
|
+
if (!v.link_id) throw new Error('thread mode requires link_id (the post ID, e.g. "1twm1zh" or "t3_1twm1zh")');
|
|
117
|
+
} else if (!v.query && !v.subreddit && !v.author && !(v.mode === 'comments' && v.link_id)) {
|
|
118
|
+
throw new Error(`${v.mode} mode requires at least one of: query, subreddit, author${v.mode === 'comments' ? ', link_id' : ''}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const subreddit = stripNamePrefix(v.subreddit);
|
|
122
|
+
const author = stripNamePrefix(v.author);
|
|
123
|
+
// Arctic Shift keyword search must be scoped (its documented constraint).
|
|
124
|
+
const scoped = Boolean(subreddit || author || (v.mode === 'comments' && v.link_id));
|
|
125
|
+
const arcticPossible = v.mode === 'thread' || !v.query || scoped;
|
|
126
|
+
// The official API serves posts search/listings and thread reads. It has no
|
|
127
|
+
// comment full-text search, so `comments` mode always uses the archives.
|
|
128
|
+
const officialPossible = this.officialConfigured && (v.mode === 'thread' || v.mode === 'posts');
|
|
129
|
+
|
|
130
|
+
let order; // backends to try, in order
|
|
131
|
+
if (v.source === 'reddit_api') {
|
|
132
|
+
if (!this.officialConfigured) {
|
|
133
|
+
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
|
+
}
|
|
135
|
+
if (v.mode === 'comments') {
|
|
136
|
+
throw new Error('the official Reddit API has no comment full-text search — use comments mode with source:"arctic_shift"/"pullpush", or read a whole thread with mode:"thread"');
|
|
137
|
+
}
|
|
138
|
+
order = ['reddit_api'];
|
|
139
|
+
} else if (v.source === 'arctic_shift') {
|
|
140
|
+
if (!arcticPossible) {
|
|
141
|
+
throw new Error('Arctic Shift cannot keyword-search across all of Reddit — add a subreddit or author scope, or use source:"pullpush"');
|
|
142
|
+
}
|
|
143
|
+
order = ['arctic_shift'];
|
|
144
|
+
} else if (v.source === 'pullpush') {
|
|
145
|
+
if (v.mode === 'thread') throw new Error('thread mode requires Arctic Shift (source:"pullpush" only supports posts/comments search)');
|
|
146
|
+
order = ['pullpush'];
|
|
147
|
+
} else {
|
|
148
|
+
// auto: prefer the user's own official API (live, authoritative) when it
|
|
149
|
+
// can serve this request, then fall back to the community archives.
|
|
150
|
+
const archives = v.mode === 'thread' ? ['arctic_shift']
|
|
151
|
+
: arcticPossible ? ['arctic_shift', 'pullpush']
|
|
152
|
+
: ['pullpush'];
|
|
153
|
+
order = officialPossible ? ['reddit_api', ...archives] : archives;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const errors = [];
|
|
157
|
+
for (const source of order) {
|
|
158
|
+
try {
|
|
159
|
+
let result;
|
|
160
|
+
if (source === 'reddit_api') {
|
|
161
|
+
result = v.mode === 'thread'
|
|
162
|
+
? await this.#official().getThread(v)
|
|
163
|
+
: await this.#official().searchPosts(v, { subreddit, author });
|
|
164
|
+
} else if (source === 'arctic_shift') {
|
|
165
|
+
result = await this.#searchArcticShift(v, { subreddit, author });
|
|
166
|
+
} else {
|
|
167
|
+
result = await this.#searchPullPush(v, { subreddit, author });
|
|
168
|
+
}
|
|
169
|
+
if (errors.length > 0) result.fallback_used = `primary source failed (${errors[0]}), fell back to ${source}`;
|
|
170
|
+
return result;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
errors.push(`${source}: ${error.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
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.'
|
|
179
|
+
: '';
|
|
180
|
+
throw new Error(`All Reddit sources failed — ${errors.join('; ')}.${hint}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async #searchArcticShift(v, { subreddit, author }) {
|
|
184
|
+
const notes = [
|
|
185
|
+
'Data from the Arctic Shift community archive (arctic-shift.photon-reddit.com), not reddit.com (which blocks scrapers).',
|
|
186
|
+
'Scores and comment counts of content less than ~36h old may read 0/1 — the archive captures content the moment it is posted.',
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
if (v.mode === 'thread') {
|
|
190
|
+
const id = stripIdPrefix(v.link_id);
|
|
191
|
+
const postData = await this.#get(`${this.arcticBaseUrl}/api/posts/ids`, { ids: id });
|
|
192
|
+
const post = postData.data?.[0] ? normalizePost(postData.data[0]) : null;
|
|
193
|
+
const treeData = await this.#get(`${this.arcticBaseUrl}/api/comments/tree`, {
|
|
194
|
+
link_id: `t3_${id}`,
|
|
195
|
+
limit: String(v.limit),
|
|
196
|
+
});
|
|
197
|
+
const comments = normalizeTreeNodes(treeData.data);
|
|
198
|
+
return {
|
|
199
|
+
source: 'arctic_shift', mode: 'thread', link_id: id,
|
|
200
|
+
post, comments, comment_count: comments.length,
|
|
201
|
+
notes, checkedAt: new Date().toISOString(),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const query = {
|
|
206
|
+
limit: String(v.limit),
|
|
207
|
+
sort: v.sort,
|
|
208
|
+
...(subreddit && { subreddit }),
|
|
209
|
+
...(author && { author }),
|
|
210
|
+
...(v.after && { after: v.after }),
|
|
211
|
+
...(v.before && { before: v.before }),
|
|
212
|
+
};
|
|
213
|
+
let path;
|
|
214
|
+
if (v.mode === 'posts') {
|
|
215
|
+
path = '/api/posts/search';
|
|
216
|
+
if (v.query) query.query = v.query; // searches title + selftext
|
|
217
|
+
} else {
|
|
218
|
+
path = '/api/comments/search';
|
|
219
|
+
if (v.query) query.body = v.query;
|
|
220
|
+
if (v.link_id) query.link_id = stripIdPrefix(v.link_id);
|
|
221
|
+
}
|
|
222
|
+
const data = await this.#get(`${this.arcticBaseUrl}${path}`, query);
|
|
223
|
+
const rows = Array.isArray(data.data) ? data.data : [];
|
|
224
|
+
const results = v.mode === 'posts' ? rows.map(normalizePost) : rows.map(normalizeComment);
|
|
225
|
+
return {
|
|
226
|
+
source: 'arctic_shift', mode: v.mode,
|
|
227
|
+
query: v.query ?? null, subreddit: subreddit ?? null, author: author ?? null,
|
|
228
|
+
count: results.length, results,
|
|
229
|
+
notes, checkedAt: new Date().toISOString(),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async #searchPullPush(v, { subreddit, author }) {
|
|
234
|
+
if (v.mode === 'thread') throw new Error('thread mode is Arctic Shift only');
|
|
235
|
+
const notes = [
|
|
236
|
+
'Data from the PullPush community archive (api.pullpush.io), not reddit.com (which blocks scrapers).',
|
|
237
|
+
'PullPush has known gaps in its post-2023 archive — an empty result does not prove the content does not exist.',
|
|
238
|
+
];
|
|
239
|
+
const query = {
|
|
240
|
+
size: String(v.limit),
|
|
241
|
+
sort: v.sort,
|
|
242
|
+
sort_type: 'created_utc',
|
|
243
|
+
...(v.query && { q: v.query }),
|
|
244
|
+
...(subreddit && { subreddit }),
|
|
245
|
+
...(author && { author }),
|
|
246
|
+
...(v.after && { after: toEpochSeconds(v.after) }),
|
|
247
|
+
...(v.before && { before: toEpochSeconds(v.before) }),
|
|
248
|
+
};
|
|
249
|
+
if (v.mode === 'comments' && v.link_id) query.link_id = stripIdPrefix(v.link_id);
|
|
250
|
+
const path = v.mode === 'posts' ? '/reddit/search/submission/' : '/reddit/search/comment/';
|
|
251
|
+
const data = await this.#get(`${this.pullpushBaseUrl}${path}`, query);
|
|
252
|
+
if (data.error) throw new Error(`PullPush error: ${data.error}`);
|
|
253
|
+
const rows = Array.isArray(data.data) ? data.data : [];
|
|
254
|
+
const results = v.mode === 'posts' ? rows.map(normalizePost) : rows.map(normalizeComment);
|
|
255
|
+
return {
|
|
256
|
+
source: 'pullpush', mode: v.mode,
|
|
257
|
+
query: v.query ?? null, subreddit: subreddit ?? null, author: author ?? null,
|
|
258
|
+
count: results.length, results,
|
|
259
|
+
notes, checkedAt: new Date().toISOString(),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* GET with one bounded retry: both archives shed load transiently (PullPush
|
|
265
|
+
* 429s at ~15 req/min; Arctic Shift answers 422 "Timeout. Maybe slow down a
|
|
266
|
+
* bit" under per-IP pressure — observed live) and usually recover in seconds.
|
|
267
|
+
*/
|
|
268
|
+
async #get(base, queryParams) {
|
|
269
|
+
const url = `${base}?${new URLSearchParams(queryParams)}`;
|
|
270
|
+
let lastError;
|
|
271
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
272
|
+
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, this.retryDelayMs));
|
|
273
|
+
try {
|
|
274
|
+
return await this.#getOnce(url);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
lastError = error;
|
|
277
|
+
if (!error.retryable) throw error;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
throw lastError;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async #getOnce(url) {
|
|
284
|
+
let response;
|
|
285
|
+
try {
|
|
286
|
+
response = await fetch(url, {
|
|
287
|
+
headers: { Accept: 'application/json', 'User-Agent': USER_AGENT },
|
|
288
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
289
|
+
});
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
|
|
292
|
+
throw new Error(`request timed out after ${this.timeoutMs}ms`);
|
|
293
|
+
}
|
|
294
|
+
throw new Error(`network error: ${error.message}`);
|
|
295
|
+
}
|
|
296
|
+
if (response.status === 429) {
|
|
297
|
+
const reset = response.headers?.get?.('x-ratelimit-reset');
|
|
298
|
+
// PullPush's 429 body states its actual policy ("does not provide free
|
|
299
|
+
// scraping resources for agents...") — pass that through verbatim.
|
|
300
|
+
let detail = '';
|
|
301
|
+
try { detail = (await response.json())?.error ?? ''; } catch { /* no body */ }
|
|
302
|
+
throw Object.assign(
|
|
303
|
+
new Error(`rate limited (429)${reset ? `, retry in ${reset}s` : ''}${detail ? ` — ${detail}` : ''}`),
|
|
304
|
+
{ retryable: true },
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (!response.ok) {
|
|
308
|
+
// Both archives put the real reason in the body (e.g. Arctic Shift's
|
|
309
|
+
// throttle/parameter complaints) — surface it, bounded.
|
|
310
|
+
let detail = '';
|
|
311
|
+
try {
|
|
312
|
+
const body = await response.text();
|
|
313
|
+
let msg = body.slice(0, 200);
|
|
314
|
+
try { msg = JSON.parse(body)?.error || msg; } catch { /* non-JSON body — use it raw */ }
|
|
315
|
+
detail = msg ? `: ${msg}` : '';
|
|
316
|
+
} catch { /* unreadable body — status alone will have to do */ }
|
|
317
|
+
throw Object.assign(
|
|
318
|
+
new Error(`HTTP ${response.status} ${response.statusText}${detail}`),
|
|
319
|
+
{ retryable: response.status === 422 && /timeout|slow down/i.test(detail) },
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return response.json();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export default RedditSearchTool;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* const result = await tool.execute({ template: "github-repo", url: "https://github.com/user/repo" });
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { TemplateRegistry } from '
|
|
9
|
+
import { TemplateRegistry } from 'crawlforge-extractors';
|
|
10
10
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
11
11
|
|
|
12
12
|
export class ScrapeTemplateTool {
|
|
@@ -35,12 +35,17 @@ export class ScrapeTemplateTool {
|
|
|
35
35
|
throw new Error(`Unknown template "${template}". Available templates: ${available}`);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// A template may redirect its own fetch to a machine-readable endpoint
|
|
39
|
+
// (shopify-product reads /products/<handle>.json). Same host either way,
|
|
40
|
+
// so the SSRF guard below still applies.
|
|
41
|
+
const fetchUrl = template === 'list' ? url : (tpl.resolveUrl ? tpl.resolveUrl(url) : url);
|
|
42
|
+
|
|
38
43
|
// Fetch the page
|
|
39
44
|
const controller = new AbortController();
|
|
40
45
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
41
46
|
let html;
|
|
42
47
|
try {
|
|
43
|
-
const response = await safeFetch(
|
|
48
|
+
const response = await safeFetch(fetchUrl, {
|
|
44
49
|
signal: controller.signal,
|
|
45
50
|
headers: {
|
|
46
51
|
'User-Agent': 'Mozilla/5.0 (compatible; CrawlForge-TemplateScraper/4.0)'
|
|
@@ -61,7 +66,7 @@ export class ScrapeTemplateTool {
|
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
// Run the template extractor
|
|
64
|
-
const result = await this.registry.run(template, html, url);
|
|
69
|
+
const result = await this.registry.run(template, html, url, fetchUrl);
|
|
65
70
|
return result;
|
|
66
71
|
}
|
|
67
72
|
}
|