- // ==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/grapheme-splitter@1.0.4/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_result) {
- //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_results.result.legacy.screen_name === tweet.legacy.retweeted_status_result.result.core.user_results.result.legacy.screen_name
- && new Date(tweet.legacy.created_at) - new Date(tweet.legacy.retweeted_status_result.result.legacy.created_at) < 10 * 24 * 60 * 60 * 1000 //10 days
- ) {
- return true;
- }
-
-
- return shouldRemoveTweet(tweet.legacy.retweeted_status_result.result);
- }
-
- if (tweet.quoted_status_result && shouldRemoveTweet(tweet.quoted_status_result.result)) {
- return true;
- }
-
- var user = tweet.core.user_results.result.legacy.screen_name.toLowerCase();
- var text, entities;
- if (tweet.note_tweet) {
- text = tweet.note_tweet.note_tweet_results.result.text;
- entities = tweet.note_tweet.note_tweet_results.result.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 fuck 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_result && !tweet.legacy.entities.media) {
- return true;
- }
- if (removeTweetsWithOnlyMentions && text.match(/^@\w+$/u) && !tweet.quoted_status_result && !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 === "TweetWithVisibilityResults") {
- 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_results.result, 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_results.result.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_results.result.entity_set[key]) {
- tweet.legacy.entities[key] = tweet.note_tweet.note_tweet_results.result.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_results.result.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_results.result.entity_set[key] = tweet.legacy.entities[key];
- }
- }
- if (tweet.note_tweet.note_tweet_results.result.richtext) {
- for (let richTextObject of tweet.note_tweet.note_tweet_results.result.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_result) {
- fixTweet(tweet.quoted_status_result.result);
- }
-
- if (tweet.legacy.retweeted_status_result) {
- fixTweet(tweet.legacy.retweeted_status_result.result);
- }
- }
-
- function patchApiResult(apiPath, data) {
-
- if (apiPath === "UserByScreenName" || apiPath === "UserByRestId") {
- if (data.data.user) {
- fixUser(data.data.user.result, 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.result.timeline_v2.timeline;
- } else if (apiPath === "UserHighlightsTweets") {
- //https://twitter.com/elonmusk/highlights
- timeline = data.data.user.result.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.usersResults) {
- fixUser(user.result, true);
- }
- } else if (instruction.type === "TimelinePinEntry") {
- //Sometimes there is an empty pinned entry? Eg https://twitter.com/RyLiberty
- if (instruction.entry.content.itemContent.tweet_results.result) {
- console.log(instruction.entry.content.itemContent.tweet_results.result);
- if (instruction.entry.content.itemContent.tweet_results.result.tweet) {
- instruction.entry.content.itemContent.tweet_results.result = instruction.entry.content.itemContent.tweet_results.result.tweet
- }
- if (accountsWithNoPinnedTweets.includes(instruction.entry.content.itemContent.tweet_results.result.core.user_results.result.legacy.screen_name.toLowerCase())) {
- instruction.shouldBeRemoved = true;
- } else if (shouldRemoveTweet(instruction.entry.content.itemContent.tweet_results.result)) {
- instruction.shouldBeRemoved = true;
- } else {
- fixTweet(instruction.entry.content.itemContent.tweet_results.result);
- }
- }
-
- } 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_results.result);
- } 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_results.result)) {
- //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_results.result);
- }
-
- } 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_results.result)) {
- item.shouldBeRemoved = true;
- } else {
- fixTweet(item.item.itemContent.tweet_results.result)
- }
- } 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_results.result)) {
- entry.content.items[i].shouldBeRemoved = true;
- } else {
- hasTweetBeenKept = true;
- }
- }
- if (!entry.content.items[i].shouldBeRemoved) {
- fixTweet(entry.content.items[i].item.itemContent.tweet_results.result);
- }
- }
- 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_results.result, 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 = patchApiResult(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 = patchApiResult(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
- });*/
- })();