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 提交的版本。查看 最新版本

// ==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
    });
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址