viruagent-cli 0.3.7 → 0.4.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.
@@ -0,0 +1,630 @@
1
+ const fs = require('fs');
2
+ const { loadInstaSession, cookiesToHeader, loadRateLimits, saveRateLimits } = require('./session');
3
+ const { IG_APP_ID, USER_AGENT } = require('./auth');
4
+
5
+ const randomDelay = (minSec, maxSec) => {
6
+ const ms = (minSec + Math.random() * (maxSec - minSec)) * 1000;
7
+ return new Promise((resolve) => setTimeout(resolve, ms));
8
+ };
9
+
10
+ // ──────────────────────────────────────────────────────────────
11
+ // Instagram 안전 액션 규칙 (2026 기준, 리서치 기반)
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
19
+ // DM 5/h | 50/h
20
+ // 게시물 3/h | 10/h
21
+ //
22
+ // [일일 한도]
23
+ // 좋아요 500/일 댓글 100/일 팔로우 250/일
24
+ // 언팔로우 200/일 DM 30/일 게시물 25/일
25
+ //
26
+ // [최소 액션 간격 (신규 계정)]
27
+ // 좋아요: 20~40초 | 댓글: 300~420초(5~7분) | 팔로우: 60~120초
28
+ // 언팔로우: 60~120초 | DM: 120~300초 | 게시물: 60~120초
29
+ //
30
+ // [주의사항]
31
+ // - 시간당 총 액션 15개 이하 (신규) / 40개 이하 (성숙)
32
+ // - 균일 간격은 봇 감지 → 랜덤 딜레이 필수
33
+ // - 동일 유저에게 반복 액션 금지
34
+ // - challenge 발생 시 브라우저에서 본인 인증 필요
35
+ // - challenge 후 24~48시간 대기 권장
36
+ // ──────────────────────────────────────────────────────────────
37
+
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분
45
+ };
46
+
47
+ const HOURLY_LIMIT = {
48
+ like: 15,
49
+ comment: 5,
50
+ follow: 15,
51
+ unfollow: 10,
52
+ dm: 5,
53
+ publish: 3,
54
+ };
55
+
56
+ const DAILY_LIMIT = {
57
+ like: 500,
58
+ comment: 100,
59
+ follow: 250,
60
+ unfollow: 200,
61
+ dm: 30,
62
+ publish: 25,
63
+ };
64
+
65
+ let lastActionTime = 0;
66
+
67
+ const createInstaApiClient = ({ sessionPath }) => {
68
+ let cachedCookies = null;
69
+ let cachedUserId = null;
70
+ let countersCache = null;
71
+
72
+ // ── 세션 파일 기반 Rate Limit 카운터 ──
73
+
74
+ const loadCounters = () => {
75
+ if (countersCache) return countersCache;
76
+ try {
77
+ const userId = getUserId();
78
+ if (!userId) { countersCache = {}; return countersCache; }
79
+ const saved = loadRateLimits(sessionPath, userId);
80
+ countersCache = saved || {};
81
+ } catch {
82
+ countersCache = {};
83
+ }
84
+ return countersCache;
85
+ };
86
+
87
+ const persistCounters = () => {
88
+ try {
89
+ const userId = getUserId();
90
+ if (!userId || !countersCache) return;
91
+ saveRateLimits(sessionPath, userId, countersCache);
92
+ } catch {
93
+ // 저장 실패해도 동작에 영향 없음
94
+ }
95
+ };
96
+
97
+ const getCounter = (type) => {
98
+ const counters = loadCounters();
99
+ if (!counters[type]) {
100
+ counters[type] = { hourly: 0, daily: 0, hourStart: Date.now(), dayStart: Date.now() };
101
+ }
102
+ const c = counters[type];
103
+ const now = Date.now();
104
+ if (now - c.hourStart > 3600000) { c.hourly = 0; c.hourStart = now; }
105
+ if (now - c.dayStart > 86400000) { c.daily = 0; c.dayStart = now; }
106
+ return c;
107
+ };
108
+
109
+ const checkLimit = (type) => {
110
+ const c = getCounter(type);
111
+ const hourlyMax = HOURLY_LIMIT[type];
112
+ const dailyMax = DAILY_LIMIT[type];
113
+ if (hourlyMax && c.hourly >= hourlyMax) {
114
+ const waitMin = Math.ceil((3600000 - (Date.now() - c.hourStart)) / 60000);
115
+ throw new Error(`hourly_limit: ${type} 시간당 한도 ${hourlyMax}개 초과. ${waitMin}분 후 재시도하세요.`);
116
+ }
117
+ if (dailyMax && c.daily >= dailyMax) {
118
+ throw new Error(`daily_limit: ${type} 일일 한도 ${dailyMax}개 초과. 내일 다시 시도하세요.`);
119
+ }
120
+ };
121
+
122
+ const incrementCounter = (type) => {
123
+ const c = getCounter(type);
124
+ c.hourly++;
125
+ c.daily++;
126
+ persistCounters();
127
+ };
128
+
129
+ const getCookies = () => {
130
+ if (cachedCookies) return cachedCookies;
131
+ const cookies = loadInstaSession(sessionPath);
132
+ if (!cookies) {
133
+ throw new Error('세션 파일이 없습니다. 먼저 로그인해 주세요.');
134
+ }
135
+ const sessionid = cookies.find((c) => c.name === 'sessionid');
136
+ if (!sessionid?.value) {
137
+ throw new Error('세션에 유효한 쿠키가 없습니다. 다시 로그인해 주세요.');
138
+ }
139
+ cachedCookies = cookies;
140
+ return cookies;
141
+ };
142
+
143
+ const getCsrfToken = () => {
144
+ const cookies = getCookies();
145
+ const csrf = cookies.find((c) => c.name === 'csrftoken');
146
+ return csrf?.value || '';
147
+ };
148
+
149
+ const getUserId = () => {
150
+ if (cachedUserId) return cachedUserId;
151
+ const cookies = getCookies();
152
+ const dsUser = cookies.find((c) => c.name === 'ds_user_id');
153
+ cachedUserId = dsUser?.value || null;
154
+ return cachedUserId;
155
+ };
156
+
157
+ const request = async (url, options = {}) => {
158
+ const cookies = getCookies();
159
+ const headers = {
160
+ 'User-Agent': USER_AGENT,
161
+ 'X-IG-App-ID': IG_APP_ID,
162
+ 'X-CSRFToken': getCsrfToken(),
163
+ 'X-Requested-With': 'XMLHttpRequest',
164
+ Referer: 'https://www.instagram.com/',
165
+ Origin: 'https://www.instagram.com',
166
+ Cookie: cookiesToHeader(cookies),
167
+ ...options.headers,
168
+ };
169
+
170
+ const res = await fetch(url, { ...options, headers, redirect: 'manual' });
171
+
172
+ if (res.status === 302 || res.status === 301) {
173
+ const location = res.headers.get('location') || '';
174
+ if (location.includes('/accounts/login')) {
175
+ throw new Error('세션이 만료되었습니다. 다시 로그인해 주세요.');
176
+ }
177
+ throw new Error(`리다이렉트 발생: ${res.status} → ${location}`);
178
+ }
179
+
180
+ if (res.status === 401 || res.status === 403) {
181
+ throw new Error(`인증 오류 (${res.status}). 다시 로그인해 주세요.`);
182
+ }
183
+
184
+ if (!res.ok && !options.allowError) {
185
+ throw new Error(`Instagram API 오류: ${res.status} ${res.statusText}`);
186
+ }
187
+
188
+ return res;
189
+ };
190
+
191
+ const getProfile = async (username) => {
192
+ const res = await request(
193
+ `https://www.instagram.com/api/v1/users/web_profile_info/?username=${encodeURIComponent(username)}`,
194
+ );
195
+ const data = await res.json();
196
+ const user = data?.data?.user;
197
+ if (!user) throw new Error(`프로필을 찾을 수 없습니다: ${username}`);
198
+ return {
199
+ id: user.id,
200
+ username: user.username,
201
+ fullName: user.full_name,
202
+ biography: user.biography,
203
+ followerCount: user.edge_followed_by?.count || 0,
204
+ followingCount: user.edge_follow?.count || 0,
205
+ postCount: user.edge_owner_to_timeline_media?.count || 0,
206
+ isPrivate: user.is_private,
207
+ isVerified: user.is_verified,
208
+ profilePicUrl: user.profile_pic_url_hd || user.profile_pic_url,
209
+ externalUrl: user.external_url,
210
+ };
211
+ };
212
+
213
+ const getFeed = async () => {
214
+ const res = await request('https://www.instagram.com/api/v1/feed/timeline/', {
215
+ method: 'POST',
216
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
217
+ });
218
+ const data = await res.json();
219
+ const items = data?.feed_items || data?.items || [];
220
+ return items
221
+ .filter((item) => item.media_or_ad || item.media)
222
+ .slice(0, 20)
223
+ .map((item) => {
224
+ const media = item.media_or_ad || item.media;
225
+ return {
226
+ id: media.pk || media.id,
227
+ code: media.code,
228
+ username: media.user?.username,
229
+ caption: media.caption?.text || '',
230
+ likeCount: media.like_count || 0,
231
+ commentCount: media.comment_count || 0,
232
+ mediaType: media.media_type,
233
+ timestamp: media.taken_at,
234
+ url: media.code ? `https://www.instagram.com/p/${media.code}/` : null,
235
+ imageUrl: media.image_versions2?.candidates?.[0]?.url || null,
236
+ };
237
+ });
238
+ };
239
+
240
+ const graphqlPost = async (docId, variables) => {
241
+ const body = new URLSearchParams({
242
+ variables: JSON.stringify(variables),
243
+ doc_id: docId,
244
+ });
245
+ const res = await request('https://www.instagram.com/graphql/query', {
246
+ method: 'POST',
247
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
248
+ body: body.toString(),
249
+ });
250
+ return res.json();
251
+ };
252
+
253
+ // doc_id for post detail
254
+ const DOC_ID_POST_DETAIL = '8845758582119845';
255
+
256
+ const mapPostItem = (item) => ({
257
+ id: item.pk || item.id,
258
+ code: item.code,
259
+ caption: item.caption?.text || '',
260
+ likeCount: item.like_count || 0,
261
+ commentCount: item.comment_count || 0,
262
+ timestamp: item.taken_at,
263
+ url: item.code ? `https://www.instagram.com/p/${item.code}/` : null,
264
+ imageUrl: item.image_versions2?.candidates?.[0]?.url || item.carousel_media?.[0]?.image_versions2?.candidates?.[0]?.url || null,
265
+ isVideo: item.media_type === 2,
266
+ });
267
+
268
+ const getUserPosts = async (username, limit = 12) => {
269
+ const profile = await getProfile(username);
270
+ const collected = [];
271
+ let maxId = null;
272
+
273
+ while (collected.length < limit) {
274
+ const url = `https://www.instagram.com/api/v1/feed/user/${profile.id}/?count=12` +
275
+ (maxId ? `&max_id=${maxId}` : '');
276
+ const res = await request(url);
277
+ const data = await res.json();
278
+ const items = data?.items || [];
279
+ if (items.length === 0) break;
280
+
281
+ collected.push(...items.map(mapPostItem));
282
+ if (!data.more_available || !data.next_max_id) break;
283
+ maxId = data.next_max_id;
284
+ }
285
+
286
+ return collected.slice(0, limit);
287
+ };
288
+
289
+ const getPostDetail = async (shortcode) => {
290
+ const data = await graphqlPost(DOC_ID_POST_DETAIL, {
291
+ shortcode,
292
+ child_comment_count: 3,
293
+ fetch_comment_count: 40,
294
+ parent_comment_count: 24,
295
+ has_threaded_comments: true,
296
+ });
297
+ const media = data?.data?.xdt_shortcode_media || data?.data?.shortcode_media;
298
+ if (!media) throw new Error(`게시물을 찾을 수 없습니다: ${shortcode}`);
299
+ return {
300
+ id: media.id,
301
+ code: media.shortcode,
302
+ owner: {
303
+ id: media.owner?.id,
304
+ username: media.owner?.username,
305
+ fullName: media.owner?.full_name,
306
+ },
307
+ caption: media.edge_media_to_caption?.edges?.[0]?.node?.text || '',
308
+ likeCount: media.edge_media_preview_like?.count || 0,
309
+ commentCount: media.edge_media_to_parent_comment?.count || media.edge_media_to_comment?.count || 0,
310
+ timestamp: media.taken_at_timestamp,
311
+ url: `https://www.instagram.com/p/${media.shortcode}/`,
312
+ imageUrl: media.display_url || media.thumbnail_src,
313
+ isVideo: media.is_video,
314
+ videoUrl: media.video_url || null,
315
+ mediaType: media.__typename,
316
+ };
317
+ };
318
+
319
+ const parseLikeResponse = async (res) => {
320
+ if (res.ok) return res.json();
321
+ if (res.status === 400) {
322
+ const data = await res.json().catch(() => ({}));
323
+ if (data.spam) {
324
+ throw new Error(`rate_limit: ${data.feedback_title || 'Try Again Later'}`);
325
+ }
326
+ // 이미 좋아요/취소 상태
327
+ return { status: 'already', message: data.message };
328
+ }
329
+ throw new Error(`Instagram API 오류: ${res.status}`);
330
+ };
331
+
332
+ const withDelay = async (type, fn) => {
333
+ // 한도 체크
334
+ checkLimit(type);
335
+
336
+ // 랜덤 딜레이
337
+ const [min, max] = DELAY[type] || [20, 40];
338
+ const elapsed = (Date.now() - lastActionTime) / 1000;
339
+ if (lastActionTime > 0 && elapsed < min) {
340
+ await randomDelay(min - elapsed, max - elapsed);
341
+ }
342
+
343
+ const result = await fn();
344
+ lastActionTime = Date.now();
345
+ incrementCounter(type);
346
+ return result;
347
+ };
348
+
349
+ const likePost = (mediaId) => withDelay('like', async () => {
350
+ const res = await request(
351
+ `https://www.instagram.com/api/v1/web/likes/${mediaId}/like/`,
352
+ {
353
+ method: 'POST',
354
+ headers: {
355
+ 'Content-Type': 'application/x-www-form-urlencoded',
356
+ 'X-Instagram-AJAX': '1',
357
+ },
358
+ body: '',
359
+ allowError: true,
360
+ },
361
+ );
362
+ return parseLikeResponse(res);
363
+ });
364
+
365
+ const unlikePost = (mediaId) => withDelay('like', async () => {
366
+ const res = await request(
367
+ `https://www.instagram.com/api/v1/web/likes/${mediaId}/unlike/`,
368
+ {
369
+ method: 'POST',
370
+ headers: {
371
+ 'Content-Type': 'application/x-www-form-urlencoded',
372
+ 'X-Instagram-AJAX': '1',
373
+ },
374
+ body: '',
375
+ allowError: true,
376
+ },
377
+ );
378
+ return parseLikeResponse(res);
379
+ });
380
+
381
+ const likeComment = (commentId) => withDelay('like', async () => {
382
+ const res = await request(
383
+ `https://www.instagram.com/api/v1/web/comments/like/${commentId}/`,
384
+ {
385
+ method: 'POST',
386
+ headers: {
387
+ 'Content-Type': 'application/x-www-form-urlencoded',
388
+ 'X-Instagram-AJAX': '1',
389
+ },
390
+ body: '',
391
+ allowError: true,
392
+ },
393
+ );
394
+ return parseLikeResponse(res);
395
+ });
396
+
397
+ const unlikeComment = (commentId) => withDelay('like', async () => {
398
+ const res = await request(
399
+ `https://www.instagram.com/api/v1/web/comments/unlike/${commentId}/`,
400
+ {
401
+ method: 'POST',
402
+ headers: {
403
+ 'Content-Type': 'application/x-www-form-urlencoded',
404
+ 'X-Instagram-AJAX': '1',
405
+ },
406
+ body: '',
407
+ allowError: true,
408
+ },
409
+ );
410
+ return parseLikeResponse(res);
411
+ });
412
+
413
+ const followUser = (userId) => withDelay('follow', async () => {
414
+ const res = await request(
415
+ `https://www.instagram.com/api/v1/friendships/create/${userId}/`,
416
+ {
417
+ method: 'POST',
418
+ headers: {
419
+ 'Content-Type': 'application/x-www-form-urlencoded',
420
+ 'X-Instagram-AJAX': '1',
421
+ },
422
+ body: '',
423
+ allowError: true,
424
+ },
425
+ );
426
+ const data = await res.json();
427
+ return {
428
+ status: data.status || (data.friendship_status ? 'ok' : 'fail'),
429
+ following: data.friendship_status?.following || false,
430
+ outgoingRequest: data.friendship_status?.outgoing_request || false,
431
+ };
432
+ });
433
+
434
+ const unfollowUser = (userId) => withDelay('unfollow', async () => {
435
+ const res = await request(
436
+ `https://www.instagram.com/api/v1/friendships/destroy/${userId}/`,
437
+ {
438
+ method: 'POST',
439
+ headers: {
440
+ 'Content-Type': 'application/x-www-form-urlencoded',
441
+ 'X-Instagram-AJAX': '1',
442
+ },
443
+ body: '',
444
+ allowError: true,
445
+ },
446
+ );
447
+ const data = await res.json();
448
+ return {
449
+ status: data.status || (data.friendship_status ? 'ok' : 'fail'),
450
+ following: data.friendship_status?.following || false,
451
+ };
452
+ });
453
+
454
+ const getComments = async (shortcode) => {
455
+ const data = await graphqlPost(DOC_ID_POST_DETAIL, {
456
+ shortcode,
457
+ child_comment_count: 0,
458
+ fetch_comment_count: 40,
459
+ parent_comment_count: 40,
460
+ has_threaded_comments: true,
461
+ });
462
+ const media = data?.data?.xdt_shortcode_media || data?.data?.shortcode_media;
463
+ const edges = media?.edge_media_to_parent_comment?.edges || media?.edge_media_to_comment?.edges || [];
464
+ return edges.map((e) => ({
465
+ id: e.node?.id,
466
+ text: e.node?.text || '',
467
+ username: e.node?.owner?.username || '',
468
+ userId: e.node?.owner?.id || '',
469
+ timestamp: e.node?.created_at,
470
+ }));
471
+ };
472
+
473
+ const hasMyComment = async (shortcode) => {
474
+ const myUserId = getUserId();
475
+ const comments = await getComments(shortcode);
476
+ return comments.some((c) => c.userId === myUserId);
477
+ };
478
+
479
+ const addComment = (mediaId, text) => withDelay('comment', async () => {
480
+ const body = new URLSearchParams({ comment_text: text });
481
+ const res = await request(
482
+ `https://www.instagram.com/api/v1/web/comments/${mediaId}/add/`,
483
+ {
484
+ method: 'POST',
485
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
486
+ body: body.toString(),
487
+ },
488
+ );
489
+ return res.json();
490
+ });
491
+
492
+ const getMediaIdFromShortcode = async (shortcode) => {
493
+ const detail = await getPostDetail(shortcode);
494
+ return detail.id;
495
+ };
496
+
497
+ const uploadPhoto = async (imageBuffer) => {
498
+ const uploadId = Date.now().toString();
499
+ const uploadName = `${uploadId}_0_${Math.floor(Math.random() * 9000000000 + 1000000000)}`;
500
+
501
+ const res = await request(
502
+ `https://www.instagram.com/rupload_igphoto/${uploadName}`,
503
+ {
504
+ method: 'POST',
505
+ headers: {
506
+ 'Content-Type': 'image/jpeg',
507
+ 'X-Entity-Name': uploadName,
508
+ 'X-Entity-Length': imageBuffer.length.toString(),
509
+ 'X-Instagram-Rupload-Params': JSON.stringify({
510
+ media_type: 1,
511
+ upload_id: uploadId,
512
+ upload_media_height: 1080,
513
+ upload_media_width: 1080,
514
+ }),
515
+ Offset: '0',
516
+ },
517
+ body: imageBuffer,
518
+ },
519
+ );
520
+ const data = await res.json();
521
+ if (data.status !== 'ok') {
522
+ throw new Error(`이미지 업로드 실패: ${data.message || 'unknown'}`);
523
+ }
524
+ return uploadId;
525
+ };
526
+
527
+ const configurePost = (uploadId, caption = '') => withDelay('publish', async () => {
528
+ const body = new URLSearchParams({
529
+ source_type: 'library',
530
+ caption,
531
+ upload_id: uploadId,
532
+ disable_comments: '0',
533
+ like_and_view_counts_disabled: '0',
534
+ igtv_share_preview_to_feed: '1',
535
+ is_unified_video: '1',
536
+ video_subtitles_enabled: '0',
537
+ });
538
+ const res = await request('https://www.instagram.com/api/v1/media/configure/', {
539
+ method: 'POST',
540
+ headers: {
541
+ 'Content-Type': 'application/x-www-form-urlencoded',
542
+ Referer: 'https://www.instagram.com/create/details/',
543
+ },
544
+ body: body.toString(),
545
+ });
546
+ const data = await res.json();
547
+ if (data.status !== 'ok') {
548
+ throw new Error(`게시물 생성 실패: ${data.message || 'unknown'}`);
549
+ }
550
+ return {
551
+ id: data.media?.pk,
552
+ code: data.media?.code,
553
+ url: data.media?.code ? `https://www.instagram.com/p/${data.media.code}/` : null,
554
+ caption: data.media?.caption?.text || caption,
555
+ };
556
+ });
557
+
558
+ const publishPost = async ({ imageUrl, imagePath, caption = '' }) => {
559
+ let imageBuffer;
560
+ if (imagePath) {
561
+ imageBuffer = fs.readFileSync(imagePath);
562
+ } else if (imageUrl) {
563
+ const res = await fetch(imageUrl);
564
+ if (!res.ok) throw new Error(`이미지 다운로드 실패: ${res.status}`);
565
+ imageBuffer = Buffer.from(await res.arrayBuffer());
566
+ } else {
567
+ throw new Error('imageUrl 또는 imagePath가 필요합니다.');
568
+ }
569
+
570
+ const uploadId = await uploadPhoto(imageBuffer);
571
+ return configurePost(uploadId, caption);
572
+ };
573
+
574
+ const deletePost = async (mediaId) => {
575
+ const res = await request(
576
+ `https://www.instagram.com/api/v1/media/${mediaId}/delete/?media_type=PHOTO`,
577
+ {
578
+ method: 'POST',
579
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
580
+ },
581
+ );
582
+ return res.json();
583
+ };
584
+
585
+ const resetState = () => {
586
+ cachedCookies = null;
587
+ cachedUserId = null;
588
+ countersCache = null;
589
+ };
590
+
591
+ return {
592
+ getCookies,
593
+ getCsrfToken,
594
+ getUserId,
595
+ request,
596
+ getProfile,
597
+ getFeed,
598
+ getUserPosts,
599
+ getPostDetail,
600
+ likePost,
601
+ unlikePost,
602
+ likeComment,
603
+ unlikeComment,
604
+ followUser,
605
+ unfollowUser,
606
+ getComments,
607
+ hasMyComment,
608
+ addComment,
609
+ getMediaIdFromShortcode,
610
+ uploadPhoto,
611
+ configurePost,
612
+ publishPost,
613
+ deletePost,
614
+ resetState,
615
+ getRateLimitStatus: () => {
616
+ const status = {};
617
+ for (const type of Object.keys(HOURLY_LIMIT)) {
618
+ const c = getCounter(type);
619
+ status[type] = {
620
+ hourly: `${c.hourly}/${HOURLY_LIMIT[type]}`,
621
+ daily: `${c.daily}/${DAILY_LIMIT[type]}`,
622
+ delay: `${DELAY[type]?.[0]}~${DELAY[type]?.[1]}초`,
623
+ };
624
+ }
625
+ return status;
626
+ },
627
+ };
628
+ };
629
+
630
+ module.exports = createInstaApiClient;