mirror of
https://github.com/CompeyDev/fxtwitter-docker.git
synced 2025-04-08 03:50:53 +01:00
Use proto-v2 thread API internally
This commit is contained in:
parent
ae7c432481
commit
eb90f48226
9 changed files with 67 additions and 359 deletions
|
@ -1,315 +0,0 @@
|
||||||
import { renderCard } from '../helpers/card';
|
|
||||||
import { Constants } from '../constants';
|
|
||||||
import { fetchConversation } from '../fetch';
|
|
||||||
import { linkFixer } from '../helpers/linkFixer';
|
|
||||||
import { handleMosaic } from '../helpers/mosaic';
|
|
||||||
import { translateTweet } from '../helpers/translate';
|
|
||||||
import { unescapeText } from '../helpers/utils';
|
|
||||||
import { processMedia } from '../helpers/media';
|
|
||||||
import { convertToApiUser } from './user';
|
|
||||||
import { isGraphQLTweet } from '../helpers/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: GraphQLTweet,
|
|
||||||
conversation: TweetResultsByRestIdResult, // TimelineBlobPartial,
|
|
||||||
language: string | undefined
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
): Promise<APITweet | null> => {
|
|
||||||
const apiTweet = {} as APITweet;
|
|
||||||
|
|
||||||
/* Sometimes, Twitter returns a different kind of Tweet type called 'TweetWithVisibilityResults'.
|
|
||||||
It has slightly different attributes from the regular 'Tweet' type. We fix that up here. */
|
|
||||||
|
|
||||||
if (typeof tweet.core === 'undefined' && typeof tweet.result !== 'undefined') {
|
|
||||||
tweet = tweet.result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof tweet.core === 'undefined' && typeof tweet.tweet?.core !== 'undefined') {
|
|
||||||
tweet.core = tweet.tweet.core;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof tweet.legacy === 'undefined' && typeof tweet.tweet?.legacy !== 'undefined') {
|
|
||||||
tweet.legacy = tweet.tweet?.legacy;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof tweet.views === 'undefined' && typeof tweet?.tweet?.views !== 'undefined') {
|
|
||||||
tweet.views = tweet?.tweet?.views;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof tweet.core === 'undefined') {
|
|
||||||
console.log('Tweet still not valid', tweet);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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. */
|
|
||||||
const graphQLUser = tweet.core.user_results.result;
|
|
||||||
const apiUser = convertToApiUser(graphQLUser);
|
|
||||||
|
|
||||||
/* Sometimes, `rest_id` is undefined for some reason. Inconsistent behavior. See: https://github.com/FixTweet/FixTweet/issues/416 */
|
|
||||||
const id = tweet.rest_id ?? tweet.legacy.id_str;
|
|
||||||
|
|
||||||
/* Populating a lot of the basics */
|
|
||||||
apiTweet.url = `${Constants.TWITTER_ROOT}/${apiUser.screen_name}/status/${id}`;
|
|
||||||
apiTweet.id = id;
|
|
||||||
apiTweet.text = unescapeText(linkFixer(tweet.legacy.entities?.urls, tweet.legacy.full_text || ''));
|
|
||||||
apiTweet.author = {
|
|
||||||
id: apiUser.id,
|
|
||||||
name: apiUser.name,
|
|
||||||
screen_name: apiUser.screen_name,
|
|
||||||
avatar_url: (apiUser.avatar_url || '').replace('_normal', '_200x200') || '',
|
|
||||||
// @ts-expect-error Legacy api shit
|
|
||||||
avatar_color: null,
|
|
||||||
banner_url: apiUser.banner_url || '',
|
|
||||||
description: apiUser.description || '',
|
|
||||||
location: apiUser.location || '',
|
|
||||||
url: apiUser.url || '',
|
|
||||||
followers: apiUser.followers,
|
|
||||||
following: apiUser.following,
|
|
||||||
joined: apiUser.joined,
|
|
||||||
tweets: apiUser.tweets,
|
|
||||||
likes: apiUser.likes,
|
|
||||||
protected: apiUser.protected,
|
|
||||||
birthday: apiUser.birthday,
|
|
||||||
website: apiUser.website
|
|
||||||
};
|
|
||||||
apiTweet.replies = tweet.legacy.reply_count;
|
|
||||||
apiTweet.reposts = tweet.legacy.retweet_count;
|
|
||||||
apiTweet.reposts = tweet.legacy.retweet_count;
|
|
||||||
apiTweet.likes = tweet.legacy.favorite_count;
|
|
||||||
// @ts-expect-error Legacy api shit
|
|
||||||
apiTweet.color = null;
|
|
||||||
// @ts-expect-error legacy api
|
|
||||||
apiTweet.twitter_card = 'tweet';
|
|
||||||
apiTweet.created_at = tweet.legacy.created_at;
|
|
||||||
apiTweet.created_timestamp = new Date(tweet.legacy.created_at).getTime() / 1000;
|
|
||||||
|
|
||||||
apiTweet.possibly_sensitive = tweet.legacy.possibly_sensitive;
|
|
||||||
|
|
||||||
if (tweet.views.state === 'EnabledWithCount') {
|
|
||||||
apiTweet.views = parseInt(tweet.views.count || '0') ?? null;
|
|
||||||
} else {
|
|
||||||
apiTweet.views = null;
|
|
||||||
}
|
|
||||||
console.log('note_tweet', JSON.stringify(tweet.note_tweet));
|
|
||||||
const noteTweetText = tweet.note_tweet?.note_tweet_results?.result?.text;
|
|
||||||
|
|
||||||
if (noteTweetText) {
|
|
||||||
tweet.legacy.entities.urls = tweet.note_tweet?.note_tweet_results?.result?.entity_set.urls;
|
|
||||||
tweet.legacy.entities.hashtags =
|
|
||||||
tweet.note_tweet?.note_tweet_results?.result?.entity_set.hashtags;
|
|
||||||
tweet.legacy.entities.symbols =
|
|
||||||
tweet.note_tweet?.note_tweet_results?.result?.entity_set.symbols;
|
|
||||||
|
|
||||||
console.log('We meet the conditions to use new note tweets');
|
|
||||||
apiTweet.text = unescapeText(linkFixer(tweet.legacy.entities.urls, noteTweetText));
|
|
||||||
apiTweet.is_note_tweet = true;
|
|
||||||
} else {
|
|
||||||
apiTweet.is_note_tweet = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tweet.legacy.lang !== 'unk') {
|
|
||||||
apiTweet.lang = tweet.legacy.lang;
|
|
||||||
} else {
|
|
||||||
apiTweet.lang = 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.legacy.extended_entities?.media || tweet.legacy.entities?.media || []
|
|
||||||
);
|
|
||||||
|
|
||||||
// console.log('tweet', JSON.stringify(tweet));
|
|
||||||
|
|
||||||
/* Populate this Tweet's media */
|
|
||||||
mediaList.forEach(media => {
|
|
||||||
const mediaObject = processMedia(media);
|
|
||||||
if (mediaObject) {
|
|
||||||
apiTweet.media = apiTweet.media || {};
|
|
||||||
apiTweet.media.all = apiTweet.media?.all || [];
|
|
||||||
apiTweet.media.all.push(mediaObject);
|
|
||||||
|
|
||||||
if (mediaObject.type === 'photo') {
|
|
||||||
apiTweet.embed_card = 'summary_large_image';
|
|
||||||
apiTweet.media.photos = apiTweet.media.photos || [];
|
|
||||||
apiTweet.media.photos.push(mediaObject);
|
|
||||||
} else if (mediaObject.type === 'video' || mediaObject.type === 'gif') {
|
|
||||||
apiTweet.embed_card = 'player';
|
|
||||||
apiTweet.media.videos = apiTweet.media.videos || [];
|
|
||||||
apiTweet.media.videos.push(mediaObject);
|
|
||||||
} else {
|
|
||||||
console.log('Unknown media type', mediaObject.type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/* 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 || [], id);
|
|
||||||
if (typeof apiTweet.media !== 'undefined' && mosaic !== null) {
|
|
||||||
apiTweet.media.mosaic = mosaic;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Tweet source but remove the link HTML tag
|
|
||||||
if (tweet.source) {
|
|
||||||
apiTweet.source = (tweet.source || '').replace(
|
|
||||||
/<a href="(.+?)" rel="nofollow">(.+?)<\/a>/,
|
|
||||||
'$2'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Populate a Twitter card */
|
|
||||||
|
|
||||||
if (tweet.card) {
|
|
||||||
const card = renderCard(tweet.card);
|
|
||||||
if (card.external_media) {
|
|
||||||
apiTweet.media = apiTweet.media || {};
|
|
||||||
apiTweet.media.external = card.external_media;
|
|
||||||
}
|
|
||||||
if (card.poll) {
|
|
||||||
apiTweet.poll = card.poll;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Workaround: Force player card by default for videos */
|
|
||||||
/* TypeScript gets confused and re-interprets the type'tweet' instead of 'tweet' | 'summary' | 'summary_large_image' | 'player'
|
|
||||||
The mediaList however can set it to something else. TODO: Reimplement as enums */
|
|
||||||
|
|
||||||
if (apiTweet.media?.videos && apiTweet.embed_card !== 'player') {
|
|
||||||
apiTweet.embed_card = 'player';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* If a language is specified in API or by user, let's try translating it! */
|
|
||||||
if (typeof language === 'string' && language.length === 2 && language !== tweet.legacy.lang) {
|
|
||||||
console.log(`Attempting to translate Tweet to ${language}...`);
|
|
||||||
const translateAPI = await translateTweet(tweet, conversation.guestToken || '', language);
|
|
||||||
if (translateAPI !== null && translateAPI?.translation) {
|
|
||||||
apiTweet.translation = {
|
|
||||||
text: unescapeText(linkFixer(tweet.legacy?.entities?.urls, translateAPI?.translation || '')),
|
|
||||||
source_lang: translateAPI?.sourceLanguage || '',
|
|
||||||
target_lang: translateAPI?.destinationLanguage || '',
|
|
||||||
source_lang_en: translateAPI?.localizedSourceLanguage || ''
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiTweet;
|
|
||||||
};
|
|
||||||
|
|
||||||
const writeDataPoint = (
|
|
||||||
event: FetchEvent,
|
|
||||||
language: string | undefined,
|
|
||||||
nsfw: boolean,
|
|
||||||
returnCode: string,
|
|
||||||
flags?: InputFlags
|
|
||||||
) => {
|
|
||||||
console.log('Writing data point...');
|
|
||||||
if (typeof AnalyticsEngine !== 'undefined') {
|
|
||||||
const flagString =
|
|
||||||
Object.keys(flags || {})
|
|
||||||
// @ts-expect-error - TypeScript doesn't like iterating over the keys, but that's OK
|
|
||||||
.filter(flag => flags?.[flag])[0] || 'standard';
|
|
||||||
|
|
||||||
AnalyticsEngine.writeDataPoint({
|
|
||||||
blobs: [
|
|
||||||
event.request.cf?.colo as string /* Datacenter location */,
|
|
||||||
event.request.cf?.country as string /* Country code */,
|
|
||||||
event.request.headers.get('user-agent') ??
|
|
||||||
'' /* User agent (for aggregating bots calling) */,
|
|
||||||
returnCode /* Return code */,
|
|
||||||
flagString /* Type of request */,
|
|
||||||
language ?? '' /* For translate feature */
|
|
||||||
],
|
|
||||||
doubles: [nsfw ? 1 : 0 /* NSFW media = 1, No NSFW Media = 0 */],
|
|
||||||
indexes: [event.request.headers.get('cf-ray') ?? '' /* CF Ray */]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/* API for Twitter statuses (Tweets)
|
|
||||||
Used internally by FixTweet's embed service, or
|
|
||||||
available for free using api.fxtwitter.com. */
|
|
||||||
export const statusAPI = async (
|
|
||||||
status: string,
|
|
||||||
language: string | undefined,
|
|
||||||
event: FetchEvent,
|
|
||||||
flags?: InputFlags
|
|
||||||
): Promise<TweetAPIResponse> => {
|
|
||||||
const res = await fetchConversation(status, event);
|
|
||||||
const tweet = res.data?.tweetResult?.result;
|
|
||||||
if (!tweet) {
|
|
||||||
return { code: 404, message: 'NOT_FOUND' };
|
|
||||||
}
|
|
||||||
/* We're handling this in the actual fetch code now */
|
|
||||||
|
|
||||||
// if (tweet.__typename === 'TweetUnavailable' && tweet.reason === 'NsfwLoggedOut') {
|
|
||||||
// wasMediaBlockedNSFW = true;
|
|
||||||
// res = await fetchConversation(status, event, true);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// console.log(JSON.stringify(tweet))
|
|
||||||
|
|
||||||
if (tweet.__typename === 'TweetUnavailable') {
|
|
||||||
if ((tweet as { reason: string })?.reason === 'Protected') {
|
|
||||||
writeDataPoint(event, language, false, 'PRIVATE_TWEET', flags);
|
|
||||||
return { code: 401, message: 'PRIVATE_TWEET' };
|
|
||||||
// } else if (tweet.reason === 'NsfwLoggedOut') {
|
|
||||||
// // API failure as elongator should have handled this
|
|
||||||
// writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags);
|
|
||||||
// return { code: 500, message: 'API_FAIL' };
|
|
||||||
} else {
|
|
||||||
// Api failure at parsing status
|
|
||||||
writeDataPoint(event, language, false, 'API_FAIL', flags);
|
|
||||||
return { code: 500, message: 'API_FAIL' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If the tweet is not a graphQL tweet something went wrong
|
|
||||||
if (!isGraphQLTweet(tweet)) {
|
|
||||||
console.log('Tweet was not a valid tweet', tweet);
|
|
||||||
writeDataPoint(event, language, false, 'API_FAIL', flags);
|
|
||||||
return { code: 500, message: 'API_FAIL' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
if (tweet.retweeted_status_id_str) {
|
|
||||||
tweet = conversation?.globalObjects?.tweets?.[tweet.retweeted_status_id_str] || {};
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
if (!tweet) {
|
|
||||||
return { code: 404, message: 'NOT_FOUND' };
|
|
||||||
}
|
|
||||||
/* Creating the response objects */
|
|
||||||
const response: TweetAPIResponse = { code: 200, message: 'OK' } as TweetAPIResponse;
|
|
||||||
const apiTweet: APITweet = (await populateTweetProperties(tweet, res, language)) as APITweet;
|
|
||||||
|
|
||||||
/* We found a quote tweet, let's process that too */
|
|
||||||
const quoteTweet = tweet.quoted_status_result;
|
|
||||||
if (quoteTweet) {
|
|
||||||
apiTweet.quote = (await populateTweetProperties(quoteTweet, res, language)) as APITweet;
|
|
||||||
/* Only override the twitter_card if it's a basic tweet, since media always takes precedence */
|
|
||||||
if (apiTweet.embed_card === 'tweet' && apiTweet.quote !== null) {
|
|
||||||
apiTweet.embed_card = apiTweet.quote.embed_card;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Finally, staple the Tweet to the response and return it */
|
|
||||||
response.tweet = apiTweet;
|
|
||||||
|
|
||||||
writeDataPoint(event, language, false, 'OK', flags);
|
|
||||||
|
|
||||||
return response;
|
|
||||||
};
|
|
|
@ -10,14 +10,14 @@ export const convertToApiUser = (user: GraphQLUser): APIUser => {
|
||||||
apiUser.followers = user.legacy.followers_count;
|
apiUser.followers = user.legacy.followers_count;
|
||||||
apiUser.following = user.legacy.friends_count;
|
apiUser.following = user.legacy.friends_count;
|
||||||
apiUser.likes = user.legacy.favourites_count;
|
apiUser.likes = user.legacy.favourites_count;
|
||||||
|
// @ts-expect-error `tweets` is only part of legacy API
|
||||||
apiUser.tweets = user.legacy.statuses_count;
|
apiUser.tweets = user.legacy.statuses_count;
|
||||||
apiUser.posts = user.legacy.statuses_count;
|
|
||||||
apiUser.name = user.legacy.name;
|
apiUser.name = user.legacy.name;
|
||||||
apiUser.screen_name = user.legacy.screen_name;
|
apiUser.screen_name = user.legacy.screen_name;
|
||||||
apiUser.global_screen_name = `${user.legacy.screen_name}@${Constants.TWITTER_GLOBAL_NAME_ROOT}`
|
apiUser.global_screen_name = `${user.legacy.screen_name}@${Constants.TWITTER_GLOBAL_NAME_ROOT}`
|
||||||
apiUser.description = user.legacy.description ? linkFixer(user.legacy.entities?.description?.urls, user.legacy.description) : null;
|
apiUser.description = user.legacy.description ? linkFixer(user.legacy.entities?.description?.urls, user.legacy.description) : '';
|
||||||
apiUser.location = user.legacy.location ? user.legacy.location : null;
|
apiUser.location = user.legacy.location ? user.legacy.location : '';
|
||||||
apiUser.banner_url = user.legacy.profile_banner_url ? user.legacy.profile_banner_url : null;
|
apiUser.banner_url = user.legacy.profile_banner_url ? user.legacy.profile_banner_url : '';
|
||||||
/*
|
/*
|
||||||
if (user.is_blue_verified) {
|
if (user.is_blue_verified) {
|
||||||
apiUser.verified = 'blue';
|
apiUser.verified = 'blue';
|
||||||
|
|
|
@ -41,7 +41,7 @@ export const handleStatus = async (
|
||||||
fetchWithThreads = true;
|
fetchWithThreads = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const thread = await constructTwitterThread(status, fetchWithThreads, request, undefined);
|
const thread = await constructTwitterThread(status, fetchWithThreads, request, undefined, flags?.api ?? false);
|
||||||
|
|
||||||
const tweet = thread?.post as APITweet;
|
const tweet = thread?.post as APITweet;
|
||||||
|
|
||||||
|
@ -395,7 +395,7 @@ export const handleStatus = async (
|
||||||
authorText = `↪ Replying to @${tweet.replying_to}`;
|
authorText = `↪ Replying to @${tweet.replying_to}`;
|
||||||
/* We'll assume it's a thread if it's a reply to themselves */
|
/* We'll assume it's a thread if it's a reply to themselves */
|
||||||
} else if (
|
} else if (
|
||||||
tweet.replying_to === tweet.author.screen_name &&
|
tweet.replying_to?.screen_name === tweet.author.screen_name &&
|
||||||
authorText === Strings.DEFAULT_AUTHOR_TEXT
|
authorText === Strings.DEFAULT_AUTHOR_TEXT
|
||||||
) {
|
) {
|
||||||
authorText = `↪ A part of @${tweet.author.screen_name}'s thread`;
|
authorText = `↪ A part of @${tweet.author.screen_name}'s thread`;
|
||||||
|
|
|
@ -14,7 +14,7 @@ const Experiments: { [key in Experiment]: ExperimentConfig } = {
|
||||||
[Experiment.ELONGATOR_BY_DEFAULT]: {
|
[Experiment.ELONGATOR_BY_DEFAULT]: {
|
||||||
name: 'Elongator by default',
|
name: 'Elongator by default',
|
||||||
description: 'Enable Elongator by default (guest token lockout bypass)',
|
description: 'Enable Elongator by default (guest token lockout bypass)',
|
||||||
percentage: 0.6
|
percentage: 1
|
||||||
},
|
},
|
||||||
[Experiment.ELONGATOR_PROFILE_API]: {
|
[Experiment.ELONGATOR_PROFILE_API]: {
|
||||||
name: 'Elongator profile API',
|
name: 'Elongator profile API',
|
||||||
|
|
|
@ -237,6 +237,8 @@ export const constructTwitterThread = async (id: string,
|
||||||
language: string | undefined,
|
language: string | undefined,
|
||||||
legacyAPI = false): Promise<SocialThread> => {
|
legacyAPI = false): Promise<SocialThread> => {
|
||||||
|
|
||||||
|
console.log('legacyAPI', legacyAPI)
|
||||||
|
|
||||||
let response: GraphQLTweetFoundResponse | TweetResultsByRestIdResult;
|
let response: GraphQLTweetFoundResponse | TweetResultsByRestIdResult;
|
||||||
let post: APITweet;
|
let post: APITweet;
|
||||||
|
|
||||||
|
@ -246,7 +248,7 @@ export const constructTwitterThread = async (id: string,
|
||||||
|
|
||||||
const result = response?.data?.tweetResult?.result as GraphQLTweet;
|
const result = response?.data?.tweetResult?.result as GraphQLTweet;
|
||||||
|
|
||||||
if (typeof result?.tweet === "undefined") {
|
if (typeof result === "undefined") {
|
||||||
return { post: null, thread: null, author: null, code: 404 };
|
return { post: null, thread: null, author: null, code: 404 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -56,16 +56,15 @@ export const buildAPITweet = async (
|
||||||
id: apiUser.id,
|
id: apiUser.id,
|
||||||
name: apiUser.name,
|
name: apiUser.name,
|
||||||
screen_name: apiUser.screen_name,
|
screen_name: apiUser.screen_name,
|
||||||
global_screen_name: apiUser.global_screen_name,
|
|
||||||
avatar_url: apiUser.avatar_url?.replace?.('_normal', '_200x200') ?? null,
|
avatar_url: apiUser.avatar_url?.replace?.('_normal', '_200x200') ?? null,
|
||||||
banner_url: apiUser.banner_url ?? null,
|
banner_url: apiUser.banner_url,
|
||||||
description: apiUser.description ?? null,
|
description: apiUser.description,
|
||||||
location: apiUser.location ?? null,
|
location: apiUser.location,
|
||||||
url: apiUser.url ?? null,
|
url: apiUser.url,
|
||||||
followers: apiUser.followers,
|
followers: apiUser.followers,
|
||||||
following: apiUser.following,
|
following: apiUser.following,
|
||||||
joined: apiUser.joined,
|
joined: apiUser.joined,
|
||||||
posts: apiUser.tweets,
|
posts: apiUser.posts,
|
||||||
likes: apiUser.likes,
|
likes: apiUser.likes,
|
||||||
protected: apiUser.protected,
|
protected: apiUser.protected,
|
||||||
birthday: apiUser.birthday,
|
birthday: apiUser.birthday,
|
||||||
|
@ -76,8 +75,19 @@ export const buildAPITweet = async (
|
||||||
if (legacyAPI) {
|
if (legacyAPI) {
|
||||||
// @ts-expect-error Use retweets for legacy API
|
// @ts-expect-error Use retweets for legacy API
|
||||||
apiTweet.retweets = tweet.legacy.retweet_count;
|
apiTweet.retweets = tweet.legacy.retweet_count;
|
||||||
|
|
||||||
|
// @ts-expect-error `tweets` is only part of legacy API
|
||||||
|
apiTweet.author.tweets = apiTweet.author.posts;
|
||||||
|
// @ts-expect-error Part of legacy API that we no longer are able to track
|
||||||
|
apiTweet.author.avatar_color = null;
|
||||||
|
// @ts-expect-error Use retweets for legacy API
|
||||||
|
delete apiTweet.reposts;
|
||||||
|
// @ts-expect-error Use tweets and not posts for legacy API
|
||||||
|
delete apiTweet.author.posts;
|
||||||
|
delete apiTweet.author.global_screen_name;
|
||||||
} else {
|
} else {
|
||||||
apiTweet.reposts = tweet.legacy.retweet_count;
|
apiTweet.reposts = tweet.legacy.retweet_count;
|
||||||
|
apiTweet.author.global_screen_name = apiUser.global_screen_name;
|
||||||
}
|
}
|
||||||
apiTweet.likes = tweet.legacy.favorite_count;
|
apiTweet.likes = tweet.legacy.favorite_count;
|
||||||
apiTweet.embed_card = 'tweet';
|
apiTweet.embed_card = 'tweet';
|
||||||
|
@ -115,27 +125,25 @@ export const buildAPITweet = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (legacyAPI) {
|
if (legacyAPI) {
|
||||||
|
// @ts-expect-error Use replying_to string for legacy API
|
||||||
apiTweet.replying_to = tweet.legacy?.in_reply_to_screen_name || null;
|
apiTweet.replying_to = tweet.legacy?.in_reply_to_screen_name || null;
|
||||||
|
// @ts-expect-error Use replying_to_status string for legacy API
|
||||||
apiTweet.replying_to_status = tweet.legacy?.in_reply_to_status_id_str || null;
|
apiTweet.replying_to_status = tweet.legacy?.in_reply_to_status_id_str || null;
|
||||||
} else if (tweet.legacy.in_reply_to_screen_name) {
|
} else if (tweet.legacy.in_reply_to_screen_name) {
|
||||||
apiTweet.reply_of = {
|
apiTweet.replying_to = {
|
||||||
screen_name: tweet.legacy.in_reply_to_screen_name || null,
|
screen_name: tweet.legacy.in_reply_to_screen_name || null,
|
||||||
post: tweet.legacy.in_reply_to_status_id_str || null
|
post: tweet.legacy.in_reply_to_status_id_str || null
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
apiTweet.reply_of = null;
|
apiTweet.replying_to = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
apiTweet.media = {
|
apiTweet.media = {};
|
||||||
all: [],
|
|
||||||
photos: [],
|
|
||||||
videos: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
/* We found a quote tweet, let's process that too */
|
/* We found a quote tweet, let's process that too */
|
||||||
const quoteTweet = tweet.quoted_status_result;
|
const quoteTweet = tweet.quoted_status_result;
|
||||||
if (quoteTweet) {
|
if (quoteTweet) {
|
||||||
apiTweet.quote = (await buildAPITweet(quoteTweet, language)) as APITweet;
|
apiTweet.quote = (await buildAPITweet(quoteTweet, language, threadPiece, legacyAPI)) as APITweet;
|
||||||
/* Only override the embed_card if it's a basic tweet, since media always takes precedence */
|
/* Only override the embed_card if it's a basic tweet, since media always takes precedence */
|
||||||
if (apiTweet.embed_card === 'tweet' && apiTweet.quote !== null) {
|
if (apiTweet.embed_card === 'tweet' && apiTweet.quote !== null) {
|
||||||
apiTweet.embed_card = apiTweet.quote.embed_card;
|
apiTweet.embed_card = apiTweet.quote.embed_card;
|
||||||
|
@ -152,13 +160,16 @@ export const buildAPITweet = async (
|
||||||
mediaList.forEach(media => {
|
mediaList.forEach(media => {
|
||||||
const mediaObject = processMedia(media);
|
const mediaObject = processMedia(media);
|
||||||
if (mediaObject) {
|
if (mediaObject) {
|
||||||
|
apiTweet.media.all = apiTweet.media?.all ?? [];
|
||||||
apiTweet.media?.all?.push(mediaObject);
|
apiTweet.media?.all?.push(mediaObject);
|
||||||
if (mediaObject.type === 'photo') {
|
if (mediaObject.type === 'photo') {
|
||||||
apiTweet.embed_card = 'summary_large_image';
|
apiTweet.embed_card = 'summary_large_image';
|
||||||
apiTweet.media?.photos?.push(mediaObject);
|
apiTweet.media.photos = apiTweet.media?.photos ?? [];
|
||||||
|
apiTweet.media.photos?.push(mediaObject);
|
||||||
} else if (mediaObject.type === 'video' || mediaObject.type === 'gif') {
|
} else if (mediaObject.type === 'video' || mediaObject.type === 'gif') {
|
||||||
apiTweet.embed_card = 'player';
|
apiTweet.embed_card = 'player';
|
||||||
apiTweet.media?.videos?.push(mediaObject);
|
apiTweet.media.videos = apiTweet.media?.videos ?? [];
|
||||||
|
apiTweet.media.videos?.push(mediaObject);
|
||||||
} else {
|
} else {
|
||||||
console.log('Unknown media type', mediaObject.type);
|
console.log('Unknown media type', mediaObject.type);
|
||||||
}
|
}
|
||||||
|
@ -173,7 +184,7 @@ export const buildAPITweet = async (
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Handle photos and mosaic if available */
|
/* Handle photos and mosaic if available */
|
||||||
if ((apiTweet?.media?.photos?.length || 0) > 1 && !threadPiece) {
|
if ((apiTweet?.media.photos?.length || 0) > 1 && !threadPiece) {
|
||||||
const mosaic = await handleMosaic(apiTweet.media?.photos || [], id);
|
const mosaic = await handleMosaic(apiTweet.media?.photos || [], id);
|
||||||
if (typeof apiTweet.media !== 'undefined' && mosaic !== null) {
|
if (typeof apiTweet.media !== 'undefined' && mosaic !== null) {
|
||||||
apiTweet.media.mosaic = mosaic;
|
apiTweet.media.mosaic = mosaic;
|
||||||
|
@ -193,7 +204,6 @@ export const buildAPITweet = async (
|
||||||
if (tweet.card) {
|
if (tweet.card) {
|
||||||
const card = renderCard(tweet.card);
|
const card = renderCard(tweet.card);
|
||||||
if (card.external_media) {
|
if (card.external_media) {
|
||||||
apiTweet.media = apiTweet.media ?? {};
|
|
||||||
apiTweet.media.external = card.external_media;
|
apiTweet.media.external = card.external_media;
|
||||||
}
|
}
|
||||||
if (card.poll) {
|
if (card.poll) {
|
||||||
|
@ -201,7 +211,7 @@ export const buildAPITweet = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apiTweet.media?.videos && apiTweet.embed_card !== 'player') {
|
if (apiTweet.media?.videos && apiTweet.media?.videos.length > 0 && apiTweet.embed_card !== 'player') {
|
||||||
apiTweet.embed_card = 'player';
|
apiTweet.embed_card = 'player';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -219,5 +229,18 @@ export const buildAPITweet = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (legacyAPI) {
|
||||||
|
// @ts-expect-error Use twitter_card for legacy API
|
||||||
|
apiTweet.twitter_card = apiTweet.embed_card;
|
||||||
|
// @ts-expect-error Part of legacy API that we no longer are able to track
|
||||||
|
apiTweet.color = null
|
||||||
|
// @ts-expect-error Use twitter_card for legacy API
|
||||||
|
delete apiTweet.embed_card;
|
||||||
|
if ((apiTweet.media.all?.length ?? 0) < 1 && !apiTweet.media.external) {
|
||||||
|
// @ts-expect-error media is not required in legacy API if empty
|
||||||
|
delete apiTweet.media;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return apiTweet;
|
return apiTweet;
|
||||||
};
|
};
|
|
@ -16,7 +16,7 @@ const populateUserLinks = (tweet: APIPost, text: string): string => {
|
||||||
return text;
|
return text;
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateTweetMedia = (tweet: APITweet): string => {
|
const generateTweetMedia = (tweet: APIPost): string => {
|
||||||
let media = '';
|
let media = '';
|
||||||
if (tweet.media?.all?.length) {
|
if (tweet.media?.all?.length) {
|
||||||
tweet.media.all.forEach(mediaItem => {
|
tweet.media.all.forEach(mediaItem => {
|
||||||
|
@ -131,7 +131,7 @@ const generateTweetFooter = (tweet: APIPost, isQuote = false): string => {
|
||||||
<!-- Embed profile picture, display name, and screen name in table -->
|
<!-- Embed profile picture, display name, and screen name in table -->
|
||||||
{aboutSection}
|
{aboutSection}
|
||||||
`.format({
|
`.format({
|
||||||
socialText: getSocialTextIV(tweet) || '',
|
socialText: getSocialTextIV(tweet as APITweet) || '',
|
||||||
viewOriginal: !isQuote ? `<a href="${tweet.url}">View original post</a>` : notApplicableComment,
|
viewOriginal: !isQuote ? `<a href="${tweet.url}">View original post</a>` : notApplicableComment,
|
||||||
aboutSection: isQuote
|
aboutSection: isQuote
|
||||||
? ''
|
? ''
|
||||||
|
@ -161,13 +161,13 @@ const generateTweetFooter = (tweet: APIPost, isQuote = false): string => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateTweet = (tweet: APITweet, isQuote = false): string => {
|
const generateTweet = (tweet: APIPost, isQuote = false): string => {
|
||||||
let text = paragraphify(sanitizeText(tweet.text), isQuote);
|
let text = paragraphify(sanitizeText(tweet.text), isQuote);
|
||||||
text = htmlifyLinks(text);
|
text = htmlifyLinks(text);
|
||||||
text = htmlifyHashtags(text);
|
text = htmlifyHashtags(text);
|
||||||
text = populateUserLinks(tweet, text);
|
text = populateUserLinks(tweet, text);
|
||||||
|
|
||||||
const translatedText = getTranslatedText(tweet, isQuote);
|
const translatedText = getTranslatedText(tweet as APITweet, isQuote);
|
||||||
|
|
||||||
return `<!-- Telegram Instant View -->
|
return `<!-- Telegram Instant View -->
|
||||||
{quoteHeader}
|
{quoteHeader}
|
||||||
|
|
20
src/types/types.d.ts
vendored
20
src/types/types.d.ts
vendored
|
@ -130,7 +130,7 @@ interface APIPost {
|
||||||
poll?: APIPoll;
|
poll?: APIPoll;
|
||||||
author: APIUser;
|
author: APIUser;
|
||||||
|
|
||||||
media?: {
|
media: {
|
||||||
external?: APIExternalMedia;
|
external?: APIExternalMedia;
|
||||||
photos?: APIPhoto[];
|
photos?: APIPhoto[];
|
||||||
videos?: APIVideo[];
|
videos?: APIVideo[];
|
||||||
|
@ -141,10 +141,7 @@ interface APIPost {
|
||||||
lang: string | null;
|
lang: string | null;
|
||||||
possibly_sensitive: boolean;
|
possibly_sensitive: boolean;
|
||||||
|
|
||||||
replying_to: string | null;
|
replying_to: {
|
||||||
replying_to_status: string | null;
|
|
||||||
|
|
||||||
reply_of: {
|
|
||||||
screen_name: string | null;
|
screen_name: string | null;
|
||||||
post: string | null;
|
post: string | null;
|
||||||
} | null
|
} | null
|
||||||
|
@ -165,19 +162,18 @@ interface APIUser {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
screen_name: string;
|
screen_name: string;
|
||||||
global_screen_name: string;
|
global_screen_name?: string;
|
||||||
avatar_url: string | null;
|
avatar_url: string;
|
||||||
banner_url: string | null;
|
banner_url: string;
|
||||||
// verified: 'legacy' | 'blue'| 'business' | 'government';
|
// verified: 'legacy' | 'blue'| 'business' | 'government';
|
||||||
// verified_label: string;
|
// verified_label: string;
|
||||||
description: string | null;
|
description: string;
|
||||||
location: string | null;
|
location: string;
|
||||||
url: string;
|
url: string;
|
||||||
protected: boolean;
|
protected: boolean;
|
||||||
followers: number;
|
followers: number;
|
||||||
following: number;
|
following: number;
|
||||||
tweets?: number;
|
posts: number;
|
||||||
posts?: number;
|
|
||||||
likes: number;
|
likes: number;
|
||||||
joined: string;
|
joined: string;
|
||||||
website: {
|
website: {
|
||||||
|
|
|
@ -120,8 +120,10 @@ test('API fetch basic Tweet', async () => {
|
||||||
expect(tweet.author.avatar_url).toBeTruthy();
|
expect(tweet.author.avatar_url).toBeTruthy();
|
||||||
expect(tweet.author.banner_url).toBeTruthy();
|
expect(tweet.author.banner_url).toBeTruthy();
|
||||||
expect(tweet.replies).toBeGreaterThan(0);
|
expect(tweet.replies).toBeGreaterThan(0);
|
||||||
|
// @ts-expect-error retweets only in legacy API
|
||||||
expect(tweet.retweets).toBeGreaterThan(0);
|
expect(tweet.retweets).toBeGreaterThan(0);
|
||||||
expect(tweet.likes).toBeGreaterThan(0);
|
expect(tweet.likes).toBeGreaterThan(0);
|
||||||
|
// @ts-expect-error twitter_card only in legacy API
|
||||||
expect(tweet.twitter_card).toEqual('tweet');
|
expect(tweet.twitter_card).toEqual('tweet');
|
||||||
expect(tweet.created_at).toEqual('Tue Mar 21 20:50:14 +0000 2006');
|
expect(tweet.created_at).toEqual('Tue Mar 21 20:50:14 +0000 2006');
|
||||||
expect(tweet.created_timestamp).toEqual(1142974214);
|
expect(tweet.created_timestamp).toEqual(1142974214);
|
||||||
|
|
Loading…
Add table
Reference in a new issue