viruagent-cli 0.2.0 → 0.3.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/LICENSE +21 -0
- package/README.ko.md +131 -0
- package/README.md +106 -39
- package/bin/index.js +2 -0
- package/package.json +2 -1
- package/skills/viruagent.md +33 -13
- package/src/providers/tistoryProvider.js +173 -0
- package/src/runner.js +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 greekr4
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.ko.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
|
|
2
|
+
<p align="center">
|
|
3
|
+
<img src="https://github.com/user-attachments/assets/76be6d06-3f0d-44e5-8152-d64b2fc68894" alt="viruagent-cli" />
|
|
4
|
+
</p>
|
|
5
|
+
|
|
6
|
+
<h1 align="center">viruagent-cli</h1>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://github.com/greekr4/viruagent-cli/stargazers"><img src="https://img.shields.io/github/stars/greekr4/viruagent-cli" alt="GitHub stars"></a>
|
|
10
|
+
</p>
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://www.npmjs.com/package/viruagent-cli"><img src="https://img.shields.io/npm/v/viruagent-cli?color=blue" alt="npm version"></a>
|
|
13
|
+
<a href="LICENSE"><img src="https://img.shields.io/npm/l/viruagent-cli" alt="license"></a>
|
|
14
|
+
<a href="https://nodejs.org"><img src="https://img.shields.io/node/v/viruagent-cli" alt="node version"></a>
|
|
15
|
+
</p>
|
|
16
|
+
<p align="center">
|
|
17
|
+
<a href="README.md"><img src="https://img.shields.io/badge/English-blue" alt="English"></a>
|
|
18
|
+
<a href="README.ko.md"><img src="https://img.shields.io/badge/한국어-red" alt="Korean"></a>
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
**AI 에이전트가 블로그를 쓰고, 태그를 만들고, 발행까지 자동으로 처리하는 CLI 도구**
|
|
22
|
+
|
|
23
|
+
사람이 아닌 **AI 에이전트를 위해** 설계되었습니다.
|
|
24
|
+
|
|
25
|
+
## 동작 방식
|
|
26
|
+
|
|
27
|
+
**CLI + 스킬 기반**으로 작동합니다. CLI 자체는 인터페이스만 담당하고, CLI 호출 흐름, 글 양식 등 스킬 파일(`viruagent.md`)이 AI 에이전트에게 지시합니다.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
사용자: "블로그 써줘"
|
|
31
|
+
→ 스킬이 에이전트에게 워크플로우 지시
|
|
32
|
+
→ 에이전트가 CLI를 호출 (로그인, 카테고리 조회, 발행 등)
|
|
33
|
+
|
|
34
|
+
사용자: /viruagent "글 써줘"
|
|
35
|
+
→ 스킬 직접 호출
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- **스킬 파일**: 에이전트가 무엇을, 어떤 순서로 할지 정의 (글 구조, 태그 규칙, 이미지 설정 등)
|
|
39
|
+
- **CLI**: 스킬의 지시를 받아 실제 작업 수행 (로그인, API 호출, 브라우저 자동화)
|
|
40
|
+
- **에이전트**: 스킬을 읽고 CLI를 조합하여 자율적으로 실행
|
|
41
|
+
- **커스텀**: 스킬 파일을 직접 수정하여 원하는 대로 동작하도록 설정 가능
|
|
42
|
+
|
|
43
|
+
## 빠른 시작
|
|
44
|
+
|
|
45
|
+

|
|
46
|
+
|
|
47
|
+
아래 내용을 AI 에이전트에게 그대로 복사해서 보여주세요. (AI 에이전트 대상이므로 영어로 작성되어 있습니다)
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
You are installing viruagent-cli, a blog publishing CLI tool. Follow these steps in order.
|
|
51
|
+
|
|
52
|
+
Step 1: Install skill
|
|
53
|
+
npx viruagent-cli install-skill
|
|
54
|
+
This copies the skill file to ~/.claude/commands/viruagent.md. Confirm the file
|
|
55
|
+
exists before proceeding.
|
|
56
|
+
|
|
57
|
+
Step 2: Verify CLI
|
|
58
|
+
npx viruagent-cli --spec
|
|
59
|
+
If the output contains "ok": true, the CLI is ready. If it fails, check that Node.js
|
|
60
|
+
>= 18 is installed.
|
|
61
|
+
|
|
62
|
+
Tell the user that viruagent-cli installation is complete.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## 로그인
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# Chrome 세션 임포트 (ID/PW 불필요, macOS 전용)
|
|
69
|
+
npx viruagent-cli login --from-chrome
|
|
70
|
+
|
|
71
|
+
# 특정 Chrome 프로필 사용
|
|
72
|
+
npx viruagent-cli login --from-chrome --profile "Profile 2"
|
|
73
|
+
|
|
74
|
+
# 기존 카카오 로그인 (ID/PW 필요)
|
|
75
|
+
npx viruagent-cli login --username <id> --password <pw> --headless
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
> [!TIP]
|
|
79
|
+
> `--from-chrome`은 macOS Keychain을 통해 Chrome 쿠키 DB를 직접 복호화합니다. 브라우저 실행 없이, 2FA 없이, 1초 내 완료됩니다.
|
|
80
|
+
|
|
81
|
+
## 사용법
|
|
82
|
+
|
|
83
|
+
| 이렇게 말하면 | 에이전트가 알아서 |
|
|
84
|
+
|---|---|
|
|
85
|
+
| "블로그 써줘" | 로그인 → 카테고리 → 글 작성 → 태그 → 발행 |
|
|
86
|
+
| "임시저장해줘" | 같은 흐름, 발행 대신 임시저장 |
|
|
87
|
+
| "최근 글 보여줘" | 최근 발행 글 목록 조회 |
|
|
88
|
+
| "카테고리 뭐 있어?" | 카테고리 목록 조회 |
|
|
89
|
+
|
|
90
|
+
자세한 사용법이나 커스터마이징은 에이전트에게 물어보면 안내해줍니다.
|
|
91
|
+
|
|
92
|
+
## 지원 환경
|
|
93
|
+
|
|
94
|
+
| 항목 | 상태 |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| Claude Code, Codex, Cursor 등 | 지원 |
|
|
97
|
+
| bash 실행 가능한 모든 AI 에이전트 | 지원 |
|
|
98
|
+
| Node.js | >= 18 |
|
|
99
|
+
|
|
100
|
+
## 지원 플랫폼
|
|
101
|
+
|
|
102
|
+
| 플랫폼 | 상태 |
|
|
103
|
+
| --- | --- |
|
|
104
|
+
| Tistory | 지원 |
|
|
105
|
+
| Naver Blog | 예정 |
|
|
106
|
+
|
|
107
|
+
## 기술 스택
|
|
108
|
+
|
|
109
|
+
| 영역 | 기술 |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| CLI 프레임워크 | Commander.js |
|
|
112
|
+
| 브라우저 자동화 | Playwright (Chromium) |
|
|
113
|
+
| 쿠키 복호화 | macOS Keychain + AES-128-CBC |
|
|
114
|
+
| 세션 관리 | JSON 파일 (`~/.viruagent-cli/`) |
|
|
115
|
+
| 이미지 검색 | DuckDuckGo, Wikimedia, Commons |
|
|
116
|
+
| 출력 형식 | JSON envelope (`{ ok, data }` / `{ ok, error, hint }`) |
|
|
117
|
+
|
|
118
|
+
## Contributing
|
|
119
|
+
|
|
120
|
+
PR과 피드백을 환영합니다!
|
|
121
|
+
|
|
122
|
+
1. **버그 리포트** — [Issues](https://github.com/greekr4/viruagent-cli/issues)에 올려주세요
|
|
123
|
+
2. **기능 제안** — Issue에 `[Feature Request]` 태그로 제안해주세요
|
|
124
|
+
3. **코드 기여** — Fork → 브랜치 생성 → PR
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
git clone https://github.com/<your-username>/viruagent-cli.git
|
|
128
|
+
git checkout -b feature/my-feature
|
|
129
|
+
git commit -m "[FEAT] Add my feature"
|
|
130
|
+
git push origin feature/my-feature
|
|
131
|
+
```
|
package/README.md
CHANGED
|
@@ -1,64 +1,131 @@
|
|
|
1
|
-
# viruagent-cli
|
|
2
1
|
|
|
3
|
-
|
|
2
|
+
<p align="center">
|
|
3
|
+
<img src="https://github.com/user-attachments/assets/76be6d06-3f0d-44e5-8152-d64b2fc68894" alt="viruagent-cli" />
|
|
4
|
+
</p>
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
<h1 align="center">viruagent-cli</h1>
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://github.com/greekr4/viruagent-cli/stargazers"><img src="https://img.shields.io/github/stars/greekr4/viruagent-cli" alt="GitHub stars"></a>
|
|
10
|
+
</p>
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://www.npmjs.com/package/viruagent-cli"><img src="https://img.shields.io/npm/v/viruagent-cli?color=blue" alt="npm version"></a>
|
|
13
|
+
<a href="LICENSE"><img src="https://img.shields.io/npm/l/viruagent-cli" alt="license"></a>
|
|
14
|
+
<a href="https://nodejs.org"><img src="https://img.shields.io/node/v/viruagent-cli" alt="node version"></a>
|
|
15
|
+
</p>
|
|
16
|
+
<p align="center">
|
|
17
|
+
<a href="README.md"><img src="https://img.shields.io/badge/English-blue" alt="English"></a>
|
|
18
|
+
<a href="README.ko.md"><img src="https://img.shields.io/badge/한국어-red" alt="Korean"></a>
|
|
19
|
+
</p>
|
|
8
20
|
|
|
9
|
-
|
|
21
|
+
**A CLI tool where AI agents write, tag, and publish blog posts automatically.**
|
|
10
22
|
|
|
11
|
-
|
|
23
|
+
Designed not for humans, but for **AI agents**.
|
|
12
24
|
|
|
13
|
-
|
|
25
|
+
## How It Works
|
|
26
|
+
|
|
27
|
+
Operates via **CLI + skill files**. The CLI handles the interface; the skill file (`viruagent.md`) instructs the AI agent on workflow, formatting, and rules.
|
|
14
28
|
|
|
15
29
|
```
|
|
16
|
-
"
|
|
30
|
+
User: "Write a blog post"
|
|
31
|
+
→ Skill instructs the agent on workflow
|
|
32
|
+
→ Agent calls CLI (login, list categories, publish, etc.)
|
|
33
|
+
|
|
34
|
+
User: /viruagent "Write a post"
|
|
35
|
+
→ Direct skill invocation
|
|
17
36
|
```
|
|
18
37
|
|
|
19
|
-
|
|
38
|
+
- **Skill file**: Defines what to do and in what order (post structure, tag rules, image settings)
|
|
39
|
+
- **CLI**: Executes tasks per skill instructions (login, API calls, browser automation)
|
|
40
|
+
- **Agent**: Reads the skill and orchestrates CLI commands autonomously
|
|
41
|
+
- **Custom**: Edit the skill file to customize behavior
|
|
20
42
|
|
|
21
|
-
##
|
|
43
|
+
## Quick Start
|
|
22
44
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
45
|
+

|
|
46
|
+
|
|
47
|
+
Copy the following to your AI agent:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
You are installing viruagent-cli, a blog publishing CLI tool. Follow these steps in order.
|
|
51
|
+
|
|
52
|
+
Step 1: Install skill
|
|
53
|
+
npx viruagent-cli install-skill
|
|
54
|
+
This copies the skill file to ~/.claude/commands/viruagent.md. Confirm the file
|
|
55
|
+
exists before proceeding.
|
|
56
|
+
|
|
57
|
+
Step 2: Verify CLI
|
|
58
|
+
npx viruagent-cli --spec
|
|
59
|
+
If the output contains "ok": true, the CLI is ready. If it fails, check that Node.js
|
|
60
|
+
>= 18 is installed.
|
|
61
|
+
|
|
62
|
+
Tell the user that viruagent-cli installation is complete.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Login
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# Import session from Chrome (no ID/PW needed, macOS only)
|
|
69
|
+
npx viruagent-cli login --from-chrome
|
|
27
70
|
|
|
28
|
-
|
|
71
|
+
# Use a specific Chrome profile
|
|
72
|
+
npx viruagent-cli login --from-chrome --profile "Profile 2"
|
|
73
|
+
|
|
74
|
+
# Traditional Kakao login (ID/PW required)
|
|
75
|
+
npx viruagent-cli login --username <id> --password <pw> --headless
|
|
76
|
+
```
|
|
29
77
|
|
|
30
|
-
|
|
78
|
+
> [!TIP]
|
|
79
|
+
> `--from-chrome` decrypts Chrome's cookie database directly via macOS Keychain. No browser launch, no 2FA — completes in under 1 second.
|
|
80
|
+
|
|
81
|
+
## Usage
|
|
82
|
+
|
|
83
|
+
| Say this | Agent handles |
|
|
31
84
|
|---|---|
|
|
32
|
-
|
|
|
33
|
-
|
|
|
85
|
+
| "Write a blog post" | Login → Categories → Draft → Tags → Publish |
|
|
86
|
+
| "Save as draft" | Same flow, saves as draft instead |
|
|
87
|
+
| "Show recent posts" | Lists recent published posts |
|
|
88
|
+
| "What categories?" | Lists available categories |
|
|
34
89
|
|
|
35
|
-
|
|
90
|
+
Ask the agent for detailed usage or customization help.
|
|
36
91
|
|
|
37
|
-
|
|
38
|
-
|---|---|---|
|
|
39
|
-
| 설정 | 복잡 (서버 실행, 클라이언트 연결) | `npx` 한 줄 |
|
|
40
|
-
| 호환성 | 특정 클라이언트 종속 | bash 실행 가능한 모든 에이전트 |
|
|
41
|
-
| 확장성 | 프로토콜 제약 | 자유로운 파이프라인 |
|
|
92
|
+
## Supported Environments
|
|
42
93
|
|
|
43
|
-
|
|
94
|
+
| Item | Status |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| Claude Code, Codex, Cursor, etc. | Supported |
|
|
97
|
+
| Any AI agent with bash access | Supported |
|
|
98
|
+
| Node.js | >= 18 |
|
|
44
99
|
|
|
45
|
-
|
|
100
|
+
## Supported Platforms
|
|
46
101
|
|
|
47
|
-
|
|
|
48
|
-
|
|
49
|
-
|
|
|
50
|
-
|
|
|
51
|
-
| 태그 수 | `Generate exactly N tags` | 5개 → 10개 |
|
|
52
|
-
| 이미지 | `--image-upload-limit` | 2장 → 3장, 또는 비활성화 |
|
|
53
|
-
| 인용 스타일 | `blockquote data-ke-style` | `style1` (세로선), `style2` (박스), `style3` (큰따옴표) |
|
|
54
|
-
| 소제목 | `Do NOT use <h3>` 규칙 제거 | h2만 → h2+h3 혼용 |
|
|
102
|
+
| Platform | Status |
|
|
103
|
+
| --- | --- |
|
|
104
|
+
| Tistory | Supported |
|
|
105
|
+
| Naver Blog | Planned |
|
|
55
106
|
|
|
56
|
-
|
|
107
|
+
## Tech Stack
|
|
57
108
|
|
|
58
|
-
|
|
109
|
+
| Area | Tech |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| CLI Framework | Commander.js |
|
|
112
|
+
| Browser Automation | Playwright (Chromium) |
|
|
113
|
+
| Cookie Decryption | macOS Keychain + AES-128-CBC |
|
|
114
|
+
| Session Management | JSON file (`~/.viruagent-cli/`) |
|
|
115
|
+
| Image Search | DuckDuckGo, Wikimedia, Commons |
|
|
116
|
+
| Output Format | JSON envelope (`{ ok, data }` / `{ ok, error, hint }`) |
|
|
59
117
|
|
|
60
|
-
|
|
118
|
+
## Contributing
|
|
61
119
|
|
|
62
|
-
|
|
120
|
+
PRs and feedback are welcome!
|
|
63
121
|
|
|
64
|
-
|
|
122
|
+
1. **Bug reports** — [Issues](https://github.com/greekr4/viruagent-cli/issues)
|
|
123
|
+
2. **Feature requests** — Tag with `[Feature Request]`
|
|
124
|
+
3. **Code contributions** — Fork → Branch → PR
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
git clone https://github.com/<your-username>/viruagent-cli.git
|
|
128
|
+
git checkout -b feature/my-feature
|
|
129
|
+
git commit -m "[FEAT] Add my feature"
|
|
130
|
+
git push origin feature/my-feature
|
|
131
|
+
```
|
package/bin/index.js
CHANGED
|
@@ -49,6 +49,8 @@ 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)')
|
|
52
54
|
.action((opts) => execute('login', opts));
|
|
53
55
|
|
|
54
56
|
const publishCmd = program
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viruagent-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "AI-agent-optimized CLI for blog publishing (Tistory, Naver)",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "commonjs",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"skills/",
|
|
31
31
|
"AGENTS.md",
|
|
32
32
|
"README.md",
|
|
33
|
+
"README.ko.md",
|
|
33
34
|
"LICENSE"
|
|
34
35
|
],
|
|
35
36
|
"scripts": {
|
package/skills/viruagent.md
CHANGED
|
@@ -62,47 +62,67 @@ Every post must follow this structure. Write in the same language as the user's
|
|
|
62
62
|
|
|
63
63
|
```html
|
|
64
64
|
<!-- 1. Hook (blockquote style2 for topic quote) -->
|
|
65
|
-
<blockquote data-ke-style="style2">[One impactful sentence
|
|
65
|
+
<blockquote data-ke-style="style2">[One impactful sentence that captures the core insight or tension]</blockquote>
|
|
66
66
|
<p data-ke-size="size16"> </p>
|
|
67
67
|
|
|
68
|
-
<!-- 2. Introduction (
|
|
69
|
-
<p>[
|
|
68
|
+
<!-- 2. Introduction (2~3 paragraphs: context, reader empathy, what this post covers) -->
|
|
69
|
+
<p data-ke-size="size18">[Describe the situation the reader relates to — paint a vivid picture, 3~5 sentences]</p>
|
|
70
|
+
<p data-ke-size="size18">[Set expectations: what angle this post takes, what the reader will gain]</p>
|
|
70
71
|
<p data-ke-size="size16"> </p>
|
|
71
72
|
|
|
72
|
-
<!-- 3. Body (
|
|
73
|
+
<!-- 3. Body (3~4 sections with h2, each section has 2~3 paragraphs) -->
|
|
73
74
|
<!-- Use <p data-ke-size="size16"> </p> between sections for spacing -->
|
|
74
75
|
<h2>[Section 1 Title — keyword-rich]</h2>
|
|
75
|
-
<p>[
|
|
76
|
+
<p data-ke-size="size18">[Explain the concept or situation in 3~5 sentences. Include evidence: expert quotes, data, or real-world examples]</p>
|
|
77
|
+
<p data-ke-size="size18">[Deepen the point with analysis, comparison, or implication. Connect to the reader's experience]</p>
|
|
76
78
|
<ul>
|
|
79
|
+
<li>[Key point — only use lists for 3+ concrete items worth scanning]</li>
|
|
77
80
|
<li>[Key point]</li>
|
|
78
81
|
<li>[Key point]</li>
|
|
79
82
|
</ul>
|
|
83
|
+
<p data-ke-size="size16"> </p>
|
|
80
84
|
|
|
81
85
|
<h2>[Section 2 Title]</h2>
|
|
82
|
-
<p>[
|
|
86
|
+
<p data-ke-size="size18">[Introduce a new angle, case study, or supporting argument. 3~5 sentences with specific details]</p>
|
|
87
|
+
<p data-ke-size="size18">[Analyze why this matters. Use <strong>bold</strong> for key terms. Connect back to the main thesis]</p>
|
|
88
|
+
<p data-ke-size="size16"> </p>
|
|
89
|
+
|
|
90
|
+
<h2>[Section 3 Title]</h2>
|
|
91
|
+
<p data-ke-size="size18">[Practical application or actionable insight. Show, don't just tell]</p>
|
|
92
|
+
<p data-ke-size="size18">[Bridge to the conclusion — "what this all means"]</p>
|
|
93
|
+
<p data-ke-size="size16"> </p>
|
|
83
94
|
|
|
84
95
|
<!-- 4. Summary / Key Takeaways -->
|
|
85
96
|
<h2>핵심 정리</h2>
|
|
86
97
|
<ul>
|
|
87
|
-
<li>[Takeaway 1]</li>
|
|
98
|
+
<li>[Takeaway 1 — one complete sentence, not a fragment]</li>
|
|
88
99
|
<li>[Takeaway 2]</li>
|
|
89
100
|
<li>[Takeaway 3]</li>
|
|
90
101
|
</ul>
|
|
102
|
+
<p data-ke-size="size16"> </p>
|
|
91
103
|
|
|
92
|
-
<!-- 5. Closing (
|
|
93
|
-
<p>[Closing
|
|
104
|
+
<!-- 5. Closing (specific action the reader can take) -->
|
|
105
|
+
<p data-ke-size="size18">[Closing 1~2 sentences — suggest a concrete, immediate action. Not vague "stay tuned" but specific "try this tomorrow"]</p>
|
|
94
106
|
```
|
|
95
107
|
|
|
96
108
|
### Writing Rules
|
|
97
109
|
|
|
98
110
|
- **Title**: Include the primary keyword. 10~20 characters. Short and impactful.
|
|
99
|
-
- **
|
|
111
|
+
- **Length**: 3000~4000 characters (한글 기준). Aim for depth, not padding.
|
|
112
|
+
- **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.
|
|
100
114
|
- **Spacing**: Use `<p data-ke-size="size16"> </p>` between sections for line breaks (Tistory-specific).
|
|
101
115
|
- **Hook**: Always use `<blockquote data-ke-style="style2">` for the opening topic quote.
|
|
102
|
-
- **
|
|
116
|
+
- **Introduction**: 2~3 paragraphs that set context and build reader empathy before diving into the body.
|
|
117
|
+
- **Body sections**: Each h2 section must have 2~3 substantial paragraphs. Do NOT jump straight to bullet lists.
|
|
118
|
+
- **Lists**: Use sparingly — only for 3+ concrete, scannable items. Default to paragraphs for explanation and analysis.
|
|
119
|
+
- **Evidence**: Each body section should include at least one of: expert quote, data point, real company example, or research finding. Cite sources naturally within the text.
|
|
120
|
+
- **Perspective shift**: Include at least one moment that reframes the reader's thinking (e.g., "X is not about A, it's about B").
|
|
121
|
+
- **Transitions**: Connect sections with bridge sentences. Avoid abrupt jumps between topics.
|
|
122
|
+
- **Bold**: Use `<strong>` for key terms and concepts (2~3 per section max).
|
|
103
123
|
- **Subheadings**: Use `<h2>` for ALL section titles. Do NOT use `<h3>`. Keep heading sizes consistent.
|
|
104
|
-
- **Tone**: Conversational but
|
|
105
|
-
- **
|
|
124
|
+
- **Tone**: Conversational but substantive. Write as if explaining to a smart colleague, not listing facts for a report.
|
|
125
|
+
- **Closing**: End with a specific, actionable suggestion the reader can try immediately — not a vague "stay tuned."
|
|
106
126
|
- **SEO**: Primary keyword in title, first paragraph, and at least one `<h2>`. Don't keyword-stuff.
|
|
107
127
|
|
|
108
128
|
```bash
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { chromium } = require('playwright');
|
|
2
|
+
const { execSync } = require('child_process');
|
|
2
3
|
const fs = require('fs');
|
|
3
4
|
const os = require('os');
|
|
4
5
|
const crypto = require('crypto');
|
|
@@ -1733,6 +1734,160 @@ const persistTistorySession = async (context, targetSessionPath) => {
|
|
|
1733
1734
|
);
|
|
1734
1735
|
};
|
|
1735
1736
|
|
|
1737
|
+
const decryptChromeCookie = (encryptedValue, derivedKey) => {
|
|
1738
|
+
if (!encryptedValue || encryptedValue.length < 4) return '';
|
|
1739
|
+
const prefix = encryptedValue.slice(0, 3).toString('ascii');
|
|
1740
|
+
if (prefix !== 'v10') return encryptedValue.toString('utf-8');
|
|
1741
|
+
|
|
1742
|
+
const encrypted = encryptedValue.slice(3);
|
|
1743
|
+
const iv = Buffer.alloc(16, 0x20);
|
|
1744
|
+
const decipher = crypto.createDecipheriv('aes-128-cbc', derivedKey, iv);
|
|
1745
|
+
decipher.setAutoPadding(true);
|
|
1746
|
+
try {
|
|
1747
|
+
const dec = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
1748
|
+
// CBC 첫 블록은 IV 불일치로 깨짐 → 끝에서부터 printable ASCII 범위 추출
|
|
1749
|
+
let start = dec.length;
|
|
1750
|
+
for (let i = dec.length - 1; i >= 0; i--) {
|
|
1751
|
+
if (dec[i] >= 0x20 && dec[i] <= 0x7e) { start = i; }
|
|
1752
|
+
else { break; }
|
|
1753
|
+
}
|
|
1754
|
+
return start < dec.length ? dec.slice(start).toString('utf-8') : '';
|
|
1755
|
+
} catch {
|
|
1756
|
+
return '';
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1759
|
+
|
|
1760
|
+
const extractChromeCookies = (cookiesDb, derivedKey, domainPattern) => {
|
|
1761
|
+
const tempDb = path.join(os.tmpdir(), `viruagent-cookies-${Date.now()}.db`);
|
|
1762
|
+
try {
|
|
1763
|
+
execSync(`sqlite3 "${cookiesDb}" ".backup '${tempDb}'"`, { timeout: 5000 });
|
|
1764
|
+
} catch {
|
|
1765
|
+
throw new Error('Chrome 쿠키 DB 복사에 실패했습니다. Chrome이 실행 중이면 잠시 후 다시 시도해 주세요.');
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
try {
|
|
1769
|
+
const rows = execSync(
|
|
1770
|
+
`sqlite3 -separator '||' "${tempDb}" "SELECT host_key, name, value, hex(encrypted_value), path, expires_utc, is_secure, is_httponly, samesite FROM cookies WHERE host_key LIKE '${domainPattern}'"`,
|
|
1771
|
+
{ encoding: 'utf-8', timeout: 5000 }
|
|
1772
|
+
).trim();
|
|
1773
|
+
if (!rows) return [];
|
|
1774
|
+
|
|
1775
|
+
const chromeEpochOffset = 11644473600;
|
|
1776
|
+
const sameSiteMap = { '-1': 'None', '0': 'None', '1': 'Lax', '2': 'Strict' };
|
|
1777
|
+
return rows.split('\n').map(row => {
|
|
1778
|
+
const [domain, name, plainValue, encHex, cookiePath, expiresUtc, isSecure, isHttpOnly, sameSite] = row.split('||');
|
|
1779
|
+
let value = plainValue || '';
|
|
1780
|
+
if (!value && encHex) {
|
|
1781
|
+
value = decryptChromeCookie(Buffer.from(encHex, 'hex'), derivedKey);
|
|
1782
|
+
}
|
|
1783
|
+
if (value && !/^[\x20-\x7E]*$/.test(value)) value = '';
|
|
1784
|
+
const expires = expiresUtc === '0' ? -1 : Math.floor(Number(expiresUtc) / 1000000) - chromeEpochOffset;
|
|
1785
|
+
return { name, value, domain, path: cookiePath || '/', expires, httpOnly: isHttpOnly === '1', secure: isSecure === '1', sameSite: sameSiteMap[sameSite] || 'None' };
|
|
1786
|
+
}).filter(c => c.value);
|
|
1787
|
+
} finally {
|
|
1788
|
+
try { fs.unlinkSync(tempDb); } catch {}
|
|
1789
|
+
}
|
|
1790
|
+
};
|
|
1791
|
+
|
|
1792
|
+
const importSessionFromChrome = async (targetSessionPath, profileName = 'Default') => {
|
|
1793
|
+
const chromeRoot = path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome');
|
|
1794
|
+
if (!fs.existsSync(chromeRoot)) {
|
|
1795
|
+
throw new Error('Chrome이 설치되어 있지 않습니다.');
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
const profileDir = path.join(chromeRoot, profileName);
|
|
1799
|
+
const cookiesDb = path.join(profileDir, 'Cookies');
|
|
1800
|
+
if (!fs.existsSync(cookiesDb)) {
|
|
1801
|
+
throw new Error(`Chrome 프로필 "${profileName}"에 쿠키 DB가 없습니다.`);
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// 1) Keychain에서 Chrome 암호화 키 추출
|
|
1805
|
+
let keychainPassword;
|
|
1806
|
+
try {
|
|
1807
|
+
keychainPassword = execSync(
|
|
1808
|
+
'security find-generic-password -s "Chrome Safe Storage" -w',
|
|
1809
|
+
{ encoding: 'utf-8', timeout: 5000 }
|
|
1810
|
+
).trim();
|
|
1811
|
+
} catch {
|
|
1812
|
+
throw new Error('Chrome Safe Storage 키를 Keychain에서 읽을 수 없습니다. macOS 권한을 확인해 주세요.');
|
|
1813
|
+
}
|
|
1814
|
+
const derivedKey = crypto.pbkdf2Sync(keychainPassword, 'saltysalt', 1003, 16, 'sha1');
|
|
1815
|
+
|
|
1816
|
+
// 2) Chrome에서 tistory + kakao 쿠키 복호화 추출
|
|
1817
|
+
const tistoryCookies = extractChromeCookies(cookiesDb, derivedKey, '%tistory.com');
|
|
1818
|
+
const kakaoCookies = extractChromeCookies(cookiesDb, derivedKey, '%kakao.com');
|
|
1819
|
+
|
|
1820
|
+
// 이미 TSSESSION 있으면 바로 저장
|
|
1821
|
+
const existingSession = tistoryCookies.some(c => c.name === 'TSSESSION' && c.value);
|
|
1822
|
+
if (existingSession) {
|
|
1823
|
+
const payload = { cookies: tistoryCookies, updatedAt: new Date().toISOString() };
|
|
1824
|
+
await fs.promises.mkdir(path.dirname(targetSessionPath), { recursive: true });
|
|
1825
|
+
await fs.promises.writeFile(targetSessionPath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
1826
|
+
return { cookieCount: tistoryCookies.length };
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// 3) 카카오 세션 쿠키가 있으면 Playwright에 주입 후 자동 로그인
|
|
1830
|
+
const hasKakaoSession = kakaoCookies.some(c => c.domain.includes('kakao.com') && (c.name === '_kawlt' || c.name === '_kawltea' || c.name === '_karmt'));
|
|
1831
|
+
if (!hasKakaoSession) {
|
|
1832
|
+
throw new Error('Chrome에 카카오 로그인 세션이 없습니다. Chrome에서 먼저 카카오 계정에 로그인해 주세요.');
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
const browser = await chromium.launch({ headless: true });
|
|
1836
|
+
const context = await browser.newContext();
|
|
1837
|
+
try {
|
|
1838
|
+
// Playwright 형식으로 변환하여 쿠키 주입
|
|
1839
|
+
const allCookies = [...tistoryCookies, ...kakaoCookies].map(c => ({
|
|
1840
|
+
...c,
|
|
1841
|
+
domain: c.domain.startsWith('.') ? c.domain : c.domain,
|
|
1842
|
+
expires: c.expires > 0 ? c.expires : undefined,
|
|
1843
|
+
}));
|
|
1844
|
+
await context.addCookies(allCookies);
|
|
1845
|
+
|
|
1846
|
+
const page = await context.newPage();
|
|
1847
|
+
await page.goto('https://www.tistory.com/auth/login', { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
1848
|
+
await page.waitForTimeout(1000);
|
|
1849
|
+
|
|
1850
|
+
// 카카오 로그인 버튼 클릭
|
|
1851
|
+
const kakaoBtn = await pickValue(page, KAKAO_TRIGGER_SELECTORS);
|
|
1852
|
+
if (kakaoBtn) {
|
|
1853
|
+
await page.locator(kakaoBtn).click({ timeout: 5000 }).catch(() => {});
|
|
1854
|
+
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// 카카오 계정 확인 → 계속하기 클릭
|
|
1858
|
+
await page.waitForTimeout(2000);
|
|
1859
|
+
const confirmBtn = await pickValue(page, [
|
|
1860
|
+
...KAKAO_ACCOUNT_CONFIRM_SELECTORS.continue,
|
|
1861
|
+
'button[type="submit"]',
|
|
1862
|
+
]);
|
|
1863
|
+
if (confirmBtn) {
|
|
1864
|
+
await page.locator(confirmBtn).click({ timeout: 3000 }).catch(() => {});
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
// TSSESSION 대기 (최대 15초)
|
|
1868
|
+
let hasSession = false;
|
|
1869
|
+
const maxWait = 15000;
|
|
1870
|
+
const startTime = Date.now();
|
|
1871
|
+
while (Date.now() - startTime < maxWait) {
|
|
1872
|
+
await page.waitForTimeout(1000);
|
|
1873
|
+
const cookies = await context.cookies('https://www.tistory.com');
|
|
1874
|
+
hasSession = cookies.some(c => c.name === 'TSSESSION' && c.value);
|
|
1875
|
+
if (hasSession) break;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
if (!hasSession) {
|
|
1879
|
+
throw new Error('Chrome 카카오 세션으로 티스토리 자동 로그인에 실패했습니다.');
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
await persistTistorySession(context, targetSessionPath);
|
|
1883
|
+
const finalCookies = await context.cookies('https://www.tistory.com');
|
|
1884
|
+
return { cookieCount: finalCookies.filter(c => String(c.domain).includes('tistory')).length };
|
|
1885
|
+
} finally {
|
|
1886
|
+
await context.close().catch(() => {});
|
|
1887
|
+
await browser.close().catch(() => {});
|
|
1888
|
+
}
|
|
1889
|
+
};
|
|
1890
|
+
|
|
1736
1891
|
const createTistoryProvider = ({ sessionPath }) => {
|
|
1737
1892
|
const tistoryApi = createTistoryApiClient({ sessionPath });
|
|
1738
1893
|
|
|
@@ -1920,7 +2075,25 @@ const askForAuthentication = async ({
|
|
|
1920
2075
|
username,
|
|
1921
2076
|
password,
|
|
1922
2077
|
twoFactorCode,
|
|
2078
|
+
fromChrome,
|
|
2079
|
+
profile,
|
|
1923
2080
|
} = {}) {
|
|
2081
|
+
if (fromChrome) {
|
|
2082
|
+
await importSessionFromChrome(sessionPath, profile || 'Default');
|
|
2083
|
+
tistoryApi.resetState();
|
|
2084
|
+
const blogName = await tistoryApi.initBlog();
|
|
2085
|
+
const result = {
|
|
2086
|
+
provider: 'tistory',
|
|
2087
|
+
loggedIn: true,
|
|
2088
|
+
blogName,
|
|
2089
|
+
blogUrl: `https://${blogName}.tistory.com`,
|
|
2090
|
+
sessionPath,
|
|
2091
|
+
source: 'chrome-import',
|
|
2092
|
+
};
|
|
2093
|
+
saveProviderMeta('tistory', { loggedIn: true, blogName, blogUrl: result.blogUrl, sessionPath });
|
|
2094
|
+
return result;
|
|
2095
|
+
}
|
|
2096
|
+
|
|
1924
2097
|
const creds = readCredentialsFromEnv();
|
|
1925
2098
|
const resolved = {
|
|
1926
2099
|
headless,
|
package/src/runner.js
CHANGED
|
@@ -97,6 +97,8 @@ 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,
|
|
100
102
|
})
|
|
101
103
|
)();
|
|
102
104
|
|