mirror of
https://github.com/CompeyDev/fxtwitter-docker.git
synced 2025-04-08 03:50:53 +01:00
Genuinely functional API
This commit is contained in:
parent
0e20ea9db4
commit
65e0b775af
6 changed files with 128 additions and 100 deletions
71
src/api.ts
71
src/api.ts
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
34
src/types.d.ts
vendored
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue