Skip to content

Commit 2b26250

Browse files
committed
api/twitter: use newer graphql endpoint, refactor
1 parent 29deb4d commit 2b26250

File tree

1 file changed

+71
-75
lines changed

1 file changed

+71
-75
lines changed

api/src/processing/services/twitter.js

Lines changed: 71 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,55 @@ 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 || cardOuter?.binding_values?.unified_card)?.string_value
139+
);
140+
141+
if (!["video_website", "image_website"].includes(card?.type) ||
142+
!card?.media_entities ||
143+
card?.component_objects?.media_1?.type !== "media") {
144+
return;
145+
}
146+
147+
const mediaId = card.component_objects?.media_1?.data?.id;
148+
return [card.media_entities[mediaId]];
149+
}
150+
151+
const extractGraphqlMedia = async (thread, dispatcher, id, guestToken, cookie) => {
152+
const addInsn = thread?.data?.threaded_conversation_with_injections_v2?.instructions?.find(
153+
insn => insn.type === 'TimelineAddEntries'
154+
);
155+
156+
const tweetResult = addInsn?.entries?.find(
157+
entry => entry.entryId === `tweet-${id}`
158+
)?.content?.itemContent?.tweet_results?.result;
159+
160+
let tweetTypename = tweetResult?.__typename;
134161

135162
if (!tweetTypename) {
136163
return { error: "fetch.empty" }
137164
}
138165

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" };
166+
if (tweetTypename === "TweetUnavailable" || tweetTypename === "TweetTombstone") {
167+
const reason = tweetResult?.result?.reason;
168+
if (reason === 'Protected') {
169+
return { error: "content.post.private" };
170+
} else if (reason === "NsfwLoggedOut" || tweetResult?.tombstone?.text?.text?.startsWith('Age-restricted')) {
171+
if (!cookie) {
172+
return { error: "content.post.age" };
173+
}
174+
175+
const tweet = await requestTweet(dispatcher, id, guestToken, cookie).then(t => t.json());
176+
return extractGraphqlMedia(tweet, dispatcher, id, guestToken);
150177
}
151178
}
152179

153180
if (!["Tweet", "TweetWithVisibilityResults"].includes(tweetTypename)) {
154181
return { error: "content.post.unavailable" }
155182
}
156183

157-
let tweetResult = tweet.data.tweetResult.result,
158-
baseTweet = tweetResult.legacy,
184+
let baseTweet = tweetResult.legacy,
159185
repostedTweet = baseTweet?.retweeted_status_result?.result.legacy.extended_entities;
160186

161187
if (tweetTypename === "TweetWithVisibilityResults") {
@@ -164,81 +190,51 @@ const extractGraphqlMedia = async (tweet, dispatcher, id, guestToken, cookie) =>
164190
}
165191

166192
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]];
193+
return parseCard(tweetResult.card);
177194
}
178195

179196
return (repostedTweet?.media || baseTweet?.extended_entities?.media);
180197
}
181198

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-
196199
export default async function({ id, index, toGif, dispatcher, alwaysProxy, subtitleLang }) {
197200
const cookie = await getCookie('twitter');
198201

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

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

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);
207+
if ([403, 404, 429].includes(tweet.status)) {
208+
// get new token & retry if old one expired
209+
if ([403, 429].includes(tweet.status)) {
210+
guestToken = await getGuestToken(dispatcher, true);
216211
}
212+
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
217213
}
218214

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

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

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

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

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" };
235+
if (!media || 'error' in media) {
236+
return { error: media?.error || "fetch.empty" };
237+
}
242238

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

0 commit comments

Comments
 (0)