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.
Files changed (40) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +8 -4
  3. package/package.json +5 -4
  4. package/server.js +22 -14
  5. package/src/core/ActionExecutor.js +246 -66
  6. package/src/core/ChangeTracker.js +215 -22
  7. package/src/core/ResearchOrchestrator.js +9 -3
  8. package/src/core/SamplingClient.js +4 -5
  9. package/src/core/StealthBrowserManager.js +64 -18
  10. package/src/core/cache/CacheManager.js +7 -2
  11. package/src/core/crawlers/BFSCrawler.js +14 -6
  12. package/src/core/llm/LLMManager.js +61 -11
  13. package/src/core/llm/OllamaProvider.js +139 -0
  14. package/src/core/processing/BrowserProcessor.js +28 -2
  15. package/src/schemas/toolOutputSchemas.js +3 -1
  16. package/src/server/requestContext.js +26 -0
  17. package/src/server/transports/streamableHttp.js +54 -11
  18. package/src/server/withAuth.js +24 -6
  19. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +26 -3
  20. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +5 -4
  21. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -0
  22. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +6 -4
  23. package/src/skills/agent-skills/crawlforge-structured-extraction/references/templates.md +2 -1
  24. package/src/tools/advanced/ScrapeWithActionsTool.js +4 -1
  25. package/src/tools/basic/_fetch.js +8 -2
  26. package/src/tools/basic/fetchUrl.js +4 -1
  27. package/src/tools/crawl/crawlDeep.js +19 -5
  28. package/src/tools/extract/extractStructured.js +16 -4
  29. package/src/tools/extract/extractWithLlm.js +80 -10
  30. package/src/tools/extract/listOllamaModels.js +4 -6
  31. package/src/tools/scrape/_brandingExtractor.js +1 -1
  32. package/src/tools/scrape/unifiedScrape.js +71 -5
  33. package/src/tools/search/adapters/redditOfficialApi.js +196 -0
  34. package/src/tools/search/redditNormalize.js +95 -0
  35. package/src/tools/search/redditSearch.js +67 -91
  36. package/src/tools/templates/ScrapeTemplateTool.js +8 -3
  37. package/src/utils/hiddenContent.js +330 -0
  38. package/src/utils/htmlToMarkdown.js +12 -2
  39. package/src/utils/ollamaConfig.js +121 -0
  40. 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.1.0 (+https://www.crawlforge.dev)';
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 === 'arctic_shift') {
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
- order = v.mode === 'thread' ? ['arctic_shift']
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
- const result = source === 'arctic_shift'
191
- ? await this.#searchArcticShift(v, { subreddit, author })
192
- : await this.#searchPullPush(v, { subreddit, author });
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 archive sources failed — ${errors.join('; ')}.${hint}`);
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 './TemplateRegistry.js';
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(url, {
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
  }