mirror of
https://github.com/CompeyDev/fxtwitter-docker.git
synced 2025-04-04 18:10:56 +01:00
commit
a2fcea4dc4
7 changed files with 136 additions and 26 deletions
40
README.md
40
README.md
|
@ -30,7 +30,7 @@ If you want to share the results of a Twitter poll, you can do so by just linkin
|
|||
|
||||
## Embed Quote Tweets & Media
|
||||
|
||||
Quote tweets and their media can provide important context to a Tweet. So we'll automatically add said context, and even media if there isn't already media embedded in the quote.
|
||||
Quote tweets and their media can provide important context to a Tweet. So we'll automatically add said context, and even media if there isn't already media embedded in the quote.
|
||||
|
||||

|
||||
|
||||
|
@ -60,11 +60,11 @@ Otherwise, it will default to the first image.
|
|||
|
||||
## Replace t.co shorteners with original link
|
||||
|
||||
The default Twitter embeds include t.co link shorteners, which make it difficult to know where the link is heading. We automatically replace t.co links with their original links to make things clearer.
|
||||
The default Twitter embeds include t.co link shorteners, which make it difficult to know where the link is heading. We automatically replace t.co links with their original links to make things clearer.
|
||||
|
||||
## Color-matched embeds on Discord
|
||||
|
||||
We use Twitter's color data for either the first image/video of the tweet, or the author's profile picture. It makes the embed's appearance more *aesthetic*, as well as in line with the content of the Tweet.
|
||||
We use Twitter's color data for either the first image/video of the tweet, or the author's profile picture. It makes the embed's appearance more _aesthetic_, as well as in line with the content of the Tweet.
|
||||
|
||||
## Built with privacy in mind
|
||||
|
||||
|
@ -78,22 +78,22 @@ Furthermore, if the person who posted a pxTwitter link forgot to strip tracking,
|
|||
|
||||
In many ways, pxTwitter has richer embeds and does more. Here's a table comparing some of pxTwitter's features compared to Twitter default embeds as well as other embedding services
|
||||
|
||||
| | pxTwitter | Twitter default | vxTwitter (BetterTwitFix) | Twxtter (sixFix) |
|
||||
| --------------------------------------- | :--------------------------------------: | :---------------------------------------: | :-----------------------------------: | :-----------------------------------: |
|
||||
| Embed Tweets / Images | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Embed profile pictures on text Tweets | :heavy_check_mark: | :x: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Embed Twitter Videos | :heavy_check_mark: | :x:¹ | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Embed External Videos (YouTube, etc.) | :heavy_check_mark:⁶ | :x: | :x:⁴ | :x: |
|
||||
| Embed Poll results | :heavy_check_mark: | :x: | :x: | :x: |
|
||||
| Embed Quote Tweets | :heavy_check_mark: | :x: | :ballot_box_with_check: Without Media | :ballot_box_with_check: Without Media |
|
||||
| Embed Multiple Images | :ballot_box_with_check: Except Telegram⁵ | :heavy_minus_sign: Discord Only³ | :ballot_box_with_check: With c.vxtwitter.com | :x: |
|
||||
| Publicly accessible embed index | :x:² | N/A | :x:² | :heavy_check_mark: |
|
||||
| Replace t.co with original links | :heavy_check_mark: | :x: | :x: | :x: |
|
||||
| Media-based embed colors on Discord | :heavy_check_mark: | :x: | :x: | :x: |
|
||||
| Redirect to media file (wihout embed) | :heavy_check_mark: | :x: | :x: | :heavy_check_mark: |
|
||||
| Strip Twitter tracking info on redirect | :heavy_check_mark: | :x: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Show retweet, like, reply counts | :heavy_check_mark: | :heavy_minus_sign: Discord Only³ | :ballot_box_with_check: No replies | :ballot_box_with_check: No replies |
|
||||
| Discord sed replace (`s/`) friendly | twittpr.com | N/A | :x: | :heavy_check_mark: |
|
||||
| | pxTwitter | Twitter default | vxTwitter (BetterTwitFix) | Twxtter (sixFix) |
|
||||
| --------------------------------------- | :--------------------------------------: | :------------------------------: | :------------------------------------------: | :-----------------------------------: |
|
||||
| Embed Tweets / Images | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Embed profile pictures on text Tweets | :heavy_check_mark: | :x: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Embed Twitter Videos | :heavy_check_mark: | :x:¹ | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Embed External Videos (YouTube, etc.) | :heavy_check_mark:⁶ | :x: | :x:⁴ | :x: |
|
||||
| Embed Poll results | :heavy_check_mark: | :x: | :x: | :x: |
|
||||
| Embed Quote Tweets | :heavy_check_mark: | :x: | :ballot_box_with_check: Without Media | :ballot_box_with_check: Without Media |
|
||||
| Embed Multiple Images | :ballot_box_with_check: Except Telegram⁵ | :heavy_minus_sign: Discord Only³ | :ballot_box_with_check: With c.vxtwitter.com | :x: |
|
||||
| Publicly accessible embed index | :x:² | N/A | :x:² | :heavy_check_mark: |
|
||||
| Replace t.co with original links | :heavy_check_mark: | :x: | :x: | :x: |
|
||||
| Media-based embed colors on Discord | :heavy_check_mark: | :x: | :x: | :x: |
|
||||
| Redirect to media file (wihout embed) | :heavy_check_mark: | :x: | :x: | :heavy_check_mark: |
|
||||
| Strip Twitter tracking info on redirect | :heavy_check_mark: | :x: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Show retweet, like, reply counts | :heavy_check_mark: | :heavy_minus_sign: Discord Only³ | :ballot_box_with_check: No replies | :ballot_box_with_check: No replies |
|
||||
| Discord sed replace (`s/`) friendly | twittpr.com | N/A | :x: | :heavy_check_mark: |
|
||||
|
||||
¹ Discord will attempt to embed Twitter's video player, but it is unreliable
|
||||
|
||||
|
@ -131,7 +131,7 @@ Once you're set up with your worker on `*.workers.dev`, [add your worker to your
|
|||
|
||||
### Things to tackle in the future
|
||||
|
||||
- Returning JPG with multi-image for Telegram as it doesn't support WebP in embeds for some reason
|
||||
- Returning JPG with multi-image for Telegram as it doesn't support WebP in embeds for some reason
|
||||
- Feature: Translating Tweets within pxTwitter
|
||||
|
||||
### Bugs or issues?
|
||||
|
|
|
@ -120,7 +120,7 @@ export const fetchUsingGuest = async (
|
|||
|
||||
/* Once we've confirmed we have a working guest token, let's cache it! */
|
||||
// event.waitUntil(cache.put(guestTokenRequest, activate.clone()));
|
||||
|
||||
conversation.guestToken = guestToken;
|
||||
return conversation;
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ const statusRequest = async (
|
|||
event: FetchEvent,
|
||||
flags: InputFlags = {}
|
||||
) => {
|
||||
const { handle, id, mediaNumber } = request.params;
|
||||
const { handle, id, mediaNumber, language } = request.params;
|
||||
const url = new URL(request.url);
|
||||
const userAgent = request.headers.get('User-Agent') || '';
|
||||
|
||||
|
@ -35,7 +35,8 @@ const statusRequest = async (
|
|||
id?.match(/\d{2,20}/)?.[0] || '0',
|
||||
mediaNumber ? parseInt(mediaNumber) : undefined,
|
||||
userAgent,
|
||||
flags
|
||||
flags,
|
||||
language
|
||||
);
|
||||
|
||||
if (statusResponse.response) {
|
||||
|
@ -84,25 +85,33 @@ const profileRequest = async (request: Request, _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/photos/: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/photos/: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/photos/: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/photos/: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);
|
||||
router.get('/:handle/status/:id/photos/: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/photos/:mediaNumber', statusRequest);
|
||||
router.get('/:handle/statuses/:id/video/:mediaNumber', statusRequest);
|
||||
router.get('/:handle/status/:id/:language', statusRequest);
|
||||
router.get('/:handle/statuses/:id/:language', statusRequest);
|
||||
|
||||
router.get('/owoembed', async (request: Request) => {
|
||||
console.log('oembed hit!');
|
||||
|
|
|
@ -7,6 +7,7 @@ import { handleQuote } from './quote';
|
|||
import { sanitizeText } from './utils';
|
||||
import { Strings } from './strings';
|
||||
import { handleMosaic } from './mosaic';
|
||||
import { translateTweet } from './translate';
|
||||
|
||||
export const returnError = (error: string): StatusResponse => {
|
||||
return {
|
||||
|
@ -25,7 +26,8 @@ export const handleStatus = async (
|
|||
status: string,
|
||||
mediaNumber?: number,
|
||||
userAgent?: string,
|
||||
flags?: InputFlags
|
||||
flags?: InputFlags,
|
||||
language?: string
|
||||
): Promise<StatusResponse> => {
|
||||
console.log('Direct?', flags?.direct);
|
||||
const conversation = await fetchUsingGuest(status, event);
|
||||
|
@ -79,6 +81,13 @@ export const handleStatus = async (
|
|||
const screenName = user?.screen_name || '';
|
||||
const name = user?.name || '';
|
||||
|
||||
if (
|
||||
(typeof language === 'string' && language.length === 2) ||
|
||||
(tweet.lang !== 'en' && tweet.lang !== 'unk')
|
||||
) {
|
||||
text = await translateTweet(tweet, conversation.guestToken || '', language || 'en');
|
||||
}
|
||||
|
||||
let mediaList = Array.from(
|
||||
tweet.extended_entities?.media || tweet.entities?.media || []
|
||||
);
|
||||
|
|
|
@ -37,6 +37,8 @@ export const Strings = {
|
|||
DEFAULT_AUTHOR_TEXT: 'Twitter',
|
||||
|
||||
QUOTE_TEXT: `═ ↘️ Quoting {name} (@{screen_name}) ═════`,
|
||||
TRANSLATE_TEXT: `═ ↘️ Translated from {language} ═════`,
|
||||
TRANSLATE_TEXT_INTL: `═ ↘️ {source} -> {destination} ═════`,
|
||||
PHOTO_COUNT: `Photo {number} of {total}`,
|
||||
|
||||
SINGULAR_DAY_LEFT: 'day left',
|
||||
|
|
73
src/translate.ts
Normal file
73
src/translate.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { Constants } from './constants';
|
||||
import { linkFixer } from './linkFixer';
|
||||
import { Strings } from './strings';
|
||||
|
||||
export const translateTweet = async (
|
||||
tweet: TweetPartial,
|
||||
guestToken: string,
|
||||
language: string
|
||||
): Promise<string> => {
|
||||
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,
|
||||
'Cookie': [
|
||||
`guest_id_ads=v1%3A${guestToken}`,
|
||||
`guest_id_marketing=v1%3A${guestToken}`,
|
||||
`guest_id=v1%3A${guestToken}`,
|
||||
`ct0=${csrfToken};`
|
||||
].join('; '),
|
||||
'x-csrf-token': csrfToken,
|
||||
'x-twitter-active-user': 'yes',
|
||||
'x-guest-token': guestToken
|
||||
};
|
||||
|
||||
let apiRequest;
|
||||
let translationResults: TranslationPartial;
|
||||
let resultText = tweet.full_text;
|
||||
|
||||
headers['x-twitter-client-language'] = language;
|
||||
|
||||
try {
|
||||
apiRequest = await fetch(
|
||||
`${Constants.TWITTER_ROOT}/i/api/1.1/strato/column/None/tweetId=${tweet.id_str},destinationLanguage=None,translationSource=Some(Google),feature=None,timeout=None,onlyCached=None/translation/service/translateTweet`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: headers
|
||||
}
|
||||
);
|
||||
translationResults = (await apiRequest.json()) as TranslationPartial;
|
||||
|
||||
console.log(translationResults);
|
||||
|
||||
if (
|
||||
translationResults.sourceLanguage === translationResults.destinationLanguage ||
|
||||
translationResults.translationState !== 'Success'
|
||||
) {
|
||||
return tweet.full_text; // No work to do
|
||||
}
|
||||
|
||||
console.log(`Twitter interpreted language as ${tweet.lang}`);
|
||||
|
||||
let formatText =
|
||||
language === 'en'
|
||||
? Strings.TRANSLATE_TEXT.format({
|
||||
language: translationResults.localizedSourceLanguage
|
||||
})
|
||||
: Strings.TRANSLATE_TEXT_INTL.format({
|
||||
source: translationResults.sourceLanguage.toUpperCase(),
|
||||
destination: translationResults.destinationLanguage.toUpperCase()
|
||||
});
|
||||
|
||||
resultText =
|
||||
`${translationResults.translation}\n\n` +
|
||||
`${formatText}\n\n` +
|
||||
`${tweet.full_text}`;
|
||||
} catch (e: any) {
|
||||
console.error('Unknown error while fetching from Translation API');
|
||||
return tweet.full_text; // No work to do
|
||||
}
|
||||
|
||||
return linkFixer(tweet, resultText);
|
||||
};
|
|
@ -44,6 +44,7 @@ type TimelineBlobPartial = {
|
|||
instructions: TimelineInstruction[];
|
||||
};
|
||||
errors?: TwitterAPIError[];
|
||||
guestToken?: string;
|
||||
};
|
||||
|
||||
type TweetMediaSize = {
|
||||
|
@ -122,13 +123,18 @@ type TweetCard = {
|
|||
name: string;
|
||||
};
|
||||
|
||||
type TweetEntities = {
|
||||
urls?: TcoExpansion[];
|
||||
media?: TweetMedia[];
|
||||
};
|
||||
|
||||
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[] };
|
||||
entities: TweetEntities;
|
||||
extended_entities: TweetEntities;
|
||||
favorite_count: number;
|
||||
in_reply_to_screen_name?: string;
|
||||
in_reply_to_status_id_str?: string;
|
||||
|
@ -163,3 +169,14 @@ type MediaPlaceholderColor = {
|
|||
blue: number;
|
||||
};
|
||||
};
|
||||
|
||||
type TranslationPartial = {
|
||||
id_str: string;
|
||||
translationState: 'Success'; // TODO: figure out other values
|
||||
sourceLanguage: string;
|
||||
localizedSourceLanguage: string;
|
||||
destinationLanguage: string;
|
||||
translationSource: 'Google';
|
||||
translation: string;
|
||||
entities: TweetEntities;
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue