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.
@@ -0,0 +1,174 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { saveProviderMeta } = require('../../storage/sessionStore');
4
+ const { readRedditCredentials, parseRedditSessionError, buildLoginErrorMessage } = require('./utils');
5
+
6
+ const readSessionFile = (sessionPath) => {
7
+ if (!fs.existsSync(sessionPath)) return null;
8
+ try {
9
+ return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));
10
+ } catch {
11
+ return null;
12
+ }
13
+ };
14
+
15
+ const writeSessionFile = (sessionPath, data) => {
16
+ fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
17
+ fs.writeFileSync(sessionPath, JSON.stringify(data, null, 2), 'utf-8');
18
+ };
19
+
20
+ const saveRedditSession = (sessionPath, tokenData) => {
21
+ const existing = readSessionFile(sessionPath) || {};
22
+ const sessionData = {
23
+ ...existing,
24
+ authMode: tokenData.authMode || existing.authMode || 'oauth',
25
+ username: tokenData.username,
26
+ updatedAt: new Date().toISOString(),
27
+ };
28
+
29
+ if (tokenData.authMode === 'browser') {
30
+ sessionData.cookies = tokenData.cookies || [];
31
+ // Clean up other auth fields
32
+ delete sessionData.accessToken;
33
+ delete sessionData.expiresAt;
34
+ delete sessionData.redditSession;
35
+ delete sessionData.modhash;
36
+ } else if (tokenData.authMode === 'cookie') {
37
+ sessionData.redditSession = tokenData.redditSession;
38
+ sessionData.modhash = tokenData.modhash;
39
+ delete sessionData.accessToken;
40
+ delete sessionData.expiresAt;
41
+ delete sessionData.cookies;
42
+ } else {
43
+ sessionData.accessToken = tokenData.accessToken;
44
+ sessionData.expiresAt = tokenData.expiresAt;
45
+ delete sessionData.cookies;
46
+ }
47
+
48
+ writeSessionFile(sessionPath, sessionData);
49
+ };
50
+
51
+ const loadRedditSession = (sessionPath) => {
52
+ const raw = readSessionFile(sessionPath);
53
+ if (!raw) return null;
54
+
55
+ if (raw.authMode === 'browser') {
56
+ if (!raw.cookies || raw.cookies.length === 0) return null;
57
+ return {
58
+ authMode: 'browser',
59
+ cookies: raw.cookies,
60
+ username: raw.username,
61
+ };
62
+ }
63
+
64
+ if (raw.authMode === 'cookie') {
65
+ if (!raw.redditSession) return null;
66
+ return {
67
+ authMode: 'cookie',
68
+ redditSession: raw.redditSession,
69
+ modhash: raw.modhash || '',
70
+ username: raw.username,
71
+ };
72
+ }
73
+
74
+ // OAuth mode
75
+ if (!raw.accessToken) return null;
76
+ return {
77
+ authMode: 'oauth',
78
+ accessToken: raw.accessToken,
79
+ expiresAt: raw.expiresAt,
80
+ username: raw.username,
81
+ };
82
+ };
83
+
84
+ const cookiesToHeader = (cookies) =>
85
+ cookies.map((c) => `${c.name}=${c.value}`).join('; ');
86
+
87
+ const isTokenExpired = (sessionPath) => {
88
+ const session = loadRedditSession(sessionPath);
89
+ if (!session) return true;
90
+ // Browser and cookie sessions don't expire via token
91
+ if (session.authMode === 'cookie' || session.authMode === 'browser') return false;
92
+ if (!session.expiresAt) return true;
93
+ // Consider expired 60 seconds before actual expiry
94
+ return Date.now() >= session.expiresAt - 60000;
95
+ };
96
+
97
+ // ── Rate Limit persistence ──
98
+
99
+ const loadRateLimits = (sessionPath) => {
100
+ const raw = readSessionFile(sessionPath);
101
+ return raw?.rateLimits || null;
102
+ };
103
+
104
+ const saveRateLimits = (sessionPath, counters) => {
105
+ const raw = readSessionFile(sessionPath) || {};
106
+ raw.rateLimits = {
107
+ ...counters,
108
+ savedAt: new Date().toISOString(),
109
+ };
110
+ writeSessionFile(sessionPath, raw);
111
+ };
112
+
113
+ const validateRedditSession = (sessionPath) => {
114
+ const session = loadRedditSession(sessionPath);
115
+ if (!session) return false;
116
+ if (session.authMode === 'browser') return session.cookies?.length > 0;
117
+ if (session.authMode === 'cookie') return Boolean(session.redditSession);
118
+ if (!session.accessToken) return false;
119
+ return !isTokenExpired(sessionPath);
120
+ };
121
+
122
+ const createRedditWithProviderSession = (loginFn, account) => async (fn) => {
123
+ const credentials = readRedditCredentials();
124
+ const hasCredentials = Boolean(
125
+ (credentials.clientId && credentials.clientSecret && credentials.username && credentials.password) ||
126
+ (credentials.username && credentials.password),
127
+ );
128
+
129
+ try {
130
+ const result = await fn();
131
+ saveProviderMeta('reddit', { loggedIn: true, lastValidatedAt: new Date().toISOString() }, account);
132
+ return result;
133
+ } catch (error) {
134
+ if (!parseRedditSessionError(error) || !hasCredentials) {
135
+ throw error;
136
+ }
137
+
138
+ try {
139
+ const loginResult = await loginFn(credentials);
140
+
141
+ saveProviderMeta('reddit', {
142
+ loggedIn: loginResult.loggedIn,
143
+ username: loginResult.username,
144
+ sessionPath: loginResult.sessionPath,
145
+ lastRefreshedAt: new Date().toISOString(),
146
+ lastError: null,
147
+ }, account);
148
+
149
+ if (!loginResult.loggedIn) {
150
+ throw new Error(loginResult.message || 'Login status could not be confirmed after session refresh.');
151
+ }
152
+
153
+ return fn();
154
+ } catch (reloginError) {
155
+ saveProviderMeta('reddit', {
156
+ loggedIn: false,
157
+ lastError: buildLoginErrorMessage(reloginError),
158
+ lastValidatedAt: new Date().toISOString(),
159
+ }, account);
160
+ throw reloginError;
161
+ }
162
+ }
163
+ };
164
+
165
+ module.exports = {
166
+ saveRedditSession,
167
+ loadRedditSession,
168
+ isTokenExpired,
169
+ cookiesToHeader,
170
+ loadRateLimits,
171
+ saveRateLimits,
172
+ validateRedditSession,
173
+ createRedditWithProviderSession,
174
+ };
@@ -0,0 +1,49 @@
1
+ const readRedditCredentials = () => {
2
+ const clientId = process.env.REDDIT_CLIENT_ID;
3
+ const clientSecret = process.env.REDDIT_CLIENT_SECRET;
4
+ const username = process.env.REDDIT_USERNAME;
5
+ const password = process.env.REDDIT_PASSWORD;
6
+ const env = (v) => typeof v === 'string' && v.trim() ? v.trim() : null;
7
+ return {
8
+ clientId: env(clientId),
9
+ clientSecret: env(clientSecret),
10
+ username: env(username),
11
+ password: env(password),
12
+ hasOAuth: Boolean(env(clientId) && env(clientSecret)),
13
+ hasPassword: Boolean(env(username) && env(password)),
14
+ };
15
+ };
16
+
17
+ const parseRedditSessionError = (error) => {
18
+ const message = String(error?.message || '').toLowerCase();
19
+ return [
20
+ 'no session file found',
21
+ 'no valid token in session',
22
+ 'session expired',
23
+ 'authentication error',
24
+ 'token expired',
25
+ 'unauthorized',
26
+ '401',
27
+ '403',
28
+ ].some((token) => message.includes(token.toLowerCase()));
29
+ };
30
+
31
+ const buildLoginErrorMessage = (error) => String(error?.message || 'Session validation failed.');
32
+
33
+ const parseRedditError = (data) => {
34
+ if (data?.json?.errors?.length) {
35
+ const [code, message] = data.json.errors[0];
36
+ return { ok: false, error: code, message, hint: `Reddit API error: ${code}` };
37
+ }
38
+ if (data?.error) {
39
+ return { ok: false, error: String(data.error), message: data.message || data.error_description || '', hint: '' };
40
+ }
41
+ return null;
42
+ };
43
+
44
+ module.exports = {
45
+ readRedditCredentials,
46
+ parseRedditSessionError,
47
+ buildLoginErrorMessage,
48
+ parseRedditError,
49
+ };
@@ -13,6 +13,7 @@ const {
13
13
  KAKAO_TRIGGER_SELECTORS,
14
14
  KAKAO_LOGIN_SELECTORS,
15
15
  KAKAO_2FA_SELECTORS,
16
+ KAKAO_SIMPLE_LOGIN_SELECTORS,
16
17
  } = require('./selectors');
17
18
  const {
18
19
  CHROME_PROFILE_DIR,
@@ -79,6 +80,16 @@ const createAskForAuthentication = ({ sessionPath, tistoryApi, pending2faResult
79
80
  await page.waitForLoadState('domcontentloaded').catch(() => {});
80
81
  await page.waitForTimeout(800);
81
82
 
83
+ // Handle "간편로그인 계정 선택" screen — click "새로운 계정으로 로그인"
84
+ if (page.url().includes('login/simple') || page.url().includes('#simpleLogin')) {
85
+ const newAccountSelector = await pickValue(page, KAKAO_SIMPLE_LOGIN_SELECTORS.newAccountButton);
86
+ if (newAccountSelector) {
87
+ await page.locator(newAccountSelector).click({ timeout: 5000 }).catch(() => {});
88
+ await page.waitForLoadState('domcontentloaded').catch(() => {});
89
+ await page.waitForTimeout(800);
90
+ }
91
+ }
92
+
82
93
  let finalLoginStatus = false;
83
94
  let pendingTwoFactorAction = false;
84
95
 
@@ -25,6 +25,14 @@ const KAKAO_2FA_SELECTORS = {
25
25
  rememberDevice: ['#isRememberBrowser--5', 'input[name="isRememberBrowser"]'],
26
26
  };
27
27
 
28
+ const KAKAO_SIMPLE_LOGIN_SELECTORS = {
29
+ newAccountButton: [
30
+ 'a:has-text("새로운 계정으로 로그인")',
31
+ 'span.tit_profile:has-text("새로운 계정으로 로그인")',
32
+ 'a[href*="login_type=simple"]',
33
+ ],
34
+ };
35
+
28
36
  const KAKAO_ACCOUNT_CONFIRM_SELECTORS = {
29
37
  textMarker: [
30
38
  'text=해당 카카오 계정으로',
@@ -47,5 +55,6 @@ module.exports = {
47
55
  KAKAO_TRIGGER_SELECTORS,
48
56
  KAKAO_LOGIN_SELECTORS,
49
57
  KAKAO_2FA_SELECTORS,
58
+ KAKAO_SIMPLE_LOGIN_SELECTORS,
50
59
  KAKAO_ACCOUNT_CONFIRM_SELECTORS,
51
60
  };
@@ -2,6 +2,36 @@ const fs = require('fs');
2
2
  const { loadXSession, cookiesToHeader, loadRateLimits, saveRateLimits } = require('./session');
3
3
  const { USER_AGENT, BEARER_TOKEN } = require('./auth');
4
4
  const { getOperation, invalidateCache } = require('./graphqlSync');
5
+ const { ClientTransaction, handleXMigration } = require('x-client-transaction-id');
6
+
7
+ // ── x-client-transaction-id singleton (cached, auto-refreshes on error) ──
8
+ let _ctInstance = null;
9
+ let _ctInitTime = 0;
10
+ const CT_TTL_MS = 3600000; // 1 hour
11
+
12
+ const getClientTransaction = async () => {
13
+ if (_ctInstance && Date.now() - _ctInitTime < CT_TTL_MS) return _ctInstance;
14
+ const document = await handleXMigration();
15
+ _ctInstance = await ClientTransaction.create(document);
16
+ _ctInitTime = Date.now();
17
+ return _ctInstance;
18
+ };
19
+
20
+ const generateTransactionId = async (method, path) => {
21
+ try {
22
+ const ct = await getClientTransaction();
23
+ return await ct.generateTransactionId(method, path);
24
+ } catch {
25
+ // If generation fails, invalidate and retry once
26
+ _ctInstance = null;
27
+ try {
28
+ const ct = await getClientTransaction();
29
+ return await ct.generateTransactionId(method, path);
30
+ } catch {
31
+ return undefined; // Proceed without the header if generation fails
32
+ }
33
+ }
34
+ };
5
35
 
6
36
  const randomDelay = (minSec, maxSec) => {
7
37
  const ms = (minSec + Math.random() * (maxSec - minSec)) * 1000;
@@ -166,10 +196,28 @@ const createXApiClient = ({ sessionPath }) => {
166
196
  'x-twitter-active-user': 'yes',
167
197
  'x-twitter-client-language': 'ko',
168
198
  Cookie: cookiesToHeader(getCookies()),
199
+ // Browser fingerprint headers
200
+ Origin: 'https://x.com',
201
+ Referer: 'https://x.com/',
202
+ 'sec-ch-ua': '"Chromium";v="146", "Google Chrome";v="146", "Not=A?Brand";v="99"',
203
+ 'sec-ch-ua-mobile': '?0',
204
+ 'sec-ch-ua-platform': '"macOS"',
205
+ 'sec-fetch-dest': 'empty',
206
+ 'sec-fetch-mode': 'cors',
207
+ 'sec-fetch-site': 'same-origin',
169
208
  });
170
209
 
171
210
  const request = async (url, options = {}) => {
172
211
  const headers = { ...buildHeaders(), ...options.headers };
212
+
213
+ // Generate x-client-transaction-id for the request
214
+ const method = (options.method || 'GET').toUpperCase();
215
+ const urlPath = new URL(url).pathname;
216
+ const transactionId = await generateTransactionId(method, urlPath);
217
+ if (transactionId) {
218
+ headers['x-client-transaction-id'] = transactionId;
219
+ }
220
+
173
221
  const res = await fetch(url, { ...options, headers, redirect: 'manual' });
174
222
 
175
223
  if (res.status === 401 || res.status === 403) {
package/src/runner.js CHANGED
@@ -118,12 +118,14 @@ const runCommand = async (command, opts = {}) => {
118
118
  twoFactorCode: opts.twoFactorCode || undefined,
119
119
  authToken: opts.authToken || undefined,
120
120
  ct0: opts.ct0 || undefined,
121
+ clientId: opts.clientId || undefined,
122
+ clientSecret: opts.clientSecret || undefined,
121
123
  })
122
124
  )();
123
125
 
124
126
  case 'publish': {
125
127
  const content = readContent(opts);
126
- if (!content && providerName !== 'insta' && providerName !== 'x') {
128
+ if (!content && providerName !== 'insta' && providerName !== 'x' && providerName !== 'reddit') {
127
129
  throw createError('MISSING_CONTENT', 'publish requires --content or --content-file', 'viruagent-cli publish --spec');
128
130
  }
129
131
  return withProvider(() =>
@@ -140,6 +142,9 @@ const runCommand = async (command, opts = {}) => {
140
142
  imageUploadLimit: parseIntOrNull(opts.imageUploadLimit) || 1,
141
143
  minimumImageCount: parseIntOrNull(opts.minimumImageCount) || 1,
142
144
  autoUploadImages: opts.autoUploadImages !== false,
145
+ subreddit: opts.subreddit || undefined,
146
+ kind: opts.kind || undefined,
147
+ flair: opts.flair || undefined,
143
148
  })
144
149
  )();
145
150
  }
@@ -304,6 +309,18 @@ const runCommand = async (command, opts = {}) => {
304
309
  }
305
310
  return withProvider(() => provider.delete ? provider.delete({ postId: opts.postId }) : provider.deletePost({ postId: opts.postId }))();
306
311
 
312
+ case 'subscribe':
313
+ if (!opts.subreddit) {
314
+ throw createError('MISSING_PARAM', 'subscribe requires --subreddit');
315
+ }
316
+ return withProvider(() => provider.subscribe({ subreddit: opts.subreddit }))();
317
+
318
+ case 'unsubscribe':
319
+ if (!opts.subreddit) {
320
+ throw createError('MISSING_PARAM', 'unsubscribe requires --subreddit');
321
+ }
322
+ return withProvider(() => provider.unsubscribe({ subreddit: opts.subreddit }))();
323
+
307
324
  case 'sync-operations':
308
325
  return withProvider(() => provider.syncOperations())();
309
326
 
@@ -4,15 +4,17 @@ const createTistoryProvider = require('../providers/tistory');
4
4
  const createNaverProvider = require('../providers/naver');
5
5
  const createInstaProvider = require('../providers/insta');
6
6
  const createXProvider = require('../providers/x');
7
+ const createRedditProvider = require('../providers/reddit');
7
8
 
8
9
  const providerFactory = {
9
10
  tistory: createTistoryProvider,
10
11
  naver: createNaverProvider,
11
12
  insta: createInstaProvider,
12
13
  x: createXProvider,
14
+ reddit: createRedditProvider,
13
15
  };
14
16
 
15
- const providers = ['tistory', 'naver', 'insta', 'x'];
17
+ const providers = ['tistory', 'naver', 'insta', 'x', 'reddit'];
16
18
 
17
19
  const createProviderManager = () => {
18
20
  const cache = new Map();
@@ -38,7 +40,7 @@ const createProviderManager = () => {
38
40
  return cache.get(cacheKey);
39
41
  };
40
42
 
41
- const providerNames = { tistory: 'Tistory', naver: 'Naver Blog', insta: 'Instagram', x: 'X (Twitter)' };
43
+ const providerNames = { tistory: 'Tistory', naver: 'Naver Blog', insta: 'Instagram', x: 'X (Twitter)', reddit: 'Reddit' };
42
44
  const getAvailableProviders = () => providers.map((provider) => ({
43
45
  id: provider,
44
46
  name: providerNames[provider] || provider,