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.
package/bin/index.js CHANGED
@@ -204,6 +204,32 @@ unlikeCommentCmd
204
204
  .option('--comment-id <id>', 'Comment ID')
205
205
  .action((opts) => execute('unlike-comment', opts));
206
206
 
207
+ const sendDmCmd = program
208
+ .command('send-dm')
209
+ .description('Send a direct message to a user');
210
+ addProviderOption(sendDmCmd);
211
+ sendDmCmd
212
+ .option('--username <username>', 'Recipient username')
213
+ .option('--thread-id <threadId>', 'Existing thread ID')
214
+ .option('--text <message>', 'Message text')
215
+ .action((opts) => execute('send-dm', opts));
216
+
217
+ const listMessagesCmd = program
218
+ .command('list-messages')
219
+ .description('List messages in a DM thread (via browser)');
220
+ addProviderOption(listMessagesCmd);
221
+ listMessagesCmd
222
+ .option('--thread-id <threadId>', 'Thread ID')
223
+ .action((opts) => execute('list-messages', opts));
224
+
225
+ const listCommentsCmd = program
226
+ .command('list-comments')
227
+ .description('List comments on a post');
228
+ addProviderOption(listCommentsCmd);
229
+ listCommentsCmd
230
+ .option('--post-id <shortcode>', 'Post shortcode')
231
+ .action((opts) => execute('list-comments', opts));
232
+
207
233
  const analyzePostCmd = program
208
234
  .command('analyze-post')
209
235
  .description('Analyze a post (thumbnail + caption + profile)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viruagent-cli",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
4
4
  "description": "AI-agent-optimized CLI for blog publishing (Tistory, Naver) and Instagram automation",
5
5
  "private": false,
6
6
  "type": "commonjs",
@@ -8,40 +8,40 @@ const randomDelay = (minSec, maxSec) => {
8
8
  };
9
9
 
10
10
  // ──────────────────────────────────────────────────────────────
11
- // Instagram 안전 액션 규칙 (2026 기준, 리서치 기반)
11
+ // Instagram Safe Action Rules (2026, research-based)
12
12
  //
13
- // [계정 나이별 시간당 한도]
14
- // 신규 (0~20) | 성숙 (20일+)
15
- // 좋아요 15/h | 60/h
16
- // 댓글 5/h | 20/h
17
- // 팔로우 15/h | 60/h
18
- // 언팔로우 10/h | 30/h
13
+ // [Hourly limits by account age]
14
+ // New (0~20 days) | Mature (20+ days)
15
+ // Like 15/h | 60/h
16
+ // Comment 5/h | 20/h
17
+ // Follow 15/h | 60/h
18
+ // Unfollow 10/h | 30/h
19
19
  // DM 5/h | 50/h
20
- // 게시물 3/h | 10/h
20
+ // Publish 3/h | 10/h
21
21
  //
22
- // [일일 한도]
23
- // 좋아요 500/일 댓글 100/일 팔로우 250/일
24
- // 언팔로우 200/일 DM 30/일 게시물 25/일
22
+ // [Daily limits]
23
+ // Like 500/day Comment 100/day Follow 250/day
24
+ // Unfollow 200/day DM 30/day Publish 25/day
25
25
  //
26
- // [최소 액션 간격 (신규 계정)]
27
- // 좋아요: 20~40초 | 댓글: 300~420초(5~7분) | 팔로우: 60~120초
28
- // 언팔로우: 60~120초 | DM: 120~300초 | 게시물: 60~120초
26
+ // [Minimum action intervals (new accounts)]
27
+ // Like: 20~40s | Comment: 300~420s (5~7min) | Follow: 60~120s
28
+ // Unfollow: 60~120s | DM: 120~300s | Publish: 60~120s
29
29
  //
30
- // [주의사항]
31
- // - 시간당 액션 15개 이하 (신규) / 40 이하 (성숙)
32
- // - 균일 간격은 감지랜덤 딜레이 필수
33
- // - 동일 유저에게 반복 액션 금지
34
- // - challenge 발생 브라우저에서 본인 인증 필요
35
- // - challenge 24~48시간 대기 권장
30
+ // [Notes]
31
+ // - Max 15 total actions/hour (new) / 40 (mature)
32
+ // - Uniform intervals trigger bot detection random delay required
33
+ // - Repeated actions on the same user are prohibited
34
+ // - Challenge requires manual verification in the browser
35
+ // - Wait 24~48 hours after a challenge is recommended
36
36
  // ──────────────────────────────────────────────────────────────
37
37
 
38
38
  const DELAY = {
39
- like: [20, 40], // 20~40초
40
- comment: [300, 420], // 5~7분
41
- follow: [60, 120], // 1~2분
42
- unfollow: [60, 120], // 1~2분
43
- dm: [120, 300], // 2~5분
44
- publish: [60, 120], // 1~2분
39
+ like: [20, 40], // 20~40s
40
+ comment: [300, 420], // 5~7min
41
+ follow: [60, 120], // 1~2min
42
+ unfollow: [60, 120], // 1~2min
43
+ dm: [120, 300], // 2~5min
44
+ publish: [60, 120], // 1~2min
45
45
  };
46
46
 
47
47
  const HOURLY_LIMIT = {
@@ -69,7 +69,7 @@ const createInstaApiClient = ({ sessionPath }) => {
69
69
  let cachedUserId = null;
70
70
  let countersCache = null;
71
71
 
72
- // ── 세션 파일 기반 Rate Limit 카운터 ──
72
+ // ── Session file-based Rate Limit counters ──
73
73
 
74
74
  const loadCounters = () => {
75
75
  if (countersCache) return countersCache;
@@ -90,7 +90,7 @@ const createInstaApiClient = ({ sessionPath }) => {
90
90
  if (!userId || !countersCache) return;
91
91
  saveRateLimits(sessionPath, userId, countersCache);
92
92
  } catch {
93
- // 저장 실패해도 동작에 영향 없음
93
+ // Save failure does not affect operation
94
94
  }
95
95
  };
96
96
 
@@ -112,10 +112,10 @@ const createInstaApiClient = ({ sessionPath }) => {
112
112
  const dailyMax = DAILY_LIMIT[type];
113
113
  if (hourlyMax && c.hourly >= hourlyMax) {
114
114
  const waitMin = Math.ceil((3600000 - (Date.now() - c.hourStart)) / 60000);
115
- throw new Error(`hourly_limit: ${type} 시간당 한도 ${hourlyMax} 초과. ${waitMin} 후 재시도하세요.`);
115
+ throw new Error(`hourly_limit: ${type} exceeded hourly limit of ${hourlyMax}. Retry in ${waitMin} minutes.`);
116
116
  }
117
117
  if (dailyMax && c.daily >= dailyMax) {
118
- throw new Error(`daily_limit: ${type} 일일 한도 ${dailyMax} 초과. 내일 다시 시도하세요.`);
118
+ throw new Error(`daily_limit: ${type} exceeded daily limit of ${dailyMax}. Try again tomorrow.`);
119
119
  }
120
120
  };
121
121
 
@@ -130,11 +130,11 @@ const createInstaApiClient = ({ sessionPath }) => {
130
130
  if (cachedCookies) return cachedCookies;
131
131
  const cookies = loadInstaSession(sessionPath);
132
132
  if (!cookies) {
133
- throw new Error('세션 파일이 없습니다. 먼저 로그인해 주세요.');
133
+ throw new Error('No session file found. Please log in first.');
134
134
  }
135
135
  const sessionid = cookies.find((c) => c.name === 'sessionid');
136
136
  if (!sessionid?.value) {
137
- throw new Error('세션에 유효한 쿠키가 없습니다. 다시 로그인해 주세요.');
137
+ throw new Error('No valid cookies in session. Please log in again.');
138
138
  }
139
139
  cachedCookies = cookies;
140
140
  return cookies;
@@ -172,21 +172,21 @@ const createInstaApiClient = ({ sessionPath }) => {
172
172
  if (res.status === 302 || res.status === 301) {
173
173
  const location = res.headers.get('location') || '';
174
174
  if (location.includes('/accounts/login')) {
175
- throw new Error('세션이 만료되었습니다. 다시 로그인해 주세요.');
175
+ throw new Error('Session expired. Please log in again.');
176
176
  }
177
177
  if (location.includes('/challenge')) {
178
- // challenge 자동 해결 시도
178
+ // Attempt automatic challenge resolution
179
179
  const resolved = await resolveChallenge();
180
180
  if (resolved) {
181
- // 해결 원래 요청 재시도
181
+ // Retry original request after resolution
182
182
  return fetch(url, { ...options, headers, redirect: 'manual' });
183
183
  }
184
- throw new Error('challenge_required: 본인 인증이 필요합니다. 브라우저에서 수동으로 처리해 주세요.');
184
+ throw new Error('challenge_required: Identity verification required. Please complete it manually in the browser.');
185
185
  }
186
- throw new Error(`리다이렉트 발생: ${res.status} → ${location}`);
186
+ throw new Error(`Redirect occurred: ${res.status} → ${location}`);
187
187
  }
188
188
 
189
- // challenge_required JSON 응답 처리
189
+ // Handle challenge_required JSON response
190
190
  if (res.status === 400 && !options.allowError) {
191
191
  const cloned = res.clone();
192
192
  try {
@@ -196,20 +196,20 @@ const createInstaApiClient = ({ sessionPath }) => {
196
196
  if (resolved) {
197
197
  return fetch(url, { ...options, headers, redirect: 'manual' });
198
198
  }
199
- throw new Error('challenge_required: 본인 인증이 필요합니다.');
199
+ throw new Error('challenge_required: Identity verification required.');
200
200
  }
201
201
  } catch (e) {
202
202
  if (e.message.includes('challenge_required')) throw e;
203
- // JSON 파싱 실패는 무시하고 원래 흐름
203
+ // Ignore JSON parse failure and continue original flow
204
204
  }
205
205
  }
206
206
 
207
207
  if (res.status === 401 || res.status === 403) {
208
- throw new Error(`인증 오류 (${res.status}). 다시 로그인해 주세요.`);
208
+ throw new Error(`Authentication error (${res.status}). Please log in again.`);
209
209
  }
210
210
 
211
211
  if (!res.ok && !options.allowError) {
212
- throw new Error(`Instagram API 오류: ${res.status} ${res.statusText}`);
212
+ throw new Error(`Instagram API error: ${res.status} ${res.statusText}`);
213
213
  }
214
214
 
215
215
  return res;
@@ -221,7 +221,7 @@ const createInstaApiClient = ({ sessionPath }) => {
221
221
  );
222
222
  const data = await res.json();
223
223
  const user = data?.data?.user;
224
- if (!user) throw new Error(`프로필을 찾을 없습니다: ${username}`);
224
+ if (!user) throw new Error(`Profile not found: ${username}`);
225
225
  return {
226
226
  id: user.id,
227
227
  username: user.username,
@@ -322,7 +322,7 @@ const createInstaApiClient = ({ sessionPath }) => {
322
322
  has_threaded_comments: true,
323
323
  });
324
324
  const media = data?.data?.xdt_shortcode_media || data?.data?.shortcode_media;
325
- if (!media) throw new Error(`게시물을 찾을 없습니다: ${shortcode}`);
325
+ if (!media) throw new Error(`Post not found: ${shortcode}`);
326
326
  return {
327
327
  id: media.id,
328
328
  code: media.shortcode,
@@ -343,7 +343,7 @@ const createInstaApiClient = ({ sessionPath }) => {
343
343
  };
344
344
  };
345
345
 
346
- // ── Challenge 자동 해결 ──
346
+ // ── Automatic Challenge Resolution ──
347
347
 
348
348
  const resolveChallenge = async () => {
349
349
  try {
@@ -379,17 +379,17 @@ const createInstaApiClient = ({ sessionPath }) => {
379
379
  if (data.spam) {
380
380
  throw new Error(`rate_limit: ${data.feedback_title || 'Try Again Later'}`);
381
381
  }
382
- // 이미 좋아요/취소 상태
382
+ // Already liked/unliked state
383
383
  return { status: 'already', message: data.message };
384
384
  }
385
- throw new Error(`Instagram API 오류: ${res.status}`);
385
+ throw new Error(`Instagram API error: ${res.status}`);
386
386
  };
387
387
 
388
388
  const withDelay = async (type, fn) => {
389
- // 한도 체크
389
+ // Check limits
390
390
  checkLimit(type);
391
391
 
392
- // 랜덤 딜레이
392
+ // Random delay
393
393
  const [min, max] = DELAY[type] || [20, 40];
394
394
  const elapsed = (Date.now() - lastActionTime) / 1000;
395
395
  if (lastActionTime > 0 && elapsed < min) {
@@ -545,6 +545,43 @@ const createInstaApiClient = ({ sessionPath }) => {
545
545
  return res.json();
546
546
  });
547
547
 
548
+ const sendDm = (recipientUserId, text) => withDelay('dm', async () => {
549
+ const cookies = getCookies();
550
+ const csrf = getCsrfToken();
551
+ const body = new URLSearchParams({
552
+ action: 'send_item',
553
+ recipient_users: `[[${recipientUserId}]]`,
554
+ client_context: `6${Date.now()}_${Math.floor(Math.random() * 1000000000)}`,
555
+ offline_threading_id: Date.now().toString(),
556
+ text,
557
+ });
558
+ const res = await fetch('https://i.instagram.com/api/v1/direct_v2/threads/broadcast/text/', {
559
+ method: 'POST',
560
+ headers: {
561
+ 'User-Agent': 'Instagram 317.0.0.34.109 Android (30/11; 420dpi; 1080x2220; samsung; SM-A515F; a51; exynos9611; en_US; 562940699)',
562
+ 'X-IG-App-ID': '567067343352427',
563
+ 'X-CSRFToken': csrf,
564
+ 'Content-Type': 'application/x-www-form-urlencoded',
565
+ Cookie: cookiesToHeader(cookies),
566
+ },
567
+ body: body.toString(),
568
+ redirect: 'manual',
569
+ });
570
+ const data = await res.json();
571
+ if (data.status !== 'ok') {
572
+ const errCode = data.content?.error_code;
573
+ if (errCode === 4415001) {
574
+ throw new Error('DM restricted: Account DM feature is limited. Please open Instagram in browser, go to Direct Messages, and resolve any pending prompts or challenges.');
575
+ }
576
+ throw new Error(`DM failed: ${JSON.stringify(data)}`);
577
+ }
578
+ return {
579
+ status: data.status,
580
+ threadId: data.payload?.thread_id || null,
581
+ itemId: data.payload?.item_id || null,
582
+ };
583
+ });
584
+
548
585
  const getMediaIdFromShortcode = async (shortcode) => {
549
586
  const detail = await getPostDetail(shortcode);
550
587
  return detail.id;
@@ -575,7 +612,7 @@ const createInstaApiClient = ({ sessionPath }) => {
575
612
  );
576
613
  const data = await res.json();
577
614
  if (data.status !== 'ok') {
578
- throw new Error(`이미지 업로드 실패: ${data.message || 'unknown'}`);
615
+ throw new Error(`Image upload failed: ${data.message || 'unknown'}`);
579
616
  }
580
617
  return uploadId;
581
618
  };
@@ -601,7 +638,7 @@ const createInstaApiClient = ({ sessionPath }) => {
601
638
  });
602
639
  const data = await res.json();
603
640
  if (data.status !== 'ok') {
604
- throw new Error(`게시물 생성 실패: ${data.message || 'unknown'}`);
641
+ throw new Error(`Post creation failed: ${data.message || 'unknown'}`);
605
642
  }
606
643
  return {
607
644
  id: data.media?.pk,
@@ -617,10 +654,10 @@ const createInstaApiClient = ({ sessionPath }) => {
617
654
  imageBuffer = fs.readFileSync(imagePath);
618
655
  } else if (imageUrl) {
619
656
  const res = await fetch(imageUrl);
620
- if (!res.ok) throw new Error(`이미지 다운로드 실패: ${res.status}`);
657
+ if (!res.ok) throw new Error(`Image download failed: ${res.status}`);
621
658
  imageBuffer = Buffer.from(await res.arrayBuffer());
622
659
  } else {
623
- throw new Error('imageUrl 또는 imagePath 필요합니다.');
660
+ throw new Error('Either imageUrl or imagePath is required.');
624
661
  }
625
662
 
626
663
  const uploadId = await uploadPhoto(imageBuffer);
@@ -666,6 +703,7 @@ const createInstaApiClient = ({ sessionPath }) => {
666
703
  uploadPhoto,
667
704
  configurePost,
668
705
  publishPost,
706
+ sendDm,
669
707
  deletePost,
670
708
  resolveChallenge,
671
709
  resetState,
@@ -676,7 +714,7 @@ const createInstaApiClient = ({ sessionPath }) => {
676
714
  status[type] = {
677
715
  hourly: `${c.hourly}/${HOURLY_LIMIT[type]}`,
678
716
  daily: `${c.daily}/${DAILY_LIMIT[type]}`,
679
- delay: `${DELAY[type]?.[0]}~${DELAY[type]?.[1]}초`,
717
+ delay: `${DELAY[type]?.[0]}~${DELAY[type]?.[1]}s`,
680
718
  };
681
719
  }
682
720
  return status;
@@ -50,12 +50,12 @@ const createAskForAuthentication = ({ sessionPath }) => async ({
50
50
 
51
51
  if (!resolvedUsername || !resolvedPassword) {
52
52
  throw new Error(
53
- '인스타그램 로그인에 username/password 필요합니다. ' +
54
- '환경변수 INSTA_USERNAME / INSTA_PASSWORD 설정해 주세요.',
53
+ 'Instagram login requires username/password. ' +
54
+ 'Please set the INSTA_USERNAME / INSTA_PASSWORD environment variables.',
55
55
  );
56
56
  }
57
57
 
58
- // Step 1: GET login page -> csrftoken + mid 쿠키 획득
58
+ // Step 1: GET login page -> obtain csrftoken + mid cookies
59
59
  const initRes = await fetch('https://www.instagram.com/accounts/login/', {
60
60
  headers: { 'User-Agent': USER_AGENT },
61
61
  redirect: 'manual',
@@ -64,7 +64,7 @@ const createAskForAuthentication = ({ sessionPath }) => async ({
64
64
 
65
65
  const csrfCookie = cookies.find((c) => c.name === 'csrftoken');
66
66
  if (!csrfCookie) {
67
- throw new Error('Instagram 초기 페이지에서 csrftoken 가져올 없습니다.');
67
+ throw new Error('Failed to retrieve csrftoken from the Instagram login page.');
68
68
  }
69
69
  const csrfToken = csrfCookie.value;
70
70
  const cookieHeader = cookies.map((c) => `${c.name}=${c.value}`).join('; ');
@@ -100,7 +100,7 @@ const createAskForAuthentication = ({ sessionPath }) => async ({
100
100
  const loginData = await loginRes.json();
101
101
 
102
102
  if (loginData.checkpoint_url || loginData.message === 'challenge_required') {
103
- // challenge 자동 해결 시도 (choice=0 = 본인입니다)
103
+ // Attempt automatic challenge resolution (choice=0 = "This was me")
104
104
  const challengeRes = await fetch('https://www.instagram.com/api/v1/challenge/web/action/', {
105
105
  method: 'POST',
106
106
  headers: {
@@ -120,11 +120,11 @@ const createAskForAuthentication = ({ sessionPath }) => async ({
120
120
  const challengeData = await challengeRes.json().catch(() => ({}));
121
121
  if (challengeData.status !== 'ok') {
122
122
  throw new Error(
123
- 'challenge_required: 자동 해결 실패. 브라우저에서 본인 인증을 완료해 주세요. ' +
123
+ 'challenge_required: Automatic resolution failed. Please complete identity verification in the browser. ' +
124
124
  (loginData.checkpoint_url || ''),
125
125
  );
126
126
  }
127
- // challenge 해결 재로그인
127
+ // Re-login after challenge resolution
128
128
  const retryRes = await fetch('https://www.instagram.com/api/v1/web/accounts/login/ajax/', {
129
129
  method: 'POST',
130
130
  headers: {
@@ -158,16 +158,16 @@ const createAskForAuthentication = ({ sessionPath }) => async ({
158
158
 
159
159
  if (loginData.two_factor_required) {
160
160
  throw new Error(
161
- '2단계 인증(2FA) 필요합니다. 브라우저에서 먼저 인증을 완료해 주세요.',
161
+ 'Two-factor authentication (2FA) is required. Please complete verification in the browser first.',
162
162
  );
163
163
  }
164
164
 
165
165
  if (!loginData.authenticated) {
166
166
  const reason = loginData.message || loginData.status || 'unknown';
167
- throw new Error(`인스타그램 로그인 실패: ${reason}`);
167
+ throw new Error(`Instagram login failed: ${reason}`);
168
168
  }
169
169
 
170
- // 세션 저장
170
+ // Save session
171
171
  saveInstaSession(sessionPath, cookies);
172
172
 
173
173
  return {