viruagent-cli 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/skills/persona-blogger/SKILL.md +47 -0
  3. package/skills/persona-influencer-manager/SKILL.md +40 -0
  4. package/skills/persona-sns-marketer/SKILL.md +38 -0
  5. package/skills/recipe-blog-publish/SKILL.md +59 -0
  6. package/skills/recipe-cross-post/SKILL.md +41 -0
  7. package/skills/recipe-daily-engagement/SKILL.md +57 -0
  8. package/skills/recipe-engage-feed/SKILL.md +56 -0
  9. package/skills/recipe-grow-followers/SKILL.md +52 -0
  10. package/skills/va-insta/SKILL.md +64 -0
  11. package/skills/va-insta-comment/SKILL.md +59 -0
  12. package/skills/va-insta-dm/SKILL.md +51 -0
  13. package/skills/va-insta-feed/SKILL.md +72 -0
  14. package/skills/va-insta-follow/SKILL.md +42 -0
  15. package/skills/va-insta-like/SKILL.md +55 -0
  16. package/skills/va-insta-login/SKILL.md +57 -0
  17. package/skills/va-naver/SKILL.md +43 -0
  18. package/skills/va-naver-categories/SKILL.md +25 -0
  19. package/skills/va-naver-draft/SKILL.md +33 -0
  20. package/skills/va-naver-login/SKILL.md +58 -0
  21. package/skills/va-naver-posts/SKILL.md +38 -0
  22. package/skills/va-naver-publish/SKILL.md +337 -0
  23. package/skills/va-shared/SKILL.md +170 -0
  24. package/skills/va-tistory/SKILL.md +42 -0
  25. package/skills/va-tistory-categories/SKILL.md +37 -0
  26. package/skills/va-tistory-draft/SKILL.md +31 -0
  27. package/skills/va-tistory-login/SKILL.md +54 -0
  28. package/skills/va-tistory-posts/SKILL.md +38 -0
  29. package/skills/va-tistory-publish/SKILL.md +466 -0
  30. package/src/providers/chromeManager.js +186 -0
  31. package/src/providers/insta/index.js +31 -10
  32. package/src/providers/insta/session.js +4 -7
  33. package/src/providers/naver/auth.js +37 -23
  34. package/src/providers/naver/index.js +6 -10
  35. package/src/providers/naver/session.js +22 -28
  36. package/src/providers/tistory/auth.js +129 -105
  37. package/src/providers/tistory/index.js +6 -6
  38. package/src/providers/tistory/session.js +24 -24
  39. package/src/runner.js +28 -17
  40. package/src/services/providerManager.js +7 -5
  41. package/src/storage/sessionStore.js +18 -9
  42. package/skills/viruagent-insta.md +0 -163
  43. package/skills/viruagent-naver.md +0 -122
  44. package/skills/viruagent-tistory.md +0 -117
  45. package/skills/viruagent.md +0 -77
@@ -15,7 +15,7 @@ const { enrichContentWithUploadedImages, resolveMandatoryThumbnail } = require('
15
15
  const { createWithProviderSession } = require('./session');
16
16
  const { createAskForAuthentication } = require('./auth');
17
17
 
18
- const createTistoryProvider = ({ sessionPath }) => {
18
+ const createTistoryProvider = ({ sessionPath, account }) => {
19
19
  const tistoryApi = createTistoryApiClient({ sessionPath });
20
20
 
21
21
  const pending2faResult = (mode = 'kakao') => ({
@@ -33,7 +33,7 @@ const createTistoryProvider = ({ sessionPath }) => {
33
33
  pending2faResult,
34
34
  });
35
35
 
36
- const withProviderSession = createWithProviderSession(askForAuthentication);
36
+ const withProviderSession = createWithProviderSession(askForAuthentication, account);
37
37
 
38
38
  return {
39
39
  id: 'tistory',
@@ -49,7 +49,7 @@ const createTistoryProvider = ({ sessionPath }) => {
49
49
  blogName,
50
50
  blogUrl: `https://${blogName}.tistory.com`,
51
51
  sessionPath,
52
- metadata: getProviderMeta('tistory') || {},
52
+ metadata: getProviderMeta('tistory', account) || {},
53
53
  };
54
54
  } catch (error) {
55
55
  return {
@@ -57,7 +57,7 @@ const createTistoryProvider = ({ sessionPath }) => {
57
57
  loggedIn: false,
58
58
  sessionPath,
59
59
  error: error.message,
60
- metadata: getProviderMeta('tistory') || {},
60
+ metadata: getProviderMeta('tistory', account) || {},
61
61
  };
62
62
  }
63
63
  });
@@ -89,7 +89,7 @@ const createTistoryProvider = ({ sessionPath }) => {
89
89
  blogName: result.blogName,
90
90
  blogUrl: result.blogUrl,
91
91
  sessionPath: result.sessionPath,
92
- });
92
+ }, account);
93
93
  return result;
94
94
  },
95
95
 
@@ -529,7 +529,7 @@ const createTistoryProvider = ({ sessionPath }) => {
529
529
  },
530
530
 
531
531
  async logout() {
532
- clearProviderMeta('tistory');
532
+ clearProviderMeta('tistory', account);
533
533
  return {
534
534
  provider: 'tistory',
535
535
  loggedOut: true,
@@ -3,19 +3,27 @@ const path = require('path');
3
3
  const { saveProviderMeta } = require('../../storage/sessionStore');
4
4
  const { sleep, readCredentialsFromEnv, parseSessionError, buildLoginErrorMessage } = require('./utils');
5
5
  const { clickKakaoAccountContinue } = require('./browserHelpers');
6
+ const { extractAllCookies, filterCookies, cookiesToSessionFormat } = require('../chromeManager');
6
7
 
7
- const isLoggedInByCookies = async (context) => {
8
- const cookies = await context.cookies('https://www.tistory.com');
9
- return cookies.some((cookie) => {
10
- const name = cookie.name.toLowerCase();
11
- return name.includes('tistory') || name.includes('access') || name.includes('login');
12
- });
8
+ const isLoggedInByCookies = async (context, page) => {
9
+ try {
10
+ // Use CDP to get all cookies including httpOnly (TSSESSION)
11
+ const all = await extractAllCookies(context, page);
12
+ return all.some((c) => c.domain.includes('tistory') && c.name === 'TSSESSION');
13
+ } catch {
14
+ // Fallback to context.cookies if CDP fails
15
+ const cookies = await context.cookies('https://www.tistory.com');
16
+ return cookies.some((cookie) => {
17
+ const name = cookie.name.toLowerCase();
18
+ return name.includes('tistory') || name.includes('access') || name.includes('login');
19
+ });
20
+ }
13
21
  };
14
22
 
15
23
  const waitForLoginFinish = async (page, context, timeoutMs = 45000) => {
16
24
  const deadline = Date.now() + timeoutMs;
17
25
  while (Date.now() < deadline) {
18
- if (await isLoggedInByCookies(context)) {
26
+ if (await isLoggedInByCookies(context, page)) {
19
27
  return true;
20
28
  }
21
29
 
@@ -33,16 +41,11 @@ const waitForLoginFinish = async (page, context, timeoutMs = 45000) => {
33
41
  return false;
34
42
  };
35
43
 
36
- const persistTistorySession = async (context, targetSessionPath) => {
37
- const cookies = await context.cookies('https://www.tistory.com');
38
- const sanitized = cookies.map((cookie) => ({
39
- ...cookie,
40
- expires: Number(cookie.expires || -1),
41
- size: undefined,
42
- partitionKey: undefined,
43
- sourcePort: undefined,
44
- sourceScheme: undefined,
45
- }));
44
+ const persistTistorySession = async (context, page, targetSessionPath) => {
45
+ // Use CDP Network.getAllCookies to capture httpOnly cookies (e.g. TSSESSION)
46
+ const all = await extractAllCookies(context, page);
47
+ const tistoryCookies = filterCookies(all, ['tistory.com']);
48
+ const sanitized = cookiesToSessionFormat(tistoryCookies);
46
49
 
47
50
  const payload = {
48
51
  cookies: sanitized,
@@ -60,16 +63,13 @@ const persistTistorySession = async (context, targetSessionPath) => {
60
63
  * withProviderSession factory.
61
64
  * Receives askForAuthentication via dependency injection to avoid scope issues.
62
65
  */
63
- const createWithProviderSession = (askForAuthentication) => async (fn) => {
66
+ const createWithProviderSession = (askForAuthentication, account) => async (fn) => {
64
67
  const credentials = readCredentialsFromEnv();
65
68
  const hasCredentials = Boolean(credentials.username && credentials.password);
66
69
 
67
70
  try {
68
71
  const result = await fn();
69
- saveProviderMeta('tistory', {
70
- loggedIn: true,
71
- lastValidatedAt: new Date().toISOString(),
72
- });
72
+ saveProviderMeta('tistory', { loggedIn: true, lastValidatedAt: new Date().toISOString() }, account);
73
73
  return result;
74
74
  } catch (error) {
75
75
  if (!parseSessionError(error) || !hasCredentials) {
@@ -91,7 +91,7 @@ const createWithProviderSession = (askForAuthentication) => async (fn) => {
91
91
  sessionPath: loginResult.sessionPath,
92
92
  lastRefreshedAt: new Date().toISOString(),
93
93
  lastError: null,
94
- });
94
+ }, account);
95
95
 
96
96
  if (!loginResult.loggedIn) {
97
97
  throw new Error(loginResult.message || 'Login status could not be confirmed after session refresh.');
@@ -103,7 +103,7 @@ const createWithProviderSession = (askForAuthentication) => async (fn) => {
103
103
  loggedIn: false,
104
104
  lastError: buildLoginErrorMessage(reloginError),
105
105
  lastValidatedAt: new Date().toISOString(),
106
- });
106
+ }, account);
107
107
  throw reloginError;
108
108
  }
109
109
  }
package/src/runner.js CHANGED
@@ -47,26 +47,36 @@ const runCommand = async (command, opts = {}) => {
47
47
 
48
48
  if (command === 'install-skill') {
49
49
  const skillsDir = path.resolve(__dirname, '..', 'skills');
50
- const skillFiles = ['viruagent.md', 'viruagent-tistory.md', 'viruagent-naver.md', 'viruagent-insta.md'];
51
-
52
50
  const targetDir = opts.target
53
51
  || path.join(os.homedir(), '.claude', 'commands');
54
- fs.mkdirSync(targetDir, { recursive: true });
55
-
56
- const installed = [];
57
- for (const file of skillFiles) {
58
- const src = path.join(skillsDir, file);
59
- if (!fs.existsSync(src)) continue;
60
- const dest = path.join(targetDir, file);
61
- fs.copyFileSync(src, dest);
62
- installed.push(dest);
63
- }
64
52
 
65
- if (installed.length === 0) {
66
- throw createError('FILE_NOT_FOUND', 'Skill files not found in package');
53
+ // Install only the main router skill as /viruagent
54
+ const routerSrc = path.join(skillsDir, 'va-shared', 'SKILL.md');
55
+ if (!fs.existsSync(routerSrc)) {
56
+ throw createError('FILE_NOT_FOUND', 'Router skill (va-shared/SKILL.md) not found');
67
57
  }
68
58
 
69
- return { installed: true, paths: installed, count: installed.length };
59
+ const destDir = path.join(targetDir, 'viruagent');
60
+ fs.mkdirSync(destDir, { recursive: true });
61
+ const dest = path.join(destDir, 'SKILL.md');
62
+ fs.copyFileSync(routerSrc, dest);
63
+
64
+ // Inject actual skills directory path into the installed SKILL.md
65
+ const skillsAbsPath = skillsDir;
66
+ let content = fs.readFileSync(dest, 'utf-8');
67
+ content = content.replace(
68
+ 'SKILLS_DIR: <viruagent-cli 설치 경로>/skills/',
69
+ `SKILLS_DIR: ${skillsAbsPath}/`
70
+ );
71
+ fs.writeFileSync(dest, content, 'utf-8');
72
+
73
+ return {
74
+ installed: true,
75
+ paths: [dest],
76
+ count: 1,
77
+ skillsDir: skillsAbsPath,
78
+ note: 'Only /viruagent is registered as a slash command. Sub-skills are loaded on demand from ' + skillsAbsPath,
79
+ };
70
80
  }
71
81
 
72
82
  const manager = createProviderManager();
@@ -76,9 +86,10 @@ const runCommand = async (command, opts = {}) => {
76
86
  }
77
87
 
78
88
  const providerName = opts.provider || 'tistory';
89
+ const account = opts.account || undefined;
79
90
  let provider;
80
91
  try {
81
- provider = manager.getProvider(providerName);
92
+ provider = manager.getProvider(providerName, account);
82
93
  } catch {
83
94
  throw createError(
84
95
  'PROVIDER_NOT_FOUND',
@@ -110,7 +121,7 @@ const runCommand = async (command, opts = {}) => {
110
121
 
111
122
  case 'publish': {
112
123
  const content = readContent(opts);
113
- if (!content) {
124
+ if (!content && providerName !== 'insta') {
114
125
  throw createError('MISSING_CONTENT', 'publish requires --content or --content-file', 'viruagent-cli publish --spec');
115
126
  }
116
127
  return withProvider(() =>
@@ -15,23 +15,25 @@ const providers = ['tistory', 'naver', 'insta'];
15
15
  const createProviderManager = () => {
16
16
  const cache = new Map();
17
17
 
18
- const getProvider = (provider = 'tistory') => {
18
+ const getProvider = (provider = 'tistory', account) => {
19
19
  const normalized = String(provider || 'tistory').toLowerCase();
20
20
  if (!providerFactory[normalized]) {
21
21
  throw new Error(`Unsupported provider: ${provider}. Available options: ${providers.join(', ')}`);
22
22
  }
23
23
 
24
- if (!cache.has(normalized)) {
25
- const sessionPath = getSessionPath(normalized);
24
+ const cacheKey = account ? `${normalized}:${String(account).toLowerCase()}` : normalized;
25
+ if (!cache.has(cacheKey)) {
26
+ const sessionPath = getSessionPath(normalized, account);
26
27
  const options = {
27
28
  provider: normalized,
28
29
  sessionPath,
30
+ account: account || undefined,
29
31
  };
30
32
  const providerInstance = providerFactory[normalized](options);
31
- cache.set(normalized, providerInstance);
33
+ cache.set(cacheKey, providerInstance);
32
34
  }
33
35
 
34
- return cache.get(normalized);
36
+ return cache.get(cacheKey);
35
37
  };
36
38
 
37
39
  const providerNames = { tistory: 'Tistory', naver: 'Naver Blog', insta: 'Instagram' };
@@ -27,9 +27,11 @@ const writeJson = (target, data) => {
27
27
  fs.writeFileSync(target, JSON.stringify(data, null, 2), 'utf-8');
28
28
  };
29
29
 
30
- const getSessionPath = (provider) => {
30
+ const getSessionPath = (provider, account) => {
31
31
  ensureDir(SESSION_DIR);
32
- return path.join(SESSION_DIR, `${normalizeProvider(provider)}-session.json`);
32
+ const base = normalizeProvider(provider);
33
+ const suffix = account ? `-${String(account).toLowerCase().replace(/[^a-z0-9._-]/g, '_')}` : '';
34
+ return path.join(SESSION_DIR, `${base}${suffix}-session.json`);
33
35
  };
34
36
 
35
37
  const getProvidersMeta = () => {
@@ -37,25 +39,32 @@ const getProvidersMeta = () => {
37
39
  return readJson(META_FILE);
38
40
  };
39
41
 
40
- const saveProviderMeta = (provider, patch) => {
42
+ const metaKey = (provider, account) => {
43
+ const base = normalizeProvider(provider);
44
+ return account ? `${base}:${String(account).toLowerCase()}` : base;
45
+ };
46
+
47
+ const saveProviderMeta = (provider, patch, account) => {
41
48
  const meta = getProvidersMeta();
42
- meta[normalizeProvider(provider)] = {
43
- ...(meta[normalizeProvider(provider)] || {}),
49
+ const key = metaKey(provider, account);
50
+ meta[key] = {
51
+ ...(meta[key] || {}),
44
52
  ...patch,
45
53
  provider: normalizeProvider(provider),
54
+ ...(account ? { account: String(account).toLowerCase() } : {}),
46
55
  updatedAt: new Date().toISOString(),
47
56
  };
48
57
  writeJson(META_FILE, meta);
49
58
  };
50
59
 
51
- const getProviderMeta = (provider) => {
60
+ const getProviderMeta = (provider, account) => {
52
61
  const meta = getProvidersMeta();
53
- return meta[normalizeProvider(provider)] || null;
62
+ return meta[metaKey(provider, account)] || null;
54
63
  };
55
64
 
56
- const clearProviderMeta = (provider) => {
65
+ const clearProviderMeta = (provider, account) => {
57
66
  const meta = getProvidersMeta();
58
- delete meta[normalizeProvider(provider)];
67
+ delete meta[metaKey(provider, account)];
59
68
  writeJson(META_FILE, meta);
60
69
  };
61
70
 
@@ -1,163 +0,0 @@
1
- ---
2
- name: viruagent-insta
3
- description: Instagram automation via viruagent-cli — like, comment, follow, post, analyze feed. Handles rate limits and bot detection avoidance.
4
- triggers:
5
- - 인스타
6
- - 인스타그램
7
- - instagram
8
- - insta
9
- - 좋아요
10
- - 댓글 달아
11
- - 팔로우
12
- - 언팔로우
13
- - 피드
14
- - like
15
- - comment
16
- - follow
17
- - unfollow
18
- - feed
19
- ---
20
-
21
- # viruagent-insta — Instagram Automation Skill
22
-
23
- You are an Instagram automation agent using `viruagent-cli`. Always use `npx viruagent-cli` with `--provider insta`.
24
-
25
- ## Step 1: Check authentication
26
-
27
- ```bash
28
- npx viruagent-cli status --provider insta
29
- ```
30
-
31
- If not logged in:
32
-
33
- ```bash
34
- npx viruagent-cli login --provider insta --username <user> --password <pass>
35
- ```
36
-
37
- Environment variables `INSTA_USERNAME` / `INSTA_PASSWORD` also work.
38
-
39
- ## Step 2: Available commands
40
-
41
- ### Profile & Feed
42
-
43
- ```bash
44
- # Get user profile
45
- npx viruagent-cli get-profile --provider insta --username <username>
46
-
47
- # Get your feed timeline
48
- npx viruagent-cli get-feed --provider insta
49
-
50
- # List user's posts (with pagination)
51
- npx viruagent-cli list-posts --provider insta --username <username> --limit 20
52
- ```
53
-
54
- ### Engagement (auto rate-limit delays applied)
55
-
56
- ```bash
57
- # Like a post
58
- npx viruagent-cli like --provider insta --post-id <shortcode>
59
-
60
- # Comment on a post
61
- npx viruagent-cli comment --provider insta --post-id <shortcode> --text "comment text"
62
-
63
- # Follow / Unfollow
64
- npx viruagent-cli follow --provider insta --username <username>
65
- npx viruagent-cli unfollow --provider insta --username <username>
66
-
67
- # Like / Unlike a comment
68
- npx viruagent-cli like-comment --provider insta --comment-id <id>
69
- npx viruagent-cli unlike-comment --provider insta --comment-id <id>
70
- ```
71
-
72
- ### Analyze & Smart Comment
73
-
74
- ```bash
75
- # Analyze post (returns caption + thumbnail base64 + profile)
76
- npx viruagent-cli analyze-post --provider insta --post-id <shortcode>
77
- ```
78
-
79
- Use `analyze-post` to get the thumbnail image, then visually analyze it to write contextual comments.
80
-
81
- ### Publish
82
-
83
- ```bash
84
- # Publish an image post (provide imageUrl or imagePath in code)
85
- # CLI does not have a direct publish command — use the Node.js API:
86
- ```
87
-
88
- ```javascript
89
- const insta = require('viruagent-cli/src/services/providerManager').createProviderManager().getProvider('insta');
90
- await insta.publish({ imageUrl: 'https://...', caption: 'My post' });
91
- ```
92
-
93
- ### Rate Limit
94
-
95
- ```bash
96
- npx viruagent-cli rate-limit-status --provider insta
97
- ```
98
-
99
- ## Workflows
100
-
101
- ### "Like all posts from @user"
102
-
103
- 1. `list-posts --username <user> --limit 20`
104
- 2. For each post: `like --post-id <shortcode>`
105
- 3. Rate limit delays are automatic (20~40s between likes)
106
-
107
- ### "Comment on all @user's posts"
108
-
109
- 1. `list-posts --username <user> --limit 20`
110
- 2. For each post:
111
- a. Check if already commented (use `analyze-post` to see existing comments)
112
- b. `analyze-post --post-id <shortcode>` — read thumbnail + caption
113
- c. Visually analyze the thumbnail to understand the content
114
- d. Write a contextual, natural comment (1~2 sentences, 1~2 emoji max)
115
- e. `comment --post-id <shortcode> --text "..."`
116
- 3. Rate limit delays are automatic (5~7min between comments)
117
-
118
- ### "Follow @user and engage with their feed"
119
-
120
- 1. `follow --username <user>`
121
- 2. `list-posts --username <user> --limit 20`
122
- 3. Like + comment each post (as above)
123
-
124
- ### Comment Writing Rules
125
-
126
- - Write in the same language as the post caption
127
- - Be specific to the content — reference what's in the image/caption
128
- - 1~2 sentences max, 1~2 emoji
129
- - No hashtags in comments
130
- - No generic phrases like "Nice post!" or "Great content!"
131
- - Vary tone and style across comments — don't repeat patterns
132
- - If the post is a video (릴스), analyze the thumbnail + caption to understand context
133
-
134
- ## Rate Limit Safety (New Account)
135
-
136
- | Action | Delay | Hourly | Daily |
137
- |--------|-------|--------|-------|
138
- | Like | 20~40s | 15 | 500 |
139
- | Comment | 5~7min | 5 | 100 |
140
- | Follow | 1~2min | 15 | 250 |
141
- | Unfollow | 1~2min | 10 | 200 |
142
- | DM | 2~5min | 5 | 30 |
143
- | Post | 1~2min | 3 | 25 |
144
-
145
- All delays are randomized and applied automatically. Counters persist across sessions per userId.
146
-
147
- ## Error Recovery
148
-
149
- | Error | Action |
150
- |---|---|
151
- | `hourly_limit` | Wait for the specified time, then retry |
152
- | `daily_limit` | Wait until tomorrow |
153
- | `rate_limit` (spam detected) | Wait 24~48 hours |
154
- | `challenge` (302 redirect to /challenge/) | User must verify identity in browser |
155
- | `SESSION_EXPIRED` | Run `login` again |
156
-
157
- ## Important Notes
158
-
159
- - Always check `rate-limit-status` before bulk operations
160
- - New accounts (< 20 days) have stricter limits
161
- - Uniform action intervals trigger bot detection — delays are randomized
162
- - challenge requires manual browser verification
163
- - Session + counters stored locally at `~/.viruagent-cli/sessions/insta-session.json`
@@ -1,122 +0,0 @@
1
- ---
2
- name: viruagent-naver
3
- description: Naver Blog publishing via viruagent-cli. Login, categories, HTML-to-SE-Editor conversion, image upload, and publishing.
4
- triggers:
5
- - 네이버
6
- - naver
7
- - 네이버 블로그
8
- - naver blog
9
- ---
10
-
11
- # viruagent-naver — Naver Blog Publishing Skill
12
-
13
- You are a Naver blog publishing agent using `viruagent-cli`. Always use `--provider naver`.
14
-
15
- ## Step 1: Check authentication
16
-
17
- ```bash
18
- npx viruagent-cli status --provider naver
19
- ```
20
-
21
- If not logged in:
22
-
23
- ```bash
24
- # Auto login
25
- npx viruagent-cli login --provider naver --username <naver_id> --password <pass>
26
-
27
- # Manual login (browser)
28
- npx viruagent-cli login --provider naver --manual
29
- ```
30
-
31
- Environment variables: `NAVER_USERNAME` / `NAVER_PASSWORD`
32
-
33
- Note: Naver has aggressive bot detection. Use `--manual` if auto login fails.
34
-
35
- ## Step 2: Get categories
36
-
37
- ```bash
38
- npx viruagent-cli list-categories --provider naver
39
- ```
40
-
41
- Ask the user which category to use if not specified.
42
-
43
- ## Step 3: Create content
44
-
45
- Write content in plain HTML. Do NOT use `data-ke-*` attributes — Naver's SE Editor ignores them.
46
-
47
- ### HTML Template
48
-
49
- ```html
50
- <!-- 1. Hook -->
51
- <blockquote>[One impactful sentence]</blockquote>
52
- <p>&nbsp;</p>
53
-
54
- <!-- 2. Introduction (2~3 paragraphs) -->
55
- <p>[Context and reader empathy, 3~5 sentences]</p>
56
- <p>[What this post covers]</p>
57
- <p>&nbsp;</p>
58
-
59
- <!-- 3. Body (3~4 sections) -->
60
- <h2>[Section Title]</h2>
61
- <p>[3~5 sentences with evidence]</p>
62
- <p>[Analysis and implications]</p>
63
- <p>&nbsp;</p>
64
-
65
- <!-- Repeat for 2~3 more sections -->
66
-
67
- <!-- 4. Summary -->
68
- <h2>핵심 정리</h2>
69
- <ul>
70
- <li>[Takeaway 1]</li>
71
- <li>[Takeaway 2]</li>
72
- <li>[Takeaway 3]</li>
73
- </ul>
74
- <p>&nbsp;</p>
75
-
76
- <!-- 5. Closing -->
77
- <p>[Specific actionable suggestion]</p>
78
- ```
79
-
80
- ### Naver-Specific Rules
81
-
82
- - Use plain `<p>` tags — no `data-ke-*` attributes
83
- - Use `<p>&nbsp;</p>` for spacing
84
- - Use plain `<blockquote>` for hook — Naver converts it to a quotation component
85
- - HTML is auto-converted to SE Editor components server-side
86
-
87
- ## Step 4: Publish
88
-
89
- ```bash
90
- npx viruagent-cli publish \
91
- --provider naver \
92
- --title "Post Title" \
93
- --content "<h2>...</h2><p>...</p>" \
94
- --category <id> \
95
- --tags "tag1,tag2,tag3,tag4,tag5" \
96
- --visibility public \
97
- --related-image-keywords "keyword1,keyword2" \
98
- --image-upload-limit 1 \
99
- --minimum-image-count 1
100
- ```
101
-
102
- For drafts: Naver saves as private post (`save-draft` uses `--visibility private`).
103
-
104
- ### Image Rules
105
-
106
- - Always include `--related-image-keywords` with 2~3 English keywords
107
- - Set `--image-upload-limit 1` and `--minimum-image-count 1`
108
- - Never use `--no-auto-upload-images` unless user explicitly asks
109
-
110
- ## Step 5: Verify
111
-
112
- ```bash
113
- npx viruagent-cli list-posts --provider naver --limit 1
114
- ```
115
-
116
- ## Other Commands
117
-
118
- ```bash
119
- npx viruagent-cli read-post --provider naver --post-id <id>
120
- npx viruagent-cli list-posts --provider naver --limit 10
121
- npx viruagent-cli logout --provider naver
122
- ```