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 +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
|
@@ -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,
|