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,135 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { readInstaCredentials } = require('./utils');
4
+ const { saveInstaSession } = require('./session');
5
+
6
+ const IG_APP_ID = '936619743392459';
7
+ const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36';
8
+
9
+ const parseCookiesFromHeaders = (headers) => {
10
+ const cookies = [];
11
+ const setCookies = headers.getSetCookie?.() || [];
12
+ for (const raw of setCookies) {
13
+ const parts = raw.split(';').map((s) => s.trim());
14
+ const [nameValue, ...attrs] = parts;
15
+ const eqIndex = nameValue.indexOf('=');
16
+ if (eqIndex < 0) continue;
17
+
18
+ const name = nameValue.slice(0, eqIndex);
19
+ const value = nameValue.slice(eqIndex + 1);
20
+
21
+ const cookie = { name, value, domain: '.instagram.com', path: '/' };
22
+ for (const attr of attrs) {
23
+ const lower = attr.toLowerCase();
24
+ if (lower.startsWith('domain=')) cookie.domain = attr.slice(7);
25
+ if (lower.startsWith('path=')) cookie.path = attr.slice(5);
26
+ if (lower === 'httponly') cookie.httpOnly = true;
27
+ if (lower === 'secure') cookie.secure = true;
28
+ }
29
+ cookies.push(cookie);
30
+ }
31
+ return cookies;
32
+ };
33
+
34
+ const mergeCookies = (existing, incoming) => {
35
+ const map = new Map();
36
+ for (const c of [...existing, ...incoming]) {
37
+ map.set(c.name, c);
38
+ }
39
+ return [...map.values()];
40
+ };
41
+
42
+ const createAskForAuthentication = ({ sessionPath }) => async ({
43
+ username,
44
+ password,
45
+ } = {}) => {
46
+ fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
47
+
48
+ const resolvedUsername = username || readInstaCredentials().username;
49
+ const resolvedPassword = password || readInstaCredentials().password;
50
+
51
+ if (!resolvedUsername || !resolvedPassword) {
52
+ throw new Error(
53
+ '인스타그램 로그인에 username/password가 필요합니다. ' +
54
+ '환경변수 INSTA_USERNAME / INSTA_PASSWORD를 설정해 주세요.',
55
+ );
56
+ }
57
+
58
+ // Step 1: GET login page -> csrftoken + mid 쿠키 획득
59
+ const initRes = await fetch('https://www.instagram.com/accounts/login/', {
60
+ headers: { 'User-Agent': USER_AGENT },
61
+ redirect: 'manual',
62
+ });
63
+ let cookies = parseCookiesFromHeaders(initRes.headers);
64
+
65
+ const csrfCookie = cookies.find((c) => c.name === 'csrftoken');
66
+ if (!csrfCookie) {
67
+ throw new Error('Instagram 초기 페이지에서 csrftoken을 가져올 수 없습니다.');
68
+ }
69
+ const csrfToken = csrfCookie.value;
70
+ const cookieHeader = cookies.map((c) => `${c.name}=${c.value}`).join('; ');
71
+
72
+ // Step 2: POST login
73
+ const timestamp = Math.floor(Date.now() / 1000);
74
+ const body = new URLSearchParams({
75
+ username: resolvedUsername,
76
+ enc_password: `#PWD_INSTAGRAM_BROWSER:0:${timestamp}:${resolvedPassword}`,
77
+ queryParams: '{}',
78
+ optIntoOneTap: 'false',
79
+ });
80
+
81
+ const loginRes = await fetch('https://www.instagram.com/api/v1/web/accounts/login/ajax/', {
82
+ method: 'POST',
83
+ headers: {
84
+ 'User-Agent': USER_AGENT,
85
+ 'X-CSRFToken': csrfToken,
86
+ 'X-Requested-With': 'XMLHttpRequest',
87
+ 'X-IG-App-ID': IG_APP_ID,
88
+ 'Content-Type': 'application/x-www-form-urlencoded',
89
+ Referer: 'https://www.instagram.com/accounts/login/',
90
+ Origin: 'https://www.instagram.com',
91
+ Cookie: cookieHeader,
92
+ },
93
+ body: body.toString(),
94
+ redirect: 'manual',
95
+ });
96
+
97
+ const loginCookies = parseCookiesFromHeaders(loginRes.headers);
98
+ cookies = mergeCookies(cookies, loginCookies);
99
+
100
+ const loginData = await loginRes.json();
101
+
102
+ if (loginData.checkpoint_url) {
103
+ throw new Error(
104
+ '2단계 인증(checkpoint)이 필요합니다. 브라우저에서 먼저 인증을 완료해 주세요.',
105
+ );
106
+ }
107
+
108
+ if (loginData.two_factor_required) {
109
+ throw new Error(
110
+ '2단계 인증(2FA)이 필요합니다. 브라우저에서 먼저 인증을 완료해 주세요.',
111
+ );
112
+ }
113
+
114
+ if (!loginData.authenticated) {
115
+ const reason = loginData.message || loginData.status || 'unknown';
116
+ throw new Error(`인스타그램 로그인 실패: ${reason}`);
117
+ }
118
+
119
+ // 세션 저장
120
+ saveInstaSession(sessionPath, cookies);
121
+
122
+ return {
123
+ provider: 'insta',
124
+ loggedIn: true,
125
+ userId: loginData.userId || null,
126
+ username: resolvedUsername,
127
+ sessionPath,
128
+ };
129
+ };
130
+
131
+ module.exports = {
132
+ createAskForAuthentication,
133
+ IG_APP_ID,
134
+ USER_AGENT,
135
+ };
@@ -0,0 +1,293 @@
1
+ const { saveProviderMeta, clearProviderMeta, getProviderMeta } = require('../../storage/sessionStore');
2
+ const createInstaApiClient = require('./apiClient');
3
+ const createSmartComment = require('./smartComment');
4
+ const { readInstaCredentials } = require('./utils');
5
+ const { createInstaWithProviderSession } = require('./session');
6
+ const { createAskForAuthentication } = require('./auth');
7
+
8
+ const createInstaProvider = ({ sessionPath }) => {
9
+ const instaApi = createInstaApiClient({ sessionPath });
10
+
11
+ const askForAuthentication = createAskForAuthentication({ sessionPath });
12
+
13
+ const withProviderSession = createInstaWithProviderSession(askForAuthentication);
14
+ const smart = createSmartComment(instaApi);
15
+
16
+ return {
17
+ id: 'insta',
18
+ name: 'Instagram',
19
+
20
+ async authStatus() {
21
+ return withProviderSession(async () => {
22
+ try {
23
+ const userId = instaApi.getUserId();
24
+ const cookies = instaApi.getCookies();
25
+ const sessionid = cookies.find((c) => c.name === 'sessionid');
26
+ return {
27
+ provider: 'insta',
28
+ loggedIn: true,
29
+ userId,
30
+ hasSession: Boolean(sessionid?.value),
31
+ sessionPath,
32
+ metadata: getProviderMeta('insta') || {},
33
+ };
34
+ } catch (error) {
35
+ return {
36
+ provider: 'insta',
37
+ loggedIn: false,
38
+ sessionPath,
39
+ error: error.message,
40
+ metadata: getProviderMeta('insta') || {},
41
+ };
42
+ }
43
+ });
44
+ },
45
+
46
+ async login({ username, password } = {}) {
47
+ const creds = readInstaCredentials();
48
+ const resolved = {
49
+ username: username || creds.username,
50
+ password: password || creds.password,
51
+ };
52
+
53
+ if (!resolved.username || !resolved.password) {
54
+ throw new Error(
55
+ '인스타그램 로그인에 username/password가 필요합니다. ' +
56
+ '환경변수 INSTA_USERNAME / INSTA_PASSWORD를 설정해 주세요.',
57
+ );
58
+ }
59
+
60
+ const result = await askForAuthentication(resolved);
61
+ instaApi.resetState();
62
+
63
+ saveProviderMeta('insta', {
64
+ loggedIn: result.loggedIn,
65
+ userId: result.userId,
66
+ username: result.username,
67
+ sessionPath: result.sessionPath,
68
+ });
69
+
70
+ return result;
71
+ },
72
+
73
+ async getProfile({ username } = {}) {
74
+ return withProviderSession(async () => {
75
+ if (!username) {
76
+ throw new Error('username이 필요합니다.');
77
+ }
78
+ const profile = await instaApi.getProfile(username);
79
+ return {
80
+ provider: 'insta',
81
+ mode: 'profile',
82
+ ...profile,
83
+ };
84
+ });
85
+ },
86
+
87
+ async getFeed() {
88
+ return withProviderSession(async () => {
89
+ const items = await instaApi.getFeed();
90
+ return {
91
+ provider: 'insta',
92
+ mode: 'feed',
93
+ count: items.length,
94
+ items,
95
+ };
96
+ });
97
+ },
98
+
99
+ async listPosts({ username, limit = 12 } = {}) {
100
+ return withProviderSession(async () => {
101
+ if (!username) {
102
+ throw new Error('username이 필요합니다.');
103
+ }
104
+ const posts = await instaApi.getUserPosts(username, limit);
105
+ return {
106
+ provider: 'insta',
107
+ mode: 'posts',
108
+ username,
109
+ totalCount: posts.length,
110
+ posts,
111
+ };
112
+ });
113
+ },
114
+
115
+ async getPost({ postId } = {}) {
116
+ return withProviderSession(async () => {
117
+ const shortcode = String(postId || '').trim();
118
+ if (!shortcode) {
119
+ return {
120
+ provider: 'insta',
121
+ mode: 'post',
122
+ status: 'invalid_post_id',
123
+ message: 'postId(shortcode)가 필요합니다.',
124
+ };
125
+ }
126
+ const post = await instaApi.getPostDetail(shortcode);
127
+ return {
128
+ provider: 'insta',
129
+ mode: 'post',
130
+ ...post,
131
+ };
132
+ });
133
+ },
134
+
135
+ async follow({ username } = {}) {
136
+ return withProviderSession(async () => {
137
+ if (!username) throw new Error('username이 필요합니다.');
138
+ const profile = await instaApi.getProfile(username);
139
+ const result = await instaApi.followUser(profile.id);
140
+ return {
141
+ provider: 'insta',
142
+ mode: 'follow',
143
+ username,
144
+ userId: profile.id,
145
+ following: result.following,
146
+ outgoingRequest: result.outgoingRequest,
147
+ status: result.status,
148
+ };
149
+ });
150
+ },
151
+
152
+ async unfollow({ username } = {}) {
153
+ return withProviderSession(async () => {
154
+ if (!username) throw new Error('username이 필요합니다.');
155
+ const profile = await instaApi.getProfile(username);
156
+ const result = await instaApi.unfollowUser(profile.id);
157
+ return {
158
+ provider: 'insta',
159
+ mode: 'unfollow',
160
+ username,
161
+ userId: profile.id,
162
+ following: result.following,
163
+ status: result.status,
164
+ };
165
+ });
166
+ },
167
+
168
+ async like({ postId } = {}) {
169
+ return withProviderSession(async () => {
170
+ const shortcode = String(postId || '').trim();
171
+ if (!shortcode) throw new Error('postId(shortcode)가 필요합니다.');
172
+ const mediaId = await instaApi.getMediaIdFromShortcode(shortcode);
173
+ const result = await instaApi.likePost(mediaId);
174
+ return { provider: 'insta', mode: 'like', postId: shortcode, status: result.status };
175
+ });
176
+ },
177
+
178
+ async unlike({ postId } = {}) {
179
+ return withProviderSession(async () => {
180
+ const shortcode = String(postId || '').trim();
181
+ if (!shortcode) throw new Error('postId(shortcode)가 필요합니다.');
182
+ const mediaId = await instaApi.getMediaIdFromShortcode(shortcode);
183
+ const result = await instaApi.unlikePost(mediaId);
184
+ return { provider: 'insta', mode: 'unlike', postId: shortcode, status: result.status };
185
+ });
186
+ },
187
+
188
+ async likeComment({ commentId } = {}) {
189
+ return withProviderSession(async () => {
190
+ if (!commentId) throw new Error('commentId가 필요합니다.');
191
+ const result = await instaApi.likeComment(commentId);
192
+ return { provider: 'insta', mode: 'likeComment', commentId, status: result.status };
193
+ });
194
+ },
195
+
196
+ async unlikeComment({ commentId } = {}) {
197
+ return withProviderSession(async () => {
198
+ if (!commentId) throw new Error('commentId가 필요합니다.');
199
+ const result = await instaApi.unlikeComment(commentId);
200
+ return { provider: 'insta', mode: 'unlikeComment', commentId, status: result.status };
201
+ });
202
+ },
203
+
204
+ async comment({ postId, text } = {}) {
205
+ return withProviderSession(async () => {
206
+ const shortcode = String(postId || '').trim();
207
+ const commentText = String(text || '').trim();
208
+ if (!shortcode) {
209
+ throw new Error('postId(shortcode)가 필요합니다.');
210
+ }
211
+ if (!commentText) {
212
+ throw new Error('댓글 내용(text)이 필요합니다.');
213
+ }
214
+ const mediaId = await instaApi.getMediaIdFromShortcode(shortcode);
215
+ const result = await instaApi.addComment(mediaId, commentText);
216
+ return {
217
+ provider: 'insta',
218
+ mode: 'comment',
219
+ postId: shortcode,
220
+ commentId: result.id,
221
+ text: result.text,
222
+ from: result.from?.username,
223
+ status: result.status,
224
+ };
225
+ });
226
+ },
227
+
228
+ async publish({ imageUrl, imagePath, caption = '' } = {}) {
229
+ return withProviderSession(async () => {
230
+ if (!imageUrl && !imagePath) {
231
+ throw new Error('imageUrl 또는 imagePath가 필요합니다.');
232
+ }
233
+ const result = await instaApi.publishPost({ imageUrl, imagePath, caption });
234
+ return {
235
+ provider: 'insta',
236
+ mode: 'publish',
237
+ ...result,
238
+ };
239
+ });
240
+ },
241
+
242
+ async analyzePost({ postId } = {}) {
243
+ return withProviderSession(async () => {
244
+ const shortcode = String(postId || '').trim();
245
+ if (!shortcode) {
246
+ throw new Error('postId(shortcode)가 필요합니다.');
247
+ }
248
+ const analysis = await smart.analyzePost({ shortcode });
249
+ return {
250
+ provider: 'insta',
251
+ mode: 'analyze',
252
+ ...analysis,
253
+ };
254
+ });
255
+ },
256
+
257
+ async deletePost({ postId } = {}) {
258
+ return withProviderSession(async () => {
259
+ const shortcode = String(postId || '').trim();
260
+ if (!shortcode) {
261
+ throw new Error('postId(shortcode)가 필요합니다.');
262
+ }
263
+ const mediaId = await instaApi.getMediaIdFromShortcode(shortcode);
264
+ const result = await instaApi.deletePost(mediaId);
265
+ return {
266
+ provider: 'insta',
267
+ mode: 'delete',
268
+ postId: shortcode,
269
+ status: result.status,
270
+ };
271
+ });
272
+ },
273
+
274
+ rateLimitStatus() {
275
+ return {
276
+ provider: 'insta',
277
+ mode: 'rateLimitStatus',
278
+ ...instaApi.getRateLimitStatus(),
279
+ };
280
+ },
281
+
282
+ async logout() {
283
+ clearProviderMeta('insta');
284
+ return {
285
+ provider: 'insta',
286
+ loggedOut: true,
287
+ sessionPath,
288
+ };
289
+ },
290
+ };
291
+ };
292
+
293
+ module.exports = createInstaProvider;
@@ -0,0 +1,118 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { saveProviderMeta } = require('../../storage/sessionStore');
4
+ const { readInstaCredentials, parseInstaSessionError, buildLoginErrorMessage } = require('./utils');
5
+
6
+ const ESSENTIAL_COOKIES = ['sessionid', 'csrftoken', 'ds_user_id'];
7
+
8
+ const readSessionFile = (sessionPath) => {
9
+ if (!fs.existsSync(sessionPath)) return null;
10
+ try {
11
+ return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));
12
+ } catch {
13
+ return null;
14
+ }
15
+ };
16
+
17
+ const writeSessionFile = (sessionPath, data) => {
18
+ fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
19
+ fs.writeFileSync(sessionPath, JSON.stringify(data, null, 2), 'utf-8');
20
+ };
21
+
22
+ const saveInstaSession = (sessionPath, cookies) => {
23
+ const existing = readSessionFile(sessionPath) || {};
24
+ writeSessionFile(sessionPath, {
25
+ ...existing,
26
+ cookies,
27
+ updatedAt: new Date().toISOString(),
28
+ });
29
+ };
30
+
31
+ const loadInstaSession = (sessionPath) => {
32
+ const raw = readSessionFile(sessionPath);
33
+ return Array.isArray(raw?.cookies) ? raw.cookies : null;
34
+ };
35
+
36
+ // ── Rate Limit 영속화 (userId별) ──
37
+
38
+ const loadRateLimits = (sessionPath, userId) => {
39
+ const raw = readSessionFile(sessionPath);
40
+ return raw?.rateLimits?.[userId] || null;
41
+ };
42
+
43
+ const saveRateLimits = (sessionPath, userId, counters) => {
44
+ const raw = readSessionFile(sessionPath) || {};
45
+ if (!raw.rateLimits) raw.rateLimits = {};
46
+ raw.rateLimits[userId] = {
47
+ ...counters,
48
+ savedAt: new Date().toISOString(),
49
+ };
50
+ writeSessionFile(sessionPath, raw);
51
+ };
52
+
53
+ const validateInstaSession = (sessionPath) => {
54
+ const cookies = loadInstaSession(sessionPath);
55
+ if (!cookies) return false;
56
+ return ESSENTIAL_COOKIES.every((name) =>
57
+ cookies.some((c) => c.name === name && c.value),
58
+ );
59
+ };
60
+
61
+ const cookiesToHeader = (cookies) =>
62
+ cookies.map((c) => `${c.name}=${c.value}`).join('; ');
63
+
64
+ const createInstaWithProviderSession = (askForAuthentication) => async (fn) => {
65
+ const credentials = readInstaCredentials();
66
+ const hasCredentials = Boolean(credentials.username && credentials.password);
67
+
68
+ try {
69
+ const result = await fn();
70
+ saveProviderMeta('insta', {
71
+ loggedIn: true,
72
+ lastValidatedAt: new Date().toISOString(),
73
+ });
74
+ return result;
75
+ } catch (error) {
76
+ if (!parseInstaSessionError(error) || !hasCredentials) {
77
+ throw error;
78
+ }
79
+
80
+ try {
81
+ const loginResult = await askForAuthentication({
82
+ username: credentials.username,
83
+ password: credentials.password,
84
+ });
85
+
86
+ saveProviderMeta('insta', {
87
+ loggedIn: loginResult.loggedIn,
88
+ userId: loginResult.userId,
89
+ sessionPath: loginResult.sessionPath,
90
+ lastRefreshedAt: new Date().toISOString(),
91
+ lastError: null,
92
+ });
93
+
94
+ if (!loginResult.loggedIn) {
95
+ throw new Error(loginResult.message || '세션 갱신 후 로그인 상태가 확인되지 않았습니다.');
96
+ }
97
+
98
+ return fn();
99
+ } catch (reloginError) {
100
+ saveProviderMeta('insta', {
101
+ loggedIn: false,
102
+ lastError: buildLoginErrorMessage(reloginError),
103
+ lastValidatedAt: new Date().toISOString(),
104
+ });
105
+ throw reloginError;
106
+ }
107
+ }
108
+ };
109
+
110
+ module.exports = {
111
+ saveInstaSession,
112
+ loadInstaSession,
113
+ validateInstaSession,
114
+ cookiesToHeader,
115
+ loadRateLimits,
116
+ saveRateLimits,
117
+ createInstaWithProviderSession,
118
+ };
@@ -0,0 +1,67 @@
1
+ const createSmartComment = (instaApi) => {
2
+ const analyzePost = async ({ shortcode }) => {
3
+ // 1. 게시물 상세
4
+ const post = await instaApi.getPostDetail(shortcode);
5
+ const caption = post.caption || '';
6
+ const isVideo = post.isVideo;
7
+ const mediaType = post.mediaType;
8
+ const thumbnailUrl = post.imageUrl;
9
+ const ownerUsername = post.owner?.username || '';
10
+
11
+ // 2. 작성자 프로필
12
+ let ownerProfile = null;
13
+ try {
14
+ ownerProfile = await instaApi.getProfile(ownerUsername);
15
+ } catch {
16
+ // 비공개 등 실패 무시
17
+ }
18
+
19
+ // 3. 썸네일 이미지 base64 (Claude Code Vision용)
20
+ let thumbnailBase64 = null;
21
+ let thumbnailMediaType = 'image/jpeg';
22
+ if (thumbnailUrl) {
23
+ try {
24
+ const res = await fetch(thumbnailUrl);
25
+ if (res.ok) {
26
+ const buffer = Buffer.from(await res.arrayBuffer());
27
+ thumbnailBase64 = buffer.toString('base64');
28
+ const ct = res.headers.get('content-type');
29
+ if (ct) thumbnailMediaType = ct;
30
+ }
31
+ } catch {
32
+ // 실패해도 캡션만으로 진행
33
+ }
34
+ }
35
+
36
+ const contentType = isVideo
37
+ ? '영상(릴스)'
38
+ : mediaType?.includes('Sidecar')
39
+ ? '캐러셀(다중 이미지)'
40
+ : '사진';
41
+
42
+ return {
43
+ shortcode,
44
+ contentType,
45
+ caption,
46
+ isVideo,
47
+ owner: {
48
+ username: ownerUsername,
49
+ fullName: ownerProfile?.fullName || '',
50
+ biography: ownerProfile?.biography || '',
51
+ followerCount: ownerProfile?.followerCount || 0,
52
+ },
53
+ engagement: {
54
+ likeCount: post.likeCount,
55
+ commentCount: post.commentCount,
56
+ },
57
+ thumbnailUrl,
58
+ thumbnailBase64,
59
+ thumbnailMediaType,
60
+ postUrl: post.url,
61
+ };
62
+ };
63
+
64
+ return { analyzePost };
65
+ };
66
+
67
+ module.exports = createSmartComment;
@@ -0,0 +1,34 @@
1
+ const readInstaCredentials = () => {
2
+ const username = process.env.INSTA_USERNAME || process.env.INSTAGRAM_USERNAME || process.env.INSTA_USER;
3
+ const password = process.env.INSTA_PASSWORD || process.env.INSTAGRAM_PASSWORD || process.env.INSTA_PW;
4
+ return {
5
+ username: typeof username === 'string' && username.trim() ? username.trim() : null,
6
+ password: typeof password === 'string' && password.trim() ? password.trim() : null,
7
+ };
8
+ };
9
+
10
+ const parseInstaSessionError = (error) => {
11
+ const message = String(error?.message || '').toLowerCase();
12
+ return [
13
+ '세션 파일이 없습니다',
14
+ '세션에 유효한 쿠키',
15
+ '세션이 만료',
16
+ '로그인이 필요합니다',
17
+ 'login_required',
18
+ 'checkpoint_required',
19
+ '401',
20
+ '403',
21
+ '302',
22
+ ].some((token) => message.includes(token.toLowerCase()));
23
+ };
24
+
25
+ const buildLoginErrorMessage = (error) => String(error?.message || '세션 검증에 실패했습니다.');
26
+
27
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
28
+
29
+ module.exports = {
30
+ readInstaCredentials,
31
+ parseInstaSessionError,
32
+ buildLoginErrorMessage,
33
+ sleep,
34
+ };