mirror of
https://github.com/CompeyDev/fxtwitter-docker.git
synced 2025-04-04 10:00:55 +01:00
✨ Added support for fetching user profiles
This commit is contained in:
parent
2d0faced46
commit
e7d8933056
9 changed files with 445 additions and 9 deletions
|
@ -187,7 +187,7 @@ export const statusAPI = async (
|
|||
language: string | undefined,
|
||||
event: FetchEvent,
|
||||
flags?: InputFlags
|
||||
): Promise<APIResponse> => {
|
||||
): Promise<TweetAPIResponse> => {
|
||||
let conversation = await fetchConversation(status, event);
|
||||
let tweet = conversation?.globalObjects?.tweets?.[status] || {};
|
||||
|
||||
|
@ -242,7 +242,7 @@ export const statusAPI = async (
|
|||
}
|
||||
|
||||
/* Creating the response objects */
|
||||
const response: APIResponse = { code: 200, message: 'OK' } as APIResponse;
|
||||
const response: TweetAPIResponse = { code: 200, message: 'OK' } as TweetAPIResponse;
|
||||
const apiTweet: APITweet = (await populateTweetProperties(
|
||||
tweet,
|
||||
conversation,
|
||||
|
|
91
src/api/user.ts
Normal file
91
src/api/user.ts
Normal file
|
@ -0,0 +1,91 @@
|
|||
import { renderCard } from '../helpers/card';
|
||||
import { Constants } from '../constants';
|
||||
import { fetchConversation, fetchUser } from '../fetch';
|
||||
import { linkFixer } from '../helpers/linkFixer';
|
||||
import { handleMosaic } from '../helpers/mosaic';
|
||||
import { colorFromPalette } from '../helpers/palette';
|
||||
import { translateTweet } from '../helpers/translate';
|
||||
import { unescapeText } from '../helpers/utils';
|
||||
import { processMedia } from '../helpers/media';
|
||||
|
||||
/* This function does the heavy lifting of processing data from Twitter API
|
||||
and using it to create FixTweet's streamlined API responses */
|
||||
const populateUserProperties = async (
|
||||
response: GraphQLUserResponse,
|
||||
language: string | undefined
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): Promise<APIUser> => {
|
||||
const apiUser = {} as APIUser;
|
||||
|
||||
const user = response.data.user.result;
|
||||
/* Populating a lot of the basics */
|
||||
apiUser.url = `${Constants.TWITTER_ROOT}/${user.legacy.screen_name}`;
|
||||
apiUser.id = user.id;
|
||||
apiUser.followers = user.legacy.followers_count;
|
||||
apiUser.following = user.legacy.friends_count;
|
||||
apiUser.likes = user.legacy.favourites_count;
|
||||
apiUser.tweets = user.legacy.statuses_count;
|
||||
apiUser.name = user.legacy.name;
|
||||
apiUser.screen_name = user.legacy.screen_name;
|
||||
apiUser.description = user.legacy.description;
|
||||
apiUser.location = user.legacy.location;
|
||||
apiUser.verified = user.legacy.verified;
|
||||
apiUser.avatar_url = user.legacy.profile_image_url_https;
|
||||
|
||||
return apiUser;
|
||||
};
|
||||
|
||||
const writeDataPoint = (
|
||||
event: FetchEvent,
|
||||
language: string | undefined,
|
||||
returnCode: string,
|
||||
flags?: InputFlags
|
||||
) => {
|
||||
console.log('Writing data point...');
|
||||
if (typeof AnalyticsEngine !== 'undefined') {
|
||||
const flagString =
|
||||
Object.keys(flags || {})
|
||||
// @ts-expect-error - TypeScript doesn't like iterating over the keys, but that's OK
|
||||
.filter(flag => flags?.[flag])[0] || 'standard';
|
||||
|
||||
AnalyticsEngine.writeDataPoint({
|
||||
blobs: [
|
||||
event.request.cf?.colo as string /* Datacenter location */,
|
||||
event.request.cf?.country as string /* Country code */,
|
||||
event.request.headers.get('user-agent') ??
|
||||
'' /* User agent (for aggregating bots calling) */,
|
||||
returnCode /* Return code */,
|
||||
flagString /* Type of request */,
|
||||
language ?? '' /* For translate feature */
|
||||
],
|
||||
doubles: [0 /* NSFW media = 1, No NSFW Media = 0 */],
|
||||
indexes: [event.request.headers.get('cf-ray') ?? '' /* CF Ray */]
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/* API for Twitter profiles (Users)
|
||||
Used internally by FixTweet's embed service, or
|
||||
available for free using api.fxtwitter.com. */
|
||||
export const userAPI = async (
|
||||
username: string,
|
||||
language: string | undefined,
|
||||
event: FetchEvent,
|
||||
flags?: InputFlags
|
||||
): Promise<UserAPIResponse> => {
|
||||
const userResponse = await fetchUser(username, event);
|
||||
|
||||
/* Creating the response objects */
|
||||
const response: UserAPIResponse = { code: 200, message: 'OK' } as UserAPIResponse;
|
||||
const apiTweet: APIUser = (await populateUserProperties(
|
||||
userResponse,
|
||||
language
|
||||
)) as APIUser;
|
||||
|
||||
/* Finally, staple the User to the response and return it */
|
||||
response.user = apiTweet;
|
||||
|
||||
writeDataPoint(event, language, 'OK', flags);
|
||||
|
||||
return response;
|
||||
};
|
37
src/fetch.ts
37
src/fetch.ts
|
@ -203,3 +203,40 @@ export const fetchConversation = async (
|
|||
}
|
||||
)) as TimelineBlobPartial;
|
||||
};
|
||||
|
||||
export const fetchUser = async (
|
||||
username: string,
|
||||
event: FetchEvent,
|
||||
useElongator = false
|
||||
): Promise<GraphQLUserResponse> => {
|
||||
return (await twitterFetch(
|
||||
`${Constants.TWITTER_ROOT}/i/api/graphql/sLVLhk0bGj3MVFEKTdax1w/UserByScreenName?variables=${
|
||||
encodeURIComponent(
|
||||
JSON.stringify({
|
||||
screen_name: username,
|
||||
withSafetyModeUserFields: true
|
||||
})
|
||||
)
|
||||
}&features=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
blue_business_profile_image_shape_enabled: true,
|
||||
responsive_web_graphql_exclude_directive_enabled: true,
|
||||
verified_phone_label_enabled: true
|
||||
})
|
||||
)}`,
|
||||
event,
|
||||
useElongator,
|
||||
// Validator function
|
||||
(_res: unknown) => {
|
||||
const response = _res as GraphQLUserResponse;
|
||||
return !(response?.data?.user?.result?.__typename !== 'User' || typeof response.data.user.result.legacy === 'undefined');
|
||||
/*
|
||||
return !(
|
||||
typeof conversation.globalObjects === 'undefined' &&
|
||||
(typeof conversation.errors === 'undefined' ||
|
||||
conversation.errors?.[0]?.code === 239)
|
||||
);
|
||||
*/
|
||||
}
|
||||
)) as GraphQLUserResponse;
|
||||
};
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Strings } from './strings';
|
|||
|
||||
import motd from '../motd.json';
|
||||
import { sanitizeText } from './helpers/utils';
|
||||
import { handleProfile } from './user';
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
@ -140,18 +141,100 @@ const statusRequest = async (
|
|||
}
|
||||
};
|
||||
|
||||
/* Redirects to user profile when linked.
|
||||
We don't do any fancy special embeds yet, just Twitter default embeds. */
|
||||
const profileRequest = async (request: IRequest) => {
|
||||
const { handle } = request.params;
|
||||
/* Handler for User Profiles */
|
||||
const profileRequest = async (request: IRequest, event: FetchEvent,
|
||||
flags: InputFlags = {}) => {
|
||||
const { handle, language } = request.params;
|
||||
const url = new URL(request.url);
|
||||
const userAgent = request.headers.get('User-Agent') || '';
|
||||
|
||||
/* User Agent matching for embed generators, bots, crawlers, and other automated
|
||||
tools. It's pretty all-encompassing. Note that Firefox/92 is in here because
|
||||
Discord sometimes uses the following UA:
|
||||
|
||||
Mozilla/5.0 (Macintosh; Intel Mac OS X 11.6; rv:92.0) Gecko/20100101 Firefox/92.0
|
||||
|
||||
I'm not sure why that specific one, it's pretty weird, but this edge case ensures
|
||||
stuff keeps working.
|
||||
|
||||
On the very rare off chance someone happens to be using specifically Firefox 92,
|
||||
the http-equiv="refresh" meta tag will ensure an actual human is sent to the destination. */
|
||||
const isBotUA = userAgent.match(Constants.BOT_UA_REGEX) !== null;
|
||||
|
||||
/* If not a valid screen name, we redirect to project GitHub */
|
||||
if (handle.match(/\w{1,15}/gi)?.[0] !== handle) {
|
||||
return Response.redirect(Constants.REDIRECT_URL, 302);
|
||||
} else {
|
||||
return Response.redirect(`${Constants.TWITTER_ROOT}${url.pathname}`, 302);
|
||||
}
|
||||
const username = handle.match(/\w{1,15}/gi)?.[0];
|
||||
/* Check if request is to api.fxtwitter.com, or the tweet is appended with .json
|
||||
Note that unlike TwitFix, FixTweet will never generate embeds for .json, and
|
||||
in fact we only support .json because it's what people using TwitFix API would
|
||||
be used to. */
|
||||
if (
|
||||
url.pathname.match(/\/status(es)?\/\d{2,20}\.(json)/g) !== null ||
|
||||
Constants.API_HOST_LIST.includes(url.hostname)
|
||||
) {
|
||||
console.log('JSON API request');
|
||||
flags.api = true;
|
||||
}
|
||||
|
||||
/* Direct media or API access bypasses bot check, returning same response regardless of UA */
|
||||
if (isBotUA || flags.direct || flags.api) {
|
||||
if (isBotUA) {
|
||||
console.log(`Matched bot UA ${userAgent}`);
|
||||
} else {
|
||||
console.log('Bypass bot check');
|
||||
}
|
||||
|
||||
/* This throws the necessary data to handleStatus (in status.ts) */
|
||||
const profileResponse = await handleProfile(
|
||||
handle.match(/\w{1,15}/gi)?.[0] || '',
|
||||
userAgent,
|
||||
flags,
|
||||
language,
|
||||
event
|
||||
);
|
||||
|
||||
/* Complete responses are normally sent just by errors. Normal embeds send a `text` value. */
|
||||
if (profileResponse.response) {
|
||||
console.log('handleProfile sent response');
|
||||
return profileResponse.response;
|
||||
} else if (profileResponse.text) {
|
||||
console.log('handleProfile sent embed');
|
||||
/* TODO This check has purpose in the original handleStatus handler, but I'm not sure if this edge case can happen here */
|
||||
if (!isBotUA) {
|
||||
return Response.redirect(`${Constants.TWITTER_ROOT}/${handle}`, 302);
|
||||
}
|
||||
|
||||
let headers = Constants.RESPONSE_HEADERS;
|
||||
|
||||
if (profileResponse.cacheControl) {
|
||||
headers = { ...headers, 'cache-control': profileResponse.cacheControl };
|
||||
}
|
||||
|
||||
/* Return the response containing embed information */
|
||||
return new Response(profileResponse.text, {
|
||||
headers: headers,
|
||||
status: 200
|
||||
});
|
||||
} else {
|
||||
/* Somehow handleStatus sent us nothing. This should *never* happen, but we have a case for it. */
|
||||
return new Response(Strings.ERROR_UNKNOWN, {
|
||||
headers: Constants.RESPONSE_HEADERS,
|
||||
status: 500
|
||||
});
|
||||
}
|
||||
} else {
|
||||
/* A human has clicked a fxtwitter.com/:screen_name link!
|
||||
Obviously we just need to redirect to the user directly.*/
|
||||
console.log('Matched human UA', userAgent);
|
||||
return Response.redirect(
|
||||
`${Constants.TWITTER_ROOT}/${handle}`,
|
||||
302
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
const genericTwitterRedirect = async (request: IRequest) => {
|
||||
|
|
|
@ -148,6 +148,7 @@ This is caused by Twitter API downtime or a new bug. Try again in a little while
|
|||
ERROR_API_FAIL: 'Tweet failed to load due to an API error :(',
|
||||
ERROR_PRIVATE: `Due to Twitter API changes, some NSFW Tweets are currently being blocked. We are currently looking into a workaround. 🙏`,
|
||||
ERROR_TWEET_NOT_FOUND: `Sorry, that Tweet doesn't exist :(`,
|
||||
ERROR_USER_NOT_FOUND: `Sorry, that Tweet doesn't exist :(`,
|
||||
ERROR_UNKNOWN: `Unknown error occurred, sorry about that :(`,
|
||||
|
||||
TWITFIX_API_SUNSET: `The original TwitFix API has been sunset. To learn more about the FixTweet API, check out <a href="https://${
|
||||
|
|
97
src/types/twitterTypes.d.ts
vendored
97
src/types/twitterTypes.d.ts
vendored
|
@ -192,3 +192,100 @@ type TranslationPartial = {
|
|||
translation: string;
|
||||
entities: TweetEntities;
|
||||
};
|
||||
|
||||
type GraphQLUserResponse = {
|
||||
data: {
|
||||
user: {
|
||||
result: GraphQLUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type GraphQLUser = {
|
||||
__typename: "User",
|
||||
id: string; // "VXNlcjozNzg0MTMxMzIy",
|
||||
"rest_id": string; // "3784131322",
|
||||
"affiliates_highlighted_label": Record<string, unknown>; // {},
|
||||
"is_blue_verified": boolean; // false,
|
||||
"profile_image_shape": 'Circle' | 'Square'; // "Circle",
|
||||
"legacy": {
|
||||
"created_at": string; // "Sat Sep 26 17:20:55 +0000 2015",
|
||||
"default_profile": boolean // false,
|
||||
"default_profile_image": boolean // false,
|
||||
"description": string; // "dangered wolf#3621 https://t.co/eBTS4kksMw",
|
||||
"entities": {
|
||||
"description": {
|
||||
"urls": {
|
||||
"display_url": string; // "t.me/dangeredwolf",
|
||||
"expanded_url": string; // "http://t.me/dangeredwolf",
|
||||
"url": string; // "https://t.co/eBTS4kksMw",
|
||||
"indices": [
|
||||
19,
|
||||
42
|
||||
]
|
||||
}[]
|
||||
}
|
||||
},
|
||||
"fast_followers_count": 0,
|
||||
"favourites_count": number; // 126708,
|
||||
"followers_count": number; // 4996,
|
||||
"friends_count": number; // 2125,
|
||||
"has_custom_timelines": boolean; // true,
|
||||
"is_translator": boolean; // false,
|
||||
"listed_count": number; // 69,
|
||||
"location": string; // "they/them",
|
||||
"media_count": number; // 20839,
|
||||
"name": string; // "dangered wolf",
|
||||
"normal_followers_count": number; // 4996,
|
||||
"pinned_tweet_ids_str": string[]; // Array of tweet ids
|
||||
"possibly_sensitive": boolean; // false,
|
||||
"profile_banner_url": string; // "https://pbs.twimg.com/profile_banners/3784131322/1658599775",
|
||||
"profile_image_url_https": string; // "https://pbs.twimg.com/profile_images/1555638673705783299/3gaaetxC_normal.jpg",
|
||||
"profile_interstitial_type": string; // "",
|
||||
"screen_name": string; // "dangeredwolf",
|
||||
"statuses_count": number; // 108222,
|
||||
"translator_type": string; // "regular",
|
||||
"verified": boolean; // false,
|
||||
"withheld_in_countries": []
|
||||
},
|
||||
"professional": {
|
||||
"rest_id": string; // "1508134739420536845",
|
||||
"professional_type": string; // "Creator",
|
||||
"category": [
|
||||
{
|
||||
"id": number; // 354,
|
||||
"name": string // "Fish & Chips Restaurant",
|
||||
"icon_name": string; // "IconBriefcaseStroke"
|
||||
}
|
||||
]
|
||||
},
|
||||
"legacy_extended_profile": {
|
||||
birthdate?: {
|
||||
day: number; // 7,
|
||||
month: number; // 1,
|
||||
visibility: string; // "Public"
|
||||
year: number; // 2000
|
||||
year_visibility: string; // "Public"
|
||||
};
|
||||
profile_image_shape: string; // "Circle",
|
||||
rest_id: string; // "3784131322",
|
||||
},
|
||||
"is_profile_translatable": false,
|
||||
"verification_info": {
|
||||
reason: {
|
||||
description: {
|
||||
entities: {
|
||||
from_index: number; // 98,
|
||||
ref: {
|
||||
url: string; // "https://help.twitter.com/managing-your-account/about-twitter-verified-accounts",
|
||||
url_type: string; // "ExternalUrl"
|
||||
};
|
||||
to_index: number; // 108
|
||||
}[];
|
||||
text: string; // "This account is verified because it’s subscribed to Twitter Blue or is a legacy verified account. Learn more"
|
||||
}
|
||||
}
|
||||
},
|
||||
"business_account": {}
|
||||
|
||||
}
|
27
src/types/types.d.ts
vendored
27
src/types/types.d.ts
vendored
|
@ -40,12 +40,18 @@ interface VerticalSize {
|
|||
secondHeight: number;
|
||||
}
|
||||
|
||||
interface APIResponse {
|
||||
interface TweetAPIResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
tweet?: APITweet;
|
||||
}
|
||||
|
||||
interface UserAPIResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
user?: APIUser;
|
||||
}
|
||||
|
||||
interface APITranslate {
|
||||
text: string;
|
||||
source_lang: string;
|
||||
|
@ -141,3 +147,22 @@ interface APITweet {
|
|||
|
||||
twitter_card: 'tweet' | 'summary' | 'summary_large_image' | 'player';
|
||||
}
|
||||
|
||||
interface APIUser {
|
||||
id: string;
|
||||
name: string;
|
||||
screen_name: string;
|
||||
avatar_url: string;
|
||||
banner_url: string;
|
||||
avatar_color: string;
|
||||
description: string;
|
||||
location: string;
|
||||
url: string;
|
||||
protected: boolean;
|
||||
verified: boolean;
|
||||
followers: number;
|
||||
following: number;
|
||||
tweets: number;
|
||||
likes: number;
|
||||
joined: string;
|
||||
}
|
53
src/user.ts
Normal file
53
src/user.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { Constants } from './constants';
|
||||
import { handleQuote } from './helpers/quote';
|
||||
import { formatNumber, sanitizeText } from './helpers/utils';
|
||||
import { Strings } from './strings';
|
||||
import { getAuthorText } from './helpers/author';
|
||||
import { userAPI } from './api/user';
|
||||
|
||||
export const returnError = (error: string): StatusResponse => {
|
||||
return {
|
||||
text: Strings.BASE_HTML.format({
|
||||
lang: '',
|
||||
headers: [
|
||||
`<meta property="og:title" content="${Constants.BRANDING_NAME}"/>`,
|
||||
`<meta property="og:description" content="${error}"/>`
|
||||
].join('')
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
/* Handler for Twitter users */
|
||||
export const handleProfile = async (
|
||||
username: string,
|
||||
userAgent?: string,
|
||||
flags?: InputFlags,
|
||||
language?: string,
|
||||
event?: FetchEvent
|
||||
): Promise<StatusResponse> => {
|
||||
console.log('Direct?', flags?.direct);
|
||||
|
||||
const api = await userAPI(username, language, event as FetchEvent);
|
||||
const user = api?.user as APIUser;
|
||||
|
||||
/* Catch this request if it's an API response */
|
||||
// For now we just always return the API response while testing
|
||||
if (flags?.api || true) {
|
||||
return {
|
||||
response: new Response(JSON.stringify(api), {
|
||||
headers: { ...Constants.RESPONSE_HEADERS, ...Constants.API_RESPONSE_HEADERS },
|
||||
status: api.code
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/* If there was any errors fetching the User, we'll return it */
|
||||
switch (api.code || true) {
|
||||
case 401:
|
||||
return returnError(Strings.ERROR_PRIVATE);
|
||||
case 404:
|
||||
return returnError(Strings.ERROR_USER_NOT_FOUND);
|
||||
case 500:
|
||||
return returnError(Strings.ERROR_API_FAIL);
|
||||
}
|
||||
};
|
|
@ -312,3 +312,52 @@ test('API fetch poll Tweet', async () => {
|
|||
expect(choices[3].count).toEqual(31706);
|
||||
expect(choices[3].percentage).toEqual(58);
|
||||
});
|
||||
|
||||
test('API fetch user', async () => {
|
||||
const result = await cacheWrapper(
|
||||
new Request('https://api.fxtwitter.com/wazbat', {
|
||||
method: 'GET',
|
||||
headers: botHeaders
|
||||
})
|
||||
);
|
||||
expect(result.status).toEqual(200);
|
||||
const response = (await result.json()) as APIResponse;
|
||||
expect(response).toBeTruthy();
|
||||
expect(response.code).toEqual(200);
|
||||
expect(response.message).toEqual('OK');
|
||||
|
||||
const tweet = response.tweet as APITweet;
|
||||
expect(tweet).toBeTruthy();
|
||||
expect(tweet.url).toEqual('https://twitter.com/Twitter/status/1055475950543167488');
|
||||
expect(tweet.id).toEqual('1055475950543167488');
|
||||
expect(tweet.text).toEqual('A poll:');
|
||||
expect(tweet.author.screen_name?.toLowerCase()).toEqual('twitter');
|
||||
expect(tweet.author.name).toBeTruthy();
|
||||
expect(tweet.author.avatar_url).toBeTruthy();
|
||||
expect(tweet.author.banner_url).toBeTruthy();
|
||||
expect(tweet.author.avatar_color).toBeTruthy();
|
||||
expect(tweet.twitter_card).toEqual('tweet');
|
||||
expect(tweet.created_at).toEqual('Thu Oct 25 15:07:31 +0000 2018');
|
||||
expect(tweet.created_timestamp).toEqual(1540480051);
|
||||
expect(tweet.lang).toEqual('en');
|
||||
expect(tweet.replying_to).toBeNull();
|
||||
expect(tweet.poll).toBeTruthy();
|
||||
const poll = tweet.poll as APIPoll;
|
||||
expect(poll.ends_at).toEqual('2018-10-26T03:07:30Z');
|
||||
expect(poll.time_left_en).toEqual('Final results');
|
||||
expect(poll.total_votes).toEqual(54703);
|
||||
|
||||
const choices = poll.choices as APIPollChoice[];
|
||||
expect(choices[0].label).toEqual('Yesssss');
|
||||
expect(choices[0].count).toEqual(14773);
|
||||
expect(choices[0].percentage).toEqual(27);
|
||||
expect(choices[1].label).toEqual('No');
|
||||
expect(choices[1].count).toEqual(3618);
|
||||
expect(choices[1].percentage).toEqual(6.6);
|
||||
expect(choices[2].label).toEqual('Maybe?');
|
||||
expect(choices[2].count).toEqual(4606);
|
||||
expect(choices[2].percentage).toEqual(8.4);
|
||||
expect(choices[3].label).toEqual('Just show me the results');
|
||||
expect(choices[3].count).toEqual(31706);
|
||||
expect(choices[3].percentage).toEqual(58);
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue