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,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
  };