viruagent-cli 0.7.0 → 0.7.2
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/README.ko.md +1 -1
- package/README.md +1 -1
- package/bin/index.js +24 -1
- package/package.json +3 -2
- package/src/providers/insta/apiClient.js +4 -2
- package/src/providers/reddit/apiClient.js +584 -0
- package/src/providers/reddit/auth.js +454 -0
- package/src/providers/reddit/index.js +233 -0
- package/src/providers/reddit/session.js +174 -0
- package/src/providers/reddit/utils.js +49 -0
- package/src/providers/tistory/auth.js +11 -0
- package/src/providers/tistory/selectors.js +9 -0
- package/src/providers/x/apiClient.js +48 -0
- package/src/runner.js +18 -1
- package/src/services/providerManager.js +4 -2
package/README.ko.md
CHANGED
package/README.md
CHANGED
package/bin/index.js
CHANGED
|
@@ -18,7 +18,7 @@ program
|
|
|
18
18
|
|
|
19
19
|
// Global options
|
|
20
20
|
const addProviderOption = (cmd) =>
|
|
21
|
-
cmd.option('--provider <name>', 'Provider name (tistory, naver, insta)', 'tistory');
|
|
21
|
+
cmd.option('--provider <name>', 'Provider name (tistory, naver, insta, x, reddit)', 'tistory');
|
|
22
22
|
|
|
23
23
|
const addDryRunOption = (cmd) =>
|
|
24
24
|
cmd.option('--dry-run', 'Validate params without executing', false);
|
|
@@ -49,6 +49,10 @@ loginCmd
|
|
|
49
49
|
.option('--headless', 'Run browser in headless mode', false)
|
|
50
50
|
.option('--manual', 'Use manual login mode', false)
|
|
51
51
|
.option('--two-factor-code <code>', '2FA verification code')
|
|
52
|
+
.option('--auth-token <token>', 'Auth token (X provider)')
|
|
53
|
+
.option('--ct0 <ct0>', 'CT0 token (X provider)')
|
|
54
|
+
.option('--client-id <id>', 'OAuth Client ID (Reddit)')
|
|
55
|
+
.option('--client-secret <secret>', 'OAuth Client Secret (Reddit)')
|
|
52
56
|
.action((opts) => execute('login', opts));
|
|
53
57
|
|
|
54
58
|
const publishCmd = program
|
|
@@ -70,6 +74,9 @@ publishCmd
|
|
|
70
74
|
.option('--minimum-image-count <n>', 'Minimum required images', '1')
|
|
71
75
|
.option('--no-auto-upload-images', 'Disable automatic image uploading')
|
|
72
76
|
.option('--no-enforce-system-prompt', 'Disable system prompt enforcement')
|
|
77
|
+
.option('--subreddit <name>', 'Subreddit name (Reddit)')
|
|
78
|
+
.option('--kind <type>', 'Post kind: self or link (Reddit)', 'self')
|
|
79
|
+
.option('--flair <id>', 'Flair template ID (Reddit)')
|
|
73
80
|
.action((opts) => execute('publish', opts));
|
|
74
81
|
|
|
75
82
|
const saveDraftCmd = program
|
|
@@ -250,6 +257,22 @@ const rateLimitCmd = program
|
|
|
250
257
|
addProviderOption(rateLimitCmd);
|
|
251
258
|
rateLimitCmd.action((opts) => execute('rate-limit-status', opts));
|
|
252
259
|
|
|
260
|
+
const subscribeCmd = program
|
|
261
|
+
.command('subscribe')
|
|
262
|
+
.description('Subscribe to a subreddit (Reddit)');
|
|
263
|
+
addProviderOption(subscribeCmd);
|
|
264
|
+
subscribeCmd
|
|
265
|
+
.option('--subreddit <name>', 'Subreddit name')
|
|
266
|
+
.action((opts) => execute('subscribe', opts));
|
|
267
|
+
|
|
268
|
+
const unsubscribeCmd = program
|
|
269
|
+
.command('unsubscribe')
|
|
270
|
+
.description('Unsubscribe from a subreddit (Reddit)');
|
|
271
|
+
addProviderOption(unsubscribeCmd);
|
|
272
|
+
unsubscribeCmd
|
|
273
|
+
.option('--subreddit <name>', 'Subreddit name')
|
|
274
|
+
.action((opts) => execute('unsubscribe', opts));
|
|
275
|
+
|
|
253
276
|
// --- Utility commands ---
|
|
254
277
|
|
|
255
278
|
const installSkillCmd = program
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viruagent-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "AI-agent-optimized CLI for blog publishing (Tistory, Naver) and Instagram automation",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "commonjs",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"commander": "^14.0.3",
|
|
45
|
-
"playwright": "^1.58.2"
|
|
45
|
+
"playwright": "^1.58.2",
|
|
46
|
+
"x-client-transaction-id": "^0.2.0"
|
|
46
47
|
}
|
|
47
48
|
}
|
|
@@ -167,9 +167,10 @@ const createInstaApiClient = ({ sessionPath }) => {
|
|
|
167
167
|
...options.headers,
|
|
168
168
|
};
|
|
169
169
|
|
|
170
|
-
const
|
|
170
|
+
const redirectMode = options.followRedirect ? 'follow' : 'manual';
|
|
171
|
+
const res = await fetch(url, { ...options, headers, redirect: redirectMode });
|
|
171
172
|
|
|
172
|
-
if (res.status === 302 || res.status === 301) {
|
|
173
|
+
if (!options.followRedirect && (res.status === 302 || res.status === 301)) {
|
|
173
174
|
const location = res.headers.get('location') || '';
|
|
174
175
|
if (location.includes('/accounts/login')) {
|
|
175
176
|
throw new Error('Session expired. Please log in again.');
|
|
@@ -608,6 +609,7 @@ const createInstaApiClient = ({ sessionPath }) => {
|
|
|
608
609
|
Offset: '0',
|
|
609
610
|
},
|
|
610
611
|
body: imageBuffer,
|
|
612
|
+
followRedirect: true,
|
|
611
613
|
},
|
|
612
614
|
);
|
|
613
615
|
const data = await res.json();
|
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
const { loadRedditSession, isTokenExpired, cookiesToHeader, loadRateLimits, saveRateLimits } = require('./session');
|
|
2
|
+
const { buildUserAgent } = require('./auth');
|
|
3
|
+
const { parseRedditError } = require('./utils');
|
|
4
|
+
|
|
5
|
+
const randomDelay = (minSec, maxSec) => {
|
|
6
|
+
const ms = (minSec + Math.random() * (maxSec - minSec)) * 1000;
|
|
7
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// ──────────────────────────────────────────────────────────────
|
|
11
|
+
// Reddit Safe Action Rules
|
|
12
|
+
//
|
|
13
|
+
// [Minimum action intervals + random jitter]
|
|
14
|
+
// Post: 600~900s (10~15min)
|
|
15
|
+
// Comment: 120~300s (2~5min)
|
|
16
|
+
// Vote: 10~30s
|
|
17
|
+
// Subscribe: 30~60s
|
|
18
|
+
//
|
|
19
|
+
// [Hourly / Daily limits (conservative)]
|
|
20
|
+
// Post: 2/h, 10/day
|
|
21
|
+
// Comment: 6/h, 50/day
|
|
22
|
+
// Vote: 30/h, 500/day
|
|
23
|
+
// Subscribe: 10/h, 100/day
|
|
24
|
+
// ──────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
const DELAY = {
|
|
27
|
+
post: [600, 900], // 10~15min
|
|
28
|
+
comment: [120, 300], // 2~5min
|
|
29
|
+
vote: [10, 30], // 10~30s
|
|
30
|
+
subscribe: [30, 60], // 30~60s
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const HOURLY_LIMIT = {
|
|
34
|
+
post: 2,
|
|
35
|
+
comment: 6,
|
|
36
|
+
vote: 30,
|
|
37
|
+
subscribe: 10,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const DAILY_LIMIT = {
|
|
41
|
+
post: 10,
|
|
42
|
+
comment: 50,
|
|
43
|
+
vote: 500,
|
|
44
|
+
subscribe: 100,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
let lastActionTime = 0;
|
|
48
|
+
|
|
49
|
+
const createRedditApiClient = ({ sessionPath }) => {
|
|
50
|
+
let cachedSession = null;
|
|
51
|
+
let countersCache = null;
|
|
52
|
+
|
|
53
|
+
// ── Session helpers ──
|
|
54
|
+
|
|
55
|
+
const getSession = () => {
|
|
56
|
+
if (cachedSession && !isTokenExpired(sessionPath)) return cachedSession;
|
|
57
|
+
const session = loadRedditSession(sessionPath);
|
|
58
|
+
if (!session) {
|
|
59
|
+
throw new Error('No session file found. Please log in first.');
|
|
60
|
+
}
|
|
61
|
+
if (session.authMode === 'browser') {
|
|
62
|
+
if (!session.cookies || session.cookies.length === 0) {
|
|
63
|
+
throw new Error('No valid cookies in session. Please log in again.');
|
|
64
|
+
}
|
|
65
|
+
} else if (session.authMode === 'cookie') {
|
|
66
|
+
if (!session.redditSession) {
|
|
67
|
+
throw new Error('No valid cookie in session. Please log in again.');
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
if (!session.accessToken) {
|
|
71
|
+
throw new Error('No valid token in session. Please log in again.');
|
|
72
|
+
}
|
|
73
|
+
if (isTokenExpired(sessionPath)) {
|
|
74
|
+
throw new Error('Token expired. Please log in again.');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
cachedSession = session;
|
|
78
|
+
return session;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const getUserAgent = () => {
|
|
82
|
+
const session = getSession();
|
|
83
|
+
return buildUserAgent(session.username);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// ── Rate limit counters ──
|
|
87
|
+
|
|
88
|
+
const loadCounters = () => {
|
|
89
|
+
if (countersCache) return countersCache;
|
|
90
|
+
try {
|
|
91
|
+
const saved = loadRateLimits(sessionPath);
|
|
92
|
+
countersCache = saved || {};
|
|
93
|
+
} catch {
|
|
94
|
+
countersCache = {};
|
|
95
|
+
}
|
|
96
|
+
return countersCache;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const persistCounters = () => {
|
|
100
|
+
try {
|
|
101
|
+
if (!countersCache) return;
|
|
102
|
+
saveRateLimits(sessionPath, countersCache);
|
|
103
|
+
} catch {
|
|
104
|
+
// silent
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const getCounter = (type) => {
|
|
109
|
+
const counters = loadCounters();
|
|
110
|
+
if (!counters[type]) {
|
|
111
|
+
counters[type] = { hourly: 0, daily: 0, hourStart: Date.now(), dayStart: Date.now() };
|
|
112
|
+
}
|
|
113
|
+
const c = counters[type];
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
if (now - c.hourStart > 3600000) { c.hourly = 0; c.hourStart = now; }
|
|
116
|
+
if (now - c.dayStart > 86400000) { c.daily = 0; c.dayStart = now; }
|
|
117
|
+
return c;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const checkLimit = (type) => {
|
|
121
|
+
const c = getCounter(type);
|
|
122
|
+
const hourlyMax = HOURLY_LIMIT[type];
|
|
123
|
+
const dailyMax = DAILY_LIMIT[type];
|
|
124
|
+
if (hourlyMax && c.hourly >= hourlyMax) {
|
|
125
|
+
const waitMin = Math.ceil((3600000 - (Date.now() - c.hourStart)) / 60000);
|
|
126
|
+
throw new Error(`hourly_limit: ${type} exceeded hourly limit of ${hourlyMax}. Retry in ${waitMin} minutes.`);
|
|
127
|
+
}
|
|
128
|
+
if (dailyMax && c.daily >= dailyMax) {
|
|
129
|
+
throw new Error(`daily_limit: ${type} exceeded daily limit of ${dailyMax}. Try again tomorrow.`);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const incrementCounter = (type) => {
|
|
134
|
+
const c = getCounter(type);
|
|
135
|
+
c.hourly++;
|
|
136
|
+
c.daily++;
|
|
137
|
+
persistCounters();
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const withDelay = async (type, fn) => {
|
|
141
|
+
checkLimit(type);
|
|
142
|
+
const [min, max] = DELAY[type] || [10, 20];
|
|
143
|
+
const elapsed = (Date.now() - lastActionTime) / 1000;
|
|
144
|
+
if (lastActionTime > 0 && elapsed < min) {
|
|
145
|
+
await randomDelay(min - elapsed, max - elapsed);
|
|
146
|
+
}
|
|
147
|
+
const result = await fn();
|
|
148
|
+
lastActionTime = Date.now();
|
|
149
|
+
incrementCounter(type);
|
|
150
|
+
return result;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// ── Core request layer ──
|
|
154
|
+
|
|
155
|
+
const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
|
156
|
+
|
|
157
|
+
const request = async (urlPath, options = {}) => {
|
|
158
|
+
const session = getSession();
|
|
159
|
+
|
|
160
|
+
let url, headers;
|
|
161
|
+
|
|
162
|
+
if (session.authMode === 'browser') {
|
|
163
|
+
// Browser-based: use token_v2 as Bearer token with oauth.reddit.com
|
|
164
|
+
const tokenV2 = session.cookies.find((c) => c.name === 'token_v2');
|
|
165
|
+
if (tokenV2) {
|
|
166
|
+
const baseUrl = 'https://oauth.reddit.com';
|
|
167
|
+
url = urlPath.startsWith('http') ? urlPath : `${baseUrl}${urlPath}`;
|
|
168
|
+
headers = {
|
|
169
|
+
'User-Agent': BROWSER_UA,
|
|
170
|
+
Authorization: `Bearer ${tokenV2.value}`,
|
|
171
|
+
...options.headers,
|
|
172
|
+
};
|
|
173
|
+
} else {
|
|
174
|
+
// Fallback: use cookies directly with www.reddit.com
|
|
175
|
+
const baseUrl = 'https://www.reddit.com';
|
|
176
|
+
url = urlPath.startsWith('http') ? urlPath : `${baseUrl}${urlPath}`;
|
|
177
|
+
headers = {
|
|
178
|
+
'User-Agent': BROWSER_UA,
|
|
179
|
+
Cookie: cookiesToHeader(session.cookies),
|
|
180
|
+
...options.headers,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
} else if (session.authMode === 'cookie') {
|
|
184
|
+
// Cookie-based: use old.reddit.com
|
|
185
|
+
const baseUrl = 'https://old.reddit.com';
|
|
186
|
+
url = urlPath.startsWith('http') ? urlPath : `${baseUrl}${urlPath}`;
|
|
187
|
+
headers = {
|
|
188
|
+
'User-Agent': BROWSER_UA,
|
|
189
|
+
Cookie: `reddit_session=${session.redditSession}`,
|
|
190
|
+
...options.headers,
|
|
191
|
+
};
|
|
192
|
+
// Inject modhash for POST requests
|
|
193
|
+
if (options.method === 'POST' && session.modhash && options.body) {
|
|
194
|
+
const bodyStr = String(options.body);
|
|
195
|
+
if (!bodyStr.includes('uh=')) {
|
|
196
|
+
options.body = bodyStr + `&uh=${session.modhash}`;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
// OAuth-based: use oauth.reddit.com
|
|
201
|
+
const baseUrl = 'https://oauth.reddit.com';
|
|
202
|
+
url = urlPath.startsWith('http') ? urlPath : `${baseUrl}${urlPath}`;
|
|
203
|
+
headers = {
|
|
204
|
+
'User-Agent': getUserAgent(),
|
|
205
|
+
Authorization: `Bearer ${session.accessToken}`,
|
|
206
|
+
...options.headers,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const res = await fetch(url, { ...options, headers });
|
|
211
|
+
|
|
212
|
+
if (res.status === 401 || res.status === 403) {
|
|
213
|
+
throw new Error(`Authentication error (${res.status}). Session expired.`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (res.status === 429) {
|
|
217
|
+
throw new Error('rate_limit: Reddit API rate limit exceeded. Please wait and try again.');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!res.ok && !options.allowError) {
|
|
221
|
+
throw new Error(`Reddit API error: ${res.status} ${res.statusText}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return res;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const requestJson = async (urlPath, options = {}) => {
|
|
228
|
+
const res = await request(urlPath, options);
|
|
229
|
+
return res.json();
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// ── API Methods ──
|
|
233
|
+
|
|
234
|
+
const getMe = async () => {
|
|
235
|
+
const session = getSession();
|
|
236
|
+
if (session.authMode === 'browser') {
|
|
237
|
+
// Browser mode: token_v2 works with oauth.reddit.com
|
|
238
|
+
const data = await requestJson('/api/v1/me');
|
|
239
|
+
const d = data.data || data;
|
|
240
|
+
return {
|
|
241
|
+
id: d.id,
|
|
242
|
+
username: d.name || session.username,
|
|
243
|
+
commentKarma: d.comment_karma,
|
|
244
|
+
linkKarma: d.link_karma,
|
|
245
|
+
totalKarma: d.total_karma || (d.comment_karma || 0) + (d.link_karma || 0),
|
|
246
|
+
createdAt: d.created_utc ? new Date(d.created_utc * 1000).toISOString() : null,
|
|
247
|
+
isVerified: d.has_verified_email || d.verified,
|
|
248
|
+
hasMail: d.has_mail,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (session.authMode === 'cookie') {
|
|
252
|
+
const data = await requestJson('/api/me.json');
|
|
253
|
+
const d = data.data || data;
|
|
254
|
+
return {
|
|
255
|
+
id: d.id,
|
|
256
|
+
username: d.name,
|
|
257
|
+
commentKarma: d.comment_karma,
|
|
258
|
+
linkKarma: d.link_karma,
|
|
259
|
+
totalKarma: (d.comment_karma || 0) + (d.link_karma || 0),
|
|
260
|
+
createdAt: d.created_utc ? new Date(d.created_utc * 1000).toISOString() : null,
|
|
261
|
+
isVerified: d.has_verified_email,
|
|
262
|
+
hasMail: d.has_mail,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const data = await requestJson('/api/v1/me');
|
|
266
|
+
return {
|
|
267
|
+
id: data.id,
|
|
268
|
+
username: data.name,
|
|
269
|
+
commentKarma: data.comment_karma,
|
|
270
|
+
linkKarma: data.link_karma,
|
|
271
|
+
totalKarma: data.total_karma,
|
|
272
|
+
createdAt: data.created_utc ? new Date(data.created_utc * 1000).toISOString() : null,
|
|
273
|
+
iconUrl: data.icon_img,
|
|
274
|
+
isVerified: data.verified,
|
|
275
|
+
hasMail: data.has_mail,
|
|
276
|
+
};
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const submitPost = ({ subreddit, title, text, kind = 'self', url: linkUrl, flair } = {}) =>
|
|
280
|
+
withDelay('post', async () => {
|
|
281
|
+
if (!subreddit || !title) throw new Error('subreddit and title are required.');
|
|
282
|
+
|
|
283
|
+
const body = new URLSearchParams({
|
|
284
|
+
api_type: 'json',
|
|
285
|
+
kind,
|
|
286
|
+
sr: subreddit,
|
|
287
|
+
title,
|
|
288
|
+
resubmit: 'true',
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
if (kind === 'self' && text) body.append('text', text);
|
|
292
|
+
if (kind === 'link' && linkUrl) body.append('url', linkUrl);
|
|
293
|
+
if (flair) body.append('flair_text', flair);
|
|
294
|
+
|
|
295
|
+
const data = await requestJson('/api/submit', {
|
|
296
|
+
method: 'POST',
|
|
297
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
298
|
+
body: body.toString(),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const err = parseRedditError(data);
|
|
302
|
+
if (err) throw new Error(`submitPost failed: ${err.error} - ${err.message}`);
|
|
303
|
+
|
|
304
|
+
const postData = data.json?.data;
|
|
305
|
+
return {
|
|
306
|
+
id: postData?.id,
|
|
307
|
+
fullname: postData?.name,
|
|
308
|
+
url: postData?.url,
|
|
309
|
+
title,
|
|
310
|
+
subreddit,
|
|
311
|
+
};
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const getSubreddit = async ({ name } = {}) => {
|
|
315
|
+
if (!name) throw new Error('subreddit name is required.');
|
|
316
|
+
const data = await requestJson(`/r/${name}/about`);
|
|
317
|
+
const d = data.data || data;
|
|
318
|
+
return {
|
|
319
|
+
name: d.display_name,
|
|
320
|
+
title: d.title,
|
|
321
|
+
description: d.public_description,
|
|
322
|
+
subscribers: d.subscribers,
|
|
323
|
+
activeUsers: d.accounts_active,
|
|
324
|
+
createdAt: d.created_utc ? new Date(d.created_utc * 1000).toISOString() : null,
|
|
325
|
+
isNsfw: d.over18,
|
|
326
|
+
url: `https://www.reddit.com/r/${d.display_name}/`,
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const getSubredditPosts = async ({ subreddit, sort = 'hot', limit = 25 } = {}) => {
|
|
331
|
+
if (!subreddit) throw new Error('subreddit is required.');
|
|
332
|
+
const params = new URLSearchParams({ limit: String(limit) });
|
|
333
|
+
const data = await requestJson(`/r/${subreddit}/${sort}?${params}`);
|
|
334
|
+
const posts = (data.data?.children || []).map((child) => {
|
|
335
|
+
const p = child.data;
|
|
336
|
+
return {
|
|
337
|
+
id: p.id,
|
|
338
|
+
fullname: p.name,
|
|
339
|
+
title: p.title,
|
|
340
|
+
author: p.author,
|
|
341
|
+
subreddit: p.subreddit,
|
|
342
|
+
score: p.score,
|
|
343
|
+
upvoteRatio: p.upvote_ratio,
|
|
344
|
+
numComments: p.num_comments,
|
|
345
|
+
url: p.url,
|
|
346
|
+
permalink: `https://www.reddit.com${p.permalink}`,
|
|
347
|
+
selftext: p.selftext?.substring(0, 500),
|
|
348
|
+
createdAt: p.created_utc ? new Date(p.created_utc * 1000).toISOString() : null,
|
|
349
|
+
isNsfw: p.over_18,
|
|
350
|
+
flair: p.link_flair_text,
|
|
351
|
+
};
|
|
352
|
+
});
|
|
353
|
+
return posts;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const getPost = async ({ postId } = {}) => {
|
|
357
|
+
if (!postId) throw new Error('postId is required.');
|
|
358
|
+
const cleanId = String(postId).replace(/^t3_/, '');
|
|
359
|
+
const data = await requestJson(`/comments/${cleanId}`);
|
|
360
|
+
const postListing = Array.isArray(data) ? data[0] : data;
|
|
361
|
+
const p = postListing?.data?.children?.[0]?.data;
|
|
362
|
+
if (!p) throw new Error(`Post not found: ${postId}`);
|
|
363
|
+
|
|
364
|
+
const commentListing = Array.isArray(data) ? data[1] : null;
|
|
365
|
+
const comments = (commentListing?.data?.children || [])
|
|
366
|
+
.filter((c) => c.kind === 't1')
|
|
367
|
+
.map((c) => ({
|
|
368
|
+
id: c.data.id,
|
|
369
|
+
fullname: c.data.name,
|
|
370
|
+
author: c.data.author,
|
|
371
|
+
body: c.data.body?.substring(0, 500),
|
|
372
|
+
score: c.data.score,
|
|
373
|
+
createdAt: c.data.created_utc ? new Date(c.data.created_utc * 1000).toISOString() : null,
|
|
374
|
+
}));
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
id: p.id,
|
|
378
|
+
fullname: p.name,
|
|
379
|
+
title: p.title,
|
|
380
|
+
author: p.author,
|
|
381
|
+
subreddit: p.subreddit,
|
|
382
|
+
selftext: p.selftext,
|
|
383
|
+
score: p.score,
|
|
384
|
+
upvoteRatio: p.upvote_ratio,
|
|
385
|
+
numComments: p.num_comments,
|
|
386
|
+
url: p.url,
|
|
387
|
+
permalink: `https://www.reddit.com${p.permalink}`,
|
|
388
|
+
createdAt: p.created_utc ? new Date(p.created_utc * 1000).toISOString() : null,
|
|
389
|
+
flair: p.link_flair_text,
|
|
390
|
+
comments,
|
|
391
|
+
};
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const comment = ({ parentFullname, text } = {}) =>
|
|
395
|
+
withDelay('comment', async () => {
|
|
396
|
+
if (!parentFullname || !text) throw new Error('parentFullname and text are required.');
|
|
397
|
+
|
|
398
|
+
const body = new URLSearchParams({
|
|
399
|
+
api_type: 'json',
|
|
400
|
+
thing_id: parentFullname,
|
|
401
|
+
text,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const data = await requestJson('/api/comment', {
|
|
405
|
+
method: 'POST',
|
|
406
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
407
|
+
body: body.toString(),
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const err = parseRedditError(data);
|
|
411
|
+
if (err) throw new Error(`comment failed: ${err.error} - ${err.message}`);
|
|
412
|
+
|
|
413
|
+
const commentData = data.json?.data?.things?.[0]?.data;
|
|
414
|
+
return {
|
|
415
|
+
id: commentData?.id,
|
|
416
|
+
fullname: commentData?.name,
|
|
417
|
+
body: text,
|
|
418
|
+
parentFullname,
|
|
419
|
+
};
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const vote = ({ fullname, direction = 1 } = {}) =>
|
|
423
|
+
withDelay('vote', async () => {
|
|
424
|
+
if (!fullname) throw new Error('fullname is required.');
|
|
425
|
+
|
|
426
|
+
const body = new URLSearchParams({
|
|
427
|
+
id: fullname,
|
|
428
|
+
dir: String(direction),
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
await request('/api/vote', {
|
|
432
|
+
method: 'POST',
|
|
433
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
434
|
+
body: body.toString(),
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
return { status: 'ok', fullname, direction };
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const search = async ({ query, subreddit, limit = 25 } = {}) => {
|
|
441
|
+
if (!query) throw new Error('query is required.');
|
|
442
|
+
const params = new URLSearchParams({
|
|
443
|
+
q: query,
|
|
444
|
+
limit: String(limit),
|
|
445
|
+
sort: 'relevance',
|
|
446
|
+
type: 'link',
|
|
447
|
+
});
|
|
448
|
+
if (subreddit) params.set('restrict_sr', 'true');
|
|
449
|
+
const endpoint = subreddit ? `/r/${subreddit}/search` : '/search';
|
|
450
|
+
const data = await requestJson(`${endpoint}?${params}`);
|
|
451
|
+
return (data.data?.children || []).map((child) => {
|
|
452
|
+
const p = child.data;
|
|
453
|
+
return {
|
|
454
|
+
id: p.id,
|
|
455
|
+
fullname: p.name,
|
|
456
|
+
title: p.title,
|
|
457
|
+
author: p.author,
|
|
458
|
+
subreddit: p.subreddit,
|
|
459
|
+
score: p.score,
|
|
460
|
+
numComments: p.num_comments,
|
|
461
|
+
url: p.url,
|
|
462
|
+
permalink: `https://www.reddit.com${p.permalink}`,
|
|
463
|
+
selftext: p.selftext?.substring(0, 300),
|
|
464
|
+
createdAt: p.created_utc ? new Date(p.created_utc * 1000).toISOString() : null,
|
|
465
|
+
};
|
|
466
|
+
});
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const subscribe = ({ subreddit } = {}) =>
|
|
470
|
+
withDelay('subscribe', async () => {
|
|
471
|
+
if (!subreddit) throw new Error('subreddit is required.');
|
|
472
|
+
|
|
473
|
+
const body = new URLSearchParams({
|
|
474
|
+
action: 'sub',
|
|
475
|
+
sr_name: subreddit,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
await request('/api/subscribe', {
|
|
479
|
+
method: 'POST',
|
|
480
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
481
|
+
body: body.toString(),
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
return { status: 'ok', subreddit, subscribed: true };
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const unsubscribe = ({ subreddit } = {}) =>
|
|
488
|
+
withDelay('subscribe', async () => {
|
|
489
|
+
if (!subreddit) throw new Error('subreddit is required.');
|
|
490
|
+
|
|
491
|
+
const body = new URLSearchParams({
|
|
492
|
+
action: 'unsub',
|
|
493
|
+
sr_name: subreddit,
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
await request('/api/subscribe', {
|
|
497
|
+
method: 'POST',
|
|
498
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
499
|
+
body: body.toString(),
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
return { status: 'ok', subreddit, subscribed: false };
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const getUserPosts = async ({ username, limit = 25 } = {}) => {
|
|
506
|
+
if (!username) throw new Error('username is required.');
|
|
507
|
+
const params = new URLSearchParams({
|
|
508
|
+
limit: String(limit),
|
|
509
|
+
sort: 'new',
|
|
510
|
+
type: 'links',
|
|
511
|
+
});
|
|
512
|
+
const data = await requestJson(`/user/${username}/submitted?${params}`);
|
|
513
|
+
return (data.data?.children || []).map((child) => {
|
|
514
|
+
const p = child.data;
|
|
515
|
+
return {
|
|
516
|
+
id: p.id,
|
|
517
|
+
fullname: p.name,
|
|
518
|
+
title: p.title,
|
|
519
|
+
subreddit: p.subreddit,
|
|
520
|
+
score: p.score,
|
|
521
|
+
numComments: p.num_comments,
|
|
522
|
+
url: p.url,
|
|
523
|
+
permalink: `https://www.reddit.com${p.permalink}`,
|
|
524
|
+
createdAt: p.created_utc ? new Date(p.created_utc * 1000).toISOString() : null,
|
|
525
|
+
};
|
|
526
|
+
});
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const deletePost = async ({ fullname } = {}) => {
|
|
530
|
+
if (!fullname) throw new Error('fullname is required.');
|
|
531
|
+
|
|
532
|
+
const body = new URLSearchParams({ id: fullname });
|
|
533
|
+
|
|
534
|
+
await request('/api/del', {
|
|
535
|
+
method: 'POST',
|
|
536
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
537
|
+
body: body.toString(),
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
return { status: 'ok', fullname };
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const refreshToken = async ({ clientId, clientSecret, username, password } = {}) => {
|
|
544
|
+
const { createLogin } = require('./auth');
|
|
545
|
+
const login = createLogin({ sessionPath });
|
|
546
|
+
return login({ clientId, clientSecret, username, password });
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const resetState = () => {
|
|
550
|
+
cachedSession = null;
|
|
551
|
+
countersCache = null;
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
return {
|
|
555
|
+
getMe,
|
|
556
|
+
submitPost,
|
|
557
|
+
getSubreddit,
|
|
558
|
+
getSubredditPosts,
|
|
559
|
+
getPost,
|
|
560
|
+
comment,
|
|
561
|
+
vote,
|
|
562
|
+
search,
|
|
563
|
+
subscribe,
|
|
564
|
+
unsubscribe,
|
|
565
|
+
getUserPosts,
|
|
566
|
+
deletePost,
|
|
567
|
+
refreshToken,
|
|
568
|
+
resetState,
|
|
569
|
+
getRateLimitStatus: () => {
|
|
570
|
+
const status = {};
|
|
571
|
+
for (const type of Object.keys(HOURLY_LIMIT)) {
|
|
572
|
+
const c = getCounter(type);
|
|
573
|
+
status[type] = {
|
|
574
|
+
hourly: `${c.hourly}/${HOURLY_LIMIT[type]}`,
|
|
575
|
+
daily: `${c.daily}/${DAILY_LIMIT[type]}`,
|
|
576
|
+
delay: `${DELAY[type]?.[0]}~${DELAY[type]?.[1]}s`,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
return status;
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
module.exports = createRedditApiClient;
|