Reddit Advanced Content Filter

Automatically hides posts in your Reddit feed based on keywords or subreddits you specify. Supports whitelist entries that override filtering.

  1. // ==UserScript==
  2. // @name Reddit Advanced Content Filter
  3. // @namespace https://greasyfork.org/en/users/567951-stuart-saddler
  4. // @version 2.8
  5. // @description Automatically hides posts in your Reddit feed based on keywords or subreddits you specify. Supports whitelist entries that override filtering.
  6. // @author ...
  7. // @license MIT
  8. // @icon https://clipart-library.com/images_k/smoke-clipart-transparent/smoke-clipart-transparent-6.png
  9. // @supportURL https://greasyfork.org/en/users/567951-stuart-saddler
  10. // @match *://www.reddit.com/*
  11. // @match *://old.reddit.com/*
  12. // @run-at document-end
  13. // @grant GM.getValue
  14. // @grant GM.setValue
  15. // @grant GM_addStyle
  16. // @grant GM_registerMenuCommand
  17. // @grant GM_unregisterMenuCommand
  18. // ==/UserScript==
  19.  
  20. (async function () {
  21. 'use strict';
  22.  
  23. console.log('[DEBUG] Script started. Reddit Advanced Content Filter.');
  24.  
  25. // -----------------------------------------------
  26. // Utility: Debounce function to prevent spam calls
  27. // -----------------------------------------------
  28. function debounce(func, wait) {
  29. let timeout;
  30. return (...args) => {
  31. clearTimeout(timeout);
  32. timeout = setTimeout(() => func.apply(this, args), wait);
  33. };
  34. }
  35.  
  36. // -----------------------
  37. // Selectors & Script Vars
  38. // -----------------------
  39. // NOTE: .thing => old.reddit.com
  40. // article, div[data-testid="post-container"], shreddit-post => new.reddit.com
  41. const postSelector = 'article, div[data-testid="post-container"], shreddit-post, .thing';
  42.  
  43. let filteredCount = 0;
  44. let menuCommand = null; // track the menu command ID, so we can unregister if needed
  45. let processedPosts = new WeakSet();
  46.  
  47. let blocklistSet = new Set();
  48. let keywordPattern = null;
  49.  
  50. let whitelistSet = new Set();
  51. let whitelistPattern = null;
  52.  
  53. let pendingUpdates = 0;
  54.  
  55. // -----------------------------------
  56. // Attempt to (re)register the menu item
  57. // -----------------------------------
  58. function updateMenuEntry() {
  59. // If GM_registerMenuCommand is unavailable, just ensure fallback button is present
  60. if (typeof GM_registerMenuCommand !== 'function') {
  61. createFallbackButton();
  62. return;
  63. }
  64.  
  65. // If it is available, let's try to unregister the old one (if supported)
  66. try {
  67. if (menuCommand !== null && typeof GM_unregisterMenuCommand === 'function') {
  68. GM_unregisterMenuCommand(menuCommand);
  69. }
  70. } catch (err) {
  71. // Some userscript managers might not support GM_unregisterMenuCommand at all
  72. console.warn('[DEBUG] Could not unregister menu command:', err);
  73. }
  74.  
  75. // Register the new menu command with updated blocked count
  76. menuCommand = GM_registerMenuCommand(`Configure Filter (${filteredCount} blocked)`, showConfig);
  77. }
  78.  
  79. // ----------------------------------------
  80. // Fallback Button (if menu is unsupported)
  81. // ----------------------------------------
  82. function createFallbackButton() {
  83. // Check if it’s already on the page
  84. if (document.getElementById('reddit-filter-fallback-btn')) {
  85. // Just update the label with the new count
  86. document.getElementById('reddit-filter-fallback-btn').textContent = `Configure Filter (${filteredCount} blocked)`;
  87. return;
  88. }
  89.  
  90. // Otherwise create a brand new button
  91. const button = document.createElement('button');
  92. button.id = 'reddit-filter-fallback-btn';
  93. button.textContent = `Configure Filter (${filteredCount} blocked)`;
  94. button.style.cssText = 'position:fixed;top:10px;right:10px;z-index:999999;padding:8px;';
  95. button.addEventListener('click', showConfig);
  96. document.body.appendChild(button);
  97. }
  98.  
  99. // ---------------------------------------------------------------------
  100. // Debounced function to update the menu/fallback button (blocking count)
  101. // ---------------------------------------------------------------------
  102. const batchUpdateCounter = debounce(() => {
  103. updateMenuEntry();
  104. }, 16);
  105.  
  106. // -----------------
  107. // CSS for Hide Class
  108. // -----------------
  109. if (!document.querySelector('style[data-reddit-filter]')) {
  110. const style = document.createElement('style');
  111. style.textContent = `
  112. .content-filtered {
  113. display: none !important;
  114. height: 0 !important;
  115. overflow: hidden !important;
  116. }
  117. `;
  118. style.setAttribute('data-reddit-filter', 'true');
  119. document.head.appendChild(style);
  120. }
  121.  
  122. // ---------------
  123. // Build Patterns
  124. // ---------------
  125. function getKeywordPattern(keywords) {
  126. if (keywords.length === 0) return null;
  127. const escapedKeywords = keywords.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
  128. // The trailing (s|es|ies)? is used to match common plurals
  129. return new RegExp(`\\b(${escapedKeywords.join('|')})(s|es|ies)?\\b`, 'i');
  130. }
  131.  
  132. // --------------------------------------------
  133. // Show the Config Dialog for Block/Whitelist
  134. // --------------------------------------------
  135. async function showConfig() {
  136. const overlay = document.createElement('div');
  137. overlay.className = 'reddit-filter-overlay';
  138. Object.assign(overlay.style, {
  139. position: 'fixed',
  140. top: 0, left: 0, right: 0, bottom: 0,
  141. background: 'rgba(0,0,0,0.5)',
  142. zIndex: '999999'
  143. });
  144.  
  145. const dialog = document.createElement('div');
  146. dialog.className = 'reddit-filter-dialog';
  147. Object.assign(dialog.style, {
  148. position: 'fixed',
  149. top: '50%', left: '50%',
  150. transform: 'translate(-50%, -50%)',
  151. background: 'white',
  152. padding: '20px',
  153. borderRadius: '8px',
  154. zIndex: '1000000',
  155. boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
  156. minWidth: '300px',
  157. maxWidth: '350px',
  158. fontFamily: 'Arial, sans-serif',
  159. color: '#333'
  160. });
  161.  
  162. // Basic styling for elements inside the dialog
  163. dialog.innerHTML = `
  164. <h2 style="margin-top:0; color:#0079d3;">Reddit Filter: Settings</h2>
  165. <p><strong>Blocklist:</strong> One entry per line. Matching posts will be hidden.</p>
  166. <textarea spellcheck="false" id="blocklist" style="width:100%; height:80px; margin-bottom:10px;"></textarea>
  167. <p><strong>Whitelist:</strong> One entry per line. If matched, post is NOT hidden.</p>
  168. <textarea spellcheck="false" id="whitelist" style="width:100%; height:80px;"></textarea>
  169. <div style="display:flex; justify-content:flex-end; margin-top:10px; gap:10px;">
  170. <button id="cancel-btn" style="padding:6px 12px;">Cancel</button>
  171. <button id="save-btn" style="padding:6px 12px; background:#0079d3; color:white;">Save</button>
  172. </div>
  173. `;
  174.  
  175. document.body.appendChild(overlay);
  176. document.body.appendChild(dialog);
  177.  
  178. // Populate with existing data
  179. dialog.querySelector('#blocklist').value = Array.from(blocklistSet).join('\n');
  180. dialog.querySelector('#whitelist').value = Array.from(whitelistSet).join('\n');
  181.  
  182. const closeDialog = () => {
  183. overlay.remove();
  184. dialog.remove();
  185. };
  186.  
  187. // Cancel / overlay click => close
  188. dialog.querySelector('#cancel-btn').addEventListener('click', closeDialog);
  189. overlay.addEventListener('click', (e) => {
  190. // Close if user clicks the overlay, but not if user clicked inside the dialog
  191. if (e.target === overlay) {
  192. closeDialog();
  193. }
  194. });
  195.  
  196. // Save => persist
  197. dialog.querySelector('#save-btn').addEventListener('click', async () => {
  198. const blocklistInput = dialog.querySelector('#blocklist').value;
  199. blocklistSet = new Set(
  200. blocklistInput
  201. .split('\n')
  202. .map(x => x.trim().toLowerCase())
  203. .filter(x => x.length > 0)
  204. );
  205. keywordPattern = getKeywordPattern(Array.from(blocklistSet));
  206. await GM.setValue('blocklist', Array.from(blocklistSet));
  207.  
  208. const whitelistInput = dialog.querySelector('#whitelist').value;
  209. whitelistSet = new Set(
  210. whitelistInput
  211. .split('\n')
  212. .map(x => x.trim().toLowerCase())
  213. .filter(x => x.length > 0)
  214. );
  215. whitelistPattern = getKeywordPattern(Array.from(whitelistSet));
  216. await GM.setValue('whitelist', Array.from(whitelistSet));
  217.  
  218. closeDialog();
  219. location.reload(); // easiest way to re-filter everything
  220. });
  221. }
  222.  
  223. // -----------------------------------------
  224. // Process an Individual Post (Hide or Not)
  225. // -----------------------------------------
  226. function processPost(post) {
  227. if (!post || processedPosts.has(post)) return;
  228. processedPosts.add(post);
  229.  
  230. const contentText = post.textContent.toLowerCase();
  231.  
  232. // If whitelisted => skip
  233. if (whitelistPattern && whitelistPattern.test(contentText)) return;
  234.  
  235. let shouldHide = false;
  236.  
  237. // Old + New Reddit subreddit link
  238. // old.reddit => .tagline a.subreddit
  239. // new.reddit => a[data-click-id="subreddit"] or a.subreddit
  240. const subredditElement = post.querySelector('a[data-click-id="subreddit"], a.subreddit, .tagline a.subreddit');
  241. if (subredditElement) {
  242. const subName = subredditElement.textContent.trim().replace(/^r\//i, '').toLowerCase();
  243. if (blocklistSet.has(subName)) {
  244. shouldHide = true;
  245. }
  246. }
  247.  
  248. // If not yet hidden => check keywords
  249. if (!shouldHide && keywordPattern) {
  250. if (keywordPattern.test(contentText)) {
  251. shouldHide = true;
  252. }
  253. }
  254.  
  255. if (shouldHide) {
  256. hidePost(post);
  257. }
  258. }
  259.  
  260. // ---------------
  261. // Hide Post Helper
  262. // ---------------
  263. function hidePost(post) {
  264. post.classList.add('content-filtered');
  265.  
  266. const parentArticle = post.closest(postSelector);
  267. if (parentArticle) {
  268. parentArticle.classList.add('content-filtered');
  269. }
  270.  
  271. filteredCount++;
  272. pendingUpdates++;
  273. batchUpdateCounter();
  274. }
  275.  
  276. // -------------------------------------------
  277. // Process a Batch of Posts (in small chunks)
  278. // -------------------------------------------
  279. async function processPostsBatch(posts) {
  280. const batchSize = 5;
  281. for (let i = 0; i < posts.length; i += batchSize) {
  282. const batch = posts.slice(i, i + batchSize);
  283. // Use requestIdleCallback to keep page responsive
  284. await new Promise(resolve => requestIdleCallback(resolve, { timeout: 800 }));
  285. batch.forEach(processPost);
  286. }
  287. }
  288.  
  289. const debouncedProcess = debounce((posts) => {
  290. processPostsBatch(Array.from(posts));
  291. }, 100);
  292.  
  293. // ----------------------------
  294. // Initialization (load config)
  295. // ----------------------------
  296. async function init() {
  297. try {
  298. const loadedBlocklist = await GM.getValue('blocklist', []);
  299. blocklistSet = new Set(loadedBlocklist.map(x => x.toLowerCase()));
  300. keywordPattern = getKeywordPattern(Array.from(blocklistSet));
  301.  
  302. const loadedWhitelist = await GM.getValue('whitelist', []);
  303. whitelistSet = new Set(loadedWhitelist.map(x => x.toLowerCase()));
  304. whitelistPattern = getKeywordPattern(Array.from(whitelistSet));
  305.  
  306. } catch (err) {
  307. console.error('[DEBUG] Error loading saved data:', err);
  308. }
  309.  
  310. // Try to create a menu entry or fallback button (zero blocked initially)
  311. updateMenuEntry();
  312.  
  313. // On old Reddit, top-level posts appear under #siteTable
  314. // On new Reddit, there's .main-content
  315. const observerTarget = document.querySelector('.main-content')
  316. || document.querySelector('#siteTable')
  317. || document.body;
  318.  
  319. const observer = new MutationObserver((mutations) => {
  320. const newPosts = new Set();
  321. for (const mutation of mutations) {
  322. for (const node of mutation.addedNodes) {
  323. if (node.nodeType === Node.ELEMENT_NODE) {
  324. if (node.matches?.(postSelector)) {
  325. newPosts.add(node);
  326. }
  327. node.querySelectorAll?.(postSelector).forEach(p => newPosts.add(p));
  328. }
  329. }
  330. }
  331. if (newPosts.size > 0) {
  332. debouncedProcess(newPosts);
  333. }
  334. });
  335.  
  336. observer.observe(observerTarget, { childList: true, subtree: true });
  337.  
  338. // Process any existing posts on load
  339. const initialPosts = document.querySelectorAll(postSelector);
  340. if (initialPosts.length > 0) {
  341. debouncedProcess(initialPosts);
  342. }
  343.  
  344. console.log('[DEBUG] Initialization complete. Now filtering posts...');
  345. }
  346.  
  347. await init();
  348. })();