Weed Out Reddit Posts

Remove unwanted posts from (new) Reddit

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Weed Out Reddit Posts
// @namespace    github.com/JasonAMelancon
// @version      2025-09-06
// @description  Remove unwanted posts from (new) Reddit
// @author       Jason Melancon
// @license      GNU AGPLv3
// @match        http*://www.reddit.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function() {
    "use strict";

    const DEBUG = false;
    // The msg is in a lambda expression body so that string interpolation only happens if we
    // actually print the message (lazy evaluation).
    function debugLog(lambdifiedMsg) {
        if (DEBUG) console.log(lambdifiedMsg());
    }

    /* REMOVE UNWANTED ELEMENTS */

    let regexList = GM_getValue("regexList", /*default = */[]);
    let logRemovals = GM_getValue("logRemovals", /*default = */true);
    let namedSubredditLists = {};
    let subredditList = []; // parsed anew each line of options; restricts post removal to these subreddits
    let articles = document.querySelectorAll("article");
    const Target = Object.freeze({
        Title: "Title", // enum value
        Flair: "Flair"  // enum value
    });

    // try to get named lists of subreddits from options
    function parseSubredditLists(lines) {
        for (let line of lines) {
            let matches = [];
            if (matches = line.match(/(\w+)\s*=\s*\{\s*(.*?)\s*}\s*$/)) {
                let stringSplit = matches[2].split(/[,;| ]/).map(x => x.trim()).filter(x => !!x);
                namedSubredditLists["_" + matches[1]] = stringSplit;
            }
        }
    }

    // get pattern, intended search target, and subreddit list --
    // assemble list of subreddits to use with the current RegExp in options
    //     options lines have three parts: /pattern/ %= target =% { subreddits }
    //     everything is optional; if the pattern is absent, it will be ""
    function parseRegExpLine(line) {
        // already handled by previous parse for named lists
        if (/\w+\s*=\s*{.*}/.test(line)) {
            return [null, null];
        }

        let matches, pattern, target, intendedTarget, list;
        if (matches = line.match(/^\s*(.*?)(?:\s*%=\s*(\w+)\s*=%\s*)?(?:\s+{\s*(.*?)\s*})?\s*$/)) {
            pattern = matches[1] ?? "";
            intendedTarget = matches[2];
            list = matches[3];

            // determine what to search in -- currently, title (default) or flair
            target = Target.Title; // search for matching title by default
            if (intendedTarget) {
                // get key by value
                let key = Object.keys(Target)
                                .find(key => Target[key].toLowerCase() === intendedTarget.toLowerCase());
                target = Target[key] ?? Target.Title; // default again if user specified bogus target
            }

            // split list
            if (list) {
                subredditList = list.split(/[,;| ]/).map(x => x.trim()).filter(x => !!x);
                debugLog(() => `sub list = ${list}`); // DEBUG
            } else {
                debugLog(() => "emptying subredditList"); // DEBUG
                subredditList = [];
            }

            // replace named list with its contents
            let token;
            let subredditListLen = subredditList.length;
            for (let i = 0; i < subredditListLen; i++) {
                token = "_" + subredditList[i];
                if (namedSubredditLists[token]) {
                    subredditList.splice(i, 1);
                    subredditList.push(...namedSubredditLists[token]);
                    // you can't put one named group inside another, so stop
                    // before you reach the replaced ones
                    i--;
                    subredditListLen--;
                }
            }
        }
        return [pattern, target];
    }

    function removeArticles(articles, optionsLines) {
        for (let i = 0; i < articles.length; i++) {
            let target, pattern;
            for (let line of optionsLines) {
                [pattern, target] = parseRegExpLine(line);
                if (pattern === null) continue; // not a regex line

                try {
                    new RegExp(pattern); // validate RegExp
                } catch (_) {
                    let err = `Invalid RegExp: ${pattern}`;
                    console.log(err);
                    alert(err);
                    return;
                }

                let postTitle = articles[i].getAttribute("aria-label");
                let flairList = [];
                let targetText = "";
                let deletePost = false;
                switch (target) {
                    case Target.Flair:
                        // this is complicated by the fact that Reddit posts can have
                        //   - no flair
                        //   - empty flair
                        //   - multiple flairs, any of which can be empty
                        flairList = Array.from(articles[i].querySelectorAll(".flair-content")).map(el => el.textContent);
                        // if the pattern is length 0 and the post has no flair,
                        // remove the post (this is the only way to remove posts with no flair)
                        if (flairList.length === 0 && pattern.length === 0) {
                            targetText = "";
                            deletePost = true;
                            break;
                        }
                        // get rid of all empty flair (including strange unprintable things)
                        flairList = flairList.map(f => f.trim())
                                             .filter(f => !f.match(/^[^a-zA-Z0-9!@#\$%\^&\*\(\)\[\]\{\}\?\.;,\+-\\\/':"`~<>]*$/));
                        // if the flairs are all empty and the pattern matches an empty string,
                        // remove the post
                        if (flairList.length === 0 && "".match(pattern)) {
                            targetText = "";
                            deletePost = true;
                            break;
                        }
                        // from here onward, an empty pattern isn't helpful
                        if (pattern.length === 0) {
                            break;
                        }
                        // if there is at least one non-empty flair that matches the non-empty pattern,
                        // remove the post
                        for (let j = 0; j < flairList.length; ++j) {
                            if (flairList[j].match(pattern)) {
                                targetText = flairList[j];
                                deletePost = true;
                                break;
                            }
                        }
                        break;
                    case Target.Title:
                    default:
                        if (pattern && postTitle.match(pattern)) {
                            targetText = postTitle;
                            deletePost = true;
                        }
                        break;
                }
                debugLog(() => `article ${i}: ${postTitle}|${pattern}|${flairList}|{${subredditList}}`); // DEBUG
                if (!deletePost) continue;

                debugLog(() => `Match! ${targetText} == ${pattern}`); // DEBUG
                let subreddit = articles[i].querySelector("shreddit-post").getAttribute("subreddit-name");
                if (subredditList.length > 0) {
                    if (!subredditList.map(x => x.toLowerCase()).includes(subreddit.toLowerCase())) {
                        debugLog(() => `Not removed: sub = ${subreddit}`) // DEBUG
                        continue; // subreddit list doesn't include this post's subreddit
                    }
                }
                const hr = articles[i].nextElementSibling;
                if (hr && hr.tagName == "HR") {
                    hr.remove();
                }
                articles[i].remove();
                if (logRemovals) {
                    let flairs = (target === Target.Flair) ? ` (flair:${flairList.join(", ")})` : "";
                    console.log(`Userscript removed "${postTitle}"${flairs} in r/${subreddit}`);
                }
                break;
            }
        }
    }

    // get named lists of subreddits
    parseSubredditLists(regexList);

    // remove articles from initial page load, before scrolling
    removeArticles(articles, regexList);

    // remove articles that appear when scrolling
    new MutationObserver(mutationList => {
        debugLog(() => `${mutationList.length} new mutations`); // DEBUG
        for (let mutation of mutationList) {
            if (mutation.type == "childList") {
                const additions = Array.from(mutation.addedNodes);
                articles = additions.reduce((accumulator, currentNode) => {
                    // added nodes could be articles, elements that contain articles, or neither
                    if (currentNode.nodeType === Node.ELEMENT_NODE) {
                        if (currentNode.tagName === "ARTICLE") {
                            return accumulator.concat(currentNode); // added node is article
                        }
                        const containedArticles = Array.from(currentNode.querySelectorAll("article"));
                        return accumulator.concat(containedArticles); // added node has possible descendent articles
                    }
                    return accumulator; // added node is not an element
                }, []);
                debugLog(() => `${articles.length} new articles`); // DEBUG
                if (articles.length == 0) {
                    continue;
                }
                regexList = GM_getValue("regexList", /*default = */[]); // refresh in case user updated options
                removeArticles(articles, regexList);
            }
        }
    }).observe(document.body, { childList: true, subtree: true });

    /* SET SCRIPT OPTIONS */

    // create the options page
    const optionsHtml = `
        <!DOCTYPE html>
        <html>
        <head>
            <title>Script Options</title>
            <style>
                #options {
                    /* for some reason this is required for checkbox alignment */
                }
                #options label {
                    all: unset;
                    font-size: 9pt;
                }
                #options label[for="regexList"] {
                    display: block;
                    margin-bottom: 10px;
                }
                #options textarea {
                    background-color: black;
                    display: block;
                    margin-bottom: 5px;
                    font-size: 9pt;
                    font-family: 'Lucida Console', Monaco, monospace;
                }
                #options button {
                    margin-top: 10px;
                    border-radius: 4px;
                    padding-left: 10px;
                    padding-right: 10px;
                }
                #options input[type="checkbox"],
                #options input[type="checkbox"] + label {
                    margin-top: 12px;
                    display: inline-block;
                }
                #options input[type="checkbox"] {
                    margin-left: 0px;
                    position: relative;
                    top: -6px;
                }
                #options p {
                    line-height: initial;
                    font-size: 8pt;
                    margin-top: 5pt;
                    margin-bottom: 5pt;
                }
                #options p#note {
                    color: color-mix(in srgb, currentColor, red 20%);
                }
                #options p > span {
                    font-family: 'Lucida Console', Monaco, monospace;
                }
                #options p > span#jokes {
                    font-family: unset;
                    white-space: nowrap;
                }
                #options form div {
                    max-width: 350px;
                    box-sizing: border-box;
                }
                #options div:has( > code) {
                    line-height: initial;
                    background-color: black;
                }
                #options code {
                    all: unset;
                    font-size: 8pt;
                    font-family: 'Lucida Console', Monaco, monospace;
                    color: inherit;
                    background-color: inherit;
                    border: 0px;
                }
            </style>
        </head>
        <body>
            <div id="options">
                <h1>Script Options</h1>
                <form id="optionsForm">
                    <label for="regexList">
                        Posts will be hidden if title matches one of these <a><strong>reg</strong>ular <strong>ex</strong>pressions</a>:
                    </label>
                    <textarea id="regexList" name="regexList" rows="5" cols="33" spellcheck="false"></textarea>
                    <div>
                        <p>You can follow a <a><strong>regex</strong></a> with a list of subreddits in curly braces. In that case,
                            the pattern will only be used to remove posts from those subreddits.</p>
                        <p>Not only that, but you can add lines that create named lists of subreddits, and then put
                            these names after a regex in place of the list they represent, like so:<p>
                        <div>
                            <code>favorites = { funny, politics }<br>
                                  [tT]rump { favorites, rant }</code>
                        </div>
                        <p>If you'd rather search in something other than the post title, enclose the alternative search target like
                            <span>%= this =%</span> after the regex but before any list of subreddits. Currently, the target can either be
                            <span>title</span> or <span>flair</span>. The following example removes all jokes except ones with "long" flair in
                            <span id="jokes">r/jokes:</span></p>
                        <div>
                            <code>^(?!\\s*[Ll]ong\\s*$)(?!\\s*$).+ %= flair=% {jokes}<br>
                                  %= flair<wbr>&nbsp;<wbr>&nbsp;<wbr>=%<wbr>&nbsp;<wbr>&nbsp;<wbr>&nbsp;<wbr>{ jokes }</code>
                        </div>
                        <p>The second line above takes care of removing posts with no flair at all.</p>
                        <p id="note">Note: Do not enclose your regex in <span>/slashes/</span> unless the slashes are part of the search!</p>
                    </div>
                    <input id="logCheckbox" name="logCheckbox" type="checkbox">
                    <label for="logCheckbox">
                        Log removed items to the Developer Tools console
                    </label>
                    <div>
                        <button type="submit">Save</button>
                        <button id="closeOptions">Close</button>
                    </div>
                </form>
            <div>
        </body>
        </html>
    `;

    function openOptionsInterface() {
        // create a modal for the options interface. Use an in-page modal because
        // - it doesn't use GM_openInTab because Firefox doesn't allow data: URLs,
        //   so the HTML would have to be in a separate file
        // - it doesn't use a separate HTML file, because I have no idea how to install
        //   that along with a userscript, and the userscript can't generate one
        // - it doesn't use a popup window, because those are typically blocked on a
        //   per-site basis by the browser settings
        const modal = document.createElement("div");
        modal.style.position = "fixed";
        modal.style.top = "0";
        modal.style.left = "0";
        modal.style.width = "100%";
        modal.style.height = "100%";
        modal.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
        modal.style.zIndex = "9999";
        modal.style.display = "flex";
        modal.style.justifyContent = "center";
        modal.style.alignItems = "center";

        const scrollBox = document.createElement("div");
        scrollBox.style.maxHeight = "100vh";
        scrollBox.style.overflowY = "auto";
        scrollBox.style.scrollbarGutter = "stable";
        scrollBox.style.padding = "0px";
        scrollBox.style.border = "0px";
        scrollBox.style.margin = "0px";

        const optionsBox = document.createElement("div");
        optionsBox.id = "options";
        optionsBox.style.backgroundColor = "hsl(from thistle h s calc(l - .90*l))";
        optionsBox.style.padding = "20px";
        optionsBox.style.borderRadius = "5px";
        optionsBox.style.boxShadow = "0 0 10px rgba(0, 0, 0, 0.8)";
        optionsBox.innerHTML = optionsHtml;

        // fill text entry with saved value, if any
        const regexArea = optionsBox.querySelector("textarea");
        regexArea.value = GM_getValue("regexList", /*default = */[]).join("\n");
        // place text entry cursor
        if (typeof regexArea.setSelectionRange === "function") {
            regexArea.focus();
            regexArea.setSelectionRange(0, 0);
        } else if (typeof regexArea.createTextRange === "function") {
            const range = regexArea.createTextRange();
            range.moveStart('character', 0);
            range.select();
        }

        // set checkbox to saved value (defaults to checked)
        const logCheckbox = optionsBox.querySelector("input#logCheckbox");
        logCheckbox.checked = GM_getValue("logRemovals", /*default = */true);

        // set up explanatory links about regular expressions
        const regexLinks = optionsBox.querySelectorAll("a");
        regexLinks.forEach(a => {
            a.href = "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions";
            a.target = "_blank";
            a.style.color = "inherit";
        });

        modal.appendChild(scrollBox);
        scrollBox.appendChild(optionsBox);
        document.body.appendChild(modal);

        regexArea.focus();

        // set up example text style
        optionsBox.querySelectorAll("#options div:has( > code)").forEach(el => {
            el.style.width = "100%";
            el.style.padding = "10px";
            el.style.paddingTop = "3px";
            el.style.paddingBottom = "6px";
        });

        // add button event listeners to set handlers
        // (button listeners are removed when the modal is removed/closed)
        function addButtonHandlers() {
            const form = document.getElementById("optionsForm");
            if (form) {
                // handle form submission (save options)
                form.addEventListener("submit", function handleFormSubmit(event) {
                    event.preventDefault();
                    let newRegexList = document.getElementById("regexList").value.split("\n");
                    newRegexList = newRegexList.filter(item => item.trim() !== "");
                    GM_setValue("regexList", newRegexList);
                    GM_setValue("logRemovals", logCheckbox.checked);
                    alert("Options saved!");
                });
                // close modal
                document.getElementById("closeOptions").addEventListener("click", function() {
                    document.body.removeChild(modal);
                    // update display using new settings
                    regexList = GM_getValue("regexList", /*default = */[]);
                    logRemovals = GM_getValue("logRemovals", /*default = */true);
                    parseSubredditLists(regexList);
                    removeArticles(articles, regexList);
                });
            }
            // opening options adds this new listener every time, so remove every time
            document.removeEventListener("DOMContentLoaded", addButtonHandlers);
        }

        // decide when to add form's event listeners
        if (document.readyState === "loading") {
            // loading hasn't finished yet
            document.addEventListener("DOMContentLoaded", addButtonHandlers);
        } else {
            // DOMContentLoaded has already fired
            addButtonHandlers();
        }
    }

    // set the options handler
    GM_registerMenuCommand("Options", openOptionsInterface);

})();