Full-ish implementation

This commit is contained in:
dangered wolf 2022-07-14 04:29:50 -04:00
parent 6094c9df98
commit c0cb1b73d2
No known key found for this signature in database
GPG key ID: 41E4D37680ED8B58
9 changed files with 467 additions and 27 deletions

View file

@ -4,5 +4,6 @@
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 90,
"arrowParens": "avoid"
"arrowParens": "avoid",
"quoteProps": "consistent"
}

View file

@ -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'
};

View file

@ -1,19 +1,42 @@
import { Constants } from '../constants';
export const fetchUsingGuest = async (
screenName: string,
status: string
): Promise<any> => {
const activate = await fetch(`${Constants.TWITTER_ROOT}/1.1/guest/activate.json`, {
export const fetchUsingGuest = async (status: string): Promise<TweetPartial> => {
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;
};

View file

@ -2,10 +2,18 @@ import { Strings } from './strings';
export const Html = {
BASE_HTML: `<!DOCTYPE html>
<!-- 👋 Hello robots and hackers -->
<html {lang}>
<head>
{headers}
</head>
`,
<!-- ███████████ ▐█▌ ███ ███
The best way to embed tweets.
A work in progress by @dangeredwolf
-->
<head>{headers}</head>`,
};

42
src/poll.ts Normal file
View file

@ -0,0 +1,42 @@
const barLength = 30;
export const renderPoll = async (card: TweetCard): Promise<string> => {
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;
}

View file

@ -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);

View file

@ -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<string> => {
const tweet = await fetchUsingGuest(id);
console.log(tweet);
// Try to deep link to mobile apps, just like Twitter does
let headers: string[] = [
`<meta property="og:site_name" content="Twitter" />`,
`<meta property="fb:app_id" content="2231777543"/>`,
`<meta content="twitter://status?id=${id}" property="al:ios:url"/>`,
`<meta content="333903271" property="al:ios:app_store_id"/>`,
`<meta content="Twitter" property="al:ios:app_name"/>`,
`<meta content="twitter://status?id=${id}" property="al:android:url"/>`,
`<meta content="com.twitter.android" property="al:android:package"/>`,
`<meta content="Twitter" property="al:android:app_name"/>`,
];
// Fallback for if Tweet did not load
if (typeof tweet.full_text === "undefined") {
headers.push(
`<meta content="Twitter" property="og:title"/>`,
`<meta content="Tweet failed to load :(" property="og:description"/>`
);
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(
`<meta content="${colorOverride}" property="theme-color"/>`,
`<meta property="og:image" content="${user?.profile_image_url_https}"/>`,
`<meta name="twitter:card" content="tweet"/>`,
`<meta name="twitter:title" content="${name} (@${screenName})"/>`,
`<meta name="twitter:image" content="0"/>`,
`<meta name="twitter:creator" content="@${name}"/>`,
`<meta content="${text}" property="og:description"/>`
);
} 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(
`<meta content="${colorOverride}" property="theme-color"/>`
)
const processMedia = (media: TweetMedia) => {
if (media.type === 'photo') {
headers.push(
`<meta name="twitter:image" content="${media.media_url_https}"/>`
);
if (!pushedCardType) {
headers.push(`<meta name="twitter:card" content="summary_large_image"/>`);
pushedCardType = true;
}
} else if (media.type === 'video') {
headers.push(
`<meta name="twitter:image" content="${media.media_url_https}"/>`
);
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(
`<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.height}"/>`,
`<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.height}"/>`,
`<meta name="og:video:type" content="${bestVariant?.content_type}" />`
);
}
}
// 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(
`<meta content="${name} (@${screenName})" property="og:title"/>`,
`<meta content="${text}" property="og:description"/>`
);
}
if (typeof tweet.in_reply_to_screen_name !== "undefined") {
authorText = `↪️ @${tweet.in_reply_to_screen_name}`;
}
headers.push(`<link rel="alternate" href="https://pxtwitter.com/owoembed?text=${authorText}" type="application/json+oembed" title="${name}">`)
console.log(JSON.stringify(tweet))
return Html.BASE_HTML.format({
lang: `lang="${tweet.lang || 'en'}"`,
headers: headers.join('')
});
};

122
src/tweetTypes.ts Normal file
View file

@ -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;
}
}

9
src/utils.ts Normal file
View file

@ -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)}`;