您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Remove unwanted posts from (new) Reddit
// ==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> <wbr> <wbr>=%<wbr> <wbr> <wbr> <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); })();