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,
};