diff --git a/docs/en/social-media.md b/docs/en/social-media.md index e88fc9dcfd..a15a423560 100644 --- a/docs/en/social-media.md +++ b/docs/en/social-media.md @@ -500,7 +500,7 @@ This route requires Twitter token's corresponding id, therefore it's only availa ### Collection - + ::: warning @@ -510,6 +510,10 @@ This route requires Twitter token's corresponding id, therefore it's only availa +### Tweet Details + + + ## Vimeo ### User Profile diff --git a/docs/social-media.md b/docs/social-media.md index a174ba4e50..b2fe235f5d 100644 --- a/docs/social-media.md +++ b/docs/social-media.md @@ -868,6 +868,11 @@ Instagram Stories 没有可靠的 guid,你的 RSS 阅读器可能将同一条 +### 推文详情 + + + + ## Vimeo ### 用户页面 diff --git a/lib/v2/twitter/maintainer.js b/lib/v2/twitter/maintainer.js index ce289e20a5..efad7dd809 100644 --- a/lib/v2/twitter/maintainer.js +++ b/lib/v2/twitter/maintainer.js @@ -6,5 +6,6 @@ module.exports = { '/list/:id/:name/:routeParams?': ['xyqfer'], '/media/:id/:routeParams?': ['yindaheng98', 'Rongronggg9'], '/trends/:woeid?': ['sakamossan'], + '/tweet/:id/status/:status/:original?': ['LarchLiu'], '/user/:id/:routeParams?': ['DIYgod', 'yindaheng98', 'Rongronggg9'], }; diff --git a/lib/v2/twitter/radar.js b/lib/v2/twitter/radar.js index cfbfe0a9e5..61841c9c45 100644 --- a/lib/v2/twitter/radar.js +++ b/lib/v2/twitter/radar.js @@ -68,6 +68,16 @@ module.exports = { } }, }, + { + title: '推文详情', + docs: 'https://docs.rsshub.app/social-media.html#twitter', + source: '/:id/status/:status', + target: (params) => { + if (params.id !== 'home' && params.id !== 'explore' && params.id !== 'notifications' && params.id !== 'messages' && params.id !== 'explore' && params.id !== 'search') { + return '/twitter/tweet/:id/suatus/:status'; + } + }, + }, ], }, }; diff --git a/lib/v2/twitter/router.js b/lib/v2/twitter/router.js index 206d355331..c5e3fe59c8 100644 --- a/lib/v2/twitter/router.js +++ b/lib/v2/twitter/router.js @@ -6,5 +6,6 @@ module.exports = (router) => { router.get('/list/:id/:name/:routeParams?', require('./list')); router.get('/media/:id/:routeParams?', require('./media')); router.get('/trends/:woeid?', require('./trends')); + router.get('/tweet/:id/status/:status/:original?', require('./tweet')); router.get('/user/:id/:routeParams?', require('./user')); }; diff --git a/lib/v2/twitter/tweet.js b/lib/v2/twitter/tweet.js new file mode 100644 index 0000000000..629e5d1300 --- /dev/null +++ b/lib/v2/twitter/tweet.js @@ -0,0 +1,5 @@ +const webApiImpl = require('./web-api/tweet'); + +module.exports = async (ctx) => { + await webApiImpl(ctx); +}; diff --git a/lib/v2/twitter/web-api/tweet.js b/lib/v2/twitter/web-api/tweet.js new file mode 100644 index 0000000000..76d2c04e39 --- /dev/null +++ b/lib/v2/twitter/web-api/tweet.js @@ -0,0 +1,25 @@ +// const config = require('@/config').value; +const { getUser, getUserTweet } = require('./twitter-api'); +const utils = require('../utils'); +const { fallback, queryToBoolean } = require('@/utils/readable-social'); +const config = require('@/config').value; + +module.exports = async (ctx) => { + const id = ctx.params.id; + const status = ctx.params.status; + const routeParams = new URLSearchParams(ctx.params.original); + const original = fallback(undefined, queryToBoolean(routeParams.get('original')), false); + const params = { focalTweetId: status }; + const userInfo = await getUser(ctx.cache, id); + const data = await getUserTweet(ctx.cache, id, params); + const profileImageUrl = userInfo.profile_image_url || userInfo.profile_image_url_https; + const item = original && config.isPackage ? data : utils.ProcessFeed(ctx, { data }); + + ctx.state.data = { + title: `Twitter @${userInfo.name}`, + link: `https://twitter.com/${id}/status/${status}`, + image: profileImageUrl.replace(/_normal.jpg$/, '.jpg'), + description: userInfo.description, + item, + }; +}; diff --git a/lib/v2/twitter/web-api/twitter-api.js b/lib/v2/twitter/web-api/twitter-api.js index 2ff909432a..e7adb2b769 100644 --- a/lib/v2/twitter/web-api/twitter-api.js +++ b/lib/v2/twitter/web-api/twitter-api.js @@ -111,11 +111,44 @@ const timelineKeywords = (keywords, params = {}) => pc: 1, }); +// https://github.com/mikf/gallery-dl/blob/a53cfc845e12d9e98fefd07e43ebffaec488c18f/gallery_dl/extractor/twitter.py#L795-L805 +const tweetDetail = (userId, params) => + paginationTweets('/graphql/ItejhtHVxU7ksltgMmyaLA/TweetDetail', userId, { + ...params, + with_rux_injections: false, + withCommunity: true, + withQuickPromoteEligibilityTweetFields: false, + withBirdwatchNotes: false, + }, ["threaded_conversation_with_injections"]); + function gatherLegacyFromData(entries, filter = 'tweet-') { const tweets = []; + const filte_entries = []; entries.forEach((entry) => { - if (entry.entryId && entry.entryId.includes(filter)) { - const tweet = entry.content.itemContent.tweet_results?.result; + const entryId = entry.entryId; + if (entryId) { + if (filter === 'none') { + if (entryId.startsWith("tweet-")) { + filte_entries.push(entry); + } + else if (entryId.startsWith("homeConversation-") || entryId.startsWith("conversationthread-")) { + filte_entries.push(...entry.content.items); + } + } + else { + if (entryId.startsWith(filter)) { + filte_entries.push(entry); + } + } + } + }); + filte_entries.forEach((entry) => { + if (entry.entryId) { + const content = entry.content || entry.item; + let tweet = 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]) { @@ -175,6 +208,8 @@ const getUserTweetsByID = async (id, params = {}) => gatherLegacyFromData(await 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 getUserTweetByStatus = async (id, params = {}) => gatherLegacyFromData(await tweetDetail(id, params), 'none'); + const excludeRetweet = function (tweets) { const excluded = []; @@ -206,6 +241,7 @@ const getUserTweets = (cache, screenName, params = {}) => cacheTryGet(cache, scr const getUserTweetsAndReplies = (cache, screenName, params = {}) => cacheTryGet(cache, screenName, params, getUserTweetsAndRepliesByID); const getUserMedia = (cache, screenName, params = {}) => cacheTryGet(cache, screenName, params, getUserMediaByID); const getUserLikes = (cache, screenName, params = {}) => cacheTryGet(cache, screenName, params, getUserLikesByID); +const getUserTweet = (cache, screenName, params) => cacheTryGet(cache, screenName, params, getUserTweetByStatus); const getSearch = async (keywords, params = {}) => gatherLegacyFromLegacyApiData(await timelineKeywords(keywords, params), 'sq-I-t-'); @@ -217,4 +253,5 @@ module.exports = { getUserLikes, excludeRetweet, getSearch, + getUserTweet, };