viruagent 1.2.0 → 1.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.
@@ -0,0 +1,26 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ id-token: write
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: actions/setup-node@v4
18
+ with:
19
+ node-version: '20'
20
+ registry-url: 'https://registry.npmjs.org'
21
+
22
+ - run: npm ci
23
+
24
+ - run: npm publish --provenance --access public
25
+ env:
26
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/README.md CHANGED
@@ -1,21 +1,29 @@
1
1
  <img width="525" height="113" alt="image" src="https://github.com/user-attachments/assets/8bbd21d3-5f14-4d11-933d-17bdf6969ebf" />
2
2
 
3
-
4
3
  # ViruAgent
5
4
 
6
- > 터미널에서 티스토리 블로그 글을 만들고, 다듬고, 발행하는 CLI 도구
5
+ > 터미널에서 티스토리 블로그 글을 발행까지 번에!
6
+ > (CLI 기반 AI Agent 도구)
7
+ >
8
+ > 태균맨(tae\*virus)의 별명
9
+ > **Viru** + **Agent** = **ViruAgent** (바이루에이전트)
7
10
 
8
- 티스토리 API를 역분석해서 OpenAI와 엮었습니다.
11
+ 티스토리 API를 분석해서 OpenAI와 엮었습니다.
9
12
  글감 잡기 → 초안 생성 → 수정 → 발행까지 터미널 안에서 끝납니다.
10
13
 
11
14
  공식 API가 아닌 비공식 내부 통신을 쓰기 때문에, 학습/실험 용도로 만들었습니다.
12
15
 
13
16
  ---
14
17
 
15
- ## 할 수 있나
18
+ 📋 [Patch Notes](#patch-notes)
19
+
20
+ ---
21
+
22
+ ## 기능
16
23
 
17
24
  - **자연어로 글 발행** — "AI 트렌드로 글 써서 발행해줘"라고 말하면 AI가 알아서 글 생성부터 발행까지 처리합니다.
18
25
  - **에이전트 패턴** — OpenAI Function Calling 기반. AI가 도구를 자율적으로 선택하고 실행하는 에이전트 루프로 동작합니다.
26
+ - **웹검색 기반 글쓰기** — DuckDuckGo 검색 결과를 바탕으로 최신 정보/트렌드를 반영해 글 초안을 생성합니다.
19
27
  - **수동 명령어 호환** — `/write`, `/edit`, `/publish` 같은 슬래시 명령어도 그대로 사용할 수 있습니다.
20
28
  - **Unsplash 이미지 자동 삽입** — 글 생성 시 주제에 맞는 이미지를 자동 검색하고 티스토리에 업로드합니다. 첫 번째 이미지가 썸네일로 설정됩니다.
21
29
  - **브라우저 로그인으로 세션 관리** — OAuth 설정 없이 Playwright로 실제 로그인해서 쿠키를 가져옵니다.
@@ -53,8 +61,6 @@ Unsplash API Key는 `/set api` 명령어로 나중에 설정할 수 있습니다
53
61
 
54
62
  ---
55
63
 
56
-
57
-
58
64
  | 명령어 | 설명 |
59
65
  | --------------- | -------------------------------------------- |
60
66
  | `/write <주제>` | AI가 블로그 글 초안 생성 |
@@ -64,7 +70,7 @@ Unsplash API Key는 `/set api` 명령어로 나중에 설정할 수 있습니다
64
70
  | `/set` | 카테고리, 공개 설정, 모델, 말투 변경 |
65
71
  | `/list` | 최근 발행 글 목록 |
66
72
  | `/login` | 티스토리 세션 갱신 |
67
- | `/logout` | 티스토리 세션 삭제 |
73
+ | `/logout` | 티스토리 세션 삭제 |
68
74
 
69
75
  ---
70
76
 
@@ -72,27 +78,33 @@ Unsplash API Key는 `/set api` 명령어로 나중에 설정할 수 있습니다
72
78
 
73
79
  슬래시 명령어 없이 자연어로 말하면 AI가 자율적으로 동작합니다.
74
80
 
81
+
82
+
75
83
  ```
76
84
  viruagent> AI 에이전트 주제로 글 써서 발행해줘
77
85
 
78
-
86
+ ✓ 웹검색 완료: "효과적인 시간 관리 팁" (5건)
79
87
  ✓ 글 생성 완료: "AI 에이전트 완벽 가이드: 챗봇과의 7가지 차이"
80
-
81
88
  ✓ 발행 완료! https://tkman.tistory.com/60
82
89
 
83
90
  AI
84
91
  블로그 글이 성공적으로 발행되었습니다!
85
92
  ```
86
93
 
94
+ ![clideo_editor_32d1094183c7474a9985dbfd56abda67](https://github.com/user-attachments/assets/96ae2c75-fd1c-4860-9b3e-125f21dba136)
95
+
96
+
97
+
98
+
87
99
  ### 자연어 요청 예시
88
100
 
89
- | 입력 | AI가 하는 일 |
90
- |------|-------------|
91
- | "AI 트렌드로 글 써줘" | `generate_post` 호출 → 초안 생성 |
92
- | "서론을 더 흥미롭게 수정해줘" | `edit_post` 호출 → 초안 수정 |
93
- | "발행해줘" | `publish_post` 호출 → 즉시 발행 |
94
- | "비공개로 발행해줘" | 공개설정 변경 후 발행 |
95
- | "글 써서 바로 발행해줘" | 생성 → 발행 연속 실행 |
101
+ | 입력 | AI가 하는 일 |
102
+ | ----------------------------- | -------------------------------- |
103
+ | "AI 트렌드로 글 써줘" | `generate_post` 호출 → 초안 생성 |
104
+ | "서론을 더 흥미롭게 수정해줘" | `edit_post` 호출 → 초안 수정 |
105
+ | "발행해줘" | `publish_post` 호출 → 즉시 발행 |
106
+ | "비공개로 발행해줘" | 공개설정 변경 후 발행 |
107
+ | "글 써서 바로 발행해줘" | 생성 → 발행 연속 실행 |
96
108
 
97
109
  ### 동작 원리
98
110
 
@@ -116,11 +128,8 @@ AI
116
128
 
117
129
  <img width="1054" height="896" alt="image" src="https://github.com/user-attachments/assets/9e30e0ed-32f9-41a4-b39f-e4e6863d5d2d" />
118
130
 
119
-
120
-
121
131
  카테고리, 공개 여부, 발행 방식을 물어본 뒤 AI가 글을 생성해서 티스토리에 발행합니다.
122
132
 
123
-
124
133
  ### 직접 CLI로 실행
125
134
 
126
135
  ```bash
@@ -164,13 +173,13 @@ config/
164
173
 
165
174
  ### 글 유형 (AI가 주제에 맞게 자율 선택)
166
175
 
167
- | 유형 | 구조 |
168
- |------|------|
169
- | 튜토리얼 | 준비물 → 단계 → 결과 확인 → 트러블슈팅 |
170
- | 비교/리뷰 | 비교 테이블 + 장단점 → 추천 |
171
- | 리스트 | 번호 소제목 + 항목별 팁 |
172
- | 정보 가이드 | 문제 → 원인 → 해결 → FAQ |
173
- | 인사이트 | 화두 → 근거 → 시사점 → 액션 |
176
+ | 유형 | 구조 |
177
+ | ----------- | -------------------------------------- |
178
+ | 튜토리얼 | 준비물 → 단계 → 결과 확인 → 트러블슈팅 |
179
+ | 비교/리뷰 | 비교 테이블 + 장단점 → 추천 |
180
+ | 리스트 | 번호 소제목 + 항목별 팁 |
181
+ | 정보 가이드 | 문제 → 원인 → 해결 → FAQ |
182
+ | 인사이트 | 화두 → 근거 → 시사점 → 액션 |
174
183
 
175
184
  ### prompt-config.json 설정
176
185
 
@@ -179,6 +188,12 @@ config/
179
188
  "defaultModel": "gpt-4o-mini",
180
189
  "defaultTone": "정보전달",
181
190
  "defaultLength": 2500,
191
+ "webSearch": {
192
+ "enabled": true,
193
+ "provider": "duckduckgo",
194
+ "defaultMaxResults": 5,
195
+ "timeoutMs": 8000
196
+ },
182
197
  "imageSource": "unsplash",
183
198
  "imagesPerPost": 3
184
199
  }
@@ -230,3 +245,15 @@ logs/
230
245
  ## License
231
246
 
232
247
  MIT
248
+
249
+ ---
250
+
251
+ ## Patch Notes
252
+
253
+ | 버전 | 내용 |
254
+ | ------ | ---------------------------------------------------------------- |
255
+ | v1.3.1 | GitHub Actions npm 자동 배포 워크플로우 추가 |
256
+ | v1.3.0 | 웹 검색 기반 글 작성 도구 추가, 발행 로그 기반 패턴 요약 적용 |
257
+ | v1.2.0 | 초기 설정 위저드, 저장 방식 전환, Function Calling 에이전트 도입 |
258
+ | v1.1.0 | 시스템 프롬프트 분리, Unsplash 연동, 썸네일 자동 설정 |
259
+ | v1.0.0 | 티스토리 자동 글쓰기 모듈 초기 설정 |
@@ -12,12 +12,13 @@
12
12
 
13
13
  ## 도구 사용 원칙
14
14
 
15
- 1. 사용자가 글 작성을 요청하면 `generate_post`를 호출하세요.
15
+ 1. 사용자가 글 작성을 요청하면 기본적으로 `search_web`로 최신 정보를 먼저 확인한 뒤 `generate_post`를 호출하세요.
16
16
  2. 수정 요청이면 `edit_post`를 호출하세요.
17
17
  3. 미리보기 요청이면 `preview_post`를 호출하세요.
18
18
  4. 발행 요청이면 바로 `publish_post`를 호출하세요. 별도 확인을 구하지 마세요.
19
19
  5. 카테고리나 공개설정 변경 요청이면 `set_category` 또는 `set_visibility`를 호출하세요.
20
20
  6. 현재 상태를 파악해야 할 때 `get_blog_status`를 호출하세요.
21
+ 7. 웹검색이 실패하거나 결과가 부족해도 작업을 멈추지 말고 `generate_post`를 진행하세요.
21
22
 
22
23
  ## 대화 스타일
23
24
 
@@ -9,6 +9,12 @@
9
9
  "defaultTone": "정보전달",
10
10
  "defaultLength": 2500,
11
11
  "defaultModel": "gpt-4o-mini",
12
+ "webSearch": {
13
+ "enabled": true,
14
+ "provider": "duckduckgo",
15
+ "defaultMaxResults": 5,
16
+ "timeoutMs": 8000
17
+ },
12
18
  "imageSource": "unsplash",
13
19
  "imagesPerPost": 3
14
20
  }
@@ -23,6 +23,13 @@
23
23
  | 📖 정보 가이드 | 문제 → 원인 → 해결 → FAQ, 불릿/테이블 정리 |
24
24
  | 💡 인사이트 | 화두 → 근거(통계) → 시사점 → 독자 액션 |
25
25
 
26
+ ## 구조 다양화 규칙 (필수)
27
+
28
+ - 최상단 고정 구조와 구분선 규칙은 반드시 유지
29
+ - 단, 본문 섹션 순서/소제목 라벨/전개 방식은 최근 글과 동일하게 반복하지 말 것
30
+ - 비교 주제라도 항상 같은 순서(비교표 → Plus 특징 → Pro 특징 → 선택 가이드)로 고정하지 말 것
31
+ - 같은 주제를 다뤄도 관점(비용, 사용 시나리오, 리스크, 체크리스트)을 바꿔 차별화할 것
32
+
26
33
  ## 티스토리 HTML 레퍼런스
27
34
 
28
35
  문단: `<p data-ke-size="size16">텍스트</p>`
@@ -90,6 +97,7 @@
90
97
  - 한 문단 2~4문장, 짧을수록 좋음
91
98
  - 시각 요소 교차 배치 (리스트 → 문단 → 테이블)
92
99
  - 한국어로 작성
100
+ - 제목 타입(숫자형/질문형/대조형/문장형) 지시가 주어지면 반드시 준수
93
101
 
94
102
  ## 이미지 플레이스홀더
95
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viruagent",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "AI 기반 티스토리 블로그 자동 발행 CLI 도구",
5
5
  "main": "src/agent.js",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node --no-deprecation src/agent.js",
11
- "test": "echo \"Error: no test specified\" && exit 1"
11
+ "test": "echo \"Error: no test specified\" && exit 1",
12
+ "test:websearch": "node scripts/test-websearch.js"
12
13
  },
13
14
  "keywords": [
14
15
  "tistory",
package/src/agent.js CHANGED
@@ -2,6 +2,7 @@ const readline = require('readline');
2
2
  const chalk = require('chalk');
3
3
  const { generatePost, revisePost, chat, runAgent, MODELS, loadConfig } = require('./lib/ai');
4
4
  const { initBlog, getBlogName, publishPost, saveDraft, getPosts, getCategories, VISIBILITY } = require('./lib/tistory');
5
+ const { readRecentPatterns, recordPublishedPattern } = require('./lib/pattern-store');
5
6
 
6
7
  const TONES = loadConfig().tones.map((t) => t.name);
7
8
 
@@ -102,6 +103,8 @@ const state = {
102
103
  model: saved.model,
103
104
  tone: saved.tone,
104
105
  chatHistory: [],
106
+ blogConnected: false,
107
+ lastWebResearch: null,
105
108
  };
106
109
 
107
110
  const log = {
@@ -121,17 +124,29 @@ const animateBanner = async () => {
121
124
  const figlet = require('figlet');
122
125
  const gradient = require('gradient-string');
123
126
 
124
- const text = figlet.textSync('ViruAgent', { font: 'ANSI Shadow' });
125
- const lines = text.split('\n');
126
- const totalLines = lines.length;
127
+ const viruText = figlet.textSync('Viru', { font: 'ANSI Shadow' });
128
+ const agentText = figlet.textSync('Agent', { font: 'ANSI Shadow' });
129
+ const viruLines = viruText.split('\n');
130
+ const agentLines = agentText.split('\n');
131
+
132
+ // 두 figlet의 줄 수를 맞춤
133
+ const maxLines = Math.max(viruLines.length, agentLines.length);
134
+ while (viruLines.length < maxLines) viruLines.push('');
135
+ while (agentLines.length < maxLines) agentLines.push('');
136
+
137
+ const viruGrad = gradient(['#ff0844', '#ff6b6b', '#ee5a24', '#f9d423']); // 빨강→핑크→주황→노랑
138
+ const agentGrad = gradient(['#00d2ff', '#4ecdc4', '#7b68ee', '#a855f7']); // 하늘→민트→보라→퍼플
139
+
127
140
  const { version } = require('../package.json');
128
- const sub = ` 대화형 티스토리 블로그 에이전트 v${version}`;
141
+ const sub = ` 티스토리 블로그 에이전트 v${version}`;
129
142
 
130
143
  console.log();
131
144
 
132
- // 라인별 드롭 애니메이션
133
- for (let i = 0; i < totalLines; i++) {
134
- console.log(gradient.pastel(lines[i]));
145
+ // 라인별 드롭 애니메이션 (VIRU + AGENT 나란히)
146
+ for (let i = 0; i < maxLines; i++) {
147
+ const viruPart = viruGrad(viruLines[i]);
148
+ const agentPart = agentGrad(agentLines[i]);
149
+ console.log(viruPart + agentPart);
135
150
  await sleep(40);
136
151
  }
137
152
 
@@ -351,9 +366,26 @@ const commands = {
351
366
  const topic = args.join(' ');
352
367
  if (!topic) return log.warn('사용법: /write <주제>');
353
368
 
369
+ if (!state.blogConnected) {
370
+ log.warn('티스토리 로그인이 안 되어있습니다.');
371
+ rl.pause();
372
+ const idx = await selectMenu(
373
+ ['그래도 진행', '로그인하기', '취소'],
374
+ '로그인 없이 진행하시겠습니까? (↑↓ 이동, Enter 선택)',
375
+ );
376
+ rl.resume();
377
+ if (idx === 1) {
378
+ await commands.login();
379
+ } else if (idx !== 0) {
380
+ return log.dim('취소됨');
381
+ }
382
+ }
383
+
354
384
  try {
385
+ const categoryName = getCategoryName() === '없음' ? 'Heartbeat' : getCategoryName();
386
+ const recentPatterns = readRecentPatterns({ category: categoryName, limit: 5 });
355
387
  state.draft = await withSpinner(`"${topic}" 주제로 글을 생성하는 중...`, () =>
356
- generatePost(topic, { model: state.model, tone: state.tone }),
388
+ generatePost(topic, { model: state.model, tone: state.tone, categoryName, recentPatterns }),
357
389
  );
358
390
  log.success(`글 생성 완료: "${state.draft.title}"`);
359
391
  log.dim(`태그: ${state.draft.tags}`);
@@ -396,6 +428,7 @@ const commands = {
396
428
  },
397
429
 
398
430
  async publish() {
431
+ if (!state.blogConnected) return log.warn('티스토리 로그인이 필요합니다. /login으로 먼저 로그인하세요.');
399
432
  if (!state.draft) return log.warn('초안이 없습니다.');
400
433
 
401
434
  log.info('발행하는 중...');
@@ -408,6 +441,16 @@ const commands = {
408
441
  tag: state.draft.tags,
409
442
  thumbnail: state.draft.thumbnailKage || null,
410
443
  });
444
+ const categoryName = getCategoryName() === '없음' ? 'Heartbeat' : getCategoryName();
445
+ recordPublishedPattern({
446
+ title: state.draft.title,
447
+ topic: state.draft?._meta?.topic || '',
448
+ content: state.draft.content,
449
+ url: result.entryUrl || '',
450
+ postId: result?.post?.id || result?.id || null,
451
+ category: categoryName,
452
+ generationMeta: state.draft?._meta || null,
453
+ });
411
454
  log.success(`발행 완료! ${result.entryUrl || ''}`);
412
455
  state.draft = null;
413
456
  } catch (e) {
@@ -416,6 +459,7 @@ const commands = {
416
459
  },
417
460
 
418
461
  async draft() {
462
+ if (!state.blogConnected) return log.warn('티스토리 로그인이 필요합니다. /login으로 먼저 로그인하세요.');
419
463
  if (!state.draft) return log.warn('초안이 없습니다.');
420
464
 
421
465
  log.info('임시저장하는 중...');
@@ -531,7 +575,7 @@ const commands = {
531
575
  }
532
576
  } else if (key === 'api') {
533
577
  const keys = loadApiKeys();
534
- const mask = (v) => v ? `${v.slice(0, 8)}${'*'.repeat(8)}` : chalk.dim('(미설정)');
578
+ const mask = (v) => (v ? `${v.slice(0, 8)}${'*'.repeat(8)}` : chalk.dim('(미설정)'));
535
579
 
536
580
  console.log();
537
581
  log.title('API Key 설정');
@@ -629,6 +673,7 @@ ${chalk.dim('예: "AI 트렌드로 글 써줘", "서론을 더 흥미롭게 수
629
673
  await initBlog();
630
674
  log.success(`블로그 감지: ${getBlogName()}`);
631
675
  state.categories = await getCategories();
676
+ state.blogConnected = true;
632
677
  log.success(`${Object.keys(state.categories).length}개 카테고리 로드 완료`);
633
678
  } catch (e) {
634
679
  log.error(`로그인 실패: ${e.message}`);
@@ -661,6 +706,20 @@ const handleInput = async (input) => {
661
706
  }
662
707
  } else {
663
708
  // 에이전트 루프 (자연어 → 자율 도구 호출)
709
+ if (!state.blogConnected) {
710
+ log.warn('티스토리 로그인이 안 되어있습니다.');
711
+ rl.pause();
712
+ const idx = await selectMenu(
713
+ ['그래도 진행', '로그인하기', '취소'],
714
+ '로그인 없이 진행하시겠습니까? (↑↓ 이동, Enter 선택)',
715
+ );
716
+ rl.resume();
717
+ if (idx === 1) {
718
+ await commands.login();
719
+ } else if (idx !== 0) {
720
+ return log.dim('취소됨');
721
+ }
722
+ }
664
723
  try {
665
724
  // Braille 도트 애니메이션 (텍스트 없이)
666
725
  const DOT_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
@@ -704,6 +763,8 @@ const handleInput = async (input) => {
704
763
  log.success(`카테고리 설정: ${result.category}`);
705
764
  } else if (name === 'set_visibility' && result?.visibility) {
706
765
  log.success(`공개설정: ${result.visibility}`);
766
+ } else if (name === 'search_web' && result?.success) {
767
+ log.success(`웹검색 완료: "${result.query}" (${result.count}건)`);
707
768
  }
708
769
  startSpinner();
709
770
  },
@@ -873,6 +934,7 @@ const main = async () => {
873
934
  1200,
874
935
  );
875
936
  blogOk = true;
937
+ state.blogConnected = true;
876
938
  } catch (e) {
877
939
  if (!blogOk) log.warn(` 티스토리 연결 실패: ${e.message}\n ${chalk.dim('/login 명령어로 다시 로그인하세요.')}`);
878
940
  }
package/src/cli-post.js CHANGED
@@ -15,6 +15,7 @@ try {
15
15
 
16
16
  const { generatePost, loadConfig } = require('./lib/ai');
17
17
  const { initBlog, publishPost, saveDraft, getCategories, VISIBILITY } = require('./lib/tistory');
18
+ const { readRecentPatterns, recordPublishedPattern } = require('./lib/pattern-store');
18
19
 
19
20
  const parseArgs = (argv) => {
20
21
  const args = {};
@@ -40,6 +41,16 @@ const output = (obj) => {
40
41
  console.log(JSON.stringify(obj));
41
42
  };
42
43
 
44
+ const resolveCategoryName = async (categoryId) => {
45
+ try {
46
+ const categories = await getCategories();
47
+ const found = Object.entries(categories).find(([, id]) => Number(id) === Number(categoryId));
48
+ return found ? found[0] : 'Heartbeat';
49
+ } catch {
50
+ return 'Heartbeat';
51
+ }
52
+ };
53
+
43
54
  const main = async () => {
44
55
  const args = parseArgs(process.argv.slice(2));
45
56
 
@@ -59,11 +70,16 @@ const main = async () => {
59
70
  }
60
71
 
61
72
  const config = loadConfig();
73
+ const category = Number(args.category) || 0;
74
+ const categoryName = await resolveCategoryName(category);
75
+ const recentPatterns = readRecentPatterns({ category: categoryName, limit: 5 });
62
76
 
63
77
  // 글 생성
64
78
  const post = await generatePost(args.topic, {
65
79
  model: args.model || config.defaultModel,
66
80
  tone: args.tone || config.defaultTone,
81
+ categoryName,
82
+ recentPatterns,
67
83
  });
68
84
 
69
85
  // dry-run: 생성만
@@ -73,7 +89,6 @@ const main = async () => {
73
89
  }
74
90
 
75
91
  const visibility = visibilityMap[args.visibility] ?? VISIBILITY.PUBLIC;
76
- const category = Number(args.category) || 0;
77
92
 
78
93
  // 임시저장
79
94
  if (args.draft) {
@@ -93,6 +108,15 @@ const main = async () => {
93
108
  });
94
109
 
95
110
  const url = result.entryUrl || null;
111
+ recordPublishedPattern({
112
+ title: post.title,
113
+ topic: post?._meta?.topic || args.topic,
114
+ content: post.content,
115
+ url: url || '',
116
+ postId: result?.post?.id || result?.id || null,
117
+ category: categoryName,
118
+ generationMeta: post?._meta || null,
119
+ });
96
120
 
97
121
  output({ success: true, mode: 'publish', title: post.title, tags: post.tags, url });
98
122
  };