Twitter auto expand show more text + filter tweets + remove short urls

Automatically expand the "show more text" section of tweets when they have more than 280 characters. While we're at it, replace short urls by their actual link, and add a way to filter those annoying repetitive tweets.

当前为 2023-11-13 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Twitter auto expand show more text + filter tweets + remove short urls
  3. // @namespace zezombye.dev
  4. // @version 0.3
  5. // @description Automatically expand the "show more text" section of tweets when they have more than 280 characters. While we're at it, replace short urls by their actual link, and add a way to filter those annoying repetitive tweets.
  6. // @author Zezombye
  7. // @match https://twitter.com/*
  8. // @icon https://www.google.com/s2/favicons?sz=64&domain=twitter.com
  9. // @license MIT
  10. // ==/UserScript==
  11.  
  12.  
  13. (function() {
  14. 'use strict';
  15.  
  16. //Define your filters here. If the text of the tweet contains any of these strings, the tweet will be removed from the timeline
  17. const forbiddenText = [
  18. "https://rumble.com/",
  19. "topg.com",
  20. "clownworldstore.com",
  21. "@dngcomics",
  22. "tatepledge.com",
  23. ].map(x => x.toLowerCase());
  24.  
  25. //Same but for regex
  26. const forbiddenTextRegex = [
  27. /^follow @\w+ for more hilarious commentaries$/i,
  28. /^GM\.?$/,
  29. ];
  30.  
  31. //Self explanatory
  32. const accountsWithNoPinnedTweets = [
  33. "clownworld_",
  34. ].map(x => x.toLowerCase());
  35. const removeTweetsWithOnlyEmojis = true //will not remove tweets with extra info such as quote tweet or media
  36. const hashtagLimit = 15 // remove tweets with more than this amount of hashtags
  37.  
  38.  
  39. function shouldRemoveTweet(tweet) {
  40.  
  41. if (!tweet.legacy) {
  42. //Tweet husk (when a quote tweet quotes another tweet, for example)
  43. return false;
  44. }
  45.  
  46. //console.log(tweet);
  47.  
  48. if (tweet.legacy.retweeted_status_result) {
  49. //Remove duplicate tweets from those annoying accounts that retweet their own tweets. (I know, it's for the algo...)
  50. //A good account to test with is https://twitter.com/ClownWorld_
  51. if (tweet.core.user_results.result.legacy.screen_name === tweet.legacy.retweeted_status_result.result.core.user_results.result.legacy.screen_name
  52. && new Date(tweet.legacy.created_at) - new Date(tweet.legacy.retweeted_status_result.result.legacy.created_at) < 10 * 24 * 60 * 60 * 1000 //10 days
  53. ) {
  54. return true;
  55. }
  56.  
  57.  
  58. return shouldRemoveTweet(tweet.legacy.retweeted_status_result.result);
  59. }
  60.  
  61. if (tweet.quoted_status_result && shouldRemoveTweet(tweet.quoted_status_result.result)) {
  62. return true;
  63. }
  64.  
  65. var user = tweet.core.user_results.result.legacy.screen_name;
  66. var text, entities;
  67. if (tweet.note_tweet) {
  68. text = tweet.note_tweet.note_tweet_results.result.text;
  69. entities = tweet.note_tweet.note_tweet_results.result.entity_set;
  70. } else {
  71. text = tweet.legacy.full_text.substring(tweet.legacy.display_text_range[0]);
  72. entities = tweet.legacy.entities;
  73. }
  74.  
  75. //Replace shorthand urls by their real links
  76. //Go in descending order to not fuck up the indices by earlier replacements
  77. var urls = entities.urls.sort((a,b) => b.indices[0] - a.indices[0])
  78. for (var url of urls) {
  79. text = text.substring(0, url.indices[0]) + url.expanded_url + text.substring(url.indices[1])
  80. }
  81.  
  82. //console.log("Testing if we should remove tweet by '"+user+"' with text: \n"+text);
  83.  
  84. if (removeTweetsWithOnlyEmojis && text.match(/^[\p{Emoji_Presentation}\p{Emoji}\s\p{P}]+$/u) && !tweet.quoted_status_result && !tweet.legacy.entities.media) {
  85. return true;
  86. }
  87.  
  88. if (tweet.legacy.entities.hashtags.length > hashtagLimit) {
  89. return true;
  90. }
  91.  
  92.  
  93. if (forbiddenText.some(x => text.toLowerCase().includes(x))) {
  94. //console.log("Removed tweet");
  95. return true;
  96. }
  97. if (forbiddenTextRegex.some(x => text.match(x))) {
  98. //console.log("Removed tweet");
  99. return true;
  100. }
  101. return false;
  102.  
  103. }
  104.  
  105. function fixUser(user, isGraphql) {
  106. if (user.__typename !== "User" && isGraphql) {
  107. console.error("Unhandled user typename '"+user.__typename+"'");
  108. return;
  109. }
  110.  
  111. var userEntities;
  112. if (isGraphql) {
  113. if (!user?.legacy?.entities) {
  114. return;
  115. }
  116. userEntities = user.legacy.entities;
  117. } else {
  118. userEntities = user.entities;
  119. }
  120.  
  121. //Edit user descriptions to remove the shortlinks
  122. if (userEntities.description) {
  123. for (let url of userEntities.description.urls) {
  124. url.url = url.expanded_url;
  125. }
  126. }
  127. if (userEntities.url) {
  128. for (let url of userEntities.url.urls) {
  129. url.url = url.expanded_url;
  130. }
  131. }
  132. }
  133.  
  134. function fixTweet(tweet) {
  135.  
  136. if (tweet.__typename === "TweetWithVisibilityResults") {
  137. if (tweet.tweetInterstitial && tweet.tweetInterstitial.text.text === "This Post violated the X Rules. However, X has determined that it may be in the public’s interest for the Post to remain accessible. Learn more") {
  138. delete tweet.tweetInterstitial;
  139. }
  140. tweet = tweet.tweet;
  141. }
  142.  
  143. if (tweet.__typename !== "Tweet" && tweet.__typename) {
  144. console.error("Unhandled tweet typename '"+tweet.__typename+"'");
  145. return;
  146. }
  147.  
  148. if (!tweet.legacy) {
  149. //Tweet husk (when a quote tweet quotes another tweet, for example)
  150. return false;
  151. }
  152.  
  153.  
  154. fixUser(tweet.core.user_results.result, true);
  155.  
  156.  
  157. if (tweet.birdwatch_pivot) {
  158. //It's pretty neat that you can just delete properties and the markup instantly adapts, ngl
  159. delete tweet.birdwatch_pivot.callToAction;
  160. delete tweet.birdwatch_pivot.footer;
  161. tweet.birdwatch_pivot.title = tweet.birdwatch_pivot.shorttitle;
  162. //Unfortunately, the full URLs of community notes aren't in the tweet itself. It's another API call
  163. }
  164.  
  165. if (tweet.hasOwnProperty("note_tweet")) {
  166. //Thank God for this property or this would simply be impossible.
  167. //For some reason the full text of the tweet is stored here. So put it in where the webapp is fetching the tweet text
  168. //Also put the entities with their indices
  169. tweet.legacy.full_text = tweet.note_tweet.note_tweet_results.result.text;
  170. tweet.legacy.display_text_range = [0, 9999999];
  171. if ("media" in tweet.legacy.entities) {
  172. for (var media of tweet.legacy.entities.media) {
  173. if (media.display_url.startsWith("pic.twitter.com/")) {
  174. media.indices = [1000000, 1000001];
  175. }
  176. }
  177. }
  178. for (var key of ["user_mentions", "urls", "hashtags", "symbols"]) {
  179. tweet.legacy.entities[key] = tweet.note_tweet.note_tweet_results.result.entity_set[key];
  180. }
  181. }
  182.  
  183. //Remove shortlinks for urls
  184. for (let url of tweet.legacy.entities.urls) {
  185. url.display_url = url.expanded_url.replace(/^https?:\/\//, "");
  186. url.url = url.expanded_url;
  187. }
  188.  
  189. if (tweet.legacy.quoted_status_permalink) {
  190. tweet.legacy.quoted_status_permalink.display = tweet.legacy.quoted_status_permalink.expanded.replace(/^https?:\/\//, "")
  191. }
  192.  
  193. if (tweet.quoted_status_result) {
  194. fixTweet(tweet.quoted_status_result.result);
  195. }
  196. }
  197.  
  198. function patchApiResult(apiPath, data) {
  199.  
  200. if (apiPath === "UserByScreenName") {
  201. fixUser(data.data.user.result, true);
  202. return data;
  203. }
  204. if (apiPath === "recommendations.json") {
  205. for (var user of data) {
  206. fixUser(user.user, false);
  207. }
  208. return data;
  209. }
  210.  
  211. var timeline;
  212. if (apiPath === "TweetDetail") {
  213. //When viewing a tweet directly.
  214. //https://twitter.com/atensnut/status/1723692342727647277
  215. timeline = data.data.threaded_conversation_with_injections_v2;
  216. } else if (apiPath === "HomeTimeline" || apiPath === "HomeLatestTimeline") {
  217. //"For you" and "Following" respectively, of the twitter homepage
  218. timeline = data.data.home.home_timeline_urt;
  219. } else if (apiPath === "UserTweets" || apiPath === "UserTweetsAndReplies" || apiPath === "UserMedia" || apiPath === "Likes") {
  220. //When viewing a user directly.
  221. //https://twitter.com/elonmusk
  222. //https://twitter.com/elonmusk/with_replies
  223. //https://twitter.com/elonmusk/media
  224. //https://twitter.com/elonmusk/likes
  225. timeline = data.data.user.result.timeline_v2.timeline;
  226. } else if (apiPath === "UserHighlightsTweets") {
  227. //https://twitter.com/elonmusk/highlights
  228. timeline = data.data.user.result.timeline.timeline;
  229. } else if (apiPath === "SearchTimeline") {
  230. //When viewing quoted tweets, or when literally searching tweets.
  231. //https://twitter.com/elonmusk/status/1721042240535973990/quotes
  232. //https://twitter.com/search?q=hormozi&src=typed_query
  233. timeline = data.data.search_by_raw_query.search_timeline.timeline;
  234. } else {
  235. console.error("Unhandled api path '"+apiPath+"'")
  236. return data;
  237. }
  238.  
  239. for (var instruction of timeline.instructions) {
  240. if (instruction.type === "TimelineClearCache" || instruction.type === "TimelineTerminateTimeline" || instruction.type === "TimelineReplaceEntry") {
  241. //do nothing
  242. } else if (instruction.type === "TimelinePinEntry") {
  243. if (accountsWithNoPinnedTweets.includes(instruction.entry.content.itemContent.tweet_results.result.core.user_results.result.legacy.screen_name.toLowerCase())) {
  244. instruction.shouldBeRemoved = true;
  245. }
  246. if (shouldRemoveTweet(instruction.entry.content.itemContent.tweet_results.result)) {
  247. instruction.shouldBeRemoved = true;
  248. } else {
  249. fixTweet(instruction.entry.content.itemContent.tweet_results.result);
  250. }
  251.  
  252. } else if (instruction.type === "TimelineAddEntries") {
  253. for (var entry of instruction.entries) {
  254. if (entry.entryId.startsWith("tweet-")) {
  255. if (apiPath !== "TweetDetail" && shouldRemoveTweet(entry.content.itemContent.tweet_results.result)) {
  256. //If TweetDetail, then the tweet is either the tweet itself, or the tweet(s) it is replying to.
  257. //Do not check them for deletion because it would make the tweet have no sense.
  258. entry.shouldBeRemoved = true;
  259. }
  260. if (!entry.shouldBeRemoved) {
  261. fixTweet(entry.content.itemContent.tweet_results.result);
  262. }
  263.  
  264. } else if (entry.entryId.startsWith("conversationthread-") || entry.entryId.startsWith("tweetdetailrelatedtweets-")) {
  265. for (let item of entry.content.items) {
  266. if (shouldRemoveTweet(item.item.itemContent.tweet_results.result)) {
  267. item.shouldBeRemoved = true;
  268. } else {
  269. fixTweet(item.item.itemContent.tweet_results.result)
  270. }
  271. }
  272. entry.content.items = entry.content.items.filter(x => !x.shouldBeRemoved)
  273. if (entry.content.items.length === 0) {
  274. entry.shouldBeRemoved = true
  275. }
  276.  
  277. } else if (entry.entryId.startsWith("profile-conversation-") || entry.entryId.startsWith("home-conversation-")) {
  278. //Only remove tweets in a conversation if it is the last tweet of the conversation. (Else, the tweets after won't make sense.)
  279. let hasTweetBeenKept = false;
  280. for (let i = entry.content.items.length - 1; i >= 0; i--) {
  281. if (!hasTweetBeenKept) {
  282. if (shouldRemoveTweet(entry.content.items[i].item.itemContent.tweet_results.result)) {
  283. entry.content.items[i].shouldBeRemoved = true;
  284. } else {
  285. hasTweetBeenKept = true;
  286. }
  287. }
  288. if (!entry.content.items[i].shouldBeRemoved) {
  289. fixTweet(entry.content.items[i].item.itemContent.tweet_results.result);
  290. }
  291. }
  292. entry.content.items = entry.content.items.filter(x => !x.shouldBeRemoved)
  293. if (entry.content.items.length === 0) {
  294. entry.shouldBeRemoved = true
  295. }
  296.  
  297. } else if (entry.entryId.startsWith("toptabsrpusermodule-")) {
  298. for (let item of entry.content.items) {
  299. fixUser(item.item.itemContent.user_results.result, true);
  300. }
  301.  
  302. } else if (entry.entryId.startsWith("who-to-follow-") || entry.entryId.startsWith("who-to-subscribe-") || entry.entryId.startsWith("promoted-tweet-")) {
  303. entry.shouldBeRemoved = true;
  304.  
  305. } else if (entry.entryId.startsWith("cursor-") || entry.entryId.startsWith("label-") || entry.entryId.startsWith("relevanceprompt-")) {
  306. //nothing to do
  307. } else {
  308. console.error("Unhandled entry id '"+entry.entryId+"'")
  309. }
  310. }
  311. instruction.entries = instruction.entries.filter(x => !x.shouldBeRemoved);
  312. } else {
  313. console.error("Unhandled instruction type '"+instruction.type+"'");
  314. }
  315. }
  316. timeline.instructions = timeline.instructions.filter(x => !x.shouldBeRemoved);
  317.  
  318. return data;
  319. }
  320.  
  321. //It's absolutely crazy that the only viable way of expanding a tweet is to hook the XMLHttpRequest object itself.
  322. //Big thanks to https://stackoverflow.com/a/28513219/4851350 because all other methods did not work.
  323. //Apparently it's only in firefox. If it doesn't work in Chrome, cry about it.
  324.  
  325. var accessor = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, 'responseText');
  326.  
  327. Object.defineProperty(XMLHttpRequest.prototype, 'responseText', {
  328. get: function() {
  329. var urlPath = this.responseURL ? (new URL(this.responseURL)).pathname : "";
  330. var apiPath = urlPath.split("/").pop()
  331. if (urlPath.startsWith("/i/api/") && ["UserTweets", "HomeTimeline", "HomeLatestTimeline", "SearchTimeline", "TweetDetail", "UserByScreenName", "UserTweetsAndReplies", "UserMedia", "Likes", "UserHighlightsTweets", "recommendations.json"].includes(apiPath)) {
  332. var originalResponseText = accessor.get.call(this);
  333. console.log(apiPath, JSON.parse(originalResponseText));
  334. originalResponseText = patchApiResult(apiPath, JSON.parse(originalResponseText));
  335. console.log(originalResponseText);
  336. return JSON.stringify(originalResponseText);
  337. } else {
  338. return accessor.get.call(this);
  339. }
  340. },
  341. set: function(str) {
  342. console.log('set responseText: %s', str);
  343. return accessor.set.call(this, str);
  344. },
  345. configurable: true
  346. });
  347. })();