diff --git a/assets/radar-rules.js b/assets/radar-rules.js
index fdd482104b..e0e26067f7 100644
--- a/assets/radar-rules.js
+++ b/assets/radar-rules.js
@@ -85,17 +85,27 @@
docs: 'https://docs.rsshub.app/social-media.html#twitter',
source: '/:id',
target: (params) => {
- if (params.id !== 'home' && params.id !== 'explore' && params.id !== 'notifications' && params.id !== 'messages' && params.id !== 'explore' && params.id !== 'search') {
+ if (!['home', 'explore', 'notifications', 'messages', 'explore', 'search'].includes(params.id)) {
return '/twitter/user/:id';
}
},
},
+ {
+ title: '用户媒体时间线',
+ docs: 'https://docs.rsshub.app/social-media.html#twitter',
+ source: '/:id/media',
+ target: (params) => {
+ if (!['home', 'explore', 'notifications', 'messages', 'explore', 'search'].includes(params.id)) {
+ return '/twitter/media/:id';
+ }
+ },
+ },
{
title: '用户关注时间线',
docs: 'https://docs.rsshub.app/social-media.html#twitter',
source: '/:id',
target: (params) => {
- if (params.id !== 'home' && params.id !== 'explore' && params.id !== 'notifications' && params.id !== 'messages' && params.id !== 'explore' && params.id !== 'search') {
+ if (!['home', 'explore', 'notifications', 'messages', 'explore', 'search'].includes(params.id)) {
return '/twitter/followings/:id';
}
},
@@ -105,7 +115,7 @@
docs: 'https://docs.rsshub.app/social-media.html#twitter',
source: '/:id',
target: (params) => {
- if (params.id !== 'home' && params.id !== 'explore' && params.id !== 'notifications' && params.id !== 'messages' && params.id !== 'explore' && params.id !== 'search') {
+ if (!['home', 'explore', 'notifications', 'messages', 'explore', 'search'].includes(params.id)) {
return '/twitter/likes/:id';
}
},
@@ -115,7 +125,7 @@
docs: 'https://docs.rsshub.app/social-media.html#twitter',
source: '/:id/lists/:name',
target: (params) => {
- if (params.id !== 'home' && params.id !== 'explore' && params.id !== 'notifications' && params.id !== 'messages' && params.id !== 'explore' && params.id !== 'search') {
+ if (!['home', 'explore', 'notifications', 'messages', 'explore', 'search'].includes(params.id)) {
return '/twitter/list/:id/:name';
}
},
diff --git a/docs/en/social-media.md b/docs/en/social-media.md
index 3d49065f97..bb3a950049 100644
--- a/docs/en/social-media.md
+++ b/docs/en/social-media.md
@@ -298,6 +298,10 @@ generates
+### User media
+
+
+
## User following timeline
diff --git a/docs/social-media.md b/docs/social-media.md
index e47392d91b..51df866d87 100644
--- a/docs/social-media.md
+++ b/docs/social-media.md
@@ -651,6 +651,10 @@ Tiny Tiny RSS 会给所有 iframe 元素添加 `sandbox="allow-scripts"` 属性
+### 用户媒体时间线
+
+
+
### 用户关注时间线
@@ -673,7 +677,7 @@ Tiny Tiny RSS 会给所有 iframe 元素添加 `sandbox="allow-scripts"` 属性
### 关键词
-
+
### Trends
diff --git a/lib/config.js b/lib/config.js
index 5c2a89d14f..76a67cef24 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -66,6 +66,8 @@ const calculateValue = () => {
consumer_key: envs.TWITTER_CONSUMER_KEY,
consumer_secret: envs.TWITTER_CONSUMER_SECRET,
tokens: twitter_tokens,
+ authorization: envs.TWITTER_WEBAPI_AUTHORIZAION || 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
+ // 此处硬编码参考自https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/twitter.py#L550
},
youtube: {
key: envs.YOUTUBE_KEY,
diff --git a/lib/router.js b/lib/router.js
index e56cf69896..9fc7af08b4 100644
--- a/lib/router.js
+++ b/lib/router.js
@@ -223,8 +223,9 @@ router.get('/twitter/user/:id/:routeParams?', lazyloadRouteHandler('./routes/twi
router.get('/twitter/list/:id/:name/:routeParams?', lazyloadRouteHandler('./routes/twitter/list'));
router.get('/twitter/likes/:id/:routeParams?', lazyloadRouteHandler('./routes/twitter/likes'));
router.get('/twitter/followings/:id/:routeParams?', lazyloadRouteHandler('./routes/twitter/followings'));
-router.get('/twitter/keyword/:keyword/:routeParams?', lazyloadRouteHandler('./routes/twitter/keyword'));
+router.get('/twitter/keyword/:keyword/:routeParams?/:limit?', lazyloadRouteHandler('./routes/twitter/keyword'));
router.get('/twitter/trends/:woeid?', lazyloadRouteHandler('./routes/twitter/trends'));
+router.get('/twitter/media/:id/:routeParams?', lazyloadRouteHandler('./routes/twitter/media'));
// YouTube
router.get('/youtube/user/:username/:embed?', lazyloadRouteHandler('./routes/youtube/user'));
diff --git a/lib/routes/twitter/developer-api/search.js b/lib/routes/twitter/developer-api/search.js
new file mode 100644
index 0000000000..5aa2367e18
--- /dev/null
+++ b/lib/routes/twitter/developer-api/search.js
@@ -0,0 +1,22 @@
+const utils = require('../utils');
+
+module.exports = async (ctx) => {
+ const keyword = ctx.params.keyword;
+ const limit = ctx.params.limit || 50;
+ const result = await utils.getTwit().get('search/tweets', {
+ q: keyword,
+ count: limit,
+ tweet_mode: 'extended',
+ result_type: 'recent',
+ });
+ const data = result.data;
+
+ ctx.state.data = {
+ title: `Twitter Keyword - ${keyword}`,
+ link: `https://twitter.com/search?q=${encodeURIComponent(keyword)}`,
+ item: utils.ProcessFeed(ctx, {
+ data: data.statuses,
+ }),
+ allowEmpty: true,
+ };
+};
diff --git a/lib/routes/twitter/developer-api/user.js b/lib/routes/twitter/developer-api/user.js
new file mode 100644
index 0000000000..b4d0cc5fb2
--- /dev/null
+++ b/lib/routes/twitter/developer-api/user.js
@@ -0,0 +1,29 @@
+const utils = require('../utils');
+
+module.exports = async (ctx) => {
+ const id = ctx.params.id;
+
+ // For compatibility
+ const { exclude_replies, include_rts, count } = utils.parseRouteParams(ctx.params.routeParams);
+ const result = await utils.getTwit().get('statuses/user_timeline', {
+ screen_name: id,
+ tweet_mode: 'extended',
+ exclude_replies,
+ include_rts,
+ count,
+ });
+ const data = result.data;
+ const userInfo = data[0].user;
+
+ const profileImageUrl = userInfo.profile_image_url || userInfo.profile_image_url_https;
+
+ ctx.state.data = {
+ title: `Twitter @${userInfo.name}`,
+ link: `https://twitter.com/${id}`,
+ image: profileImageUrl,
+ description: userInfo.description,
+ item: utils.ProcessFeed(ctx, {
+ data,
+ }),
+ };
+};
diff --git a/lib/routes/twitter/keyword.js b/lib/routes/twitter/keyword.js
index 49d58f43de..6ba30b8a56 100644
--- a/lib/routes/twitter/keyword.js
+++ b/lib/routes/twitter/keyword.js
@@ -1,25 +1,11 @@
-const utils = require('./utils');
const config = require('@/config').value;
+const webApiImpl = require('./web-api/search');
+const devApiImpl = require('./developer-api/search');
module.exports = async (ctx) => {
if (!config.twitter || !config.twitter.consumer_key || !config.twitter.consumer_secret) {
- throw 'Twitter RSS is disabled due to the lack of relevant config';
+ await webApiImpl(ctx);
+ } else {
+ await devApiImpl(ctx);
}
- const keyword = ctx.params.keyword;
- const result = await utils.getTwit().get('search/tweets', {
- q: keyword,
- count: 50,
- tweet_mode: 'extended',
- result_type: 'recent',
- });
- const data = result.data;
-
- ctx.state.data = {
- title: `Twitter Keyword - ${keyword}`,
- link: `https://twitter.com/search?q=${encodeURIComponent(keyword)}`,
- item: utils.ProcessFeed(ctx, {
- data: data.statuses,
- }),
- allowEmpty: true,
- };
};
diff --git a/lib/routes/twitter/media.js b/lib/routes/twitter/media.js
new file mode 100644
index 0000000000..f68fbe009d
--- /dev/null
+++ b/lib/routes/twitter/media.js
@@ -0,0 +1,7 @@
+// const config = require('@/config').value;
+// const devApiImpl = require('./developer-api/user');
+const webApiImpl = require('./web-api/media');
+
+module.exports = async (ctx) => {
+ await webApiImpl(ctx);
+};
diff --git a/lib/routes/twitter/user.js b/lib/routes/twitter/user.js
index 658b01263b..5cbde27071 100644
--- a/lib/routes/twitter/user.js
+++ b/lib/routes/twitter/user.js
@@ -1,54 +1,11 @@
-const querystring = require('querystring');
-const utils = require('./utils');
const config = require('@/config').value;
-const { fallback, queryToInteger, queryToBoolean } = require('@/utils/readable-social');
+const devApiImpl = require('./developer-api/user');
+const webApiImpl = require('./web-api/user');
module.exports = async (ctx) => {
if (!config.twitter || !config.twitter.consumer_key || !config.twitter.consumer_secret) {
- throw 'Twitter RSS is disabled due to the lack of relevant config';
- }
-
- const id = ctx.params.id;
- const routeParams = ctx.params.routeParams;
- let exclude_replies = false;
- let include_rts = true;
-
- // For compatibility
- let count = undefined;
- if (routeParams === 'exclude_rts_replies' || routeParams === 'exclude_replies_rts') {
- exclude_replies = true;
- include_rts = false;
- } else if (routeParams === 'exclude_replies') {
- exclude_replies = true;
- include_rts = true;
- } else if (routeParams === 'exclude_rts') {
- exclude_replies = false;
- include_rts = false;
+ await webApiImpl(ctx);
} else {
- const parsed = querystring.parse(routeParams);
- count = fallback(undefined, queryToInteger(parsed.count), undefined);
- exclude_replies = fallback(undefined, queryToBoolean(parsed.excludeReplies), false);
- include_rts = fallback(undefined, queryToBoolean(parsed.includeRts), true);
+ await devApiImpl(ctx);
}
-
- const result = await utils.getTwit().get('statuses/user_timeline', {
- screen_name: id,
- tweet_mode: 'extended',
- exclude_replies,
- include_rts,
- count,
- });
- const data = result.data;
- const userInfo = data[0].user;
- const profileImageUrl = userInfo.profile_image_url || userInfo.profile_image_url_https;
-
- ctx.state.data = {
- title: `Twitter @${userInfo.name}`,
- link: `https://twitter.com/${id}/`,
- image: profileImageUrl,
- description: userInfo.description,
- item: utils.ProcessFeed(ctx, {
- data,
- }),
- };
};
diff --git a/lib/routes/twitter/utils.js b/lib/routes/twitter/utils.js
index 8a3b1932c0..0f8bd9d14e 100644
--- a/lib/routes/twitter/utils.js
+++ b/lib/routes/twitter/utils.js
@@ -93,7 +93,7 @@ const ProcessFeed = (ctx, { data = [] }, params = {}) => {
if (!readable) {
content += '
';
}
- content += ``;
+ content += ``;
}
return content;
@@ -120,7 +120,7 @@ const ProcessFeed = (ctx, { data = [] }, params = {}) => {
content += `
`;
}
if (addLinkForPics) {
- content += ``;
+ content += ``;
}
content += `
= 0) {
@@ -142,7 +142,7 @@ const ProcessFeed = (ctx, { data = [] }, params = {}) => {
});
if (readable && img) {
- img = `
` + img;
+ img = `
` + img;
}
return img;
};
@@ -162,7 +162,7 @@ const ProcessFeed = (ctx, { data = [] }, params = {}) => {
case 'photo':
default:
originalImg = getOriginalImg(item.media_url_https);
- content = `
`;
+ content = `
`;
break;
}
@@ -172,14 +172,15 @@ const ProcessFeed = (ctx, { data = [] }, params = {}) => {
};
const formatUrl = (item) => {
let url = '';
- item.entities.urls.forEach((u) => {
- if (readable) {
- url += '
';
- } else {
- url += ' ';
- }
- url += `${u.expanded_url}`;
- });
+ item.entities.urls &&
+ item.entities.urls.forEach((u) => {
+ if (readable) {
+ url += '
';
+ } else {
+ url += ' ';
+ }
+ url += `${u.expanded_url}`;
+ });
return url;
};
@@ -201,18 +202,18 @@ const ProcessFeed = (ctx, { data = [] }, params = {}) => {
if (quoteData) {
const author = quoteData.user;
if (readable) {
- quote += `
`;
- quote += ``;
+ quote += `
`;
+ quote += ``;
} else {
quote += `
`;
}
if (readable) {
- quote += ``;
+ quote += ``;
}
if (showQuotedAuthorAvatarInDesc) {
- quote += `
`;
+ quote += `
`;
}
if (authorNameBold) {
@@ -242,13 +243,13 @@ const ProcessFeed = (ctx, { data = [] }, params = {}) => {
quoteInTitle += `${author.name}: ${formatTextToPlain(quoteData.full_text)}`;
if (readable) {
- quote += `
Link: https://twitter.com/${author.screen_name}/status/${quoteData.id_str}`;
+ quote += `
Link: https://twitter.com/${author.screen_name}/status/${quoteData.id_str}`;
}
if (showTimestampInDescription) {
quote += '
' + new Date(quoteData.created_at).toLocaleString();
quote += ``;
if (readable) {
- quote += `
`;
+ quote += `
`;
}
}
@@ -297,7 +298,7 @@ const ProcessFeed = (ctx, { data = [] }, params = {}) => {
if (showAuthorInDesc) {
if (readable) {
description += '';
- description += ``;
+ description += ``;
}
if (authorNameBold) {
description += ``;
@@ -315,7 +316,7 @@ const ProcessFeed = (ctx, { data = [] }, params = {}) => {
if (!showAuthorInDesc) {
description += ' ';
if (readable) {
- description += ``;
+ description += ``;
}
if (authorNameBold) {
description += ``;
@@ -335,11 +336,11 @@ const ProcessFeed = (ctx, { data = [] }, params = {}) => {
}
if (showAuthorInDesc) {
if (readable) {
- description += ``;
+ description += ``;
}
if (showAuthorAvatarInDesc) {
- description += `
`;
+ description += `
`;
}
if (authorNameBold) {
description += ``;
@@ -363,7 +364,7 @@ const ProcessFeed = (ctx, { data = [] }, params = {}) => {
description += quote;
if (readable) {
- description += `
`;
+ description += `
`;
}
if (showTimestampInDescription) {
@@ -407,7 +408,29 @@ if (config.twitter.consumer_key && config.twitter.consumer_secret) {
};
}
+function parseRouteParams(routeParams) {
+ let exclude_replies, include_rts;
+ let count = undefined;
+ if (routeParams === 'exclude_rts_replies' || routeParams === 'exclude_replies_rts') {
+ exclude_replies = true;
+ include_rts = false;
+ } else if (routeParams === 'exclude_replies') {
+ exclude_replies = true;
+ include_rts = true;
+ } else if (routeParams === 'exclude_rts') {
+ exclude_replies = false;
+ include_rts = false;
+ } else {
+ const parsed = querystring.parse(routeParams);
+ count = fallback(undefined, queryToInteger(parsed.count), undefined);
+ exclude_replies = fallback(undefined, queryToBoolean(parsed.excludeReplies), false);
+ include_rts = fallback(undefined, queryToBoolean(parsed.includeRts), true);
+ }
+ return { count, exclude_replies, include_rts };
+}
+
module.exports = {
ProcessFeed,
getTwit,
+ parseRouteParams,
};
diff --git a/lib/routes/twitter/web-api/media.js b/lib/routes/twitter/web-api/media.js
new file mode 100644
index 0000000000..91ba6f12ea
--- /dev/null
+++ b/lib/routes/twitter/web-api/media.js
@@ -0,0 +1,20 @@
+const utils = require('../utils');
+// const config = require('@/config').value;
+const { getUser, getUserMedia } = require('./twitter-api');
+
+module.exports = async (ctx) => {
+ const id = ctx.params.id;
+ const userInfo = await getUser(ctx.cache, id);
+ const data = await getUserMedia(ctx.cache, id);
+ const profileImageUrl = userInfo.profile_image_url || userInfo.profile_image_url_https;
+
+ ctx.state.data = {
+ title: `Twitter @${userInfo.name}`,
+ link: `https://twitter.com/${id}/media`,
+ image: profileImageUrl,
+ description: userInfo.description,
+ item: utils.ProcessFeed(ctx, {
+ data,
+ }),
+ };
+};
diff --git a/lib/routes/twitter/web-api/search.js b/lib/routes/twitter/web-api/search.js
new file mode 100644
index 0000000000..ce4d931de5
--- /dev/null
+++ b/lib/routes/twitter/web-api/search.js
@@ -0,0 +1,16 @@
+const utils = require('../utils');
+const { getSearch } = require('./twitter-api');
+
+module.exports = async (ctx) => {
+ const keyword = ctx.params.keyword;
+ const data = await getSearch(keyword);
+
+ ctx.state.data = {
+ title: `Twitter Keyword - ${keyword}`,
+ link: `https://twitter.com/search?q=${encodeURIComponent(keyword)}`,
+ item: utils.ProcessFeed(ctx, {
+ data,
+ }),
+ allowEmpty: true,
+ };
+};
diff --git a/lib/routes/twitter/web-api/twitter-api.js b/lib/routes/twitter/web-api/twitter-api.js
new file mode 100644
index 0000000000..f772cdfae5
--- /dev/null
+++ b/lib/routes/twitter/web-api/twitter-api.js
@@ -0,0 +1,123 @@
+const twitterGot = require('./twitter-got');
+const _params = {
+ count: 20,
+ include_profile_interstitial_type: 1,
+ include_blocking: 1,
+ include_blocked_by: 1,
+ include_followed_by: 1,
+ include_want_retweets: 1,
+ include_mute_edge: 1,
+ include_can_dm: 1,
+ include_can_media_tag: 1,
+ skip_status: 1,
+ cards_platform: 'Web-12',
+ include_cards: 1,
+ include_ext_alt_text: true,
+ include_quote_count: true,
+ include_reply_count: 1,
+ tweet_mode: 'extended',
+ include_entities: true,
+ include_user_entities: true,
+ include_ext_media_color: true,
+ include_ext_media_availability: true,
+ send_error_codes: true,
+ simple_quoted_tweet: true,
+ include_tweet_replies: false,
+ ext: 'mediaStats,highlightedLabel',
+};
+
+const pagination = (endpoint, userId, params) =>
+ twitterGot('https://api.twitter.com' + endpoint, {
+ ..._params,
+ ...params,
+ userId,
+ });
+const timelineMedia = (userId, params = {}) => pagination(`/2/timeline/media/${userId}.json`, userId, params);
+const timelineTweets = (userId, params = {}) => pagination(`/2/timeline/profile/${userId}.json`, userId, params);
+const timelineTweetsAndReplies = (userId, params = {}) =>
+ pagination(`/2/timeline/profile/${userId}.json`, userId, {
+ ...params,
+ include_tweet_replies: true,
+ });
+const timelineLikes = (userId, params = {}) =>
+ pagination(`/2/timeline/favorites/${userId}.json`, userId, {
+ ...params,
+ sorted_by_time: true,
+ });
+const timelineKeywords = (keywords, params = {}) =>
+ twitterGot('https://twitter.com/i/api/2/search/adaptive.json', {
+ ..._params,
+ ...params,
+ q: keywords,
+ query_source: 'typed_query',
+ pc: 1,
+ });
+
+function pickLegacyByID(id, tweets_dict, users_dict) {
+ function pickLegacyFromTweet(tweet) {
+ tweet.user = users_dict[tweet.user_id_str];
+ if (tweet.retweeted_status_id_str) {
+ tweet.retweeted_status = pickLegacyFromTweet(tweets_dict[tweet.retweeted_status_id_str]);
+ }
+ return tweet;
+ }
+
+ if (tweets_dict[id]) {
+ return pickLegacyFromTweet(tweets_dict[id]);
+ }
+}
+
+function gatherLegacyFromData(data, filter = 'tweet-') {
+ const tweets_dict = data.globalObjects.tweets;
+ const users_dict = data.globalObjects.users;
+ const tweets = [];
+ data.timeline.instructions[0].addEntries.entries.forEach((entry) => {
+ if (entry.entryId && entry.entryId.indexOf(filter) !== -1) {
+ const tweet = pickLegacyByID(entry.content.item.content.tweet.id, tweets_dict, users_dict);
+ if (tweet) {
+ tweets.push(tweet);
+ }
+ }
+ });
+ return tweets;
+}
+
+const getUserTweetsByID = async (id, params = {}) => gatherLegacyFromData(await timelineTweets(id, params));
+const getUserTweetsAndRepliesByID = async (id, params = {}) => gatherLegacyFromData(await timelineTweetsAndReplies(id, params));
+const getUserMediaByID = async (id, params = {}) => gatherLegacyFromData(await timelineMedia(id, params));
+const getUserLikesByID = async (id, params = {}) => gatherLegacyFromData(await timelineLikes(id, params));
+
+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('https://twitter.com/i/api/graphql/hc-pka9A7gyS3xODIafnrQ/UserByScreenName', {
+ variables: `{"screen_name":"${screenName}","withHighlightedLabel":true}`,
+ });
+const getUserData = (cache, screenName) => cache.tryGet(`twitter-userdata-${screenName}`, () => userByScreenName(screenName));
+const getUserID = async (cache, screenName) => (await getUserData(cache, screenName)).data.user.rest_id;
+const getUser = async (cache, screenName) => (await getUserData(cache, screenName)).data.user.legacy;
+
+const getUserTweets = async (cache, screenName, params = {}) => getUserTweetsByID(await getUserID(cache, screenName), params);
+const getUserTweetsAndReplies = async (cache, screenName, params = {}) => getUserTweetsAndRepliesByID(await getUserID(cache, screenName), params);
+const getUserMedia = async (cache, screenName, params = {}) => getUserMediaByID(await getUserID(cache, screenName), params);
+const getUserLikes = async (cache, screenName, params = {}) => getUserLikesByID(await getUserID(cache, screenName), params);
+const getSearch = async (keywords, params = {}) => gatherLegacyFromData(await timelineKeywords(keywords, params), 'sq-I-t-');
+
+module.exports = {
+ getUser,
+ getUserTweets,
+ getUserTweetsAndReplies,
+ getUserMedia,
+ getUserLikes,
+ excludeRetweet,
+ getSearch,
+};
diff --git a/lib/routes/twitter/web-api/twitter-got.js b/lib/routes/twitter/web-api/twitter-got.js
new file mode 100644
index 0000000000..b478fc6353
--- /dev/null
+++ b/lib/routes/twitter/web-api/twitter-got.js
@@ -0,0 +1,87 @@
+const { CookieJar, Cookie } = require('tough-cookie');
+const { promisify } = require('util');
+const queryString = require('query-string');
+const got = require('@/utils/got');
+const config = require('@/config').value;
+const headers = {
+ authorization: config.twitter.authorization,
+ 'x-guest-token': undefined,
+ 'x-twitter-auth-type': undefined,
+ 'x-twitter-client-language': 'en',
+ 'x-twitter-active-user': 'yes',
+ 'x-csrf-token': undefined,
+};
+let cookieJar, setCookie, getCookies;
+
+const cookiedomain = 'twitter.com';
+const cookieurl = 'https://twitter.com';
+
+async function twitterGot(options) {
+ const response = await got({
+ ...options,
+ headers: { ...headers, ...(options.headers || {}) },
+ cookieJar,
+ });
+ // 更新csrfToken
+ for (const c of await getCookies(cookieurl)) {
+ if (c.key === 'ct0') {
+ headers['x-csrf-token'] = c.value;
+ }
+ }
+ return response;
+}
+
+async function resetSession() {
+ cookieJar = new CookieJar();
+ getCookies = promisify(cookieJar.getCookies.bind(cookieJar));
+ setCookie = promisify(cookieJar.setCookie.bind(cookieJar));
+ // 生成csrf-token
+ const csrfToken = [...Array(16 * 2)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
+ await setCookie(new Cookie({ key: 'ct0', value: csrfToken, domain: cookiedomain, secure: false }), cookieurl);
+ headers['x-csrf-token'] = csrfToken;
+ headers['x-guest-token'] = undefined;
+ // 发起初始化请求
+ const response = await twitterGot({
+ url: 'https://api.twitter.com/1.1/guest/activate.json',
+ method: 'POST',
+ });
+ // 获取guest-token
+ // TODO: OAuth2Session, 参见 https://github.com/DIYgod/RSSHub/pull/7739#discussionR655932602
+ const guestToken = response.data.guest_token;
+ headers['x-guest-token'] = guestToken;
+ await setCookie(new Cookie({ key: 'gt', value: guestToken, domain: cookiedomain, secure: false }), cookieurl);
+ // 发起第二个初始化请求, 获取_twitter_sess
+ await twitterGot({
+ url: 'https://twitter.com/i/js_inst',
+ method: 'GET',
+ searchParams: queryString.stringify({ c_name: 'ui_metrics' }),
+ });
+ return cookieJar;
+}
+
+const initSession = () => cookieJar || resetSession();
+
+async function twitterRequest(url, params, method) {
+ await initSession();
+ // 发起请求
+ const request = () =>
+ twitterGot({
+ url,
+ method,
+ searchParams: queryString.stringify(params),
+ });
+ let response;
+ try {
+ response = await request();
+ } catch (e) {
+ if (e.response.status === 403) {
+ await resetSession();
+ response = await request();
+ } else {
+ throw e;
+ }
+ }
+ return response.data;
+}
+
+module.exports = twitterRequest;
diff --git a/lib/routes/twitter/web-api/user.js b/lib/routes/twitter/web-api/user.js
new file mode 100644
index 0000000000..96a70a1e9f
--- /dev/null
+++ b/lib/routes/twitter/web-api/user.js
@@ -0,0 +1,32 @@
+const utils = require('../utils');
+const { getUser, getUserTweets, getUserTweetsAndReplies, excludeRetweet } = require('./twitter-api');
+
+module.exports = async (ctx) => {
+ const id = ctx.params.id;
+
+ // For compatibility
+ const { exclude_replies, include_rts } = utils.parseRouteParams(ctx.params.routeParams);
+
+ let data;
+ const userInfo = await getUser(ctx.cache, id);
+ if (exclude_replies) {
+ data = await getUserTweets(ctx.cache, id);
+ } else {
+ data = await getUserTweetsAndReplies(ctx.cache, id);
+ }
+ if (!include_rts) {
+ data = excludeRetweet(data);
+ }
+
+ const profileImageUrl = userInfo.profile_image_url || userInfo.profile_image_url_https;
+
+ ctx.state.data = {
+ title: `Twitter @${userInfo.name}`,
+ link: `https://twitter.com/${id}`,
+ image: profileImageUrl,
+ description: userInfo.description,
+ item: utils.ProcessFeed(ctx, {
+ data,
+ }),
+ };
+};