From 8875fb127fbbd88835b2d23a9e7b7df3f540be63 Mon Sep 17 00:00:00 2001 From: Howard Yin <37261065+yindaheng98@users.noreply.github.com> Date: Tue, 5 Oct 2021 17:40:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(route):=20Twitter=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E7=BA=BF=E5=92=8C=E7=94=A8=E6=88=B7=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E6=97=B6=E9=97=B4=E7=BA=BF=E4=B8=8D=E5=86=8D=E9=9C=80?= =?UTF-8?q?=E8=A6=81=E5=BC=80=E5=8F=91=E8=80=85=E8=B4=A6=E6=88=B7=20(#7739?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/radar-rules.js | 18 ++- docs/en/social-media.md | 4 + docs/social-media.md | 6 +- lib/config.js | 2 + lib/router.js | 3 +- lib/routes/twitter/developer-api/search.js | 22 ++++ lib/routes/twitter/developer-api/user.js | 29 +++++ lib/routes/twitter/keyword.js | 24 +--- lib/routes/twitter/media.js | 7 ++ lib/routes/twitter/user.js | 51 +-------- lib/routes/twitter/utils.js | 69 ++++++++---- lib/routes/twitter/web-api/media.js | 20 ++++ lib/routes/twitter/web-api/search.js | 16 +++ lib/routes/twitter/web-api/twitter-api.js | 123 +++++++++++++++++++++ lib/routes/twitter/web-api/twitter-got.js | 87 +++++++++++++++ lib/routes/twitter/web-api/user.js | 32 ++++++ 16 files changed, 418 insertions(+), 95 deletions(-) create mode 100644 lib/routes/twitter/developer-api/search.js create mode 100644 lib/routes/twitter/developer-api/user.js create mode 100644 lib/routes/twitter/media.js create mode 100644 lib/routes/twitter/web-api/media.js create mode 100644 lib/routes/twitter/web-api/search.js create mode 100644 lib/routes/twitter/web-api/twitter-api.js create mode 100644 lib/routes/twitter/web-api/twitter-got.js create mode 100644 lib/routes/twitter/web-api/user.js 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, + }), + }; +};