Skip to content

Commit f76b929

Browse files
committed
api/twitter: use newer graphql endpoint, refactor
1 parent 990ce9a commit f76b929

File tree

1 file changed

+72
-75
lines changed

1 file changed

+72
-75
lines changed

api/src/processing/services/twitter.js

Lines changed: 72 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import { genericUserAgent } from "../../config.js";
33
import { createStream } from "../../stream/manage.js";
44
import { getCookie, updateCookie } from "../cookie/manager.js";
55

6-
const graphqlURL = 'https://api.x.com/graphql/I9GDzyCGZL2wSoYFFrrTVw/TweetResultByRestId';
6+
const graphqlURL = 'https://api.x.com/graphql/4Siu98E55GquhG52zHdY5w/TweetDetail';
77
const tokenURL = 'https://api.x.com/1.1/guest/activate.json';
88

9-
const tweetFeatures = JSON.stringify({"creator_subscriptions_tweet_preview_api_enabled":true,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"articles_preview_enabled":true,"tweetypie_unmention_optimization_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"tweet_awards_web_tipping_enabled":false,"creator_subscriptions_quote_tweet_preview_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"rweb_video_timestamps_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"rweb_tipjar_consumption_enabled":true,"responsive_web_graphql_exclude_directive_enabled":true,"verified_phone_label_enabled":false,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_enhance_cards_enabled":false});
9+
const tweetFeatures = JSON.stringify({"rweb_video_screen_enabled":false,"payments_enabled":false,"rweb_xchat_enabled":false,"profile_label_improvements_pcf_label_in_post_enabled":true,"rweb_tipjar_consumption_enabled":true,"verified_phone_label_enabled":false,"creator_subscriptions_tweet_preview_api_enabled":true,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"premium_content_api_read_enabled":false,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"responsive_web_grok_analyze_button_fetch_trends_enabled":false,"responsive_web_grok_analyze_post_followups_enabled":true,"responsive_web_jetfuel_frame":true,"responsive_web_grok_share_attachment_enabled":true,"articles_preview_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"tweet_awards_web_tipping_enabled":false,"responsive_web_grok_show_grok_translated_post":false,"responsive_web_grok_analysis_button_from_backend":true,"creator_subscriptions_quote_tweet_preview_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"responsive_web_grok_image_annotation_enabled":true,"responsive_web_grok_imagine_annotation_enabled":true,"responsive_web_grok_community_note_auto_translation_is_enabled":false,"responsive_web_enhance_cards_enabled":false});
1010

11-
const tweetFieldToggles = JSON.stringify({"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false});
11+
const tweetFieldToggles = JSON.stringify({"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false});
1212

1313
const commonHeaders = {
1414
"user-agent": genericUserAgent,
@@ -100,10 +100,14 @@ const requestTweet = async(dispatcher, tweetId, token, cookie) => {
100100

101101
graphqlTweetURL.searchParams.set('variables',
102102
JSON.stringify({
103-
tweetId,
104-
withCommunity: false,
105-
includePromotedContent: false,
106-
withVoice: false
103+
focalTweetId: tweetId,
104+
with_rux_injections: false,
105+
rankingMode: "Relevance",
106+
includePromotedContent: true,
107+
withCommunity: true,
108+
withQuickPromoteEligibilityTweetFields: true,
109+
withBirdwatchNotes: true,
110+
withVoice: true
107111
})
108112
);
109113
graphqlTweetURL.searchParams.set('features', tweetFeatures);
@@ -129,33 +133,56 @@ const requestTweet = async(dispatcher, tweetId, token, cookie) => {
129133
return result
130134
}
131135

132-
const extractGraphqlMedia = async (tweet, dispatcher, id, guestToken, cookie) => {
133-
let tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
136+
const parseCard = (cardOuter) => {
137+
const card = JSON.parse(
138+
(cardOuter?.legacy?.binding_values[0].value
139+
|| cardOuter?.binding_values?.unified_card)?.string_value,
140+
);
141+
142+
if (!["video_website", "image_website"].includes(card?.type)
143+
|| !card?.media_entities
144+
|| card?.component_objects?.media_1?.type !== "media") {
145+
return;
146+
}
147+
148+
const mediaId = card.component_objects?.media_1?.data?.id;
149+
return [card.media_entities[mediaId]];
150+
};
151+
152+
const extractGraphqlMedia = async (thread, dispatcher, id, guestToken, cookie) => {
153+
const addInsn = thread?.data?.threaded_conversation_with_injections_v2?.instructions?.find(
154+
insn => insn.type === 'TimelineAddEntries'
155+
);
156+
157+
const tweetResult = addInsn?.entries?.find(
158+
entry => entry.entryId === `tweet-${id}`
159+
)?.content?.itemContent?.tweet_results?.result;
160+
161+
let tweetTypename = tweetResult?.__typename;
134162

135163
if (!tweetTypename) {
136164
return { error: "fetch.empty" }
137165
}
138166

139-
if (tweetTypename === "TweetUnavailable") {
140-
const reason = tweet?.data?.tweetResult?.result?.reason;
141-
switch(reason) {
142-
case "Protected":
143-
return { error: "content.post.private" };
144-
case "NsfwLoggedOut":
145-
if (cookie) {
146-
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
147-
tweet = await tweet.json();
148-
tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
149-
} else return { error: "content.post.age" };
167+
if (tweetTypename === "TweetUnavailable" || tweetTypename === "TweetTombstone") {
168+
const reason = tweetResult?.result?.reason;
169+
if (reason === 'Protected') {
170+
return { error: "content.post.private" };
171+
} else if (reason === "NsfwLoggedOut" || tweetResult?.tombstone?.text?.text?.startsWith('Age-restricted')) {
172+
if (!cookie) {
173+
return { error: "content.post.age" };
174+
}
175+
176+
const tweet = await requestTweet(dispatcher, id, guestToken, cookie).then(t => t.json());
177+
return extractGraphqlMedia(tweet, dispatcher, id, guestToken);
150178
}
151179
}
152180

153181
if (!["Tweet", "TweetWithVisibilityResults"].includes(tweetTypename)) {
154182
return { error: "content.post.unavailable" }
155183
}
156184

157-
let tweetResult = tweet.data.tweetResult.result,
158-
baseTweet = tweetResult.legacy,
185+
let baseTweet = tweetResult.legacy,
159186
repostedTweet = baseTweet?.retweeted_status_result?.result.legacy.extended_entities;
160187

161188
if (tweetTypename === "TweetWithVisibilityResults") {
@@ -164,81 +191,51 @@ const extractGraphqlMedia = async (tweet, dispatcher, id, guestToken, cookie) =>
164191
}
165192

166193
if (tweetResult.card?.legacy?.binding_values?.length) {
167-
const card = JSON.parse(tweetResult.card.legacy.binding_values[0].value?.string_value);
168-
169-
if (!["video_website", "image_website"].includes(card?.type) ||
170-
!card?.media_entities ||
171-
card?.component_objects?.media_1?.type !== "media") {
172-
return;
173-
}
174-
175-
const mediaId = card.component_objects?.media_1?.data?.id;
176-
return [card.media_entities[mediaId]];
194+
return parseCard(tweetResult.card);
177195
}
178196

179197
return (repostedTweet?.media || baseTweet?.extended_entities?.media);
180198
}
181199

182-
const testResponse = (result) => {
183-
const contentLength = result.headers.get("content-length");
184-
185-
if (!contentLength || contentLength === '0') {
186-
return false;
187-
}
188-
189-
if (!result.headers.get("content-type").startsWith("application/json")) {
190-
return false;
191-
}
192-
193-
return true;
194-
}
195-
196200
export default async function({ id, index, toGif, dispatcher, alwaysProxy, subtitleLang }) {
197201
const cookie = await getCookie('twitter');
198202

199-
let syndication = false;
200-
201203
let guestToken = await getGuestToken(dispatcher);
202204
if (!guestToken) return { error: "fetch.fail" };
203205

204-
// for now we assume that graphql api will come back after some time,
205-
// so we try it first
206-
207206
let tweet = await requestTweet(dispatcher, id, guestToken);
208207

209-
// get new token & retry if old one expired
210-
if ([403, 429].includes(tweet.status)) {
211-
guestToken = await getGuestToken(dispatcher, true);
212-
if (cookie) {
213-
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
214-
} else {
215-
tweet = await requestTweet(dispatcher, id, guestToken);
208+
if ([403, 404, 429].includes(tweet.status)) {
209+
// get new token & retry if old one expired
210+
if ([403, 429].includes(tweet.status)) {
211+
guestToken = await getGuestToken(dispatcher, true);
216212
}
213+
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
217214
}
218215

219-
const testGraphql = testResponse(tweet);
216+
let media;
217+
try {
218+
tweet = await tweet.json();
219+
media = await extractGraphqlMedia(tweet, dispatcher, id, guestToken, cookie);
220+
} catch {}
220221

221222
// if graphql requests fail, then resort to tweet embed api
222-
if (!testGraphql) {
223-
syndication = true;
224-
tweet = await requestSyndication(dispatcher, id);
223+
if (!media || 'error' in media) {
224+
try {
225+
tweet = await requestSyndication(dispatcher, id);
226+
tweet = await tweet.json();
225227

226-
const testSyndication = testResponse(tweet);
228+
if (tweet?.card) {
229+
media = parseCard(tweet.card);
230+
}
231+
} catch {}
227232

228-
// if even syndication request failed, then cry out loud
229-
if (!testSyndication) {
230-
return { error: "fetch.fail" };
231-
}
233+
media = tweet?.mediaDetails ?? media;
232234
}
233235

234-
tweet = await tweet.json();
235-
236-
let media =
237-
syndication
238-
? tweet.mediaDetails
239-
: await extractGraphqlMedia(tweet, dispatcher, id, guestToken, cookie);
240-
241-
if (!media) return { error: "fetch.empty" };
236+
if (!media || 'error' in media) {
237+
return { error: media?.error || "fetch.empty" };
238+
}
242239

243240
// check if there's a video at given index (/video/<index>)
244241
if (index >= 0 && index < media?.length) {

0 commit comments

Comments
 (0)