likes.js
1 import { cli, Strategy } from '@jackwener/opencli/registry'; 2 import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; 3 import { resolveTwitterQueryId, sanitizeQueryId, extractMedia } from './shared.js'; 4 const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'; 5 const LIKES_QUERY_ID = 'RozQdCp4CilQzrcuU0NY5w'; 6 const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ'; 7 const FEATURES = { 8 rweb_video_screen_enabled: false, 9 profile_label_improvements_pcf_label_in_post_enabled: true, 10 responsive_web_profile_redirect_enabled: false, 11 rweb_tipjar_consumption_enabled: false, 12 verified_phone_label_enabled: false, 13 creator_subscriptions_tweet_preview_api_enabled: true, 14 responsive_web_graphql_timeline_navigation_enabled: true, 15 responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, 16 premium_content_api_read_enabled: false, 17 communities_web_enable_tweet_community_results_fetch: true, 18 c9s_tweet_anatomy_moderator_badge_enabled: true, 19 responsive_web_grok_analyze_button_fetch_trends_enabled: false, 20 responsive_web_grok_analyze_post_followups_enabled: true, 21 responsive_web_jetfuel_frame: true, 22 responsive_web_grok_share_attachment_enabled: true, 23 responsive_web_grok_annotations_enabled: true, 24 articles_preview_enabled: true, 25 responsive_web_edit_tweet_api_enabled: true, 26 graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, 27 view_counts_everywhere_api_enabled: true, 28 longform_notetweets_consumption_enabled: true, 29 responsive_web_twitter_article_tweet_consumption_enabled: true, 30 tweet_awards_web_tipping_enabled: false, 31 content_disclosure_indicator_enabled: true, 32 content_disclosure_ai_generated_indicator_enabled: true, 33 responsive_web_grok_show_grok_translated_post: false, 34 responsive_web_grok_analysis_button_from_backend: true, 35 post_ctas_fetch_enabled: false, 36 freedom_of_speech_not_reach_fetch_enabled: true, 37 standardized_nudges_misinfo: true, 38 tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, 39 longform_notetweets_rich_text_read_enabled: true, 40 longform_notetweets_inline_media_enabled: false, 41 responsive_web_grok_image_annotation_enabled: true, 42 responsive_web_grok_imagine_annotation_enabled: true, 43 responsive_web_grok_community_note_auto_translation_is_enabled: false, 44 responsive_web_enhance_cards_enabled: false 45 }; 46 function buildLikesUrl(queryId, userId, count, cursor) { 47 const vars = { 48 userId, 49 count, 50 includePromotedContent: false, 51 withClientEventToken: false, 52 withBirdwatchNotes: false, 53 withVoice: true 54 }; 55 if (cursor) 56 vars.cursor = cursor; 57 return `/i/api/graphql/${queryId}/Likes` 58 + `?variables=${encodeURIComponent(JSON.stringify(vars))}` 59 + `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`; 60 } 61 function buildUserByScreenNameUrl(queryId, screenName) { 62 const vars = JSON.stringify({ screen_name: screenName, withSafetyModeUserFields: true }); 63 const feats = JSON.stringify({ 64 hidden_profile_subscriptions_enabled: true, 65 rweb_tipjar_consumption_enabled: true, 66 responsive_web_graphql_exclude_directive_enabled: true, 67 verified_phone_label_enabled: false, 68 subscriptions_verification_info_is_identity_verified_enabled: true, 69 subscriptions_verification_info_verified_since_enabled: true, 70 highlights_tweets_tab_ui_enabled: true, 71 responsive_web_twitter_article_notes_tab_enabled: true, 72 subscriptions_feature_can_gift_premium: true, 73 creator_subscriptions_tweet_preview_api_enabled: true, 74 responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, 75 responsive_web_graphql_timeline_navigation_enabled: true, 76 }); 77 return `/i/api/graphql/${queryId}/UserByScreenName` 78 + `?variables=${encodeURIComponent(vars)}` 79 + `&features=${encodeURIComponent(feats)}`; 80 } 81 function extractLikedTweet(result, seen) { 82 if (!result) 83 return null; 84 const tw = result.tweet || result; 85 const legacy = tw.legacy || {}; 86 if (!tw.rest_id || seen.has(tw.rest_id)) 87 return null; 88 seen.add(tw.rest_id); 89 const user = tw.core?.user_results?.result; 90 const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown'; 91 const displayName = user?.legacy?.name || user?.core?.name || ''; 92 const noteText = tw.note_tweet?.note_tweet_results?.result?.text; 93 return { 94 id: tw.rest_id, 95 author: screenName, 96 name: displayName, 97 text: noteText || legacy.full_text || '', 98 likes: legacy.favorite_count || 0, 99 retweets: legacy.retweet_count || 0, 100 created_at: legacy.created_at || '', 101 url: `https://x.com/${screenName}/status/${tw.rest_id}`, 102 ...extractMedia(legacy), 103 }; 104 } 105 function parseLikes(data, seen) { 106 const tweets = []; 107 let nextCursor = null; 108 const instructions = data?.data?.user?.result?.timeline_v2?.timeline?.instructions 109 || data?.data?.user?.result?.timeline?.timeline?.instructions 110 || []; 111 for (const inst of instructions) { 112 for (const entry of inst.entries || []) { 113 const content = entry.content; 114 if (content?.entryType === 'TimelineTimelineCursor' || content?.__typename === 'TimelineTimelineCursor') { 115 if (content.cursorType === 'Bottom' || content.cursorType === 'ShowMore') 116 nextCursor = content.value; 117 continue; 118 } 119 if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) { 120 nextCursor = content?.value || content?.itemContent?.value || nextCursor; 121 continue; 122 } 123 const direct = extractLikedTweet(content?.itemContent?.tweet_results?.result, seen); 124 if (direct) { 125 tweets.push(direct); 126 continue; 127 } 128 for (const item of content?.items || []) { 129 const nested = extractLikedTweet(item.item?.itemContent?.tweet_results?.result, seen); 130 if (nested) 131 tweets.push(nested); 132 } 133 } 134 } 135 return { tweets, nextCursor }; 136 } 137 cli({ 138 site: 'twitter', 139 name: 'likes', 140 description: 'Fetch liked tweets of a Twitter user', 141 domain: 'x.com', 142 strategy: Strategy.COOKIE, 143 browser: true, 144 args: [ 145 { name: 'username', type: 'string', positional: true, help: 'Twitter screen name (without @). Defaults to logged-in user.' }, 146 { name: 'limit', type: 'int', default: 20 }, 147 ], 148 columns: ['author', 'name', 'text', 'likes', 'url', 'has_media', 'media_urls'], 149 func: async (page, kwargs) => { 150 const limit = kwargs.limit || 20; 151 let username = (kwargs.username || '').replace(/^@/, ''); 152 await page.goto('https://x.com'); 153 await page.wait(3); 154 const ct0 = await page.evaluate(`() => { 155 return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null; 156 }`); 157 if (!ct0) 158 throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)'); 159 // If no username provided, detect the logged-in user 160 if (!username) { 161 const href = await page.evaluate(`() => { 162 const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]'); 163 return link ? link.getAttribute('href') : null; 164 }`); 165 if (!href) 166 throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?'); 167 username = href.replace('/', ''); 168 } 169 const likesQueryId = await resolveTwitterQueryId(page, 'Likes', LIKES_QUERY_ID); 170 const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID); 171 const headers = JSON.stringify({ 172 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`, 173 'X-Csrf-Token': ct0, 174 'X-Twitter-Auth-Type': 'OAuth2Session', 175 'X-Twitter-Active-User': 'yes', 176 }); 177 // Get userId from screen_name 178 const userId = await page.evaluate(`async () => { 179 const screenName = ${JSON.stringify(username)}; 180 const url = ${JSON.stringify(buildUserByScreenNameUrl(userByScreenNameQueryId, username))}; 181 const resp = await fetch(url, { headers: ${headers}, credentials: 'include' }); 182 if (!resp.ok) return null; 183 const d = await resp.json(); 184 return d.data?.user?.result?.rest_id || null; 185 }`); 186 if (!userId) { 187 throw new CommandExecutionError(`Could not find user @${username}`); 188 } 189 const allTweets = []; 190 const seen = new Set(); 191 let cursor = null; 192 for (let i = 0; i < 5 && allTweets.length < limit; i++) { 193 const fetchCount = Math.min(100, limit - allTweets.length + 10); 194 const apiUrl = buildLikesUrl(likesQueryId, userId, fetchCount, cursor); 195 const data = await page.evaluate(`async () => { 196 const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' }); 197 return r.ok ? await r.json() : { error: r.status }; 198 }`); 199 if (data?.error) { 200 if (allTweets.length === 0) 201 throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch likes. queryId may have expired.`); 202 break; 203 } 204 const { tweets, nextCursor } = parseLikes(data, seen); 205 allTweets.push(...tweets); 206 if (!nextCursor || nextCursor === cursor) 207 break; 208 cursor = nextCursor; 209 } 210 return allTweets.slice(0, limit); 211 }, 212 }); 213 export const __test__ = { 214 sanitizeQueryId, 215 buildLikesUrl, 216 buildUserByScreenNameUrl, 217 parseLikes, 218 };