♻️ Refactored tweet fetching to use graphql api

This commit is contained in:
Wazbat 2023-07-01 00:34:51 +02:00
parent 8326e7fd8f
commit 703d7744a8
8 changed files with 358 additions and 124 deletions

View file

@ -7,12 +7,14 @@ import { colorFromPalette } from '../helpers/palette';
import { translateTweet } from '../helpers/translate';
import { unescapeText } from '../helpers/utils';
import { processMedia } from '../helpers/media';
import { convertToApiUser } from './user';
import { isGraphQLTweet, isGraphQLTweetNotFoundResponse } from '../utils/graphql';
/* This function does the heavy lifting of processing data from Twitter API
and using it to create FixTweet's streamlined API responses */
const populateTweetProperties = async (
tweet: TweetPartial,
conversation: TimelineBlobPartial,
tweet: GraphQLTweet,
conversation: any, // TimelineBlobPartial,
language: string | undefined
// eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<APITweet> => {
@ -21,54 +23,51 @@ const populateTweetProperties = async (
/* With v2 conversation API we re-add the user object ot the tweet because
Twitter stores it separately in the conversation API. This is to consolidate
it in case a user appears multiple times in a thread. */
tweet.user = conversation?.globalObjects?.users?.[tweet.user_id_str] || {};
const user = tweet.user as UserPartial;
const screenName = user?.screen_name || '';
const name = user?.name || '';
const graphQLUser = tweet.core.user_results.result;
const apiUser = convertToApiUser(graphQLUser);
/* Populating a lot of the basics */
apiTweet.url = `${Constants.TWITTER_ROOT}/${screenName}/status/${tweet.id_str}`;
apiTweet.id = tweet.id_str;
apiTweet.text = unescapeText(linkFixer(tweet, tweet.full_text || ''));
apiTweet.url = `${Constants.TWITTER_ROOT}/${apiUser.screen_name}/status/${tweet.rest_id}`;
apiTweet.id = tweet.rest_id;
apiTweet.text = unescapeText(linkFixer(tweet, tweet.legacy.full_text || ''));
apiTweet.author = {
id: tweet.user_id_str,
name: name,
screen_name: screenName,
id: apiUser.id,
name: apiUser.name,
screen_name: apiUser.screen_name,
avatar_url:
(user?.profile_image_url_https || '').replace('_normal', '_200x200') || '',
avatar_color: colorFromPalette(
(apiUser.avatar_url || '').replace('_normal', '_200x200') || '',
avatar_color: '0000FF' /* colorFromPalette(
tweet.user?.profile_image_extensions_media_color?.palette || []
),
banner_url: user?.profile_banner_url || ''
),*/,
banner_url: apiUser.banner_url || ''
};
apiTweet.replies = tweet.reply_count;
apiTweet.retweets = tweet.retweet_count;
apiTweet.likes = tweet.favorite_count;
apiTweet.replies = tweet.legacy.reply_count;
apiTweet.retweets = tweet.legacy.retweet_count;
apiTweet.likes = tweet.legacy.favorite_count;
apiTweet.color = apiTweet.author.avatar_color;
apiTweet.twitter_card = 'tweet';
apiTweet.created_at = tweet.created_at;
apiTweet.created_timestamp = new Date(tweet.created_at).getTime() / 1000;
apiTweet.created_at = tweet.legacy.created_at;
apiTweet.created_timestamp = new Date(tweet.legacy.created_at).getTime() / 1000;
apiTweet.possibly_sensitive = tweet.possibly_sensitive;
apiTweet.possibly_sensitive = tweet.legacy.possibly_sensitive;
if (tweet.ext_views?.state === 'EnabledWithCount') {
apiTweet.views = parseInt(tweet.ext_views.count || '0') ?? null;
if (tweet.views.state === 'EnabledWithCount') {
apiTweet.views = parseInt(tweet.views.count || '0') ?? null;
} else {
apiTweet.views = null;
}
if (tweet.lang !== 'unk') {
apiTweet.lang = tweet.lang;
if (tweet.legacy.lang !== 'unk') {
apiTweet.lang = tweet.legacy.lang;
} else {
apiTweet.lang = null;
}
apiTweet.replying_to = tweet.in_reply_to_screen_name || null;
apiTweet.replying_to_status = tweet.in_reply_to_status_id_str || null;
apiTweet.replying_to = tweet.legacy?.in_reply_to_screen_name || null;
apiTweet.replying_to_status = tweet.legacy?.in_reply_to_status_id_str || null;
const mediaList = Array.from(
tweet.extended_entities?.media || tweet.entities?.media || []
tweet.legacy.extended_entities?.media || tweet.legacy.entities?.media || []
);
// console.log('tweet', JSON.stringify(tweet));
@ -94,13 +93,15 @@ const populateTweetProperties = async (
});
/* Grab color palette data */
/*
if (mediaList[0]?.ext_media_color?.palette) {
apiTweet.color = colorFromPalette(mediaList[0].ext_media_color.palette);
}
*/
/* Handle photos and mosaic if available */
if ((apiTweet.media?.photos?.length || 0) > 1) {
const mosaic = await handleMosaic(apiTweet.media?.photos || [], tweet.id_str);
const mosaic = await handleMosaic(apiTweet.media?.photos || [], tweet.rest_id);
if (typeof apiTweet.media !== 'undefined' && mosaic !== null) {
apiTweet.media.mosaic = mosaic;
}
@ -115,6 +116,7 @@ const populateTweetProperties = async (
}
/* Populate a Twitter card */
if (tweet.card) {
const card = await renderCard(tweet.card);
if (card.external_media) {
@ -128,7 +130,7 @@ const populateTweetProperties = async (
}
/* If a language is specified in API or by user, let's try translating it! */
if (typeof language === 'string' && language.length === 2 && language !== tweet.lang) {
if (typeof language === 'string' && language.length === 2 && language !== tweet.legacy.lang) {
const translateAPI = await translateTweet(
tweet,
conversation.guestToken || '',
@ -186,59 +188,62 @@ export const statusAPI = async (
event: FetchEvent,
flags?: InputFlags
): Promise<TweetAPIResponse> => {
let conversation = await fetchConversation(status, event);
let tweet = conversation?.globalObjects?.tweets?.[status] || {};
let wasMediaBlockedNSFW = false;
let conversation = await fetchConversation(status, event);
let tweet: GraphQLTweet | TweetTombstone;
if (isGraphQLTweetNotFoundResponse(conversation)) {
writeDataPoint(event, language, wasMediaBlockedNSFW, 'NOT_FOUND', flags);
return { code: 404, message: 'NOT_FOUND' };
}
/* Fallback for if Tweet did not load (i.e. NSFW) */
if (Object.keys(conversation).length === 0) {
// Try again using elongator API proxy
console.log('No Tweet was found, loading again from elongator');
conversation = await fetchConversation(status, event, true);
if (Object.keys(conversation).length === 0) {
writeDataPoint(event, language, wasMediaBlockedNSFW, 'NOT_FOUND', flags);
return { code: 404, message: 'NOT_FOUND' };
}
// If the tweet now loads, it was probably NSFW
wasMediaBlockedNSFW = true;
}
// Find this specific tweet in the conversation
try {
const instructions = conversation?.data?.threaded_conversation_with_injections_v2?.instructions;
if (!Array.isArray(instructions)) {
console.log(JSON.stringify(conversation, null, 2));
throw new Error('Invalid instructions');
}
const timelineAddEntries = instructions.find((e): e is TimeLineAddEntriesInstruction => e?.type === 'TimelineAddEntries');
if (!timelineAddEntries) throw new Error('No valid timeline entries');
const graphQLTimelineTweetEntry = timelineAddEntries.entries
.find((e): e is GraphQLTimelineTweetEntry =>
// TODO Fix this idk what's up with the typings
!!(e && typeof e === 'object' && ('entryId' in e) && e?.entryId === `tweet-${status}`));
if (!graphQLTimelineTweetEntry) throw new Error('No tweet entry with');
tweet = graphQLTimelineTweetEntry?.content?.itemContent?.tweet_results?.result;
if (!tweet) throw new Error('No tweet in timeline entry');
} catch (e) {
// Api failure at parsing status
console.log('Tweet could not be accessed, got conversation ', conversation);
writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags);
return { code: 500, message: 'API_FAIL' };
}
// If the tweet is not a graphQL tweet it's a tombstone, return the error to the user
if (!isGraphQLTweet(tweet)) {
console.log('Tweet was not a valid tweet', tweet);
writeDataPoint(event, language, wasMediaBlockedNSFW, 'PRIVATE_TWEET', flags);
return { code: 401, message: 'PRIVATE_TWEET' };
}
/*
if (tweet.retweeted_status_id_str) {
tweet = conversation?.globalObjects?.tweets?.[tweet.retweeted_status_id_str] || {};
}
*/
/* Fallback for if Tweet did not load (i.e. NSFW) */
if (typeof tweet.full_text === 'undefined') {
if (conversation.timeline?.instructions?.length > 0) {
/* Try again using elongator API proxy */
console.log('No Tweet was found, loading again from elongator');
conversation = await fetchConversation(status, event, true);
tweet = conversation?.globalObjects?.tweets?.[status] || {};
if (typeof tweet.full_text !== 'undefined') {
console.log('Successfully loaded Tweet using elongator');
wasMediaBlockedNSFW = true;
} else if (
typeof tweet.full_text === 'undefined' &&
conversation.timeline?.instructions?.length > 0
) {
console.log(
'Tweet could not be accessed with elongator, must be private/suspended, got tweet ',
tweet,
' conversation ',
conversation
);
writeDataPoint(event, language, wasMediaBlockedNSFW, 'PRIVATE_TWEET', flags);
return { code: 401, message: 'PRIVATE_TWEET' };
}
} else {
/* {"errors":[{"code":34,"message":"Sorry, that page does not exist."}]} */
if (conversation.errors?.[0]?.code === 34) {
writeDataPoint(event, language, wasMediaBlockedNSFW, 'NOT_FOUND', flags);
return { code: 404, message: 'NOT_FOUND' };
}
/* Commented this the part below out for now since it seems like atm this check doesn't actually do anything */
/* Tweets object is completely missing, smells like API failure */
// if (typeof conversation?.globalObjects?.tweets === 'undefined') {
// writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags);
// return { code: 500, message: 'API_FAIL' };
// }
/* If we have no idea what happened then just return API error */
writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags);
return { code: 500, message: 'API_FAIL' };
}
if (!tweet) {
return { code: 404, message: 'NOT_FOUND' };
}
/* Creating the response objects */
@ -250,8 +255,7 @@ export const statusAPI = async (
)) as APITweet;
/* We found a quote tweet, let's process that too */
const quoteTweet =
conversation.globalObjects?.tweets?.[tweet.quoted_status_id_str || '0'] || null;
const quoteTweet = tweet.quoted_status_result;
if (quoteTweet) {
apiTweet.quote = (await populateTweetProperties(
quoteTweet,

View file

@ -1,15 +1,8 @@
import { Constants } from '../constants';
import { fetchUser } from '../fetch';
/* This function does the heavy lifting of processing data from Twitter API
and using it to create FixTweet's streamlined API responses */
const populateUserProperties = async (
response: GraphQLUserResponse
// eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<APIUser> => {
export const convertToApiUser = (user: GraphQLUser): APIUser => {
const apiUser = {} as APIUser;
const user = response.data.user.result;
/* Populating a lot of the basics */
apiUser.url = `${Constants.TWITTER_ROOT}/${user.legacy.screen_name}`;
apiUser.id = user.rest_id;
@ -51,6 +44,16 @@ const populateUserProperties = async (
return apiUser;
};
/* This function does the heavy lifting of processing data from Twitter API
and using it to create FixTweet's streamlined API responses */
const populateUserProperties = async (
response: GraphQLUserResponse
// eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<APIUser> => {
const user = response.data.user.result;
return convertToApiUser(user);
};
/* API for Twitter profiles (Users)
Used internally by FixTweet's embed service, or
available for free using api.fxtwitter.com. */

View file

@ -1,5 +1,6 @@
import { Constants } from './constants';
import { generateUserAgent } from './helpers/useragent';
import { isGraphQLTweetNotFoundResponse } from './utils/graphql';
const API_ATTEMPTS = 16;
@ -130,7 +131,12 @@ export const twitterFetch = async (
headers: headers
});
}
/*
If the tweet is nsfw, the body is empty and status is 404
const raw = await apiRequest?.clone().text();
console.log('Raw response:', raw);
console.log('Response code:', apiRequest?.status);
*/
response = await apiRequest?.json();
} catch (e: unknown) {
/* We'll usually only hit this if we get an invalid response from Twitter.
@ -188,20 +194,71 @@ export const fetchConversation = async (
status: string,
event: FetchEvent,
useElongator = false
): Promise<TimelineBlobPartial> => {
): Promise<GraphQLTweetDetailResponse> => {
return (await twitterFetch(
`${Constants.TWITTER_API_ROOT}/2/timeline/conversation/${status}.json?${Constants.GUEST_FETCH_PARAMETERS}`,
`${
Constants.TWITTER_ROOT
}/i/api/graphql/TuC3CinYecrqAyqccUyFhw/TweetDetail?variables=${encodeURIComponent(
JSON.stringify({
focalTweetId: status,
referrer: 'messages',
with_rux_injections:false,
includePromotedContent:true,
withCommunity:true,
withQuickPromoteEligibilityTweetFields:true,
withArticleRichContent:false,
withBirdwatchNotes:true,
withVoice:true,
withV2Timeline:true
})
)}&features=${encodeURIComponent(
JSON.stringify({
rweb_lists_timeline_redesign_enabled:true,
responsive_web_graphql_exclude_directive_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,
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:false,
tweet_awards_web_tipping_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_media_download_video_enabled:false,
responsive_web_enhance_cards_enabled:false
})
)}&fieldToggles=${encodeURIComponent(
JSON.stringify({
// TODO Figure out what this property does
withArticleRichContentState: false
})
)}`,
event,
useElongator,
(_conversation: unknown) => {
const conversation = _conversation as TimelineBlobPartial;
return !(
typeof conversation.globalObjects === 'undefined' &&
(typeof conversation.errors === 'undefined' ||
conversation.errors?.[0]?.code === 239)
);
const conversation = _conversation as GraphQLTweetDetailResponse;
// If we get a not found error it's still a valid response
if (isGraphQLTweetNotFoundResponse(conversation)) return true;
const instructions = conversation?.data?.threaded_conversation_with_injections_v2?.instructions;
if (!Array.isArray(instructions)) return false;
const timelineAddEntries = instructions.find((e): e is TimeLineAddEntriesInstruction => e?.type === 'TimelineAddEntries');
if (!timelineAddEntries) return false;
const graphQLTimelineTweetEntry = timelineAddEntries.entries
.find((e): e is GraphQLTimelineTweetEntry =>
// TODO Fix this idk what's up with the typings
!!(e && typeof e === 'object' && ('entryId' in e) && e?.entryId === `tweet-${status}`));
if (!graphQLTimelineTweetEntry) return false;
const tweet = graphQLTimelineTweetEntry?.content?.itemContent?.tweet_results?.result;
return !!tweet;
}
)) as TimelineBlobPartial;
)) as GraphQLTweetDetailResponse;
};
export const fetchUser = async (

View file

@ -2,33 +2,43 @@ import { calculateTimeLeftString } from './pollTime';
/* Renders card for polls and non-Twitter video embeds (i.e. YouTube) */
export const renderCard = async (
card: TweetCard
card: GraphQLTweet['card']
): Promise<{ poll?: APIPoll; external_media?: APIExternalMedia }> => {
const values = card.binding_values;
// We convert the binding_values array into an object with the legacy format
// TODO Clean this up
const binding_values: Record<string, { string_value?: string; boolean_value?: boolean }> = {};
if (Array.isArray(card.legacy.binding_values)) {
card.legacy.binding_values.forEach(value => {
if (value.key && value.value) {
binding_values[value.key] = value.value;
}
});
}
console.log('rendering card');
if (typeof values !== 'undefined') {
if (typeof values.choice1_count !== 'undefined') {
if (typeof binding_values !== 'undefined') {
if (typeof binding_values.choice1_count !== 'undefined') {
const poll = {} as APIPoll;
poll.ends_at = values.end_datetime_utc?.string_value || '';
poll.ends_at = binding_values.end_datetime_utc?.string_value || '';
poll.time_left_en = calculateTimeLeftString(
new Date(values.end_datetime_utc?.string_value || '')
new Date(binding_values.end_datetime_utc?.string_value || '')
);
const choices: { [label: string]: number } = {
[values.choice1_label?.string_value || '']: parseInt(
values.choice1_count?.string_value || '0'
[binding_values.choice1_label?.string_value || '']: parseInt(
binding_values.choice1_count?.string_value || '0'
),
[values.choice2_label?.string_value || '']: parseInt(
values.choice2_count?.string_value || '0'
[binding_values.choice2_label?.string_value || '']: parseInt(
binding_values.choice2_count?.string_value || '0'
),
[values.choice3_label?.string_value || '']: parseInt(
values.choice3_count?.string_value || '0'
[binding_values.choice3_label?.string_value || '']: parseInt(
binding_values.choice3_count?.string_value || '0'
),
[values.choice4_label?.string_value || '']: parseInt(
values.choice4_count?.string_value || '0'
[binding_values.choice4_label?.string_value || '']: parseInt(
binding_values.choice4_count?.string_value || '0'
)
};
@ -46,17 +56,17 @@ export const renderCard = async (
});
return { poll: poll };
} else if (typeof values.player_url !== 'undefined') {
} else if (typeof binding_values.player_url !== 'undefined' && binding_values.player_url.string_value) {
/* Oh good, a non-Twitter video URL! This enables YouTube embeds and stuff to just work */
return {
external_media: {
type: 'video',
url: values.player_url.string_value,
url: binding_values.player_url.string_value,
width: parseInt(
(values.player_width?.string_value || '1280').replace('px', '')
(binding_values.player_width?.string_value || '1280').replace('px', '')
), // TODO: Replacing px might not be necessary, it's just there as a precaution
height: parseInt(
(values.player_height?.string_value || '720').replace('px', '')
(binding_values.player_height?.string_value || '720').replace('px', '')
)
}
};

View file

@ -1,7 +1,7 @@
/* Helps replace t.co links with their originals */
export const linkFixer = (tweet: TweetPartial, text: string): string => {
if (typeof tweet.entities?.urls !== 'undefined') {
tweet.entities?.urls.forEach((url: TcoExpansion) => {
export const linkFixer = (tweet: GraphQLTweet, text: string): string => {
if (Array.isArray(tweet.legacy.entities?.urls) && tweet.legacy.entities.urls.length) {
tweet.legacy.entities.urls.forEach((url: TcoExpansion) => {
let newURL = url.expanded_url;
if (newURL.match(/^https:\/\/twitter\.com\/i\/web\/status\/\w+/g) !== null) {

View file

@ -2,7 +2,7 @@ import { Constants } from '../constants';
/* Handles translating Tweets when asked! */
export const translateTweet = async (
tweet: TweetPartial,
tweet: GraphQLTweet,
guestToken: string,
language: string
): Promise<TranslationPartial | null> => {
@ -29,7 +29,7 @@ export const translateTweet = async (
try {
apiRequest = await fetch(
`${Constants.TWITTER_ROOT}/i/api/1.1/strato/column/None/tweetId=${tweet.id_str},destinationLanguage=None,translationSource=Some(Google),feature=None,timeout=None,onlyCached=None/translation/service/translateTweet`,
`${Constants.TWITTER_ROOT}/i/api/1.1/strato/column/None/tweetId=${tweet.rest_id},destinationLanguage=None,translationSource=Some(Google),feature=None,timeout=None,onlyCached=None/translation/service/translateTweet`,
{
method: 'GET',
headers: headers

View file

@ -304,3 +304,156 @@ type GraphQLUser = {
};
};
};
type GraphQLTweet = {
__typename: 'Tweet';
rest_id: string; // "1674824189176590336",
has_birdwatch_notes: false,
core: {
user_results: {
result: GraphQLUser;
}
}
edit_control: unknown,
edit_perspective: unknown,
is_translatable: false,
views: {
count: string; // "562"
state: string; // "EnabledWithCount"
}
source: string; // "<a href=\"https://mobile.twitter.com\" rel=\"nofollow\">Twitter Web App</a>"
quoted_status_result?: GraphQLTweet;
legacy: {
created_at: string; // "Tue Sep 14 20:00:00 +0000 2021"
conversation_id_str: string; // "1674824189176590336"
bookmark_count: number; // 0
bookmarked: boolean; // false
favorite_count: number; // 28
full_text: string; // "This is a test tweet"
in_reply_to_screen_name: string; // "username"
in_reply_to_status_id_str: string; // "1674824189176590336"
in_reply_to_user_id_str: string; // "783214"
is_quote_status: boolean; // false
quote_count: number; // 39
quoted_status_id_str: string; // "1674824189176590336"
quoted_status_permalink: {
url: string; // "https://t.co/aBcDeFgHiJ"
expanded: string; // "https://twitter.com/username/status/1674824189176590336"
display: string; // "twitter.com/username/statu…"
};
reply_count: number; // 1
retweet_count: number; // 4
lang: string; // "en"
possibly_sensitive: boolean; // false
possibly_sensitive_editable: boolean; // false
entities: {
media: {
display_url: string; // "pic.twitter.com/1X2X3X4X5X"
expanded_url: string; // "https://twitter.com/username/status/1674824189176590336/photo/1" "https://twitter.com/username/status/1674824189176590336/video/1"
id_str: string; // "1674824189176590336"
indices: [number, number]; // [number, number]
media_url_https: string; // "https://pbs.twimg.com/media/FAKESCREENSHOT.jpg" With videos appears to be the thumbnail
type: string; // "photo" Seems to be photo even with videos
}[]
user_mentions: unknown[];
urls: TcoExpansion[];
hashtags: unknown[];
symbols: unknown[];
}
extended_entities: {
media: TweetMedia[]
}
}
card: {
rest_id: string; // "card://1674824189176590336",
legacy: {
binding_values: {
key: `choice${1|2|3|4}_label`|'counts_are_final'|`choice${1|2|3|4}_count`|'last_updated_datetime_utc'|'duration_minutes'|'api'|'card_url'
value: {
string_value: string; // "Option text"
type: 'STRING'
}|{
boolean_value: boolean; // true
type: 'BOOLEAN'
}
}[]
}
}
}
type TweetTombstone = {
__typename: 'TweetTombstone';
tombstone: {
__typename: 'TextTombstone';
text: {
rtl: boolean; // false;
text: string; // "Youre unable to view this Tweet because this account owner limits who can view their Tweets. Learn more"
entities: unknown[];
}
}
}
type GraphQLTimelineTweetEntry = {
/** The entryID contains the tweet ID */
entryId: `tweet-${number}`; // "tweet-1674824189176590336"
sortIndex: string;
content: {
entryType: 'TimelineTimelineItem',
__typename: 'TimelineTimelineItem',
itemContent: {
item: 'TimelineTweet',
__typename: 'TimelineTweet',
tweet_results: {
result: GraphQLTweet|TweetTombstone;
}
}
}
}
type GraphQLConversationThread = {
entryId: `conversationthread-${number}`; // "conversationthread-1674824189176590336"
sortIndex: string;
}
type GraphQLTimelineEntry = GraphQLTimelineTweetEntry|GraphQLConversationThread|unknown;
type V2ThreadInstruction = TimeLineAddEntriesInstruction | TimeLineTerminateTimelineInstruction;
type TimeLineAddEntriesInstruction = {
type: 'TimelineAddEntries';
entries: GraphQLTimelineEntry[];
}
type TimeLineTerminateTimelineInstruction = {
type: 'TimelineTerminateTimeline';
direction: 'Top';
}
type GraphQLTweetNotFoundResponse = {
errors: [{
message: string; // "_Missing: No status found with that ID"
locations: unknown[];
path: string[]; // ["threaded_conversation_with_injections_v2"]
extensions: {
name: string; // "GenericError"
source: string; // "Server"
code: number; // 144
kind: string; // "NonFatal"
tracing: {
trace_id: string; // "2e39ff747de237db"
}
}
code: number; // 144
kind: string; // "NonFatal"
name: string; // "GenericError"
source: string; // "Server"
tracing: {
trace_id: string; // "2e39ff747de237db"
}
}]
data: Record<string, never>;
}
type GraphQLTweetFoundResponse = {
data: {
threaded_conversation_with_injections_v2: {
instructions: V2ThreadInstruction[]
}
}
}
type GraphQLTweetDetailResponse = GraphQLTweetFoundResponse | GraphQLTweetNotFoundResponse;

7
src/utils/graphql.ts Normal file
View file

@ -0,0 +1,7 @@
export const isGraphQLTweetNotFoundResponse = (response: unknown): response is GraphQLTweetNotFoundResponse => {
return typeof response === 'object' && response !== null && 'errors' in response && Array.isArray(response.errors) && response.errors.length > 0 && 'message' in response.errors[0] && response.errors[0].message === '_Missing: No status found with that ID';
};
export const isGraphQLTweet = (response: unknown): response is GraphQLTweet => {
return typeof response === 'object' && response !== null && '__typename' in response && response.__typename === 'Tweet';
}