viruagent-cli 0.3.2 → 0.3.4

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,117 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { saveProviderMeta } = require('../../storage/sessionStore');
4
+ const { sleep, readCredentialsFromEnv, parseSessionError, buildLoginErrorMessage } = require('./utils');
5
+ const { clickKakaoAccountContinue } = require('./browserHelpers');
6
+
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
+ });
13
+ };
14
+
15
+ const waitForLoginFinish = async (page, context, timeoutMs = 45000) => {
16
+ const deadline = Date.now() + timeoutMs;
17
+ while (Date.now() < deadline) {
18
+ if (await isLoggedInByCookies(context)) {
19
+ return true;
20
+ }
21
+
22
+ if (await clickKakaoAccountContinue(page)) {
23
+ continue;
24
+ }
25
+
26
+ const url = page.url();
27
+ if (!url.includes('/auth/login') && !url.includes('accounts.kakao.com/login') && !url.includes('kauth.kakao.com')) {
28
+ return true;
29
+ }
30
+
31
+ await sleep(1000);
32
+ }
33
+ return false;
34
+ };
35
+
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
+ }));
46
+
47
+ const payload = {
48
+ cookies: sanitized,
49
+ updatedAt: new Date().toISOString(),
50
+ };
51
+ await fs.promises.mkdir(path.dirname(targetSessionPath), { recursive: true });
52
+ await fs.promises.writeFile(
53
+ targetSessionPath,
54
+ JSON.stringify(payload, null, 2),
55
+ 'utf-8'
56
+ );
57
+ };
58
+
59
+ /**
60
+ * withProviderSession 팩토리.
61
+ * askForAuthentication을 외부에서 주입받아 스코프 버그를 해결한다.
62
+ */
63
+ const createWithProviderSession = (askForAuthentication) => async (fn) => {
64
+ const credentials = readCredentialsFromEnv();
65
+ const hasCredentials = Boolean(credentials.username && credentials.password);
66
+
67
+ try {
68
+ const result = await fn();
69
+ saveProviderMeta('tistory', {
70
+ loggedIn: true,
71
+ lastValidatedAt: new Date().toISOString(),
72
+ });
73
+ return result;
74
+ } catch (error) {
75
+ if (!parseSessionError(error) || !hasCredentials) {
76
+ throw error;
77
+ }
78
+
79
+ try {
80
+ const loginResult = await askForAuthentication({
81
+ headless: false,
82
+ manual: false,
83
+ username: credentials.username,
84
+ password: credentials.password,
85
+ });
86
+
87
+ saveProviderMeta('tistory', {
88
+ loggedIn: loginResult.loggedIn,
89
+ blogName: loginResult.blogName,
90
+ blogUrl: loginResult.blogUrl,
91
+ sessionPath: loginResult.sessionPath,
92
+ lastRefreshedAt: new Date().toISOString(),
93
+ lastError: null,
94
+ });
95
+
96
+ if (!loginResult.loggedIn) {
97
+ throw new Error(loginResult.message || '세션 갱신 후 로그인 상태가 확인되지 않았습니다.');
98
+ }
99
+
100
+ return fn();
101
+ } catch (reloginError) {
102
+ saveProviderMeta('tistory', {
103
+ loggedIn: false,
104
+ lastError: buildLoginErrorMessage(reloginError),
105
+ lastValidatedAt: new Date().toISOString(),
106
+ });
107
+ throw reloginError;
108
+ }
109
+ }
110
+ };
111
+
112
+ module.exports = {
113
+ isLoggedInByCookies,
114
+ waitForLoginFinish,
115
+ persistTistorySession,
116
+ createWithProviderSession,
117
+ };
@@ -0,0 +1,235 @@
1
+ const crypto = require('crypto');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const readline = require('readline');
6
+
7
+ const IMAGE_TRACE_ENABLED = process.env.VIRUAGENT_IMAGE_TRACE === '1';
8
+
9
+ const imageTrace = (message, data) => {
10
+ if (!IMAGE_TRACE_ENABLED) {
11
+ return;
12
+ }
13
+ if (data === undefined) {
14
+ console.log(`[이미지 추적] ${message}`);
15
+ return;
16
+ }
17
+ console.log(`[이미지 추적] ${message}`, data);
18
+ };
19
+
20
+ const MAX_IMAGE_UPLOAD_COUNT = 1;
21
+
22
+ const IMAGE_PLACEHOLDER_REGEX = /<!--\s*IMAGE:\s*([^>]*?)\s*-->/g;
23
+
24
+ const readCredentialsFromEnv = () => {
25
+ const username = process.env.TISTORY_USERNAME || process.env.TISTORY_USER || process.env.TISTORY_ID;
26
+ const password = process.env.TISTORY_PASSWORD || process.env.TISTORY_PW;
27
+ return {
28
+ username: typeof username === 'string' && username.trim() ? username.trim() : null,
29
+ password: typeof password === 'string' && password.trim() ? password.trim() : null,
30
+ };
31
+ };
32
+
33
+ const mapVisibility = (visibility) => {
34
+ const normalized = String(visibility || 'public').toLowerCase();
35
+ if (Number.isFinite(Number(visibility)) && [0, 15, 20].includes(Number(visibility))) {
36
+ return Number(visibility);
37
+ }
38
+ if (normalized === 'private') return 0;
39
+ if (normalized === 'protected') return 15;
40
+ return 20;
41
+ };
42
+
43
+ const normalizeTagList = (value = '') => {
44
+ const source = Array.isArray(value)
45
+ ? value
46
+ : String(value || '').replace(/\r?\n/g, ',').split(',');
47
+
48
+ return source
49
+ .map((tag) => String(tag || '').trim())
50
+ .filter(Boolean)
51
+ .map((tag) => tag.replace(/["']/g, '').trim())
52
+ .filter(Boolean)
53
+ .slice(0, 10)
54
+ .join(',');
55
+ };
56
+
57
+ const parseSessionError = (error) => {
58
+ const message = String(error?.message || '').toLowerCase();
59
+ return [
60
+ '세션이 만료',
61
+ '세션에 유효한 쿠키',
62
+ '세션 파일이 없습니다',
63
+ '블로그 정보 조회 실패: 401',
64
+ '블로그 정보 조회 실패: 403',
65
+ '세션이 만료되었습니다',
66
+ '다시 로그인',
67
+ ].some((token) => message.includes(token.toLowerCase()));
68
+ };
69
+
70
+ const buildLoginErrorMessage = (error) => String(error?.message || '세션 검증에 실패했습니다.');
71
+
72
+ const promptCategorySelection = async (categories = []) => {
73
+ if (!process.stdin || !process.stdin.isTTY) {
74
+ return null;
75
+ }
76
+ if (!Array.isArray(categories) || categories.length === 0) {
77
+ return null;
78
+ }
79
+
80
+ const candidates = categories.map((category, index) => `${index + 1}. ${category.name} (${category.id})`);
81
+ const lines = [
82
+ '발행할 카테고리를 선택해 주세요.',
83
+ ...candidates,
84
+ `입력: 번호(1-${categories.length}) 또는 카테고리 ID (엔터 입력 시 건너뛰기)`,
85
+ ];
86
+ const prompt = `${lines.join('\n')}\n> `;
87
+
88
+ const parseSelection = (input) => {
89
+ const normalized = String(input || '').trim();
90
+ if (!normalized) {
91
+ return null;
92
+ }
93
+
94
+ const numeric = Number(normalized);
95
+ if (Number.isInteger(numeric) && numeric > 0) {
96
+ if (numeric <= categories.length) {
97
+ return Number(categories[numeric - 1].id);
98
+ }
99
+ const matchedById = categories.find((item) => Number(item.id) === numeric);
100
+ if (matchedById) {
101
+ return Number(matchedById.id);
102
+ }
103
+ }
104
+
105
+ return null;
106
+ };
107
+
108
+ return new Promise((resolve) => {
109
+ const rl = readline.createInterface({
110
+ input: process.stdin,
111
+ output: process.stdout,
112
+ });
113
+
114
+ const ask = (retryCount = 0) => {
115
+ rl.question(prompt, (input) => {
116
+ const selectedId = parseSelection(input);
117
+ if (selectedId) {
118
+ rl.close();
119
+ resolve(selectedId);
120
+ return;
121
+ }
122
+
123
+ if (retryCount >= 2) {
124
+ rl.close();
125
+ resolve(null);
126
+ return;
127
+ }
128
+
129
+ console.log('잘못된 입력입니다. 번호 또는 카테고리 ID를 다시 입력해 주세요.');
130
+ ask(retryCount + 1);
131
+ });
132
+ };
133
+
134
+ ask(0);
135
+ });
136
+ };
137
+
138
+ const isPublishLimitError = (error) => {
139
+ const message = String(error?.message || '');
140
+ return /발행 실패:\s*403/.test(message) || /\b403\b/.test(message);
141
+ };
142
+
143
+ const isProvidedCategory = (value) => {
144
+ return value !== undefined && value !== null && String(value).trim() !== '';
145
+ };
146
+
147
+ const buildCategoryList = (rawCategories) => {
148
+ const entries = Object.entries(rawCategories || {});
149
+ const categories = entries.map(([name, id]) => ({
150
+ name,
151
+ id: Number(id),
152
+ }));
153
+ return categories.sort((a, b) => a.id - b.id);
154
+ };
155
+
156
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
157
+
158
+ const escapeRegExp = (value = '') => {
159
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
160
+ };
161
+
162
+ const sanitizeKeywordForFilename = (value = '') => {
163
+ return String(value)
164
+ .trim()
165
+ .toLowerCase()
166
+ .replace(/[^a-z0-9]+/gi, '-')
167
+ .replace(/^-+|-+$/g, '')
168
+ .replace(/-{2,}/g, '-')
169
+ .slice(0, 50) || 'image';
170
+ };
171
+
172
+ const normalizeTempDir = () => {
173
+ const tmpDir = path.join(os.tmpdir(), 'viruagent-cli-images');
174
+ fs.mkdirSync(tmpDir, { recursive: true });
175
+ return tmpDir;
176
+ };
177
+
178
+ const buildImageFileName = (keyword, ext = 'jpg') => {
179
+ const base = sanitizeKeywordForFilename(keyword || 'image');
180
+ const random = crypto.randomBytes(4).toString('hex');
181
+ return `${base}-${random}.${ext}`;
182
+ };
183
+
184
+ const dedupeTextValues = (values = []) => {
185
+ const seen = new Set();
186
+ return values
187
+ .filter(Boolean)
188
+ .map((value) => String(value || '').trim())
189
+ .filter(Boolean)
190
+ .filter((value) => {
191
+ if (seen.has(value)) {
192
+ return false;
193
+ }
194
+ seen.add(value);
195
+ return true;
196
+ });
197
+ };
198
+
199
+ const dedupeImageSources = (sources = []) => {
200
+ const seen = new Set();
201
+ return sources
202
+ .filter(Boolean)
203
+ .map((source) => String(source || '').trim())
204
+ .filter(Boolean)
205
+ .filter((source) => {
206
+ if (seen.has(source)) {
207
+ return false;
208
+ }
209
+ seen.add(source);
210
+ return true;
211
+ });
212
+ };
213
+
214
+ module.exports = {
215
+ IMAGE_TRACE_ENABLED,
216
+ imageTrace,
217
+ MAX_IMAGE_UPLOAD_COUNT,
218
+ IMAGE_PLACEHOLDER_REGEX,
219
+ readCredentialsFromEnv,
220
+ mapVisibility,
221
+ normalizeTagList,
222
+ parseSessionError,
223
+ buildLoginErrorMessage,
224
+ promptCategorySelection,
225
+ isPublishLimitError,
226
+ isProvidedCategory,
227
+ buildCategoryList,
228
+ sleep,
229
+ escapeRegExp,
230
+ sanitizeKeywordForFilename,
231
+ normalizeTempDir,
232
+ buildImageFileName,
233
+ dedupeTextValues,
234
+ dedupeImageSources,
235
+ };
@@ -1,6 +1,6 @@
1
1
  const path = require('path');
2
2
  const { getSessionPath } = require('../storage/sessionStore');
3
- const createTistoryProvider = require('../providers/tistoryProvider');
3
+ const createTistoryProvider = require('../providers/tistory');
4
4
  const createNaverProvider = require('../providers/naverProvider');
5
5
 
6
6
  const providerFactory = {