feat(route/twitter): official api support (#21109)

* feat(route/twitter): official api support

* fix: update namespace docs
This commit is contained in:
Tony 2026-02-10 22:32:13 +08:00 committed by GitHub
parent 8cc0b1bf71
commit 2f6303fcaf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 439 additions and 131 deletions

View File

@ -204,10 +204,12 @@ type ConfigEnvKeys =
| 'TUMBLR_CLIENT_ID'
| 'TUMBLR_CLIENT_SECRET'
| 'TUMBLR_REFRESH_TOKEN'
| 'TWITTER_USERNAME'
| 'TWITTER_PASSWORD'
| 'TWITTER_AUTHENTICATION_SECRET'
| 'TWITTER_PHONE_OR_EMAIL'
| 'TWITTER_CONSUMER_KEY'
| 'TWITTER_CONSUMER_SECRET'
// | 'TWITTER_USERNAME'
// | 'TWITTER_PASSWORD'
// | 'TWITTER_AUTHENTICATION_SECRET'
// | 'TWITTER_PHONE_OR_EMAIL'
| 'TWITTER_AUTH_TOKEN'
| 'TWITTER_THIRD_PARTY_API'
| 'UESTC_BBS_COOKIE'
@ -617,10 +619,12 @@ export type Config = {
refreshToken?: string;
};
twitter: {
username?: string[];
password?: string[];
authenticationSecret?: string[];
phoneOrEmail?: string[];
consumerKey?: string;
consumerSecret?: string;
// username?: string[];
// password?: string[];
// authenticationSecret?: string[];
// phoneOrEmail?: string[];
authToken?: string[];
thirdPartyApi?: string;
};
@ -1102,10 +1106,12 @@ const calculateValue = () => {
refreshToken: envs.TUMBLR_REFRESH_TOKEN,
},
twitter: {
username: envs.TWITTER_USERNAME?.split(','),
password: envs.TWITTER_PASSWORD?.split(','),
authenticationSecret: envs.TWITTER_AUTHENTICATION_SECRET?.split(','),
phoneOrEmail: envs.TWITTER_PHONE_OR_EMAIL?.split(','),
consumerKey: envs.TWITTER_CONSUMER_KEY,
consumerSecret: envs.TWITTER_CONSUMER_SECRET,
// username: envs.TWITTER_USERNAME?.split(','),
// password: envs.TWITTER_PASSWORD?.split(','),
// authenticationSecret: envs.TWITTER_AUTHENTICATION_SECRET?.split(','),
// phoneOrEmail: envs.TWITTER_PHONE_OR_EMAIL?.split(','),
authToken: envs.TWITTER_AUTH_TOKEN?.split(','),
thirdPartyApi: envs.TWITTER_THIRD_PARTY_API,
},

View File

@ -0,0 +1,330 @@
import type { TwitterApiReadOnly } from 'twitter-api-v2';
import { TwitterApi } from 'twitter-api-v2';
import { config } from '@/config';
import ConfigNotFoundError from '@/errors/types/config-not-found';
import InvalidParameterError from '@/errors/types/invalid-parameter';
import cache from '@/utils/cache';
const appClients: TwitterApiReadOnly[] = [];
let index = -1;
const init = () => {
if (appClients.length) {
return;
}
if (!config.twitter.consumerKey || !config.twitter.consumerSecret) {
return;
}
const consumerKeys = config.twitter.consumerKey.split(',');
const consumerSecrets = config.twitter.consumerSecret.split(',');
for (const [index, consumerKey] of consumerKeys.entries()) {
const consumerSecret = consumerSecrets[index];
if (!consumerKey || !consumerSecret) {
continue;
}
appClients.push(
new TwitterApi({
appKey: consumerKey,
appSecret: consumerSecret,
}).readOnly
);
}
};
export const getAppClient = async () => {
init();
if (!appClients.length) {
throw new ConfigNotFoundError('Twitter API is not configured');
}
index += 1;
return await appClients[index % appClients.length].appLogin();
};
const mapUserToLegacy = (user: Record<string, any>) =>
user
? {
id_str: user.id,
name: user.name,
screen_name: user.username,
description: user.description,
profile_image_url: user.profile_image_url,
profile_image_url_https: user.profile_image_url,
url: user.url,
verified: user.verified,
}
: null;
const mapUrlsToLegacy = (urls: Array<Record<string, any>> = []) =>
urls.map((url) => ({
url: url.url,
expanded_url: url.expanded_url ?? url.unwound_url ?? url.url,
display_url: url.display_url ?? url.url,
}));
const mapHashtagsToLegacy = (hashtags: Array<Record<string, any>> = []) => hashtags.map((hashtag) => ({ text: hashtag.tag }));
const mapMentionsToLegacy = (mentions: Array<Record<string, any>> = []) =>
mentions.map((mention) => ({
id_str: mention.id,
screen_name: mention.username,
name: mention.username,
}));
const mapMediaToLegacy = (media: Record<string, any>) => {
const url = media.url ?? media.preview_image_url;
const mapped = {
id_str: media.media_key,
type: media.type,
media_url_https: url,
media_url: url,
url,
sizes: {
large: {
w: media.width ?? 0,
h: media.height ?? 0,
resize: 'fit',
},
},
} as Record<string, any>;
if (media.variants?.length) {
mapped.video_info = {
variants: media.variants.map((variant) => ({
bitrate: variant.bit_rate,
content_type: variant.content_type,
url: variant.url,
})),
};
}
return mapped;
};
const mapTweetToLegacy = (tweet: Record<string, any>, includes: Record<string, any> | undefined, cacheMap: Map<string, Record<string, any>>) => {
if (cacheMap.has(tweet.id)) {
return cacheMap.get(tweet.id);
}
const users = new Map((includes?.users ?? []).map((user) => [user.id, user]));
const tweets = new Map((includes?.tweets ?? []).map((item) => [item.id, item]));
const media = new Map((includes?.media ?? []).map((item) => [item.media_key, item]));
const user = users.get(tweet.author_id);
const legacyUser = mapUserToLegacy(user);
const legacy: Record<string, any> = {
id_str: tweet.id,
conversation_id_str: tweet.conversation_id,
full_text: tweet.text,
text: tweet.text,
created_at: tweet.created_at,
entities: {
urls: mapUrlsToLegacy(tweet.entities?.urls),
hashtags: mapHashtagsToLegacy(tweet.entities?.hashtags),
user_mentions: mapMentionsToLegacy(tweet.entities?.mentions),
symbols: [],
},
extended_entities: {
media: (tweet.attachments?.media_keys ?? [])
.map((key) => media.get(key))
.filter(Boolean)
.map((item) => mapMediaToLegacy(item)),
},
user: legacyUser,
user_id_str: tweet.author_id,
in_reply_to_user_id_str: tweet.in_reply_to_user_id,
};
cacheMap.set(tweet.id, legacy);
for (const reference of tweet.referenced_tweets ?? []) {
const referenced = tweets.get(reference.id);
if (!referenced) {
continue;
}
const mappedReferenced = mapTweetToLegacy(referenced, includes, cacheMap);
switch (reference.type) {
case 'retweeted':
legacy.retweeted_status = mappedReferenced;
break;
case 'quoted':
legacy.quoted_status = mappedReferenced;
break;
case 'replied_to': {
legacy.in_reply_to_status_id_str = reference.id;
legacy.in_reply_to_user_id_str = referenced.author_id;
const replyUser = users.get(referenced.author_id);
legacy.in_reply_to_screen_name = replyUser?.username;
break;
}
default:
// Do nothing
}
}
if (!legacy.extended_entities.media?.length) {
delete legacy.extended_entities;
}
return legacy;
};
const mapTweetResponseToLegacy = (response: Record<string, any>) => {
const cacheMap = new Map<string, Record<string, any>>();
return (response?.data ?? []).map((tweet) => mapTweetToLegacy(tweet, response.includes, cacheMap));
};
const getUserData = (id: string) =>
cache.tryGet(`twitter-userdata-${id}`, async () => {
const client = await getAppClient();
const params = {
'user.fields': 'profile_image_url,description,verified,url',
};
const response = id.startsWith('+') ? await client.v2.user(id.slice(1), params) : await client.v2.userByUsername(id, params);
return mapUserToLegacy(response?.data);
});
const cacheTryGet = async (_id: string, params: Record<string, any> | undefined, func: (id: string, params?: Record<string, any>) => Promise<any>) => {
const userData: any = await getUserData(_id);
const id = userData?.id_str;
if (id === undefined) {
cache.set(`twitter-userdata-${_id}`, '', config.cache.contentExpire);
throw new InvalidParameterError('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);
};
const getUserTimeline = async (id: string, params?: Record<string, any>, options: Record<string, any> = {}) => {
const client = await getAppClient();
const response = await client.v2.get(`users/${id}/tweets`, {
max_results: params?.count ?? 20,
expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id',
'user.fields': 'username,name,profile_image_url,description',
'media.fields': 'preview_image_url,url,type,width,height,variants',
...options,
});
return mapTweetResponseToLegacy(response);
};
const getUserTweets = (id: string, params?: Record<string, any>) => cacheTryGet(id, params, (id, params = {}) => getUserTimeline(id, params, { exclude: 'replies' }));
const getUserTweetsAndReplies = (id: string, params?: Record<string, any>) => cacheTryGet(id, params, (id, params = {}) => getUserTimeline(id, params));
const getUserMedia = (id: string, params?: Record<string, any>) =>
cacheTryGet(id, params, async (id, params = {}) => {
const data = await getUserTimeline(id, params);
return data.filter((tweet) => tweet.extended_entities?.media);
});
const getUserLikes = (id: string, params?: Record<string, any>) =>
cacheTryGet(id, params, async (id, params = {}) => {
const client = await getAppClient();
const response = await client.v2.get(`users/${id}/liked_tweets`, {
max_results: params.count ?? 20,
expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id',
'user.fields': 'username,name,profile_image_url,description',
'media.fields': 'preview_image_url,url,type,width,height,variants',
});
return mapTweetResponseToLegacy(response);
});
const getUserTweet = (id: string, params?: Record<string, any>) =>
cacheTryGet(id, params, async (_id, params = {}) => {
const client = await getAppClient();
const tweetId = params.focalTweetId;
if (!tweetId) {
throw new InvalidParameterError('Tweet ID is required');
}
const response = await client.v2.get(`tweets/${tweetId}`, {
expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id',
'user.fields': 'username,name,profile_image_url,description',
'media.fields': 'preview_image_url,url,type,width,height,variants',
});
return mapTweetResponseToLegacy({ data: response?.data ? [response.data] : [], includes: response?.includes });
});
const getSearch = (keywords: string, params?: Record<string, any>) =>
cache.tryGet(
`twitter:search:${keywords}:${JSON.stringify(params)}`,
async () => {
const client = await getAppClient();
const response = await client.v2.get('tweets/search/recent', {
query: keywords,
max_results: params?.count ?? 20,
expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id',
'user.fields': 'username,name,profile_image_url,description',
'media.fields': 'preview_image_url,url,type,width,height,variants',
});
return mapTweetResponseToLegacy(response);
},
config.cache.routeExpire,
false
);
const getList = (id: string, params?: Record<string, any>) =>
cache.tryGet(
`twitter:list:${id}:${JSON.stringify(params)}`,
async () => {
const client = await getAppClient();
const response = await client.v2.get(`lists/${id}/tweets`, {
max_results: params?.count ?? 20,
expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id',
'user.fields': 'username,name,profile_image_url,description',
'media.fields': 'preview_image_url,url,type,width,height,variants',
});
return mapTweetResponseToLegacy(response);
},
config.cache.routeExpire,
false
);
const getHomeTimeline = (_id: string, params?: Record<string, any>) =>
cache.tryGet(
`twitter:home:${JSON.stringify(params)}`,
async () => {
if (!_id) {
throw new InvalidParameterError('User ID is required for the v2 home timeline');
}
const client = await getAppClient();
const response = await client.v2.get(`users/${_id}/timelines/reverse_chronological`, {
max_results: params?.count ?? 20,
expansions: 'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
'tweet.fields': 'created_at,entities,conversation_id,referenced_tweets,author_id,in_reply_to_user_id',
'user.fields': 'username,name,profile_image_url,description',
'media.fields': 'preview_image_url,url,type,width,height,variants',
});
return mapTweetResponseToLegacy(response);
},
config.cache.routeExpire,
false
);
const getHomeLatestTimeline = (id: string, params?: Record<string, any>) => getHomeTimeline(id, params);
const getUser = (id: string) => getUserData(id);
export default {
getUser,
getUserTweets,
getUserTweetsAndReplies,
getUserMedia,
getUserLikes,
getUserTweet,
getSearch,
getList,
getHomeTimeline,
getHomeLatestTimeline,
init,
};

View File

@ -1,21 +1,17 @@
import utils from '../../utils';
import api from './api';
const handler = async (ctx) => {
const keyword = ctx.req.param('keyword');
const limit = ctx.req.query('limit') ?? 50;
const client = await utils.getAppClient();
const data = await client.v1.get('search/tweets.json', {
q: keyword,
count: limit,
tweet_mode: 'extended',
result_type: 'recent',
});
await api.init();
const data = await api.getSearch(keyword, { count: limit });
return {
title: `Twitter Keyword - ${keyword}`,
link: `https://x.com/search?q=${encodeURIComponent(keyword)}`,
item: utils.ProcessFeed(ctx, {
data: data.statuses,
data,
}),
allowEmpty: true,
};

View File

@ -1,38 +1,31 @@
import utils from '../../utils';
import api from './api';
const handler = async (ctx) => {
const id = ctx.req.param('id');
// For compatibility
const { include_replies, include_rts, count } = utils.parseRouteParams(ctx.req.param('routeParams'));
const client = await utils.getAppClient();
const user_timeline_query = {
tweet_mode: 'extended',
exclude_replies: !include_replies,
include_rts,
count,
};
let screen_name;
if (id.startsWith('+')) {
user_timeline_query.user_id = +id.slice(1);
} else {
user_timeline_query.screen_name = id;
screen_name = id;
const params = count ? { count } : {};
await api.init();
const userInfo: any = await api.getUser(id);
let data = await (include_replies ? api.getUserTweetsAndReplies(id, params) : api.getUserTweets(id, params));
if (!include_rts) {
data = utils.excludeRetweet(data);
}
const data = await client.v1.get('statuses/user_timeline.json', user_timeline_query);
const userInfo = data[0].user;
if (!screen_name) {
screen_name = userInfo.screen_name;
}
const profileImageUrl = userInfo.profile_image_url || userInfo.profile_image_url_https;
const screenName = userInfo?.screen_name;
const profileImageUrl = userInfo?.profile_image_url || userInfo?.profile_image_url_https;
return {
title: `Twitter @${userInfo.name}`,
link: `https://x.com/${screen_name}`,
link: `https://x.com/${screenName}`,
image: profileImageUrl,
description: userInfo.description,
item: utils.ProcessFeed(ctx, {
data,
}),
allowEmpty: true,
};
};
export default handler;

View File

@ -1,12 +1,14 @@
import { config } from '@/config';
import ConfigNotFoundError from '@/errors/types/config-not-found';
import mobileApi from './mobile-api/api';
import devApi from './developer-api/api';
// import mobileApi from './mobile-api/api';
import webApi from './web-api/api';
const enableThirdPartyApi = config.twitter.thirdPartyApi;
const enableMobileApi = config.twitter.username && config.twitter.password;
// const enableMobileApi = config.twitter.username && config.twitter.password;
const enableWebApi = config.twitter.authToken;
const enableDeveloperApi = config.twitter.consumerKey && config.twitter.consumerSecret;
type ApiItem = (id: string, params?: Record<string, any>) => Promise<Record<string, any>> | Record<string, any> | null;
let api: {
@ -41,8 +43,8 @@ if (enableThirdPartyApi) {
api = webApi;
} else if (enableWebApi) {
api = webApi;
} else if (enableMobileApi) {
api = mobileApi;
} else if (enableDeveloperApi) {
api = devApi;
}
export default api;

View File

@ -9,14 +9,14 @@ export const route: Route = {
example: '/twitter/home_latest',
features: {
requireConfig: [
{
name: 'TWITTER_USERNAME',
description: 'Please see above for details.',
},
{
name: 'TWITTER_PASSWORD',
description: 'Please see above for details.',
},
// {
// name: 'TWITTER_USERNAME',
// description: 'Please see above for details.',
// },
// {
// name: 'TWITTER_PASSWORD',
// description: 'Please see above for details.',
// },
{
name: 'TWITTER_AUTH_TOKEN',
description: 'Please see above for details.',

View File

@ -9,14 +9,14 @@ export const route: Route = {
example: '/twitter/home',
features: {
requireConfig: [
{
name: 'TWITTER_USERNAME',
description: 'Please see above for details.',
},
{
name: 'TWITTER_PASSWORD',
description: 'Please see above for details.',
},
// {
// name: 'TWITTER_USERNAME',
// description: 'Please see above for details.',
// },
// {
// name: 'TWITTER_PASSWORD',
// description: 'Please see above for details.',
// },
{
name: 'TWITTER_AUTH_TOKEN',
description: 'Please see above for details.',

View File

@ -12,14 +12,14 @@ export const route: Route = {
parameters: { keyword: 'keyword', routeParams: 'extra parameters, see the table above' },
features: {
requireConfig: [
{
name: 'TWITTER_USERNAME',
description: 'Please see above for details.',
},
{
name: 'TWITTER_PASSWORD',
description: 'Please see above for details.',
},
// {
// name: 'TWITTER_USERNAME',
// description: 'Please see above for details.',
// },
// {
// name: 'TWITTER_PASSWORD',
// description: 'Please see above for details.',
// },
{
name: 'TWITTER_AUTH_TOKEN',
description: 'Please see above for details.',

View File

@ -13,14 +13,14 @@ export const route: Route = {
parameters: { id: 'username; in particular, if starts with `+`, it will be recognized as a [unique ID](https://github.com/DIYgod/RSSHub/issues/12221), e.g. `+44196397`', routeParams: 'extra parameters, see the table above.' },
features: {
requireConfig: [
{
name: 'TWITTER_USERNAME',
description: 'Please see above for details.',
},
{
name: 'TWITTER_PASSWORD',
description: 'Please see above for details.',
},
// {
// name: 'TWITTER_USERNAME',
// description: 'Please see above for details.',
// },
// {
// name: 'TWITTER_PASSWORD',
// description: 'Please see above for details.',
// },
{
name: 'TWITTER_AUTH_TOKEN',
description: 'Please see above for details.',

View File

@ -45,7 +45,10 @@ Currently supports two authentication methods:
- Using \`TWITTER_AUTH_TOKEN\` (recommended): Configure a comma-separated list of \`auth_token\` cookies of logged-in Twitter Web. RSSHub will use this information to directly access Twitter's web API to obtain data.
- Using \`TWITTER_USERNAME\` \`TWITTER_PASSWORD\` and \`TWITTER_AUTHENTICATION_SECRET\`: Configure a comma-separated list of Twitter username and password. RSSHub will use this information to log in to Twitter and obtain data using the mobile API. Please note that if you have not logged in with the current IP address before, it is easy to trigger Twitter's risk control mechanism.
~~- Using \`TWITTER_USERNAME\` \`TWITTER_PASSWORD\` and \`TWITTER_AUTHENTICATION_SECRET\`: Configure a comma-separated list of Twitter username and password. RSSHub will use this information to log in to Twitter and obtain data using the mobile API. Please note that if you have not logged in with the current IP address before, it is easy to trigger Twitter's risk control mechanism.~~ This no longer works since mobile client attestation has been implemented in October 2025.
- Using \`TWITTER_CONSUMER_KEY\` and \`TWITTER_CONSUMER_SECRET\`: Configure a comma-separated list of Twitter API keys and secrets. RSSHub will use this information to access Twitter's Pay-Per-Use developer API to obtain data.
`,
lang: 'en',
};

View File

@ -2,7 +2,7 @@ import { config } from '@/config';
import ConfigNotFoundError from '@/errors/types/config-not-found';
import type { Route } from '@/types';
import utils from './utils';
import { getAppClient } from './api/developer-api/api';
export const route: Route = {
path: '/trends/:woeid?',
@ -23,11 +23,11 @@ export const route: Route = {
};
async function handler(ctx) {
if (!config.twitter || !config.twitter.consumer_key || !config.twitter.consumer_secret) {
if (!config.twitter || !config.twitter.consumerKey || !config.twitter.consumerSecret) {
throw new ConfigNotFoundError('Twitter RSS is disabled due to the lack of <a href="https://docs.rsshub.app/deploy/config#route-specific-configurations">relevant config</a>');
}
const woeid = ctx.req.param('woeid') ?? 1; // Global information is available by using 1 as the WOEID
const client = await utils.getAppClient();
const client = await getAppClient();
const data = await client.v1.get('trends/place.json', { id: woeid });
const [{ trends }] = data;

View File

@ -16,14 +16,14 @@ export const route: Route = {
},
features: {
requireConfig: [
{
name: 'TWITTER_USERNAME',
description: 'Please see above for details.',
},
{
name: 'TWITTER_PASSWORD',
description: 'Please see above for details.',
},
// {
// name: 'TWITTER_USERNAME',
// description: 'Please see above for details.',
// },
// {
// name: 'TWITTER_PASSWORD',
// description: 'Please see above for details.',
// },
],
requirePuppeteer: false,
antiCrawler: false,

View File

@ -16,19 +16,19 @@ export const route: Route = {
},
features: {
requireConfig: [
{
name: 'TWITTER_USERNAME',
description: 'Please see above for details.',
},
{
name: 'TWITTER_PASSWORD',
description: 'Please see above for details.',
},
{
name: 'TWITTER_AUTHENTICATION_SECRET',
description: 'TOTP 2FA secret, please see above for details.',
optional: true,
},
// {
// name: 'TWITTER_USERNAME',
// description: 'Please see above for details.',
// },
// {
// name: 'TWITTER_PASSWORD',
// description: 'Please see above for details.',
// },
// {
// name: 'TWITTER_AUTHENTICATION_SECRET',
// description: 'TOTP 2FA secret, please see above for details.',
// optional: true,
// },
{
name: 'TWITTER_AUTH_TOKEN',
description: 'Please see above for details.',
@ -38,6 +38,14 @@ export const route: Route = {
description: 'Use third-party API to query twitter data',
optional: true,
},
{
name: 'TWITTER_CONSUMER_KEY',
description: 'Please see above for details.',
},
{
name: 'TWITTER_CONSUMER_SECRET',
description: 'Please see above for details.',
},
],
requirePuppeteer: false,
antiCrawler: false,

View File

@ -1,6 +1,3 @@
import { TwitterApi } from 'twitter-api-v2';
import { config } from '@/config';
import { parseDate } from '@/utils/parse-date';
import { fallback, queryToBoolean, queryToInteger } from '@/utils/readable-social';
@ -440,32 +437,6 @@ const ProcessFeed = (ctx, { data = [] }, params = {}) => {
});
};
let getAppClient = () => null;
if (config.twitter.consumer_key && config.twitter.consumer_secret) {
const consumer_keys = config.twitter.consumer_key.split(',');
const consumer_secrets = config.twitter.consumer_secret.split(',');
const T = {};
let count = 0;
let index = -1;
for (const [i, consumer_key] of consumer_keys.entries()) {
const consumer_secret = consumer_secrets[i];
if (consumer_key && consumer_secret) {
T[i] = new TwitterApi({
appKey: consumer_key,
appSecret: consumer_secret,
}).readOnly;
count = i + 1;
}
}
getAppClient = () => {
index++;
return T[index % count].appLogin();
};
}
const parseRouteParams = (routeParams) => {
let count, include_replies, include_rts, only_media;
let force_web_api = false;
@ -519,7 +490,6 @@ export const keepOnlyMedia = function (tweets) {
export default {
ProcessFeed,
getAppClient,
parseRouteParams,
excludeRetweet,
keepOnlyMedia,