// ==UserScript== // @name Vichan Thread Hider + Automatic Image Hashban // @namespace local.vichan.hider // @version 3.0.0 // @description Hide threads and locally hashban images on vichan-style imageboards. // @match *://*/*/catalog.html // @match *://*/catalog.html // @match *://*/*/res/*.html // @match *://*/res/*.html // @run-at document-idle // @grant GM_xmlhttpRequest // @connect * // ==/UserScript== (function () { 'use strict'; const STORAGE_KEY = 'vichan_hider_v4'; // ------------------------------------------------------------ // 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 : [], hashCache: data?.hashCache && typeof data.hashCache === 'object' ? data.hashCache : {} }; } catch { return { hiddenThreads: [], hashbans: [], hashCache: {} }; } } function saveStorage(data) { localStorage.setItem( STORAGE_KEY, JSON.stringify(data) ); } // ------------------------------------------------------------ // BOARD / URL // ------------------------------------------------------------ function getBoard() { if (document.body.dataset.board) { return document.body.dataset.board; } if (typeof window.board_name !== 'undefined') { return String(window.board_name); } const match = location.pathname.match( /^\/([^/]+)\/(?:catalog\.html|res\/)/ ); return match ? match[1] : location.hostname; } function getBoardRoot() { const match = location.pathname.match( /^\/([^/]+)\// ); if (!match) { return '/'; } return '/' + match[1] + '/'; } function isCatalog() { return /\/catalog\.html$/i.test( location.pathname ); } function isThread() { return /\/res\/\d+\.html$/i.test( location.pathname ); } function getCurrentThreadId() { const match = location.pathname.match( /\/res\/(\d+)\.html$/i ); return match ? match[1] : null; } // ------------------------------------------------------------ // MD5 IMPLEMENTATION // ------------------------------------------------------------ // // Pure JavaScript MD5. // No external library required. // function md5(buffer) { const bytes = new Uint8Array(buffer); function leftRotate(x, amount) { return ( (x << amount) | (x >>> (32 - amount)) ) >>> 0; } function add32(a, b) { return (a + b) >>> 0; } const originalLength = bytes.length; const bitLength = originalLength * 8; const paddedLength = ((originalLength + 8) >> 6 << 6) + 64; const message = new Uint8Array(paddedLength); message.set(bytes); message[originalLength] = 0x80; const view = new DataView(message.buffer); view.setUint32( paddedLength - 8, bitLength >>> 0, true ); view.setUint32( paddedLength - 4, Math.floor(bitLength / 0x100000000), true ); let a0 = 0x67452301; let b0 = 0xefcdab89; let c0 = 0x98badcfe; let d0 = 0x10325476; const s = [ 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21 ]; const K = []; for (let i = 0; i < 64; i++) { K[i] = Math.floor( Math.abs( Math.sin(i + 1) ) * 0x100000000 ) >>> 0; } for ( let offset = 0; offset < paddedLength; offset += 64 ) { const M = new Uint32Array(16); for (let i = 0; i < 16; i++) { M[i] = view.getUint32( offset + i * 4, true ); } let A = a0; let B = b0; let C = c0; let D = d0; for (let i = 0; i < 64; i++) { let F; let g; if (i < 16) { F = (B & C) | (~B & D); g = i; } else if (i < 32) { F = (D & B) | (~D & C); g = (5 * i + 1) % 16; } else if (i < 48) { F = B ^ C ^ D; g = (3 * i + 5) % 16; } else { F = C ^ (B | ~D); g = (7 * i) % 16; } const temp = D; D = C; C = B; const sum = add32( add32( add32(A, F), K[i] ), M[g] ); B = add32( B, leftRotate( sum, s[i] ) ); A = temp; } a0 = add32(a0, A); b0 = add32(b0, B); c0 = add32(c0, C); d0 = add32(d0, D); } const output = new Uint8Array(16); const result = new DataView(output.buffer); result.setUint32(0, a0, true); result.setUint32(4, b0, true); result.setUint32(8, c0, true); result.setUint32(12, d0, true); return output; } function md5Hex(buffer) { const hash = md5(buffer); return Array.from(hash) .map( byte => byte .toString(16) .padStart(2, '0') ) .join(''); } /* * Vichan/4chan API represents MD5 as * packed binary -> base64. */ function md5PackedBase64(buffer) { const hash = md5(buffer); let binary = ''; for (const byte of hash) { binary += String.fromCharCode(byte); } return btoa(binary); } // ------------------------------------------------------------ // IMAGE / POST DETECTION // ------------------------------------------------------------ function getImageContainer(image) { if (!image) { return null; } if (isCatalog()) { return ( image.closest('.mix') || image.closest('.catalog-item') || image.closest('.thread') || image.closest('[data-id]') ); } return ( image.closest('.post') || image.closest('.reply') || image.closest('.postContainer') || image.closest('[data-post-id]') ); } function getThreadId(element) { if (isThread()) { return getCurrentThreadId(); } const container = getImageContainer(element); if (!container) { return null; } if (container.dataset.id) { return container.dataset.id; } for (const attr of [ 'data-thread', 'data-thread-id', 'data-post', 'data-post-id' ]) { const value = container.getAttribute(attr); 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]; } } for ( const link of container.querySelectorAll( 'a[href]' ) ) { const match = link.href.match( /\/res\/(\d+)(?:\.html)?/ ); if (match) { return match[1]; } } return null; } // ------------------------------------------------------------ // FIND FULL IMAGE // ------------------------------------------------------------ function getFullImageURL(image) { if (!image) { return null; } /* * Best case: thumbnail is wrapped in a link * pointing to the original file. */ let link = image.closest('a[href]'); if ( link && link.href && !/\/res\/\d+\.html/i.test( link.href ) ) { return link.href; } /* * Common vichan attributes. */ for (const attr of [ 'data-original', 'data-full', 'data-full-image', 'data-src' ]) { const value = image.getAttribute(attr); if (value) { return new URL( value, location.href ).href; } } /* * If this is already the full image. */ if ( image.src && !/thumb/i.test(image.src) ) { return image.src; } return image.src || null; } // ------------------------------------------------------------ // API SUPPORT // ------------------------------------------------------------ function getThreadAPIURL(threadId) { /* * Common vichan endpoint: * * /board/thread/123.json * * Some forks instead use: * * /board/res/123.json */ const root = location.origin + getBoardRoot(); return [ root + 'thread/' + threadId + '.json', root + 'res/' + threadId + '.json' ]; } function gmFetch(url) { return new Promise( (resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'text', onload(response) { if ( response.status >= 200 && response.status < 300 ) { resolve( response.responseText ); } else { reject( new Error( 'HTTP ' + response.status ) ); } }, onerror() { reject( new Error( 'Network error' ) ); } }); } ); } async function getAPIHash( threadId, image ) { if (!threadId) { return null; } const urls = getThreadAPIURL(threadId); for (const url of urls) { try { const text = await gmFetch(url); const data = JSON.parse(text); const posts = data.posts || []; for ( const post of posts ) { /* * Match by post image filename * when possible. */ const candidates = [ post.md5, post.filehash, post.hash ]; for ( const candidate of candidates ) { if (candidate) { return candidate; } } } } catch { // Try next endpoint. } } return null; } // ------------------------------------------------------------ // DOWNLOAD IMAGE // ------------------------------------------------------------ function downloadImage(url) { return new Promise( (resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', onload(response) { if ( response.status >= 200 && response.status < 300 && response.response ) { resolve( response.response ); } else { reject( new Error( 'Image download failed: HTTP ' + response.status ) ); } }, onerror() { reject( new Error( 'Image download failed' ) ); } }); } ); } // ------------------------------------------------------------ // GET HASH // ------------------------------------------------------------ async function calculateImageHash( image ) { const url = getFullImageURL(image); if (!url) { throw new Error( 'Could not determine full image URL.' ); } /* * Cache based on URL first. */ const data = getStorage(); if (data.hashCache[url]) { return data.hashCache[url]; } console.log( '[Vichan Hider] Downloading image for MD5:', url ); const buffer = await downloadImage(url); const result = { hex: md5Hex(buffer), packed: md5PackedBase64(buffer) }; data.hashCache[url] = result; saveStorage(data); console.log( '[Vichan Hider] Image MD5:', result.hex ); console.log( '[Vichan Hider] Vichan MD5:', result.packed ); return result; } // ------------------------------------------------------------ // HASHBAN // ------------------------------------------------------------ async function hashbanImage( image ) { const button = document.getElementById( 'vichan-hider-status' ); if (button) { button.textContent = 'Hashing image...'; } try { /* * First attempt: * board's own API. */ const threadId = getThreadId(image); let apiHash = await getAPIHash( threadId, image ); /* * Second attempt: * actually download the full image. */ let localHash = await calculateImageHash( image ); const hashes = []; if (localHash) { hashes.push( localHash.hex ); hashes.push( localHash.packed ); } if (apiHash) { hashes.push( String(apiHash) ); } if (!hashes.length) { throw new Error( 'No hash was obtained.' ); } const data = getStorage(); for ( const hash of hashes ) { if ( !data.hashbans.includes( hash ) ) { data.hashbans.push( hash ); } } saveStorage(data); const container = getImageContainer(image); if (container) { hideThread(container); } if (button) { button.textContent = 'Hashbanned ✓'; setTimeout(() => { button.textContent = 'Show hidden'; }, 1500); } console.log( '[Vichan Hider] Hashban added:', hashes ); } catch (error) { console.error( '[Vichan Hider] Hashing failed:', error ); if (button) { button.textContent = 'Hash failed'; } alert( 'Could not hash this image.\n\n' + error.message + '\n\n' + 'Check the browser console for details.' ); } } // ------------------------------------------------------------ // CHECK HASHBAN // ------------------------------------------------------------ async function isHashbanned( image ) { const data = getStorage(); /* * First check whether we've already * cached this URL. */ const url = getFullImageURL(image); if ( url && data.hashCache[url] ) { const cached = data.hashCache[url]; return ( data.hashbans.includes( cached.hex ) || data.hashbans.includes( cached.packed ) ); } return false; } // ------------------------------------------------------------ // 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 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); } function hideCurrentThread() { const id = getCurrentThreadId(); if (!id) { return; } const data = getStorage(); const key = getBoard() + ':' + id; if ( !data.hiddenThreads.includes( key ) ) { data.hiddenThreads.push( key ); } saveStorage(data); /* * Keep our UI visible. */ const thread = document.querySelector( '[id^="thread-"]' ) || document.querySelector( '.thread' ) || document.querySelector( '#thread' ); if (thread) { hideThread(thread); } else { /* * No recognizable wrapper: * return to catalog. */ location.href = location.pathname.replace( /\/res\/\d+\.html$/i, '/catalog.html' ); } } // ------------------------------------------------------------ // APPLY SAVED THREAD HIDES // ------------------------------------------------------------ function applyThreadHides() { const data = getStorage(); const board = getBoard(); if (isCatalog()) { const containers = document.querySelectorAll( '#Grid .mix,' + '#Grid .thread,' + '.mix,' + '.catalog-item' ); containers.forEach( container => { const id = getThreadId( container ); if (!id) { return; } const key = board + ':' + id; if ( data.hiddenThreads.includes( key ) ) { hideThread( container ); } } ); } if (isThread()) { const id = getCurrentThreadId(); const key = board + ':' + id; if ( data.hiddenThreads.includes( key ) ) { const thread = document.querySelector( '[id^="thread-"]' ) || document.querySelector( '.thread' ); if (thread) { hideThread(thread); } } } } // ------------------------------------------------------------ // UI // ------------------------------------------------------------ function createControls() { if ( document.getElementById( 'vichan-hider-controls' ) ) { return; } const box = document.createElement('div'); box.id = 'vichan-hider-controls'; box.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 show = document.createElement( 'button' ); show.textContent = 'Show hidden'; show.style.cssText = 'cursor:pointer;margin-right:6px;'; show.addEventListener( 'click', () => { document .querySelectorAll( '[data-vichan-hidden="true"]' ) .forEach( showThread ); } ); const clear = document.createElement( 'button' ); clear.textContent = 'Clear filters'; clear.style.cssText = 'cursor:pointer;'; clear.addEventListener( 'click', () => { if ( !confirm( 'Clear all locally hidden threads and hashbans?' ) ) { return; } localStorage.removeItem( STORAGE_KEY ); location.reload(); } ); const status = document.createElement( 'div' ); status.id = 'vichan-hider-status'; status.style.cssText = ` margin-top:6px; opacity:.8; font-size:11px; `; status.textContent = 'Shift+click = hide • Ctrl+click = hashban • Alt+click = unhashban'; box.appendChild(show); box.appendChild(clear); box.appendChild(status); document.body.appendChild(box); } // ------------------------------------------------------------ // ALT-CLICK HASHBAN REMOVAL // ------------------------------------------------------------ async function removeHashban( image ) { try { const hash = await calculateImageHash( image ); const data = getStorage(); data.hashbans = data.hashbans.filter( value => value !== hash.hex && value !== hash.packed ); saveStorage(data); location.reload(); } catch (error) { console.error( '[Vichan Hider] Could not remove hashban:', error ); alert( 'Could not calculate the image hash.' ); } } // ------------------------------------------------------------ // CLICK HANDLER // ------------------------------------------------------------ document.addEventListener( 'click', function (event) { const image = event.target.closest( 'img' ); if (!image) { return; } if ( image.closest( '#vichan-hider-controls' ) ) { return; } /* * SHIFT */ if (event.shiftKey) { event.preventDefault(); event.stopPropagation(); if (isCatalog()) { const container = getImageContainer( image ); if (container) { hideCatalogThread( container ); } } if (isThread()) { hideCurrentThread(); } return; } /* * CTRL / CMD */ if ( event.ctrlKey || event.metaKey ) { event.preventDefault(); event.stopPropagation(); hashbanImage( image ); return; } /* * ALT */ if (event.altKey) { event.preventDefault(); event.stopPropagation(); removeHashban( image ); return; } }, true ); // ------------------------------------------------------------ // AUTOMATIC HASH CHECKING // ------------------------------------------------------------ async function scanForHashbans() { const data = getStorage(); if (!data.hashbans.length) { return; } const images = document.querySelectorAll( 'img' ); /* * Don't download every image on a large * catalog simultaneously. */ for ( const image of images ) { if ( image.closest( '#vichan-hider-controls' ) ) { continue; } try { const url = getFullImageURL( image ); if (!url) { continue; } /* * Already cached? */ const cached = data.hashCache[url]; if (cached) { if ( data.hashbans.includes( cached.hex ) || data.hashbans.includes( cached.packed ) ) { const container = getImageContainer( image ); if (container) { hideThread( container ); } } continue; } /* * Only automatically hash images * once they're loaded/visible. * * This prevents a catalog containing * hundreds of images from hammering * the board immediately. */ const rect = image.getBoundingClientRect(); const visible = rect.bottom >= 0 && rect.top <= window.innerHeight; if (!visible) { continue; } const hash = await calculateImageHash( image ); if ( data.hashbans.includes( hash.hex ) || data.hashbans.includes( hash.packed ) ) { const container = getImageContainer( image ); if (container) { hideThread( container ); } } } catch { /* * CORS/network failures are ignored. * Ctrl-click will still try the same * process and report the actual error. */ } } } // ------------------------------------------------------------ // MUTATION OBSERVER // ------------------------------------------------------------ let timer = null; const observer = new MutationObserver(() => { clearTimeout(timer); timer = setTimeout( () => { applyThreadHides(); }, 150 ); }); observer.observe( document.body, { childList: true, subtree: true } ); // ------------------------------------------------------------ // START // ------------------------------------------------------------ createControls(); applyThreadHides(); /* * Give the page a moment to finish constructing * its catalog/thread DOM. */ setTimeout( scanForHashbans, 500 ); console.log( '[Vichan Hider] v3 loaded', { board: getBoard(), page: isCatalog() ? 'catalog' : isThread() ? 'thread' : 'unknown' } ); })();