Last active
September 4, 2024 18:42
-
-
Save jonathansampson/2814580886e5a5e2d0aaecd32794d53c to your computer and use it in GitHub Desktop.
This snippet (written as a browser snippet) intercepts outgoing XHR requests on x.com to capture CSRF and Bearer tokens, enabling authenticated GraphQL requests. It provides two functions, `sampson.getPost(postID)` and `sampson.getPostThread(postID)`, to fetch detailed post data or conversation threads that aren't yet loaded into Redux memory.
This file contains 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
const { setRequestHeader: xhrSetH } = XMLHttpRequest.prototype; | |
const setupInterceptor = async () => { | |
console.log('Setting up interceptor'); | |
XMLHttpRequest.prototype.setRequestHeader = function (name, value) { | |
if (['x-csrf-token', 'authorization'].includes(name.toLowerCase())) { | |
updateTokens(name.toLowerCase(), value); | |
} | |
return xhrSetH.apply(this, arguments); | |
}; | |
} | |
const teardownInterceptor = () => { | |
console.log('Tearing down interceptor'); | |
Object.assign(XMLHttpRequest.prototype, { setRequestHeader: xhrSetH }); | |
} | |
const waitForTokens = async () => { | |
console.log(`Waiting for tokens...`); | |
const advice = setTimeout(() => { | |
console.log('Try scrolling the page to trigger a request'); | |
}, 3000); | |
while (!bearerToken || !csrfToken) { | |
await new Promise(resolve => setTimeout(resolve, 1000)); | |
} | |
clearTimeout(advice); | |
} | |
const updateTokens = (header, value) => { | |
if (header === 'x-csrf-token') { | |
csrfToken = value; | |
console.log('Updated CSRF token:', csrfToken); | |
} | |
if (header === 'authorization') { | |
bearerToken = value.split('Bearer ')[1]; | |
console.log('Updated Bearer token:', bearerToken); | |
} | |
} | |
const requestPostData = async (postID, { queryId, operationName, metadata: meta }) => { | |
const url = `https://x.com/i/api/graphql/${queryId + '/' + operationName}`; | |
const variables = { | |
includePromotedContent: true, | |
withCommunity: true, | |
withBirdwatchNotes: true, | |
withVoice: true | |
}; | |
switch (operationName) { | |
case 'TweetDetail': | |
variables.focalTweetId = postID; | |
break; | |
case 'TweetResultByRestId': | |
variables.tweetId = postID; | |
break; | |
default: | |
throw new Error('Invalid query type'); | |
} | |
const features = Object.fromEntries(meta.featureSwitches.map(f => [f, true])); | |
const fieldToggles = Object.fromEntries(meta.fieldToggles.map(f => [f, true])); | |
const params = new URLSearchParams({ | |
features: JSON.stringify(features), | |
variables: JSON.stringify(variables), | |
fieldToggles: JSON.stringify(fieldToggles) | |
}); | |
const response = await fetch(`${url}?${params.toString()}`, { | |
method: 'GET', | |
credentials: 'include', | |
headers: { | |
'x-csrf-token': csrfToken, | |
'Content-Type': 'application/json', | |
'authorization': `Bearer ${bearerToken}` | |
}, | |
}); | |
return response.json(); | |
} | |
let csrfToken = localStorage.getItem('latestCsrfToken'); | |
let bearerToken = localStorage.getItem('latestBearerToken'); | |
/** | |
* Extract these query types from the main.*.js file | |
* | |
* TODO: We may be able to use the xhr interceptor to | |
* extract these query types from requests made by the | |
* platform itself. | |
*/ | |
const queryTypes = { | |
TweetDetail: { | |
queryId: "QuBlQ6SxNAQCt6-kBiCXCQ", | |
operationName: "TweetDetail", | |
operationType: "query", | |
metadata: { | |
featureSwitches: ["rweb_tipjar_consumption_enabled", "responsive_web_graphql_exclude_directive_enabled", "verified_phone_label_enabled", "creator_subscriptions_tweet_preview_api_enabled", "responsive_web_graphql_timeline_navigation_enabled", "responsive_web_graphql_skip_user_profile_image_extensions_enabled", "communities_web_enable_tweet_community_results_fetch", "c9s_tweet_anatomy_moderator_badge_enabled", "articles_preview_enabled", "responsive_web_edit_tweet_api_enabled", "graphql_is_translatable_rweb_tweet_is_translatable_enabled", "view_counts_everywhere_api_enabled", "longform_notetweets_consumption_enabled", "responsive_web_twitter_article_tweet_consumption_enabled", "tweet_awards_web_tipping_enabled", "creator_subscriptions_quote_tweet_preview_enabled", "freedom_of_speech_not_reach_fetch_enabled", "standardized_nudges_misinfo", "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled", "rweb_video_timestamps_enabled", "longform_notetweets_rich_text_read_enabled", "longform_notetweets_inline_media_enabled", "responsive_web_enhance_cards_enabled"], | |
fieldToggles: ["withAuxiliaryUserLabels", "withArticleRichContentState", "withArticlePlainText", "withGrokAnalyze", "withDisallowedReplyControls"], | |
} | |
}, | |
TweetResultByRestId: { | |
queryId: "sCU6ckfHY0CyJ4HFjPhjtg", | |
operationName: "TweetResultByRestId", | |
operationType: "query", | |
metadata: { | |
featureSwitches: ["creator_subscriptions_tweet_preview_api_enabled", "communities_web_enable_tweet_community_results_fetch", "c9s_tweet_anatomy_moderator_badge_enabled", "articles_preview_enabled", "responsive_web_edit_tweet_api_enabled", "graphql_is_translatable_rweb_tweet_is_translatable_enabled", "view_counts_everywhere_api_enabled", "longform_notetweets_consumption_enabled", "responsive_web_twitter_article_tweet_consumption_enabled", "tweet_awards_web_tipping_enabled", "creator_subscriptions_quote_tweet_preview_enabled", "freedom_of_speech_not_reach_fetch_enabled", "standardized_nudges_misinfo", "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled", "rweb_video_timestamps_enabled", "longform_notetweets_rich_text_read_enabled", "longform_notetweets_inline_media_enabled", "rweb_tipjar_consumption_enabled", "responsive_web_graphql_exclude_directive_enabled", "verified_phone_label_enabled", "responsive_web_graphql_skip_user_profile_image_extensions_enabled", "responsive_web_graphql_timeline_navigation_enabled", "responsive_web_enhance_cards_enabled"], | |
fieldToggles: ["withArticleRichContentState", "withArticlePlainText", "withGrokAnalyze", "withDisallowedReplyControls", "withAuxiliaryUserLabels"], | |
} | |
} | |
}; | |
/** | |
* We can expose these functions to other snippets | |
* by attaching them to the `window` object. | |
*/ | |
window.sampson = { | |
getPost: async (postID) => { | |
const response = await waitForTokens().then(() => | |
requestPostData(postID, queryTypes.TweetResultByRestId) | |
); | |
return response.data.tweetResult; | |
}, | |
getPostThread: async (postID) => { | |
const response = await waitForTokens().then(() => | |
requestPostData(postID, queryTypes.TweetDetail) | |
); | |
return response.data.threaded_conversation_with_injections_v2; | |
} | |
}; | |
setupInterceptor() | |
.then(() => sampson.getPost('1831304208886145372')) | |
.then(console.log) | |
.finally(teardownInterceptor); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment