// ==UserScript==
// @name Reddit Advanced Content Filter
// @namespace https://gf.qytechs.cn/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://gf.qytechs.cn/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();
})();