viruagent 1.0.0

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,93 @@
1
+ #!/usr/bin/env node
2
+
3
+ const path = require('path');
4
+ require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
5
+
6
+ const { generatePost, loadConfig } = require('./lib/ai');
7
+ const { initBlog, publishPost, saveDraft, getCategories, VISIBILITY } = require('./lib/tistory');
8
+
9
+ const parseArgs = (argv) => {
10
+ const args = {};
11
+ for (let i = 0; i < argv.length; i++) {
12
+ const arg = argv[i];
13
+ if (arg === '--dry-run') { args.dryRun = true; continue; }
14
+ if (arg === '--draft') { args.draft = true; continue; }
15
+ if (arg === '--list-categories') { args.listCategories = true; continue; }
16
+ if (arg.startsWith('--') && i + 1 < argv.length) {
17
+ args[arg.slice(2)] = argv[++i];
18
+ }
19
+ }
20
+ return args;
21
+ };
22
+
23
+ const visibilityMap = {
24
+ public: VISIBILITY.PUBLIC,
25
+ private: VISIBILITY.PRIVATE,
26
+ protected: VISIBILITY.PROTECTED,
27
+ };
28
+
29
+ const output = (obj) => {
30
+ console.log(JSON.stringify(obj));
31
+ };
32
+
33
+ const main = async () => {
34
+ const args = parseArgs(process.argv.slice(2));
35
+
36
+ await initBlog();
37
+
38
+ // 카테고리 목록 조회
39
+ if (args.listCategories) {
40
+ const cats = await getCategories();
41
+ output({ success: true, categories: Object.entries(cats).map(([name, id]) => ({ name, id })) });
42
+ return;
43
+ }
44
+
45
+ // topic 필수
46
+ if (!args.topic) {
47
+ output({ success: false, error: '--topic은 필수입니다.' });
48
+ process.exit(1);
49
+ }
50
+
51
+ const config = loadConfig();
52
+
53
+ // 글 생성
54
+ const post = await generatePost(args.topic, {
55
+ model: args.model || config.defaultModel,
56
+ tone: args.tone || config.defaultTone,
57
+ });
58
+
59
+ // dry-run: 생성만
60
+ if (args.dryRun) {
61
+ output({ success: true, title: post.title, tags: post.tags, preview: post.content.slice(0, 200) });
62
+ return;
63
+ }
64
+
65
+ const visibility = visibilityMap[args.visibility] ?? VISIBILITY.PUBLIC;
66
+ const category = Number(args.category) || 0;
67
+
68
+ // 임시저장
69
+ if (args.draft) {
70
+ const result = await saveDraft({ title: post.title, content: post.content });
71
+ output({ success: true, mode: 'draft', title: post.title, tags: post.tags, sequence: result.draft?.sequence });
72
+ return;
73
+ }
74
+
75
+ // 발행
76
+ const result = await publishPost({
77
+ title: post.title,
78
+ content: post.content,
79
+ visibility,
80
+ category,
81
+ tag: post.tags,
82
+ thumbnail: post.thumbnailKage || null,
83
+ });
84
+
85
+ const url = result.entryUrl || null;
86
+
87
+ output({ success: true, mode: 'publish', title: post.title, tags: post.tags, url });
88
+ };
89
+
90
+ main().catch((e) => {
91
+ output({ success: false, error: e.message });
92
+ process.exit(1);
93
+ });
package/src/lib/ai.js ADDED
@@ -0,0 +1,192 @@
1
+ const OpenAI = require('openai');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ require('dotenv').config({ path: path.join(__dirname, '..', '..', '.env') });
5
+
6
+ if (!process.env.OPENAI_API_KEY) {
7
+ console.error('\x1b[31m✗ OPENAI_API_KEY가 설정되지 않았습니다.\x1b[0m');
8
+ console.error('\x1b[33m 1. 프로젝트 루트에 .env 파일을 생성하세요');
9
+ console.error(' 2. OPENAI_API_KEY=sk-... 형식으로 키를 입력하세요');
10
+ console.error(' 3. https://platform.openai.com/api-keys 에서 키를 발급받을 수 있습니다\x1b[0m');
11
+ process.exit(1);
12
+ }
13
+
14
+ const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
15
+
16
+ const { replaceImagePlaceholders } = require('./unsplash');
17
+ const { createLogger } = require('./logger');
18
+ const aiLog = createLogger('ai');
19
+
20
+ const handleApiError = (e) => {
21
+ if (e?.status === 401 || e?.code === 'invalid_api_key') {
22
+ throw new Error('OpenAI API 키가 유효하지 않습니다. .env 파일의 OPENAI_API_KEY를 확인하세요.');
23
+ }
24
+ if (e?.status === 429) {
25
+ throw new Error('API 요청 한도를 초과했습니다. 잠시 후 다시 시도하거나 요금제를 확인하세요.');
26
+ }
27
+ if (e?.status === 403) {
28
+ throw new Error('API 키에 해당 모델 접근 권한이 없습니다. OpenAI 대시보드에서 권한을 확인하세요.');
29
+ }
30
+ throw e;
31
+ };
32
+
33
+ const CONFIG_PATH = path.join(__dirname, '..', '..', 'config', 'prompt-config.json');
34
+ const SYSTEM_PROMPT_PATH = path.join(__dirname, '..', '..', 'config', 'system-prompt.md');
35
+
36
+ const loadConfig = () => {
37
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
38
+ config.systemPrompt = fs.readFileSync(SYSTEM_PROMPT_PATH, 'utf-8');
39
+ return config;
40
+ };
41
+
42
+ const MODELS = [
43
+ // GPT-4o
44
+ 'gpt-4o-mini', 'gpt-4o',
45
+ // GPT-4.1
46
+ 'gpt-4.1-nano', 'gpt-4.1-mini', 'gpt-4.1',
47
+ // GPT-5
48
+ 'gpt-5-nano', 'gpt-5-mini', 'gpt-5',
49
+ // GPT-5.1
50
+ 'gpt-5.1', 'gpt-5.1-codex-mini', 'gpt-5.1-codex',
51
+ // GPT-5.2
52
+ 'gpt-5.2', 'gpt-5.2-pro', 'gpt-5.2-codex',
53
+ // Reasoning
54
+ 'o3-mini', 'o3', 'o3-pro', 'o4-mini',
55
+ ];
56
+
57
+ // reasoning 모델은 temperature를 지원하지 않음
58
+ const REASONING_MODELS = /^(o[1-9]|o\d+-)/;
59
+ const isReasoningModel = (model) => REASONING_MODELS.test(model);
60
+
61
+ /**
62
+ * 블로그 글 초안 생성
63
+ * @param {string} topic - 주제
64
+ * @param {Object} [options]
65
+ * @param {string} [options.tone] - 말투
66
+ * @param {number} [options.length] - 대략적인 글자 수
67
+ * @param {string} [options.model] - 모델명
68
+ * @returns {Promise<{title: string, content: string, tags: string}>}
69
+ */
70
+ const generatePost = async (topic, options = {}) => {
71
+ const config = loadConfig();
72
+ const {
73
+ tone = config.defaultTone,
74
+ length = config.defaultLength,
75
+ model = config.defaultModel,
76
+ } = options;
77
+
78
+ let res;
79
+ try {
80
+ res = await client.chat.completions.create({
81
+ model,
82
+ messages: [
83
+ { role: 'system', content: config.systemPrompt },
84
+ {
85
+ role: 'user',
86
+ content: `주제: ${topic}
87
+ 말투: ${tone}
88
+ 분량: 약 ${length}자
89
+
90
+ 작성 요구사항:
91
+ - 제목은 검색 키워드를 포함하면서 클릭을 유도하는 형태로 작성 (숫자 활용 권장)
92
+ - 주제에 가장 적합한 글 유형과 구조를 자율적으로 선택하여 작성
93
+ - 본문 중 적절한 위치에 <!-- IMAGE: 영문키워드 --> 플레이스홀더를 3개 내외 삽입
94
+ - 태그는 검색 유입에 효과적인 키워드 5~7개
95
+
96
+ 다음 JSON 형식으로 응답하세요:
97
+ {"title": "글 제목", "content": "<p>HTML 본문...</p>", "tags": "태그1,태그2,태그3,태그4,태그5"}`,
98
+ },
99
+ ],
100
+ response_format: { type: 'json_object' },
101
+ ...(!isReasoningModel(model) && { temperature: 0.7 }),
102
+ });
103
+ } catch (e) {
104
+ handleApiError(e);
105
+ }
106
+
107
+ const result = JSON.parse(res.choices[0].message.content);
108
+ aiLog.info('GPT 응답 수신', { title: result.title, contentLength: result.content?.length });
109
+
110
+ // 티스토리 업로드 함수가 있으면 전달 (initBlog 완료 상태에서만 동작)
111
+ let uploadFn;
112
+ try {
113
+ const { uploadImage } = require('./tistory');
114
+ uploadFn = uploadImage;
115
+ } catch (e) {
116
+ aiLog.warn('tistory uploadImage 로드 실패', { error: e.message });
117
+ }
118
+ const imageResult = await replaceImagePlaceholders(result.content, { uploadFn });
119
+ result.content = imageResult.html;
120
+ result.thumbnailUrl = imageResult.thumbnailUrl;
121
+ result.thumbnailKage = imageResult.thumbnailKage;
122
+ aiLog.info('이미지 처리 완료', { thumbnailKage: result.thumbnailKage });
123
+
124
+ return result;
125
+ };
126
+
127
+ /**
128
+ * 글 수정
129
+ * @param {string} content - 현재 HTML 본문
130
+ * @param {string} instruction - 수정 지시
131
+ * @returns {Promise<{title: string, content: string, tags: string}>}
132
+ */
133
+ const revisePost = async (content, instruction, model) => {
134
+ const config = loadConfig();
135
+ model = model || config.defaultModel;
136
+
137
+ let res;
138
+ try {
139
+ res = await client.chat.completions.create({
140
+ model,
141
+ messages: [
142
+ { role: 'system', content: config.systemPrompt },
143
+ {
144
+ role: 'user',
145
+ content: `다음 글을 수정해주세요.
146
+
147
+ 수정 지시: ${instruction}
148
+
149
+ 현재 본문:
150
+ ${content}
151
+
152
+ 다음 JSON 형식으로 응답하세요:
153
+ {"title": "수정된 제목", "content": "<p>수정된 HTML 본문...</p>", "tags": "태그1,태그2,태그3,태그4,태그5"}`,
154
+ },
155
+ ],
156
+ response_format: { type: 'json_object' },
157
+ ...(!isReasoningModel(model) && { temperature: 0.7 }),
158
+ });
159
+ } catch (e) {
160
+ handleApiError(e);
161
+ }
162
+
163
+ return JSON.parse(res.choices[0].message.content);
164
+ };
165
+
166
+ /**
167
+ * 자유 대화
168
+ * @param {Array<{role: string, content: string}>} messages
169
+ * @returns {Promise<string>}
170
+ */
171
+ const chat = async (messages, model) => {
172
+ const config = loadConfig();
173
+ model = model || config.defaultModel;
174
+
175
+ let res;
176
+ try {
177
+ res = await client.chat.completions.create({
178
+ model,
179
+ messages: [
180
+ { role: 'system', content: '당신은 블로그 글쓰기를 돕는 AI 어시스턴트입니다. 주제 논의, 아이디어 브레인스토밍, 글 구조 제안 등을 도와줍니다. 한국어로 대화하세요.' },
181
+ ...messages,
182
+ ],
183
+ ...(!isReasoningModel(model) && { temperature: 0.7 }),
184
+ });
185
+ } catch (e) {
186
+ handleApiError(e);
187
+ }
188
+
189
+ return res.choices[0].message.content;
190
+ };
191
+
192
+ module.exports = { generatePost, revisePost, chat, MODELS, loadConfig };
@@ -0,0 +1,27 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const LOGS_DIR = path.join(__dirname, '..', '..', 'logs');
5
+
6
+ const getLogFile = () => {
7
+ const date = new Date().toISOString().slice(0, 10);
8
+ return path.join(LOGS_DIR, `${date}.log`);
9
+ };
10
+
11
+ const formatTime = () => new Date().toISOString().slice(11, 23);
12
+
13
+ const write = (level, module, message, data) => {
14
+ const line = `[${formatTime()}] [${level}] [${module}] ${message}${data !== undefined ? ' | ' + JSON.stringify(data) : ''}\n`;
15
+ try {
16
+ fs.appendFileSync(getLogFile(), line);
17
+ } catch {}
18
+ };
19
+
20
+ const createLogger = (module) => ({
21
+ info: (msg, data) => write('INFO', module, msg, data),
22
+ warn: (msg, data) => write('WARN', module, msg, data),
23
+ error: (msg, data) => write('ERROR', module, msg, data),
24
+ debug: (msg, data) => write('DEBUG', module, msg, data),
25
+ });
26
+
27
+ module.exports = { createLogger };
@@ -0,0 +1,25 @@
1
+ const { chromium } = require('playwright');
2
+ const readline = require('readline');
3
+ const path = require('path');
4
+
5
+ (async () => {
6
+ const browser = await chromium.launch({ headless: false });
7
+ const context = await browser.newContext();
8
+ const page = await context.newPage();
9
+
10
+ await page.goto('https://www.tistory.com/auth/login');
11
+
12
+ console.log('\n========================================');
13
+ console.log('브라우저에서 로그인을 완료하세요.');
14
+ console.log('로그인 후 여기서 Enter를 누르면 세션이 저장됩니다.');
15
+ console.log('========================================\n');
16
+
17
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
18
+ await new Promise((resolve) => rl.question('', resolve));
19
+ rl.close();
20
+
21
+ await context.storageState({ path: path.join(__dirname, '..', '..', 'data', 'session.json') });
22
+ console.log('세션이 session.json에 저장되었습니다!');
23
+
24
+ await browser.close();
25
+ })();
@@ -0,0 +1,243 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { createLogger } = require('./logger');
4
+ const log = createLogger('tistory');
5
+
6
+ let blogName = null;
7
+ let blogInfo = null;
8
+
9
+ const loadCookies = () => {
10
+ const session = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'data', 'session.json'), 'utf-8'));
11
+ return session.cookies
12
+ .filter(c => c.domain.includes('tistory'))
13
+ .map(c => `${c.name}=${c.value}`)
14
+ .join('; ');
15
+ };
16
+
17
+ const getBase = () => {
18
+ if (!blogName) throw new Error('블로그 이름이 초기화되지 않았습니다. initBlog()를 먼저 호출하세요.');
19
+ return `https://${blogName}.tistory.com/manage`;
20
+ };
21
+
22
+ const getHeaders = () => ({
23
+ 'Cookie': loadCookies(),
24
+ 'Content-Type': 'application/json;charset=UTF-8',
25
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
26
+ 'Referer': `${getBase()}/newpost`,
27
+ 'X-Requested-With': 'XMLHttpRequest',
28
+ });
29
+
30
+ /**
31
+ * 로그인 세션에서 블로그 정보를 자동 감지
32
+ * @returns {Promise<string>} 블로그 이름
33
+ */
34
+ const initBlog = async () => {
35
+ if (blogName) return blogName;
36
+
37
+ const res = await fetch('https://www.tistory.com/legacy/member/blog/api/myBlogs', {
38
+ headers: {
39
+ 'Cookie': loadCookies(),
40
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
41
+ },
42
+ });
43
+
44
+ if (!res.ok) throw new Error(`블로그 정보 조회 실패: ${res.status}`);
45
+
46
+ const contentType = res.headers.get('content-type') || '';
47
+ if (!contentType.includes('application/json')) {
48
+ throw new Error('세션이 만료되었습니다. /login으로 다시 로그인하세요.');
49
+ }
50
+
51
+ const json = await res.json();
52
+
53
+ const defaultBlog = json.data?.find(b => b.defaultBlog) || json.data?.[0];
54
+ if (!defaultBlog) throw new Error('블로그를 찾을 수 없습니다.');
55
+
56
+ blogName = defaultBlog.name;
57
+ blogInfo = defaultBlog;
58
+ return blogName;
59
+ };
60
+
61
+ const getBlogName = () => blogName;
62
+ const getBlogInfo = () => blogInfo;
63
+
64
+ // visibility: 0=비공개, 15=보호, 20=공개
65
+ const VISIBILITY = { PRIVATE: 0, PROTECTED: 15, PUBLIC: 20 };
66
+
67
+ /**
68
+ * 글 발행
69
+ * @param {Object} options
70
+ * @param {string} options.title - 제목
71
+ * @param {string} options.content - HTML 본문
72
+ * @param {number} [options.visibility=20] - 0: 비공개, 15: 보호, 20: 공개
73
+ * @param {number} [options.category=0] - 카테고리 ID
74
+ * @param {string} [options.tag=''] - 태그 (쉼표 구분)
75
+ * @param {string} [options.thumbnail=null] - 썸네일 kage@ 경로
76
+ * @returns {Promise<Object>}
77
+ */
78
+ const publishPost = async ({ title, content, visibility = VISIBILITY.PUBLIC, category = 0, tag = '', thumbnail = null }) => {
79
+ const body = {
80
+ id: '0',
81
+ title,
82
+ content,
83
+ visibility,
84
+ category,
85
+ tag,
86
+ published: 1,
87
+ type: 'post',
88
+ uselessMarginForEntry: 1,
89
+ cclCommercial: 0,
90
+ cclDerive: 0,
91
+ attachments: [],
92
+ recaptchaValue: '',
93
+ draftSequence: null,
94
+ };
95
+ if (thumbnail) body.thumbnail = thumbnail;
96
+
97
+ const res = await fetch(`${getBase()}/post.json`, {
98
+ method: 'POST',
99
+ headers: getHeaders(),
100
+ body: JSON.stringify(body),
101
+ });
102
+
103
+ if (!res.ok) throw new Error(`발행 실패: ${res.status}`);
104
+ const data = await res.json();
105
+ return data;
106
+ };
107
+
108
+ /**
109
+ * 임시저장
110
+ */
111
+ const saveDraft = async ({ title, content }) => {
112
+ const res = await fetch(`${getBase()}/drafts`, {
113
+ method: 'POST',
114
+ headers: getHeaders(),
115
+ body: JSON.stringify({ title, content }),
116
+ });
117
+
118
+ if (!res.ok) throw new Error(`임시저장 실패: ${res.status}`);
119
+ const data = await res.json();
120
+ console.log(`임시저장 완료 (sequence: ${data.draft.sequence})`);
121
+ return data;
122
+ };
123
+
124
+ /**
125
+ * 글 목록 조회
126
+ */
127
+ const getPosts = async () => {
128
+ const res = await fetch(`${getBase()}/posts.json`, { headers: getHeaders() });
129
+ if (!res.ok) throw new Error(`목록 조회 실패: ${res.status}`);
130
+ return res.json();
131
+ };
132
+
133
+ /**
134
+ * 카테고리 목록을 글쓰기 페이지에서 동적으로 가져옴
135
+ * @returns {Promise<Record<string, number>>} { "카테고리명": id }
136
+ */
137
+ const getCategories = async () => {
138
+ const res = await fetch(`${getBase()}/newpost`, { headers: getHeaders() });
139
+ if (!res.ok) throw new Error(`카테고리 조회 실패: ${res.status}`);
140
+ const html = await res.text();
141
+
142
+ // window.Config의 blog.categories를 vm으로 안전하게 추출
143
+ const vm = require('vm');
144
+ const configMatch = html.match(/window\.Config\s*=\s*(\{[\s\S]*?\})\s*\n/);
145
+ if (!configMatch) throw new Error('카테고리 파싱 실패');
146
+
147
+ const sandbox = {};
148
+ vm.runInNewContext(`var result = ${configMatch[1]}`, sandbox);
149
+ const catList = sandbox.result.blog.categories;
150
+ const categories = {};
151
+
152
+ const flatten = (list) => {
153
+ for (const cat of list) {
154
+ categories[cat.label] = cat.id;
155
+ if (cat.children?.length) flatten(cat.children);
156
+ }
157
+ };
158
+ flatten(catList);
159
+
160
+ return categories;
161
+ };
162
+
163
+ /**
164
+ * 이미지 업로드
165
+ * @param {Buffer} imageBuffer - 이미지 데이터
166
+ * @param {string} [filename='image.jpg'] - 파일명
167
+ * @returns {Promise<string>} 티스토리 이미지 치환자 문자열
168
+ */
169
+ const uploadImage = async (imageBuffer, filename = 'image.jpg') => {
170
+ if (!blogName) await initBlog();
171
+
172
+ const formData = new FormData();
173
+ const blob = new Blob([imageBuffer], { type: 'image/jpeg' });
174
+ formData.append('file', blob, filename);
175
+
176
+ const cookies = loadCookies();
177
+ const res = await fetch(`${getBase()}/post/attach.json`, {
178
+ method: 'POST',
179
+ headers: {
180
+ 'Cookie': cookies,
181
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
182
+ 'Referer': `${getBase()}/newpost`,
183
+ 'Accept': 'application/json, text/plain, */*',
184
+ },
185
+ body: formData,
186
+ });
187
+
188
+ if (!res.ok) {
189
+ const text = await res.text().catch(() => '');
190
+ log.error('이미지 업로드 실패', { status: res.status, body: text.substring(0, 500) });
191
+ throw new Error(`이미지 업로드 실패: ${res.status}`);
192
+ }
193
+ const data = await res.json();
194
+ log.info('이미지 업로드 성공', { url: data.url, key: data.key, filename: data.filename });
195
+
196
+ if (!data.url) throw new Error('이미지 업로드 응답에 URL이 없습니다.');
197
+ return data;
198
+ };
199
+
200
+ module.exports = { initBlog, getBlogName, getBlogInfo, publishPost, saveDraft, getPosts, getCategories, uploadImage, VISIBILITY };
201
+
202
+ // CLI 모드
203
+ if (require.main === module) {
204
+ const args = process.argv.slice(2);
205
+ const cmd = args[0];
206
+
207
+ initBlog().then(() => {
208
+
209
+ if (cmd === 'publish') {
210
+ publishPost({
211
+ title: args[1] || '테스트 글',
212
+ content: args[2] || '<p>자동 작성된 글입니다.</p>',
213
+ visibility: Number(args[3]) || 0,
214
+ category: Number(args[4]) || 0,
215
+ tag: args[5] || '',
216
+ });
217
+ } else if (cmd === 'draft') {
218
+ saveDraft({
219
+ title: args[1] || '임시저장 테스트',
220
+ content: args[2] || '<p>임시저장 내용</p>',
221
+ });
222
+ } else if (cmd === 'list') {
223
+ getPosts().then(d => {
224
+ console.log(`총 ${d.totalCount}개 글`);
225
+ d.items?.forEach(p => console.log(` [${p.id}] ${p.title} (${p.visibility})`));
226
+ });
227
+ } else if (cmd === 'categories') {
228
+ getCategories().then(cats => {
229
+ console.log('카테고리 목록:');
230
+ Object.entries(cats).forEach(([name, id]) => console.log(` ${id} → ${name}`));
231
+ });
232
+ } else {
233
+ console.log(`사용법:
234
+ node tistory.js publish "제목" "<p>내용</p>" [visibility] [categoryId] [tags]
235
+ node tistory.js draft "제목" "<p>내용</p>"
236
+ node tistory.js list
237
+ node tistory.js categories
238
+
239
+ visibility: 0=비공개, 3=공개`);
240
+ }
241
+
242
+ }).catch(e => console.error(`초기화 실패: ${e.message}`));
243
+ }