// ==UserScript== // @name Vichan Catalog Thread Hider + Image Hashban // @namespace local.vichan.catalog-hider // @version 1.0.0 // @description Hide threads and locally hashban images in vichan-style catalog mode. // @match *://*/*/catalog.html // @match *://*/catalog.html // @run-at document-idle // @grant none // ==/UserScript== (function () { 'use strict'; /* * ------------------------------------------------------------ * Configuration * ------------------------------------------------------------ */ const STORAGE_KEY = 'vichan_catalog_hider_v1'; /* * ------------------------------------------------------------ * Storage * ------------------------------------------------------------ */ function getStorage() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || { hiddenThreads: [], hashbans: [] }; } catch (e) { console.error('[Vichan Hider] Failed to read storage:', e); return { hiddenThreads: [], hashbans: [] }; } } function saveStorage(data) { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } /* * ------------------------------------------------------------ * Board identification * ------------------------------------------------------------ */ function getBoard() { // Standard vichan catalog body const body = document.body; if (body && body.dataset.board) { return body.dataset.board; } // Standard vichan exposes board_name in some versions. if (typeof window.board_name !== 'undefined') { return String(window.board_name); } // Try URL: // /b/catalog.html const match = location.pathname.match(/\/([^/]+)\/catalog\.html$/); if (match) { return match[1]; } return location.hostname + location.pathname; } /* * ------------------------------------------------------------ * Thread detection * ------------------------------------------------------------ */ function getThreadElement(element) { return ( element.closest('.mix') || element.closest('.thread') || element.closest('[data-id]') || element.closest('li') || element.closest('.catalog-item') ); } function getThreadId(thread) { if (!thread) { return null; } // Normal vichan catalog if (thread.dataset.id) { return thread.dataset.id; } // Look for the image ID. const image = thread.querySelector('img[id^="img-"]'); if (image) { const match = image.id.match(/^img-(.+)$/); if (match) { return match[1]; } } // Look for links containing /res/12345 const link = thread.querySelector('a[href]'); if (link) { const match = link.href.match(/\/res\/(\d+)/); if (match) { return match[1]; } } return null; } /* * ------------------------------------------------------------ * Image hash detection * ------------------------------------------------------------ * * Different vichan forks expose the MD5 differently. * * We check several common attributes. */ function getImageHash(thread) { if (!thread) { return null; } const image = thread.querySelector('img'); if (!image) { return null; } const possibleAttributes = [ 'data-md5', 'data-hash', 'data-filehash', 'data-file-hash', 'data-image-hash', 'data-img-hash', 'data-muhhash', 'data-muh-hash' ]; for (const attr of possibleAttributes) { const value = image.getAttribute(attr); if (value && isHash(value)) { return value.toLowerCase(); } } // Sometimes the hash is attached to the containing element. for (const attr of possibleAttributes) { const value = thread.getAttribute(attr); if (value && isHash(value)) { return value.toLowerCase(); } } /* * Some forks put it in the image URL/query string. */ const src = image.currentSrc || image.src || ''; const urlHash = src.match(/[?&](?:md5|hash|filehash)=([a-f0-9]{32})/i); if (urlHash) { return urlHash[1].toLowerCase(); } return null; } function isHash(value) { return /^[a-f0-9]{32}$/i.test(String(value).trim()); } /* * ------------------------------------------------------------ * Hide/show * ------------------------------------------------------------ */ function hideThread(thread) { if (!thread) { return; } thread.dataset.vichanHidden = 'true'; thread.style.display = 'none'; } function showThread(thread) { if (!thread) { return; } thread.dataset.vichanHidden = 'false'; thread.style.display = ''; } /* * ------------------------------------------------------------ * Hashban * ------------------------------------------------------------ */ function hashbanThread(thread) { const hash = getImageHash(thread); if (!hash) { alert( 'Could not find an image hash for this thread.\n\n' + 'This vichan fork may not expose the MD5 in catalog mode.' ); return; } const data = getStorage(); if (!data.hashbans.includes(hash)) { data.hashbans.push(hash); saveStorage(data); } hideThread(thread); console.log('[Vichan Hider] Hashbanned:', hash); } function removeHashban(thread) { const hash = getImageHash(thread); if (!hash) { return; } const data = getStorage(); data.hashbans = data.hashbans.filter( h => h !== hash ); saveStorage(data); applyFilters(); console.log('[Vichan Hider] Removed hashban:', hash); } /* * ------------------------------------------------------------ * Thread hiding * ------------------------------------------------------------ */ function permanentlyHideThread(thread) { const id = getThreadId(thread); if (!id) { alert('Could not determine the thread ID.'); return; } const board = getBoard(); const key = board + ':' + id; const data = getStorage(); if (!data.hiddenThreads.includes(key)) { data.hiddenThreads.push(key); } saveStorage(data); hideThread(thread); console.log('[Vichan Hider] Hidden thread:', key); } /* * ------------------------------------------------------------ * Apply saved filters * ------------------------------------------------------------ */ function applyFilters() { const data = getStorage(); const board = getBoard(); const threads = document.querySelectorAll( '#Grid .mix, #Grid .thread, .threads .mix' ); threads.forEach(thread => { const id = getThreadId(thread); const hash = getImageHash(thread); const threadKey = id ? board + ':' + id : null; const hiddenById = threadKey && data.hiddenThreads.includes(threadKey); const hiddenByHash = hash && data.hashbans.includes(hash); if (hiddenById || hiddenByHash) { hideThread(thread); } else { showThread(thread); } }); } /* * ------------------------------------------------------------ * 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; right: 12px; bottom: 12px; z-index: 999999; background: rgba(0, 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 thread • Ctrl+click = hashban • Alt+click = unhashban'; container.appendChild(showButton); container.appendChild(clearButton); container.appendChild(help); document.body.appendChild(container); } /* * ------------------------------------------------------------ * Mouse handling * ------------------------------------------------------------ */ document.addEventListener('click', function (event) { const image = event.target.closest( '#Grid img, .threads img' ); if (!image) { return; } const thread = getThreadElement(image); if (!thread) { return; } /* * Shift+click * Hide this particular thread. */ if (event.shiftKey) { event.preventDefault(); event.stopPropagation(); permanentlyHideThread(thread); return; } /* * Ctrl+click * Hashban this image. */ if (event.ctrlKey || event.metaKey) { event.preventDefault(); event.stopPropagation(); hashbanThread(thread); return; } /* * Alt+click * Remove this image from the hashban list. */ if (event.altKey) { event.preventDefault(); event.stopPropagation(); removeHashban(thread); return; } }, true); /* * ------------------------------------------------------------ * Mutation observer * ------------------------------------------------------------ * * Useful for catalogs that dynamically sort/rebuild the grid. */ const observer = new MutationObserver(() => { applyFilters(); }); observer.observe(document.body, { childList: true, subtree: true }); /* * ------------------------------------------------------------ * Start * ------------------------------------------------------------ */ function init() { createControls(); applyFilters(); console.log( '[Vichan Hider] Loaded on board:', getBoard() ); } init(); })();