viruagent-cli 0.4.2 → 0.5.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.
@@ -37,14 +37,14 @@ const extractImagePlaceholders = (content = '') => {
37
37
 
38
38
  const fetchImageBuffer = async (url, retryCount = 0) => {
39
39
  if (!url) {
40
- throw new Error('이미지 URL 없습니다.');
40
+ throw new Error('Image URL is missing.');
41
41
  }
42
42
 
43
43
  const localPath = resolveLocalImagePath(url);
44
44
  if (localPath && !/https?:/.test(url)) {
45
45
  const buffer = await fs.promises.readFile(localPath);
46
46
  if (!buffer || buffer.length === 0) {
47
- throw new Error(`이미지 파일이 비어 있습니다: ${localPath}`);
47
+ throw new Error(`Image file is empty: ${localPath}`);
48
48
  }
49
49
 
50
50
  const extensionFromSignature = getImageSignatureExtension(buffer);
@@ -73,7 +73,7 @@ const fetchImageBuffer = async (url, retryCount = 0) => {
73
73
  });
74
74
 
75
75
  if (!response.ok) {
76
- throw new Error(`이미지 다운로드 실패: ${response.status} ${response.statusText} (${url})`);
76
+ throw new Error(`Image download failed: ${response.status} ${response.statusText} (${url})`);
77
77
  }
78
78
 
79
79
  const contentType = response.headers.get('content-type') || '';
@@ -99,7 +99,7 @@ const fetchImageBuffer = async (url, retryCount = 0) => {
99
99
  || extensionFromSignature;
100
100
 
101
101
  if (!isImage) {
102
- throw new Error(`이미지 콘텐츠가 아닙니다: ${contentType || '(미확인)'}, url=${finalUrl}`);
102
+ throw new Error(`Not an image content type: ${contentType || '(unknown)'}, url=${finalUrl}`);
103
103
  }
104
104
 
105
105
  return {
@@ -112,7 +112,7 @@ const fetchImageBuffer = async (url, retryCount = 0) => {
112
112
  await new Promise((resolve) => setTimeout(resolve, 800));
113
113
  return fetchImageBuffer(url, retryCount + 1);
114
114
  }
115
- throw new Error(`이미지 다운로드 실패: ${error.message}`);
115
+ throw new Error(`Image download failed: ${error.message}`);
116
116
  } finally {
117
117
  clearTimeout(timeout);
118
118
  }
@@ -124,10 +124,10 @@ const uploadImageFromRemote = async (api, remoteUrl, fallbackName = 'image', dep
124
124
  if (downloaded?.isHtml && downloaded?.html) {
125
125
  const extractedImageUrl = extractImageFromHtml(downloaded.html, downloaded.finalUrl || remoteUrl);
126
126
  if (!extractedImageUrl) {
127
- throw new Error('이미지 페이지에서 유효한 대표 이미지를 찾지 못했습니다.');
127
+ throw new Error('Could not find a valid representative image from the image page.');
128
128
  }
129
129
  if (depth >= 1 || extractedImageUrl === remoteUrl) {
130
- throw new Error('이미지 페이지에서 추출된 URL 유효하지 않아 업로드를 중단했습니다.');
130
+ throw new Error('The extracted URL from the image page is invalid. Upload aborted.');
131
131
  }
132
132
  return uploadImageFromRemote(api, extractedImageUrl, fallbackName, depth + 1);
133
133
  }
@@ -145,7 +145,7 @@ const uploadImageFromRemote = async (api, remoteUrl, fallbackName = 'image', dep
145
145
  const uploadedKage = normalizeUploadedImageThumbnail(uploaded) || (uploaded?.key ? `kage@${uploaded.key}` : null);
146
146
 
147
147
  if (!uploaded || !(uploaded.url || uploaded.key)) {
148
- throw new Error('이미지 업로드 응답이 비정상적입니다.');
148
+ throw new Error('Image upload response is invalid.');
149
149
  }
150
150
 
151
151
  return {
@@ -256,7 +256,7 @@ const replaceImagePlaceholdersWithUploaded = async (
256
256
  uploaded: [],
257
257
  uploadedCount: 0,
258
258
  status: 'need_image_urls',
259
- message: '자동 업로드할 이미지 후보 키워드가 없습니다. imageUrls 또는 relatedImageKeywords/플레이스홀더 키워드를 제공해 주세요.',
259
+ message: 'No image candidate keywords available for auto-upload. Please provide imageUrls or relatedImageKeywords/placeholder keywords.',
260
260
  requestedKeywords,
261
261
  requestedCount: requestedImageCount,
262
262
  providedImageUrls: collectedImageUrls.length,
@@ -275,7 +275,7 @@ const replaceImagePlaceholdersWithUploaded = async (
275
275
  index: i,
276
276
  sourceUrl: null,
277
277
  keyword: target.keyword,
278
- message: '이미지 소스가 없습니다.',
278
+ message: 'No image source available.',
279
279
  });
280
280
  continue;
281
281
  }
@@ -302,7 +302,7 @@ const replaceImagePlaceholdersWithUploaded = async (
302
302
  break;
303
303
  } catch (error) {
304
304
  lastMessage = error.message;
305
- console.log('이미지 처리 실패:', sourceUrl, error.message);
305
+ console.log('Image processing failed:', sourceUrl, error.message);
306
306
  }
307
307
  }
308
308
 
@@ -337,7 +337,7 @@ const replaceImagePlaceholdersWithUploaded = async (
337
337
  break;
338
338
  } catch (error) {
339
339
  lastMessage = error.message;
340
- console.log('이미지 처리 실패(보정 소스):', sourceUrl, error.message);
340
+ console.log('Image processing failed (fallback source):', sourceUrl, error.message);
341
341
  }
342
342
  }
343
343
  }
@@ -347,7 +347,7 @@ const replaceImagePlaceholdersWithUploaded = async (
347
347
  index: i,
348
348
  sourceUrl: uniqueSources[0],
349
349
  keyword: target.keyword,
350
- message: `이미지 업로드 실패(대체 이미지 재시도 포함): ${lastMessage}`,
350
+ message: `Image upload failed (including fallback retries): ${lastMessage}`,
351
351
  });
352
352
  continue;
353
353
  }
@@ -369,7 +369,7 @@ const replaceImagePlaceholdersWithUploaded = async (
369
369
  uploaded: [],
370
370
  uploadedCount: 0,
371
371
  status: 'image_upload_failed',
372
- message: '이미지 업로드에 실패했습니다. 수집한 이미지 URL을 확인해 다시 호출해 주세요.',
372
+ message: 'Image upload failed. Please verify the collected image URLs and try again.',
373
373
  errors: uploadErrors,
374
374
  requestedKeywords,
375
375
  requestedCount: requestedImageCount,
@@ -384,7 +384,7 @@ const replaceImagePlaceholdersWithUploaded = async (
384
384
  uploaded: uploadedImages,
385
385
  uploadedCount: uploadedImages.length,
386
386
  status: 'insufficient_images',
387
- message: `최소 이미지 업로드 장수를 충족하지 못했습니다. (요청: ${safeMinimumImageCount} / 실제: ${uploadedImages.length})`,
387
+ message: `Minimum image upload count not met. (required: ${safeMinimumImageCount} / actual: ${uploadedImages.length})`,
388
388
  errors: uploadErrors,
389
389
  requestedKeywords,
390
390
  requestedCount: requestedImageCount,
@@ -400,7 +400,7 @@ const replaceImagePlaceholdersWithUploaded = async (
400
400
  uploaded: uploadedImages,
401
401
  uploadedCount: uploadedImages.length,
402
402
  status: 'image_upload_partial',
403
- message: '일부 이미지 업로드가 실패했습니다.',
403
+ message: 'Some image uploads failed.',
404
404
  errors: uploadErrors,
405
405
  requestedCount: requestedImageCount,
406
406
  uploadedPlaceholders: uploadedImages.length,
@@ -414,7 +414,7 @@ const replaceImagePlaceholdersWithUploaded = async (
414
414
  uploaded: uploadedImages,
415
415
  uploadedCount: uploadedImages.length,
416
416
  status: 'insufficient_images',
417
- message: `최소 이미지 업로드 장수를 충족하지 못했습니다. (요청: ${safeMinimumImageCount} / 실제: ${uploadedImages.length})`,
417
+ message: `Minimum image upload count not met. (required: ${safeMinimumImageCount} / actual: ${uploadedImages.length})`,
418
418
  errors: uploadErrors,
419
419
  requestedKeywords,
420
420
  requestedCount: requestedImageCount,
@@ -546,8 +546,8 @@ const resolveMandatoryThumbnail = async ({
546
546
  .split(',')
547
547
  .map((item) => item.trim())),
548
548
  String(title || '').trim(),
549
- '뉴스 이미지',
550
- '뉴스',
549
+ 'news image',
550
+ 'news',
551
551
  'thumbnail',
552
552
  ]);
553
553
 
@@ -21,7 +21,7 @@ const normalizeKageFromUrl = (value) => {
21
21
  }
22
22
  }
23
23
  } catch {
24
- // URL 파싱이 실패하면 기존 정규식 경로로 폴백
24
+ // If URL parsing fails, fall back to regex-based path extraction
25
25
  }
26
26
 
27
27
  const directKageMatch = trimmed.match(/kage@([^|\s\]>"']+)/u);
@@ -174,8 +174,8 @@ const buildKeywordImageCandidates = async (keyword = '') => {
174
174
 
175
175
  const duckduckgoQueries = [
176
176
  safeKeyword,
177
- `${safeKeyword} 이미지`,
178
- `${safeKeyword} 뉴스`,
177
+ `${safeKeyword} image`,
178
+ `${safeKeyword} news`,
179
179
  ];
180
180
  const searchCandidates = [];
181
181
  const seen = new Set();
@@ -203,10 +203,10 @@ const buildKeywordImageCandidates = async (keyword = '') => {
203
203
 
204
204
  const fallbackQueries = [
205
205
  safeKeyword,
206
- `${safeKeyword} 이미지`,
206
+ `${safeKeyword} image`,
207
207
  `${safeKeyword} news`,
208
- '뉴스',
209
- '세계 뉴스',
208
+ 'news',
209
+ 'world news',
210
210
  ];
211
211
  for (const query of fallbackQueries) {
212
212
  if (searchCandidates.length >= 6) {
@@ -23,8 +23,8 @@ const createTistoryProvider = ({ sessionPath }) => {
23
23
  status: 'pending_2fa',
24
24
  loggedIn: false,
25
25
  message: mode === 'otp'
26
- ? '2차 인증이 필요합니다. otp 코드를 twoFactorCode로 전달해 주세요.'
27
- : '카카오 2차 인증이 필요합니다. 앱에서 인증 다시 실행하면 됩니다.',
26
+ ? '2FA is required. Please provide the OTP code via twoFactorCode.'
27
+ : 'Kakao 2FA is required. Please verify in the app and try again.',
28
28
  });
29
29
 
30
30
  const askForAuthentication = createAskForAuthentication({
@@ -80,7 +80,7 @@ const createTistoryProvider = ({ sessionPath }) => {
80
80
  };
81
81
 
82
82
  if (!resolved.manual && (!resolved.username || !resolved.password)) {
83
- throw new Error('티스토리 자동 로그인을 진행하려면 username/password 필요합니다. 요청 값으로 전달하거나, 환경변수 TISTORY_USERNAME / TISTORY_PASSWORD 설정해 주세요.');
83
+ throw new Error('Tistory auto-login requires username/password. Please provide them directly or set TISTORY_USERNAME/TISTORY_PASSWORD environment variables.');
84
84
  }
85
85
 
86
86
  const result = await askForAuthentication(resolved);
@@ -95,7 +95,7 @@ const createTistoryProvider = ({ sessionPath }) => {
95
95
 
96
96
  async publish(payload) {
97
97
  return withProviderSession(async () => {
98
- const title = payload.title || '제목 없음';
98
+ const title = payload.title || 'Untitled';
99
99
  const rawContent = payload.content || '';
100
100
  const visibility = mapVisibility(payload.visibility);
101
101
  const tag = normalizeTagList(payload.tags);
@@ -198,7 +198,7 @@ const createTistoryProvider = ({ sessionPath }) => {
198
198
  title,
199
199
  visibility,
200
200
  tags: tag,
201
- message: '발행을 위해 카테고리가 필요합니다. categories 확인하고 category를 지정해 주세요.',
201
+ message: 'A category is required for publishing. Please check categories and specify a category.',
202
202
  categories,
203
203
  };
204
204
  }
@@ -208,7 +208,7 @@ const createTistoryProvider = ({ sessionPath }) => {
208
208
  } else {
209
209
  if (!process.stdin || !process.stdin.isTTY) {
210
210
  const sampleCategory = categories.slice(0, 5).map((item) => `${item.id}: ${item.name}`).join(', ');
211
- const sampleText = sampleCategory.length > 0 ? ` 예: ${sampleCategory}` : '';
211
+ const sampleText = sampleCategory.length > 0 ? ` e.g. ${sampleCategory}` : '';
212
212
  return {
213
213
  provider: 'tistory',
214
214
  mode: 'publish',
@@ -217,7 +217,7 @@ const createTistoryProvider = ({ sessionPath }) => {
217
217
  title,
218
218
  visibility,
219
219
  tags: tag,
220
- message: `카테고리가 지정되지 않았습니다. 비대화형 환경에서는 --category 옵션이 필수입니다. 사용법: --category <카테고리ID>.${sampleText}`,
220
+ message: `No category specified. The --category option is required in non-interactive mode. Usage: --category <categoryID>.${sampleText}`,
221
221
  categories,
222
222
  };
223
223
  }
@@ -232,7 +232,7 @@ const createTistoryProvider = ({ sessionPath }) => {
232
232
  title,
233
233
  visibility,
234
234
  tags: tag,
235
- message: '카테고리가 지정되지 않았습니다. 카테고리를 입력해 발행을 진행해 주세요.',
235
+ message: 'No category specified. Please enter a category to proceed with publishing.',
236
236
  categories,
237
237
  };
238
238
  }
@@ -251,7 +251,7 @@ const createTistoryProvider = ({ sessionPath }) => {
251
251
  title,
252
252
  visibility,
253
253
  tags: tag,
254
- message: '유효한 category 숫자로 지정해 주세요.',
254
+ message: 'Please specify a valid category as a number.',
255
255
  categories,
256
256
  };
257
257
  }
@@ -266,7 +266,7 @@ const createTistoryProvider = ({ sessionPath }) => {
266
266
  title,
267
267
  visibility,
268
268
  tags: tag,
269
- message: '존재하지 않는 category입니다. categories를 확인해 주세요.',
269
+ message: 'The specified category does not exist. Please check the available categories.',
270
270
  categories,
271
271
  };
272
272
  }
@@ -324,7 +324,7 @@ const createTistoryProvider = ({ sessionPath }) => {
324
324
  minimumImageCount: safeMinimumImageCount,
325
325
  url: fallbackPublishResult.entryUrl || null,
326
326
  raw: fallbackPublishResult,
327
- message: '발행 제한(403)으로 인해 비공개로 발행했습니다.',
327
+ message: 'Published as private due to publish limit (403).',
328
328
  fallbackThumbnail: finalThumbnail,
329
329
  };
330
330
  } catch (fallbackError) {
@@ -344,7 +344,7 @@ const createTistoryProvider = ({ sessionPath }) => {
344
344
  images: enrichedImages.images,
345
345
  imageCount: enrichedImages.uploadedCount,
346
346
  minimumImageCount: safeMinimumImageCount,
347
- message: '발행 제한(403)으로 인해 공개/비공개 모두 실패했습니다.',
347
+ message: 'Both public and private publishing failed due to publish limit (403).',
348
348
  raw: {
349
349
  success: false,
350
350
  error: fallbackError.message,
@@ -357,7 +357,7 @@ const createTistoryProvider = ({ sessionPath }) => {
357
357
 
358
358
  async saveDraft(payload) {
359
359
  return withProviderSession(async () => {
360
- const title = payload.title || '임시저장';
360
+ const title = payload.title || 'Draft';
361
361
  const rawContent = payload.content || '';
362
362
  const rawThumbnail = payload.thumbnail || null;
363
363
  const tag = normalizeTagList(payload.tags);
@@ -499,7 +499,7 @@ const createTistoryProvider = ({ sessionPath }) => {
499
499
  provider: 'tistory',
500
500
  mode: 'post',
501
501
  status: 'invalid_post_id',
502
- message: 'postId 필요합니다.',
502
+ message: 'postId is required.',
503
503
  };
504
504
  }
505
505
 
@@ -515,7 +515,7 @@ const createTistoryProvider = ({ sessionPath }) => {
515
515
  status: 'not_found',
516
516
  postId: resolvedPostId,
517
517
  includeDraft: Boolean(includeDraft),
518
- message: '해당 postId의 글을 찾을 없습니다.',
518
+ message: 'No post found with the specified postId.',
519
519
  };
520
520
  }
521
521
  return {
@@ -57,8 +57,8 @@ const persistTistorySession = async (context, targetSessionPath) => {
57
57
  };
58
58
 
59
59
  /**
60
- * withProviderSession 팩토리.
61
- * askForAuthentication 외부에서 주입받아 스코프 버그를 해결한다.
60
+ * withProviderSession factory.
61
+ * Receives askForAuthentication via dependency injection to avoid scope issues.
62
62
  */
63
63
  const createWithProviderSession = (askForAuthentication) => async (fn) => {
64
64
  const credentials = readCredentialsFromEnv();
@@ -94,7 +94,7 @@ const createWithProviderSession = (askForAuthentication) => async (fn) => {
94
94
  });
95
95
 
96
96
  if (!loginResult.loggedIn) {
97
- throw new Error(loginResult.message || '세션 갱신 로그인 상태가 확인되지 않았습니다.');
97
+ throw new Error(loginResult.message || 'Login status could not be confirmed after session refresh.');
98
98
  }
99
99
 
100
100
  return fn();
@@ -11,10 +11,10 @@ const imageTrace = (message, data) => {
11
11
  return;
12
12
  }
13
13
  if (data === undefined) {
14
- console.log(`[이미지 추적] ${message}`);
14
+ console.log(`[Image Trace] ${message}`);
15
15
  return;
16
16
  }
17
- console.log(`[이미지 추적] ${message}`, data);
17
+ console.log(`[Image Trace] ${message}`, data);
18
18
  };
19
19
 
20
20
  const MAX_IMAGE_UPLOAD_COUNT = 1;
@@ -57,17 +57,17 @@ const normalizeTagList = (value = '') => {
57
57
  const parseSessionError = (error) => {
58
58
  const message = String(error?.message || '').toLowerCase();
59
59
  return [
60
- '세션이 만료',
61
- '세션에 유효한 쿠키',
62
- '세션 파일이 없습니다',
63
- '블로그 정보 조회 실패: 401',
64
- '블로그 정보 조회 실패: 403',
65
- '세션이 만료되었습니다',
66
- '다시 로그인',
60
+ 'session expired',
61
+ 'valid cookie in session',
62
+ 'session file not found',
63
+ 'blog info fetch failed: 401',
64
+ 'blog info fetch failed: 403',
65
+ 'session has expired',
66
+ 'please log in again',
67
67
  ].some((token) => message.includes(token.toLowerCase()));
68
68
  };
69
69
 
70
- const buildLoginErrorMessage = (error) => String(error?.message || '세션 검증에 실패했습니다.');
70
+ const buildLoginErrorMessage = (error) => String(error?.message || 'Session validation failed.');
71
71
 
72
72
  const promptCategorySelection = async (categories = []) => {
73
73
  if (!process.stdin || !process.stdin.isTTY) {
@@ -79,9 +79,9 @@ const promptCategorySelection = async (categories = []) => {
79
79
 
80
80
  const candidates = categories.map((category, index) => `${index + 1}. ${category.name} (${category.id})`);
81
81
  const lines = [
82
- '발행할 카테고리를 선택해 주세요.',
82
+ 'Please select a category for publishing.',
83
83
  ...candidates,
84
- `입력: 번호(1-${categories.length}) 또는 카테고리 ID (엔터 입력 건너뛰기)`,
84
+ `Enter: number (1-${categories.length}) or category ID (press Enter to skip)`,
85
85
  ];
86
86
  const prompt = `${lines.join('\n')}\n> `;
87
87
 
@@ -126,7 +126,7 @@ const promptCategorySelection = async (categories = []) => {
126
126
  return;
127
127
  }
128
128
 
129
- console.log('잘못된 입력입니다. 번호 또는 카테고리 ID를 다시 입력해 주세요.');
129
+ console.log('Invalid input. Please enter a number or category ID again.');
130
130
  ask(retryCount + 1);
131
131
  });
132
132
  };
@@ -137,7 +137,7 @@ const promptCategorySelection = async (categories = []) => {
137
137
 
138
138
  const isPublishLimitError = (error) => {
139
139
  const message = String(error?.message || '');
140
- return /발행 실패:\s*403/.test(message) || /\b403\b/.test(message);
140
+ return /publish failed:\s*403/i.test(message) || /\b403\b/.test(message);
141
141
  };
142
142
 
143
143
  const isProvidedCategory = (value) => {
package/src/runner.js CHANGED
@@ -178,7 +178,7 @@ const runCommand = async (command, opts = {}) => {
178
178
  case 'logout':
179
179
  return withProvider(() => provider.logout())();
180
180
 
181
- // ── Instagram 전용 (다른 프로바이더에도 메서드가 있으면 동작) ──
181
+ // ── Instagram-specific (works with other providers if the method exists) ──
182
182
 
183
183
  case 'get-profile':
184
184
  if (!opts.username) {
@@ -29,19 +29,19 @@ const normalizeCookies = (session) => {
29
29
  const readSessionCookies = (sessionPath) => {
30
30
  const resolvedPath = path.resolve(sessionPath);
31
31
  if (!fs.existsSync(resolvedPath)) {
32
- throw new Error(`세션 파일이 없습니다. ${resolvedPath} 로그인 정보를 먼저 저장하세요.`);
32
+ throw new Error(`Session file not found. Please save login credentials to ${resolvedPath} first.`);
33
33
  }
34
34
 
35
35
  let raw;
36
36
  try {
37
37
  raw = JSON.parse(fs.readFileSync(resolvedPath, 'utf-8'));
38
38
  } catch (error) {
39
- throw new Error(`세션 파일 파싱 실패: ${error.message}`);
39
+ throw new Error(`Failed to parse session file: ${error.message}`);
40
40
  }
41
41
 
42
42
  const cookies = normalizeCookies(raw);
43
43
  if (!cookies.length) {
44
- throw new Error('세션에 유효한 쿠키가 없습니다. 다시 로그인해 주세요.');
44
+ throw new Error('No valid cookies found in session. Please log in again.');
45
45
  }
46
46
 
47
47
  return cookies.join('; ');
@@ -88,7 +88,7 @@ const createNaverApiClient = ({ sessionPath }) => {
88
88
  detail = await response.text();
89
89
  detail = detail ? `: ${detail.slice(0, 200)}` : '';
90
90
  } catch {}
91
- throw new Error(`요청 실패: ${response.status} ${response.statusText}${detail}`);
91
+ throw new Error(`Request failed: ${response.status} ${response.statusText}${detail}`);
92
92
  }
93
93
  return response.json();
94
94
  } finally {
@@ -105,7 +105,7 @@ const createNaverApiClient = ({ sessionPath }) => {
105
105
  ...options,
106
106
  });
107
107
  if (!response.ok) {
108
- throw new Error(`요청 실패: ${response.status} ${response.statusText}`);
108
+ throw new Error(`Request failed: ${response.status} ${response.statusText}`);
109
109
  }
110
110
  return response.text();
111
111
  } finally {
@@ -122,11 +122,11 @@ const createNaverApiClient = ({ sessionPath }) => {
122
122
 
123
123
  const match = html.match(/blogId\s*=\s*'([^']+)'/);
124
124
  if (!match) {
125
- // JSON 응답인 경우도 체크
125
+ // Also check if response is a login page
126
126
  if (html.includes('로그인') || html.includes('login')) {
127
- throw new Error('세션이 만료되었습니다. 다시 로그인해 주세요.');
127
+ throw new Error('Session expired. Please log in again.');
128
128
  }
129
- throw new Error('MyBlog 응답에서 blogId 찾을 없습니다.');
129
+ throw new Error('Could not find blogId in MyBlog response.');
130
130
  }
131
131
 
132
132
  blogId = match[1];
@@ -144,7 +144,7 @@ const createNaverApiClient = ({ sessionPath }) => {
144
144
  }
145
145
  );
146
146
  const token = json?.result?.token;
147
- if (!token) throw new Error('Se-Authorization 토큰을 가져올 수 없습니다.');
147
+ if (!token) throw new Error('Failed to retrieve Se-Authorization token.');
148
148
  return token;
149
149
  };
150
150
 
@@ -185,7 +185,7 @@ const createNaverApiClient = ({ sessionPath }) => {
185
185
  }
186
186
  );
187
187
  const editorId = configJson?.editorInfo?.id;
188
- if (!editorId) throw new Error('에디터 ID를 가져올 없습니다.');
188
+ if (!editorId) throw new Error('Failed to retrieve editor ID.');
189
189
 
190
190
  const managerJson = await requestJson(
191
191
  `${BLOG_HOST}/PostWriteFormManagerOptions.naver?blogId=${encodeURIComponent(id)}&categoryNo=${encodeURIComponent(categoryNo)}`,
@@ -216,7 +216,7 @@ const createNaverApiClient = ({ sessionPath }) => {
216
216
  const uploadImage = async (imageBuffer, filename, token) => {
217
217
  const id = blogId || await initBlog();
218
218
  const sessionKey = await getUploadSessionKey(token);
219
- if (!sessionKey) throw new Error('이미지 업로드 세션 키를 가져올 없습니다.');
219
+ if (!sessionKey) throw new Error('Failed to retrieve image upload session key.');
220
220
 
221
221
  const uploadUrl = `https://blog.upphoto.naver.com/${sessionKey}/simpleUpload/0?userId=${encodeURIComponent(id)}&extractExif=true&extractAnimatedCnt=true&autorotate=true&extractDominantColor=false&denyAnimatedImage=false&skipXcamFiltering=false`;
222
222
 
@@ -238,12 +238,12 @@ const createNaverApiClient = ({ sessionPath }) => {
238
238
  });
239
239
 
240
240
  if (!response.ok) {
241
- throw new Error(`이미지 업로드 실패: ${response.status}`);
241
+ throw new Error(`Image upload failed: ${response.status}`);
242
242
  }
243
243
 
244
244
  const xml = await response.text();
245
245
  if (!xml.includes('<url>')) {
246
- throw new Error('이미지 업로드 응답에 URL이 없습니다.');
246
+ throw new Error('Image upload response does not contain a URL.');
247
247
  }
248
248
 
249
249
  const extractTag = (tag) => {
@@ -309,7 +309,7 @@ const createNaverApiClient = ({ sessionPath }) => {
309
309
  : await getDefaultCategoryNo();
310
310
  const { editorId, editorSource, token } = await getEditorInfo(resolvedCategoryNo);
311
311
 
312
- // content 이미 컴포넌트 배열이면 그대로 사용, 아니면 배열
312
+ // Use content as-is if already a component array, otherwise empty array
313
313
  const contentComponents = Array.isArray(content) ? content : [];
314
314
 
315
315
  const titleComponent = {
@@ -396,12 +396,12 @@ const createNaverApiClient = ({ sessionPath }) => {
396
396
  });
397
397
 
398
398
  if (!response.ok) {
399
- throw new Error(`글 발행 실패: ${response.status}`);
399
+ throw new Error(`Post publish failed: ${response.status}`);
400
400
  }
401
401
 
402
402
  const json = await response.json();
403
403
  if (!json.isSuccess) {
404
- throw new Error(`글 발행 실패: ${JSON.stringify(json).slice(0, 200)}`);
404
+ throw new Error(`Post publish failed: ${JSON.stringify(json).slice(0, 200)}`);
405
405
  }
406
406
 
407
407
  const redirectUrl = json.result?.redirectUrl || '';
@@ -432,7 +432,7 @@ const createNaverApiClient = ({ sessionPath }) => {
432
432
 
433
433
  let json;
434
434
  try {
435
- // 네이버 응답에 잘못된 이스케이프(\') 포함될 수 있어 정리
435
+ // Naver response may contain invalid escape sequences (\'), so sanitize
436
436
  const sanitized = text.replace(/\\'/g, "'");
437
437
  json = JSON.parse(sanitized);
438
438
  } catch {
@@ -18,7 +18,7 @@ const createProviderManager = () => {
18
18
  const getProvider = (provider = 'tistory') => {
19
19
  const normalized = String(provider || 'tistory').toLowerCase();
20
20
  if (!providerFactory[normalized]) {
21
- throw new Error(`지원하지 않는 provider입니다: ${provider}. 가능한 값: ${providers.join(', ')}`);
21
+ throw new Error(`Unsupported provider: ${provider}. Available options: ${providers.join(', ')}`);
22
22
  }
23
23
 
24
24
  if (!cache.has(normalized)) {