viruagent-cli 0.4.2 → 0.5.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.
@@ -4,7 +4,7 @@ const { createImageComponent } = require('./editorConvert');
4
4
  const { buildKeywordImageCandidates } = require('../tistory/imageSources');
5
5
 
6
6
  /**
7
- * 이미지 URL 또는 로컬 경로에서 이미지 버퍼를 가져온다.
7
+ * Fetches an image buffer from a URL or local file path.
8
8
  */
9
9
  const fetchImageBuffer = async (source) => {
10
10
  if (fs.existsSync(source)) {
@@ -26,7 +26,7 @@ const fetchImageBuffer = async (source) => {
26
26
  },
27
27
  });
28
28
  if (!response.ok) {
29
- throw new Error(`이미지 다운로드 실패: ${response.status} — ${source}`);
29
+ throw new Error(`Image download failed: ${response.status} — ${source}`);
30
30
  }
31
31
  const arrayBuffer = await response.arrayBuffer();
32
32
  const urlPath = new URL(response.url || source).pathname;
@@ -41,7 +41,7 @@ const fetchImageBuffer = async (source) => {
41
41
  };
42
42
 
43
43
  /**
44
- * 이미지 소스들을 네이버에 업로드하고 에디터 컴포넌트 배열로 반환한다.
44
+ * Uploads image sources to Naver and returns an array of editor components.
45
45
  */
46
46
  const uploadAndCreateImageComponents = async (naverApi, imageSources, token) => {
47
47
  const components = [];
@@ -65,8 +65,8 @@ const uploadAndCreateImageComponents = async (naverApi, imageSources, token) =>
65
65
  };
66
66
 
67
67
  /**
68
- * relatedImageKeywords에서 이미지를 검색하고, imageUrls 합쳐서 업로드한다.
69
- * 티스토리 imageSources.js의 buildKeywordImageCandidates를 재사용한다.
68
+ * Searches for images from relatedImageKeywords, merges with imageUrls, and uploads them.
69
+ * Reuses buildKeywordImageCandidates from Tistory's imageSources.js.
70
70
  */
71
71
  const collectAndUploadImages = async (naverApi, {
72
72
  imageUrls = [],
@@ -76,7 +76,7 @@ const collectAndUploadImages = async (naverApi, {
76
76
  }) => {
77
77
  const collectedUrls = [...imageUrls];
78
78
 
79
- // 키워드에서 이미지 URL 검색
79
+ // Search image URLs from keywords
80
80
  const normalizedKeywords = Array.isArray(relatedImageKeywords)
81
81
  ? relatedImageKeywords.map((k) => String(k || '').trim()).filter(Boolean)
82
82
  : String(relatedImageKeywords || '').split(',').map((k) => k.trim()).filter(Boolean);
@@ -92,7 +92,7 @@ const collectAndUploadImages = async (naverApi, {
92
92
  }
93
93
  }
94
94
  } catch {
95
- // 검색 실패 시 무시
95
+ // Ignore search failures
96
96
  }
97
97
  }
98
98
 
@@ -63,7 +63,7 @@ const createNaverProvider = ({ sessionPath }) => {
63
63
  };
64
64
 
65
65
  if (!resolved.manual && (!resolved.username || !resolved.password)) {
66
- throw new Error('네이버 자동 로그인을 진행하려면 username/password 필요합니다. 환경변수 NAVER_USERNAME / NAVER_PASSWORD 설정하거나 --manual 모드를 사용해 주세요.');
66
+ throw new Error('Naver auto-login requires username/password. Set the NAVER_USERNAME / NAVER_PASSWORD environment variables or use --manual mode.');
67
67
  }
68
68
 
69
69
  const result = await askForAuthentication(resolved);
@@ -78,7 +78,7 @@ const createNaverProvider = ({ sessionPath }) => {
78
78
 
79
79
  async publish(payload) {
80
80
  return withProviderSession(async () => {
81
- const title = payload.title || '제목 없음';
81
+ const title = payload.title || 'Untitled';
82
82
  const rawContent = payload.content || '';
83
83
  const openType = mapNaverVisibility(payload.visibility);
84
84
  const tags = normalizeNaverTagList(payload.tags);
@@ -91,7 +91,7 @@ const createNaverProvider = ({ sessionPath }) => {
91
91
  const rawCategories = await naverApi.getCategories();
92
92
  const categories = Object.entries(rawCategories).map(([name, id]) => ({ name, id: Number(id) })).sort((a, b) => a.id - b.id);
93
93
 
94
- // 카테고리 결정
94
+ // Determine category
95
95
  let categoryNo = payload.category;
96
96
  if (categoryNo === undefined || categoryNo === null || String(categoryNo).trim() === '') {
97
97
  if (categories.length === 0) {
@@ -107,14 +107,14 @@ const createNaverProvider = ({ sessionPath }) => {
107
107
  title,
108
108
  openType,
109
109
  tags,
110
- message: '발행을 위해 카테고리가 필요합니다. categories 확인하고 category를 지정해 주세요.',
110
+ message: 'A category is required for publishing. Please check categories and specify a category.',
111
111
  categories,
112
112
  };
113
113
  }
114
114
  }
115
115
  categoryNo = String(categoryNo);
116
116
 
117
- // 이미지 업로드 (imageUrls + relatedImageKeywords 기반 자동 검색)
117
+ // Image upload (auto-search based on imageUrls + relatedImageKeywords)
118
118
  let imageComponents = [];
119
119
  const hasImageSources = imageUrls.length > 0 || relatedImageKeywords.length > 0;
120
120
  if (autoUploadImages && hasImageSources) {
@@ -128,7 +128,7 @@ const createNaverProvider = ({ sessionPath }) => {
128
128
  imageComponents = uploadResult.components;
129
129
  }
130
130
 
131
- // HTML 에디터 컴포넌트 변환
131
+ // Convert HTML to editor components
132
132
  const contentComponents = await convertHtmlToEditorComponents(naverApi, rawContent, imageComponents);
133
133
 
134
134
  const result = await naverApi.publishPost({
@@ -154,7 +154,7 @@ const createNaverProvider = ({ sessionPath }) => {
154
154
  },
155
155
 
156
156
  async saveDraft(payload) {
157
- // 네이버는 임시저장 API 없으므로 비공개(openType: 0)로 발행
157
+ // Naver has no draft API, so publish as private (openType: 0)
158
158
  return this.publish({
159
159
  ...payload,
160
160
  visibility: 'private',
@@ -196,7 +196,7 @@ const createNaverProvider = ({ sessionPath }) => {
196
196
  provider: 'naver',
197
197
  mode: 'post',
198
198
  status: 'invalid_post_id',
199
- message: 'postId 필요합니다.',
199
+ message: 'postId is required.',
200
200
  };
201
201
  }
202
202
 
@@ -208,7 +208,7 @@ const createNaverProvider = ({ sessionPath }) => {
208
208
  mode: 'post',
209
209
  status: 'not_found',
210
210
  postId: resolvedPostId,
211
- message: '해당 postId의 글을 찾을 없습니다.',
211
+ message: 'Post not found for the given postId.',
212
212
  };
213
213
  }
214
214
  return {
@@ -21,7 +21,7 @@ const persistNaverSession = async (context, targetSessionPath) => {
21
21
  allCookies.push(...cookies);
22
22
  }
23
23
 
24
- // 중복 제거 (name+domain 기준)
24
+ // Deduplicate by name+domain
25
25
  const seen = new Set();
26
26
  const unique = allCookies.filter((c) => {
27
27
  const key = `${c.name}@${c.domain}`;
@@ -96,7 +96,7 @@ const createNaverWithProviderSession = (askForAuthentication) => async (fn) => {
96
96
  });
97
97
 
98
98
  if (!loginResult.loggedIn) {
99
- throw new Error(loginResult.message || '세션 갱신 로그인 상태가 확인되지 않았습니다.');
99
+ throw new Error(loginResult.message || 'Login status could not be verified after session refresh.');
100
100
  }
101
101
 
102
102
  return fn();
@@ -10,19 +10,19 @@ const readNaverCredentials = () => {
10
10
  const parseNaverSessionError = (error) => {
11
11
  const message = String(error?.message || '').toLowerCase();
12
12
  return [
13
- '세션 파일이 없습니다',
14
- '세션에 유효한 쿠키',
15
- '세션이 만료',
16
- '로그인이 필요합니다',
17
- 'blogid를 찾을 수 없습니다',
18
- '블로그 정보 조회 실패',
19
- '다시 로그인',
13
+ 'session file not found',
14
+ 'no valid cookies',
15
+ 'session expired',
16
+ 'login required',
17
+ 'find blogid',
18
+ 'failed to fetch blog info',
19
+ 'log in again',
20
20
  '401',
21
21
  '403',
22
22
  ].some((token) => message.includes(token.toLowerCase()));
23
23
  };
24
24
 
25
- const buildLoginErrorMessage = (error) => String(error?.message || '세션 검증에 실패했습니다.');
25
+ const buildLoginErrorMessage = (error) => String(error?.message || 'Session validation failed.');
26
26
 
27
27
  const normalizeNaverTagList = (value = '') => {
28
28
  const source = Array.isArray(value)
@@ -18,8 +18,8 @@ const {
18
18
  } = require('./selectors');
19
19
 
20
20
  /**
21
- * askForAuthentication 팩토리.
22
- * sessionPath, tistoryApi, pending2faResult 외부에서 주입받는다.
21
+ * askForAuthentication factory.
22
+ * Receives sessionPath, tistoryApi, and pending2faResult via dependency injection.
23
23
  */
24
24
  const createAskForAuthentication = ({ sessionPath, tistoryApi, pending2faResult }) => async ({
25
25
  headless = false,
@@ -35,7 +35,7 @@ const createAskForAuthentication = ({ sessionPath, tistoryApi, pending2faResult
35
35
  const shouldAutoFill = !manual;
36
36
 
37
37
  if (!manual && (!resolvedUsername || !resolvedPassword)) {
38
- throw new Error('티스토리 로그인 요청에 id/pw 없습니다. id/pw 먼저 전달하거나 TISTORY_USERNAME/TISTORY_PASSWORD 설정해 주세요.');
38
+ throw new Error('Tistory login requires id/pw. Please provide id/pw or set TISTORY_USERNAME/TISTORY_PASSWORD environment variables.');
39
39
  }
40
40
 
41
41
  const browser = await chromium.launch({
@@ -55,7 +55,7 @@ const createAskForAuthentication = ({ sessionPath, tistoryApi, pending2faResult
55
55
 
56
56
  const kakaoLoginSelector = await pickValue(page, KAKAO_TRIGGER_SELECTORS);
57
57
  if (!kakaoLoginSelector) {
58
- throw new Error('카카오 로그인 버튼을 찾지 못했습니다. 로그인 화면 UI 변경되었는지 확인해 주세요.');
58
+ throw new Error('Could not find the Kakao login button. Please check if the login page UI has changed.');
59
59
  }
60
60
 
61
61
  await page.locator(kakaoLoginSelector).click({ timeout: 5000 }).catch(() => {});
@@ -68,16 +68,16 @@ const createAskForAuthentication = ({ sessionPath, tistoryApi, pending2faResult
68
68
  if (manual) {
69
69
  console.log('');
70
70
  console.log('==============================');
71
- console.log('수동 로그인 모드로 전환합니다.');
72
- console.log('브라우저에서 직접 ID/PW/2차 인증을 완료한 뒤, 로그인 완료 상태를 기다립니다.');
73
- console.log('로그인 완료 또는 2차 인증은 최대 5 내에 처리해 주세요.');
71
+ console.log('Switching to manual login mode.');
72
+ console.log('Please complete ID/PW/2FA verification in the browser.');
73
+ console.log('Login or 2FA must be completed within 5 minutes.');
74
74
  console.log('==============================');
75
75
  finalLoginStatus = await waitForLoginFinish(page, context, 300000);
76
76
  } else if (shouldAutoFill) {
77
77
  const usernameFilled = await fillBySelector(page, KAKAO_LOGIN_SELECTORS.username, loginId);
78
78
  const passwordFilled = await fillBySelector(page, KAKAO_LOGIN_SELECTORS.password, loginPw);
79
79
  if (!usernameFilled || !passwordFilled) {
80
- throw new Error('카카오 로그인 입력 필드를 찾지 못했습니다. 티스토리 로그인 화면 변경 시도를 확인해 주세요.');
80
+ throw new Error('Could not find Kakao login form input fields. Please check if the Tistory login page has changed.');
81
81
  }
82
82
 
83
83
  await checkBySelector(page, KAKAO_LOGIN_SELECTORS.rememberLogin);
@@ -94,7 +94,7 @@ const createAskForAuthentication = ({ sessionPath, tistoryApi, pending2faResult
94
94
  }
95
95
  const otpFilled = await fillBySelector(page, LOGIN_OTP_SELECTORS, twoFactorCode);
96
96
  if (!otpFilled) {
97
- throw new Error('OTP 입력 필드를 찾지 못했습니다. 로그인 페이지를 확인해 주세요.');
97
+ throw new Error('Could not find the OTP input field. Please check the login page.');
98
98
  }
99
99
  await page.keyboard.press('Enter');
100
100
  finalLoginStatus = await waitForLoginFinish(page, context, 45000);
@@ -106,7 +106,7 @@ const createAskForAuthentication = ({ sessionPath, tistoryApi, pending2faResult
106
106
  if (hasEmailCodeInput && twoFactorCode) {
107
107
  const codeFilled = await fillBySelector(page, KAKAO_2FA_SELECTORS.codeInput, twoFactorCode);
108
108
  if (!codeFilled) {
109
- throw new Error('2차 인증 입력 필드를 찾지 못했습니다. 로그인 페이지를 확인해 주세요.');
109
+ throw new Error('Could not find the 2FA input field. Please check the login page.');
110
110
  }
111
111
  const confirmed = await clickSubmit(page, KAKAO_2FA_SELECTORS.confirm);
112
112
  if (!confirmed) {
@@ -122,7 +122,7 @@ const createAskForAuthentication = ({ sessionPath, tistoryApi, pending2faResult
122
122
 
123
123
  const codeFilled = await fillBySelector(page, KAKAO_2FA_SELECTORS.codeInput, twoFactorCode);
124
124
  if (!codeFilled) {
125
- throw new Error('카카오 이메일 인증 입력 필드를 찾지 못했습니다. 로그인 페이지를 확인해 주세요.');
125
+ throw new Error('Could not find the Kakao email verification input field. Please check the login page.');
126
126
  }
127
127
 
128
128
  const confirmed = await clickSubmit(page, KAKAO_2FA_SELECTORS.confirm);
@@ -140,7 +140,7 @@ const createAskForAuthentication = ({ sessionPath, tistoryApi, pending2faResult
140
140
  if (pendingTwoFactorAction) {
141
141
  return pending2faResult('kakao');
142
142
  }
143
- throw new Error('로그인에 실패했습니다. 아이디/비밀번호가 정확한지 확인하고, 없으면 환경변수 TISTORY_USERNAME/TISTORY_PASSWORD 다시 설정해 주세요.');
143
+ throw new Error('Login failed. Please verify your credentials and ensure TISTORY_USERNAME/TISTORY_PASSWORD environment variables are set correctly.');
144
144
  }
145
145
 
146
146
  await context.storageState({ path: sessionPath });
@@ -2,7 +2,7 @@ const { sleep, imageTrace } = require('./utils');
2
2
 
3
3
  const fetchText = async (url, retryCount = 0) => {
4
4
  if (!url) {
5
- throw new Error('텍스트 URL 없습니다.');
5
+ throw new Error('Text URL is missing.');
6
6
  }
7
7
 
8
8
  const headers = {
@@ -22,7 +22,7 @@ const fetchText = async (url, retryCount = 0) => {
22
22
  });
23
23
 
24
24
  if (!response.ok) {
25
- throw new Error(`텍스트 요청 실패: ${response.status} ${response.statusText}, url=${url}`);
25
+ throw new Error(`Text request failed: ${response.status} ${response.statusText}, url=${url}`);
26
26
  }
27
27
 
28
28
  return response.text();
@@ -31,7 +31,7 @@ const fetchText = async (url, retryCount = 0) => {
31
31
  await sleep(700);
32
32
  return fetchText(url, retryCount + 1);
33
33
  }
34
- throw new Error(`웹 텍스트 다운로드 실패: ${error.message}`);
34
+ throw new Error(`Web text download failed: ${error.message}`);
35
35
  } finally {
36
36
  clearTimeout(timeout);
37
37
  }
@@ -52,7 +52,7 @@ const fetchTextWithHeaders = async (url, headers = {}, retryCount = 0) => {
52
52
  signal: controller.signal,
53
53
  });
54
54
  if (!response.ok) {
55
- throw new Error(`텍스트 요청 실패: ${response.status} ${response.statusText}, url=${url}`);
55
+ throw new Error(`Text request failed: ${response.status} ${response.statusText}, url=${url}`);
56
56
  }
57
57
  return response.text();
58
58
  } catch (error) {
@@ -60,7 +60,7 @@ const fetchTextWithHeaders = async (url, headers = {}, retryCount = 0) => {
60
60
  await sleep(700);
61
61
  return fetchTextWithHeaders(url, headers, retryCount + 1);
62
62
  }
63
- throw new Error(`웹 텍스트 다운로드 실패: ${error.message}`);
63
+ throw new Error(`Web text download failed: ${error.message}`);
64
64
  } finally {
65
65
  clearTimeout(timeout);
66
66
  }
@@ -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 {