您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Automatically hides posts in your Reddit feed based on keywords or subreddits you specify. Supports whitelist entries that override filtering.
- // ==UserScript==
- // @name Reddit Advanced Content Filter
- // @namespace https://greasyfork.org/en/users/567951-stuart-saddler
- // @version 2.8
- // @description Automatically hides posts in your Reddit feed based on keywords or subreddits you specify. Supports whitelist entries that override filtering.
- // @author ...
- // @license MIT
- // @icon https://clipart-library.com/images_k/smoke-clipart-transparent/smoke-clipart-transparent-6.png
- // @supportURL https://greasyfork.org/en/users/567951-stuart-saddler
- // @match *://www.reddit.com/*
- // @match *://old.reddit.com/*
- // @run-at document-end
- // @grant GM.getValue
- // @grant GM.setValue
- // @grant GM_addStyle
- // @grant GM_registerMenuCommand
- // @grant GM_unregisterMenuCommand
- // ==/UserScript==
- (async function () {
- 'use strict';
- console.log('[DEBUG] Script started. Reddit Advanced Content Filter.');
- // -----------------------------------------------
- // Utility: Debounce function to prevent spam calls
- // -----------------------------------------------
- function debounce(func, wait) {
- let timeout;
- return (...args) => {
- clearTimeout(timeout);
- timeout = setTimeout(() => func.apply(this, args), wait);
- };
- }
- // -----------------------
- // Selectors & Script Vars
- // -----------------------
- // NOTE: .thing => old.reddit.com
- // article, div[data-testid="post-container"], shreddit-post => new.reddit.com
- const postSelector = 'article, div[data-testid="post-container"], shreddit-post, .thing';
- let filteredCount = 0;
- let menuCommand = null; // track the menu command ID, so we can unregister if needed
- let processedPosts = new WeakSet();
- let blocklistSet = new Set();
- let keywordPattern = null;
- let whitelistSet = new Set();
- let whitelistPattern = null;
- let pendingUpdates = 0;
- // -----------------------------------
- // Attempt to (re)register the menu item
- // -----------------------------------
- function updateMenuEntry() {
- // If GM_registerMenuCommand is unavailable, just ensure fallback button is present
- if (typeof GM_registerMenuCommand !== 'function') {
- createFallbackButton();
- return;
- }
- // If it is available, let's try to unregister the old one (if supported)
- try {
- if (menuCommand !== null && typeof GM_unregisterMenuCommand === 'function') {
- GM_unregisterMenuCommand(menuCommand);
- }
- } catch (err) {
- // Some userscript managers might not support GM_unregisterMenuCommand at all
- console.warn('[DEBUG] Could not unregister menu command:', err);
- }
- // Register the new menu command with updated blocked count
- menuCommand = GM_registerMenuCommand(`Configure Filter (${filteredCount} blocked)`, showConfig);
- }
- // ----------------------------------------
- // Fallback Button (if menu is unsupported)
- // ----------------------------------------
- function createFallbackButton() {
- // Check if it’s already on the page
- if (document.getElementById('reddit-filter-fallback-btn')) {
- // Just update the label with the new count
- document.getElementById('reddit-filter-fallback-btn').textContent = `Configure Filter (${filteredCount} blocked)`;
- return;
- }
- // Otherwise create a brand new button
- const button = document.createElement('button');
- button.id = 'reddit-filter-fallback-btn';
- button.textContent = `Configure Filter (${filteredCount} blocked)`;
- button.style.cssText = 'position:fixed;top:10px;right:10px;z-index:999999;padding:8px;';
- button.addEventListener('click', showConfig);
- document.body.appendChild(button);
- }
- // ---------------------------------------------------------------------
- // Debounced function to update the menu/fallback button (blocking count)
- // ---------------------------------------------------------------------
- const batchUpdateCounter = debounce(() => {
- updateMenuEntry();
- }, 16);
- // -----------------
- // CSS for Hide Class
- // -----------------
- if (!document.querySelector('style[data-reddit-filter]')) {
- const style = document.createElement('style');
- style.textContent = `
- .content-filtered {
- display: none !important;
- height: 0 !important;
- overflow: hidden !important;
- }
- `;
- style.setAttribute('data-reddit-filter', 'true');
- document.head.appendChild(style);
- }
- // ---------------
- // Build Patterns
- // ---------------
- function getKeywordPattern(keywords) {
- if (keywords.length === 0) return null;
- const escapedKeywords = keywords.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
- // The trailing (s|es|ies)? is used to match common plurals
- return new RegExp(`\\b(${escapedKeywords.join('|')})(s|es|ies)?\\b`, 'i');
- }
- // --------------------------------------------
- // Show the Config Dialog for Block/Whitelist
- // --------------------------------------------
- async function showConfig() {
- const overlay = document.createElement('div');
- overlay.className = 'reddit-filter-overlay';
- Object.assign(overlay.style, {
- position: 'fixed',
- top: 0, left: 0, right: 0, bottom: 0,
- background: 'rgba(0,0,0,0.5)',
- zIndex: '999999'
- });
- const dialog = document.createElement('div');
- dialog.className = 'reddit-filter-dialog';
- Object.assign(dialog.style, {
- position: 'fixed',
- top: '50%', left: '50%',
- transform: 'translate(-50%, -50%)',
- background: 'white',
- padding: '20px',
- borderRadius: '8px',
- zIndex: '1000000',
- boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
- minWidth: '300px',
- maxWidth: '350px',
- fontFamily: 'Arial, sans-serif',
- color: '#333'
- });
- // Basic styling for elements inside the dialog
- dialog.innerHTML = `
- <h2 style="margin-top:0; color:#0079d3;">Reddit Filter: Settings</h2>
- <p><strong>Blocklist:</strong> One entry per line. Matching posts will be hidden.</p>
- <textarea spellcheck="false" id="blocklist" style="width:100%; height:80px; margin-bottom:10px;"></textarea>
- <p><strong>Whitelist:</strong> One entry per line. If matched, post is NOT hidden.</p>
- <textarea spellcheck="false" id="whitelist" style="width:100%; height:80px;"></textarea>
- <div style="display:flex; justify-content:flex-end; margin-top:10px; gap:10px;">
- <button id="cancel-btn" style="padding:6px 12px;">Cancel</button>
- <button id="save-btn" style="padding:6px 12px; background:#0079d3; color:white;">Save</button>
- </div>
- `;
- document.body.appendChild(overlay);
- document.body.appendChild(dialog);
- // Populate with existing data
- dialog.querySelector('#blocklist').value = Array.from(blocklistSet).join('\n');
- dialog.querySelector('#whitelist').value = Array.from(whitelistSet).join('\n');
- const closeDialog = () => {
- overlay.remove();
- dialog.remove();
- };
- // Cancel / overlay click => close
- dialog.querySelector('#cancel-btn').addEventListener('click', closeDialog);
- overlay.addEventListener('click', (e) => {
- // Close if user clicks the overlay, but not if user clicked inside the dialog
- if (e.target === overlay) {
- closeDialog();
- }
- });
- // Save => persist
- dialog.querySelector('#save-btn').addEventListener('click', async () => {
- const blocklistInput = dialog.querySelector('#blocklist').value;
- blocklistSet = new Set(
- blocklistInput
- .split('\n')
- .map(x => x.trim().toLowerCase())
- .filter(x => x.length > 0)
- );
- keywordPattern = getKeywordPattern(Array.from(blocklistSet));
- await GM.setValue('blocklist', Array.from(blocklistSet));
- const whitelistInput = dialog.querySelector('#whitelist').value;
- whitelistSet = new Set(
- whitelistInput
- .split('\n')
- .map(x => x.trim().toLowerCase())
- .filter(x => x.length > 0)
- );
- whitelistPattern = getKeywordPattern(Array.from(whitelistSet));
- await GM.setValue('whitelist', Array.from(whitelistSet));
- closeDialog();
- location.reload(); // easiest way to re-filter everything
- });
- }
- // -----------------------------------------
- // Process an Individual Post (Hide or Not)
- // -----------------------------------------
- function processPost(post) {
- if (!post || processedPosts.has(post)) return;
- processedPosts.add(post);
- const contentText = post.textContent.toLowerCase();
- // If whitelisted => skip
- if (whitelistPattern && whitelistPattern.test(contentText)) return;
- let shouldHide = false;
- // Old + New Reddit subreddit link
- // old.reddit => .tagline a.subreddit
- // new.reddit => a[data-click-id="subreddit"] or a.subreddit
- const subredditElement = post.querySelector('a[data-click-id="subreddit"], a.subreddit, .tagline a.subreddit');
- if (subredditElement) {
- const subName = subredditElement.textContent.trim().replace(/^r\//i, '').toLowerCase();
- if (blocklistSet.has(subName)) {
- shouldHide = true;
- }
- }
- // If not yet hidden => check keywords
- if (!shouldHide && keywordPattern) {
- if (keywordPattern.test(contentText)) {
- shouldHide = true;
- }
- }
- if (shouldHide) {
- hidePost(post);
- }
- }
- // ---------------
- // Hide Post Helper
- // ---------------
- function hidePost(post) {
- post.classList.add('content-filtered');
- const parentArticle = post.closest(postSelector);
- if (parentArticle) {
- parentArticle.classList.add('content-filtered');
- }
- filteredCount++;
- pendingUpdates++;
- batchUpdateCounter();
- }
- // -------------------------------------------
- // Process a Batch of Posts (in small chunks)
- // -------------------------------------------
- async function processPostsBatch(posts) {
- const batchSize = 5;
- for (let i = 0; i < posts.length; i += batchSize) {
- const batch = posts.slice(i, i + batchSize);
- // Use requestIdleCallback to keep page responsive
- await new Promise(resolve => requestIdleCallback(resolve, { timeout: 800 }));
- batch.forEach(processPost);
- }
- }
- const debouncedProcess = debounce((posts) => {
- processPostsBatch(Array.from(posts));
- }, 100);
- // ----------------------------
- // Initialization (load config)
- // ----------------------------
- async function init() {
- try {
- const loadedBlocklist = await GM.getValue('blocklist', []);
- blocklistSet = new Set(loadedBlocklist.map(x => x.toLowerCase()));
- keywordPattern = getKeywordPattern(Array.from(blocklistSet));
- const loadedWhitelist = await GM.getValue('whitelist', []);
- whitelistSet = new Set(loadedWhitelist.map(x => x.toLowerCase()));
- whitelistPattern = getKeywordPattern(Array.from(whitelistSet));
- } catch (err) {
- console.error('[DEBUG] Error loading saved data:', err);
- }
- // Try to create a menu entry or fallback button (zero blocked initially)
- updateMenuEntry();
- // On old Reddit, top-level posts appear under #siteTable
- // On new Reddit, there's .main-content
- const observerTarget = document.querySelector('.main-content')
- || document.querySelector('#siteTable')
- || document.body;
- const observer = new MutationObserver((mutations) => {
- const newPosts = new Set();
- for (const mutation of mutations) {
- for (const node of mutation.addedNodes) {
- if (node.nodeType === Node.ELEMENT_NODE) {
- if (node.matches?.(postSelector)) {
- newPosts.add(node);
- }
- node.querySelectorAll?.(postSelector).forEach(p => newPosts.add(p));
- }
- }
- }
- if (newPosts.size > 0) {
- debouncedProcess(newPosts);
- }
- });
- observer.observe(observerTarget, { childList: true, subtree: true });
- // Process any existing posts on load
- const initialPosts = document.querySelectorAll(postSelector);
- if (initialPosts.length > 0) {
- debouncedProcess(initialPosts);
- }
- console.log('[DEBUG] Initialization complete. Now filtering posts...');
- }
- await init();
- })();