// ==UserScript==
// @name Twitter auto expand show more text + filter tweets + remove short urls
// @namespace zezombye.dev
// @version 0.3
// @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
// ==/UserScript==
(function() {
'use strict';
//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",
].map(x => x.toLowerCase());
//Same but for regex
const forbiddenTextRegex = [
/^follow @\w+ for more hilarious commentaries$/i,
/^GM\.?$/,
];
//Self explanatory
const accountsWithNoPinnedTweets = [
"clownworld_",
].map(x => x.toLowerCase());
const removeTweetsWithOnlyEmojis = true //will not remove tweets with extra info such as quote tweet or media
const hashtagLimit = 15 // remove tweets with more than this amount of hashtags
function shouldRemoveTweet(tweet) {
if (!tweet.legacy) {
//Tweet husk (when a quote tweet quotes another tweet, for example)
return false;
}
//console.log(tweet);
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;
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 = tweet.legacy.full_text.substring(tweet.legacy.display_text_range[0]);
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{Emoji}\s\p{P}]+$/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;
}
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) {
url.url = url.expanded_url;
}
}
if (userEntities.url) {
for (let url of userEntities.url.urls) {
url.url = url.expanded_url;
}
}
}
function fixTweet(tweet) {
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 false;
}
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 (var key of ["user_mentions", "urls", "hashtags", "symbols"]) {
tweet.legacy.entities[key] = tweet.note_tweet.note_tweet_results.result.entity_set[key];
}
}
//Remove shortlinks for urls
for (let url of tweet.legacy.entities.urls) {
url.display_url = url.expanded_url.replace(/^https?:\/\//, "");
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);
}
}
function patchApiResult(apiPath, data) {
if (apiPath === "UserByScreenName") {
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;
}
for (var instruction of timeline.instructions) {
if (instruction.type === "TimelineClearCache" || instruction.type === "TimelineTerminateTimeline" || instruction.type === "TimelineReplaceEntry") {
//do nothing
} else if (instruction.type === "TimelinePinEntry") {
if (accountsWithNoPinnedTweets.includes(instruction.entry.content.itemContent.tweet_results.result.core.user_results.result.legacy.screen_name.toLowerCase())) {
instruction.shouldBeRemoved = true;
}
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 === "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 (shouldRemoveTweet(item.item.itemContent.tweet_results.result)) {
item.shouldBeRemoved = true;
} else {
fixTweet(item.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("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("promoted-tweet-")) {
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.
var accessor = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, 'responseText');
Object.defineProperty(XMLHttpRequest.prototype, 'responseText', {
get: function() {
var urlPath = this.responseURL ? (new URL(this.responseURL)).pathname : "";
var apiPath = urlPath.split("/").pop()
if (urlPath.startsWith("/i/api/") && ["UserTweets", "HomeTimeline", "HomeLatestTimeline", "SearchTimeline", "TweetDetail", "UserByScreenName", "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
});
})();