viruagent-cli 0.7.0 → 0.7.1

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 CHANGED
@@ -51,7 +51,7 @@
51
51
 
52
52
  ## 빠른 시작
53
53
 
54
- ![viru_install](https://github.com/user-attachments/assets/35efb57f-fada-44c0-8dd4-6d586ef33a7c)
54
+ ![viruagent-cli demo](demo/demo.gif)
55
55
 
56
56
  아래 내용을 AI 에이전트에게 그대로 복사해서 보여주세요.
57
57
 
package/README.md CHANGED
@@ -51,7 +51,7 @@ User: "Like and comment on all posts from @username"
51
51
 
52
52
  ## Quick Start
53
53
 
54
- ![viru_install](https://github.com/user-attachments/assets/35efb57f-fada-44c0-8dd4-6d586ef33a7c)
54
+ ![viruagent-cli demo](demo/demo.gif)
55
55
 
56
56
  Copy the following to your AI agent:
57
57
 
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.0",
3
+ "version": "0.7.1",
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 res = await fetch(url, { ...options, headers, redirect: 'manual' });
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;