Skip to content

Instantly share code, notes, and snippets.

@jonathansampson
Last active September 4, 2024 18:42
Show Gist options
  • Save jonathansampson/2814580886e5a5e2d0aaecd32794d53c to your computer and use it in GitHub Desktop.
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.
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