viruagent-cli 0.5.0 → 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 +26 -0
- package/package.json +1 -1
- package/src/providers/insta/apiClient.js +38 -0
- package/src/providers/insta/index.js +198 -0
- package/src/runner.js +21 -0
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
|
@@ -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;
|
|
@@ -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,
|
|
@@ -239,6 +239,204 @@ 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();
|
package/src/runner.js
CHANGED
|
@@ -231,6 +231,27 @@ const runCommand = async (command, opts = {}) => {
|
|
|
231
231
|
}
|
|
232
232
|
return withProvider(() => provider.unlikeComment({ commentId: opts.commentId }))();
|
|
233
233
|
|
|
234
|
+
case 'send-dm':
|
|
235
|
+
if (!opts.username && !opts.threadId) {
|
|
236
|
+
throw createError('MISSING_PARAM', 'send-dm requires --username or --thread-id');
|
|
237
|
+
}
|
|
238
|
+
if (!opts.text) {
|
|
239
|
+
throw createError('MISSING_PARAM', 'send-dm requires --text');
|
|
240
|
+
}
|
|
241
|
+
return withProvider(() => provider.sendDm({ username: opts.username, threadId: opts.threadId, text: opts.text }))();
|
|
242
|
+
|
|
243
|
+
case 'list-messages':
|
|
244
|
+
if (!opts.threadId) {
|
|
245
|
+
throw createError('MISSING_PARAM', 'list-messages requires --thread-id');
|
|
246
|
+
}
|
|
247
|
+
return withProvider(() => provider.listMessages({ threadId: opts.threadId }))();
|
|
248
|
+
|
|
249
|
+
case 'list-comments':
|
|
250
|
+
if (!opts.postId) {
|
|
251
|
+
throw createError('MISSING_PARAM', 'list-comments requires --post-id');
|
|
252
|
+
}
|
|
253
|
+
return withProvider(() => provider.listComments({ postId: opts.postId }))();
|
|
254
|
+
|
|
234
255
|
case 'analyze-post':
|
|
235
256
|
if (!opts.postId) {
|
|
236
257
|
throw createError('MISSING_PARAM', 'analyze-post requires --post-id');
|