const { baseUrl, gqlMap, gqlFeatures, consumerKey, consumerSecret } = require('./constants'); import { config } from '@/config'; import logger from '@/utils/logger'; import got from '@/utils/got'; const OAuth = require('oauth-1.0a'); const CryptoJS = require('crypto-js'); import queryString from 'query-string'; const { getToken } = require('./token'); import cache from '@/utils/cache'; const twitterGot = async (url, params) => { const token = await getToken(); const oauth = OAuth({ consumer: { key: consumerKey, secret: consumerSecret, }, signature_method: 'HMAC-SHA1', hash_function: (base_string, key) => CryptoJS.HmacSHA1(base_string, key).toString(CryptoJS.enc.Base64), }); const requestData = { url: `${url}?${queryString.stringify(params)}`, method: 'GET', headers: { connection: 'keep-alive', 'content-type': 'application/json', 'x-twitter-active-user': 'yes', authority: 'api.twitter.com', 'accept-encoding': 'gzip', 'accept-language': 'en-US,en;q=0.9', accept: '*/*', DNT: '1', }, }; const response = await got(requestData.url, { headers: oauth.toHeader(oauth.authorize(requestData, token)), }); return response.data; }; const paginationTweets = async (endpoint, userId, variables, path) => { const { data } = await twitterGot(baseUrl + endpoint, { variables: JSON.stringify({ ...variables, rest_id: userId, }), features: gqlFeatures, }); let instructions; if (path) { instructions = data; for (const p of path) { instructions = instructions[p]; } instructions = instructions.instructions; } else { instructions = data.user_result.result.timeline_response.timeline.instructions; } return instructions.find((i) => i.__typename === 'TimelineAddEntries' || i.type === 'TimelineAddEntries').entries; }; const timelineTweets = (userId, params = {}) => paginationTweets(gqlMap.UserWithProfileTweets, userId, { ...params, withQuickPromoteEligibilityTweetFields: true, }); const timelineTweetsAndReplies = (userId, params = {}) => paginationTweets(gqlMap.UserWithProfileTweetsAndReplies, userId, { ...params, count: 20, }); const timelineMedia = (userId, params = {}) => paginationTweets(gqlMap.MediaTimeline, userId, params); // const timelineLikes = (userId, params = {}) => paginationTweets(gqlMap.Likes, userId, params); const timelineKeywords = (keywords, params = {}) => paginationTweets( gqlMap.SearchTimeline, null, { ...params, rawQuery: keywords, count: 20, product: 'Latest', withDownvotePerspective: false, withReactionsMetadata: false, withReactionsPerspective: false, }, ['search_by_raw_query', 'search_timeline', 'timeline'] ); const tweetDetail = (userId, params) => paginationTweets( gqlMap.TweetDetail, userId, { ...params, includeHasBirdwatchNotes: false, includePromotedContent: false, withBirdwatchNotes: false, withVoice: false, withV2Timeline: true, }, ['threaded_conversation_with_injections_v2'] ); function gatherLegacyFromData(entries, filterNested, userId) { const tweets = []; const filteredEntries = []; for (const entry of entries) { const entryId = entry.entryId; if (entryId) { if (entryId.startsWith('tweet-')) { filteredEntries.push(entry); } if (filterNested && filterNested.some((f) => entryId.startsWith(f))) { filteredEntries.push(...entry.content.items); } } } for (const entry of filteredEntries) { if (entry.entryId) { const content = entry.content || entry.item; let tweet = content?.content?.tweetResult?.result || content?.itemContent?.tweet_results?.result; if (tweet && tweet.tweet) { tweet = tweet.tweet; } if (tweet) { const retweet = tweet.legacy?.retweeted_status_result?.result; for (const t of [tweet, retweet]) { if (!t?.legacy) { continue; } t.legacy.user = t.core?.user_result?.result?.legacy || t.core?.user_results?.result?.legacy; t.legacy.id_str = t.rest_id; // avoid falling back to conversation_id_str elsewhere const quote = t.quoted_status_result?.result; if (quote) { t.legacy.quoted_status = quote.legacy; t.legacy.quoted_status.user = quote.core.user_result?.result?.legacy || quote.core.user_results?.result?.legacy; } } const legacy = tweet.legacy; if (legacy) { if (retweet) { legacy.retweeted_status = retweet.legacy; } if (userId === undefined || legacy.user_id_str === userId + '') { tweets.push(legacy); } } } } } return tweets; } const getUserTweetsByID = async (id, params = {}) => gatherLegacyFromData(await timelineTweets(id, params)); // TODO: show the whole conversation instead of just the reply tweet const getUserTweetsAndRepliesByID = async (id, params = {}) => gatherLegacyFromData(await timelineTweetsAndReplies(id, params), ['profile-conversation-'], id); const getUserMediaByID = async (id, params = {}) => gatherLegacyFromData(await timelineMedia(id, params)); // const getUserLikesByID = async (id, params = {}) => gatherLegacyFromData(await timelineLikes(id, params)); const getUserTweetByStatus = async (id, params = {}) => gatherLegacyFromData(await tweetDetail(id, params), ['homeConversation-', 'conversationthread-']); const excludeRetweet = function (tweets) { const excluded = []; for (const t of tweets) { if (t.retweeted_status) { continue; } excluded.push(t); } return excluded; }; const userByScreenName = (screenName) => twitterGot(`${baseUrl}${gqlMap.UserResultByScreenName}`, { variables: `{"screen_name":"${screenName}","withHighlightedLabel":true}`, features: gqlFeatures, }); const userByRestId = (restId) => twitterGot(`${baseUrl}${gqlMap.UserByRestId}`, { variables: `{"userId":"${restId}","withHighlightedLabel":true}`, features: gqlFeatures, }); const userByAuto = (id) => { if (id.startsWith('+')) { return userByRestId(id.slice(1)); } return userByScreenName(id); }; const getUserData = (id) => cache.tryGet(`twitter-userdata-${id}`, () => userByAuto(id)); const getUserID = async (id) => { const userData = await getUserData(id); return (userData.data?.user || userData.data?.user_result)?.result?.rest_id; }; const getUser = async (id) => { const userData = await getUserData(id); return (userData.data?.user || userData.data?.user_result)?.result?.legacy; }; const cacheTryGet = async (_id, params, func) => { const id = await getUserID(_id); if (id === undefined) { throw new Error('User not found'); } const funcName = func.name; const paramsString = JSON.stringify(params); return cache.tryGet(`twitter:${id}:${funcName}:${paramsString}`, () => func(id, params), config.cache.routeExpire, false); }; // returns: // 1. nothing for some users // 2. HOT tweets for the other users, instead of the LATEST ones const _getUserTweets = (id, params = {}) => cacheTryGet(id, params, getUserTweetsByID); // workaround for the above issue: // 1. getUserTweetsAndReplies return LATEST tweets and replies, which requires filtering // a. if one replies a lot (e.g. elonmusk), there is sometimes no tweets left after filtering, caching may help // 2. getUserMedia return LATEST media tweets, which is a good plus const getUserTweets = async (id, params = {}) => { let tweets = []; const rest_id = await getUserID(id); await Promise.all( [_getUserTweets, getUserTweetsAndReplies, getUserMedia].map(async (func) => { try { tweets.push(...(await func(id, params))); } catch (error) { logger.warn(`Failed to get tweets for ${id} with ${func.name}: ${error}`); } }) ); const cacheKey = `twitter:user:tweets-cache:${rest_id}`; let cacheValue = await cache.get(cacheKey); if (cacheValue) { cacheValue = JSON.parse(cacheValue); if (cacheValue && cacheValue.length) { tweets = [...cacheValue, ...tweets]; } } const idSet = new Set(); tweets = tweets .filter( (tweet) => !tweet.in_reply_to_user_id_str || // exclude replies tweet.in_reply_to_user_id_str === rest_id // but include replies to self (threads) ) .map((tweet) => { const id_str = tweet.id_str || tweet.conversation_id_str; return !idSet.has(id_str) && idSet.add(id_str) && tweet; }) // deduplicate .filter(Boolean) // remove null .sort((a, b) => (b.id_str || b.conversation_id_str) - (a.id_str || a.conversation_id_str)) // desc .slice(0, 20); cache.set(cacheKey, JSON.stringify(tweets)); return tweets; }; const getUserTweetsAndReplies = (id, params = {}) => cacheTryGet(id, params, getUserTweetsAndRepliesByID); const getUserMedia = (id, params = {}) => cacheTryGet(id, params, getUserMediaByID); // const getUserLikes = (id, params = {}) => cacheTryGet(id, params, getUserLikesByID); const getUserTweet = (id, params) => cacheTryGet(id, params, getUserTweetByStatus); const getSearch = async (keywords, params = {}) => gatherLegacyFromData(await timelineKeywords(keywords, params)); module.exports = { getUser, getUserTweets, getUserTweetsAndReplies, getUserMedia, // getUserLikes, excludeRetweet, getSearch, getUserTweet, };