diff --git a/src/constants.ts b/src/constants.ts index 6375f14..de242d8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,6 +2,12 @@ const fakeChromeVersion = '103'; export const Constants = { BRANDING_NAME: `pxTwitter`, + DIRECT_MEDIA_DOMAINS: [ + 'd.pxtwitter.com', + 'd.twittpr.com', + 'dl.pxtwitter.com', + 'dl.twittpr.com' + ], HOST_URL: `https://pxtwitter.com`, REDIRECT_URL: 'https://github.com/dangeredwolf/pxTwitter', TWITTER_ROOT: 'https://twitter.com', diff --git a/src/server.ts b/src/server.ts index d1d53cc..9a6c2c7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,15 +2,27 @@ import { Router } from 'itty-router'; import { Constants } from './constants'; import { handleStatus } from './status'; import { Strings } from './strings'; +import { Flags } from './types'; const router = Router(); -const statusRequest = async (request: any, event: FetchEvent) => { - const { id, mediaNumber } = request.params; +const statusRequest = async (request: any, event: FetchEvent, flags: Flags = {}) => { + const { handle, id, mediaNumber } = request.params; const url = new URL(request.url); const userAgent = request.headers.get('User-Agent'); - if (userAgent.match(/bot|facebook/gi) !== null) { + let isBotUA = userAgent.match(/bot|facebook/gi) !== null; + + if ( + url.pathname.match(/\/status(es)?\/\d+\.(mp4|png|jpg)/g) !== null || + Constants.DIRECT_MEDIA_DOMAINS.includes(url.hostname) + ) { + console.log('Direct media request by extension'); + flags.direct = true; + } + + if (isBotUA || flags.direct) { + console.log('Matched bot UA'); // https://developers.cloudflare.com/workers/examples/cache-api/ const cacheUrl = new URL(request.url); const cacheKey = new Request(cacheUrl.toString(), request); @@ -25,13 +37,26 @@ const statusRequest = async (request: any, event: FetchEvent) => { console.log('Cache miss'); - response = new Response( - await handleStatus(id, parseInt(mediaNumber || 1), userAgent), - { - headers: Constants.RESPONSE_HEADERS, - status: 200 + let status = await handleStatus(id.match(/\d{2,20}/)?.[0], parseInt(mediaNumber || 1), userAgent, flags); + + if (status instanceof Response) { + console.log('handleStatus sent response'); + response = status; + } else { + /* Fallback if a person browses to a direct media link with a Tweet without media */ + if (!isBotUA) { + return Response.redirect(`${Constants.TWITTER_ROOT}/${handle}/status/${id}`, 302); } - ); + console.log('handleStatus sent embed'); + + response = new Response( + status, + { + headers: Constants.RESPONSE_HEADERS, + status: 200 + } + ); + } // Store the fetched response as cacheKey // Use waitUntil so you can return the response without blocking on @@ -40,10 +65,15 @@ const statusRequest = async (request: any, event: FetchEvent) => { return response; } else { - return Response.redirect(`${Constants.TWITTER_ROOT}${url.pathname}`, 302); + console.log('Matched human UA'); + return Response.redirect(`${Constants.TWITTER_ROOT}/${handle}/status/${id}`, 302); } }; +const statusDirectMediaRequest = async (request: any, event: FetchEvent) => { + return await statusRequest(request, event, { direct: true }); +}; + const profileRequest = async (request: any, _event: FetchEvent) => { const { handle } = request.params; const url = new URL(request.url); @@ -55,6 +85,21 @@ const profileRequest = async (request: any, _event: FetchEvent) => { } }; +/* Direct media handlers */ +router.get('/dl/:handle/status/:id', statusDirectMediaRequest); +router.get('/dl/:handle/status/:id/photo/:mediaNumber', statusDirectMediaRequest); +router.get('/dl/:handle/status/:id/video/:mediaNumber', statusDirectMediaRequest); +router.get('/dl/:handle/statuses/:id', statusDirectMediaRequest); +router.get('/dl/:handle/statuses/:id/photo/:mediaNumber', statusDirectMediaRequest); +router.get('/dl/:handle/statuses/:id/video/:mediaNumber', statusDirectMediaRequest); + +router.get('/dir/:handle/status/:id', statusDirectMediaRequest); +router.get('/dir/:handle/status/:id/photo/:mediaNumber', statusDirectMediaRequest); +router.get('/dir/:handle/status/:id/video/:mediaNumber', statusDirectMediaRequest); +router.get('/dir/:handle/statuses/:id', statusDirectMediaRequest); +router.get('/dir/:handle/statuses/:id/photo/:mediaNumber', statusDirectMediaRequest); +router.get('/dir/:handle/statuses/:id/video/:mediaNumber', statusDirectMediaRequest); + /* Handlers for Twitter statuses */ router.get('/:handle/status/:id', statusRequest); router.get('/:handle/status/:id/photo/:mediaNumber', statusRequest); diff --git a/src/status.ts b/src/status.ts index 865cb6e..c800ac6 100644 --- a/src/status.ts +++ b/src/status.ts @@ -6,6 +6,7 @@ import { renderCard } from './card'; import { handleQuote } from './quote'; import { sanitizeText } from './utils'; import { Strings } from './strings'; +import { Flags } from './types'; export const returnError = (error: string) => { return Strings.BASE_HTML.format({ @@ -20,8 +21,10 @@ export const returnError = (error: string) => { export const handleStatus = async ( status: string, mediaNumber?: number, - userAgent?: string -): Promise => { + userAgent?: string, + flags?: Flags +): Promise => { + console.log('Direct?', flags?.direct); const conversation = await fetchUsingGuest(status); const tweet = conversation?.globalObjects?.tweets?.[status] || {}; @@ -42,6 +45,8 @@ export const handleStatus = async ( `` ]; + let redirectMedia = ''; + /* Fallback for if Tweet did not load */ if (typeof tweet.full_text === 'undefined') { console.log('Invalid status, got tweet ', tweet, ' conversation ', conversation); @@ -166,6 +171,11 @@ export const handleStatus = async ( /* 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( ``, `` @@ -185,6 +195,18 @@ export const handleStatus = async ( 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; + } + headers.push(``); if (userAgent && userAgent?.indexOf?.('Discord') > -1) { @@ -193,11 +215,6 @@ export const handleStatus = async ( authorText = encodeURIComponent(text); - // Find the variant with the highest bitrate - let bestVariant = media.video_info?.variants?.reduce?.((a, b) => - (a.bitrate ?? 0) > (b.bitrate ?? 0) ? a : b - ); - headers.push( ``, ``, @@ -234,6 +251,12 @@ export const handleStatus = async ( processMedia(firstMedia); } + if (flags?.direct && redirectMedia) { + let response = Response.redirect(redirectMedia, 302) + console.log(response); + return response; + } + if (mediaList.length > 1) { authorText = `Photo ${actualMediaNumber + 1} of ${mediaList.length}`; headers.push( diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..5b89af4 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,6 @@ +/* tweetTypes has all the Twitter API-related types */ + +export type Flags = { + standard?: boolean; + direct?: boolean; +};