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.
// ==UserScript== // @name Twitter auto expand show more text + filter tweets + remove short urls // @namespace zezombye.dev // @version 0.11 // @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. // @author Zezombye // @match https://twitter.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=twitter.com // @license MIT // @require https://cdn.jsdelivr.net/npm/[email protected]/index.min.js // @run-at document-start // ==/UserScript== (function() { 'use strict'; //################# START OF CONFIG ################# //Define your filters here. If the text of the tweet contains any of these strings, the tweet will be removed from the timeline const forbiddenText = [ "https://rumble.com/", "topg.com", "clownworldstore.com", "dngcomics", "tatepledge.com", "tate confidential ep ", "skool.com/monetize", "http://tinyurl.com/48ntfwhh", "tinyurl.com/334jes22", "greatonlinegame.com", "thedankoe.com", "getairchat.com", "theartoffocusbook.com", ].map(x => x.toLowerCase()); //Same but for regex const forbiddenTextRegex = [ /^follow @\w+ for more hilarious commentaries$/i, /^GM\.?$/, /You can make money, TODAY.+\s+Begin now: .+university\.com/i, /\b(israel|palestine|israeli|palestinian)\b/i, /(^|\W)\$\w+\b/, //cashtags - good for removing crypto shit ]; const removeTextAtStart = /^([\p{Emoji_Presentation}\p{Extended_Pictographic}\p{Emoji_Modifier_Base}\p{Format}\u{1F3FB}-\u{1F3FF}\u{1F9B0}-\u{1F9B3}\uFE0F\u20E3\s]|breaking|flash|alerte info|[:|-])+/iu; //Remove the pinned tweets of these accounts const accountsWithNoPinnedTweets = [ "clownworld_", ].map(x => x.toLowerCase()); //Remove the tweets of these accounts that only contain images const accountsWithNoImages = [ "HumansNoContext", ].map(x => x.toLowerCase()); //Remove the tweets of these accounts that do not contain a photo/video (only text) const accountsWithRequiredMedia = [ "NoContextHumans", ].map(x => x.toLowerCase()); //Remove threads starting by these tweet ids const forbiddenThreadIds = [ "1681712437085478912", //tate jamaican music "1710941438526017858", //tatepledge "1709651947354026010", //tate trw promo quotes "1753107137444888972", //tate university.com promo quotes "1738538369557090317", //tate avoiding speaking to famous people ] const removeTweetsWithOnlyEmojis = true //will not remove tweets with extra info such as quote tweet or media const removeTweetsWithOnlyMentions = true //same const hashtagLimit = 15 // remove tweets with more than this amount of hashtags //################# END OF CONFIG ################# var splitter = new GraphemeSplitter(); window.splitter = splitter; function shouldRemoveTweet(tweet) { if (!tweet || !tweet.legacy) { //Tweet husk (when a quote tweet quotes another tweet, for example) return false; } //console.log(tweet); if (forbiddenThreadIds.includes(tweet.legacy.conversation_id_str)) { return true; } if (tweet.legacy.retweeted_status_r###lt) { //Remove duplicate tweets from those annoying accounts that retweet their own tweets. (I know, it's for the algo...) //A good account to test with is https://twitter.com/ClownWorld_ if (tweet.core.user_r###lts.r###lt.legacy.screen_name === tweet.legacy.retweeted_status_r###lt.r###lt.core.user_r###lts.r###lt.legacy.screen_name && new Date(tweet.legacy.created_at) - new Date(tweet.legacy.retweeted_status_r###lt.r###lt.legacy.created_at) < 10 * 24 * 60 * 60 * 1000 //10 days ) { return true; } return shouldRemoveTweet(tweet.legacy.retweeted_status_r###lt.r###lt); } if (tweet.quoted_status_r###lt && shouldRemoveTweet(tweet.quoted_status_r###lt.r###lt)) { return true; } var user = tweet.core.user_r###lts.r###lt.legacy.screen_name.toLowerCase(); var text, entities; if (tweet.note_tweet) { text = tweet.note_tweet.note_tweet_r###lts.r###lt.text; entities = tweet.note_tweet.note_tweet_r###lts.r###lt.entity_set; } else { text = splitter.splitGraphemes(tweet.legacy.full_text).slice(tweet.legacy.display_text_range[0], tweet.legacy.display_text_range[1]).join(""); entities = tweet.legacy.entities; } //Replace shorthand urls by their real links //Go in descending order to not #### up the indices by earlier replacements var urls = entities.urls.sort((a,b) => b.indices[0] - a.indices[0]) for (var url of urls) { text = text.substring(0, url.indices[0]) + url.expanded_url + text.substring(url.indices[1]) } //console.log("Testing if we should remove tweet by '"+user+"' with text: \n"+text); if (removeTweetsWithOnlyEmojis && text.match(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\p{Emoji_Modifier_Base}\p{Format}\u{1F3FB}-\u{1F3FF}\u{1F9B0}-\u{1F9B3}\uFE0F\u20E3\s\p{P}]+$/u) && !tweet.quoted_status_r###lt && !tweet.legacy.entities.media) { return true; } if (removeTweetsWithOnlyMentions && text.match(/^@\w+$/u) && !tweet.quoted_status_r###lt && !tweet.legacy.entities.media) { return true; } if (tweet.legacy.entities.hashtags.length > hashtagLimit) { return true; } if (forbiddenText.some(x => text.toLowerCase().includes(x))) { //console.log("Removed tweet"); return true; } if (forbiddenTextRegex.some(x => text.match(x))) { //console.log("Removed tweet"); return true; } if (accountsWithNoImages.includes(user) && tweet.legacy.entities.media && tweet.legacy.entities.media.every(x => x.expanded_url.includes("/photo/"))) { return true; } if (accountsWithRequiredMedia.includes(user) && !tweet.legacy.entities.media) { return true; } return false; } function fixUser(user, isGraphql) { if (user.__typename !== "User" && isGraphql) { console.error("Unhandled user typename '"+user.__typename+"'"); return; } var userEntities; if (isGraphql) { if (!user?.legacy?.entities) { return; } userEntities = user.legacy.entities; } else { userEntities = user.entities; } //Edit user descriptions to remove the shortlinks if (userEntities.description) { for (let url of userEntities.description.urls) { if (url.expanded_url) { url.url = url.expanded_url; url.display_url = url.expanded_url.replace(/^https?:\/\/(www\.)?/, ""); } } } if (userEntities.url) { for (let url of userEntities.url.urls) { if (url.expanded_url) { url.url = url.expanded_url; url.display_url = url.expanded_url.replace(/^https?:\/\/(www\.)?/, ""); } } } } function fixTweet(tweet) { if (!tweet) { return; } if (tweet.__typename === "TweetWithVisibilityR###lts") { 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") { delete tweet.tweetInterstitial; } tweet = tweet.tweet; } if (tweet.__typename !== "Tweet" && tweet.__typename) { console.error("Unhandled tweet typename '"+tweet.__typename+"'"); return; } if (!tweet.legacy) { //Tweet husk (when a quote tweet quotes another tweet, for example) return; } //console.log("Fixing tweet:", tweet); fixUser(tweet.core.user_r###lts.r###lt, true); if (tweet.birdwatch_pivot) { //It's pretty neat that you can just delete properties and the markup instantly adapts, ngl delete tweet.birdwatch_pivot.callToAction; delete tweet.birdwatch_pivot.footer; tweet.birdwatch_pivot.title = tweet.birdwatch_pivot.shorttitle; //Unfortunately, the full URLs of community notes aren't in the tweet itself. It's another API call } if (tweet.hasOwnProperty("note_tweet")) { //Thank God for this property or this would simply be impossible. //For some reason the full text of the tweet is stored here. So put it in where the webapp is fetching the tweet text //Also put the entities with their indices tweet.legacy.full_text = tweet.note_tweet.note_tweet_r###lts.r###lt.text; tweet.legacy.display_text_range = [0, 9999999]; if ("media" in tweet.legacy.entities) { for (var media of tweet.legacy.entities.media) { if (media.display_url.startsWith("pic.twitter.com/")) { media.indices = [1000000, 1000001]; } } } for (let key of ["user_mentions", "urls", "hashtags", "media", "symbols"]) { if (tweet.note_tweet.note_tweet_r###lts.r###lt.entity_set[key]) { tweet.legacy.entities[key] = tweet.note_tweet.note_tweet_r###lts.r###lt.entity_set[key]; } } } //Good account to test that on: https://twitter.com/AlertesInfos let displayedTweetText = splitter.splitGraphemes(tweet.legacy.full_text).slice(tweet.legacy.display_text_range[0], tweet.legacy.display_text_range[1]).join("") let oldLength = splitter.countGraphemes(displayedTweetText) let oldLengthMedia = [...displayedTweetText].length //media indices are apparently based on this length? let oldLengthRichText = displayedTweetText.length //apparently rich text indices are based on js calculated length, which counts emojis as 2 chars //console.log("oldlength", oldLength, "oldLengthRichText", oldLengthRichText) displayedTweetText = displayedTweetText.replace(removeTextAtStart, "") let lengthDifference = oldLength - splitter.countGraphemes(displayedTweetText) let lengthDifferenceMedia = oldLengthMedia - [...displayedTweetText].length let lengthDifferenceRichText = oldLengthRichText - displayedTweetText.length //console.log(lengthDifference, lengthDifferenceRichText) tweet.legacy.full_text = splitter.splitGraphemes(tweet.legacy.full_text).slice(0, tweet.legacy.display_text_range[0]).join("")+displayedTweetText+splitter.splitGraphemes(tweet.legacy.full_text).slice(tweet.legacy.display_text_range[1]).join("") tweet.legacy.display_text_range[1] -= lengthDifference for (let key of ["user_mentions", "urls", "hashtags", "media", "symbols"]) { if (tweet.legacy.entities[key]) { for (let entity of tweet.legacy.entities[key]) { entity.indices[0] -= lengthDifferenceMedia entity.indices[1] -= lengthDifferenceMedia } } if (tweet.legacy.extended_entities && tweet.legacy.extended_entities[key]) { for (let entity of tweet.legacy.extended_entities[key]) { entity.indices[0] -= lengthDifferenceMedia entity.indices[1] -= lengthDifferenceMedia } } } if (tweet.hasOwnProperty("note_tweet")) { //Tweets with more than 250 chars are displayed using the note tweet, so put back the modified full text to the note tweet property //https://twitter.com/MarioNawfal/status/1733973012050022523 tweet.note_tweet.note_tweet_r###lts.r###lt.text = tweet.legacy.full_text; for (let key of ["user_mentions", "urls", "hashtags", "media", "symbols"]) { if (tweet.legacy.entities[key]) { tweet.note_tweet.note_tweet_r###lts.r###lt.entity_set[key] = tweet.legacy.entities[key]; } } if (tweet.note_tweet.note_tweet_r###lts.r###lt.richtext) { for (let richTextObject of tweet.note_tweet.note_tweet_r###lts.r###lt.richtext.richtext_tags) { richTextObject.from_index -= lengthDifferenceRichText richTextObject.to_index -= lengthDifferenceRichText } } } //Remove shortlinks for urls for (let url of tweet.legacy.entities.urls) { if (url.expanded_url) { url.display_url = url.expanded_url.replace(/^https?:\/\/(www\.)?/, ""); url.url = url.expanded_url; } } if (tweet.legacy.quoted_status_permalink) { tweet.legacy.quoted_status_permalink.display = tweet.legacy.quoted_status_permalink.expanded.replace(/^https?:\/\//, "") } if (tweet.quoted_status_r###lt) { fixTweet(tweet.quoted_status_r###lt.r###lt); } if (tweet.legacy.retweeted_status_r###lt) { fixTweet(tweet.legacy.retweeted_status_r###lt.r###lt); } } function patchApiR###lt(apiPath, data) { if (apiPath === "UserByScreenName" || apiPath === "UserByRestId") { if (data.data.user) { fixUser(data.data.user.r###lt, true); } return data; } if (apiPath === "recommendations.json") { for (var user of data) { fixUser(user.user, false); } return data; } var timeline; if (apiPath === "TweetDetail") { //When viewing a tweet directly. //https://twitter.com/atensnut/status/1723692342727647277 timeline = data.data.threaded_conversation_with_injections_v2; } else if (apiPath === "HomeTimeline" || apiPath === "HomeLatestTimeline") { //"For you" and "Following" respectively, of the twitter homepage timeline = data.data.home.home_timeline_urt; } else if (apiPath === "UserTweets" || apiPath === "UserTweetsAndReplies" || apiPath === "UserMedia" || apiPath === "Likes") { //When viewing a user directly. //https://twitter.com/elonmusk //https://twitter.com/elonmusk/with_replies //https://twitter.com/elonmusk/media //https://twitter.com/elonmusk/likes timeline = data.data.user.r###lt.timeline_v2.timeline; } else if (apiPath === "UserHighlightsTweets") { //https://twitter.com/elonmusk/highlights timeline = data.data.user.r###lt.timeline.timeline; } else if (apiPath === "SearchTimeline") { //When viewing quoted tweets, or when literally searching tweets. //https://twitter.com/elonmusk/status/1721042240535973990/quotes //https://twitter.com/search?q=hormozi&src=typed_query timeline = data.data.search_by_raw_query.search_timeline.timeline; } else { console.error("Unhandled api path '"+apiPath+"'") return data; } if (!timeline) { console.error("No timeline found"); return data; } for (var instruction of timeline.instructions) { if (instruction.type === "TimelineClearCache" || instruction.type === "TimelineTerminateTimeline" || instruction.type === "TimelineReplaceEntry") { //do nothing } else if (instruction.type === "TimelineShowAlert") { for (let user of instruction.usersR###lts) { fixUser(user.r###lt, true); } } else if (instruction.type === "TimelinePinEntry") { //Sometimes there is an empty pinned entry? Eg https://twitter.com/RyLiberty if (instruction.entry.content.itemContent.tweet_r###lts.r###lt) { console.log(instruction.entry.content.itemContent.tweet_r###lts.r###lt); if (instruction.entry.content.itemContent.tweet_r###lts.r###lt.tweet) { instruction.entry.content.itemContent.tweet_r###lts.r###lt = instruction.entry.content.itemContent.tweet_r###lts.r###lt.tweet } if (accountsWithNoPinnedTweets.includes(instruction.entry.content.itemContent.tweet_r###lts.r###lt.core.user_r###lts.r###lt.legacy.screen_name.toLowerCase())) { instruction.shouldBeRemoved = true; } else if (shouldRemoveTweet(instruction.entry.content.itemContent.tweet_r###lts.r###lt)) { instruction.shouldBeRemoved = true; } else { fixTweet(instruction.entry.content.itemContent.tweet_r###lts.r###lt); } } } else if (instruction.type === "TimelineAddToModule") { //Only seen on threads with longer than 30 tweets. Eg: https://twitter.com/Cobratate/status/1653053914411941897 for (let item of instruction.moduleItems) { if (item.entryId.startsWith(instruction.moduleEntryId+"-tweet-")) { fixTweet(item.item.itemContent.tweet_r###lts.r###lt); } else if (item.entryId.startsWith(instruction.moduleEntryId+"-cursor-")) { //do nothing } else { console.error("Unhandled item entry id '"+item.entryId+"'"); } } } else if (instruction.type === "TimelineAddEntries") { for (var entry of instruction.entries) { if (entry.entryId.startsWith("tweet-")) { if (apiPath !== "TweetDetail" && shouldRemoveTweet(entry.content.itemContent.tweet_r###lts.r###lt)) { //If TweetDetail, then the tweet is either the tweet itself, or the tweet(s) it is replying to. //Do not check them for deletion because it would make the tweet have no sense. entry.shouldBeRemoved = true; } if (!entry.shouldBeRemoved) { fixTweet(entry.content.itemContent.tweet_r###lts.r###lt); } } else if (entry.entryId.startsWith("conversationthread-") || entry.entryId.startsWith("tweetdetailrelatedtweets-")) { for (let item of entry.content.items) { if (item.entryId.startsWith(entry.entryId+"-tweet-")) { if (shouldRemoveTweet(item.item.itemContent.tweet_r###lts.r###lt)) { item.shouldBeRemoved = true; } else { fixTweet(item.item.itemContent.tweet_r###lts.r###lt) } } else if (item.entryId.startsWith(entry.entryId+"-cursor-")) { //do nothing } else { console.error("Unhandled item entry id '"+item.entryId+"'"); } } entry.content.items = entry.content.items.filter(x => !x.shouldBeRemoved) if (entry.content.items.length === 0) { entry.shouldBeRemoved = true } } else if (entry.entryId.startsWith("profile-conversation-") || entry.entryId.startsWith("home-conversation-")) { //Only remove tweets in a conversation if it is the last tweet of the conversation. (Else, the tweets after won't make sense.) let hasTweetBeenKept = false; for (let i = entry.content.items.length - 1; i >= 0; i--) { if (!hasTweetBeenKept) { if (shouldRemoveTweet(entry.content.items[i].item.itemContent.tweet_r###lts.r###lt)) { entry.content.items[i].shouldBeRemoved = true; } else { hasTweetBeenKept = true; } } if (!entry.content.items[i].shouldBeRemoved) { fixTweet(entry.content.items[i].item.itemContent.tweet_r###lts.r###lt); } } entry.content.items = entry.content.items.filter(x => !x.shouldBeRemoved) if (entry.content.items.length === 0) { entry.shouldBeRemoved = true } } else if (entry.entryId.startsWith("toptabsrpusermodule-")) { for (let item of entry.content.items) { fixUser(item.item.itemContent.user_r###lts.r###lt, true); } } else if (entry.entryId.startsWith("who-to-follow-") || entry.entryId.startsWith("who-to-subscribe-") || entry.entryId.startsWith("promoted-tweet-") || entry.entryId === "messageprompt-premium-plus-upsell-prompt") { entry.shouldBeRemoved = true; } else if (entry.entryId.startsWith("cursor-") || entry.entryId.startsWith("label-") || entry.entryId.startsWith("relevanceprompt-")) { //nothing to do } else { console.error("Unhandled entry id '"+entry.entryId+"'") } } instruction.entries = instruction.entries.filter(x => !x.shouldBeRemoved); } else { console.error("Unhandled instruction type '"+instruction.type+"'"); } } timeline.instructions = timeline.instructions.filter(x => !x.shouldBeRemoved); return data; } //It's absolutely crazy that the only viable way of expanding a tweet is to hook the XMLHttpRequest object itself. //Big thanks to https://stackoverflow.com/a/28513219/4851350 because all other methods did not work. //Apparently it's only in firefox. If it doesn't work in Chrome, cry about it. /*const OriginalXMLHttpRequest = unsafeWindow.XMLHttpRequest; class XMLHttpRequest extends OriginalXMLHttpRequest { get responseText() { // If the request is not done, return the original responseText if (this.readyState !== 4) { return super.responseText; } console.log(super.responseText); return super.responseText.replaceAll("worse", "owo"); } } unsafeWindow.XMLHttpRequest = XMLHttpRequest;*/ try { var open_prototype = XMLHttpRequest.prototype.open XMLHttpRequest.prototype.open = function() { this.addEventListener('readystatechange', function(event) { if ( this.readyState === 4 ) { var urlPath = event.target.responseURL ? (new URL(event.target.responseURL)).pathname : ""; var apiPath = urlPath.split("/").pop() //console.log(urlPath); if (urlPath.startsWith("/i/api/") && ["UserTweets", "HomeTimeline", "HomeLatestTimeline", "SearchTimeline", "TweetDetail", "UserByScreenName", "UserByRestId", "UserTweetsAndReplies", "UserMedia", "Likes", "UserHighlightsTweets", "recommendations.json"].includes(apiPath)) { var originalResponseText = event.target.responseText; console.log(apiPath, JSON.parse(originalResponseText)); originalResponseText = patchApiR###lt(apiPath, JSON.parse(originalResponseText)); console.log(originalResponseText); Object.defineProperty(this, 'response', {writable: true}); Object.defineProperty(this, 'responseText', {writable: true}); this.response = this.responseText = JSON.stringify(originalResponseText); } } }); return open_prototype.apply(this, arguments); }; } catch (e) { console.error(e); } /*var accessor = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, 'responseText'); Object.defineProperty(unsafeWindow.XMLHttpRequest.prototype, 'responseText', { get: function() { var urlPath = this.responseURL ? (new URL(this.responseURL)).pathname : ""; var apiPath = urlPath.split("/").pop() console.log(urlPath); if (urlPath.startsWith("/i/api/") && ["UserTweets", "HomeTimeline", "HomeLatestTimeline", "SearchTimeline", "TweetDetail", "UserByScreenName", "UserByRestId", "UserTweetsAndReplies", "UserMedia", "Likes", "UserHighlightsTweets", "recommendations.json"].includes(apiPath)) { var originalResponseText = accessor.get.call(this); console.log(apiPath, JSON.parse(originalResponseText)); originalResponseText = patchApiR###lt(apiPath, JSON.parse(originalResponseText)); console.log(originalResponseText); return JSON.stringify(originalResponseText); } else { return accessor.get.call(this); } }, set: function(str) { console.log('set responseText: %s', str); return accessor.set.call(this, str); }, configurable: true });*/ })();