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.
@@ -52,8 +52,8 @@ const createInstaProvider = ({ sessionPath }) => {
52
52
 
53
53
  if (!resolved.username || !resolved.password) {
54
54
  throw new Error(
55
- '인스타그램 로그인에 username/password 필요합니다. ' +
56
- '환경변수 INSTA_USERNAME / INSTA_PASSWORD 설정해 주세요.',
55
+ 'Instagram login requires username/password. ' +
56
+ 'Please set the INSTA_USERNAME / INSTA_PASSWORD environment variables.',
57
57
  );
58
58
  }
59
59
 
@@ -73,7 +73,7 @@ const createInstaProvider = ({ sessionPath }) => {
73
73
  async getProfile({ username } = {}) {
74
74
  return withProviderSession(async () => {
75
75
  if (!username) {
76
- throw new Error('username 필요합니다.');
76
+ throw new Error('username is required.');
77
77
  }
78
78
  const profile = await instaApi.getProfile(username);
79
79
  return {
@@ -99,7 +99,7 @@ const createInstaProvider = ({ sessionPath }) => {
99
99
  async listPosts({ username, limit = 12 } = {}) {
100
100
  return withProviderSession(async () => {
101
101
  if (!username) {
102
- throw new Error('username 필요합니다.');
102
+ throw new Error('username is required.');
103
103
  }
104
104
  const posts = await instaApi.getUserPosts(username, limit);
105
105
  return {
@@ -120,7 +120,7 @@ const createInstaProvider = ({ sessionPath }) => {
120
120
  provider: 'insta',
121
121
  mode: 'post',
122
122
  status: 'invalid_post_id',
123
- message: 'postId(shortcode) 필요합니다.',
123
+ message: 'postId (shortcode) is required.',
124
124
  };
125
125
  }
126
126
  const post = await instaApi.getPostDetail(shortcode);
@@ -134,7 +134,7 @@ const createInstaProvider = ({ sessionPath }) => {
134
134
 
135
135
  async follow({ username } = {}) {
136
136
  return withProviderSession(async () => {
137
- if (!username) throw new Error('username 필요합니다.');
137
+ if (!username) throw new Error('username is required.');
138
138
  const profile = await instaApi.getProfile(username);
139
139
  const result = await instaApi.followUser(profile.id);
140
140
  return {
@@ -151,7 +151,7 @@ const createInstaProvider = ({ sessionPath }) => {
151
151
 
152
152
  async unfollow({ username } = {}) {
153
153
  return withProviderSession(async () => {
154
- if (!username) throw new Error('username 필요합니다.');
154
+ if (!username) throw new Error('username is required.');
155
155
  const profile = await instaApi.getProfile(username);
156
156
  const result = await instaApi.unfollowUser(profile.id);
157
157
  return {
@@ -168,7 +168,7 @@ const createInstaProvider = ({ sessionPath }) => {
168
168
  async like({ postId } = {}) {
169
169
  return withProviderSession(async () => {
170
170
  const shortcode = String(postId || '').trim();
171
- if (!shortcode) throw new Error('postId(shortcode) 필요합니다.');
171
+ if (!shortcode) throw new Error('postId (shortcode) is required.');
172
172
  const mediaId = await instaApi.getMediaIdFromShortcode(shortcode);
173
173
  const result = await instaApi.likePost(mediaId);
174
174
  return { provider: 'insta', mode: 'like', postId: shortcode, status: result.status };
@@ -178,7 +178,7 @@ const createInstaProvider = ({ sessionPath }) => {
178
178
  async unlike({ postId } = {}) {
179
179
  return withProviderSession(async () => {
180
180
  const shortcode = String(postId || '').trim();
181
- if (!shortcode) throw new Error('postId(shortcode) 필요합니다.');
181
+ if (!shortcode) throw new Error('postId (shortcode) is required.');
182
182
  const mediaId = await instaApi.getMediaIdFromShortcode(shortcode);
183
183
  const result = await instaApi.unlikePost(mediaId);
184
184
  return { provider: 'insta', mode: 'unlike', postId: shortcode, status: result.status };
@@ -187,7 +187,7 @@ const createInstaProvider = ({ sessionPath }) => {
187
187
 
188
188
  async likeComment({ commentId } = {}) {
189
189
  return withProviderSession(async () => {
190
- if (!commentId) throw new Error('commentId 필요합니다.');
190
+ if (!commentId) throw new Error('commentId is required.');
191
191
  const result = await instaApi.likeComment(commentId);
192
192
  return { provider: 'insta', mode: 'likeComment', commentId, status: result.status };
193
193
  });
@@ -195,7 +195,7 @@ const createInstaProvider = ({ sessionPath }) => {
195
195
 
196
196
  async unlikeComment({ commentId } = {}) {
197
197
  return withProviderSession(async () => {
198
- if (!commentId) throw new Error('commentId 필요합니다.');
198
+ if (!commentId) throw new Error('commentId is required.');
199
199
  const result = await instaApi.unlikeComment(commentId);
200
200
  return { provider: 'insta', mode: 'unlikeComment', commentId, status: result.status };
201
201
  });
@@ -206,10 +206,10 @@ const createInstaProvider = ({ sessionPath }) => {
206
206
  const shortcode = String(postId || '').trim();
207
207
  const commentText = String(text || '').trim();
208
208
  if (!shortcode) {
209
- throw new Error('postId(shortcode) 필요합니다.');
209
+ throw new Error('postId (shortcode) is required.');
210
210
  }
211
211
  if (!commentText) {
212
- throw new Error('댓글 내용(text)이 필요합니다.');
212
+ throw new Error('Comment text is required.');
213
213
  }
214
214
  const mediaId = await instaApi.getMediaIdFromShortcode(shortcode);
215
215
  const result = await instaApi.addComment(mediaId, commentText);
@@ -228,7 +228,7 @@ const createInstaProvider = ({ sessionPath }) => {
228
228
  async publish({ imageUrl, imagePath, caption = '' } = {}) {
229
229
  return withProviderSession(async () => {
230
230
  if (!imageUrl && !imagePath) {
231
- throw new Error('imageUrl 또는 imagePath 필요합니다.');
231
+ throw new Error('Either imageUrl or imagePath is required.');
232
232
  }
233
233
  const result = await instaApi.publishPost({ imageUrl, imagePath, caption });
234
234
  return {
@@ -239,11 +239,209 @@ const createInstaProvider = ({ sessionPath }) => {
239
239
  });
240
240
  },
241
241
 
242
+ async sendDm({ username, threadId, text } = {}) {
243
+ const target = String(username || '').trim();
244
+ const tid = String(threadId || '').trim();
245
+ const msg = String(text || '').trim();
246
+ if (!target && !tid) throw new Error('username or threadId is required.');
247
+ if (!msg) throw new Error('text is required.');
248
+
249
+ const { chromium } = require('playwright');
250
+ const path = require('path');
251
+ const userDataDir = path.join(path.dirname(sessionPath), '..', 'browser-data', 'insta');
252
+ const fs = require('fs');
253
+ if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true });
254
+
255
+ // Determine DM URL
256
+ let dmUrl;
257
+ if (tid) {
258
+ dmUrl = `https://www.instagram.com/direct/t/${tid}/`;
259
+ } else {
260
+ dmUrl = `https://www.instagram.com/direct/new/`;
261
+ }
262
+
263
+ const context = await chromium.launchPersistentContext(userDataDir, {
264
+ headless: true,
265
+ viewport: { width: 1280, height: 800 },
266
+ });
267
+
268
+ try {
269
+ const page = context.pages()[0] || await context.newPage();
270
+
271
+ if (!tid && target) {
272
+ // New DM: go to new message, search for user
273
+ await page.goto('https://www.instagram.com/direct/new/', { waitUntil: 'domcontentloaded' });
274
+ await page.waitForTimeout(3000);
275
+
276
+ // Search for recipient
277
+ const searchInput = page.locator('input[name="queryBox"]').or(page.getByPlaceholder(/검색|Search/i));
278
+ await searchInput.first().waitFor({ timeout: 10000 });
279
+ await searchInput.first().fill(target);
280
+ await page.waitForTimeout(2000);
281
+
282
+ // Click the user result
283
+ const userResult = page.locator(`text=${target}`).first();
284
+ await userResult.click();
285
+ await page.waitForTimeout(1000);
286
+
287
+ // Click chat/next button
288
+ const chatBtn = page.getByRole('button', { name: /채팅|Chat|다음|Next/i });
289
+ await chatBtn.first().click();
290
+ await page.waitForTimeout(2000);
291
+ } else {
292
+ await page.goto(dmUrl, { waitUntil: 'domcontentloaded' });
293
+ await page.waitForTimeout(3000);
294
+ }
295
+
296
+ // Dismiss popups
297
+ try {
298
+ const btn = page.getByRole('button', { name: /나중에|Not Now/i });
299
+ if (await btn.first().isVisible({ timeout: 2000 })) await btn.first().click();
300
+ } catch {}
301
+
302
+ // Send message
303
+ const input = page.locator('[role="textbox"]').first();
304
+ await input.waitFor({ timeout: 10000 });
305
+ await input.click();
306
+ await page.keyboard.type(msg);
307
+ await page.waitForTimeout(500);
308
+ await page.keyboard.press('Enter');
309
+ await page.waitForTimeout(3000);
310
+
311
+ return {
312
+ provider: 'insta',
313
+ mode: 'dm',
314
+ to: target || tid,
315
+ text: msg,
316
+ status: 'ok',
317
+ };
318
+ } finally {
319
+ await context.close();
320
+ }
321
+ },
322
+
323
+ async listMessages({ threadId } = {}) {
324
+ const tid = String(threadId || '').trim();
325
+ if (!tid) throw new Error('threadId is required.');
326
+
327
+ const { chromium } = require('playwright');
328
+ const path = require('path');
329
+ const fs = require('fs');
330
+ const userDataDir = path.join(path.dirname(sessionPath), '..', 'browser-data', 'insta');
331
+ if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true });
332
+
333
+ const context = await chromium.launchPersistentContext(userDataDir, {
334
+ headless: true,
335
+ viewport: { width: 1280, height: 800 },
336
+ });
337
+
338
+ try {
339
+ const page = context.pages()[0] || await context.newPage();
340
+ await page.goto(`https://www.instagram.com/direct/t/${tid}/`, { waitUntil: 'domcontentloaded' });
341
+ await page.waitForTimeout(5000);
342
+
343
+ // Dismiss popups
344
+ try {
345
+ const btn = page.getByRole('button', { name: /나중에|Not Now/i });
346
+ if (await btn.first().isVisible({ timeout: 2000 })) await btn.first().click();
347
+ } catch {}
348
+ await page.waitForTimeout(1000);
349
+
350
+ // Extract messages from DOM
351
+ const messages = await page.evaluate(() => {
352
+ const result = [];
353
+ // Find message containers - Instagram uses div with role="row" or specific data attributes
354
+ const rows = document.querySelectorAll('div[role="row"]');
355
+ rows.forEach((row) => {
356
+ const textEl = row.querySelector('div[dir="auto"]');
357
+ if (!textEl) return;
358
+ const text = textEl.innerText?.trim();
359
+ if (!text) return;
360
+
361
+ // Determine if sent or received by checking position/style
362
+ const wrapper = row.closest('[class]');
363
+ const style = wrapper ? window.getComputedStyle(wrapper) : null;
364
+ const isSent = row.innerHTML.includes('rgb(99, 91, 255)') ||
365
+ row.innerHTML.includes('#635BFF') ||
366
+ row.querySelector('[style*="flex-end"]') !== null;
367
+
368
+ result.push({ text, isSent });
369
+ });
370
+ return result;
371
+ });
372
+
373
+ // If role="row" didn't work, try alternative extraction
374
+ if (messages.length === 0) {
375
+ const altMessages = await page.evaluate(() => {
376
+ const result = [];
377
+ const allDivs = document.querySelectorAll('div[dir="auto"]');
378
+ const seen = new Set();
379
+ allDivs.forEach((el) => {
380
+ const text = el.innerText?.trim();
381
+ if (!text || text.length > 500 || seen.has(text)) return;
382
+ // Skip UI elements
383
+ if (['메시지 입력...', '검색', 'Message...'].includes(text)) return;
384
+ if (el.closest('nav') || el.closest('header')) return;
385
+ seen.add(text);
386
+
387
+ // Check if element is in right-aligned (sent) bubble
388
+ const rect = el.getBoundingClientRect();
389
+ const isSent = rect.left > window.innerWidth / 2;
390
+
391
+ result.push({ text, isSent });
392
+ });
393
+ return result;
394
+ });
395
+ if (altMessages.length > 0) messages.push(...altMessages);
396
+ }
397
+
398
+ // Get thread participant name
399
+ const participant = await page.evaluate(() => {
400
+ const header = document.querySelector('header');
401
+ if (!header) return null;
402
+ const spans = header.querySelectorAll('span');
403
+ for (const s of spans) {
404
+ const t = s.innerText?.trim();
405
+ if (t && !['메시지', 'Direct', '뒤로'].includes(t) && t.length < 30) return t;
406
+ }
407
+ return null;
408
+ });
409
+
410
+ return {
411
+ provider: 'insta',
412
+ mode: 'messages',
413
+ threadId: tid,
414
+ participant,
415
+ totalCount: messages.length,
416
+ messages,
417
+ };
418
+ } finally {
419
+ await context.close();
420
+ }
421
+ },
422
+
423
+ async listComments({ postId } = {}) {
424
+ return withProviderSession(async () => {
425
+ const shortcode = String(postId || '').trim();
426
+ if (!shortcode) {
427
+ throw new Error('postId (shortcode) is required.');
428
+ }
429
+ const comments = await instaApi.getComments(shortcode);
430
+ return {
431
+ provider: 'insta',
432
+ mode: 'comments',
433
+ postId: shortcode,
434
+ totalCount: comments.length,
435
+ comments,
436
+ };
437
+ });
438
+ },
439
+
242
440
  async analyzePost({ postId } = {}) {
243
441
  return withProviderSession(async () => {
244
442
  const shortcode = String(postId || '').trim();
245
443
  if (!shortcode) {
246
- throw new Error('postId(shortcode) 필요합니다.');
444
+ throw new Error('postId (shortcode) is required.');
247
445
  }
248
446
  const analysis = await smart.analyzePost({ shortcode });
249
447
  return {
@@ -258,7 +456,7 @@ const createInstaProvider = ({ sessionPath }) => {
258
456
  return withProviderSession(async () => {
259
457
  const shortcode = String(postId || '').trim();
260
458
  if (!shortcode) {
261
- throw new Error('postId(shortcode) 필요합니다.');
459
+ throw new Error('postId (shortcode) is required.');
262
460
  }
263
461
  const mediaId = await instaApi.getMediaIdFromShortcode(shortcode);
264
462
  const result = await instaApi.deletePost(mediaId);
@@ -277,7 +475,7 @@ const createInstaProvider = ({ sessionPath }) => {
277
475
  provider: 'insta',
278
476
  mode: 'resolveChallenge',
279
477
  resolved,
280
- message: resolved ? 'Challenge 해결 완료' : 'Challenge 해결 실패. 브라우저에서 수동으로 처리해 주세요.',
478
+ message: resolved ? 'Challenge resolved successfully.' : 'Challenge resolution failed. Please handle it manually in the browser.',
281
479
  };
282
480
  },
283
481
 
@@ -33,7 +33,7 @@ const loadInstaSession = (sessionPath) => {
33
33
  return Array.isArray(raw?.cookies) ? raw.cookies : null;
34
34
  };
35
35
 
36
- // ── Rate Limit 영속화 (userId) ──
36
+ // ── Rate Limit persistence (per userId) ──
37
37
 
38
38
  const loadRateLimits = (sessionPath, userId) => {
39
39
  const raw = readSessionFile(sessionPath);
@@ -92,7 +92,7 @@ const createInstaWithProviderSession = (askForAuthentication) => async (fn) => {
92
92
  });
93
93
 
94
94
  if (!loginResult.loggedIn) {
95
- throw new Error(loginResult.message || '세션 갱신 로그인 상태가 확인되지 않았습니다.');
95
+ throw new Error(loginResult.message || 'Login status could not be confirmed after session refresh.');
96
96
  }
97
97
 
98
98
  return fn();
@@ -1,6 +1,6 @@
1
1
  const createSmartComment = (instaApi) => {
2
2
  const analyzePost = async ({ shortcode }) => {
3
- // 1. 게시물 상세
3
+ // 1. Post detail
4
4
  const post = await instaApi.getPostDetail(shortcode);
5
5
  const caption = post.caption || '';
6
6
  const isVideo = post.isVideo;
@@ -8,15 +8,15 @@ const createSmartComment = (instaApi) => {
8
8
  const thumbnailUrl = post.imageUrl;
9
9
  const ownerUsername = post.owner?.username || '';
10
10
 
11
- // 2. 작성자 프로필
11
+ // 2. Owner profile
12
12
  let ownerProfile = null;
13
13
  try {
14
14
  ownerProfile = await instaApi.getProfile(ownerUsername);
15
15
  } catch {
16
- // 비공개 실패 무시
16
+ // Ignore failures (e.g., private account)
17
17
  }
18
18
 
19
- // 3. 썸네일 이미지 base64 (Claude Code Vision)
19
+ // 3. Thumbnail image base64 (for Claude Code Vision)
20
20
  let thumbnailBase64 = null;
21
21
  let thumbnailMediaType = 'image/jpeg';
22
22
  if (thumbnailUrl) {
@@ -29,15 +29,15 @@ const createSmartComment = (instaApi) => {
29
29
  if (ct) thumbnailMediaType = ct;
30
30
  }
31
31
  } catch {
32
- // 실패해도 캡션만으로 진행
32
+ // Proceed with caption only on failure
33
33
  }
34
34
  }
35
35
 
36
36
  const contentType = isVideo
37
- ? '영상(릴스)'
37
+ ? 'video (reel)'
38
38
  : mediaType?.includes('Sidecar')
39
- ? '캐러셀(다중 이미지)'
40
- : '사진';
39
+ ? 'carousel (multiple images)'
40
+ : 'photo';
41
41
 
42
42
  return {
43
43
  shortcode,
@@ -10,10 +10,10 @@ const readInstaCredentials = () => {
10
10
  const parseInstaSessionError = (error) => {
11
11
  const message = String(error?.message || '').toLowerCase();
12
12
  return [
13
- '세션 파일이 없습니다',
14
- '세션에 유효한 쿠키',
15
- '세션이 만료',
16
- '로그인이 필요합니다',
13
+ 'no session file found',
14
+ 'no valid cookies in session',
15
+ 'session expired',
16
+ 'login required',
17
17
  'login_required',
18
18
  'checkpoint_required',
19
19
  '401',
@@ -22,7 +22,7 @@ const parseInstaSessionError = (error) => {
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 sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
28
28
 
@@ -30,28 +30,28 @@ const checkLoginResult = async (page) => {
30
30
  const patterns = NAVER_LOGIN_ERROR_PATTERNS;
31
31
 
32
32
  if (content.includes(patterns.wrongPassword)) {
33
- return { success: false, error: 'wrong_password', message: '비밀번호가 잘못되었습니다.' };
33
+ return { success: false, error: 'wrong_password', message: 'Incorrect password.' };
34
34
  }
35
35
  if (content.includes(patterns.accountProtected)) {
36
- return { success: false, error: 'account_protected', message: '계정 보호조치가 활성화되어 있습니다.' };
36
+ return { success: false, error: 'account_protected', message: 'Account protection is enabled.' };
37
37
  }
38
38
  if (content.includes(patterns.regionBlocked)) {
39
- return { success: false, error: 'region_blocked', message: '허용하지 않은 지역에서 접속이 감지되었습니다.' };
39
+ return { success: false, error: 'region_blocked', message: 'Access from a disallowed region was detected.' };
40
40
  }
41
41
  if (content.includes(patterns.usageRestricted)) {
42
- return { success: false, error: 'usage_restricted', message: '비정상적인 활동이 감지되어 이용이 제한되었습니다.' };
42
+ return { success: false, error: 'usage_restricted', message: 'Abnormal activity detected. Usage has been restricted.' };
43
43
  }
44
44
  if (content.includes(patterns.twoFactor)) {
45
- return { success: false, error: 'two_factor', message: '2단계 인증이 필요합니다. --manual 모드로 로그인해 주세요.' };
45
+ return { success: false, error: 'two_factor', message: 'Two-factor authentication required. Please log in using --manual mode.' };
46
46
  }
47
47
 
48
- // 캡차 감지
48
+ // Captcha detection
49
49
  const hasCaptcha = patterns.captcha.some((p) => content.toLowerCase().includes(p.toLowerCase()));
50
50
  if (hasCaptcha) {
51
- return { success: false, error: 'captcha', message: '캡차가 감지되었습니다. --manual 모드를 사용해 주세요.' };
51
+ return { success: false, error: 'captcha', message: 'Captcha detected. Please use --manual mode.' };
52
52
  }
53
53
 
54
- // 성공 (운영원칙 위반 포함)
54
+ // Success (including operation violation notice)
55
55
  if (content.includes(patterns.operationViolation) || content.includes(patterns.newDevice)) {
56
56
  return { success: true };
57
57
  }
@@ -61,7 +61,7 @@ const checkLoginResult = async (page) => {
61
61
  return { success: true };
62
62
  }
63
63
 
64
- return { success: false, error: 'unknown', message: '로그인 상태를 확인할 없습니다.' };
64
+ return { success: false, error: 'unknown', message: 'Unable to verify login status.' };
65
65
  };
66
66
 
67
67
  const createAskForAuthentication = ({ sessionPath, naverApi }) => async ({
@@ -76,7 +76,7 @@ const createAskForAuthentication = ({ sessionPath, naverApi }) => async ({
76
76
  const resolvedPassword = password || readNaverCredentials().password;
77
77
 
78
78
  if (!manual && (!resolvedUsername || !resolvedPassword)) {
79
- throw new Error('네이버 로그인에 id/pw 필요합니다. 환경변수 NAVER_USERNAME/NAVER_PASSWORD 설정하거나 --manual 모드를 사용해 주세요.');
79
+ throw new Error('Naver login requires id/pw. Set the NAVER_USERNAME/NAVER_PASSWORD environment variables or use --manual mode.');
80
80
  }
81
81
 
82
82
  const browser = await chromium.launch({
@@ -102,13 +102,13 @@ const createAskForAuthentication = ({ sessionPath, naverApi }) => async ({
102
102
  if (manual) {
103
103
  console.log('');
104
104
  console.log('==============================');
105
- console.log('수동 로그인 모드로 전환합니다.');
106
- console.log('브라우저에서 직접 네이버 로그인을 완료해 주세요.');
107
- console.log('최대 5분 내에 로그인을 완료해 주세요.');
105
+ console.log('Switching to manual login mode.');
106
+ console.log('Please complete the Naver login in the browser.');
107
+ console.log('Please complete the login within 5 minutes.');
108
108
  console.log('==============================');
109
109
  loginSuccess = await waitForNaverLoginFinish(page, context, 300000);
110
110
  } else {
111
- // JS 인젝션으로 ID/PW 입력 (fill() 대신 감지 우회)
111
+ // Inject ID/PW via JS (instead of fill() — bypasses bot detection)
112
112
  await page.evaluate((id) => {
113
113
  const el = document.getElementById('id');
114
114
  if (el) el.value = id;
@@ -121,14 +121,14 @@ const createAskForAuthentication = ({ sessionPath, naverApi }) => async ({
121
121
  }, resolvedPassword);
122
122
  await sleep(300);
123
123
 
124
- // 로그인 유지 체크
124
+ // Check "keep me logged in"
125
125
  const keepCheck = await page.$(NAVER_LOGIN_SELECTORS.keepLogin);
126
126
  if (keepCheck) {
127
127
  await keepCheck.click().catch(() => {});
128
128
  await sleep(300);
129
129
  }
130
130
 
131
- // 로그인 버튼 클릭
131
+ // Click login button
132
132
  const loginBtn = await page.$(NAVER_LOGIN_SELECTORS.submit);
133
133
  if (loginBtn) {
134
134
  await loginBtn.click();
@@ -137,7 +137,7 @@ const createAskForAuthentication = ({ sessionPath, naverApi }) => async ({
137
137
  }
138
138
  await sleep(3000);
139
139
 
140
- // 결과 확인
140
+ // Check result
141
141
  const result = await checkLoginResult(page);
142
142
  if (!result.success) {
143
143
  throw new Error(result.message);
@@ -145,7 +145,7 @@ const createAskForAuthentication = ({ sessionPath, naverApi }) => async ({
145
145
 
146
146
  loginSuccess = await waitForNaverLoginFinish(page, context, 15000);
147
147
  if (!loginSuccess) {
148
- // URL 기반 추가 확인
148
+ // Additional URL-based check
149
149
  const url = page.url();
150
150
  if (url.includes('naver.com') && !url.includes('nid.naver.com/nidlogin')) {
151
151
  loginSuccess = true;
@@ -154,7 +154,7 @@ const createAskForAuthentication = ({ sessionPath, naverApi }) => async ({
154
154
  }
155
155
 
156
156
  if (!loginSuccess) {
157
- throw new Error('네이버 로그인에 실패했습니다. 아이디/비밀번호를 확인하거나 --manual 모드를 사용해 주세요.');
157
+ throw new Error('Naver login failed. Please verify your id/password or use --manual mode.');
158
158
  }
159
159
 
160
160
  await persistNaverSession(context, sessionPath);
@@ -60,29 +60,29 @@ const createImageComponent = (imgData) => ({
60
60
  const stripHtmlTags = (html) => html.replace(/<[^>]*>/g, '');
61
61
 
62
62
  /**
63
- * HTML 네이버 에디터 컴포넌트 배열로 변환한다.
64
- * Primary: 네이버 API (upconvert.editor.naver.com)
65
- * Fallback: 커스텀 파싱
63
+ * Converts HTML to an array of Naver editor components.
64
+ * Primary: Naver API (upconvert.editor.naver.com)
65
+ * Fallback: Custom parsing
66
66
  */
67
67
  const convertHtmlToEditorComponents = async (naverApi, html, imageComponents = []) => {
68
- // 1. 네이버 API 변환 시도
68
+ // 1. Try Naver API conversion
69
69
  const apiComponents = await naverApi.convertHtmlToComponents(html);
70
70
  if (Array.isArray(apiComponents) && apiComponents.length > 0) {
71
- // 이미지를 위에 배치 (티스토리 스타일)
71
+ // Place images at the top of the post (Tistory style)
72
72
  return [...imageComponents, ...apiComponents];
73
73
  }
74
74
 
75
- // 2. Fallback: 커스텀 파싱 (이미지를 위에 배치)
75
+ // 2. Fallback: Custom parsing (images placed at the top)
76
76
  const textComponents = parseHtmlToComponents(html, []);
77
77
  return [...imageComponents, ...textComponents];
78
78
  };
79
79
 
80
80
  /**
81
- * HTML 수동으로 파싱하여 네이버 에디터 컴포넌트로 변환한다.
82
- * Python process_html_to_components() 포팅
81
+ * Manually parses HTML and converts it to Naver editor components.
82
+ * Ported from Python's process_html_to_components()
83
83
  */
84
84
  const parseHtmlToComponents = (html, imageComponents = []) => {
85
- // heading(h1-h6) 또는 strong 태그 기준으로 분할
85
+ // Split by heading (h1-h6) or strong tags
86
86
  const segments = html.split(/(<h[1-6][^>]*>.*?<\/h[1-6]>|<strong>.*?<\/strong>)/is);
87
87
  const components = [];
88
88
  const images = [...imageComponents];
@@ -96,7 +96,7 @@ const parseHtmlToComponents = (html, imageComponents = []) => {
96
96
  const isStrong = /<strong>/i.test(trimmed);
97
97
  const isBoldSection = isHeading || isStrong;
98
98
 
99
- // heading 태그 자체는 건너뛰기 (Python 코드의 continue와 동일)
99
+ // Skip heading tags themselves (same as Python code's continue)
100
100
  if (/^<h[1-6][^>]*>.*<\/h[1-6]>$/is.test(trimmed)) {
101
101
  const text = stripHtmlTags(trimmed);
102
102
  if (!text.trim()) continue;
@@ -111,7 +111,7 @@ const parseHtmlToComponents = (html, imageComponents = []) => {
111
111
  ctype: 'text',
112
112
  }));
113
113
  } else {
114
- // 이미지 삽입
114
+ // Insert image
115
115
  if (images.length > 0) {
116
116
  components.push(images.shift());
117
117
  }
@@ -125,7 +125,7 @@ const parseHtmlToComponents = (html, imageComponents = []) => {
125
125
  continue;
126
126
  }
127
127
 
128
- // 일반 텍스트 세그먼트
128
+ // Plain text segment
129
129
  const text = stripHtmlTags(trimmed);
130
130
  if (!text.trim()) continue;
131
131
 
@@ -146,7 +146,7 @@ const parseHtmlToComponents = (html, imageComponents = []) => {
146
146
  ctype: 'quotation',
147
147
  }));
148
148
  } else {
149
- // 일반 단락: <p> 또는 <br> 기준으로 분할
149
+ // Regular paragraphs: split by <p> or <br>
150
150
  const paragraphs = text.split(/\n+/).filter((p) => p.trim());
151
151
  for (const para of paragraphs) {
152
152
  components.push(createTextComponent(para.trim()));
@@ -154,7 +154,7 @@ const parseHtmlToComponents = (html, imageComponents = []) => {
154
154
  }
155
155
  }
156
156
 
157
- // 남은 이미지 append
157
+ // Append remaining images
158
158
  for (const img of images) {
159
159
  components.push(img);
160
160
  }
@@ -163,7 +163,7 @@ const parseHtmlToComponents = (html, imageComponents = []) => {
163
163
  };
164
164
 
165
165
  /**
166
- * API 반환 컴포넌트 사이에 이미지를 삽입한다.
166
+ * Intersperses images between API-returned components.
167
167
  */
168
168
  const intersperse = (components, imageComponents) => {
169
169
  if (!imageComponents.length) return components;
@@ -181,7 +181,7 @@ const intersperse = (components, imageComponents) => {
181
181
  result.push(comp);
182
182
  }
183
183
 
184
- // 남은 이미지 append
184
+ // Append remaining images
185
185
  for (const img of images) {
186
186
  result.push(img);
187
187
  }