Twitter auto expand show more text + filter timeline + 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.

La data de 12-11-2023. Vezi ultima versiune.

// ==UserScript==
// @name         Twitter auto expand show more text + filter timeline + remove short urls
// @namespace    zezombye.dev
// @version      0.2
// @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"].map(x => x.toLowerCase());
    //Same but for regex
    const forbiddenTextRegex = [/^follow @\w+ for more hilarious commentaries$/i];
    //Self explanatory
    const accountsWithNoPinnedTweets = ["clownworld_"].map(x => x.toLowerCase());


    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;
            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 (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 fixTweetGraph(obj) {
        if (obj.__typename === "Tweet" && obj.core || obj.__typename === "TweetWithVisibilityResults" && obj.tweet) {

            if (obj.__typename === "TweetWithVisibilityResults") {
                obj = obj.tweet;
            }

            if (obj.birdwatch_pivot) {
                //It's pretty neat that you can just delete properties and the markup instantly adapts, ngl
                delete obj.birdwatch_pivot.callToAction;
                delete obj.birdwatch_pivot.footer;
                obj.birdwatch_pivot.title = obj.birdwatch_pivot.shorttitle;
                //Unfortunately, the full URLs of community notes aren't in the tweet itself. It's another API call
            }

            if (obj.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
                obj.legacy.full_text = obj.note_tweet.note_tweet_results.result.text;
                obj.legacy.display_text_range = [0, 9999999];
                if ("media" in obj.legacy.entities) {
                    for (var media of obj.legacy.entities.media) {
                        if (media.display_url.startsWith("pic.twitter.com/")) {
                            media.indices = [1000000, 1000001];
                        }
                    }
                }
                for (var key of ["user_mentions", "urls", "hashtags", "symbols"]) {
                    obj.legacy.entities[key] = obj.note_tweet.note_tweet_results.result.entity_set[key];
                }
            }

            //Remove shortlinks for urls
            for (let url of obj.legacy.entities.urls) {
                url.display_url = url.expanded_url.replace(/^https?:\/\//, "");
                url.url = url.expanded_url;
            }

            if (obj.legacy.quoted_status_permalink) {
                obj.legacy.quoted_status_permalink.display = obj.legacy.quoted_status_permalink.expanded.replace(/^https?:\/\//, "")
            }

        //Edit user descriptions to remove the shortlinks
        } else if (obj.__typename === "User" && obj.legacy && obj.legacy.entities) {
            if (obj.legacy.entities.description) {
                for (let url of obj.legacy.entities.description.urls) {
                    url.url = url.expanded_url;
                }
            }
            if (obj.legacy.entities.url) {
                for (let url of obj.legacy.entities.url.urls) {
                    url.url = url.expanded_url;
                }
            }

        //Remove "who to follow" shelf
        } else if (obj.entryId && obj.entryId.startsWith("who-to-follow-")) {
            obj.content = {};
        }

        // Recursively iterate over properties
        for (var prop in obj) {
            if (obj.hasOwnProperty(prop) && typeof obj[prop] === "object") {

                //Timeline scrubber. Directly remove all useless tweets
                //Todo: check for entryId.startsWith("profile-conversation-") and delete the whole conversation if deleting the last tweet?

                if (obj[prop].__typename === "Tweet" && obj[prop].core) {
                    if (shouldRemoveTweet(obj[prop])) {
                        delete obj[prop];
                    } else {
                        fixTweetGraph(obj[prop]);
                    }
                } else if (obj[prop].__typename === "TweetWithVisibilityResults" && obj[prop].tweet) {
                    if (shouldRemoveTweet(obj[prop].tweet)) {
                        delete obj[prop];
                    } else {
                        fixTweetGraph(obj[prop]);
                    }

                //Buggy. Todo later
                //} else if (obj.type === "TimelinePinEntry" && accountsWithNoPinnedTweets.includes(obj.entry.content.itemContent.tweet_results.result.core.user_results.result.legacy.screen_name.toLowerCase())) {
                //    delete obj.entry;

                } else {
                    fixTweetGraph(obj[prop]);
                }
            }
        }

        return obj;
    }

    //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() {
            if (this.responseURL.includes("/UserTweets") || this.responseURL.includes("/HomeTimeline")|| this.responseURL.includes("/HomeLatestTimeline") || this.responseURL.includes("/TweetDetail")) {
                var originalResponseText = accessor.get.call(this);
                console.log(JSON.parse(originalResponseText));
                originalResponseText = JSON.stringify(fixTweetGraph(JSON.parse(originalResponseText)));
                console.log(JSON.parse(originalResponseText));
                return originalResponseText;
            } else {
                return accessor.get.call(this);
            }
        },
        set: function(str) {
            console.log('set responseText: %s', str);
            return accessor.set.call(this, str);
        },
        configurable: true
    });
})();