// ==UserScript== // @name Vichan Thread Hider + Image Hashban // @namespace local.vichan.hider // @version 2.0.0 // @description Hide threads and locally hashban images on vichan-style imageboards. // @match *://*/*/catalog.html // @match *://*/catalog.html // @match *://*/*/res/* // @match *://*/res/* // @run-at document-idle // @grant none // ==/UserScript== (function () { 'use strict'; const STORAGE_KEY = 'vichan_catalog_hider_v3'; // ------------------------------------------------------------ // STORAGE // ------------------------------------------------------------ function getStorage() { try { const data = JSON.parse(localStorage.getItem(STORAGE_KEY)); return { hiddenThreads: Array.isArray(data?.hiddenThreads) ? data.hiddenThreads : [], hashbans: Array.isArray(data?.hashbans) ? data.hashbans : [] }; } catch { return { hiddenThreads: [], hashbans: [] }; } } function saveStorage(data) { localStorage.setItem( STORAGE_KEY, JSON.stringify(data) ); } // ------------------------------------------------------------ // BOARD / PAGE DETECTION // ------------------------------------------------------------ function getBoard() { if (document.body.dataset.board) { return document.body.dataset.board; } if (typeof window.board_name !== 'undefined') { return String(window.board_name); } /* * /b/catalog.html * /b/res/12345.html */ const match = location.pathname.match( /^\/([^/]+)\/(?:catalog\.html|res\/)/ ); if (match) { return match[1]; } return location.hostname; } function isCatalog() { return /\/catalog\.html$/i.test(location.pathname); } function isThread() { return /\/res\/\d+\.html$/i.test(location.pathname); } // ------------------------------------------------------------ // MD5 DETECTION // ------------------------------------------------------------ function extractHash(value) { if (!value) { return null; } const string = String(value); /* * Direct MD5. */ let match = string.match(/\b[a-f0-9]{32}\b/i); if (match) { return match[0].toLowerCase(); } /* * Explicit hash parameters. */ match = string.match( /(?:md5|hash|filehash|file_hash|image_hash|img_hash)=([a-f0-9]{32})/i ); if (match) { return match[1].toLowerCase(); } return null; } /* * Search an element and all of its attributes for an MD5. */ function findHashInElement(element) { if (!element) { return null; } /* * Check every attribute. */ for (const attribute of element.attributes || []) { const hash = extractHash(attribute.value); if (hash) { return hash; } } /* * Check common attributes explicitly. */ const attributes = [ 'data-md5', 'data-hash', 'data-filehash', 'data-file-hash', 'data-image-hash', 'data-img-hash', 'data-muhhash', 'data-muh-hash', 'data-file' ]; for (const attribute of attributes) { const value = element.getAttribute(attribute); const hash = extractHash(value); if (hash) { return hash; } } return null; } /* * Find the MD5 associated with a particular image. */ function getImageHash(image) { if (!image) { return null; } /* * 1. Image itself. */ let hash = findHashInElement(image); if (hash) { return hash; } /* * 2. Walk upward through surrounding elements. * * This catches things like: * * * * */ let element = image.parentElement; for (let i = 0; element && i < 5; i++) { hash = findHashInElement(element); if (hash) { return hash; } element = element.parentElement; } /* * 3. Check links surrounding the image. */ const parent = image.parentElement; if (parent) { for (const link of parent.querySelectorAll('a[href]')) { hash = extractHash(link.href); if (hash) { return hash; } hash = findHashInElement(link); if (hash) { return hash; } } } /* * 4. Image URLs. */ const sources = [ image.src, image.currentSrc, image.getAttribute('src'), image.getAttribute('data-src'), image.getAttribute('data-original'), image.getAttribute('data-image') ]; for (const source of sources) { hash = extractHash(source); if (hash) { return hash; } } /* * 5. Search the nearest post/catalog item HTML. */ const container = getContainer(image); if (container) { const html = container.outerHTML; /* * Prefer hashes associated with explicit hash names. */ const namedHash = html.match( /(?:md5|hash|filehash|file_hash|image_hash|img_hash)[^a-f0-9]{0,100}([a-f0-9]{32})/i ); if (namedHash) { return namedHash[1].toLowerCase(); } /* * Last resort: any standalone MD5. */ const standalone = html.match( /\b[a-f0-9]{32}\b/i ); if (standalone) { return standalone[0].toLowerCase(); } } return null; } // ------------------------------------------------------------ // CONTAINER / THREAD DETECTION // ------------------------------------------------------------ function getContainer(element) { if (!element) { return null; } if (isCatalog()) { return ( element.closest('.mix') || element.closest('.catalog-item') || element.closest('.thread') || element.closest('[data-id]') ); } /* * Thread page. * * Try common vichan/4chan-style post containers. */ return ( element.closest('.post') || element.closest('.op') || element.closest('.reply') || element.closest('.postContainer') || element.closest('.post.reply') || element.closest('[id^="thread-"]') || element.closest('[id^="op-"]') || element.closest('[id^="reply-"]') || element.closest('[data-post-id]') ); } function getThreadId(element) { if (!element) { return null; } /* * On a thread page the URL itself is authoritative. */ const urlMatch = location.pathname.match( /\/res\/(\d+)\.html$/i ); if (urlMatch) { return urlMatch[1]; } /* * Catalog. */ const container = getContainer(element); if (!container) { return null; } if (container.dataset.id) { return container.dataset.id; } for (const attribute of [ 'data-thread', 'data-thread-id', 'data-post', 'data-post-id' ]) { const value = container.getAttribute(attribute); if (value && /^\d+$/.test(value)) { return value; } } const image = container.querySelector( 'img[id^="img-"]' ); if (image) { const match = image.id.match( /^img-(\d+)$/ ); if (match) { return match[1]; } } /* * Look for /res/12345.html. */ for (const link of container.querySelectorAll('a[href]')) { const match = link.href.match( /\/res\/(\d+)(?:\.html)?/ ); if (match) { return match[1]; } } return null; } // ------------------------------------------------------------ // THREAD HIDING // ------------------------------------------------------------ function hideThread(container) { if (!container) { return; } container.dataset.vichanHidden = 'true'; container.style.display = 'none'; } function showThread(container) { if (!container) { return; } container.dataset.vichanHidden = 'false'; container.style.display = ''; } function hideCurrentThread() { const id = getThreadId(document.body); if (!id) { alert('Could not determine the thread ID.'); return; } const data = getStorage(); const key = getBoard() + ':' + id; if (!data.hiddenThreads.includes(key)) { data.hiddenThreads.push(key); } saveStorage(data); /* * On a thread page, hide the thread content. * * We don't hide , otherwise the controls disappear. */ const thread = document.querySelector( '[id^="thread-"]' ) || document.querySelector( '.thread' ) || document.querySelector( '#thread' ); if (thread) { hideThread(thread); } /* * If the board doesn't have a recognizable wrapper, * return to the catalog instead. */ if (!thread) { const catalogURL = location.pathname .replace( /\/res\/\d+\.html$/i, '/catalog.html' ); location.href = catalogURL; } } function hideCatalogThread(container) { const id = getThreadId(container); if (!id) { alert('Could not determine the thread ID.'); return; } const data = getStorage(); const key = getBoard() + ':' + id; if (!data.hiddenThreads.includes(key)) { data.hiddenThreads.push(key); } saveStorage(data); hideThread(container); } // ------------------------------------------------------------ // HASHBANNING // ------------------------------------------------------------ function hashbanImage(image) { const hash = getImageHash(image); if (!hash) { console.warn( '[Vichan Hider] Could not find image hash.', image ); alert( 'Could not find the image MD5.\n\n' + 'This board does not appear to expose the MD5 in the page HTML. ' + 'Open the browser console and the image element will be logged there.' ); return; } const data = getStorage(); if (!data.hashbans.includes(hash)) { data.hashbans.push(hash); } saveStorage(data); /* * Hide the containing post/catalog item. */ const container = getContainer(image); if (container) { hideThread(container); } console.log( '[Vichan Hider] Hashbanned:', hash ); } function removeHashban(image) { const hash = getImageHash(image); if (!hash) { alert('Could not find the image MD5.'); return; } const data = getStorage(); data.hashbans = data.hashbans.filter( h => h !== hash ); saveStorage(data); applyFilters(); console.log( '[Vichan Hider] Removed hashban:', hash ); } // ------------------------------------------------------------ // APPLY FILTERS // ------------------------------------------------------------ function applyFilters() { const data = getStorage(); const board = getBoard(); /* * Catalog items. */ if (isCatalog()) { const containers = document.querySelectorAll( '#Grid .mix, ' + '#Grid .thread, ' + '.mix, ' + '.catalog-item' ); containers.forEach(container => { const id = getThreadId(container); const key = id ? board + ':' + id : null; const hidden = key && data.hiddenThreads.includes(key); const image = container.querySelector('img'); const hash = image ? getImageHash(image) : null; const hashbanned = hash && data.hashbans.includes(hash); if (hidden || hashbanned) { hideThread(container); } else { showThread(container); } }); } /* * Thread page. * * Hashbanned posts are hidden individually. */ if (isThread()) { const containers = document.querySelectorAll( '.post, ' + '.reply, ' + '.postContainer, ' + '.post.reply, ' + '[data-post-id]' ); containers.forEach(container => { const image = container.querySelector( 'img' ); if (!image) { return; } const hash = getImageHash(image); if ( hash && data.hashbans.includes(hash) ) { hideThread(container); } }); } } // ------------------------------------------------------------ // UI // ------------------------------------------------------------ function createControls() { if ( document.getElementById( 'vichan-hider-controls' ) ) { return; } const container = document.createElement('div'); container.id = 'vichan-hider-controls'; container.style.cssText = ` position: fixed; left: 12px; bottom: 12px; z-index: 999999; background: rgba(0,0,0,.85); color: white; padding: 8px 10px; border-radius: 5px; font: 13px sans-serif; box-shadow: 0 2px 8px rgba(0,0,0,.4); `; const showButton = document.createElement('button'); showButton.textContent = 'Show hidden'; showButton.style.cssText = ` cursor: pointer; margin-right: 6px; `; showButton.addEventListener( 'click', () => { document .querySelectorAll( '[data-vichan-hidden="true"]' ) .forEach(showThread); } ); const clearButton = document.createElement('button'); clearButton.textContent = 'Clear filters'; clearButton.style.cssText = ` cursor: pointer; `; clearButton.addEventListener( 'click', () => { if ( !confirm( 'Clear all locally hidden threads and hashbans?' ) ) { return; } localStorage.removeItem( STORAGE_KEY ); applyFilters(); } ); const help = document.createElement('div'); help.style.cssText = ` margin-top: 6px; opacity: .8; font-size: 11px; `; help.textContent = 'Shift+click = hide • Ctrl+click = hashban • Alt+click = unhashban'; container.appendChild(showButton); container.appendChild(clearButton); container.appendChild(help); document.body.appendChild(container); } // ------------------------------------------------------------ // CLICK HANDLING // ------------------------------------------------------------ document.addEventListener( 'click', function (event) { const image = event.target.closest( 'img' ); if (!image) { return; } /* * Ignore images belonging to our UI. */ if ( image.closest( '#vichan-hider-controls' ) ) { return; } /* * SHIFT + CLICK * * Hide the entire thread. */ if (event.shiftKey) { event.preventDefault(); event.stopPropagation(); if (isCatalog()) { const container = getContainer(image); if (container) { hideCatalogThread( container ); } } if (isThread()) { hideCurrentThread(); } return; } /* * CTRL / CMD + CLICK * * Hashban the image. */ if ( event.ctrlKey || event.metaKey ) { event.preventDefault(); event.stopPropagation(); hashbanImage(image); return; } /* * ALT + CLICK * * Remove the image hashban. */ if (event.altKey) { event.preventDefault(); event.stopPropagation(); removeHashban(image); return; } }, true ); // ------------------------------------------------------------ // DYNAMIC CONTENT // ------------------------------------------------------------ let updateTimer = null; const observer = new MutationObserver(() => { clearTimeout(updateTimer); updateTimer = setTimeout( applyFilters, 150 ); }); observer.observe( document.body, { childList: true, subtree: true } ); // ------------------------------------------------------------ // INITIALIZE // ------------------------------------------------------------ createControls(); applyFilters(); console.log( '[Vichan Hider] Loaded:', getBoard(), isCatalog() ? 'catalog' : isThread() ? 'thread' : 'unknown page' ); })();