crawlforge-mcp-server 5.1.0 → 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 +1 -1
- package/README.md +8 -4
- package/package.json +5 -4
- package/server.js +22 -14
- package/src/core/ActionExecutor.js +246 -66
- 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 +3 -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 +67 -91
- 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
|
+
}
|
|
@@ -18,9 +18,26 @@
|
|
|
18
18
|
* Routing: scoped searches go to Arctic Shift (fresher) with PullPush as an
|
|
19
19
|
* error-only fallback; unscoped keyword searches can only go to PullPush.
|
|
20
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.
|
|
21
30
|
*/
|
|
22
31
|
|
|
23
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';
|
|
24
41
|
|
|
25
42
|
const ARCTIC_SHIFT_BASE = 'https://arctic-shift.photon-reddit.com';
|
|
26
43
|
const PULLPUSH_BASE = 'https://api.pullpush.io';
|
|
@@ -30,10 +47,7 @@ const PULLPUSH_BASE = 'https://api.pullpush.io';
|
|
|
30
47
|
* into a shared bucket (422 "Timeout. Maybe slow down a bit" while curl got
|
|
31
48
|
* 200 for the same URL); with a descriptive UA it answers instantly.
|
|
32
49
|
*/
|
|
33
|
-
const USER_AGENT = 'CrawlForge-MCP/5.
|
|
34
|
-
|
|
35
|
-
/** Cap selftext/body length so a 100-result payload stays LLM-friendly. */
|
|
36
|
-
const TEXT_MAX = 2000;
|
|
50
|
+
const USER_AGENT = 'CrawlForge-MCP/5.2.0 (+https://www.crawlforge.dev)';
|
|
37
51
|
|
|
38
52
|
const RedditSearchSchema = z.object({
|
|
39
53
|
query: z.string().min(1).optional(),
|
|
@@ -45,19 +59,9 @@ const RedditSearchSchema = z.object({
|
|
|
45
59
|
before: z.string().min(1).optional(),
|
|
46
60
|
limit: z.number().int().min(1).max(100).optional().default(25),
|
|
47
61
|
sort: z.enum(['asc', 'desc']).optional().default('desc'),
|
|
48
|
-
source: z.enum(['auto', 'arctic_shift', 'pullpush']).optional().default('auto'),
|
|
62
|
+
source: z.enum(['auto', 'arctic_shift', 'pullpush', 'reddit_api']).optional().default('auto'),
|
|
49
63
|
});
|
|
50
64
|
|
|
51
|
-
/** "t3_abc123" / "t1_abc123" → "abc123" (both archives accept bare IDs). */
|
|
52
|
-
function stripIdPrefix(id) {
|
|
53
|
-
return String(id).replace(/^t[13]_/, '');
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** "r/Foo" → "Foo", "u/bar" → "bar" (Arctic Shift ignores prefixes; PullPush doesn't). */
|
|
57
|
-
function stripNamePrefix(name) {
|
|
58
|
-
return name == null ? name : String(name).replace(/^[ru]\//, '');
|
|
59
|
-
}
|
|
60
|
-
|
|
61
65
|
/**
|
|
62
66
|
* PullPush (Pushshift schema) wants epoch seconds for after/before. Pass
|
|
63
67
|
* through epoch and offset forms ("7d"); convert ISO dates. Arctic Shift
|
|
@@ -72,76 +76,6 @@ function toEpochSeconds(value) {
|
|
|
72
76
|
return String(Math.floor(ms / 1000));
|
|
73
77
|
}
|
|
74
78
|
|
|
75
|
-
function truncate(text) {
|
|
76
|
-
if (typeof text !== 'string' || text.length <= TEXT_MAX) {
|
|
77
|
-
return { text: text ?? null, truncated: false };
|
|
78
|
-
}
|
|
79
|
-
return { text: text.slice(0, TEXT_MAX), truncated: true };
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function toIso(epochSeconds) {
|
|
83
|
-
return typeof epochSeconds === 'number'
|
|
84
|
-
? new Date(epochSeconds * 1000).toISOString()
|
|
85
|
-
: null;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Both archives store raw Reddit post objects — reduce to the fields that matter. */
|
|
89
|
-
function normalizePost(raw) {
|
|
90
|
-
const { text: selftext, truncated } = truncate(raw.selftext);
|
|
91
|
-
return {
|
|
92
|
-
id: raw.id ?? null,
|
|
93
|
-
title: raw.title ?? null,
|
|
94
|
-
author: raw.author ?? null,
|
|
95
|
-
subreddit: raw.subreddit ?? null,
|
|
96
|
-
created_utc: raw.created_utc ?? null,
|
|
97
|
-
created_iso: toIso(raw.created_utc),
|
|
98
|
-
score: raw.score ?? null,
|
|
99
|
-
num_comments: raw.num_comments ?? null,
|
|
100
|
-
upvote_ratio: raw.upvote_ratio ?? null,
|
|
101
|
-
flair: raw.link_flair_text ?? null,
|
|
102
|
-
over_18: raw.over_18 ?? null,
|
|
103
|
-
selftext,
|
|
104
|
-
selftext_truncated: truncated,
|
|
105
|
-
url: raw.url ?? null,
|
|
106
|
-
permalink: raw.permalink ? `https://www.reddit.com${raw.permalink}` : null,
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function normalizeComment(raw) {
|
|
111
|
-
const { text: body, truncated } = truncate(raw.body);
|
|
112
|
-
return {
|
|
113
|
-
id: raw.id ?? null,
|
|
114
|
-
author: raw.author ?? null,
|
|
115
|
-
subreddit: raw.subreddit ?? null,
|
|
116
|
-
created_utc: raw.created_utc ?? null,
|
|
117
|
-
created_iso: toIso(raw.created_utc),
|
|
118
|
-
score: raw.score ?? null,
|
|
119
|
-
body,
|
|
120
|
-
body_truncated: truncated,
|
|
121
|
-
link_id: raw.link_id ? stripIdPrefix(raw.link_id) : null,
|
|
122
|
-
parent_id: raw.parent_id ?? null,
|
|
123
|
-
permalink: raw.permalink ? `https://www.reddit.com${raw.permalink}` : null,
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Arctic Shift's /api/comments/tree returns Reddit-API-style nodes:
|
|
129
|
-
* {kind:"t1", data:{...comment, replies:{kind:"Listing", data:{children:[...]}}}}
|
|
130
|
-
* and {kind:"more", data:{count, children:[ids]}} for collapsed branches.
|
|
131
|
-
* Flatten to a nested {..comment, replies:[...]} shape.
|
|
132
|
-
*/
|
|
133
|
-
function normalizeTreeNodes(nodes) {
|
|
134
|
-
if (!Array.isArray(nodes)) return [];
|
|
135
|
-
return nodes.map((node) => {
|
|
136
|
-
if (node?.kind === 'more') {
|
|
137
|
-
return { more_count: node.data?.count ?? null, more_ids: node.data?.children ?? [] };
|
|
138
|
-
}
|
|
139
|
-
const data = node?.data ?? {};
|
|
140
|
-
const children = data.replies?.data?.children;
|
|
141
|
-
return { ...normalizeComment(data), replies: normalizeTreeNodes(children) };
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
79
|
export class RedditSearchTool {
|
|
146
80
|
constructor(options = {}) {
|
|
147
81
|
// Overridable for tests / self-hosted mirrors.
|
|
@@ -151,6 +85,27 @@ export class RedditSearchTool {
|
|
|
151
85
|
this.timeoutMs = options.timeoutMs ?? (Number(process.env.REDDIT_SEARCH_TIMEOUT_MS) || 30000);
|
|
152
86
|
// Pause before the single retry of a transient throttle response.
|
|
153
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;
|
|
154
109
|
}
|
|
155
110
|
|
|
156
111
|
async execute(params) {
|
|
@@ -168,9 +123,20 @@ export class RedditSearchTool {
|
|
|
168
123
|
// Arctic Shift keyword search must be scoped (its documented constraint).
|
|
169
124
|
const scoped = Boolean(subreddit || author || (v.mode === 'comments' && v.link_id));
|
|
170
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');
|
|
171
129
|
|
|
172
130
|
let order; // backends to try, in order
|
|
173
|
-
if (v.source === '
|
|
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') {
|
|
174
140
|
if (!arcticPossible) {
|
|
175
141
|
throw new Error('Arctic Shift cannot keyword-search across all of Reddit — add a subreddit or author scope, or use source:"pullpush"');
|
|
176
142
|
}
|
|
@@ -179,17 +145,27 @@ export class RedditSearchTool {
|
|
|
179
145
|
if (v.mode === 'thread') throw new Error('thread mode requires Arctic Shift (source:"pullpush" only supports posts/comments search)');
|
|
180
146
|
order = ['pullpush'];
|
|
181
147
|
} else {
|
|
182
|
-
|
|
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']
|
|
183
151
|
: arcticPossible ? ['arctic_shift', 'pullpush']
|
|
184
152
|
: ['pullpush'];
|
|
153
|
+
order = officialPossible ? ['reddit_api', ...archives] : archives;
|
|
185
154
|
}
|
|
186
155
|
|
|
187
156
|
const errors = [];
|
|
188
157
|
for (const source of order) {
|
|
189
158
|
try {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
}
|
|
193
169
|
if (errors.length > 0) result.fallback_used = `primary source failed (${errors[0]}), fell back to ${source}`;
|
|
194
170
|
return result;
|
|
195
171
|
} catch (error) {
|
|
@@ -201,7 +177,7 @@ export class RedditSearchTool {
|
|
|
201
177
|
const hint = order.length === 1 && order[0] === 'pullpush' && v.source === 'auto'
|
|
202
178
|
? ' Tip: add a subreddit or author filter to route to the more reliable Arctic Shift archive.'
|
|
203
179
|
: '';
|
|
204
|
-
throw new Error(`All Reddit
|
|
180
|
+
throw new Error(`All Reddit sources failed — ${errors.join('; ')}.${hint}`);
|
|
205
181
|
}
|
|
206
182
|
|
|
207
183
|
async #searchArcticShift(v, { subreddit, author }) {
|
|
@@ -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
|
}
|