Created
November 18, 2022 06:31
-
-
Save chrisuehlinger/eae26de6aa3d8eb4cbf1d0da339390be to your computer and use it in GitHub Desktop.
Twitter Likes Exporter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import fs from 'fs'; | |
import fetch from "node-fetch"; | |
// Things you'll need to get from the Network tab of Chrome DevTools: | |
// The base URL (it contains an ID thing that might be unique to you) | |
let baseUrl = 'https://twitter.com/i/api/graphql/lr2pk7rKqCqLSqWRGRaW5Q/Likes' | |
// Your userId (in the URL, it comes early in the "variables" querystring) | |
let userId = ""; | |
// Your token from the Authorization header (don't include the word "Bearer", don't share this with anyone or they can impersonate you) | |
let token = '' | |
// Just copy the whole Cookie header | |
let cookie = '' | |
// You can find this in the x-csrf-token header | |
let csrf = ''; | |
let variables = { "userId": userId, "count": 100, "includePromotedContent": false, "withSuperFollowsUserFields": true, "withDownvotePerspective": false, "withReactionsMetadata": false, "withReactionsPerspective": false, "withSuperFollowsTweetFields": true, "withClientEventToken": false, "withBirdwatchNotes": false, "withVoice": true, "withV2Timeline": true }; | |
let features = '%7B%22responsive_web_twitter_blue_verified_badge_is_enabled%22%3Atrue%2C%22verified_phone_label_enabled%22%3Afalse%2C%22responsive_web_graphql_timeline_navigation_enabled%22%3Atrue%2C%22unified_cards_ad_metadata_container_dynamic_card_content_query_enabled%22%3Atrue%2C%22tweetypie_unmention_optimization_enabled%22%3Atrue%2C%22responsive_web_uc_gql_enabled%22%3Atrue%2C%22vibe_api_enabled%22%3Atrue%2C%22responsive_web_edit_tweet_api_enabled%22%3Atrue%2C%22graphql_is_translatable_rweb_tweet_is_translatable_enabled%22%3Atrue%2C%22standardized_nudges_misinfo%22%3Atrue%2C%22tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled%22%3Afalse%2C%22interactive_text_enabled%22%3Atrue%2C%22responsive_web_text_conversations_enabled%22%3Afalse%2C%22responsive_web_enhance_cards_enabled%22%3Atrue%7D' | |
let tweets = []; | |
while (true) { | |
let url = `${baseUrl}?variables=${encodeURIComponent(JSON.stringify(variables))}&features=${features}` | |
let response = await fetch(url, { | |
headers: { | |
Authorization: `Bearer ${token}`, | |
Cookie: cookie, | |
'X-CSRF-Token': csrf, | |
} | |
}); | |
let result = await response.json(); | |
let entries = result.data.user.result.timeline_v2.timeline.instructions[0].entries; | |
tweets = tweets.concat(entries.filter(entry => entry.entryId.startsWith('tweet'))); | |
let oldcursor = variables.cursor; | |
variables.cursor = entries.find(entry => entry.entryId.startsWith('cursor-bottom')).content.value; | |
console.log(tweets.length, variables.cursor); | |
if (oldcursor === variables.cursor) { | |
break; | |
} | |
await new Promise(resolve => setTimeout(resolve, 5000 + Math.random() * 1000)); | |
} | |
await fs.promises.writeFile('rawlikes.json', JSON.stringify(tweets, null, 4)); | |
let profilePics = new Map(); | |
let animatedGifs = new Map(); | |
let images = new Map(); | |
let videos = new Map(); | |
tweets.map((tweet, i) => { | |
try { | |
console.log(i) | |
let innerTweet = tweet.content.itemContent.tweet_results.result.core ? tweet.content.itemContent.tweet_results.result : tweet.content.itemContent.tweet_results.result.tweet; | |
profilePics.set(innerTweet.core.user_results.result.id, innerTweet.core.user_results.result.legacy.profile_image_url_https); | |
let extended_entities = innerTweet.legacy.extended_entities | |
if (extended_entities) { | |
extended_entities.media.map(entity => { | |
if (entity.type === 'video') { | |
let bestVideo = entity.video_info.variants.reduce((acc, variant) => variant.bitrate && variant.bitrate > acc.bitrate ? variant : acc, { bitrate: 0 }); | |
videos.set(entity.id_str, bestVideo.url); | |
} else if (entity.type === 'photo') { | |
images.set(entity.id_str, entity.media_url_https); | |
} else if (entity.type === 'animated_gif') { | |
let bestVideo = entity.video_info.variants.reduce((acc, variant) => variant.bitrate !== undefined && variant.bitrate > acc.bitrate ? variant : acc, { bitrate: -1 }); | |
animatedGifs.set(entity.id_str, bestVideo.url); | |
} else { | |
throw 'Unknown media type: ' + entity.type; | |
} | |
}) | |
} | |
} catch (err) { | |
console.error(err); | |
console.log(JSON.stringify(tweet, null, 2)); | |
throw err; | |
} | |
}); | |
profilePics = Array.from(profilePics.entries()); | |
animatedGifs = Array.from(animatedGifs.entries()); | |
images = Array.from(images.entries()); | |
videos = Array.from(videos.entries()); | |
console.log(`Ripping ${profilePics.length} profile pics, ${animatedGifs.length} GIFs, ${images.length} images, ${videos.length} videos.`); | |
for (let i = 0; i < images.length; i++) { | |
const image = images[i]; | |
let id = image[0]; | |
let url = image[1]; | |
console.log(`Downloading image ${i + 1} of ${images.length}: ${id} ${url}`); | |
try { | |
let response = await fetch(url); | |
let blob = await response.blob(); | |
await fs.promises.writeFile(`./media/images/${id}.${url.split('.').pop()}`, Buffer.from(await blob.arrayBuffer(), 'binary')); | |
} catch (err) { | |
console.error(`Error, retrying in a moment...`, err); | |
// Try again in case it was a fluke (there are often timeouts, but after enough retries it usually works) | |
i--; | |
} | |
await new Promise(resolve => setTimeout(resolve, 4 * 1000 + Math.random() * 1000)); | |
} | |
for (let i = 0; i < animatedGifs.length; i++) { | |
const animatedGif = animatedGifs[i]; | |
let id = animatedGif[0]; | |
let url = animatedGif[1]; | |
console.log(`Downloading animated GIF ${i + 1} of ${animatedGifs.length}: ${id} ${url}`); | |
try { | |
let response = await fetch(url); | |
let blob = await response.blob(); | |
await fs.promises.writeFile(`./media/animated-gifs/${id}.${url.split('.').pop()}`, Buffer.from(await blob.arrayBuffer(), 'binary')); | |
} catch (err) { | |
console.error(`Error, retrying in a moment...`, err); | |
// Try again in case it was a fluke (there are often timeouts, but after enough retries it usually works) | |
i--; | |
} | |
await new Promise(resolve => setTimeout(resolve, 4 * 1000 + Math.random() * 1000)); | |
} | |
for (let i = 0; i < videos.length; i++) { | |
const video = videos[i]; | |
let id = video[0]; | |
let url = video[1]; | |
console.log(`Downloading video ${i + 1} of ${videos.length}: ${id} ${url}`); | |
try { | |
let response = await fetch(url); | |
let blob = await response.blob(); | |
await fs.promises.writeFile(`./media/videos/${id}.mp4`, Buffer.from(await blob.arrayBuffer(), 'binary')); | |
} catch (err) { | |
console.error(`Error, retrying in a moment...`, err); | |
// Try again in case it was a fluke (there are often timeouts, but after enough retries it usually works) | |
i--; | |
} | |
await new Promise(resolve => setTimeout(resolve, 4 * 1000 + Math.random() * 1000)); | |
} | |
for (let i = 0; i < profilePics.length; i++) { | |
const profilePic = profilePics[i]; | |
let id = profilePic[0]; | |
let url = profilePic[1]; | |
console.log(`Downloading profile pic ${i + 1} of ${profilePics.length}: ${id} ${url}`); | |
try { | |
let response = await fetch(url.replace('_normal', '_400x400')); | |
let blob = await response.blob(); | |
await fs.promises.writeFile(`./media/profile-pics/${id}.${url.split('.').pop()}`, Buffer.from(await blob.arrayBuffer(), 'binary')); | |
} catch (err) { | |
console.error(`Error, retrying in a moment...`, err); | |
// Try again in case it was a fluke (there are often timeouts, but after enough retries it usually works) | |
i--; | |
} | |
await new Promise(resolve => setTimeout(resolve, 4 * 1000 + Math.random() * 1000)); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"name": "twitter-evac", | |
"version": "1.0.0", | |
"description": "", | |
"main": "index.js", | |
"type": "module", | |
"scripts": { | |
"test": "echo \"Error: no test specified\" && exit 1" | |
}, | |
"author": "Chris Uehlinger <[email protected]>", | |
"license": "ISC", | |
"dependencies": { | |
"node-fetch": "^3.3.0", | |
"undici": "^5.12.0" | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment