From c0cb1b73d232c89ab63adaacc67250fce18229b9 Mon Sep 17 00:00:00 2001 From: dangered wolf Date: Thu, 14 Jul 2022 04:29:50 -0400 Subject: [PATCH] Full-ish implementation --- .prettierrc | 3 +- src/constants.ts | 29 +++++-- src/drivers/guest.ts | 45 ++++++++--- src/html.ts | 18 +++-- src/poll.ts | 42 +++++++++++ src/server.ts | 51 ++++++++++++- src/status.ts | 175 ++++++++++++++++++++++++++++++++++++++++++- src/tweetTypes.ts | 122 ++++++++++++++++++++++++++++++ src/utils.ts | 9 +++ 9 files changed, 467 insertions(+), 27 deletions(-) create mode 100644 src/poll.ts create mode 100644 src/tweetTypes.ts create mode 100644 src/utils.ts diff --git a/.prettierrc b/.prettierrc index 8a563fb..d121ded 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,5 +4,6 @@ "trailingComma": "es5", "tabWidth": 2, "printWidth": 90, - "arrowParens": "avoid" + "arrowParens": "avoid", + "quoteProps": "consistent" } diff --git a/src/constants.ts b/src/constants.ts index 262fed3..83f7a1c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,25 +1,44 @@ const fakeChromeVersion = '103'; export const Constants = { - REDIRECT_URL: 'https://github.com/dangeredwolf', + REDIRECT_URL: 'https://twitter.com/dangeredwolf', TWITTER_ROOT: 'https://twitter.com', + TWITTER_API_ROOT: 'https://api.twitter.com', GUEST_BEARER_TOKEN: `Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA`, + GUEST_FETCH_PARAMETERS: [ + 'cards_platform=Web-12', + 'include_cards=1', + 'include_ext_alt_text=true', + 'include_quote_count=true', + 'include_reply_count=1', + 'tweet_mode=extended', + 'include_ext_media_color=true', + 'include_ext_media_availability=true', + 'include_ext_sensitive_media_warning=true', + 'simple_quoted_tweet=true', + ].join('&'), BASE_HEADERS: { 'sec-ch-ua': `".Not/A)Brand";v="99", "Google Chrome";v="${fakeChromeVersion}", "Chromium";v="${fakeChromeVersion}"`, - DNT: `1`, + 'DNT': `1`, 'x-twitter-client-language': `en`, 'sec-ch-ua-mobile': `?0`, 'content-type': `application/x-www-form-urlencoded`, 'User-Agent': `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${fakeChromeVersion}.0.0.0 Safari/537.36`, 'x-twitter-active-user': `yes`, 'sec-ch-ua-platform': `"Windows"`, - Accept: `*/*`, - Origin: `https://twitter.com`, + 'Accept': `*/*`, + 'Origin': `https://twitter.com`, 'Sec-Fetch-Site': `same-site`, 'Sec-Fetch-Mode': `cors`, 'Sec-Fetch-Dest': `empty`, - Referer: `https://twitter.com/`, + 'Referer': `https://twitter.com/`, 'Accept-Encoding': `gzip, deflate, br`, 'Accept-Language': `en`, }, + RESPONSE_HEADERS: { + 'content-type': 'text/html;charset=UTF-8', + "x-powered-by": 'Black Magic', + // 'cache-control': 'max-age=1' + }, + DEFAULT_COLOR: '#10A3FF' }; diff --git a/src/drivers/guest.ts b/src/drivers/guest.ts index 692db11..d27da3f 100644 --- a/src/drivers/guest.ts +++ b/src/drivers/guest.ts @@ -1,19 +1,42 @@ import { Constants } from '../constants'; -export const fetchUsingGuest = async ( - screenName: string, - status: string -): Promise => { - const activate = await fetch(`${Constants.TWITTER_ROOT}/1.1/guest/activate.json`, { +export const fetchUsingGuest = async (status: string): Promise => { + const csrfToken = crypto.randomUUID().replace(/-/g, ''); // Generate a random CSRF token, this doesn't matter, Twitter just cares that header and cookie match + + let headers: { [header: string]: string } = { + Authorization: Constants.GUEST_BEARER_TOKEN, + ...Constants.BASE_HEADERS, + }; + + const activate = await fetch(`${Constants.TWITTER_API_ROOT}/1.1/guest/activate.json`, { method: 'POST', - headers: { - ...Constants.BASE_HEADERS, - Authorization: `Bearer ${Constants.GUEST_BEARER_TOKEN}`, - }, + headers: headers, body: '', }); - console.log(activate.json()); + const activateJson = (await activate.json()) as { guest_token: string }; + const guestToken = activateJson.guest_token; - return activate.json(); + headers['Cookie'] = `guest_id=v1%3A${guestToken}; ct0=${csrfToken};`; + headers['x-csrf-token'] = csrfToken; + headers['x-twitter-active-user'] = 'yes'; + headers['x-guest-token'] = guestToken; + + const conversation = (await ( + await fetch( + `${Constants.TWITTER_ROOT}/i/api/2/timeline/conversation/${status}.json?${Constants.GUEST_FETCH_PARAMETERS}`, + { + method: 'GET', + headers: headers, + } + ) + ).json()) as TimelineBlobPartial; + + console.log(conversation); + + const tweet = conversation?.globalObjects?.tweets?.[status] || {}; + + tweet.user = conversation?.globalObjects?.users?.[tweet.user_id_str] || {}; + + return tweet; }; diff --git a/src/html.ts b/src/html.ts index 5d56ac0..5b56961 100644 --- a/src/html.ts +++ b/src/html.ts @@ -2,10 +2,18 @@ import { Strings } from './strings'; export const Html = { BASE_HTML: ` - - - {headers} - -`, + +{headers}`, }; diff --git a/src/poll.ts b/src/poll.ts new file mode 100644 index 0000000..6fe533d --- /dev/null +++ b/src/poll.ts @@ -0,0 +1,42 @@ +const barLength = 30; + +export const renderPoll = async (card: TweetCard): Promise => { + let str = '\n\n'; + const values = card.binding_values; + + console.log('rendering poll on ', card); + + let choices: { [label: string]: number } = {}; + let totalVotes = 0; + + if (typeof values !== "undefined" && typeof values.choice1_count !== "undefined" && typeof values.choice2_count !== "undefined") { + choices[values.choice1_label?.string_value || ''] = parseInt(values.choice1_count.string_value); + totalVotes += parseInt(values.choice1_count.string_value); + choices[values.choice2_label?.string_value || ''] = parseInt(values.choice2_count.string_value); + totalVotes += parseInt(values.choice2_count.string_value); + if (typeof values.choice3_count !== "undefined") { + choices[values.choice3_label?.string_value || ''] = parseInt(values.choice3_count.string_value); + totalVotes += parseInt(values.choice3_count.string_value); + } + if (typeof values.choice4_count !== "undefined") { + choices[values.choice4_label?.string_value || ''] = parseInt(values.choice4_count.string_value); + totalVotes += parseInt(values.choice4_count.string_value); + } + } else { + console.log('no choices found', values); + } + console.log(choices); + + for (const [label, votes] of Object.entries(choices)) { + // render bar + const bar = '█'.repeat(Math.floor(votes / totalVotes * barLength)); + str += `${bar} +${label}  (${Math.floor(votes / totalVotes * 100)}%) +`; + } + + str += `\n${totalVotes} votes`; + + console.log(str); + return str; +} \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 11369d8..f7d5559 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,7 @@ import { Router } from 'itty-router'; import { Constants } from './constants'; import { fetchUsingGuest } from './drivers/guest'; +import { handleStatus } from './status'; /* Useful little function to format strings for us @@ -23,10 +24,52 @@ String.prototype.format = function (options: any) { const router = Router(); -router.get('/:handle/status/:id', async (request: any) => { - const { handle, id } = request.params; - return new Response(await fetchUsingGuest(handle, id), { status: 200 }); -}); +const statusRequest = async (request: any) => { + const { handle, id, mediaNumber } = request.params; + const url = new URL(request.url); + const userAgent = request.headers.get('User-Agent'); + + if (userAgent.match(/bot/ig) !== null) { + return new Response(await handleStatus(handle, id, mediaNumber), { + headers: { + 'content-type': 'text/html;charset=UTF-8', + }, + status: 200 + }); + } else { + return Response.redirect(`${Constants.TWITTER_ROOT}${url.pathname}`, 302); + } +} + +router.get('/:handle/status/:id', statusRequest); +router.get('/:handle/status/:id/photo/:mediaNumber', statusRequest); +router.get('/:handle/status/:id/video/:mediaNumber', statusRequest); +router.get('/:handle/statuses/:id', statusRequest); +router.get('/:handle/statuses/:id/photo/:mediaNumber', statusRequest); +router.get('/:handle/statuses/:id/video/:mediaNumber', statusRequest); + +router.get('/owoembed', async (request: any) => { + console.log("THE OWOEMBED HAS BEEN ACCESSED!!!!!!!!!"); + const { searchParams } = new URL(request.url) + + let text = searchParams.get('text') || 'Twitter'; + + const test = { + "author_name":text, + "author_url":"https://twitter.com/AquosTheWolf/status/1547447632284553216", + "provider_name":"pxTwitter", + "provider_url":"https://github.com/dangeredwolf/pxtwitter", + "title":"test", + "type":"link", + "version":"1.0" + } + return new Response(JSON.stringify(test), { + headers: { + 'content-type': 'application/json', + }, + status: 200 + }); +}) router.all('*', async request => { return Response.redirect(Constants.REDIRECT_URL); diff --git a/src/status.ts b/src/status.ts index 2e5fa88..8aea1da 100644 --- a/src/status.ts +++ b/src/status.ts @@ -1 +1,174 @@ -export const handleStatus = (screenName: string, status: number) => {}; +import { Constants } from "./constants"; +import { fetchUsingGuest } from "./drivers/guest"; +import { Html } from "./html"; +import { renderPoll } from "./poll"; +import { rgbToHex } from "./utils"; + +const colorFromPalette = (palette: MediaPlaceholderColor[]) => { + for (let i = 0; i < palette.length; i++) { + const rgb = palette[i].rgb; + + // We need vibrant colors, grey backgrounds won't do! + if (rgb.red + rgb.green + rgb.blue < 120) { + continue; + } + + return rgbToHex(rgb.red, rgb.green, rgb.blue); + } + + return Constants.DEFAULT_COLOR; +} + +export const handleStatus = async (handle: string, id: string, mediaNumber?: number): Promise => { + const tweet = await fetchUsingGuest(id); + console.log(tweet); + + // Try to deep link to mobile apps, just like Twitter does + let headers: string[] = [ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ]; + + // Fallback for if Tweet did not load + if (typeof tweet.full_text === "undefined") { + headers.push( + ``, + `` + ); + + return Html.BASE_HTML.format({ + lang: '', + headers: headers.join(''), + tweet: JSON.stringify(tweet), + }); + } + + let text = tweet.full_text; + const user = tweet.user; + const screenName = user?.screen_name || ''; + const name = user?.name || ''; + + let authorText = 'Twitter'; + + // This is used to chop off the end if it's like pic.twitter.com or something + if (tweet.display_text_range) { + const [start, end] = tweet.display_text_range; + // We ignore start because it cuts off reply handles + text = text.substring(0, end + 1); + } + + if (tweet.card) { + text += await renderPoll(tweet.card); + } + + // Replace t.co links with their full counterparts + if (typeof tweet.entities?.urls !== 'undefined') { + tweet.entities?.urls.forEach((url: TcoExpansion) => { + text = text.replace(url.url, url.expanded_url); + }); + } + + if (typeof tweet.extended_entities?.media === 'undefined' && typeof tweet.entities?.media === 'undefined') { + let palette = user?.profile_image_extensions_media_color?.palette; + let colorOverride: string = Constants.DEFAULT_COLOR; + + // for loop for palettes + if (palette) { + colorOverride = colorFromPalette(palette); + } + + headers.push( + ``, + ``, + ``, + ``, + ``, + ``, + `` + ); + } else { + let media = tweet.extended_entities?.media || tweet.entities?.media || []; + + let firstMedia = media[0]; + + let palette = firstMedia?.ext_media_color?.palette; + let colorOverride: string = Constants.DEFAULT_COLOR; + let pushedCardType = false; + + // for loop for palettes + if (palette) { + colorOverride = colorFromPalette(palette); + } + + headers.push( + `` + ) + + const processMedia = (media: TweetMedia) => { + if (media.type === 'photo') { + headers.push( + `` + ); + + if (!pushedCardType) { + headers.push(``); + pushedCardType = true; + } + } else if (media.type === 'video') { + headers.push( + `` + ); + + 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( + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + `` + ); + } + } + + // You can specify a specific photo in the URL + if (typeof mediaNumber === "number" && media[mediaNumber]) { + processMedia(media[mediaNumber]); + } else { + // I wish Telegram respected multiple photos in a tweet + // media.forEach(media => processMedia(media)); + processMedia(firstMedia); + } + + headers.push( + ``, + `` + ); + } + + if (typeof tweet.in_reply_to_screen_name !== "undefined") { + authorText = `↪️ @${tweet.in_reply_to_screen_name}`; + } + + headers.push(``) + + console.log(JSON.stringify(tweet)) + + return Html.BASE_HTML.format({ + lang: `lang="${tweet.lang || 'en'}"`, + headers: headers.join('') + }); +}; diff --git a/src/tweetTypes.ts b/src/tweetTypes.ts new file mode 100644 index 0000000..c75b2c2 --- /dev/null +++ b/src/tweetTypes.ts @@ -0,0 +1,122 @@ +type TimelineBlobPartial = { + globalObjects: { + tweets: { + [tweetId: string]: TweetPartial; + }; + users: { + [userId: string]: UserPartial; + } + }; +}; + +type TweetMediaSize = { + w: number; + h: number; + resize: 'crop' | 'fit'; +}; + +type TweetMediaFormat = { + bitrate: number; + content_type: string; + url: string; +}; + +type TcoExpansion = { + display_url: string; + expanded_url: string; + indices: [number, number]; + url: string; +}; + +type TweetMedia = { + additional_media_info: { monetizable: boolean }; + display_url: string; + expanded_url: string; + ext_media_color?: { + palette?: MediaPlaceholderColor[] + }; + id_str: string; + indices: [number, number]; + media_key: string; + media_url: string; + media_url_https: string; + original_info: { width: number; height: number }; + sizes: { + thumb: TweetMediaSize; + large: TweetMediaSize; + medium: TweetMediaSize; + small: TweetMediaSize; + }; + type: 'photo' | 'video'; + url: string; + video_info?: { + aspect_ratio: [number, number]; + duration_millis: number; + variants: TweetMediaFormat[]; + }; +}; + +type CardValue = { + type: 'BOOLEAN' | 'STRING', + boolean_value: boolean, + string_value: string, +} + +type TweetCard = { + binding_values: { + card_url: CardValue; + choice1_count?: CardValue, + choice2_count?: CardValue, + choice3_count?: CardValue, + choice4_count?: CardValue, + choice1_label?: CardValue, + choice2_label?: CardValue, + choice3_label?: CardValue, + choice4_label?: CardValue, + counts_are_final?: CardValue, + duration_minutes?: CardValue, + end_datetime_utc?: CardValue, + }, + name: string +} + +type TweetPartial = { + card?: TweetCard; + conversation_id_str: string; + created_at: string; // date string + display_text_range: [number, number]; + entities: { urls?: TcoExpansion[], media?: TweetMedia[] }; + extended_entities: { media?: TweetMedia[] }; + favorite_count: number; + in_reply_to_screen_name?: string; + in_reply_to_status_id_str?: string; + in_reply_to_user_id_str?: string; + id_str: string; + lang: string; + possibly_sensitive_editable: boolean; + retweet_count: number; + quote_count: number; + reply_count: number; + source: string; + full_text: string; + user_id_str: string; + user?: UserPartial; +}; + +type UserPartial = { + id_str: string; + name: string; + screen_name: string; + profile_image_url_https: string; + profile_image_extensions_media_color?: { + palette?: MediaPlaceholderColor[] + }; +} + +type MediaPlaceholderColor = { + rgb: { + red: number; + green: number; + blue: number; + } +} \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..632258b --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,9 @@ +// https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb + +const componentToHex = (component: number) => { + let hex = component.toString(16); + return hex.length === 1 ? "0" + hex : hex; +} + +export const rgbToHex = (r: number, g: number, b: number) => + `#${componentToHex(r)}${componentToHex(g)}${componentToHex(b)}`; \ No newline at end of file