viruagent-cli 0.5.1 → 0.6.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/package.json +1 -1
- package/skills/persona-blogger/SKILL.md +47 -0
- package/skills/persona-influencer-manager/SKILL.md +40 -0
- package/skills/persona-sns-marketer/SKILL.md +38 -0
- package/skills/recipe-blog-publish/SKILL.md +59 -0
- package/skills/recipe-cross-post/SKILL.md +41 -0
- package/skills/recipe-daily-engagement/SKILL.md +57 -0
- package/skills/recipe-engage-feed/SKILL.md +56 -0
- package/skills/recipe-grow-followers/SKILL.md +52 -0
- package/skills/va-insta/SKILL.md +64 -0
- package/skills/va-insta-comment/SKILL.md +59 -0
- package/skills/va-insta-dm/SKILL.md +51 -0
- package/skills/va-insta-feed/SKILL.md +72 -0
- package/skills/va-insta-follow/SKILL.md +42 -0
- package/skills/va-insta-like/SKILL.md +55 -0
- package/skills/va-insta-login/SKILL.md +57 -0
- package/skills/va-naver/SKILL.md +43 -0
- package/skills/va-naver-categories/SKILL.md +25 -0
- package/skills/va-naver-draft/SKILL.md +33 -0
- package/skills/va-naver-login/SKILL.md +58 -0
- package/skills/va-naver-posts/SKILL.md +38 -0
- package/skills/va-naver-publish/SKILL.md +337 -0
- package/skills/va-shared/SKILL.md +170 -0
- package/skills/va-tistory/SKILL.md +42 -0
- package/skills/va-tistory-categories/SKILL.md +37 -0
- package/skills/va-tistory-draft/SKILL.md +31 -0
- package/skills/va-tistory-login/SKILL.md +54 -0
- package/skills/va-tistory-posts/SKILL.md +38 -0
- package/skills/va-tistory-publish/SKILL.md +466 -0
- package/src/providers/chromeManager.js +186 -0
- package/src/providers/insta/index.js +31 -10
- package/src/providers/insta/session.js +4 -7
- package/src/providers/naver/auth.js +37 -23
- package/src/providers/naver/index.js +6 -10
- package/src/providers/naver/session.js +22 -28
- package/src/providers/tistory/auth.js +129 -105
- package/src/providers/tistory/index.js +6 -6
- package/src/providers/tistory/session.js +24 -24
- package/src/runner.js +28 -17
- package/src/services/providerManager.js +7 -5
- package/src/storage/sessionStore.js +18 -9
- package/skills/viruagent-insta.md +0 -163
- package/skills/viruagent-naver.md +0 -122
- package/skills/viruagent-tistory.md +0 -117
- package/skills/viruagent.md +0 -77
|
@@ -5,12 +5,12 @@ const { readInstaCredentials } = require('./utils');
|
|
|
5
5
|
const { createInstaWithProviderSession } = require('./session');
|
|
6
6
|
const { createAskForAuthentication } = require('./auth');
|
|
7
7
|
|
|
8
|
-
const createInstaProvider = ({ sessionPath }) => {
|
|
8
|
+
const createInstaProvider = ({ sessionPath, account }) => {
|
|
9
9
|
const instaApi = createInstaApiClient({ sessionPath });
|
|
10
10
|
|
|
11
11
|
const askForAuthentication = createAskForAuthentication({ sessionPath });
|
|
12
12
|
|
|
13
|
-
const withProviderSession = createInstaWithProviderSession(askForAuthentication);
|
|
13
|
+
const withProviderSession = createInstaWithProviderSession(askForAuthentication, account);
|
|
14
14
|
const smart = createSmartComment(instaApi);
|
|
15
15
|
|
|
16
16
|
return {
|
|
@@ -29,7 +29,7 @@ const createInstaProvider = ({ sessionPath }) => {
|
|
|
29
29
|
userId,
|
|
30
30
|
hasSession: Boolean(sessionid?.value),
|
|
31
31
|
sessionPath,
|
|
32
|
-
metadata: getProviderMeta('insta') || {},
|
|
32
|
+
metadata: getProviderMeta('insta', account) || {},
|
|
33
33
|
};
|
|
34
34
|
} catch (error) {
|
|
35
35
|
return {
|
|
@@ -37,7 +37,7 @@ const createInstaProvider = ({ sessionPath }) => {
|
|
|
37
37
|
loggedIn: false,
|
|
38
38
|
sessionPath,
|
|
39
39
|
error: error.message,
|
|
40
|
-
metadata: getProviderMeta('insta') || {},
|
|
40
|
+
metadata: getProviderMeta('insta', account) || {},
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
});
|
|
@@ -65,7 +65,7 @@ const createInstaProvider = ({ sessionPath }) => {
|
|
|
65
65
|
userId: result.userId,
|
|
66
66
|
username: result.username,
|
|
67
67
|
sessionPath: result.sessionPath,
|
|
68
|
-
});
|
|
68
|
+
}, account);
|
|
69
69
|
|
|
70
70
|
return result;
|
|
71
71
|
},
|
|
@@ -225,12 +225,33 @@ const createInstaProvider = ({ sessionPath }) => {
|
|
|
225
225
|
});
|
|
226
226
|
},
|
|
227
227
|
|
|
228
|
-
async publish({ imageUrl, imagePath, caption = '' } = {}) {
|
|
228
|
+
async publish({ imageUrl, imagePath, caption = '', content, title, relatedImageKeywords = [], imageUrls = [] } = {}) {
|
|
229
229
|
return withProviderSession(async () => {
|
|
230
|
-
if
|
|
231
|
-
|
|
230
|
+
// Use content as caption if caption is not provided
|
|
231
|
+
const finalCaption = caption || content || '';
|
|
232
|
+
|
|
233
|
+
// Resolve image: explicit imageUrl/imagePath > imageUrls > relatedImageKeywords search
|
|
234
|
+
let resolvedImageUrl = imageUrl;
|
|
235
|
+
if (!resolvedImageUrl && !imagePath) {
|
|
236
|
+
if (imageUrls.length > 0) {
|
|
237
|
+
resolvedImageUrl = imageUrls[0];
|
|
238
|
+
} else if (relatedImageKeywords.length > 0) {
|
|
239
|
+
const { buildKeywordImageCandidates } = require('../tistory/imageSources');
|
|
240
|
+
for (const keyword of relatedImageKeywords) {
|
|
241
|
+
const candidates = await buildKeywordImageCandidates(keyword);
|
|
242
|
+
if (candidates.length > 0) {
|
|
243
|
+
resolvedImageUrl = candidates[0];
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
232
248
|
}
|
|
233
|
-
|
|
249
|
+
|
|
250
|
+
if (!resolvedImageUrl && !imagePath) {
|
|
251
|
+
throw new Error('No image found. Provide --image-urls, --related-image-keywords, or use imageUrl/imagePath.');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const result = await instaApi.publishPost({ imageUrl: resolvedImageUrl, imagePath, caption: finalCaption });
|
|
234
255
|
return {
|
|
235
256
|
provider: 'insta',
|
|
236
257
|
mode: 'publish',
|
|
@@ -488,7 +509,7 @@ const createInstaProvider = ({ sessionPath }) => {
|
|
|
488
509
|
},
|
|
489
510
|
|
|
490
511
|
async logout() {
|
|
491
|
-
clearProviderMeta('insta');
|
|
512
|
+
clearProviderMeta('insta', account);
|
|
492
513
|
return {
|
|
493
514
|
provider: 'insta',
|
|
494
515
|
loggedOut: true,
|
|
@@ -61,16 +61,13 @@ const validateInstaSession = (sessionPath) => {
|
|
|
61
61
|
const cookiesToHeader = (cookies) =>
|
|
62
62
|
cookies.map((c) => `${c.name}=${c.value}`).join('; ');
|
|
63
63
|
|
|
64
|
-
const createInstaWithProviderSession = (askForAuthentication) => async (fn) => {
|
|
64
|
+
const createInstaWithProviderSession = (askForAuthentication, account) => async (fn) => {
|
|
65
65
|
const credentials = readInstaCredentials();
|
|
66
66
|
const hasCredentials = Boolean(credentials.username && credentials.password);
|
|
67
67
|
|
|
68
68
|
try {
|
|
69
69
|
const result = await fn();
|
|
70
|
-
saveProviderMeta('insta', {
|
|
71
|
-
loggedIn: true,
|
|
72
|
-
lastValidatedAt: new Date().toISOString(),
|
|
73
|
-
});
|
|
70
|
+
saveProviderMeta('insta', { loggedIn: true, lastValidatedAt: new Date().toISOString() }, account);
|
|
74
71
|
return result;
|
|
75
72
|
} catch (error) {
|
|
76
73
|
if (!parseInstaSessionError(error) || !hasCredentials) {
|
|
@@ -89,7 +86,7 @@ const createInstaWithProviderSession = (askForAuthentication) => async (fn) => {
|
|
|
89
86
|
sessionPath: loginResult.sessionPath,
|
|
90
87
|
lastRefreshedAt: new Date().toISOString(),
|
|
91
88
|
lastError: null,
|
|
92
|
-
});
|
|
89
|
+
}, account);
|
|
93
90
|
|
|
94
91
|
if (!loginResult.loggedIn) {
|
|
95
92
|
throw new Error(loginResult.message || 'Login status could not be confirmed after session refresh.');
|
|
@@ -101,7 +98,7 @@ const createInstaWithProviderSession = (askForAuthentication) => async (fn) => {
|
|
|
101
98
|
loggedIn: false,
|
|
102
99
|
lastError: buildLoginErrorMessage(reloginError),
|
|
103
100
|
lastValidatedAt: new Date().toISOString(),
|
|
104
|
-
});
|
|
101
|
+
}, account);
|
|
105
102
|
throw reloginError;
|
|
106
103
|
}
|
|
107
104
|
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const { chromium } = require('playwright');
|
|
4
3
|
const { readNaverCredentials, sleep } = require('./utils');
|
|
5
4
|
const { isLoggedInByCookies, persistNaverSession } = require('./session');
|
|
6
5
|
const { NAVER_LOGIN_SELECTORS, NAVER_LOGIN_ERROR_PATTERNS } = require('./selectors');
|
|
6
|
+
const {
|
|
7
|
+
CHROME_PROFILE_DIR,
|
|
8
|
+
launchChrome,
|
|
9
|
+
connectChrome,
|
|
10
|
+
} = require('../chromeManager');
|
|
11
|
+
const NAVER_PROFILE_DIR = path.join(CHROME_PROFILE_DIR, 'naver');
|
|
7
12
|
|
|
8
13
|
const ANTI_DETECTION_SCRIPT = `
|
|
9
14
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
@@ -15,7 +20,7 @@ const ANTI_DETECTION_SCRIPT = `
|
|
|
15
20
|
const waitForNaverLoginFinish = async (page, context, timeoutMs = 45000) => {
|
|
16
21
|
const deadline = Date.now() + timeoutMs;
|
|
17
22
|
while (Date.now() < deadline) {
|
|
18
|
-
if (await isLoggedInByCookies(context)) return true;
|
|
23
|
+
if (await isLoggedInByCookies(context, page)) return true;
|
|
19
24
|
|
|
20
25
|
const url = page.url();
|
|
21
26
|
if (url.includes('naver.com') && !url.includes('nid.naver.com/nidlogin')) return true;
|
|
@@ -75,27 +80,37 @@ const createAskForAuthentication = ({ sessionPath, naverApi }) => async ({
|
|
|
75
80
|
const resolvedUsername = username || readNaverCredentials().username;
|
|
76
81
|
const resolvedPassword = password || readNaverCredentials().password;
|
|
77
82
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const browser = await chromium.launch({
|
|
83
|
-
headless: manual ? false : headless,
|
|
84
|
-
});
|
|
83
|
+
// Launch real Chrome with persistent profile + CDP
|
|
84
|
+
// TIP: openchrome (npx openchrome-mcp setup) skips 2FA by reusing existing sessions.
|
|
85
|
+
const port = await launchChrome(NAVER_PROFILE_DIR);
|
|
86
|
+
const { browser, context, page } = await connectChrome(port);
|
|
85
87
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
});
|
|
88
|
+
try {
|
|
89
|
+
// Check if already logged in (openchrome profile may have active session)
|
|
90
|
+
// Navigate to myinfo — redirects to login if not authenticated, stays if logged in
|
|
91
|
+
await page.goto('https://nid.naver.com/user2/help/myInfo', { waitUntil: 'domcontentloaded' });
|
|
92
|
+
await sleep(1000);
|
|
92
93
|
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
const afterUrl = page.url();
|
|
95
|
+
if (!afterUrl.includes('nidlogin') && !afterUrl.includes('nid.naver.com/nidlogin')) {
|
|
96
|
+
await persistNaverSession(context, page, sessionPath);
|
|
97
|
+
naverApi.resetState();
|
|
98
|
+
const blogId = await naverApi.initBlog();
|
|
99
|
+
return {
|
|
100
|
+
provider: 'naver',
|
|
101
|
+
loggedIn: true,
|
|
102
|
+
blogId,
|
|
103
|
+
blogUrl: `https://blog.naver.com/${blogId}`,
|
|
104
|
+
sessionPath,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
95
107
|
|
|
96
|
-
try {
|
|
97
108
|
await page.goto('https://nid.naver.com/nidlogin.login', { waitUntil: 'domcontentloaded' });
|
|
98
|
-
await sleep(
|
|
109
|
+
await sleep(1000);
|
|
110
|
+
|
|
111
|
+
if (!manual && (!resolvedUsername || !resolvedPassword)) {
|
|
112
|
+
throw new Error('Naver login requires id/pw. Set the NAVER_USERNAME/NAVER_PASSWORD environment variables or use --manual mode.');
|
|
113
|
+
}
|
|
99
114
|
|
|
100
115
|
let loginSuccess = false;
|
|
101
116
|
|
|
@@ -157,7 +172,7 @@ const createAskForAuthentication = ({ sessionPath, naverApi }) => async ({
|
|
|
157
172
|
throw new Error('Naver login failed. Please verify your id/password or use --manual mode.');
|
|
158
173
|
}
|
|
159
174
|
|
|
160
|
-
await persistNaverSession(context, sessionPath);
|
|
175
|
+
await persistNaverSession(context, page, sessionPath);
|
|
161
176
|
|
|
162
177
|
naverApi.resetState();
|
|
163
178
|
const blogId = await naverApi.initBlog();
|
|
@@ -169,9 +184,8 @@ const createAskForAuthentication = ({ sessionPath, naverApi }) => async ({
|
|
|
169
184
|
sessionPath,
|
|
170
185
|
};
|
|
171
186
|
} finally {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
187
|
+
// Don't close browser — keep Chrome running with persistent profile
|
|
188
|
+
await browser.close().catch(() => {});
|
|
175
189
|
}
|
|
176
190
|
};
|
|
177
191
|
|
|
@@ -10,7 +10,7 @@ const { collectAndUploadImages } = require('./imageUpload');
|
|
|
10
10
|
const { createNaverWithProviderSession } = require('./session');
|
|
11
11
|
const { createAskForAuthentication } = require('./auth');
|
|
12
12
|
|
|
13
|
-
const createNaverProvider = ({ sessionPath }) => {
|
|
13
|
+
const createNaverProvider = ({ sessionPath, account }) => {
|
|
14
14
|
const naverApi = createNaverApiClient({ sessionPath });
|
|
15
15
|
|
|
16
16
|
const askForAuthentication = createAskForAuthentication({
|
|
@@ -18,7 +18,7 @@ const createNaverProvider = ({ sessionPath }) => {
|
|
|
18
18
|
naverApi,
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
const withProviderSession = createNaverWithProviderSession(askForAuthentication);
|
|
21
|
+
const withProviderSession = createNaverWithProviderSession(askForAuthentication, account);
|
|
22
22
|
|
|
23
23
|
return {
|
|
24
24
|
id: 'naver',
|
|
@@ -34,7 +34,7 @@ const createNaverProvider = ({ sessionPath }) => {
|
|
|
34
34
|
blogId,
|
|
35
35
|
blogUrl: `https://blog.naver.com/${blogId}`,
|
|
36
36
|
sessionPath,
|
|
37
|
-
metadata: getProviderMeta('naver') || {},
|
|
37
|
+
metadata: getProviderMeta('naver', account) || {},
|
|
38
38
|
};
|
|
39
39
|
} catch (error) {
|
|
40
40
|
return {
|
|
@@ -42,7 +42,7 @@ const createNaverProvider = ({ sessionPath }) => {
|
|
|
42
42
|
loggedIn: false,
|
|
43
43
|
sessionPath,
|
|
44
44
|
error: error.message,
|
|
45
|
-
metadata: getProviderMeta('naver') || {},
|
|
45
|
+
metadata: getProviderMeta('naver', account) || {},
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
});
|
|
@@ -62,17 +62,13 @@ const createNaverProvider = ({ sessionPath }) => {
|
|
|
62
62
|
password: password || creds.password,
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
-
if (!resolved.manual && (!resolved.username || !resolved.password)) {
|
|
66
|
-
throw new Error('Naver auto-login requires username/password. Set the NAVER_USERNAME / NAVER_PASSWORD environment variables or use --manual mode.');
|
|
67
|
-
}
|
|
68
|
-
|
|
69
65
|
const result = await askForAuthentication(resolved);
|
|
70
66
|
saveProviderMeta('naver', {
|
|
71
67
|
loggedIn: result.loggedIn,
|
|
72
68
|
blogId: result.blogId,
|
|
73
69
|
blogUrl: result.blogUrl,
|
|
74
70
|
sessionPath: result.sessionPath,
|
|
75
|
-
});
|
|
71
|
+
}, account);
|
|
76
72
|
return result;
|
|
77
73
|
},
|
|
78
74
|
|
|
@@ -221,7 +217,7 @@ const createNaverProvider = ({ sessionPath }) => {
|
|
|
221
217
|
},
|
|
222
218
|
|
|
223
219
|
async logout() {
|
|
224
|
-
clearProviderMeta('naver');
|
|
220
|
+
clearProviderMeta('naver', account);
|
|
225
221
|
return {
|
|
226
222
|
provider: 'naver',
|
|
227
223
|
loggedOut: true,
|
|
@@ -2,42 +2,39 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { saveProviderMeta } = require('../../storage/sessionStore');
|
|
4
4
|
const { readNaverCredentials, parseNaverSessionError, buildLoginErrorMessage } = require('./utils');
|
|
5
|
+
const { extractAllCookies, filterCookies, cookiesToSessionFormat } = require('../chromeManager');
|
|
5
6
|
|
|
6
7
|
const NAVER_COOKIE_DOMAINS = ['https://www.naver.com', 'https://nid.naver.com', 'https://blog.naver.com'];
|
|
7
8
|
|
|
8
|
-
const isLoggedInByCookies = async (context) => {
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
const isLoggedInByCookies = async (context, page) => {
|
|
10
|
+
try {
|
|
11
|
+
const all = await extractAllCookies(context, page);
|
|
12
|
+
return all.some((c) => c.domain.includes('naver.com') && (c.name === 'NID_AUT' || c.name === 'NID_SES'));
|
|
13
|
+
} catch {
|
|
14
|
+
for (const domain of NAVER_COOKIE_DOMAINS) {
|
|
15
|
+
const cookies = await context.cookies(domain);
|
|
16
|
+
const hasAuth = cookies.some((c) => c.name === 'NID_AUT' || c.name === 'NID_SES');
|
|
17
|
+
if (hasAuth) return true;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
13
20
|
}
|
|
14
|
-
return false;
|
|
15
21
|
};
|
|
16
22
|
|
|
17
|
-
const persistNaverSession = async (context, targetSessionPath) => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
allCookies.push(...cookies);
|
|
22
|
-
}
|
|
23
|
+
const persistNaverSession = async (context, page, targetSessionPath) => {
|
|
24
|
+
// Use CDP Network.getAllCookies to capture httpOnly cookies (e.g. NID_AUT)
|
|
25
|
+
const all = await extractAllCookies(context, page);
|
|
26
|
+
const naverCookies = filterCookies(all, ['naver.com']);
|
|
23
27
|
|
|
24
28
|
// Deduplicate by name+domain
|
|
25
29
|
const seen = new Set();
|
|
26
|
-
const unique =
|
|
30
|
+
const unique = naverCookies.filter((c) => {
|
|
27
31
|
const key = `${c.name}@${c.domain}`;
|
|
28
32
|
if (seen.has(key)) return false;
|
|
29
33
|
seen.add(key);
|
|
30
34
|
return true;
|
|
31
35
|
});
|
|
32
36
|
|
|
33
|
-
const sanitized = unique
|
|
34
|
-
...cookie,
|
|
35
|
-
expires: Number(cookie.expires || -1),
|
|
36
|
-
size: undefined,
|
|
37
|
-
partitionKey: undefined,
|
|
38
|
-
sourcePort: undefined,
|
|
39
|
-
sourceScheme: undefined,
|
|
40
|
-
}));
|
|
37
|
+
const sanitized = cookiesToSessionFormat(unique);
|
|
41
38
|
|
|
42
39
|
const payload = {
|
|
43
40
|
cookies: sanitized,
|
|
@@ -62,16 +59,13 @@ const validateNaverSession = async (sessionPath) => {
|
|
|
62
59
|
cookies.some((c) => c.name === 'NID_SES' && c.value);
|
|
63
60
|
};
|
|
64
61
|
|
|
65
|
-
const createNaverWithProviderSession = (askForAuthentication) => async (fn) => {
|
|
62
|
+
const createNaverWithProviderSession = (askForAuthentication, account) => async (fn) => {
|
|
66
63
|
const credentials = readNaverCredentials();
|
|
67
64
|
const hasCredentials = Boolean(credentials.username && credentials.password);
|
|
68
65
|
|
|
69
66
|
try {
|
|
70
67
|
const result = await fn();
|
|
71
|
-
saveProviderMeta('naver', {
|
|
72
|
-
loggedIn: true,
|
|
73
|
-
lastValidatedAt: new Date().toISOString(),
|
|
74
|
-
});
|
|
68
|
+
saveProviderMeta('naver', { loggedIn: true, lastValidatedAt: new Date().toISOString() }, account);
|
|
75
69
|
return result;
|
|
76
70
|
} catch (error) {
|
|
77
71
|
if (!parseNaverSessionError(error) || !hasCredentials) {
|
|
@@ -93,7 +87,7 @@ const createNaverWithProviderSession = (askForAuthentication) => async (fn) => {
|
|
|
93
87
|
sessionPath: loginResult.sessionPath,
|
|
94
88
|
lastRefreshedAt: new Date().toISOString(),
|
|
95
89
|
lastError: null,
|
|
96
|
-
});
|
|
90
|
+
}, account);
|
|
97
91
|
|
|
98
92
|
if (!loginResult.loggedIn) {
|
|
99
93
|
throw new Error(loginResult.message || 'Login status could not be verified after session refresh.');
|
|
@@ -105,7 +99,7 @@ const createNaverWithProviderSession = (askForAuthentication) => async (fn) => {
|
|
|
105
99
|
loggedIn: false,
|
|
106
100
|
lastError: buildLoginErrorMessage(reloginError),
|
|
107
101
|
lastValidatedAt: new Date().toISOString(),
|
|
108
|
-
});
|
|
102
|
+
}, account);
|
|
109
103
|
throw reloginError;
|
|
110
104
|
}
|
|
111
105
|
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const { chromium } = require('playwright');
|
|
4
3
|
const { readCredentialsFromEnv } = require('./utils');
|
|
5
4
|
const {
|
|
6
|
-
pickValue,
|
|
7
5
|
fillBySelector,
|
|
8
6
|
clickSubmit,
|
|
9
7
|
checkBySelector,
|
|
@@ -16,6 +14,13 @@ const {
|
|
|
16
14
|
KAKAO_LOGIN_SELECTORS,
|
|
17
15
|
KAKAO_2FA_SELECTORS,
|
|
18
16
|
} = require('./selectors');
|
|
17
|
+
const {
|
|
18
|
+
CHROME_PROFILE_DIR,
|
|
19
|
+
launchChrome,
|
|
20
|
+
connectChrome,
|
|
21
|
+
} = require('../chromeManager');
|
|
22
|
+
|
|
23
|
+
const TISTORY_PROFILE_DIR = path.join(CHROME_PROFILE_DIR, 'tistory');
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* askForAuthentication factory.
|
|
@@ -38,128 +43,147 @@ const createAskForAuthentication = ({ sessionPath, tistoryApi, pending2faResult
|
|
|
38
43
|
throw new Error('Tistory login requires id/pw. Please provide id/pw or set TISTORY_USERNAME/TISTORY_PASSWORD environment variables.');
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const context = await
|
|
46
|
+
// Launch real Chrome with persistent profile + CDP
|
|
47
|
+
// TIP: openchrome (npx openchrome-mcp setup) skips 2FA by reusing existing sessions.
|
|
48
|
+
const port = await launchChrome(TISTORY_PROFILE_DIR);
|
|
49
|
+
const { browser, context, page } = await connectChrome(port);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await page.goto('https://www.tistory.com/auth/login', {
|
|
53
|
+
waitUntil: 'domcontentloaded',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Already logged in check
|
|
57
|
+
if (!page.url().includes('/auth/login')) {
|
|
58
|
+
await persistTistorySession(context, page, sessionPath);
|
|
59
|
+
tistoryApi.resetState();
|
|
60
|
+
const blogName = await tistoryApi.initBlog();
|
|
61
|
+
return {
|
|
62
|
+
provider: 'tistory',
|
|
63
|
+
loggedIn: true,
|
|
64
|
+
blogName,
|
|
65
|
+
blogUrl: `https://${blogName}.tistory.com`,
|
|
66
|
+
sessionPath,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
45
69
|
|
|
46
|
-
|
|
70
|
+
const loginId = resolvedUsername;
|
|
71
|
+
const loginPw = resolvedPassword;
|
|
47
72
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
73
|
+
const kakaoLoginSelector = await pickValue(page, KAKAO_TRIGGER_SELECTORS);
|
|
74
|
+
if (!kakaoLoginSelector) {
|
|
75
|
+
throw new Error('Could not find the Kakao login button. Please check if the login page UI has changed.');
|
|
76
|
+
}
|
|
52
77
|
|
|
53
|
-
|
|
54
|
-
|
|
78
|
+
await page.locator(kakaoLoginSelector).click({ timeout: 5000 }).catch(() => {});
|
|
79
|
+
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
80
|
+
await page.waitForTimeout(800);
|
|
81
|
+
|
|
82
|
+
let finalLoginStatus = false;
|
|
83
|
+
let pendingTwoFactorAction = false;
|
|
84
|
+
|
|
85
|
+
if (manual) {
|
|
86
|
+
console.log('');
|
|
87
|
+
console.log('==============================');
|
|
88
|
+
console.log('Switching to manual login mode.');
|
|
89
|
+
console.log('Please complete ID/PW/2FA verification in the browser.');
|
|
90
|
+
console.log('Login or 2FA must be completed within 5 minutes.');
|
|
91
|
+
console.log('==============================');
|
|
92
|
+
finalLoginStatus = await waitForLoginFinish(page, context, 300000);
|
|
93
|
+
} else if (shouldAutoFill) {
|
|
94
|
+
const usernameFilled = await fillBySelector(page, KAKAO_LOGIN_SELECTORS.username, loginId);
|
|
95
|
+
const passwordFilled = await fillBySelector(page, KAKAO_LOGIN_SELECTORS.password, loginPw);
|
|
96
|
+
if (!usernameFilled || !passwordFilled) {
|
|
97
|
+
throw new Error('Could not find Kakao login form input fields. Please check if the Tistory login page has changed.');
|
|
98
|
+
}
|
|
55
99
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
100
|
+
await checkBySelector(page, KAKAO_LOGIN_SELECTORS.rememberLogin);
|
|
101
|
+
const kakaoSubmitted = await clickSubmit(page, KAKAO_LOGIN_SELECTORS.submit);
|
|
102
|
+
if (!kakaoSubmitted) {
|
|
103
|
+
await page.keyboard.press('Enter');
|
|
59
104
|
}
|
|
60
105
|
|
|
61
|
-
|
|
62
|
-
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
63
|
-
await page.waitForTimeout(800);
|
|
64
|
-
|
|
65
|
-
let finalLoginStatus = false;
|
|
66
|
-
let pendingTwoFactorAction = false;
|
|
67
|
-
|
|
68
|
-
if (manual) {
|
|
69
|
-
console.log('');
|
|
70
|
-
console.log('==============================');
|
|
71
|
-
console.log('Switching to manual login mode.');
|
|
72
|
-
console.log('Please complete ID/PW/2FA verification in the browser.');
|
|
73
|
-
console.log('Login or 2FA must be completed within 5 minutes.');
|
|
74
|
-
console.log('==============================');
|
|
75
|
-
finalLoginStatus = await waitForLoginFinish(page, context, 300000);
|
|
76
|
-
} else if (shouldAutoFill) {
|
|
77
|
-
const usernameFilled = await fillBySelector(page, KAKAO_LOGIN_SELECTORS.username, loginId);
|
|
78
|
-
const passwordFilled = await fillBySelector(page, KAKAO_LOGIN_SELECTORS.password, loginPw);
|
|
79
|
-
if (!usernameFilled || !passwordFilled) {
|
|
80
|
-
throw new Error('Could not find Kakao login form input fields. Please check if the Tistory login page has changed.');
|
|
81
|
-
}
|
|
106
|
+
finalLoginStatus = await waitForLoginFinish(page, context);
|
|
82
107
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
await page.keyboard.press('Enter');
|
|
108
|
+
if (!finalLoginStatus && await hasElement(page, LOGIN_OTP_SELECTORS)) {
|
|
109
|
+
if (!twoFactorCode) {
|
|
110
|
+
return pending2faResult('otp');
|
|
87
111
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
112
|
+
const otpFilled = await fillBySelector(page, LOGIN_OTP_SELECTORS, twoFactorCode);
|
|
113
|
+
if (!otpFilled) {
|
|
114
|
+
throw new Error('Could not find the OTP input field. Please check the login page.');
|
|
115
|
+
}
|
|
116
|
+
await page.keyboard.press('Enter');
|
|
117
|
+
finalLoginStatus = await waitForLoginFinish(page, context, 45000);
|
|
118
|
+
} else if (!finalLoginStatus && (await hasElement(page, KAKAO_2FA_SELECTORS.start) || page.url().includes('tmsTwoStepVerification') || page.url().includes('emailTwoStepVerification'))) {
|
|
119
|
+
await checkBySelector(page, KAKAO_2FA_SELECTORS.rememberDevice);
|
|
120
|
+
const isEmailModeAvailable = await hasElement(page, KAKAO_2FA_SELECTORS.emailModeButton);
|
|
121
|
+
const hasEmailCodeInput = await hasElement(page, KAKAO_2FA_SELECTORS.codeInput);
|
|
122
|
+
|
|
123
|
+
if (hasEmailCodeInput && twoFactorCode) {
|
|
124
|
+
const codeFilled = await fillBySelector(page, KAKAO_2FA_SELECTORS.codeInput, twoFactorCode);
|
|
125
|
+
if (!codeFilled) {
|
|
126
|
+
throw new Error('Could not find the 2FA input field. Please check the login page.');
|
|
94
127
|
}
|
|
95
|
-
const
|
|
96
|
-
if (!
|
|
97
|
-
|
|
128
|
+
const confirmed = await clickSubmit(page, KAKAO_2FA_SELECTORS.confirm);
|
|
129
|
+
if (!confirmed) {
|
|
130
|
+
await page.keyboard.press('Enter');
|
|
98
131
|
}
|
|
99
|
-
await page.keyboard.press('Enter');
|
|
100
132
|
finalLoginStatus = await waitForLoginFinish(page, context, 45000);
|
|
101
|
-
} else if (!
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const confirmed = await clickSubmit(page, KAKAO_2FA_SELECTORS.confirm);
|
|
112
|
-
if (!confirmed) {
|
|
113
|
-
await page.keyboard.press('Enter');
|
|
114
|
-
}
|
|
115
|
-
finalLoginStatus = await waitForLoginFinish(page, context, 45000);
|
|
116
|
-
} else if (!twoFactorCode) {
|
|
117
|
-
pendingTwoFactorAction = true;
|
|
118
|
-
} else if (isEmailModeAvailable) {
|
|
119
|
-
await clickSubmit(page, KAKAO_2FA_SELECTORS.emailModeButton).catch(() => {});
|
|
120
|
-
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
121
|
-
await page.waitForTimeout(800);
|
|
122
|
-
|
|
123
|
-
const codeFilled = await fillBySelector(page, KAKAO_2FA_SELECTORS.codeInput, twoFactorCode);
|
|
124
|
-
if (!codeFilled) {
|
|
125
|
-
throw new Error('Could not find the Kakao email verification input field. Please check the login page.');
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const confirmed = await clickSubmit(page, KAKAO_2FA_SELECTORS.confirm);
|
|
129
|
-
if (!confirmed) {
|
|
130
|
-
await page.keyboard.press('Enter');
|
|
131
|
-
}
|
|
132
|
-
finalLoginStatus = await waitForLoginFinish(page, context, 45000);
|
|
133
|
-
} else {
|
|
134
|
-
return pending2faResult('kakao');
|
|
133
|
+
} else if (!twoFactorCode) {
|
|
134
|
+
pendingTwoFactorAction = true;
|
|
135
|
+
} else if (isEmailModeAvailable) {
|
|
136
|
+
await clickSubmit(page, KAKAO_2FA_SELECTORS.emailModeButton).catch(() => {});
|
|
137
|
+
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
138
|
+
await page.waitForTimeout(800);
|
|
139
|
+
|
|
140
|
+
const codeFilled = await fillBySelector(page, KAKAO_2FA_SELECTORS.codeInput, twoFactorCode);
|
|
141
|
+
if (!codeFilled) {
|
|
142
|
+
throw new Error('Could not find the Kakao email verification input field. Please check the login page.');
|
|
135
143
|
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
144
|
|
|
139
|
-
|
|
140
|
-
|
|
145
|
+
const confirmed = await clickSubmit(page, KAKAO_2FA_SELECTORS.confirm);
|
|
146
|
+
if (!confirmed) {
|
|
147
|
+
await page.keyboard.press('Enter');
|
|
148
|
+
}
|
|
149
|
+
finalLoginStatus = await waitForLoginFinish(page, context, 45000);
|
|
150
|
+
} else {
|
|
141
151
|
return pending2faResult('kakao');
|
|
142
152
|
}
|
|
143
|
-
throw new Error('Login failed. Please verify your credentials and ensure TISTORY_USERNAME/TISTORY_PASSWORD environment variables are set correctly.');
|
|
144
153
|
}
|
|
154
|
+
}
|
|
145
155
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
tistoryApi.resetState();
|
|
150
|
-
const blogName = await tistoryApi.initBlog();
|
|
151
|
-
return {
|
|
152
|
-
provider: 'tistory',
|
|
153
|
-
loggedIn: true,
|
|
154
|
-
blogName,
|
|
155
|
-
blogUrl: `https://${blogName}.tistory.com`,
|
|
156
|
-
sessionPath,
|
|
157
|
-
};
|
|
158
|
-
} finally {
|
|
159
|
-
if (browser) {
|
|
160
|
-
await browser.close().catch(() => {});
|
|
156
|
+
if (!finalLoginStatus) {
|
|
157
|
+
if (pendingTwoFactorAction) {
|
|
158
|
+
return pending2faResult('kakao');
|
|
161
159
|
}
|
|
160
|
+
throw new Error('Login failed. Please verify your credentials and ensure TISTORY_USERNAME/TISTORY_PASSWORD environment variables are set correctly.');
|
|
162
161
|
}
|
|
162
|
+
|
|
163
|
+
await persistTistorySession(context, page, sessionPath);
|
|
164
|
+
|
|
165
|
+
tistoryApi.resetState();
|
|
166
|
+
const blogName = await tistoryApi.initBlog();
|
|
167
|
+
return {
|
|
168
|
+
provider: 'tistory',
|
|
169
|
+
loggedIn: true,
|
|
170
|
+
blogName,
|
|
171
|
+
blogUrl: `https://${blogName}.tistory.com`,
|
|
172
|
+
sessionPath,
|
|
173
|
+
};
|
|
174
|
+
} finally {
|
|
175
|
+
// Don't close browser — keep Chrome running with persistent profile
|
|
176
|
+
await browser.close().catch(() => {});
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// Keep pickValue local since it's used above
|
|
181
|
+
const pickValue = async (page, selectors) => {
|
|
182
|
+
for (const selector of selectors) {
|
|
183
|
+
const element = await page.$(selector);
|
|
184
|
+
if (element) return selector;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
163
187
|
};
|
|
164
188
|
|
|
165
189
|
module.exports = {
|