Genuinely functional API

This commit is contained in:
dangered wolf 2022-07-25 16:32:50 -04:00
parent 0e20ea9db4
commit 65e0b775af
No known key found for this signature in database
GPG key ID: 41E4D37680ED8B58
6 changed files with 128 additions and 100 deletions

View file

@ -1,9 +1,36 @@
import { renderCard } from './card'; import { renderCard } from './card';
import { Constants } from './constants';
import { fetchUsingGuest } from './fetch'; import { fetchUsingGuest } from './fetch';
import { linkFixer } from './linkFixer'; import { linkFixer } from './linkFixer';
import { handleMosaic } from './mosaic';
import { colorFromPalette } from './palette'; import { colorFromPalette } from './palette';
import { translateTweet } from './translate'; import { translateTweet } from './translate';
const processMedia = (media: TweetMedia): APIPhoto | APIVideo | null => {
if (media.type === 'photo') {
return {
type: 'photo',
url: media.media_url_https,
width: media.original_info.width,
height: media.original_info.height
}
} else if (media.type === 'video' || media.type === 'animated_gif') {
// Find the variant with the highest bitrate
let bestVariant = media.video_info?.variants?.reduce?.((a, b) =>
(a.bitrate ?? 0) > (b.bitrate ?? 0) ? a : b
);
return {
url: bestVariant?.url || '',
thumbnail_url: media.media_url_https,
width: media.original_info.width,
height: media.original_info.height,
format: bestVariant?.content_type || '',
type: media.type === 'animated_gif' ? 'gif' : 'video'
}
}
return null;
};
const populateTweetProperties = async ( const populateTweetProperties = async (
tweet: TweetPartial, tweet: TweetPartial,
conversation: TimelineBlobPartial, conversation: TimelineBlobPartial,
@ -11,30 +38,62 @@ const populateTweetProperties = async (
): Promise<APITweet> => { ): Promise<APITweet> => {
let apiTweet = {} as APITweet; let apiTweet = {} as APITweet;
const user = tweet.user; const user = tweet.user as UserPartial;
const screenName = user?.screen_name || ''; const screenName = user?.screen_name || '';
const name = user?.name || ''; const name = user?.name || '';
apiTweet.url = `${Constants.TWITTER_ROOT}/${screenName}/status/${tweet.id_str}`;
apiTweet.text = linkFixer(tweet, tweet.full_text); apiTweet.text = linkFixer(tweet, tweet.full_text);
apiTweet.author = { apiTweet.author = {
name: name, name: name,
screen_name: screenName, screen_name: screenName,
avatar_url: user?.profile_image_url_https.replace('_normal', '_200x200') || '', avatar_url: user?.profile_image_url_https.replace('_normal', '_200x200') || '',
avatar_color: (apiTweet.palette = colorFromPalette( avatar_color: colorFromPalette(tweet.user?.profile_image_extensions_media_color?.palette || []),
tweet.user?.profile_image_extensions_media_color?.palette || []
)),
banner_url: user?.profile_banner_url || '' banner_url: user?.profile_banner_url || ''
}; };
apiTweet.replies = tweet.reply_count; apiTweet.replies = tweet.reply_count;
apiTweet.retweets = tweet.retweet_count; apiTweet.retweets = tweet.retweet_count;
apiTweet.likes = tweet.favorite_count; apiTweet.likes = tweet.favorite_count;
apiTweet.palette = colorFromPalette( apiTweet.color = apiTweet.author.avatar_color;
tweet.user?.profile_image_extensions_media_color?.palette || [] apiTweet.twitter_card = 'tweet';
let mediaList = Array.from(
tweet.extended_entities?.media || tweet.entities?.media || []
); );
mediaList.forEach(media => {
let mediaObject = processMedia(media);
console.log('mediaObject', JSON.stringify(mediaObject))
if (mediaObject) {
apiTweet.twitter_card = 'summary_large_image';
if (mediaObject.type === 'photo') {
apiTweet.media = apiTweet.media || {};
apiTweet.media.photos = apiTweet.media.photos || [];
apiTweet.media.photos.push(mediaObject);
console.log('media',apiTweet.media);
} else if (mediaObject.type === 'video' || mediaObject.type === 'gif') {
apiTweet.media = apiTweet.media || {};
apiTweet.media.video = mediaObject as APIVideo;
}
}
})
if (mediaList[0]?.ext_media_color?.palette) {
apiTweet.color = colorFromPalette(mediaList[0].ext_media_color.palette);
}
if (apiTweet.media?.photos?.length || 0 > 1) {
let mosaic = await handleMosaic(apiTweet.media.photos || []);
if (mosaic !== null) {
apiTweet.media.mosaic = mosaic;
}
}
if (tweet.card) { if (tweet.card) {
let card = await renderCard(tweet.card); let card = await renderCard(tweet.card);
if (card.external_media) { if (card.external_media) {
apiTweet.twitter_card = 'summary_large_image';
apiTweet.media = apiTweet.media || {}; apiTweet.media = apiTweet.media || {};
apiTweet.media.external = card.external_media; apiTweet.media.external = card.external_media;
} }

View file

@ -1,9 +1,8 @@
import { Constants } from './constants'; import { Constants } from './constants';
export const handleMosaic = async ( export const handleMosaic = async (
mediaList: TweetMedia[], mediaList: APIPhoto[]
userAgent: string ): Promise<APIMosaicPhoto | null> => {
): Promise<TweetMedia> => {
let mosaicDomains = Constants.MOSAIC_DOMAIN_LIST; let mosaicDomains = Constants.MOSAIC_DOMAIN_LIST;
let selectedDomain: string | null = null; let selectedDomain: string | null = null;
while (selectedDomain === null && mosaicDomains.length > 0) { while (selectedDomain === null && mosaicDomains.length > 0) {
@ -20,41 +19,39 @@ export const handleMosaic = async (
// Fallback if all Mosaic servers are down // Fallback if all Mosaic servers are down
if (selectedDomain === null) { if (selectedDomain === null) {
return mediaList[0]; return null;
} else { } else {
// console.log('mediaList', mediaList); // console.log('mediaList', mediaList);
let mosaicMedia = mediaList.map( let mosaicMedia = mediaList.map(
media => media =>
media.media_url_https?.match(/(?<=\/media\/)[a-zA-Z0-9_\-]+(?=[\.\?])/g)?.[0] || media.url?.match(/(?<=\/media\/)[a-zA-Z0-9_\-]+(?=[\.\?])/g)?.[0] ||
'' ''
); );
// console.log('mosaicMedia', mosaicMedia); // console.log('mosaicMedia', mosaicMedia);
// TODO: use a better system for this, 0 gets png 1 gets webp, usually // TODO: use a better system for this, 0 gets png 1 gets webp, usually
let constructUrl = `https://${selectedDomain}/${ let baseUrl = `https://${selectedDomain}/`;
userAgent.indexOf('Telegram') > -1 ? '0' : '1' let path = '';
}`;
if (mosaicMedia[0]) { if (mosaicMedia[0]) {
constructUrl += `/${mosaicMedia[0]}`; path += `/${mosaicMedia[0]}`;
} }
if (mosaicMedia[1]) { if (mosaicMedia[1]) {
constructUrl += `/${mosaicMedia[1]}`; path += `/${mosaicMedia[1]}`;
} }
if (mosaicMedia[2]) { if (mosaicMedia[2]) {
constructUrl += `/${mosaicMedia[2]}`; path += `/${mosaicMedia[2]}`;
} }
if (mosaicMedia[3]) { if (mosaicMedia[3]) {
constructUrl += `/${mosaicMedia[3]}`; path += `/${mosaicMedia[3]}`;
} }
console.log(`Mosaic URL: ${constructUrl}`);
return { return {
media_url_https: constructUrl, height: mediaList.reduce((acc, media) => acc + media.height, 0),
original_info: { width: mediaList.reduce((acc, media) => acc + media.width, 0),
height: mediaList.reduce((acc, media) => acc + media.original_info?.height, 0), formats: {
width: mediaList.reduce((acc, media) => acc + media.original_info?.width, 0) jpeg: `${baseUrl}jpeg${path}`,
}, webp: `${baseUrl}webp${path}`,
type: 'photo' }
} as TweetMedia; } as APIMosaicPhoto;
} }
}; };

View file

@ -168,15 +168,18 @@ const cacheWrapper = async (event: FetchEvent): Promise<Response> => {
switch (request.method) { switch (request.method) {
case 'GET': case 'GET':
let cachedResponse = await cache.match(cacheKey); if (cacheUrl.hostname !== Constants.API_HOST) {
if (cachedResponse) { let cachedResponse = await cache.match(cacheKey);
console.log('Cache hit');
return cachedResponse; if (cachedResponse) {
console.log('Cache hit');
return cachedResponse;
}
console.log('Cache miss');
} }
console.log('Cache miss');
let response = await router.handle(event.request, event); let response = await router.handle(event.request, event);
// Store the fetched response as cacheKey // Store the fetched response as cacheKey

View file

@ -162,70 +162,7 @@ export const handleStatus = async (
headers.push(`<meta content="${colorOverride}" property="theme-color"/>`); headers.push(`<meta content="${colorOverride}" property="theme-color"/>`);
/* Inline helper function for handling media */ /* Inline helper function for handling media */
const processMedia = (media: TweetMedia) => {
if (media.type === 'photo') {
if (flags?.direct && typeof media.media_url_https === 'string') {
redirectMedia = media.media_url_https;
return;
}
headers.push(
`<meta name="twitter:image" content="${media.media_url_https}"/>`,
`<meta property="og:image" content="${media.media_url_https}"/>`
);
if (media.original_info?.width && media.original_info?.height) {
headers.push(
`<meta name="twitter:image:width" content="${media.original_info.width}"/>`,
`<meta name="twitter:image:height" content="${media.original_info.height}"/>`,
`<meta name="og:image:width" content="${media.original_info.width}"/>`,
`<meta name="og:image:height" content="${media.original_info.height}"/>`
);
}
if (!pushedCardType) {
headers.push(`<meta name="twitter:card" content="summary_large_image"/>`);
pushedCardType = true;
}
} else if (media.type === 'video' || media.type === 'animated_gif') {
// Find the variant with the highest bitrate
let bestVariant = media.video_info?.variants?.reduce?.((a, b) =>
(a.bitrate ?? 0) > (b.bitrate ?? 0) ? a : b
);
if (flags?.direct && bestVariant?.url) {
console.log(`Redirecting to ${bestVariant.url}`);
redirectMedia = bestVariant.url;
return;
}
/* This is for the video thumbnail */
headers.push(`<meta name="twitter:image" content="${media.media_url_https}"/>`);
/* On Discord we have to use the author field in order to get the tweet text
to display on videos. This length is limited, however, and if there is too
much text Discord will refuse to display it at all, so we trim down as much
as the client will display. */
if (userAgent && userAgent?.indexOf?.('Discord') > -1) {
text = text.substr(0, 179);
}
authorText = encodeURIComponent(text);
headers.push(
`<meta name="twitter:card" content="player"/>`,
`<meta name="twitter:player:stream" content="${bestVariant?.url}"/>`,
`<meta name="twitter:player:stream:content_type" content="${bestVariant?.content_type}"/>`,
`<meta name="twitter:player:height" content="${media.original_info.height}"/>`,
`<meta name="twitter:player:width" content="${media.original_info.width}"/>`,
`<meta name="og:video" content="${bestVariant?.url}"/>`,
`<meta name="og:video:secure_url" content="${bestVariant?.url}"/>`,
`<meta name="og:video:height" content="${media.original_info.height}"/>`,
`<meta name="og:video:width" content="${media.original_info.width}"/>`,
`<meta name="og:video:type" content="${bestVariant?.content_type}"/>`
);
}
};
let actualMediaNumber = 0; let actualMediaNumber = 0;
let renderedMosaic = false; let renderedMosaic = false;

View file

@ -85,7 +85,7 @@ type TweetMedia = {
medium: TweetMediaSize; medium: TweetMediaSize;
small: TweetMediaSize; small: TweetMediaSize;
}; };
type: 'photo' | 'video'; type: 'photo' | 'video' | 'animated_gif';
url: string; url: string;
video_info?: { video_info?: {
aspect_ratio: [number, number]; aspect_ratio: [number, number];

34
src/types.d.ts vendored
View file

@ -56,8 +56,35 @@ interface APIPoll {
ends_at: string; ends_at: string;
} }
interface APIPhoto {
type: 'photo';
url: string;
width: number;
height: number;
}
interface APIMosaicPhoto {
type: 'mosaic_photo';
width: number;
height: number;
formats: {
webp: string;
jpeg: string;
}
}
interface APIVideo {
type: 'video' | 'gif';
url: string;
thumbnail_url: string;
width: number;
height: number;
format: string;
}
interface APITweet { interface APITweet {
id: string; id: string;
url: string;
tweet: string; tweet: string;
text?: string; text?: string;
created_at: string; created_at: string;
@ -66,7 +93,7 @@ interface APITweet {
retweets: number; retweets: number;
replies: number; replies: number;
palette: string; color: string;
quote?: APITweet; quote?: APITweet;
poll?: APIPoll; poll?: APIPoll;
@ -75,5 +102,10 @@ interface APITweet {
media: { media: {
external?: APIExternalMedia; external?: APIExternalMedia;
photos?: APIPhoto[];
video?: APIVideo;
mosaic?: APIMosaicPhoto;
}; };
twitter_card: 'tweet' | 'summary' | 'summary_large_image' | 'player';
} }