viruagent 1.0.1 → 1.2.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.
- package/README.md +53 -15
- package/config/agent-prompt.md +31 -0
- package/docs/agent-pattern-guide.md +574 -0
- package/docs/hybrid-db-agent-guide.md +484 -0
- package/package.json +1 -2
- package/src/agent.js +208 -42
- package/src/cli-post.js +11 -1
- package/src/lib/ai.js +295 -16
- package/src/lib/unsplash.js +5 -7
- package/.env.example +0 -2
package/src/agent.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const readline = require('readline');
|
|
2
2
|
const chalk = require('chalk');
|
|
3
|
-
const { generatePost, revisePost, chat, MODELS, loadConfig } = require('./lib/ai');
|
|
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
5
|
|
|
6
6
|
const TONES = loadConfig().tones.map((t) => t.name);
|
|
@@ -63,23 +63,32 @@ const selectMenu = (items, title = '선택하세요') =>
|
|
|
63
63
|
// 설정 저장/로드
|
|
64
64
|
const fs = require('fs');
|
|
65
65
|
const path = require('path');
|
|
66
|
-
const
|
|
66
|
+
const os = require('os');
|
|
67
|
+
const CONFIG_DIR = path.join(os.homedir(), '.viruagent');
|
|
68
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
67
69
|
|
|
68
70
|
const DEFAULTS = { category: 0, visibility: 20, model: 'gpt-4o-mini', tone: loadConfig().defaultTone };
|
|
69
71
|
|
|
70
72
|
const visLabel = (v) => (v === 20 ? '공개 발행' : v === 15 ? '보호 발행' : '비공개 발행');
|
|
71
73
|
|
|
74
|
+
const ensureConfigDir = () => {
|
|
75
|
+
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
76
|
+
};
|
|
77
|
+
|
|
72
78
|
const loadSettings = () => {
|
|
73
79
|
try {
|
|
74
|
-
return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(
|
|
80
|
+
return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) };
|
|
75
81
|
} catch {
|
|
76
82
|
return { ...DEFAULTS };
|
|
77
83
|
}
|
|
78
84
|
};
|
|
79
85
|
|
|
80
86
|
const saveSettings = () => {
|
|
87
|
+
ensureConfigDir();
|
|
88
|
+
const current = loadSettings();
|
|
81
89
|
const { category, visibility, model, tone } = state;
|
|
82
|
-
|
|
90
|
+
const merged = { ...current, category, visibility, model, tone };
|
|
91
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2));
|
|
83
92
|
};
|
|
84
93
|
|
|
85
94
|
const saved = loadSettings();
|
|
@@ -115,7 +124,8 @@ const animateBanner = async () => {
|
|
|
115
124
|
const text = figlet.textSync('ViruAgent', { font: 'ANSI Shadow' });
|
|
116
125
|
const lines = text.split('\n');
|
|
117
126
|
const totalLines = lines.length;
|
|
118
|
-
const
|
|
127
|
+
const { version } = require('../package.json');
|
|
128
|
+
const sub = ` 대화형 티스토리 블로그 에이전트 v${version}`;
|
|
119
129
|
|
|
120
130
|
console.log();
|
|
121
131
|
|
|
@@ -152,8 +162,10 @@ const showBootStep = async (msg, asyncFn, minMs = 800) => {
|
|
|
152
162
|
|
|
153
163
|
const withSpinner = async (message, asyncFn) => {
|
|
154
164
|
let i = 0;
|
|
165
|
+
const cols = process.stdout.columns || 80;
|
|
166
|
+
const truncated = message.length + 2 > cols ? message.slice(0, cols - 5) + '...' : message;
|
|
155
167
|
const timer = setInterval(() => {
|
|
156
|
-
process.stdout.write(`\r${chalk.cyan(SPINNER_FRAMES[i++ % SPINNER_FRAMES.length])} ${
|
|
168
|
+
process.stdout.write(`\r\x1B[K${chalk.cyan(SPINNER_FRAMES[i++ % SPINNER_FRAMES.length])} ${truncated}`);
|
|
157
169
|
}, 80);
|
|
158
170
|
try {
|
|
159
171
|
return await asyncFn();
|
|
@@ -177,7 +189,7 @@ const COMMANDS = [
|
|
|
177
189
|
'/help',
|
|
178
190
|
'/exit',
|
|
179
191
|
];
|
|
180
|
-
const SET_KEYS = ['category', 'visibility', 'model', 'tone'];
|
|
192
|
+
const SET_KEYS = ['category', 'visibility', 'model', 'tone', 'api'];
|
|
181
193
|
|
|
182
194
|
const completer = (line) => {
|
|
183
195
|
// /set model <값> 자동완성
|
|
@@ -234,7 +246,7 @@ const COMMAND_HINTS = {
|
|
|
234
246
|
'/draft': '/draft',
|
|
235
247
|
'/list': '/list',
|
|
236
248
|
'/categories': '/categories',
|
|
237
|
-
'/set': '/set <category|visibility|model|tone>',
|
|
249
|
+
'/set': '/set <category|visibility|model|tone|api>',
|
|
238
250
|
'/login': '/login',
|
|
239
251
|
'/logout': '/logout',
|
|
240
252
|
'/help': '/help',
|
|
@@ -517,8 +529,53 @@ const commands = {
|
|
|
517
529
|
log.dim('취소됨');
|
|
518
530
|
}
|
|
519
531
|
}
|
|
532
|
+
} else if (key === 'api') {
|
|
533
|
+
const keys = loadApiKeys();
|
|
534
|
+
const mask = (v) => v ? `${v.slice(0, 8)}${'*'.repeat(8)}` : chalk.dim('(미설정)');
|
|
535
|
+
|
|
536
|
+
console.log();
|
|
537
|
+
log.title('API Key 설정');
|
|
538
|
+
console.log(` OpenAI: ${mask(keys.OPENAI_API_KEY)}`);
|
|
539
|
+
console.log(` Unsplash: ${mask(keys.UNSPLASH_ACCESS_KEY)}`);
|
|
540
|
+
console.log();
|
|
541
|
+
|
|
542
|
+
rl.pause();
|
|
543
|
+
const apiOptions = ['OpenAI API Key', 'Unsplash Access Key', '취소'];
|
|
544
|
+
const idx = await selectMenu(apiOptions, 'API Key 선택 (↑↓ 이동, Enter 선택, Esc 취소)');
|
|
545
|
+
rl.resume();
|
|
546
|
+
|
|
547
|
+
if (idx === 0) {
|
|
548
|
+
rl.pause();
|
|
549
|
+
const newKey = await askQuestion(chalk.cyan(' 새 OpenAI API Key: '));
|
|
550
|
+
rl.resume();
|
|
551
|
+
if (newKey) {
|
|
552
|
+
saveApiKeys({ OPENAI_API_KEY: newKey });
|
|
553
|
+
log.success('OpenAI API Key가 저장되었습니다.');
|
|
554
|
+
log.warn('다음 세션부터 적용됩니다. 프로그램을 재시작하세요.');
|
|
555
|
+
} else {
|
|
556
|
+
log.dim('변경 없음');
|
|
557
|
+
}
|
|
558
|
+
} else if (idx === 1) {
|
|
559
|
+
rl.pause();
|
|
560
|
+
const newKey = await askQuestion(chalk.cyan(' 새 Unsplash Access Key (삭제하려면 빈 값): '));
|
|
561
|
+
rl.resume();
|
|
562
|
+
if (newKey) {
|
|
563
|
+
saveApiKeys({ UNSPLASH_ACCESS_KEY: newKey });
|
|
564
|
+
log.success('Unsplash Access Key가 저장되었습니다.');
|
|
565
|
+
log.warn('다음 세션부터 적용됩니다. 프로그램을 재시작하세요.');
|
|
566
|
+
} else if (keys.UNSPLASH_ACCESS_KEY) {
|
|
567
|
+
saveApiKeys({ UNSPLASH_ACCESS_KEY: '' });
|
|
568
|
+
log.success('Unsplash Access Key가 삭제되었습니다.');
|
|
569
|
+
log.warn('다음 세션부터 적용됩니다. 프로그램을 재시작하세요.');
|
|
570
|
+
} else {
|
|
571
|
+
log.dim('변경 없음');
|
|
572
|
+
}
|
|
573
|
+
} else {
|
|
574
|
+
log.dim('취소됨');
|
|
575
|
+
}
|
|
576
|
+
return;
|
|
520
577
|
} else {
|
|
521
|
-
return log.warn('사용법: /set category | /set visibility | /set model | /set tone');
|
|
578
|
+
return log.warn('사용법: /set category | /set visibility | /set model | /set tone | /set api');
|
|
522
579
|
}
|
|
523
580
|
saveSettings();
|
|
524
581
|
},
|
|
@@ -538,12 +595,14 @@ ${chalk.cyan('/set category')} 카테고리 설정
|
|
|
538
595
|
${chalk.cyan('/set visibility')} 공개설정
|
|
539
596
|
${chalk.cyan('/set model')} AI 모델 선택
|
|
540
597
|
${chalk.cyan('/set tone')} 글쓰기 톤 설정
|
|
598
|
+
${chalk.cyan('/set api')} API Key 관리 (OpenAI, Unsplash)
|
|
541
599
|
${chalk.cyan('/login')} 티스토리 로그인
|
|
542
600
|
${chalk.cyan('/logout')} 로그아웃 (세션 삭제)
|
|
543
601
|
${chalk.cyan('/help')} 도움말
|
|
544
602
|
${chalk.cyan('/exit')} 종료
|
|
545
603
|
|
|
546
|
-
슬래시 없이 입력하면 AI
|
|
604
|
+
슬래시 없이 자연어로 입력하면 AI가 자율적으로 도구를 호출합니다.
|
|
605
|
+
${chalk.dim('예: "AI 트렌드로 글 써줘", "서론을 더 흥미롭게 수정해줘", "발행해줘"')}
|
|
547
606
|
`);
|
|
548
607
|
},
|
|
549
608
|
|
|
@@ -601,12 +660,57 @@ const handleInput = async (input) => {
|
|
|
601
660
|
log.warn(`알 수 없는 명령어: /${cmd}. /help로 확인하세요.`);
|
|
602
661
|
}
|
|
603
662
|
} else {
|
|
604
|
-
//
|
|
605
|
-
state.chatHistory.push({ role: 'user', content: trimmed });
|
|
663
|
+
// 에이전트 루프 (자연어 → 자율 도구 호출)
|
|
606
664
|
try {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
665
|
+
// Braille 도트 애니메이션 (텍스트 없이)
|
|
666
|
+
const DOT_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
667
|
+
const DOT_COLORS = [chalk.cyan, chalk.blue, chalk.magenta, chalk.blue];
|
|
668
|
+
let spinnerTimer = null;
|
|
669
|
+
let spinnerIdx = 0;
|
|
670
|
+
|
|
671
|
+
const startSpinner = () => {
|
|
672
|
+
stopSpinner();
|
|
673
|
+
spinnerTimer = setInterval(() => {
|
|
674
|
+
const colorFn = DOT_COLORS[Math.floor(spinnerIdx / 3) % DOT_COLORS.length];
|
|
675
|
+
process.stdout.write(`\r\x1B[K ${colorFn(DOT_FRAMES[spinnerIdx++ % DOT_FRAMES.length])}`);
|
|
676
|
+
}, 80);
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
const stopSpinner = () => {
|
|
680
|
+
if (spinnerTimer) {
|
|
681
|
+
clearInterval(spinnerTimer);
|
|
682
|
+
spinnerTimer = null;
|
|
683
|
+
process.stdout.write('\r\x1B[K');
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
startSpinner();
|
|
688
|
+
|
|
689
|
+
const reply = await runAgent(trimmed, {
|
|
690
|
+
state,
|
|
691
|
+
publishPost: publishPost,
|
|
692
|
+
onToolCall: () => {},
|
|
693
|
+
onToolResult: (name, result) => {
|
|
694
|
+
stopSpinner();
|
|
695
|
+
if (result?.error) {
|
|
696
|
+
log.warn(result.error);
|
|
697
|
+
} else if (name === 'generate_post' && result?.title) {
|
|
698
|
+
log.success(`글 생성 완료: "${result.title}"`);
|
|
699
|
+
} else if (name === 'edit_post' && result?.title) {
|
|
700
|
+
log.success(`수정 완료: "${result.title}"`);
|
|
701
|
+
} else if (name === 'publish_post' && result?.url) {
|
|
702
|
+
log.success(`발행 완료! ${result.url}`);
|
|
703
|
+
} else if (name === 'set_category' && result?.category) {
|
|
704
|
+
log.success(`카테고리 설정: ${result.category}`);
|
|
705
|
+
} else if (name === 'set_visibility' && result?.visibility) {
|
|
706
|
+
log.success(`공개설정: ${result.visibility}`);
|
|
707
|
+
}
|
|
708
|
+
startSpinner();
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
stopSpinner();
|
|
713
|
+
console.log(`\n${chalk.blue('AI')}\n${reply}\n`);
|
|
610
714
|
} catch (e) {
|
|
611
715
|
log.error(`대화 실패: ${e.message}`);
|
|
612
716
|
}
|
|
@@ -622,37 +726,91 @@ const askQuestion = (query) =>
|
|
|
622
726
|
});
|
|
623
727
|
});
|
|
624
728
|
|
|
729
|
+
const loadApiKeys = () => {
|
|
730
|
+
const settings = loadSettings();
|
|
731
|
+
return {
|
|
732
|
+
OPENAI_API_KEY: settings.openaiApiKey || '',
|
|
733
|
+
UNSPLASH_ACCESS_KEY: settings.unsplashAccessKey || '',
|
|
734
|
+
};
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
const saveApiKeys = (keys) => {
|
|
738
|
+
ensureConfigDir();
|
|
739
|
+
const current = loadSettings();
|
|
740
|
+
if (keys.OPENAI_API_KEY !== undefined) current.openaiApiKey = keys.OPENAI_API_KEY || undefined;
|
|
741
|
+
if (keys.UNSPLASH_ACCESS_KEY !== undefined) current.unsplashAccessKey = keys.UNSPLASH_ACCESS_KEY || undefined;
|
|
742
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(current, null, 2));
|
|
743
|
+
// 환경 변수에 반영
|
|
744
|
+
if (keys.OPENAI_API_KEY) process.env.OPENAI_API_KEY = keys.OPENAI_API_KEY;
|
|
745
|
+
if (keys.UNSPLASH_ACCESS_KEY) process.env.UNSPLASH_ACCESS_KEY = keys.UNSPLASH_ACCESS_KEY;
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
const applyApiKeys = () => {
|
|
749
|
+
const keys = loadApiKeys();
|
|
750
|
+
if (keys.OPENAI_API_KEY) process.env.OPENAI_API_KEY = keys.OPENAI_API_KEY;
|
|
751
|
+
if (keys.UNSPLASH_ACCESS_KEY) process.env.UNSPLASH_ACCESS_KEY = keys.UNSPLASH_ACCESS_KEY;
|
|
752
|
+
};
|
|
753
|
+
|
|
625
754
|
const setupEnv = async () => {
|
|
626
|
-
|
|
627
|
-
if (
|
|
755
|
+
applyApiKeys();
|
|
756
|
+
if (process.env.OPENAI_API_KEY) return;
|
|
628
757
|
|
|
629
758
|
console.log();
|
|
630
759
|
log.title('━━━ 초기 설정 ━━━');
|
|
631
760
|
console.log();
|
|
632
|
-
log.info('
|
|
761
|
+
log.info('OpenAI API Key가 설정되지 않았습니다.\n');
|
|
762
|
+
log.dim(' https://platform.openai.com/api-keys 에서 발급받을 수 있습니다.\n');
|
|
633
763
|
|
|
634
|
-
const openaiKey = await askQuestion(chalk.cyan(' OpenAI API Key
|
|
764
|
+
const openaiKey = await askQuestion(chalk.cyan(' OpenAI API Key: '));
|
|
635
765
|
if (!openaiKey) {
|
|
636
766
|
log.error('OpenAI API Key는 필수입니다. 프로그램을 종료합니다.');
|
|
637
767
|
process.exit(1);
|
|
638
768
|
}
|
|
639
769
|
|
|
640
|
-
|
|
770
|
+
// OpenAI Key 검증
|
|
771
|
+
try {
|
|
772
|
+
const res = await fetch('https://api.openai.com/v1/models', {
|
|
773
|
+
headers: { Authorization: `Bearer ${openaiKey}` },
|
|
774
|
+
});
|
|
775
|
+
if (!res.ok) {
|
|
776
|
+
log.error('OpenAI API Key가 유효하지 않습니다. 키를 확인 후 다시 시도하세요.');
|
|
777
|
+
process.exit(1);
|
|
778
|
+
}
|
|
779
|
+
log.success('OpenAI API Key 확인 완료!');
|
|
780
|
+
} catch {
|
|
781
|
+
log.error('OpenAI 서버에 연결할 수 없습니다. 네트워크를 확인하세요.');
|
|
782
|
+
process.exit(1);
|
|
783
|
+
}
|
|
641
784
|
|
|
642
|
-
const
|
|
643
|
-
if (unsplashKey) lines.push(`UNSPLASH_ACCESS_KEY=${unsplashKey}`);
|
|
785
|
+
const unsplashKey = await askQuestion(chalk.cyan(' Unsplash Access Key (선택, Enter로 건너뛰기): '));
|
|
644
786
|
|
|
645
|
-
|
|
646
|
-
|
|
787
|
+
if (unsplashKey) {
|
|
788
|
+
try {
|
|
789
|
+
const res = await fetch('https://api.unsplash.com/photos/random?count=1', {
|
|
790
|
+
headers: { Authorization: `Client-ID ${unsplashKey}` },
|
|
791
|
+
});
|
|
792
|
+
if (!res.ok) {
|
|
793
|
+
log.warn('Unsplash Key가 유효하지 않습니다. 건너뜁니다.');
|
|
794
|
+
} else {
|
|
795
|
+
log.success('Unsplash Access Key 확인 완료!');
|
|
796
|
+
}
|
|
797
|
+
} catch {
|
|
798
|
+
log.warn('Unsplash 서버에 연결할 수 없습니다. 건너뜁니다.');
|
|
799
|
+
}
|
|
800
|
+
}
|
|
647
801
|
|
|
648
|
-
|
|
649
|
-
|
|
802
|
+
saveApiKeys({
|
|
803
|
+
OPENAI_API_KEY: openaiKey,
|
|
804
|
+
...(unsplashKey && { UNSPLASH_ACCESS_KEY: unsplashKey }),
|
|
805
|
+
});
|
|
806
|
+
log.success(`설정이 저장되었습니다! (${CONFIG_PATH})\n`);
|
|
807
|
+
if (!unsplashKey) log.dim(' Unsplash는 나중에 /set api 에서 설정할 수 있습니다.\n');
|
|
650
808
|
};
|
|
651
809
|
|
|
652
810
|
const main = async () => {
|
|
653
811
|
await animateBanner();
|
|
654
812
|
|
|
655
|
-
//
|
|
813
|
+
// API Key 없으면 초기 설정
|
|
656
814
|
await setupEnv();
|
|
657
815
|
|
|
658
816
|
// 세션 체크 — 로그인 필수
|
|
@@ -672,28 +830,36 @@ const main = async () => {
|
|
|
672
830
|
// 부팅 시퀀스
|
|
673
831
|
console.log(chalk.dim(' 시스템 초기화 중...\n'));
|
|
674
832
|
|
|
675
|
-
|
|
676
|
-
'OpenAI 연결',
|
|
677
|
-
async () => {
|
|
678
|
-
const res = await fetch('https://api.openai.com/v1/models', {
|
|
679
|
-
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
|
|
680
|
-
});
|
|
681
|
-
if (!res.ok) throw new Error('키가 유효하지 않습니다');
|
|
682
|
-
},
|
|
683
|
-
1000,
|
|
684
|
-
);
|
|
685
|
-
|
|
686
|
-
if (process.env.UNSPLASH_ACCESS_KEY) {
|
|
833
|
+
try {
|
|
687
834
|
await showBootStep(
|
|
688
|
-
'
|
|
835
|
+
'OpenAI 연결',
|
|
689
836
|
async () => {
|
|
690
|
-
const res = await fetch('https://api.
|
|
691
|
-
headers: { Authorization: `
|
|
837
|
+
const res = await fetch('https://api.openai.com/v1/models', {
|
|
838
|
+
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
|
|
692
839
|
});
|
|
693
840
|
if (!res.ok) throw new Error('키가 유효하지 않습니다');
|
|
694
841
|
},
|
|
695
842
|
1000,
|
|
696
843
|
);
|
|
844
|
+
} catch (e) {
|
|
845
|
+
log.warn(` OpenAI 연결 실패: ${e.message}\n ${chalk.dim('/set api 명령어로 키를 확인하세요.')}`);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (process.env.UNSPLASH_ACCESS_KEY) {
|
|
849
|
+
try {
|
|
850
|
+
await showBootStep(
|
|
851
|
+
'Unsplash 연결',
|
|
852
|
+
async () => {
|
|
853
|
+
const res = await fetch('https://api.unsplash.com/photos/random?count=1', {
|
|
854
|
+
headers: { Authorization: `Client-ID ${process.env.UNSPLASH_ACCESS_KEY}` },
|
|
855
|
+
});
|
|
856
|
+
if (!res.ok) throw new Error('키가 유효하지 않습니다');
|
|
857
|
+
},
|
|
858
|
+
1000,
|
|
859
|
+
);
|
|
860
|
+
} catch (e) {
|
|
861
|
+
log.warn(` Unsplash 연결 실패: ${e.message}\n ${chalk.dim('/set api 명령어로 키를 확인하세요.')}`);
|
|
862
|
+
}
|
|
697
863
|
}
|
|
698
864
|
|
|
699
865
|
let blogOk = false;
|
package/src/cli-post.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
|
-
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
// ~/.viruagent/config.json에서 API 키 로드
|
|
8
|
+
const configPath = path.join(os.homedir(), '.viruagent', 'config.json');
|
|
9
|
+
try {
|
|
10
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
11
|
+
if (config.openaiApiKey) process.env.OPENAI_API_KEY = config.openaiApiKey;
|
|
12
|
+
if (config.unsplashAccessKey) process.env.UNSPLASH_ACCESS_KEY = config.unsplashAccessKey;
|
|
13
|
+
} catch {}
|
|
14
|
+
|
|
5
15
|
|
|
6
16
|
const { generatePost, loadConfig } = require('./lib/ai');
|
|
7
17
|
const { initBlog, publishPost, saveDraft, getCategories, VISIBILITY } = require('./lib/tistory');
|