viruagent-cli 0.6.2 → 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.
@@ -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;