notifications.js
1 import { CommandExecutionError } from '@jackwener/opencli/errors'; 2 import { cli, Strategy } from '@jackwener/opencli/registry'; 3 cli({ 4 site: 'twitter', 5 name: 'notifications', 6 description: 'Get Twitter/X notifications', 7 domain: 'x.com', 8 strategy: Strategy.INTERCEPT, 9 browser: true, 10 args: [ 11 { name: 'limit', type: 'int', default: 20 }, 12 ], 13 columns: ['id', 'action', 'author', 'text', 'url'], 14 func: async (page, kwargs) => { 15 // 1. Navigate to home first (we need a loaded Twitter page for SPA navigation) 16 await page.goto('https://x.com/home'); 17 await page.wait(3); 18 // 2. Install interceptor BEFORE SPA navigation 19 await page.installInterceptor('NotificationsTimeline'); 20 // 3. SPA navigate to notifications via history API 21 await page.evaluate(`() => { 22 window.history.pushState({}, '', '/notifications'); 23 window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); 24 }`); 25 await page.waitForCapture(5); 26 // Verify SPA navigation succeeded 27 const currentUrl = await page.evaluate('() => window.location.pathname'); 28 if (currentUrl !== '/notifications') { 29 throw new CommandExecutionError('SPA navigation to notifications failed. Twitter may have changed its routing.'); 30 } 31 // 4. Scroll to trigger pagination 32 await page.autoScroll({ times: 2, delayMs: 2000 }); 33 // 5. Retrieve data 34 const requests = await page.getInterceptedRequests(); 35 if (!requests || requests.length === 0) 36 return []; 37 let results = []; 38 const seen = new Set(); 39 for (const req of requests) { 40 try { 41 // GraphQL response: { data: { viewer: ... } } (one level of .data) 42 let instructions = []; 43 if (req.data?.viewer?.timeline_response?.timeline?.instructions) { 44 instructions = req.data.viewer.timeline_response.timeline.instructions; 45 } 46 else if (req.data?.viewer_v2?.user_results?.result?.notification_timeline?.timeline?.instructions) { 47 instructions = req.data.viewer_v2.user_results.result.notification_timeline.timeline.instructions; 48 } 49 else if (req.data?.timeline?.instructions) { 50 instructions = req.data.timeline.instructions; 51 } 52 let addEntries = instructions.find((i) => i.type === 'TimelineAddEntries'); 53 if (!addEntries) { 54 addEntries = instructions.find((i) => i.entries && Array.isArray(i.entries)); 55 } 56 if (!addEntries) 57 continue; 58 for (const entry of addEntries.entries) { 59 if (!entry.entryId.startsWith('notification-')) { 60 if (entry.content?.items) { 61 for (const subItem of entry.content.items) { 62 processNotificationItem(subItem.item?.itemContent, subItem.entryId); 63 } 64 } 65 continue; 66 } 67 processNotificationItem(entry.content?.itemContent, entry.entryId); 68 } 69 function processNotificationItem(itemContent, entryId) { 70 if (!itemContent) 71 return; 72 let item = itemContent?.notification_results?.result || itemContent?.tweet_results?.result || itemContent; 73 let actionText = 'Notification'; 74 let author = 'unknown'; 75 let text = ''; 76 let urlStr = ''; 77 if (item.__typename === 'TimelineNotification') { 78 text = item.rich_message?.text || item.message?.text || ''; 79 const fromUser = item.template?.from_users?.[0]?.user_results?.result; 80 // Twitter moved screen_name from legacy to core 81 author = fromUser?.core?.screen_name || fromUser?.legacy?.screen_name || 'unknown'; 82 urlStr = item.notification_url?.url || ''; 83 actionText = item.notification_icon || 'Activity'; 84 const targetTweet = item.template?.target_objects?.[0]?.tweet_results?.result; 85 if (targetTweet) { 86 const targetText = targetTweet.note_tweet?.note_tweet_results?.result?.text || targetTweet.legacy?.full_text || ''; 87 text += text && targetText ? ' | ' + targetText : targetText; 88 if (!urlStr) { 89 urlStr = `https://x.com/i/status/${targetTweet.rest_id}`; 90 } 91 } 92 } 93 else if (item.__typename === 'TweetNotification') { 94 const tweet = item.tweet_result?.result; 95 const tweetUser = tweet?.core?.user_results?.result; 96 author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown'; 97 text = tweet?.note_tweet?.note_tweet_results?.result?.text || tweet?.legacy?.full_text || item.message?.text || ''; 98 actionText = 'Mention/Reply'; 99 urlStr = `https://x.com/i/status/${tweet?.rest_id}`; 100 } 101 else if (item.__typename === 'Tweet') { 102 const tweetUser = item.core?.user_results?.result; 103 author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown'; 104 text = item.note_tweet?.note_tweet_results?.result?.text || item.legacy?.full_text || ''; 105 actionText = 'Mention'; 106 urlStr = `https://x.com/i/status/${item.rest_id}`; 107 } 108 const id = item.id || item.rest_id || entryId; 109 if (seen.has(id)) 110 return; 111 seen.add(id); 112 results.push({ 113 id, 114 action: actionText, 115 author: author, 116 text: text, 117 url: urlStr || `https://x.com/notifications` 118 }); 119 } 120 } 121 catch (e) { 122 // ignore parsing errors for individual payloads 123 } 124 } 125 return results.slice(0, kwargs.limit); 126 } 127 });