// ==UserScript== // @name Vichan Thread Hider + Automatic Image Hashban // @namespace local.vichan.hider // @version 4.1.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]') ); } /* * Resolve the post/thread number a given * container represents. This is the single * source of truth for ID extraction - used * both for thread-level hides and for matching * a specific post against API-provided md5s. */ function extractPostNumber(container) { 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]; } } /* * Many vichan themes set the post's own * element id to its post number directly, * e.g.
. * The original script never checked this. */ if (container.id) { const match = container.id.match(/(\d+)$/); if (match) { return match[1]; } } return null; } /* * Thread-level ID, used for hide/show-thread * bookkeeping. In a thread page this is always * the current thread, regardless of which post * was clicked. */ function getThreadId(element) { if (isThread()) { return getCurrentThreadId(); } return extractPostNumber( getImageContainer(element) ); } /* * Post-level ID: always the specific post the * image belongs to, even inside a thread. This * is what must be used to look up that post's * md5 in the API - using getThreadId() here was * the main reason hashbans matched the wrong post. */ function getPostNumber(element) { return extractPostNumber( getImageContainer(element) ); } // ------------------------------------------------------------ // 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 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 fetchThreadJSON(threadId) { const root = location.origin + getBoardRoot(); const urls = [ root + 'res/' + threadId + '.json', root + 'thread/' + threadId + '.json' ]; let lastError = null; for (const url of urls) { try { const text = await gmFetch(url); const data = JSON.parse(text); console.log( '[Vichan Hider] Fetched thread JSON from', url ); return data; } catch (error) { console.warn( '[Vichan Hider] Thread JSON fetch failed:', url, error.message ); lastError = error; } } throw lastError || new Error('Could not fetch thread JSON.'); } async function fetchCatalogJSON() { const root = location.origin + getBoardRoot(); const url = root + 'catalog.json'; const text = await gmFetch(url); console.log( '[Vichan Hider] Fetched catalog JSON from', url ); const data = JSON.parse(text); if (Array.isArray(data)) { return data; } if (data && Array.isArray(data.threads)) { return [{ threads: data.threads }]; } return []; } /* * Resolve the raw API post object (with md5, * tim, ext, etc.) for the exact post an image * belongs to - not just "some post in the * thread" (that was the original bug). */ async function resolveAPIPost(image) { const postNumber = getPostNumber(image); if (!postNumber) { console.warn( '[Vichan Hider] Could not determine a post number for this image - check extractPostNumber().' ); return null; } const threadIdForFetch = isThread() ? getCurrentThreadId() : postNumber; // in the catalog, the OP number IS the thread id if (!threadIdForFetch) { return null; } const threadData = await fetchThreadJSON(threadIdForFetch); const posts = threadData.posts || []; const match = posts.find( post => String(post.no) === String(postNumber) ); if (!match) { console.warn( '[Vichan Hider] Post', postNumber, 'was not found in thread JSON for', threadIdForFetch ); } return match || null; } /* * Canonical hash resolution, in priority order: * 1. The API's own md5 field for this exact post * (authoritative - this is what every other * post's md5 will be compared against). * 2. Download the ORIGINAL file reconstructed from * the API's tim/ext fields, if md5 is missing. * 3. Last resort: parse the DOM for a full-image URL * and hash that. On catalog pages this can end up * hashing the thumbnail instead of the original if * the thumbnail is only linked to the thread page, * which will NOT match other instances of the file - * a warning is logged when this path is taken. */ async function getCanonicalHash(image) { try { const post = await resolveAPIPost(image); if (post) { if (post.md5) { console.log( '[Vichan Hider] Using API md5 for post', post.no, '->', post.md5 ); return { hex: null, packed: post.md5, source: 'api' }; } if (post.tim && post.ext) { const root = location.origin + getBoardRoot(); const url = root + 'src/' + post.tim + post.ext; console.log( '[Vichan Hider] Post has no md5 field, downloading reconstructed original:', url ); const buffer = await downloadImage(url); return { hex: md5Hex(buffer), packed: md5PackedBase64(buffer), source: 'api-src' }; } } } catch (error) { console.warn( '[Vichan Hider] API-based hash lookup failed, falling back to DOM parsing:', error.message ); } try { const local = await calculateImageHash(image); console.warn( '[Vichan Hider] Using DOM-parsed image as hash source (fallback). ' + 'If this is a catalog thumbnail rather than the original file, ' + 'it will NOT match other posts of the same image.' ); return { hex: local.hex, packed: local.packed, source: 'dom-fallback' }; } catch (error) { console.error( '[Vichan Hider] DOM-based fallback hashing also failed:', error.message ); 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 (add) // ------------------------------------------------------------ async function hashbanImage( image ) { const button = document.getElementById( 'vichan-hider-status' ); if (button) { button.textContent = 'Hashing image...'; } try { const canonical = await getCanonicalHash(image); if (!canonical) { throw new Error( 'No hash was obtained.' ); } const hashes = []; if (canonical.packed) { hashes.push(canonical.packed); } if (canonical.hex) { hashes.push(canonical.hex); } 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); } /* * Immediately hide any other copies of * this image already loaded on the page, * not just the one that was clicked. */ await applyHashbans(); if (button) { button.textContent = 'Hashbanned \u2713'; 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.' ); } } // ------------------------------------------------------------ // HASHBAN (remove) // ------------------------------------------------------------ async function removeHashban( image ) { try { const canonical = await getCanonicalHash(image); const toRemove = new Set(); if (canonical) { if (canonical.packed) { toRemove.add(canonical.packed); } if (canonical.hex) { toRemove.add(canonical.hex); } } if (!toRemove.size) { throw new Error( "Could not determine this image's hash." ); } const data = getStorage(); data.hashbans = data.hashbans.filter( value => !toRemove.has(value) ); saveStorage(data); location.reload(); } catch (error) { console.error( '[Vichan Hider] Could not remove hashban:', error ); alert( 'Could not calculate the image hash.' ); } } // ------------------------------------------------------------ // APPLY HASHBANS TO CURRENT PAGE // ------------------------------------------------------------ // // This is the part that was completely missing before: // nothing ever checked already-loaded images against the // stored hashban list. Rather than downloading every full // image on the page (slow, and hostile to the target site), // this pulls the board's own catalog.json / res/ID.json, // which already includes each post's md5, and matches that // in bulk. // async function applyHashbans() { const data = getStorage(); if (!data.hashbans || !data.hashbans.length) { return; } const hashSet = new Set(data.hashbans); try { if (isCatalog()) { const pages = await fetchCatalogJSON(); const md5ByPost = new Map(); for (const page of pages) { const threads = page.threads || []; for (const thread of threads) { if (thread && thread.md5 && thread.no) { md5ByPost.set( String(thread.no), thread.md5 ); } } } let matched = 0; document .querySelectorAll( '#Grid .mix,' + '#Grid .thread,' + '.mix,' + '.catalog-item' ) .forEach(container => { const id = extractPostNumber(container); if (!id) { return; } const md5 = md5ByPost.get(String(id)); if (md5 && hashSet.has(md5)) { hideThread(container); matched++; } }); console.log( '[Vichan Hider] Catalog scan: checked', md5ByPost.size, 'threads,', hashSet.size, 'hashbans stored,', matched, 'hidden' ); } if (isThread()) { const threadId = getCurrentThreadId(); if (!threadId) { return; } const threadData = await fetchThreadJSON(threadId); const posts = threadData.posts || []; if (!posts.length) { return; } // If the OP's image is hashbanned, hide the whole thread. const op = posts[0]; if (op && op.md5 && hashSet.has(op.md5)) { const threadEl = document.querySelector('[id^="thread-"]') || document.querySelector('.thread') || document.querySelector('#thread'); if (threadEl) { hideThread(threadEl); return; } } const md5ByPost = new Map(); for (const post of posts) { if (post.md5 && post.no) { md5ByPost.set(String(post.no), post.md5); } } let matched = 0; document .querySelectorAll( '.post, .reply, .postContainer, [data-post-id]' ) .forEach(container => { const id = extractPostNumber(container); if (!id) { return; } const md5 = md5ByPost.get(String(id)); if (md5 && hashSet.has(md5)) { hideThread(container); matched++; } }); console.log( '[Vichan Hider] Thread scan: checked', md5ByPost.size, 'posts,', hashSet.size, 'hashbans stored,', matched, 'hidden' ); } } catch (error) { console.warn( '[Vichan Hider] Could not apply hashbans from API:', error ); } } // ------------------------------------------------------------ // 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 \u2022 Ctrl+click = hashban \u2022 Alt+click = unhashban'; box.appendChild(show); box.appendChild(clear); box.appendChild(status); document.body.appendChild(box); } // ------------------------------------------------------------ // 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; } } ); // ------------------------------------------------------------ // INIT // ------------------------------------------------------------ function init() { console.log( '[Vichan Hider] v4.1.0 loaded on', location.href ); createControls(); applyThreadHides(); applyHashbans(); if (isThread()) { observeThreadUpdates(); } } /* * On boards with a live thread auto-updater, * new replies get inserted into the DOM without * a page reload. Re-check hashbans (debounced) * whenever that happens, so new posts matching an * existing hashban get hidden too. */ function observeThreadUpdates() { const target = document.querySelector('form[name="postform"]') || document.body; let timer = null; const observer = new MutationObserver(() => { clearTimeout(timer); timer = setTimeout(() => { applyHashbans(); }, 800); }); observer.observe(target, { childList: true, subtree: true }); } if ( document.readyState === 'complete' || document.readyState === 'interactive' ) { init(); } else { document.addEventListener( 'DOMContentLoaded', init ); } /* * Manual debugging from devtools, e.g.: * __vichanHiderDebug.getStorage() * __vichanHiderDebug.fetchThreadJSON('12345').then(console.log) * __vichanHiderDebug.fetchCatalogJSON().then(console.log) * __vichanHiderDebug.applyHashbans() * Useful for checking whether a given vichan fork's JSON * actually matches the field names this script expects * (posts[].no, posts[].md5, posts[].tim, posts[].ext). */ window.__vichanHiderDebug = { getStorage, fetchThreadJSON, fetchCatalogJSON, applyHashbans, extractPostNumber }; })();