viruagent-cli 0.3.6 → 0.3.7
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 -13
- package/README.md +1 -13
- package/bin/index.js +0 -2
- package/package.json +1 -1
- package/skills/viruagent.md +11 -9
- package/src/providers/naver/auth.js +1 -1
- package/src/providers/naver/index.js +0 -19
- package/src/providers/tistory/index.js +0 -19
- package/src/runner.js +0 -2
- package/src/providers/naver/chromeImport.js +0 -195
- package/src/providers/tistory/chromeImport.js +0 -790
package/README.ko.md
CHANGED
|
@@ -67,22 +67,13 @@ Tell the user that viruagent-cli installation is complete.
|
|
|
67
67
|
### Tistory
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
|
-
#
|
|
71
|
-
npx viruagent-cli login --from-chrome
|
|
72
|
-
|
|
73
|
-
# 특정 Chrome 프로필 사용
|
|
74
|
-
npx viruagent-cli login --from-chrome --profile "Profile 2"
|
|
75
|
-
|
|
76
|
-
# 기존 카카오 로그인 (ID/PW 필요)
|
|
70
|
+
# 카카오 로그인 (ID/PW 필요)
|
|
77
71
|
npx viruagent-cli login --username <id> --password <pw> --headless
|
|
78
72
|
```
|
|
79
73
|
|
|
80
74
|
### Naver Blog
|
|
81
75
|
|
|
82
76
|
```bash
|
|
83
|
-
# Chrome 세션 임포트 (권장)
|
|
84
|
-
npx viruagent-cli login --provider naver --from-chrome
|
|
85
|
-
|
|
86
77
|
# 수동 로그인 (브라우저에서 직접 로그인)
|
|
87
78
|
npx viruagent-cli login --provider naver --manual
|
|
88
79
|
|
|
@@ -90,9 +81,6 @@ npx viruagent-cli login --provider naver --manual
|
|
|
90
81
|
npx viruagent-cli login --provider naver --username <id> --password <pw>
|
|
91
82
|
```
|
|
92
83
|
|
|
93
|
-
> [!TIP]
|
|
94
|
-
> `--from-chrome`은 Chrome에서 직접 쿠키를 추출합니다 — macOS Keychain 또는 Windows DPAPI를 사용하며, CDP 폴백도 지원합니다. 브라우저 실행 없이, 2FA 없이, 1초 내 완료됩니다.
|
|
95
|
-
|
|
96
84
|
## 사용법
|
|
97
85
|
|
|
98
86
|
| 이렇게 말하면 | 에이전트가 알아서 |
|
package/README.md
CHANGED
|
@@ -67,22 +67,13 @@ Tell the user that viruagent-cli installation is complete.
|
|
|
67
67
|
### Tistory
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
|
-
#
|
|
71
|
-
npx viruagent-cli login --from-chrome
|
|
72
|
-
|
|
73
|
-
# Use a specific Chrome profile
|
|
74
|
-
npx viruagent-cli login --from-chrome --profile "Profile 2"
|
|
75
|
-
|
|
76
|
-
# Traditional Kakao login (ID/PW required)
|
|
70
|
+
# Kakao login (ID/PW required)
|
|
77
71
|
npx viruagent-cli login --username <id> --password <pw> --headless
|
|
78
72
|
```
|
|
79
73
|
|
|
80
74
|
### Naver Blog
|
|
81
75
|
|
|
82
76
|
```bash
|
|
83
|
-
# Import session from Chrome (recommended)
|
|
84
|
-
npx viruagent-cli login --provider naver --from-chrome
|
|
85
|
-
|
|
86
77
|
# Manual login via browser
|
|
87
78
|
npx viruagent-cli login --provider naver --manual
|
|
88
79
|
|
|
@@ -90,9 +81,6 @@ npx viruagent-cli login --provider naver --manual
|
|
|
90
81
|
npx viruagent-cli login --provider naver --username <id> --password <pw>
|
|
91
82
|
```
|
|
92
83
|
|
|
93
|
-
> [!TIP]
|
|
94
|
-
> `--from-chrome` extracts cookies from Chrome directly — via macOS Keychain or Windows DPAPI, with CDP fallback. No browser launch, no 2FA — completes in under 1 second.
|
|
95
|
-
|
|
96
84
|
## Usage
|
|
97
85
|
|
|
98
86
|
| Say this | Agent handles |
|
package/bin/index.js
CHANGED
|
@@ -49,8 +49,6 @@ loginCmd
|
|
|
49
49
|
.option('--headless', 'Run browser in headless mode', false)
|
|
50
50
|
.option('--manual', 'Use manual login mode', false)
|
|
51
51
|
.option('--two-factor-code <code>', '2FA verification code')
|
|
52
|
-
.option('--from-chrome', 'Import session from Chrome browser', false)
|
|
53
|
-
.option('--profile <name>', 'Chrome profile name (default: Default)')
|
|
54
52
|
.action((opts) => execute('login', opts));
|
|
55
53
|
|
|
56
54
|
const publishCmd = program
|
package/package.json
CHANGED
package/skills/viruagent.md
CHANGED
|
@@ -26,19 +26,15 @@ npx viruagent-cli --spec
|
|
|
26
26
|
|
|
27
27
|
All responses are JSON: `{ "ok": true, "data": {...} }` on success, `{ "ok": false, "error": "...", "message": "...", "hint": "..." }` on failure.
|
|
28
28
|
|
|
29
|
-
## Step 2:
|
|
29
|
+
## Step 2: Check authentication
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
### Tistory
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
|
|
35
|
-
npx viruagent-cli login --provider tistory --from-chrome
|
|
36
|
-
npx viruagent-cli login --provider naver --from-chrome
|
|
34
|
+
npx viruagent-cli status --provider tistory
|
|
37
35
|
```
|
|
38
36
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
### Tistory fallback
|
|
37
|
+
If not logged in, authenticate:
|
|
42
38
|
|
|
43
39
|
```bash
|
|
44
40
|
npx viruagent-cli login --provider tistory --username <user> --password <pass> --headless
|
|
@@ -46,7 +42,13 @@ npx viruagent-cli login --provider tistory --username <user> --password <pass> -
|
|
|
46
42
|
|
|
47
43
|
If 2FA is required (response contains `pending_2fa`), ask the user to approve the login on their mobile device (Kakao app notification), then retry the status check.
|
|
48
44
|
|
|
49
|
-
### Naver
|
|
45
|
+
### Naver Blog
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npx viruagent-cli status --provider naver
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
If not logged in, authenticate:
|
|
50
52
|
|
|
51
53
|
```bash
|
|
52
54
|
# Manual login via browser
|
|
@@ -48,7 +48,7 @@ const checkLoginResult = async (page) => {
|
|
|
48
48
|
// 캡차 감지
|
|
49
49
|
const hasCaptcha = patterns.captcha.some((p) => content.toLowerCase().includes(p.toLowerCase()));
|
|
50
50
|
if (hasCaptcha) {
|
|
51
|
-
return { success: false, error: 'captcha', message: '캡차가 감지되었습니다. --manual
|
|
51
|
+
return { success: false, error: 'captcha', message: '캡차가 감지되었습니다. --manual 모드를 사용해 주세요.' };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// 성공 (운영원칙 위반 포함)
|
|
@@ -8,7 +8,6 @@ const {
|
|
|
8
8
|
const { convertHtmlToEditorComponents } = require('./editorConvert');
|
|
9
9
|
const { collectAndUploadImages } = require('./imageUpload');
|
|
10
10
|
const { createNaverWithProviderSession } = require('./session');
|
|
11
|
-
const { importSessionFromChrome } = require('./chromeImport');
|
|
12
11
|
const { createAskForAuthentication } = require('./auth');
|
|
13
12
|
|
|
14
13
|
const createNaverProvider = ({ sessionPath }) => {
|
|
@@ -54,25 +53,7 @@ const createNaverProvider = ({ sessionPath }) => {
|
|
|
54
53
|
manual = false,
|
|
55
54
|
username,
|
|
56
55
|
password,
|
|
57
|
-
fromChrome,
|
|
58
|
-
profile,
|
|
59
56
|
} = {}) {
|
|
60
|
-
if (fromChrome) {
|
|
61
|
-
await importSessionFromChrome(sessionPath, profile || 'Default');
|
|
62
|
-
naverApi.resetState();
|
|
63
|
-
const blogId = await naverApi.initBlog();
|
|
64
|
-
const result = {
|
|
65
|
-
provider: 'naver',
|
|
66
|
-
loggedIn: true,
|
|
67
|
-
blogId,
|
|
68
|
-
blogUrl: `https://blog.naver.com/${blogId}`,
|
|
69
|
-
sessionPath,
|
|
70
|
-
source: 'chrome-import',
|
|
71
|
-
};
|
|
72
|
-
saveProviderMeta('naver', { loggedIn: true, blogId, blogUrl: result.blogUrl, sessionPath });
|
|
73
|
-
return result;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
57
|
const creds = readNaverCredentials();
|
|
77
58
|
const resolved = {
|
|
78
59
|
headless,
|
|
@@ -13,7 +13,6 @@ const {
|
|
|
13
13
|
const { normalizeThumbnailForPublish } = require('./imageNormalization');
|
|
14
14
|
const { enrichContentWithUploadedImages, resolveMandatoryThumbnail } = require('./imageEnrichment');
|
|
15
15
|
const { createWithProviderSession } = require('./session');
|
|
16
|
-
const { importSessionFromChrome } = require('./chromeImport');
|
|
17
16
|
const { createAskForAuthentication } = require('./auth');
|
|
18
17
|
|
|
19
18
|
const createTistoryProvider = ({ sessionPath }) => {
|
|
@@ -70,25 +69,7 @@ const createTistoryProvider = ({ sessionPath }) => {
|
|
|
70
69
|
username,
|
|
71
70
|
password,
|
|
72
71
|
twoFactorCode,
|
|
73
|
-
fromChrome,
|
|
74
|
-
profile,
|
|
75
72
|
} = {}) {
|
|
76
|
-
if (fromChrome) {
|
|
77
|
-
await importSessionFromChrome(sessionPath, profile || 'Default');
|
|
78
|
-
tistoryApi.resetState();
|
|
79
|
-
const blogName = await tistoryApi.initBlog();
|
|
80
|
-
const result = {
|
|
81
|
-
provider: 'tistory',
|
|
82
|
-
loggedIn: true,
|
|
83
|
-
blogName,
|
|
84
|
-
blogUrl: `https://${blogName}.tistory.com`,
|
|
85
|
-
sessionPath,
|
|
86
|
-
source: 'chrome-import',
|
|
87
|
-
};
|
|
88
|
-
saveProviderMeta('tistory', { loggedIn: true, blogName, blogUrl: result.blogUrl, sessionPath });
|
|
89
|
-
return result;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
73
|
const creds = readCredentialsFromEnv();
|
|
93
74
|
const resolved = {
|
|
94
75
|
headless,
|
package/src/runner.js
CHANGED
|
@@ -97,8 +97,6 @@ const runCommand = async (command, opts = {}) => {
|
|
|
97
97
|
username: opts.username || undefined,
|
|
98
98
|
password: opts.password || undefined,
|
|
99
99
|
twoFactorCode: opts.twoFactorCode || undefined,
|
|
100
|
-
fromChrome: Boolean(opts.fromChrome),
|
|
101
|
-
profile: opts.profile || undefined,
|
|
102
100
|
})
|
|
103
101
|
)();
|
|
104
102
|
|
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
|
-
const crypto = require('crypto');
|
|
5
|
-
const { execSync, execFileSync } = require('child_process');
|
|
6
|
-
|
|
7
|
-
const NAVER_SESSION_COOKIES = ['NID_AUT', 'NID_SES'];
|
|
8
|
-
|
|
9
|
-
const decryptChromeCookieMac = (encryptedValue, derivedKey) => {
|
|
10
|
-
if (!encryptedValue || encryptedValue.length < 4) return '';
|
|
11
|
-
const prefix = encryptedValue.slice(0, 3).toString('ascii');
|
|
12
|
-
if (prefix !== 'v10') return encryptedValue.toString('utf-8');
|
|
13
|
-
|
|
14
|
-
const encrypted = encryptedValue.slice(3);
|
|
15
|
-
const iv = Buffer.alloc(16, 0x20);
|
|
16
|
-
const decipher = crypto.createDecipheriv('aes-128-cbc', derivedKey, iv);
|
|
17
|
-
decipher.setAutoPadding(true);
|
|
18
|
-
try {
|
|
19
|
-
const dec = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
20
|
-
let start = dec.length;
|
|
21
|
-
for (let i = dec.length - 1; i >= 0; i--) {
|
|
22
|
-
if (dec[i] >= 0x20 && dec[i] <= 0x7e) { start = i; }
|
|
23
|
-
else { break; }
|
|
24
|
-
}
|
|
25
|
-
return start < dec.length ? dec.slice(start).toString('utf-8') : '';
|
|
26
|
-
} catch {
|
|
27
|
-
return '';
|
|
28
|
-
}
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const getWindowsChromeMasterKey = (chromeRoot) => {
|
|
32
|
-
const localStatePath = path.join(chromeRoot, 'Local State');
|
|
33
|
-
if (!fs.existsSync(localStatePath)) {
|
|
34
|
-
throw new Error('Chrome Local State 파일을 찾을 수 없습니다.');
|
|
35
|
-
}
|
|
36
|
-
const localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8'));
|
|
37
|
-
const encryptedKeyB64 = localState.os_crypt && localState.os_crypt.encrypted_key;
|
|
38
|
-
if (!encryptedKeyB64) {
|
|
39
|
-
throw new Error('Chrome Local State에서 암호화 키를 찾을 수 없습니다.');
|
|
40
|
-
}
|
|
41
|
-
const encryptedKeyWithPrefix = Buffer.from(encryptedKeyB64, 'base64');
|
|
42
|
-
const encryptedKey = encryptedKeyWithPrefix.slice(5);
|
|
43
|
-
const encHex = encryptedKey.toString('hex');
|
|
44
|
-
|
|
45
|
-
const psScript = `
|
|
46
|
-
Add-Type -AssemblyName System.Security
|
|
47
|
-
$encBytes = [byte[]]::new(${encryptedKey.length})
|
|
48
|
-
$hex = '${encHex}'
|
|
49
|
-
for ($i = 0; $i -lt $encBytes.Length; $i++) {
|
|
50
|
-
$encBytes[$i] = [Convert]::ToByte($hex.Substring($i * 2, 2), 16)
|
|
51
|
-
}
|
|
52
|
-
$decBytes = [System.Security.Cryptography.ProtectedData]::Unprotect($encBytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
53
|
-
$decHex = -join ($decBytes | ForEach-Object { $_.ToString('x2') })
|
|
54
|
-
Write-Output $decHex
|
|
55
|
-
`.trim().replace(/\n/g, '; ');
|
|
56
|
-
|
|
57
|
-
try {
|
|
58
|
-
const decHex = execSync(
|
|
59
|
-
`powershell -NoProfile -Command "${psScript}"`,
|
|
60
|
-
{ encoding: 'utf-8', timeout: 10000 }
|
|
61
|
-
).trim();
|
|
62
|
-
return Buffer.from(decHex, 'hex');
|
|
63
|
-
} catch {
|
|
64
|
-
throw new Error('Chrome 암호화 키를 DPAPI로 복호화할 수 없습니다.');
|
|
65
|
-
}
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const decryptChromeCookieWindows = (encryptedValue, masterKey) => {
|
|
69
|
-
if (!encryptedValue || encryptedValue.length < 4) return '';
|
|
70
|
-
const prefix = encryptedValue.slice(0, 3).toString('ascii');
|
|
71
|
-
if (prefix !== 'v10' && prefix !== 'v20') return encryptedValue.toString('utf-8');
|
|
72
|
-
|
|
73
|
-
const nonce = encryptedValue.slice(3, 3 + 12);
|
|
74
|
-
const authTag = encryptedValue.slice(encryptedValue.length - 16);
|
|
75
|
-
const ciphertext = encryptedValue.slice(3 + 12, encryptedValue.length - 16);
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
const decipher = crypto.createDecipheriv('aes-256-gcm', masterKey, nonce);
|
|
79
|
-
decipher.setAuthTag(authTag);
|
|
80
|
-
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
81
|
-
return dec.toString('utf-8');
|
|
82
|
-
} catch {
|
|
83
|
-
return '';
|
|
84
|
-
}
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const decryptChromeCookie = (encryptedValue, key) => {
|
|
88
|
-
if (process.platform === 'win32') {
|
|
89
|
-
return decryptChromeCookieWindows(encryptedValue, key);
|
|
90
|
-
}
|
|
91
|
-
return decryptChromeCookieMac(encryptedValue, key);
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const extractChromeCookies = (cookiesDb, derivedKey, domainPattern) => {
|
|
95
|
-
const tempDb = path.join(os.tmpdir(), `viruagent-naver-cookies-${Date.now()}.db`);
|
|
96
|
-
|
|
97
|
-
const backupCmd = process.platform === 'win32'
|
|
98
|
-
? `.backup "${tempDb}"`
|
|
99
|
-
: `.backup '${tempDb.replace(/'/g, "''")}'`;
|
|
100
|
-
try {
|
|
101
|
-
execFileSync('sqlite3', [cookiesDb, backupCmd], { stdio: 'ignore', timeout: 10000 });
|
|
102
|
-
} catch {
|
|
103
|
-
let copied = false;
|
|
104
|
-
try {
|
|
105
|
-
fs.copyFileSync(cookiesDb, tempDb);
|
|
106
|
-
copied = true;
|
|
107
|
-
} catch {}
|
|
108
|
-
if (!copied) {
|
|
109
|
-
throw new Error('Chrome 쿠키 DB 복사에 실패했습니다. Chrome이 실행 중이면 종료 후 다시 시도해 주세요.');
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
for (const suffix of ['-wal', '-shm', '-journal']) {
|
|
114
|
-
try { fs.unlinkSync(tempDb + suffix); } catch {}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
try {
|
|
118
|
-
const query = `SELECT host_key, name, value, hex(encrypted_value), path, expires_utc, is_secure, is_httponly, samesite FROM cookies WHERE host_key LIKE '${domainPattern}'`;
|
|
119
|
-
const rows = execFileSync('sqlite3', ['-separator', '||', tempDb, query], {
|
|
120
|
-
encoding: 'utf-8',
|
|
121
|
-
timeout: 5000,
|
|
122
|
-
}).trim();
|
|
123
|
-
if (!rows) return [];
|
|
124
|
-
|
|
125
|
-
const chromeEpochOffset = 11644473600;
|
|
126
|
-
const sameSiteMap = { '-1': 'None', '0': 'None', '1': 'Lax', '2': 'Strict' };
|
|
127
|
-
return rows.split('\n').map((row) => {
|
|
128
|
-
const [domain, name, plainValue, encHex, cookiePath, expiresUtc, isSecure, isHttpOnly, sameSite] = row.split('||');
|
|
129
|
-
let value = plainValue || '';
|
|
130
|
-
if (!value && encHex) {
|
|
131
|
-
value = decryptChromeCookie(Buffer.from(encHex, 'hex'), derivedKey);
|
|
132
|
-
}
|
|
133
|
-
if (value && !/^[\x20-\x7E]*$/.test(value)) value = '';
|
|
134
|
-
const expires = expiresUtc === '0' ? -1 : Math.floor(Number(expiresUtc) / 1000000) - chromeEpochOffset;
|
|
135
|
-
return { name, value, domain, path: cookiePath || '/', expires, httpOnly: isHttpOnly === '1', secure: isSecure === '1', sameSite: sameSiteMap[sameSite] || 'None' };
|
|
136
|
-
}).filter((c) => c.value);
|
|
137
|
-
} finally {
|
|
138
|
-
try { fs.unlinkSync(tempDb); } catch {}
|
|
139
|
-
}
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
const importSessionFromChrome = async (targetSessionPath, profileName = 'Default') => {
|
|
143
|
-
let chromeRoot;
|
|
144
|
-
if (process.platform === 'win32') {
|
|
145
|
-
chromeRoot = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'Google', 'Chrome', 'User Data');
|
|
146
|
-
} else {
|
|
147
|
-
chromeRoot = path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome');
|
|
148
|
-
}
|
|
149
|
-
if (!fs.existsSync(chromeRoot)) {
|
|
150
|
-
throw new Error('Chrome이 설치되어 있지 않습니다.');
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const profileDir = path.join(chromeRoot, profileName);
|
|
154
|
-
let cookiesDb = path.join(profileDir, 'Network', 'Cookies');
|
|
155
|
-
if (!fs.existsSync(cookiesDb)) {
|
|
156
|
-
cookiesDb = path.join(profileDir, 'Cookies');
|
|
157
|
-
}
|
|
158
|
-
if (!fs.existsSync(cookiesDb)) {
|
|
159
|
-
throw new Error(`Chrome 프로필 "${profileName}"에 쿠키 DB가 없습니다.`);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
let derivedKey;
|
|
163
|
-
if (process.platform === 'win32') {
|
|
164
|
-
derivedKey = getWindowsChromeMasterKey(chromeRoot);
|
|
165
|
-
} else {
|
|
166
|
-
let keychainPassword;
|
|
167
|
-
try {
|
|
168
|
-
keychainPassword = execSync(
|
|
169
|
-
'security find-generic-password -s "Chrome Safe Storage" -w',
|
|
170
|
-
{ encoding: 'utf-8', timeout: 5000 }
|
|
171
|
-
).trim();
|
|
172
|
-
} catch {
|
|
173
|
-
throw new Error('Chrome Safe Storage 키를 Keychain에서 읽을 수 없습니다. macOS 권한을 확인해 주세요.');
|
|
174
|
-
}
|
|
175
|
-
derivedKey = crypto.pbkdf2Sync(keychainPassword, 'saltysalt', 1003, 16, 'sha1');
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const naverCookies = extractChromeCookies(cookiesDb, derivedKey, '%naver.com');
|
|
179
|
-
|
|
180
|
-
const hasAuth = naverCookies.some((c) => c.name === 'NID_AUT' && c.value) &&
|
|
181
|
-
naverCookies.some((c) => c.name === 'NID_SES' && c.value);
|
|
182
|
-
|
|
183
|
-
if (!hasAuth) {
|
|
184
|
-
throw new Error('Chrome에 네이버 로그인 세션이 없습니다. Chrome에서 먼저 네이버에 로그인해 주세요.');
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const payload = { cookies: naverCookies, updatedAt: new Date().toISOString() };
|
|
188
|
-
await fs.promises.mkdir(path.dirname(targetSessionPath), { recursive: true });
|
|
189
|
-
await fs.promises.writeFile(targetSessionPath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
190
|
-
return { cookieCount: naverCookies.length };
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
module.exports = {
|
|
194
|
-
importSessionFromChrome,
|
|
195
|
-
};
|
|
@@ -1,790 +0,0 @@
|
|
|
1
|
-
const { chromium } = require('playwright');
|
|
2
|
-
const { execSync, execFileSync } = require('child_process');
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const os = require('os');
|
|
5
|
-
const crypto = require('crypto');
|
|
6
|
-
const path = require('path');
|
|
7
|
-
const { pickValue } = require('./browserHelpers');
|
|
8
|
-
const { persistTistorySession } = require('./session');
|
|
9
|
-
const {
|
|
10
|
-
KAKAO_TRIGGER_SELECTORS,
|
|
11
|
-
KAKAO_ACCOUNT_CONFIRM_SELECTORS,
|
|
12
|
-
} = require('./selectors');
|
|
13
|
-
|
|
14
|
-
const decryptChromeCookieMac = (encryptedValue, derivedKey) => {
|
|
15
|
-
if (!encryptedValue || encryptedValue.length < 4) return '';
|
|
16
|
-
const prefix = encryptedValue.slice(0, 3).toString('ascii');
|
|
17
|
-
if (prefix !== 'v10') return encryptedValue.toString('utf-8');
|
|
18
|
-
|
|
19
|
-
const encrypted = encryptedValue.slice(3);
|
|
20
|
-
const iv = Buffer.alloc(16, 0x20);
|
|
21
|
-
const decipher = crypto.createDecipheriv('aes-128-cbc', derivedKey, iv);
|
|
22
|
-
decipher.setAutoPadding(true);
|
|
23
|
-
try {
|
|
24
|
-
const dec = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
25
|
-
// CBC 첫 블록은 IV 불일치로 깨짐 → 끝에서부터 printable ASCII 범위 추출
|
|
26
|
-
let start = dec.length;
|
|
27
|
-
for (let i = dec.length - 1; i >= 0; i--) {
|
|
28
|
-
if (dec[i] >= 0x20 && dec[i] <= 0x7e) { start = i; }
|
|
29
|
-
else { break; }
|
|
30
|
-
}
|
|
31
|
-
return start < dec.length ? dec.slice(start).toString('utf-8') : '';
|
|
32
|
-
} catch {
|
|
33
|
-
return '';
|
|
34
|
-
}
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const getWindowsChromeMasterKey = (chromeRoot) => {
|
|
38
|
-
const localStatePath = path.join(chromeRoot, 'Local State');
|
|
39
|
-
if (!fs.existsSync(localStatePath)) {
|
|
40
|
-
throw new Error('Chrome Local State 파일을 찾을 수 없습니다.');
|
|
41
|
-
}
|
|
42
|
-
const localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8'));
|
|
43
|
-
const encryptedKeyB64 = localState.os_crypt && localState.os_crypt.encrypted_key;
|
|
44
|
-
if (!encryptedKeyB64) {
|
|
45
|
-
throw new Error('Chrome Local State에서 암호화 키를 찾을 수 없습니다.');
|
|
46
|
-
}
|
|
47
|
-
const encryptedKeyWithPrefix = Buffer.from(encryptedKeyB64, 'base64');
|
|
48
|
-
// 앞 5바이트 "DPAPI" 접두사 제거
|
|
49
|
-
const encryptedKey = encryptedKeyWithPrefix.slice(5);
|
|
50
|
-
const encHex = encryptedKey.toString('hex');
|
|
51
|
-
|
|
52
|
-
// PowerShell DPAPI로 복호화
|
|
53
|
-
const psScript = `
|
|
54
|
-
Add-Type -AssemblyName System.Security
|
|
55
|
-
$encBytes = [byte[]]::new(${encryptedKey.length})
|
|
56
|
-
$hex = '${encHex}'
|
|
57
|
-
for ($i = 0; $i -lt $encBytes.Length; $i++) {
|
|
58
|
-
$encBytes[$i] = [Convert]::ToByte($hex.Substring($i * 2, 2), 16)
|
|
59
|
-
}
|
|
60
|
-
$decBytes = [System.Security.Cryptography.ProtectedData]::Unprotect($encBytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
61
|
-
$decHex = -join ($decBytes | ForEach-Object { $_.ToString('x2') })
|
|
62
|
-
Write-Output $decHex
|
|
63
|
-
`.trim().replace(/\n/g, '; ');
|
|
64
|
-
|
|
65
|
-
try {
|
|
66
|
-
const decHex = execSync(
|
|
67
|
-
`powershell -NoProfile -Command "${psScript}"`,
|
|
68
|
-
{ encoding: 'utf-8', timeout: 10000 }
|
|
69
|
-
).trim();
|
|
70
|
-
return Buffer.from(decHex, 'hex');
|
|
71
|
-
} catch {
|
|
72
|
-
throw new Error('Chrome 암호화 키를 DPAPI로 복호화할 수 없습니다.');
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const decryptChromeCookieWindows = (encryptedValue, masterKey) => {
|
|
77
|
-
if (!encryptedValue || encryptedValue.length < 4) return '';
|
|
78
|
-
const prefix = encryptedValue.slice(0, 3).toString('ascii');
|
|
79
|
-
if (prefix !== 'v10' && prefix !== 'v20') return encryptedValue.toString('utf-8');
|
|
80
|
-
|
|
81
|
-
// AES-256-GCM: nonce(12바이트) + ciphertext + authTag(16바이트)
|
|
82
|
-
const nonce = encryptedValue.slice(3, 3 + 12);
|
|
83
|
-
const authTag = encryptedValue.slice(encryptedValue.length - 16);
|
|
84
|
-
const ciphertext = encryptedValue.slice(3 + 12, encryptedValue.length - 16);
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
const decipher = crypto.createDecipheriv('aes-256-gcm', masterKey, nonce);
|
|
88
|
-
decipher.setAuthTag(authTag);
|
|
89
|
-
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
90
|
-
return dec.toString('utf-8');
|
|
91
|
-
} catch {
|
|
92
|
-
return '';
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const decryptChromeCookie = (encryptedValue, key) => {
|
|
97
|
-
if (process.platform === 'win32') {
|
|
98
|
-
return decryptChromeCookieWindows(encryptedValue, key);
|
|
99
|
-
}
|
|
100
|
-
return decryptChromeCookieMac(encryptedValue, key);
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
const copyFileViaVSS = (srcPath, destPath) => {
|
|
104
|
-
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'vss-copy.ps1');
|
|
105
|
-
if (!fs.existsSync(scriptPath)) return false;
|
|
106
|
-
try {
|
|
107
|
-
const result = execSync(
|
|
108
|
-
'powershell -NoProfile -ExecutionPolicy Bypass -File "' + scriptPath + '" -SourcePath "' + srcPath + '" -DestPath "' + destPath + '"',
|
|
109
|
-
{ encoding: 'utf-8', timeout: 30000 }
|
|
110
|
-
).trim();
|
|
111
|
-
return result.includes('OK');
|
|
112
|
-
} catch {
|
|
113
|
-
return false;
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const isChromeRunning = () => {
|
|
118
|
-
try {
|
|
119
|
-
if (process.platform === 'win32') {
|
|
120
|
-
const result = execSync('tasklist /FI "IMAGENAME eq chrome.exe" /NH', { encoding: 'utf-8', timeout: 5000 });
|
|
121
|
-
return result.includes('chrome.exe');
|
|
122
|
-
}
|
|
123
|
-
const result = execSync('pgrep -x "Google Chrome" 2>/dev/null || pgrep -x chrome 2>/dev/null', { encoding: 'utf-8', timeout: 5000 });
|
|
124
|
-
return result.trim().length > 0;
|
|
125
|
-
} catch {
|
|
126
|
-
return false;
|
|
127
|
-
}
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
const extractChromeCookies = (cookiesDb, derivedKey, domainPattern) => {
|
|
131
|
-
const tempDb = path.join(os.tmpdir(), `viruagent-cookies-${Date.now()}.db`);
|
|
132
|
-
|
|
133
|
-
// SQLite 온라인 백업 API 사용 (Chrome이 실행 중이어도 동작)
|
|
134
|
-
// execFileSync로 쉘을 거치지 않아 Windows 경로 공백/이스케이핑 문제 없음
|
|
135
|
-
const backupCmd = process.platform === 'win32'
|
|
136
|
-
? `.backup "${tempDb}"`
|
|
137
|
-
: `.backup '${tempDb.replace(/'/g, "''")}'`;
|
|
138
|
-
try {
|
|
139
|
-
execFileSync('sqlite3', [cookiesDb, backupCmd], { stdio: 'ignore', timeout: 10000 });
|
|
140
|
-
} catch {
|
|
141
|
-
// sqlite3 백업 실패 시 파일 복사 → VSS 순으로 폴백
|
|
142
|
-
let copied = false;
|
|
143
|
-
try {
|
|
144
|
-
fs.copyFileSync(cookiesDb, tempDb);
|
|
145
|
-
copied = true;
|
|
146
|
-
} catch {}
|
|
147
|
-
if (!copied && process.platform === 'win32') {
|
|
148
|
-
// Windows: VSS(Volume Shadow Copy)로 잠긴 파일 복사
|
|
149
|
-
copied = copyFileViaVSS(cookiesDb, tempDb);
|
|
150
|
-
}
|
|
151
|
-
if (!copied) {
|
|
152
|
-
throw new Error('Chrome 쿠키 DB 복사에 실패했습니다. Chrome이 실행 중이면 종료 후 다시 시도해 주세요.');
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// 백업 후 남은 WAL/SHM 파일 제거 (깨끗한 DB 보장)
|
|
157
|
-
for (const suffix of ['-wal', '-shm', '-journal']) {
|
|
158
|
-
try { fs.unlinkSync(tempDb + suffix); } catch {}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
try {
|
|
162
|
-
const query = `SELECT host_key, name, value, hex(encrypted_value), path, expires_utc, is_secure, is_httponly, samesite FROM cookies WHERE host_key LIKE '${domainPattern}'`;
|
|
163
|
-
const rows = execFileSync('sqlite3', ['-separator', '||', tempDb, query], {
|
|
164
|
-
encoding: 'utf-8',
|
|
165
|
-
timeout: 5000,
|
|
166
|
-
}).trim();
|
|
167
|
-
if (!rows) return [];
|
|
168
|
-
|
|
169
|
-
const chromeEpochOffset = 11644473600;
|
|
170
|
-
const sameSiteMap = { '-1': 'None', '0': 'None', '1': 'Lax', '2': 'Strict' };
|
|
171
|
-
return rows.split('\n').map(row => {
|
|
172
|
-
const [domain, name, plainValue, encHex, cookiePath, expiresUtc, isSecure, isHttpOnly, sameSite] = row.split('||');
|
|
173
|
-
let value = plainValue || '';
|
|
174
|
-
if (!value && encHex) {
|
|
175
|
-
value = decryptChromeCookie(Buffer.from(encHex, 'hex'), derivedKey);
|
|
176
|
-
}
|
|
177
|
-
if (value && !/^[\x20-\x7E]*$/.test(value)) value = '';
|
|
178
|
-
const expires = expiresUtc === '0' ? -1 : Math.floor(Number(expiresUtc) / 1000000) - chromeEpochOffset;
|
|
179
|
-
return { name, value, domain, path: cookiePath || '/', expires, httpOnly: isHttpOnly === '1', secure: isSecure === '1', sameSite: sameSiteMap[sameSite] || 'None' };
|
|
180
|
-
}).filter(c => c.value);
|
|
181
|
-
} finally {
|
|
182
|
-
try { fs.unlinkSync(tempDb); } catch {}
|
|
183
|
-
}
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
const findWindowsChromePath = () => {
|
|
187
|
-
const candidates = [
|
|
188
|
-
path.join(process.env['PROGRAMFILES(X86)'] || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
189
|
-
path.join(process.env['PROGRAMFILES'] || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
190
|
-
path.join(process.env['LOCALAPPDATA'] || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
191
|
-
];
|
|
192
|
-
return candidates.find(p => fs.existsSync(p)) || null;
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
const findMacChromePath = () => {
|
|
196
|
-
const candidates = [
|
|
197
|
-
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
198
|
-
path.join(os.homedir(), 'Applications', 'Google Chrome.app', 'Contents', 'MacOS', 'Google Chrome'),
|
|
199
|
-
];
|
|
200
|
-
return candidates.find(p => fs.existsSync(p)) || null;
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
const findChromePath = () => {
|
|
204
|
-
if (process.platform === 'win32') return findWindowsChromePath();
|
|
205
|
-
return findMacChromePath();
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
const generateSelfSignedCert = (domain) => {
|
|
209
|
-
const tempDir = path.join(os.tmpdir(), `viruagent-cert-${Date.now()}`);
|
|
210
|
-
fs.mkdirSync(tempDir, { recursive: true });
|
|
211
|
-
const keyPath = path.join(tempDir, 'key.pem');
|
|
212
|
-
const certPath = path.join(tempDir, 'cert.pem');
|
|
213
|
-
|
|
214
|
-
// openssl (Git for Windows에 포함)
|
|
215
|
-
const opensslPaths = [
|
|
216
|
-
'openssl',
|
|
217
|
-
'C:/Program Files/Git/usr/bin/openssl.exe',
|
|
218
|
-
'C:/Program Files (x86)/Git/usr/bin/openssl.exe',
|
|
219
|
-
];
|
|
220
|
-
let generated = false;
|
|
221
|
-
for (const openssl of opensslPaths) {
|
|
222
|
-
try {
|
|
223
|
-
execSync(
|
|
224
|
-
`"${openssl}" req -x509 -newkey rsa:2048 -nodes -keyout "${keyPath}" -out "${certPath}" -days 1 -subj "/CN=${domain}"`,
|
|
225
|
-
{ timeout: 10000, stdio: 'pipe' }
|
|
226
|
-
);
|
|
227
|
-
generated = true;
|
|
228
|
-
break;
|
|
229
|
-
} catch {}
|
|
230
|
-
}
|
|
231
|
-
if (!generated) {
|
|
232
|
-
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
|
|
233
|
-
return null;
|
|
234
|
-
}
|
|
235
|
-
return { keyPath, certPath, tempDir };
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
const CDP_DEBUG_PORT = 9222;
|
|
239
|
-
|
|
240
|
-
const tryConnectCDP = async (port) => {
|
|
241
|
-
const http = require('http');
|
|
242
|
-
return new Promise((resolve) => {
|
|
243
|
-
http.get(`http://127.0.0.1:${port}/json/version`, { timeout: 2000 }, (res) => {
|
|
244
|
-
let data = '';
|
|
245
|
-
res.on('data', c => data += c);
|
|
246
|
-
res.on('end', () => {
|
|
247
|
-
try {
|
|
248
|
-
const info = JSON.parse(data);
|
|
249
|
-
resolve(info.webSocketDebuggerUrl || null);
|
|
250
|
-
} catch { resolve(null); }
|
|
251
|
-
});
|
|
252
|
-
}).on('error', () => resolve(null));
|
|
253
|
-
});
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
const findChromeDebugPort = async () => {
|
|
257
|
-
// 1. 고정 포트 9222 시도
|
|
258
|
-
const ws = await tryConnectCDP(CDP_DEBUG_PORT);
|
|
259
|
-
if (ws) return { port: CDP_DEBUG_PORT, wsUrl: ws };
|
|
260
|
-
|
|
261
|
-
// 2. DevToolsActivePort 파일 확인 (Windows/macOS 모두)
|
|
262
|
-
const dtpPaths = [];
|
|
263
|
-
if (process.platform === 'win32') {
|
|
264
|
-
dtpPaths.push(path.join(
|
|
265
|
-
process.env.LOCALAPPDATA || '',
|
|
266
|
-
'Google', 'Chrome', 'User Data', 'DevToolsActivePort'
|
|
267
|
-
));
|
|
268
|
-
} else {
|
|
269
|
-
dtpPaths.push(path.join(
|
|
270
|
-
os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort'
|
|
271
|
-
));
|
|
272
|
-
}
|
|
273
|
-
for (const dtpPath of dtpPaths) {
|
|
274
|
-
try {
|
|
275
|
-
const content = fs.readFileSync(dtpPath, 'utf-8').trim();
|
|
276
|
-
const port = parseInt(content.split('\n')[0], 10);
|
|
277
|
-
if (port > 0) {
|
|
278
|
-
const ws2 = await tryConnectCDP(port);
|
|
279
|
-
if (ws2) return { port, wsUrl: ws2 };
|
|
280
|
-
}
|
|
281
|
-
} catch {}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
return null;
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
const enableChromeDebugPort = () => {
|
|
288
|
-
// Chrome 바로가기에 --remote-debugging-port 추가 (한 번만 실행)
|
|
289
|
-
if (process.platform !== 'win32') return false;
|
|
290
|
-
|
|
291
|
-
const flag = `--remote-debugging-port=${CDP_DEBUG_PORT}`;
|
|
292
|
-
const shortcutPaths = [];
|
|
293
|
-
|
|
294
|
-
// 바탕화면, 시작 메뉴, 작업표시줄 바로가기 검색
|
|
295
|
-
const locations = [
|
|
296
|
-
path.join(os.homedir(), 'Desktop'),
|
|
297
|
-
path.join(os.homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs'),
|
|
298
|
-
path.join(os.homedir(), 'AppData', 'Roaming', 'Microsoft', 'Internet Explorer', 'Quick Launch', 'User Pinned', 'TaskBar'),
|
|
299
|
-
'C:\\ProgramData\\Microsoft\\Windows\\Start Menu\\Programs',
|
|
300
|
-
];
|
|
301
|
-
for (const loc of locations) {
|
|
302
|
-
try {
|
|
303
|
-
const files = fs.readdirSync(loc);
|
|
304
|
-
for (const f of files) {
|
|
305
|
-
if (/chrome/i.test(f) && f.endsWith('.lnk')) {
|
|
306
|
-
shortcutPaths.push(path.join(loc, f));
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
} catch {}
|
|
310
|
-
}
|
|
311
|
-
// Google Chrome 폴더 내부도 탐색
|
|
312
|
-
for (const loc of locations) {
|
|
313
|
-
try {
|
|
314
|
-
const chromeDir = path.join(loc, 'Google Chrome');
|
|
315
|
-
if (fs.existsSync(chromeDir)) {
|
|
316
|
-
const files = fs.readdirSync(chromeDir);
|
|
317
|
-
for (const f of files) {
|
|
318
|
-
if (/chrome/i.test(f) && f.endsWith('.lnk')) {
|
|
319
|
-
shortcutPaths.push(path.join(chromeDir, f));
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
} catch {}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
let modified = 0;
|
|
327
|
-
for (const lnkPath of shortcutPaths) {
|
|
328
|
-
try {
|
|
329
|
-
const psScript = `
|
|
330
|
-
$shell = New-Object -ComObject WScript.Shell
|
|
331
|
-
$sc = $shell.CreateShortcut('${lnkPath.replace(/'/g, "''")}')
|
|
332
|
-
if ($sc.Arguments -notmatch 'remote-debugging-port') {
|
|
333
|
-
$sc.Arguments = ($sc.Arguments + ' ${flag}').Trim()
|
|
334
|
-
$sc.Save()
|
|
335
|
-
Write-Output 'MODIFIED'
|
|
336
|
-
} else {
|
|
337
|
-
Write-Output 'ALREADY'
|
|
338
|
-
}`;
|
|
339
|
-
const result = execSync(`powershell -Command "${psScript.replace(/"/g, '\\"')}"`, {
|
|
340
|
-
timeout: 5000,
|
|
341
|
-
encoding: 'utf-8',
|
|
342
|
-
}).trim();
|
|
343
|
-
if (result === 'MODIFIED') modified++;
|
|
344
|
-
} catch {}
|
|
345
|
-
}
|
|
346
|
-
return modified > 0;
|
|
347
|
-
};
|
|
348
|
-
|
|
349
|
-
const extractCookiesFromCDP = async (port, targetSessionPath) => {
|
|
350
|
-
const http = require('http');
|
|
351
|
-
const WebSocket = require('ws');
|
|
352
|
-
|
|
353
|
-
// 1. 브라우저 레벨 CDP에 연결하여 tistory 탭 생성/탐색
|
|
354
|
-
const browserWsUrl = await tryConnectCDP(port);
|
|
355
|
-
if (!browserWsUrl) throw new Error('Chrome CDP 연결 실패');
|
|
356
|
-
|
|
357
|
-
// 2. 기존 tistory 탭 찾거나 새로 생성
|
|
358
|
-
const targetsJson = await new Promise((resolve, reject) => {
|
|
359
|
-
http.get(`http://127.0.0.1:${port}/json/list`, { timeout: 3000 }, (res) => {
|
|
360
|
-
let data = '';
|
|
361
|
-
res.on('data', c => data += c);
|
|
362
|
-
res.on('end', () => resolve(data));
|
|
363
|
-
}).on('error', reject);
|
|
364
|
-
});
|
|
365
|
-
const targets = JSON.parse(targetsJson);
|
|
366
|
-
let pageTarget = targets.find(t => t.type === 'page' && t.url && t.url.includes('tistory'));
|
|
367
|
-
|
|
368
|
-
if (!pageTarget) {
|
|
369
|
-
// tistory 탭이 없으면 브라우저 CDP로 새 탭 생성
|
|
370
|
-
const bws = new WebSocket(browserWsUrl);
|
|
371
|
-
const newTargetId = await new Promise((resolve, reject) => {
|
|
372
|
-
const timeout = setTimeout(() => reject(new Error('탭 생성 시간 초과')), 10000);
|
|
373
|
-
bws.on('open', () => {
|
|
374
|
-
bws.send(JSON.stringify({ id: 1, method: 'Target.createTarget', params: { url: 'https://www.tistory.com/' } }));
|
|
375
|
-
});
|
|
376
|
-
bws.on('message', (msg) => {
|
|
377
|
-
const resp = JSON.parse(msg.toString());
|
|
378
|
-
if (resp.id === 1) {
|
|
379
|
-
clearTimeout(timeout);
|
|
380
|
-
resolve(resp.result?.targetId);
|
|
381
|
-
bws.close();
|
|
382
|
-
}
|
|
383
|
-
});
|
|
384
|
-
bws.on('error', (e) => { clearTimeout(timeout); reject(e); });
|
|
385
|
-
});
|
|
386
|
-
// 새 탭의 WebSocket URL 조회
|
|
387
|
-
await new Promise(r => setTimeout(r, 3000));
|
|
388
|
-
const newTargetsJson = await new Promise((resolve, reject) => {
|
|
389
|
-
http.get(`http://127.0.0.1:${port}/json/list`, { timeout: 3000 }, (res) => {
|
|
390
|
-
let data = '';
|
|
391
|
-
res.on('data', c => data += c);
|
|
392
|
-
res.on('end', () => resolve(data));
|
|
393
|
-
}).on('error', reject);
|
|
394
|
-
});
|
|
395
|
-
const newTargets = JSON.parse(newTargetsJson);
|
|
396
|
-
pageTarget = newTargets.find(t => t.id === newTargetId) || newTargets.find(t => t.type === 'page' && t.url && t.url.includes('tistory'));
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
if (!pageTarget || !pageTarget.webSocketDebuggerUrl) {
|
|
400
|
-
throw new Error('tistory 페이지 타겟을 찾을 수 없습니다.');
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// 3. 페이지 레벨 CDP에서 Network.enable → Network.getAllCookies
|
|
404
|
-
const ws = new WebSocket(pageTarget.webSocketDebuggerUrl);
|
|
405
|
-
const cookies = await new Promise((resolve, reject) => {
|
|
406
|
-
const timeout = setTimeout(() => reject(new Error('CDP 쿠키 추출 시간 초과')), 15000);
|
|
407
|
-
let msgId = 1;
|
|
408
|
-
ws.on('open', () => {
|
|
409
|
-
ws.send(JSON.stringify({ id: msgId++, method: 'Network.enable' }));
|
|
410
|
-
});
|
|
411
|
-
ws.on('message', (msg) => {
|
|
412
|
-
const resp = JSON.parse(msg.toString());
|
|
413
|
-
if (resp.id === 1) {
|
|
414
|
-
// Network enabled → getAllCookies
|
|
415
|
-
ws.send(JSON.stringify({ id: msgId++, method: 'Network.getAllCookies' }));
|
|
416
|
-
}
|
|
417
|
-
if (resp.id === 2) {
|
|
418
|
-
clearTimeout(timeout);
|
|
419
|
-
resolve(resp.result?.cookies || []);
|
|
420
|
-
ws.close();
|
|
421
|
-
}
|
|
422
|
-
});
|
|
423
|
-
ws.on('error', (e) => { clearTimeout(timeout); reject(e); });
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
const tistoryCookies = cookies.filter(c => String(c.domain).includes('tistory'));
|
|
427
|
-
const tssession = tistoryCookies.find(c => c.name === 'TSSESSION');
|
|
428
|
-
if (!tssession || !tssession.value) {
|
|
429
|
-
throw new Error('Chrome에 티스토리 로그인 세션이 없습니다. Chrome에서 먼저 티스토리에 로그인해 주세요.');
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
const payload = {
|
|
433
|
-
cookies: tistoryCookies.map(c => ({
|
|
434
|
-
name: c.name, value: c.value, domain: c.domain,
|
|
435
|
-
path: c.path || '/', expires: c.expires > 0 ? c.expires : -1,
|
|
436
|
-
httpOnly: !!c.httpOnly, secure: !!c.secure,
|
|
437
|
-
sameSite: c.sameSite || 'None',
|
|
438
|
-
})),
|
|
439
|
-
updatedAt: new Date().toISOString(),
|
|
440
|
-
};
|
|
441
|
-
await fs.promises.mkdir(path.dirname(targetSessionPath), { recursive: true });
|
|
442
|
-
await fs.promises.writeFile(targetSessionPath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
443
|
-
return { cookieCount: tistoryCookies.length };
|
|
444
|
-
};
|
|
445
|
-
|
|
446
|
-
const getOrCreateJunctionPath = (chromeRoot) => {
|
|
447
|
-
// Chrome 145+: 기본 user-data-dir에서는 --remote-debugging-port가 작동하지 않음
|
|
448
|
-
// Junction point로 같은 디렉토리를 다른 경로로 가리켜서 우회
|
|
449
|
-
if (process.platform !== 'win32') return chromeRoot;
|
|
450
|
-
|
|
451
|
-
const junctionPath = path.join(path.dirname(chromeRoot), 'ChromeDebug');
|
|
452
|
-
if (!fs.existsSync(junctionPath)) {
|
|
453
|
-
try {
|
|
454
|
-
execSync(`cmd /c "mklink /J "${junctionPath}" "${chromeRoot}""`, {
|
|
455
|
-
timeout: 5000, stdio: 'pipe',
|
|
456
|
-
});
|
|
457
|
-
} catch {
|
|
458
|
-
// Junction 생성 실패 시 원본 경로 사용 (디버그 포트 작동 안 할 수 있음)
|
|
459
|
-
return chromeRoot;
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
return junctionPath;
|
|
463
|
-
};
|
|
464
|
-
|
|
465
|
-
const gracefulKillChrome = async () => {
|
|
466
|
-
try {
|
|
467
|
-
if (process.platform === 'win32') {
|
|
468
|
-
execSync('cmd /c "taskkill /IM chrome.exe"', { stdio: 'ignore', timeout: 10000 });
|
|
469
|
-
} else {
|
|
470
|
-
// macOS: SIGTERM으로 graceful 종료
|
|
471
|
-
execSync('pkill -TERM "Google Chrome" 2>/dev/null || true', { stdio: 'ignore', timeout: 10000 });
|
|
472
|
-
}
|
|
473
|
-
} catch {}
|
|
474
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
475
|
-
if (isChromeRunning()) {
|
|
476
|
-
try {
|
|
477
|
-
if (process.platform === 'win32') {
|
|
478
|
-
execSync('cmd /c "taskkill /F /IM chrome.exe"', { stdio: 'ignore', timeout: 5000 });
|
|
479
|
-
} else {
|
|
480
|
-
execSync('pkill -9 "Google Chrome" 2>/dev/null || true', { stdio: 'ignore', timeout: 5000 });
|
|
481
|
-
}
|
|
482
|
-
} catch {}
|
|
483
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
484
|
-
}
|
|
485
|
-
};
|
|
486
|
-
|
|
487
|
-
const extractCookiesViaCDP = async (targetSessionPath, chromeRoot, profileName) => {
|
|
488
|
-
// Chrome 실행 중: CDP(Chrome DevTools Protocol)로 쿠키 추출
|
|
489
|
-
// 1단계: 이미 디버그 포트가 열려있으면 바로 연결 (크롬 종료 없음)
|
|
490
|
-
// 2단계: 없으면 한 번만 재시작 (Windows: 바로가기 수정, macOS: 직접 재시작)
|
|
491
|
-
const { spawn } = require('child_process');
|
|
492
|
-
|
|
493
|
-
// 1. 이미 디버그 포트가 열려있는지 확인
|
|
494
|
-
const existing = await findChromeDebugPort();
|
|
495
|
-
if (existing) {
|
|
496
|
-
console.log(`[chrome-cdp] 기존 Chrome 디버그 포트(${existing.port}) 감지 — 크롬 종료 없이 쿠키 추출`);
|
|
497
|
-
return await extractCookiesFromCDP(existing.port, targetSessionPath);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// 2. 디버그 포트 없음 → Windows만 바로가기 수정 (macOS는 바로가기 개념 없음)
|
|
501
|
-
if (process.platform === 'win32') {
|
|
502
|
-
console.log('[chrome-cdp] Chrome 디버그 포트 미감지 — 바로가기에 --remote-debugging-port 추가 중...');
|
|
503
|
-
const shortcutModified = enableChromeDebugPort();
|
|
504
|
-
if (shortcutModified) {
|
|
505
|
-
console.log('[chrome-cdp] Chrome 바로가기 수정 완료 — 다음부터는 크롬 종료 없이 쿠키 추출 가능');
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// 3. Chrome 경로 확인
|
|
510
|
-
const chromePath = findChromePath();
|
|
511
|
-
if (!chromePath) throw new Error('Chrome 실행 파일을 찾을 수 없습니다.');
|
|
512
|
-
|
|
513
|
-
// 4. Chrome을 graceful하게 종료하고 디버그 포트로 재시작 (최초 1회만)
|
|
514
|
-
console.log('[chrome-cdp] Chrome을 디버그 포트와 함께 재시작합니다 (탭 자동 복원)...');
|
|
515
|
-
await gracefulKillChrome();
|
|
516
|
-
|
|
517
|
-
// 5. 디버그 포트 + 세션 복원 재시작
|
|
518
|
-
// Windows Chrome 145+: Junction으로 user-data-dir 우회
|
|
519
|
-
// macOS: user-data-dir 직접 지정
|
|
520
|
-
const userDataDir = process.platform === 'win32'
|
|
521
|
-
? getOrCreateJunctionPath(chromeRoot)
|
|
522
|
-
: chromeRoot;
|
|
523
|
-
const chromeProc = spawn(chromePath, [
|
|
524
|
-
`--remote-debugging-port=${CDP_DEBUG_PORT}`,
|
|
525
|
-
'--remote-allow-origins=*',
|
|
526
|
-
'--restore-last-session',
|
|
527
|
-
`--user-data-dir=${userDataDir}`,
|
|
528
|
-
`--profile-directory=${profileName}`,
|
|
529
|
-
], { detached: true, stdio: 'ignore' });
|
|
530
|
-
chromeProc.unref();
|
|
531
|
-
|
|
532
|
-
// 6. CDP 연결 대기
|
|
533
|
-
let connected = null;
|
|
534
|
-
const maxWait = 15000;
|
|
535
|
-
const start = Date.now();
|
|
536
|
-
while (Date.now() - start < maxWait) {
|
|
537
|
-
await new Promise(r => setTimeout(r, 500));
|
|
538
|
-
connected = await findChromeDebugPort();
|
|
539
|
-
if (connected) break;
|
|
540
|
-
}
|
|
541
|
-
if (!connected) throw new Error('Chrome 디버그 포트 연결 시간 초과');
|
|
542
|
-
|
|
543
|
-
// 7. 쿠키 추출 (Chrome은 계속 실행 상태 유지 — 종료하지 않음)
|
|
544
|
-
return await extractCookiesFromCDP(connected.port, targetSessionPath);
|
|
545
|
-
};
|
|
546
|
-
|
|
547
|
-
const importSessionViaChromeDirectLaunch = async (targetSessionPath, chromeRoot, profileName) => {
|
|
548
|
-
// Windows Chrome 145+: v20 App Bound Encryption으로 외부에서 쿠키 복호화 불가
|
|
549
|
-
// Chrome 실행 중이면 CDP 방식으로 추출 (잠시 재시작, 탭 자동 복원)
|
|
550
|
-
if (isChromeRunning()) {
|
|
551
|
-
return await extractCookiesViaCDP(targetSessionPath, chromeRoot, profileName);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
const chromePath = findWindowsChromePath();
|
|
555
|
-
if (!chromePath) {
|
|
556
|
-
throw new Error('Chrome 실행 파일을 찾을 수 없습니다.');
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// 1. 자체 서명 인증서 생성 (openssl 필요)
|
|
560
|
-
const cert = generateSelfSignedCert('www.tistory.com');
|
|
561
|
-
if (!cert) {
|
|
562
|
-
throw new Error(
|
|
563
|
-
'openssl을 찾을 수 없습니다. Git for Windows를 설치하면 openssl이 포함됩니다.'
|
|
564
|
-
);
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
const https = require('https');
|
|
568
|
-
const { spawn } = require('child_process');
|
|
569
|
-
|
|
570
|
-
// 2. HTTPS 서버 시작 (포트 443)
|
|
571
|
-
const server = https.createServer({
|
|
572
|
-
key: fs.readFileSync(cert.keyPath),
|
|
573
|
-
cert: fs.readFileSync(cert.certPath),
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
try {
|
|
577
|
-
await new Promise((resolve, reject) => {
|
|
578
|
-
server.on('error', reject);
|
|
579
|
-
server.listen(443, '127.0.0.1', resolve);
|
|
580
|
-
});
|
|
581
|
-
} catch (e) {
|
|
582
|
-
try { fs.rmSync(cert.tempDir, { recursive: true, force: true }); } catch {}
|
|
583
|
-
throw new Error(`포트 443 바인딩 실패: ${e.message}. 관리자 권한으로 실행해 주세요.`);
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// 3. 쿠키 수신 Promise
|
|
587
|
-
let chromeProc = null;
|
|
588
|
-
const cookiePromise = new Promise((resolve, reject) => {
|
|
589
|
-
const timeout = setTimeout(() => {
|
|
590
|
-
reject(new Error('Chrome 쿠키 추출 시간 초과 (15초)'));
|
|
591
|
-
}, 15000);
|
|
592
|
-
|
|
593
|
-
server.on('request', (req, res) => {
|
|
594
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
595
|
-
res.end('<html><body>Session captured. You can close this window.</body></html>');
|
|
596
|
-
|
|
597
|
-
if (req.url === '/' || req.url === '') {
|
|
598
|
-
clearTimeout(timeout);
|
|
599
|
-
const cookieHeader = req.headers.cookie || '';
|
|
600
|
-
resolve(cookieHeader);
|
|
601
|
-
}
|
|
602
|
-
});
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
// 4. Chrome 실행 (Chrome이 꺼진 상태에서만 실행됨 - DNS 리다이렉션, 인증서 오류 무시)
|
|
606
|
-
chromeProc = spawn(chromePath, [
|
|
607
|
-
'--no-first-run',
|
|
608
|
-
'--no-default-browser-check',
|
|
609
|
-
`--profile-directory=${profileName}`,
|
|
610
|
-
'--host-resolver-rules=MAP www.tistory.com 127.0.0.1',
|
|
611
|
-
'--ignore-certificate-errors',
|
|
612
|
-
'https://www.tistory.com/',
|
|
613
|
-
], { detached: true, stdio: 'ignore' });
|
|
614
|
-
chromeProc.unref();
|
|
615
|
-
|
|
616
|
-
try {
|
|
617
|
-
const cookieHeader = await cookiePromise;
|
|
618
|
-
|
|
619
|
-
// Cookie 헤더 파싱
|
|
620
|
-
const cookies = cookieHeader.split(';')
|
|
621
|
-
.map(c => c.trim())
|
|
622
|
-
.filter(Boolean)
|
|
623
|
-
.map(c => {
|
|
624
|
-
const eqIdx = c.indexOf('=');
|
|
625
|
-
if (eqIdx < 0) return null;
|
|
626
|
-
return { name: c.slice(0, eqIdx).trim(), value: c.slice(eqIdx + 1).trim() };
|
|
627
|
-
})
|
|
628
|
-
.filter(Boolean);
|
|
629
|
-
|
|
630
|
-
const tssession = cookies.find(c => c.name === 'TSSESSION');
|
|
631
|
-
if (!tssession || !tssession.value) {
|
|
632
|
-
throw new Error(
|
|
633
|
-
'Chrome에 티스토리 로그인 세션이 없습니다. Chrome에서 먼저 티스토리에 로그인해 주세요.'
|
|
634
|
-
);
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
// Cookie 헤더에는 domain/path/expires 정보가 없으므로 기본값 설정
|
|
638
|
-
const payload = {
|
|
639
|
-
cookies: cookies.map(c => ({
|
|
640
|
-
name: c.name,
|
|
641
|
-
value: c.value,
|
|
642
|
-
domain: '.tistory.com',
|
|
643
|
-
path: '/',
|
|
644
|
-
expires: -1,
|
|
645
|
-
httpOnly: false,
|
|
646
|
-
secure: true,
|
|
647
|
-
sameSite: 'None',
|
|
648
|
-
})),
|
|
649
|
-
updatedAt: new Date().toISOString(),
|
|
650
|
-
};
|
|
651
|
-
|
|
652
|
-
await fs.promises.mkdir(path.dirname(targetSessionPath), { recursive: true });
|
|
653
|
-
await fs.promises.writeFile(targetSessionPath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
654
|
-
|
|
655
|
-
return { cookieCount: cookies.length };
|
|
656
|
-
} finally {
|
|
657
|
-
server.close();
|
|
658
|
-
if (chromeProc) {
|
|
659
|
-
try { execSync(`taskkill /F /PID ${chromeProc.pid} /T`, { stdio: 'ignore', timeout: 5000 }); } catch {}
|
|
660
|
-
}
|
|
661
|
-
try { fs.rmSync(cert.tempDir, { recursive: true, force: true }); } catch {}
|
|
662
|
-
}
|
|
663
|
-
};
|
|
664
|
-
|
|
665
|
-
const importSessionFromChrome = async (targetSessionPath, profileName = 'Default') => {
|
|
666
|
-
let chromeRoot;
|
|
667
|
-
if (process.platform === 'win32') {
|
|
668
|
-
chromeRoot = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'Google', 'Chrome', 'User Data');
|
|
669
|
-
} else {
|
|
670
|
-
chromeRoot = path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome');
|
|
671
|
-
}
|
|
672
|
-
if (!fs.existsSync(chromeRoot)) {
|
|
673
|
-
throw new Error('Chrome이 설치되어 있지 않습니다.');
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
const profileDir = path.join(chromeRoot, profileName);
|
|
677
|
-
// Windows 최신 Chrome은 Network/Cookies, 이전 버전은 Cookies
|
|
678
|
-
let cookiesDb = path.join(profileDir, 'Network', 'Cookies');
|
|
679
|
-
if (!fs.existsSync(cookiesDb)) {
|
|
680
|
-
cookiesDb = path.join(profileDir, 'Cookies');
|
|
681
|
-
}
|
|
682
|
-
if (!fs.existsSync(cookiesDb)) {
|
|
683
|
-
throw new Error(`Chrome 프로필 "${profileName}"에 쿠키 DB가 없습니다.`);
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
let derivedKey;
|
|
687
|
-
if (process.platform === 'win32') {
|
|
688
|
-
// Windows: Local State → DPAPI로 마스터 키 복호화
|
|
689
|
-
derivedKey = getWindowsChromeMasterKey(chromeRoot);
|
|
690
|
-
} else {
|
|
691
|
-
// macOS: Keychain에서 Chrome 암호화 키 추출
|
|
692
|
-
let keychainPassword;
|
|
693
|
-
try {
|
|
694
|
-
keychainPassword = execSync(
|
|
695
|
-
'security find-generic-password -s "Chrome Safe Storage" -w',
|
|
696
|
-
{ encoding: 'utf-8', timeout: 5000 }
|
|
697
|
-
).trim();
|
|
698
|
-
} catch {
|
|
699
|
-
throw new Error('Chrome Safe Storage 키를 Keychain에서 읽을 수 없습니다. macOS 권한을 확인해 주세요.');
|
|
700
|
-
}
|
|
701
|
-
derivedKey = crypto.pbkdf2Sync(keychainPassword, 'saltysalt', 1003, 16, 'sha1');
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
// Chrome에서 tistory + kakao 쿠키 복호화 추출
|
|
705
|
-
const tistoryCookies = extractChromeCookies(cookiesDb, derivedKey, '%tistory.com');
|
|
706
|
-
const kakaoCookies = extractChromeCookies(cookiesDb, derivedKey, '%kakao.com');
|
|
707
|
-
|
|
708
|
-
// 이미 TSSESSION 있으면 바로 저장
|
|
709
|
-
const existingSession = tistoryCookies.some(c => c.name === 'TSSESSION' && c.value);
|
|
710
|
-
if (existingSession) {
|
|
711
|
-
const payload = { cookies: tistoryCookies, updatedAt: new Date().toISOString() };
|
|
712
|
-
await fs.promises.mkdir(path.dirname(targetSessionPath), { recursive: true });
|
|
713
|
-
await fs.promises.writeFile(targetSessionPath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
714
|
-
return { cookieCount: tistoryCookies.length };
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
// 3) 카카오 세션 쿠키가 있으면 Playwright에 주입 후 자동 로그인
|
|
718
|
-
const hasKakaoSession = kakaoCookies.some(c => c.domain.includes('kakao.com') && (c.name === '_kawlt' || c.name === '_kawltea' || c.name === '_karmt'));
|
|
719
|
-
if (!hasKakaoSession) {
|
|
720
|
-
// 쿠키 복호화로 세션을 얻을 수 없는 경우 → CDP로 Chrome에서 직접 추출
|
|
721
|
-
if (isChromeRunning()) {
|
|
722
|
-
return await extractCookiesViaCDP(targetSessionPath, chromeRoot, profileName);
|
|
723
|
-
}
|
|
724
|
-
// Windows: Chrome이 꺼져있으면 DirectLaunch 방식으로 추출
|
|
725
|
-
if (process.platform === 'win32') {
|
|
726
|
-
return await importSessionViaChromeDirectLaunch(targetSessionPath, chromeRoot, profileName);
|
|
727
|
-
}
|
|
728
|
-
// macOS: Chrome이 꺼져있으면 CDP로 재시작하여 추출
|
|
729
|
-
return await extractCookiesViaCDP(targetSessionPath, chromeRoot, profileName);
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
const browser = await chromium.launch({ headless: true });
|
|
733
|
-
const context = await browser.newContext();
|
|
734
|
-
try {
|
|
735
|
-
// Playwright 형식으로 변환하여 쿠키 주입
|
|
736
|
-
const allCookies = [...tistoryCookies, ...kakaoCookies].map(c => ({
|
|
737
|
-
...c,
|
|
738
|
-
domain: c.domain.startsWith('.') ? c.domain : c.domain,
|
|
739
|
-
expires: c.expires > 0 ? c.expires : undefined,
|
|
740
|
-
}));
|
|
741
|
-
await context.addCookies(allCookies);
|
|
742
|
-
|
|
743
|
-
const page = await context.newPage();
|
|
744
|
-
await page.goto('https://www.tistory.com/auth/login', { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
745
|
-
await page.waitForTimeout(1000);
|
|
746
|
-
|
|
747
|
-
// 카카오 로그인 버튼 클릭
|
|
748
|
-
const kakaoBtn = await pickValue(page, KAKAO_TRIGGER_SELECTORS);
|
|
749
|
-
if (kakaoBtn) {
|
|
750
|
-
await page.locator(kakaoBtn).click({ timeout: 5000 }).catch(() => {});
|
|
751
|
-
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
// 카카오 계정 확인 → 계속하기 클릭
|
|
755
|
-
await page.waitForTimeout(2000);
|
|
756
|
-
const confirmBtn = await pickValue(page, [
|
|
757
|
-
...KAKAO_ACCOUNT_CONFIRM_SELECTORS.continue,
|
|
758
|
-
'button[type="submit"]',
|
|
759
|
-
]);
|
|
760
|
-
if (confirmBtn) {
|
|
761
|
-
await page.locator(confirmBtn).click({ timeout: 3000 }).catch(() => {});
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
// TSSESSION 대기 (최대 15초)
|
|
765
|
-
let hasSession = false;
|
|
766
|
-
const maxWait = 15000;
|
|
767
|
-
const startTime = Date.now();
|
|
768
|
-
while (Date.now() - startTime < maxWait) {
|
|
769
|
-
await page.waitForTimeout(1000);
|
|
770
|
-
const cookies = await context.cookies('https://www.tistory.com');
|
|
771
|
-
hasSession = cookies.some(c => c.name === 'TSSESSION' && c.value);
|
|
772
|
-
if (hasSession) break;
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
if (!hasSession) {
|
|
776
|
-
throw new Error('Chrome 카카오 세션으로 티스토리 자동 로그인에 실패했습니다.');
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
await persistTistorySession(context, targetSessionPath);
|
|
780
|
-
const finalCookies = await context.cookies('https://www.tistory.com');
|
|
781
|
-
return { cookieCount: finalCookies.filter(c => String(c.domain).includes('tistory')).length };
|
|
782
|
-
} finally {
|
|
783
|
-
await context.close().catch(() => {});
|
|
784
|
-
await browser.close().catch(() => {});
|
|
785
|
-
}
|
|
786
|
-
};
|
|
787
|
-
|
|
788
|
-
module.exports = {
|
|
789
|
-
importSessionFromChrome,
|
|
790
|
-
};
|