viruagent-cli 0.3.3 → 0.3.5
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 +21 -5
- package/README.md +21 -5
- package/package.json +1 -1
- package/skills/viruagent.md +100 -6
- package/src/providers/naver/auth.js +180 -0
- package/src/providers/naver/chromeImport.js +195 -0
- package/src/providers/naver/editorConvert.js +198 -0
- package/src/providers/naver/imageUpload.js +111 -0
- package/src/providers/naver/index.js +253 -0
- package/src/providers/naver/selectors.js +23 -0
- package/src/providers/naver/session.js +119 -0
- package/src/providers/naver/utils.js +56 -0
- package/src/providers/tistory/auth.js +167 -0
- package/src/providers/tistory/browserHelpers.js +91 -0
- package/src/providers/tistory/chromeImport.js +790 -0
- package/src/providers/tistory/fetchLayer.js +237 -0
- package/src/providers/tistory/imageEnrichment.js +574 -0
- package/src/providers/tistory/imageNormalization.js +301 -0
- package/src/providers/tistory/imageSources.js +270 -0
- package/src/providers/tistory/index.js +561 -0
- package/src/providers/tistory/selectors.js +51 -0
- package/src/providers/tistory/session.js +117 -0
- package/src/providers/tistory/utils.js +235 -0
- package/src/services/naverApiClient.js +493 -0
- package/src/services/providerManager.js +2 -2
- package/src/providers/tistoryProvider.js +0 -3141
package/README.ko.md
CHANGED
|
@@ -64,8 +64,10 @@ Tell the user that viruagent-cli installation is complete.
|
|
|
64
64
|
|
|
65
65
|
## 로그인
|
|
66
66
|
|
|
67
|
+
### Tistory
|
|
68
|
+
|
|
67
69
|
```bash
|
|
68
|
-
# Chrome 세션 임포트 (ID/PW 불필요, macOS
|
|
70
|
+
# Chrome 세션 임포트 (ID/PW 불필요, macOS & Windows)
|
|
69
71
|
npx viruagent-cli login --from-chrome
|
|
70
72
|
|
|
71
73
|
# 특정 Chrome 프로필 사용
|
|
@@ -75,8 +77,21 @@ npx viruagent-cli login --from-chrome --profile "Profile 2"
|
|
|
75
77
|
npx viruagent-cli login --username <id> --password <pw> --headless
|
|
76
78
|
```
|
|
77
79
|
|
|
80
|
+
### Naver Blog
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# Chrome 세션 임포트 (권장)
|
|
84
|
+
npx viruagent-cli login --provider naver --from-chrome
|
|
85
|
+
|
|
86
|
+
# 수동 로그인 (브라우저에서 직접 로그인)
|
|
87
|
+
npx viruagent-cli login --provider naver --manual
|
|
88
|
+
|
|
89
|
+
# 자동 로그인 (ID/PW)
|
|
90
|
+
npx viruagent-cli login --provider naver --username <id> --password <pw>
|
|
91
|
+
```
|
|
92
|
+
|
|
78
93
|
> [!TIP]
|
|
79
|
-
> `--from-chrome`은 macOS Keychain
|
|
94
|
+
> `--from-chrome`은 Chrome에서 직접 쿠키를 추출합니다 — macOS Keychain 또는 Windows DPAPI를 사용하며, CDP 폴백도 지원합니다. 브라우저 실행 없이, 2FA 없이, 1초 내 완료됩니다.
|
|
80
95
|
|
|
81
96
|
## 사용법
|
|
82
97
|
|
|
@@ -102,7 +117,7 @@ npx viruagent-cli login --username <id> --password <pw> --headless
|
|
|
102
117
|
| 플랫폼 | 상태 |
|
|
103
118
|
| --- | --- |
|
|
104
119
|
| Tistory | 지원 |
|
|
105
|
-
| Naver Blog |
|
|
120
|
+
| Naver Blog | 지원 |
|
|
106
121
|
|
|
107
122
|
## 기술 스택
|
|
108
123
|
|
|
@@ -110,9 +125,10 @@ npx viruagent-cli login --username <id> --password <pw> --headless
|
|
|
110
125
|
| --- | --- |
|
|
111
126
|
| CLI 프레임워크 | Commander.js |
|
|
112
127
|
| 브라우저 자동화 | Playwright (Chromium) |
|
|
113
|
-
| 쿠키 복호화 | macOS Keychain + AES-128-CBC |
|
|
128
|
+
| 쿠키 복호화 | macOS Keychain + AES-128-CBC / Windows DPAPI + AES-256-GCM / CDP 폴백 |
|
|
114
129
|
| 세션 관리 | JSON 파일 (`~/.viruagent-cli/`) |
|
|
115
|
-
| 이미지 검색 | DuckDuckGo, Wikimedia
|
|
130
|
+
| 이미지 검색 | DuckDuckGo, Wikimedia Commons |
|
|
131
|
+
| 네이버 에디터 | SE Editor 컴포넌트 모델 + RabbitWrite API |
|
|
116
132
|
| 출력 형식 | JSON envelope (`{ ok, data }` / `{ ok, error, hint }`) |
|
|
117
133
|
|
|
118
134
|
## Contributing
|
package/README.md
CHANGED
|
@@ -64,8 +64,10 @@ Tell the user that viruagent-cli installation is complete.
|
|
|
64
64
|
|
|
65
65
|
## Login
|
|
66
66
|
|
|
67
|
+
### Tistory
|
|
68
|
+
|
|
67
69
|
```bash
|
|
68
|
-
# Import session from Chrome (no ID/PW needed, macOS
|
|
70
|
+
# Import session from Chrome (no ID/PW needed, macOS & Windows)
|
|
69
71
|
npx viruagent-cli login --from-chrome
|
|
70
72
|
|
|
71
73
|
# Use a specific Chrome profile
|
|
@@ -75,8 +77,21 @@ npx viruagent-cli login --from-chrome --profile "Profile 2"
|
|
|
75
77
|
npx viruagent-cli login --username <id> --password <pw> --headless
|
|
76
78
|
```
|
|
77
79
|
|
|
80
|
+
### Naver Blog
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# Import session from Chrome (recommended)
|
|
84
|
+
npx viruagent-cli login --provider naver --from-chrome
|
|
85
|
+
|
|
86
|
+
# Manual login via browser
|
|
87
|
+
npx viruagent-cli login --provider naver --manual
|
|
88
|
+
|
|
89
|
+
# Auto login with credentials
|
|
90
|
+
npx viruagent-cli login --provider naver --username <id> --password <pw>
|
|
91
|
+
```
|
|
92
|
+
|
|
78
93
|
> [!TIP]
|
|
79
|
-
> `--from-chrome`
|
|
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.
|
|
80
95
|
|
|
81
96
|
## Usage
|
|
82
97
|
|
|
@@ -102,7 +117,7 @@ Ask the agent for detailed usage or customization help.
|
|
|
102
117
|
| Platform | Status |
|
|
103
118
|
| --- | --- |
|
|
104
119
|
| Tistory | Supported |
|
|
105
|
-
| Naver Blog |
|
|
120
|
+
| Naver Blog | Supported |
|
|
106
121
|
|
|
107
122
|
## Tech Stack
|
|
108
123
|
|
|
@@ -110,9 +125,10 @@ Ask the agent for detailed usage or customization help.
|
|
|
110
125
|
| --- | --- |
|
|
111
126
|
| CLI Framework | Commander.js |
|
|
112
127
|
| Browser Automation | Playwright (Chromium) |
|
|
113
|
-
| Cookie Decryption | macOS Keychain + AES-128-CBC |
|
|
128
|
+
| Cookie Decryption | macOS Keychain + AES-128-CBC / Windows DPAPI + AES-256-GCM / CDP fallback |
|
|
114
129
|
| Session Management | JSON file (`~/.viruagent-cli/`) |
|
|
115
|
-
| Image Search | DuckDuckGo, Wikimedia
|
|
130
|
+
| Image Search | DuckDuckGo, Wikimedia Commons |
|
|
131
|
+
| Naver Editor | SE Editor component model + RabbitWrite API |
|
|
116
132
|
| Output Format | JSON envelope (`{ ok, data }` / `{ ok, error, hint }`) |
|
|
117
133
|
|
|
118
134
|
## Contributing
|
package/package.json
CHANGED
package/skills/viruagent.md
CHANGED
|
@@ -28,6 +28,8 @@ All responses are JSON: `{ "ok": true, "data": {...} }` on success, `{ "ok": fal
|
|
|
28
28
|
|
|
29
29
|
## Step 2: Check authentication
|
|
30
30
|
|
|
31
|
+
### Tistory
|
|
32
|
+
|
|
31
33
|
```bash
|
|
32
34
|
npx viruagent-cli status --provider tistory
|
|
33
35
|
```
|
|
@@ -40,10 +42,29 @@ npx viruagent-cli login --provider tistory --username <user> --password <pass> -
|
|
|
40
42
|
|
|
41
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.
|
|
42
44
|
|
|
45
|
+
### Naver Blog
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npx viruagent-cli status --provider naver
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
If not logged in, authenticate (recommended: Chrome import):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Chrome session import (recommended, fastest)
|
|
55
|
+
npx viruagent-cli login --provider naver --from-chrome
|
|
56
|
+
|
|
57
|
+
# Manual login via browser
|
|
58
|
+
npx viruagent-cli login --provider naver --manual
|
|
59
|
+
|
|
60
|
+
# Auto login with credentials (NAVER_USERNAME / NAVER_PASSWORD env vars also work)
|
|
61
|
+
npx viruagent-cli login --provider naver --username <user> --password <pass>
|
|
62
|
+
```
|
|
63
|
+
|
|
43
64
|
## Step 3: Get categories (for publish)
|
|
44
65
|
|
|
45
66
|
```bash
|
|
46
|
-
npx viruagent-cli list-categories --provider tistory
|
|
67
|
+
npx viruagent-cli list-categories --provider <tistory|naver>
|
|
47
68
|
```
|
|
48
69
|
|
|
49
70
|
Ask the user which category to use if not specified.
|
|
@@ -60,6 +81,10 @@ When the user asks to write a blog post:
|
|
|
60
81
|
|
|
61
82
|
Every post must follow this structure. Write in the same language as the user's request.
|
|
62
83
|
|
|
84
|
+
#### Tistory HTML Template
|
|
85
|
+
|
|
86
|
+
Tistory uses `data-ke-*` attributes for styling.
|
|
87
|
+
|
|
63
88
|
```html
|
|
64
89
|
<!-- 1. Hook (blockquote style2 for topic quote) -->
|
|
65
90
|
<blockquote data-ke-style="style2">[One impactful sentence that captures the core insight or tension]</blockquote>
|
|
@@ -105,14 +130,65 @@ Every post must follow this structure. Write in the same language as the user's
|
|
|
105
130
|
<p data-ke-size="size18">[Closing 1~2 sentences — suggest a concrete, immediate action. Not vague "stay tuned" but specific "try this tomorrow"]</p>
|
|
106
131
|
```
|
|
107
132
|
|
|
133
|
+
#### Naver Blog HTML Template
|
|
134
|
+
|
|
135
|
+
Naver converts HTML to editor components server-side. Do NOT use `data-ke-*` attributes. Use plain HTML with `<p> </p>` for spacing.
|
|
136
|
+
|
|
137
|
+
```html
|
|
138
|
+
<!-- 1. Hook -->
|
|
139
|
+
<blockquote>[One impactful sentence that captures the core insight or tension]</blockquote>
|
|
140
|
+
<p> </p>
|
|
141
|
+
|
|
142
|
+
<!-- 2. Introduction (2~3 paragraphs) -->
|
|
143
|
+
<p>[Describe the situation the reader relates to — paint a vivid picture, 3~5 sentences]</p>
|
|
144
|
+
<p>[Set expectations: what angle this post takes, what the reader will gain]</p>
|
|
145
|
+
<p> </p>
|
|
146
|
+
|
|
147
|
+
<!-- 3. Body (3~4 sections with h2, each section has 2~3 paragraphs) -->
|
|
148
|
+
<h2>[Section 1 Title — keyword-rich]</h2>
|
|
149
|
+
<p>[Explain the concept or situation in 3~5 sentences. Include evidence: expert quotes, data, or real-world examples]</p>
|
|
150
|
+
<p>[Deepen the point with analysis, comparison, or implication. Connect to the reader's experience]</p>
|
|
151
|
+
<ul>
|
|
152
|
+
<li>[Key point — only use lists for 3+ concrete items worth scanning]</li>
|
|
153
|
+
<li>[Key point]</li>
|
|
154
|
+
<li>[Key point]</li>
|
|
155
|
+
</ul>
|
|
156
|
+
<p> </p>
|
|
157
|
+
|
|
158
|
+
<h2>[Section 2 Title]</h2>
|
|
159
|
+
<p>[Introduce a new angle, case study, or supporting argument. 3~5 sentences with specific details]</p>
|
|
160
|
+
<p>[Analyze why this matters. Use <strong>bold</strong> for key terms. Connect back to the main thesis]</p>
|
|
161
|
+
<p> </p>
|
|
162
|
+
|
|
163
|
+
<h2>[Section 3 Title]</h2>
|
|
164
|
+
<p>[Practical application or actionable insight. Show, don't just tell]</p>
|
|
165
|
+
<p>[Bridge to the conclusion — "what this all means"]</p>
|
|
166
|
+
<p> </p>
|
|
167
|
+
|
|
168
|
+
<!-- 4. Summary / Key Takeaways -->
|
|
169
|
+
<h2>핵심 정리</h2>
|
|
170
|
+
<ul>
|
|
171
|
+
<li>[Takeaway 1 — one complete sentence, not a fragment]</li>
|
|
172
|
+
<li>[Takeaway 2]</li>
|
|
173
|
+
<li>[Takeaway 3]</li>
|
|
174
|
+
</ul>
|
|
175
|
+
<p> </p>
|
|
176
|
+
|
|
177
|
+
<!-- 5. Closing -->
|
|
178
|
+
<p>[Closing 1~2 sentences — suggest a concrete, immediate action]</p>
|
|
179
|
+
```
|
|
180
|
+
|
|
108
181
|
### Writing Rules
|
|
109
182
|
|
|
110
183
|
- **Title**: Include the primary keyword. 10~20 characters. Short and impactful.
|
|
111
184
|
- **Length**: 3000~4000 characters (한글 기준). Aim for depth, not padding.
|
|
112
185
|
- **Paragraphs**: 3~5 sentences each. Develop ideas fully within a paragraph before moving on. Do NOT write 1~2 sentence paragraphs repeatedly.
|
|
113
|
-
- **Font size**: Use `<p data-ke-size="size18">` for all body text paragraphs. Use `<p data-ke-size="size16"> </p>` only for spacing between sections.
|
|
114
|
-
- **
|
|
115
|
-
- **
|
|
186
|
+
- **Font size (Tistory)**: Use `<p data-ke-size="size18">` for all body text paragraphs. Use `<p data-ke-size="size16"> </p>` only for spacing between sections.
|
|
187
|
+
- **Font size (Naver)**: Use plain `<p>` tags. Do NOT use `data-ke-*` attributes — Naver's editor ignores them.
|
|
188
|
+
- **Spacing (Tistory)**: Use `<p data-ke-size="size16"> </p>` between sections for line breaks.
|
|
189
|
+
- **Spacing (Naver)**: Use `<p> </p>` between sections.
|
|
190
|
+
- **Hook (Tistory)**: Always use `<blockquote data-ke-style="style2">` for the opening topic quote.
|
|
191
|
+
- **Hook (Naver)**: Use plain `<blockquote>` — Naver converts it to a quotation component.
|
|
116
192
|
- **Introduction**: 2~3 paragraphs that set context and build reader empathy before diving into the body.
|
|
117
193
|
- **Body sections**: Each h2 section must have 2~3 substantial paragraphs. Do NOT jump straight to bullet lists.
|
|
118
194
|
- **Lists**: Use sparingly — only for 3+ concrete, scannable items. Default to paragraphs for explanation and analysis.
|
|
@@ -141,6 +217,8 @@ npx viruagent-cli publish \
|
|
|
141
217
|
|
|
142
218
|
## Step 5: Publish
|
|
143
219
|
|
|
220
|
+
### Tistory
|
|
221
|
+
|
|
144
222
|
```bash
|
|
145
223
|
npx viruagent-cli publish \
|
|
146
224
|
--provider tistory \
|
|
@@ -154,12 +232,28 @@ npx viruagent-cli publish \
|
|
|
154
232
|
--minimum-image-count 1
|
|
155
233
|
```
|
|
156
234
|
|
|
157
|
-
|
|
235
|
+
### Naver Blog
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
npx viruagent-cli publish \
|
|
239
|
+
--provider naver \
|
|
240
|
+
--title "Post Title" \
|
|
241
|
+
--content "<h2>...</h2><p>...</p>" \
|
|
242
|
+
--category <id> \
|
|
243
|
+
--tags "tag1,tag2,tag3,tag4,tag5" \
|
|
244
|
+
--visibility public \
|
|
245
|
+
--related-image-keywords "keyword1,keyword2" \
|
|
246
|
+
--image-upload-limit 1 \
|
|
247
|
+
--minimum-image-count 1
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
For drafts, use `save-draft` instead of `publish` (Naver saves as private post).
|
|
158
251
|
|
|
159
252
|
### Image Rules (MUST FOLLOW)
|
|
160
253
|
|
|
161
254
|
- **Always** include `--related-image-keywords` with 2~3 keywords relevant to the post topic
|
|
162
|
-
- **
|
|
255
|
+
- **Tistory**: set `--image-upload-limit 2` and `--minimum-image-count 1`
|
|
256
|
+
- **Naver**: set `--image-upload-limit 1` and `--minimum-image-count 1`
|
|
163
257
|
- Keywords should be in English for better image search results
|
|
164
258
|
- Never use `--no-auto-upload-images` unless the user explicitly asks
|
|
165
259
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { chromium } = require('playwright');
|
|
4
|
+
const { readNaverCredentials, sleep } = require('./utils');
|
|
5
|
+
const { isLoggedInByCookies, persistNaverSession } = require('./session');
|
|
6
|
+
const { NAVER_LOGIN_SELECTORS, NAVER_LOGIN_ERROR_PATTERNS } = require('./selectors');
|
|
7
|
+
|
|
8
|
+
const ANTI_DETECTION_SCRIPT = `
|
|
9
|
+
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
10
|
+
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
|
11
|
+
Object.defineProperty(navigator, 'languages', { get: () => ['ko-KR', 'ko', 'en-US', 'en'] });
|
|
12
|
+
window.chrome = { runtime: {} };
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
const waitForNaverLoginFinish = async (page, context, timeoutMs = 45000) => {
|
|
16
|
+
const deadline = Date.now() + timeoutMs;
|
|
17
|
+
while (Date.now() < deadline) {
|
|
18
|
+
if (await isLoggedInByCookies(context)) return true;
|
|
19
|
+
|
|
20
|
+
const url = page.url();
|
|
21
|
+
if (url.includes('naver.com') && !url.includes('nid.naver.com/nidlogin')) return true;
|
|
22
|
+
|
|
23
|
+
await sleep(1000);
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const checkLoginResult = async (page) => {
|
|
29
|
+
const content = await page.content();
|
|
30
|
+
const patterns = NAVER_LOGIN_ERROR_PATTERNS;
|
|
31
|
+
|
|
32
|
+
if (content.includes(patterns.wrongPassword)) {
|
|
33
|
+
return { success: false, error: 'wrong_password', message: '비밀번호가 잘못되었습니다.' };
|
|
34
|
+
}
|
|
35
|
+
if (content.includes(patterns.accountProtected)) {
|
|
36
|
+
return { success: false, error: 'account_protected', message: '계정 보호조치가 활성화되어 있습니다.' };
|
|
37
|
+
}
|
|
38
|
+
if (content.includes(patterns.regionBlocked)) {
|
|
39
|
+
return { success: false, error: 'region_blocked', message: '허용하지 않은 지역에서 접속이 감지되었습니다.' };
|
|
40
|
+
}
|
|
41
|
+
if (content.includes(patterns.usageRestricted)) {
|
|
42
|
+
return { success: false, error: 'usage_restricted', message: '비정상적인 활동이 감지되어 이용이 제한되었습니다.' };
|
|
43
|
+
}
|
|
44
|
+
if (content.includes(patterns.twoFactor)) {
|
|
45
|
+
return { success: false, error: 'two_factor', message: '2단계 인증이 필요합니다. --manual 모드로 로그인해 주세요.' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 캡차 감지
|
|
49
|
+
const hasCaptcha = patterns.captcha.some((p) => content.toLowerCase().includes(p.toLowerCase()));
|
|
50
|
+
if (hasCaptcha) {
|
|
51
|
+
return { success: false, error: 'captcha', message: '캡차가 감지되었습니다. --manual 또는 --from-chrome 모드를 사용해 주세요.' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 성공 (운영원칙 위반 포함)
|
|
55
|
+
if (content.includes(patterns.operationViolation) || content.includes(patterns.newDevice)) {
|
|
56
|
+
return { success: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const url = page.url();
|
|
60
|
+
if (url.includes('naver.com') && !url.includes('nid.naver.com/nidlogin')) {
|
|
61
|
+
return { success: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { success: false, error: 'unknown', message: '로그인 상태를 확인할 수 없습니다.' };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const createAskForAuthentication = ({ sessionPath, naverApi }) => async ({
|
|
68
|
+
headless = false,
|
|
69
|
+
manual = false,
|
|
70
|
+
username,
|
|
71
|
+
password,
|
|
72
|
+
} = {}) => {
|
|
73
|
+
fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
|
|
74
|
+
|
|
75
|
+
const resolvedUsername = username || readNaverCredentials().username;
|
|
76
|
+
const resolvedPassword = password || readNaverCredentials().password;
|
|
77
|
+
|
|
78
|
+
if (!manual && (!resolvedUsername || !resolvedPassword)) {
|
|
79
|
+
throw new Error('네이버 로그인에 id/pw가 필요합니다. 환경변수 NAVER_USERNAME/NAVER_PASSWORD를 설정하거나 --manual 모드를 사용해 주세요.');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const browser = await chromium.launch({
|
|
83
|
+
headless: manual ? false : headless,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const context = await browser.newContext({
|
|
87
|
+
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
88
|
+
viewport: { width: 1366, height: 768 },
|
|
89
|
+
locale: 'ko-KR',
|
|
90
|
+
timezoneId: 'Asia/Seoul',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await context.addInitScript(ANTI_DETECTION_SCRIPT);
|
|
94
|
+
const page = context.pages()[0] || (await context.newPage());
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
await page.goto('https://nid.naver.com/nidlogin.login', { waitUntil: 'domcontentloaded' });
|
|
98
|
+
await sleep(1500);
|
|
99
|
+
|
|
100
|
+
let loginSuccess = false;
|
|
101
|
+
|
|
102
|
+
if (manual) {
|
|
103
|
+
console.log('');
|
|
104
|
+
console.log('==============================');
|
|
105
|
+
console.log('수동 로그인 모드로 전환합니다.');
|
|
106
|
+
console.log('브라우저에서 직접 네이버 로그인을 완료해 주세요.');
|
|
107
|
+
console.log('최대 5분 내에 로그인을 완료해 주세요.');
|
|
108
|
+
console.log('==============================');
|
|
109
|
+
loginSuccess = await waitForNaverLoginFinish(page, context, 300000);
|
|
110
|
+
} else {
|
|
111
|
+
// JS 인젝션으로 ID/PW 입력 (fill() 대신 — 봇 감지 우회)
|
|
112
|
+
await page.evaluate((id) => {
|
|
113
|
+
const el = document.getElementById('id');
|
|
114
|
+
if (el) el.value = id;
|
|
115
|
+
}, resolvedUsername);
|
|
116
|
+
await sleep(300);
|
|
117
|
+
|
|
118
|
+
await page.evaluate((pw) => {
|
|
119
|
+
const el = document.getElementById('pw');
|
|
120
|
+
if (el) el.value = pw;
|
|
121
|
+
}, resolvedPassword);
|
|
122
|
+
await sleep(300);
|
|
123
|
+
|
|
124
|
+
// 로그인 유지 체크
|
|
125
|
+
const keepCheck = await page.$(NAVER_LOGIN_SELECTORS.keepLogin);
|
|
126
|
+
if (keepCheck) {
|
|
127
|
+
await keepCheck.click().catch(() => {});
|
|
128
|
+
await sleep(300);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 로그인 버튼 클릭
|
|
132
|
+
const loginBtn = await page.$(NAVER_LOGIN_SELECTORS.submit);
|
|
133
|
+
if (loginBtn) {
|
|
134
|
+
await loginBtn.click();
|
|
135
|
+
} else {
|
|
136
|
+
await page.keyboard.press('Enter');
|
|
137
|
+
}
|
|
138
|
+
await sleep(3000);
|
|
139
|
+
|
|
140
|
+
// 결과 확인
|
|
141
|
+
const result = await checkLoginResult(page);
|
|
142
|
+
if (!result.success) {
|
|
143
|
+
throw new Error(result.message);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
loginSuccess = await waitForNaverLoginFinish(page, context, 15000);
|
|
147
|
+
if (!loginSuccess) {
|
|
148
|
+
// URL 기반 추가 확인
|
|
149
|
+
const url = page.url();
|
|
150
|
+
if (url.includes('naver.com') && !url.includes('nid.naver.com/nidlogin')) {
|
|
151
|
+
loginSuccess = true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!loginSuccess) {
|
|
157
|
+
throw new Error('네이버 로그인에 실패했습니다. 아이디/비밀번호를 확인하거나 --manual 모드를 사용해 주세요.');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await persistNaverSession(context, sessionPath);
|
|
161
|
+
|
|
162
|
+
naverApi.resetState();
|
|
163
|
+
const blogId = await naverApi.initBlog();
|
|
164
|
+
return {
|
|
165
|
+
provider: 'naver',
|
|
166
|
+
loggedIn: true,
|
|
167
|
+
blogId,
|
|
168
|
+
blogUrl: `https://blog.naver.com/${blogId}`,
|
|
169
|
+
sessionPath,
|
|
170
|
+
};
|
|
171
|
+
} finally {
|
|
172
|
+
if (browser) {
|
|
173
|
+
await browser.close().catch(() => {});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
module.exports = {
|
|
179
|
+
createAskForAuthentication,
|
|
180
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
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
|
+
};
|