// ==UserScript== // @name vichan hider // @namespace local.vichan.hider // @version 5.2.0 // @description Hide threads, hashban images, import/export bans and optionally use similarity hashing on vichan-style imageboards. // @match *://*/*/catalog.html // @match *://*/catalog.html // @match *://*/*/res/*.html // @match *://*/res/*.html // @match *://*/*/thread/*.html // @match *://*/thread/*.html // @match *://*/mod.php* // @run-at document-idle // @grant GM_xmlhttpRequest // @connect * // ==/UserScript== (function () { 'use strict'; const STORAGE_KEY = 'vichan_hider_v5'; /* * ------------------------------------------------------------ * CONFIGURATION * ------------------------------------------------------------ */ const DEFAULT_SETTINGS = { guiHidden: false, panelOpacity: 1, aggressiveHashing: false, similarityThreshold: 10, // 0 = identical, 64 = completely different panelPosition: null // {left, top} in px once the user drags the panel; null = default corner }; // If a board blocks external images via CSP, the panel/tab just fall // back to plain text ("v") instead of the icon. const VICHAN_ICON = 'https://comfy.guide/icons/vichan.png'; const GUI_TOGGLE_SHORTCUT = 'Ctrl+Alt+H'; /* * ------------------------------------------------------------ * STORAGE * ------------------------------------------------------------ */ function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } 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 : [], similarityBans: Array.isArray(data?.similarityBans) ? data.similarityBans : [], hashCache: data?.hashCache && typeof data.hashCache === 'object' ? data.hashCache : {}, similarityCache: data?.similarityCache && typeof data.similarityCache === 'object' ? data.similarityCache : {}, settings: { ...DEFAULT_SETTINGS, ...(data?.settings || {}), panelOpacity: typeof data?.settings?.panelOpacity === 'number' ? clamp(data.settings.panelOpacity, 0.25, 1) : 1, similarityThreshold: typeof data?.settings?.similarityThreshold === 'number' ? clamp(Math.round(data.settings.similarityThreshold), 0, 64) : 10, panelPosition: data?.settings?.panelPosition && typeof data.settings.panelPosition.left === 'number' && typeof data.settings.panelPosition.top === 'number' ? { left: data.settings.panelPosition.left, top: data.settings.panelPosition.top } : null } }; } catch { return { hiddenThreads: [], hashbans: [], similarityBans: [], hashCache: {}, similarityCache: {}, settings: { ...DEFAULT_SETTINGS } }; } } function saveStorage(data) { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } function getSimilarityThreshold() { return getStorage().settings.similarityThreshold; } /* * ------------------------------------------------------------ * BOARD / URL * ------------------------------------------------------------ */ function getEffectivePath() { if (/\/mod\.php$/i.test(location.pathname) && location.search.startsWith('?/')) { return location.search.slice(1); } return location.pathname; } 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 = getEffectivePath().match(/^\/([^/]+)\/(?:catalog\.html|res\/|thread\/)/); return match ? match[1] : location.hostname; } function getBoardRoot() { const match = getEffectivePath().match(/^\/([^/]+)\//); return match ? '/' + match[1] + '/' : '/'; } function isCatalog() { return /\/catalog\.html$/i.test(getEffectivePath()); } function isThread() { return /\/(?:res|thread)\/\d+\.html$/i.test(getEffectivePath()); } function getCurrentThreadId() { const match = getEffectivePath().match(/\/(?:res|thread)\/(\d+)\.html$/i); return match ? match[1] : null; } /* * ------------------------------------------------------------ * MD5 * ------------------------------------------------------------ */ 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, B = b0, C = c0, D = d0; for (let i = 0; i < 64; i++) { let F, 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) { return Array.from(md5(buffer)).map(byte => byte.toString(16).padStart(2, '0')).join(''); } function md5PackedBase64(buffer) { const hash = md5(buffer); let binary = ''; for (const byte of hash) binary += String.fromCharCode(byte); return btoa(binary); } /* * ------------------------------------------------------------ * IMAGE / POST DETECTION * ------------------------------------------------------------ */ // The thread wrapper (e.g.
) must // never be treated as an individual post's container - if it is, a // single-post hide/hashban ends up hiding the entire thread. function isThreadRoot(el) { if (!el) return false; if (el.classList && el.classList.contains('thread')) return true; if (el.id && /^thread[-_]/i.test(el.id)) return true; return false; } function getImageContainer(image) { if (!image) return null; let known; if (isCatalog()) { known = image.closest('.mix') || image.closest('.catalog-item') || image.closest('.thread') || image.closest('[data-id]'); } else { known = image.closest('.post') || image.closest('.reply') || image.closest('.postContainer') || image.closest('[data-post-id]'); // Safety net: some skins put data-post-id (or similar) on the // thread wrapper too. Never let a per-post lookup resolve to // the whole thread - see isThreadRoot(). if (known && isThreadRoot(known)) { known = null; } } if (known) return known; return findGenericContainer(image); } function findGenericContainer(image) { let node = image.parentElement; let hops = 0; while (node && hops < 6) { if (isThreadRoot(node)) break; if (extractPostNumber(node)) return node; node = node.parentElement; hops++; } const fallback = image.closest('article'); if (fallback && !isThreadRoot(fallback)) return fallback; return image.parentElement?.parentElement || image.parentElement || image; } 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; } // Check the container's own id (op_N / reply_N / post_N / thread_N) // BEFORE scanning links. This must come first: almost every post // contains a link back to the thread's own res/.html // page (its "No." link, or a reply quoting it), which matches the // href pattern below but yields the THREAD's id, not this post's // own id - checking links first was the cause of "hashbanning one // post hides the whole thread": every post's link-based lookup // resolved to the same (thread) id, matching the banned post's // number for all of them at once. if (container.id) { const specific = container.id.match(/^(?:op|reply|post|thread)[-_](\d+)$/i); if (specific) return specific[1]; } const image = container.querySelector('img[id^="img-"]'); if (image) { const match = image.id.match(/^img-(\d+)$/); if (match) return match[1]; } // Fallback for containers without a recognizable id: prefer a URL // fragment (usually identifies a specific post) over the thread id // that appears in the path. for (const link of container.querySelectorAll('a[href]')) { const hashMatch = link.href.match(/#(?:reply|post)?[-_]?(\d+)$/i); if (hashMatch) return hashMatch[1]; } for (const link of container.querySelectorAll('a[href]')) { const match = link.href.match(/\/(?:res|thread)\/(\d+)(?:\.html)?/); if (match) return match[1]; } // Last resort: any trailing digits in the container's own id. if (container.id) { const match = container.id.match(/(\d+)$/); if (match) return match[1]; } return null; } function getThreadId(element) { if (isThread()) return getCurrentThreadId(); return extractPostNumber(getImageContainer(element)); } function getPostNumber(element) { return extractPostNumber(getImageContainer(element)); } /* * ------------------------------------------------------------ * FULL IMAGE URL * ------------------------------------------------------------ */ function getFullImageURL(image) { if (!image) return null; const link = image.closest('a[href]'); if (link && link.href && !/\/(?:res|thread)\/\d+\.html/i.test(link.href)) { return link.href; } 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 (image.src && !/thumb/i.test(image.src)) return image.src; return image.src || null; } /* * ------------------------------------------------------------ * NETWORK * ------------------------------------------------------------ */ async function gmFetch(url) { try { const response = await fetch(url, { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }); if (!response.ok) throw new Error('HTTP ' + response.status); return await response.text(); } catch (error) { if (typeof GM_xmlhttpRequest === 'undefined') throw error; return gmXhrFetchText(url); } } function gmXhrFetchText(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 gmFetchBuffer(url) { try { const response = await fetch(url, { credentials: 'same-origin' }); if (!response.ok) throw new Error('Image download failed: HTTP ' + response.status); return await response.arrayBuffer(); } catch (error) { if (typeof GM_xmlhttpRequest === 'undefined') throw error; return gmXhrFetchBuffer(url); } } function gmXhrFetchBuffer(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')); } }); }); } function downloadImage(url) { return gmFetchBuffer(url); } /* * ------------------------------------------------------------ * VICHAN API * ------------------------------------------------------------ */ 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); return JSON.parse(text); } catch (error) { 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); const data = JSON.parse(text); if (Array.isArray(data)) return data; if (data && Array.isArray(data.threads)) return [{ threads: data.threads }]; return []; } async function resolveAPIPost(image) { const postNumber = getPostNumber(image); if (!postNumber) return null; const threadIdForFetch = isThread() ? getCurrentThreadId() : postNumber; if (!threadIdForFetch) return null; const threadData = await fetchThreadJSON(threadIdForFetch); const posts = threadData.posts || []; return posts.find(post => String(post.no) === String(postNumber)) || null; } /* * ------------------------------------------------------------ * EXACT HASH * ------------------------------------------------------------ */ async function calculateImageHash(image) { const url = getFullImageURL(image); if (!url) throw new Error('Could not determine full image URL.'); const data = getStorage(); if (data.hashCache[url]) return data.hashCache[url]; const buffer = await downloadImage(url); const result = { hex: md5Hex(buffer), packed: md5PackedBase64(buffer) }; data.hashCache[url] = result; saveStorage(data); return result; } async function getCanonicalHash(image) { try { const post = await resolveAPIPost(image); if (post && post.md5) { return { hex: null, packed: post.md5, source: 'api' }; } if (post && post.tim && post.ext) { const root = location.origin + getBoardRoot(); const url = root + 'src/' + post.tim + post.ext; const buffer = await downloadImage(url); return { hex: md5Hex(buffer), packed: md5PackedBase64(buffer), source: 'api-src' }; } } catch (error) { console.warn('[Vichan Hider] API hash lookup failed:', error.message); } try { const local = await calculateImageHash(image); return { hex: local.hex, packed: local.packed, source: 'dom-fallback' }; } catch { return null; } } /* * ------------------------------------------------------------ * SIMILARITY HASH * * Deliberately separate from MD5. Builds a 64-bit dHash from a small * grayscale representation of the image, so similar images can have * similar hashes even after resizing/recompression. * ------------------------------------------------------------ */ async function calculateSimilarityHash(image) { const url = getFullImageURL(image); if (!url) throw new Error('Could not determine full image URL.'); const data = getStorage(); if (data.similarityCache[url]) return data.similarityCache[url]; const buffer = await downloadImage(url); const blob = new Blob([buffer]); const bitmap = await createImageBitmap(blob); const canvas = document.createElement('canvas'); canvas.width = 9; canvas.height = 8; const ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.drawImage(bitmap, 0, 0, 9, 8); bitmap.close(); const pixels = ctx.getImageData(0, 0, 9, 8).data; let hash = ''; for (let y = 0; y < 8; y++) { for (let x = 0; x < 8; x++) { const left = getPixelGray(pixels, x, y); const right = getPixelGray(pixels, x + 1, y); hash += left > right ? '1' : '0'; } } const result = hashToHex(hash); data.similarityCache[url] = result; saveStorage(data); return result; } function getPixelGray(pixels, x, y) { const i = (y * 9 + x) * 4; return pixels[i] * 0.299 + pixels[i + 1] * 0.587 + pixels[i + 2] * 0.114; } function hashToHex(binary) { let output = ''; for (let i = 0; i < binary.length; i += 4) { output += parseInt(binary.slice(i, i + 4), 2).toString(16); } return output; } function hammingDistance(a, b) { if (!a || !b || a.length !== b.length) return Infinity; let distance = 0; for (let i = 0; i < a.length; i++) { distance += bitCount(parseInt(a[i], 16) ^ parseInt(b[i], 16)); } return distance; } function bitCount(value) { let count = 0; while (value) { value &= value - 1; count++; } return count; } /* * ------------------------------------------------------------ * VISIBILITY * ------------------------------------------------------------ */ let hiddenRevealed = false; let hashbansRevealed = false; function hideElement(container, type) { if (!container) return; container.dataset.vichanHidden = 'true'; container.dataset.vichanHiddenType = type; if (type === 'hash' && hashbansRevealed) { container.style.display = ''; return; } if (type === 'normal' && hiddenRevealed) { container.style.display = ''; return; } container.style.display = 'none'; } function showElement(container) { if (!container) return; container.dataset.vichanHidden = 'false'; container.style.display = ''; } function refreshVisibility() { document.querySelectorAll('[data-vichan-hidden="true"]').forEach(container => { const type = container.dataset.vichanHiddenType; container.style.display = (type === 'hash' ? hashbansRevealed : hiddenRevealed) ? '' : 'none'; }); } /* * ------------------------------------------------------------ * NORMAL THREAD HIDING * ------------------------------------------------------------ */ function rememberHiddenThread(id) { const data = getStorage(); const key = getBoard() + ':' + id; if (!data.hiddenThreads.includes(key)) { data.hiddenThreads.push(key); } saveStorage(data); } function hideCatalogThread(container) { const id = getThreadId(container); if (!id) { alert('Could not determine the thread ID.'); return; } rememberHiddenThread(id); hideElement(container, 'normal'); } function hideCurrentThread() { const id = getCurrentThreadId(); if (!id) return; rememberHiddenThread(id); const thread = document.querySelector('[id^="thread-"]') || document.querySelector('.thread') || document.querySelector('#thread'); if (thread) { hideElement(thread, 'normal'); } else { location.href = location.pathname.replace(/\/res\/\d+\.html$/i, '/catalog.html'); } } function applyThreadHides() { const data = getStorage(); const board = getBoard(); if (isCatalog()) { document.querySelectorAll('#Grid .mix,#Grid .thread,.mix,.catalog-item').forEach(container => { const id = getThreadId(container); if (!id) return; const key = board + ':' + id; if (data.hiddenThreads.includes(key)) { hideElement(container, 'normal'); } }); } 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) hideElement(thread, 'normal'); } } } /* * ------------------------------------------------------------ * HASHBAN * ------------------------------------------------------------ */ async function hashbanImage(image) { setStatus('Hashing...'); try { const canonical = await getCanonicalHash(image); if (!canonical) throw new Error('No hash was obtained.'); const data = getStorage(); const hashes = []; if (canonical.packed) hashes.push(canonical.packed); if (canonical.hex) hashes.push(canonical.hex); for (const hash of hashes) { if (!data.hashbans.includes(hash)) data.hashbans.push(hash); } // Optional similarity hash. if (data.settings.aggressiveHashing) { try { const similarity = await calculateSimilarityHash(image); if (similarity && !data.similarityBans.includes(similarity)) { data.similarityBans.push(similarity); } } catch (error) { console.warn('[Vichan Hider] Similarity hash failed:', error); } } saveStorage(data); // Only ever hide the single post/thumbnail this image belongs // to - getImageContainer() now refuses to return the thread // wrapper itself, see isThreadRoot(). const container = getImageContainer(image); if (container) hideElement(container, 'hash'); await applyHashbans(); setStatus('Hashbanned ✓', 1400); } catch (error) { console.error('[Vichan Hider] Hashing failed:', error); setStatus('Hash failed', 1600); alert('Could not hash this image.\n\n' + error.message); } } async function removeHashban(image) { try { const canonical = await getCanonicalHash(image); const data = getStorage(); const toRemove = new Set(); if (canonical?.packed) toRemove.add(canonical.packed); if (canonical?.hex) toRemove.add(canonical.hex); data.hashbans = data.hashbans.filter(value => !toRemove.has(value)); if (data.settings.aggressiveHashing) { try { const similarity = await calculateSimilarityHash(image); data.similarityBans = data.similarityBans.filter(value => value !== similarity); } catch { // Exact hashes can still be removed. } } saveStorage(data); location.reload(); } catch (error) { console.error('[Vichan Hider] Could not remove hashban:', error); alert('Could not calculate the image hash.'); } } /* * ------------------------------------------------------------ * APPLY HASHBANS * ------------------------------------------------------------ */ async function applyHashbans() { const data = getStorage(); if (!data.hashbans.length && !data.similarityBans.length) return; const hashSet = new Set(data.hashbans); try { if (isCatalog()) { const pages = await fetchCatalogJSON(); const md5ByPost = new Map(); for (const page of pages) { for (const thread of (page.threads || [])) { if (thread && thread.md5 && thread.no) { md5ByPost.set(String(thread.no), thread.md5); } } } const containers = document.querySelectorAll('#Grid .mix,#Grid .thread,.mix,.catalog-item'); containers.forEach(container => { const id = extractPostNumber(container); if (!id) return; const md5 = md5ByPost.get(String(id)); if (md5 && hashSet.has(md5)) hideElement(container, 'hash'); }); // Aggressive mode downloads the actual catalog images, so // it only runs when explicitly enabled. if (data.settings.aggressiveHashing && data.similarityBans.length) { await applySimilarityToContainers(containers); } } if (isThread()) { const threadId = getCurrentThreadId(); if (!threadId) return; const threadData = await fetchThreadJSON(threadId); const posts = threadData.posts || []; if (!posts.length) return; const md5ByPost = new Map(); for (const post of posts) { if (post.md5 && post.no) md5ByPost.set(String(post.no), post.md5); } const containers = document.querySelectorAll('.post,.reply,.postContainer,[data-post-id]'); containers.forEach(container => { // Never hash-hide the entire thread wrapper - only // individual posts. See isThreadRoot(). if (isThreadRoot(container)) return; const id = extractPostNumber(container); if (!id) return; const md5 = md5ByPost.get(String(id)); if (md5 && hashSet.has(md5)) hideElement(container, 'hash'); }); if (data.settings.aggressiveHashing && data.similarityBans.length) { await applySimilarityToContainers(containers); } } } catch (error) { console.warn('[Vichan Hider] Could not apply hashbans:', error); } } async function applySimilarityToContainers(containers) { const data = getStorage(); if (!data.similarityBans.length) return; const threshold = getSimilarityThreshold(); for (const container of containers) { if (isThreadRoot(container)) continue; const image = container.querySelector('img'); if (!image) continue; try { const hash = await calculateSimilarityHash(image); const matched = data.similarityBans.some(banned => hammingDistance(hash, banned) <= threshold); if (matched) hideElement(container, 'hash'); } catch (error) { console.debug('[Vichan Hider] Similarity check skipped:', error.message); } } } /* * ------------------------------------------------------------ * EXPORT / IMPORT * ------------------------------------------------------------ */ function exportHashbans() { const data = getStorage(); const exportData = { format: 'vichan-hider-hashbans', version: 1, exported: new Date().toISOString(), hashbans: data.hashbans, similarityBans: data.similarityBans }; const text = JSON.stringify(exportData, null, 2); const blob = new Blob([text], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); const date = new Date().toISOString().slice(0, 10); link.href = url; link.download = 'vichan-hashbans-' + date + '.json'; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); setStatus('Exported ✓', 1200); } function importHashbans() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json'; input.addEventListener('change', () => { const file = input.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const imported = JSON.parse(reader.result); if (!imported || typeof imported !== 'object') { throw new Error('Invalid file.'); } const importedHashes = Array.isArray(imported.hashbans) ? imported.hashbans : []; const importedSimilarity = Array.isArray(imported.similarityBans) ? imported.similarityBans : []; if (!importedHashes.length && !importedSimilarity.length) { throw new Error('No hashbans were found in this file.'); } const data = getStorage(); let added = 0; for (const hash of importedHashes) { if (typeof hash !== 'string') continue; if (!data.hashbans.includes(hash)) { data.hashbans.push(hash); added++; } } for (const hash of importedSimilarity) { if (typeof hash !== 'string') continue; if (!data.similarityBans.includes(hash)) { data.similarityBans.push(hash); added++; } } saveStorage(data); setStatus('Imported ' + added + ' ✓', 1800); applyHashbans(); } catch (error) { alert('Could not import hashbans.\n\n' + error.message); } }; reader.readAsText(file); }); input.click(); } /* * ------------------------------------------------------------ * STYLES (incl. dark/light mode via prefers-color-scheme) * ------------------------------------------------------------ */ function injectStyles() { if (document.getElementById('vichan-hider-style')) return; const style = document.createElement('style'); style.id = 'vichan-hider-style'; style.textContent = ` :root { --vh-bg: #181818; --vh-fg: #eee; --vh-border: rgba(255,255,255,.16); --vh-border-soft: rgba(255,255,255,.1); --vh-btn-bg: rgba(255,255,255,.08); --vh-btn-bg-hover: rgba(255,255,255,.16); --vh-danger-bg: rgba(180,40,40,.15); --vh-shadow: rgba(0,0,0,.4); --vh-shadow-strong: rgba(0,0,0,.45); --vh-muted: rgba(255,255,255,.65); --vh-muted-soft: rgba(255,255,255,.55); } @media (prefers-color-scheme: light) { :root { --vh-bg: #f2f2f2; --vh-fg: #181818; --vh-border: rgba(0,0,0,.16); --vh-border-soft: rgba(0,0,0,.1); --vh-btn-bg: rgba(0,0,0,.06); --vh-btn-bg-hover: rgba(0,0,0,.12); --vh-danger-bg: rgba(180,40,40,.12); --vh-shadow: rgba(0,0,0,.18); --vh-shadow-strong: rgba(0,0,0,.22); --vh-muted: rgba(0,0,0,.65); --vh-muted-soft: rgba(0,0,0,.55); } } #vichan-hider-controls, #vichan-hider-settings { color-scheme: light dark; } .vh-controls { position: fixed; left: 12px; bottom: 32px; z-index: 999999; background: var(--vh-bg); color: var(--vh-fg); padding: 5px; border: 1px solid var(--vh-border); border-radius: 2px; font: 12px/1 sans-serif; box-shadow: 0 4px 18px var(--vh-shadow); display: flex; align-items: center; gap: 4px; box-sizing: border-box; } .vh-icon { width: 22px; height: 22px; object-fit: contain; display: block; margin: 0 2px; } .vh-title { font-weight: 600; padding: 0 3px; white-space: nowrap; cursor: move; user-select: none; } .vh-controls.vh-dragging { opacity: .85; } .vh-btn { appearance: none; border: 1px solid var(--vh-border); border-radius: 2px; padding: 4px 7px; min-height: 25px; background: var(--vh-btn-bg); color: inherit; font: inherit; cursor: pointer; white-space: nowrap; box-sizing: border-box; } .vh-btn:hover { background: var(--vh-btn-bg-hover); } .vh-btn-icon { width: 26px; padding: 3px; font-size: 14px; line-height: 1; } .vh-btn-close { width: 24px; padding: 3px; font-size: 16px; line-height: 1; } .vh-btn-danger { background: var(--vh-danger-bg); } .vh-status { display: none; opacity: .7; font-size: 11px; margin-left: 2px; white-space: nowrap; } .vh-settings-wrapper { position: relative; display: flex; align-items: center; } .vh-settings { position: absolute; left: 0; bottom: calc(100% + 6px); min-width: 235px; padding: 9px; background: var(--vh-bg); color: var(--vh-fg); border: 1px solid var(--vh-border); border-radius: 2px; box-shadow: 0 5px 20px var(--vh-shadow-strong); font: 12px/1.3 sans-serif; display: none; box-sizing: border-box; } .vh-settings-heading { font-weight: 600; margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1px solid var(--vh-border-soft); } .vh-row { display: grid; grid-template-columns: 1fr auto; gap: 6px; align-items: center; margin-bottom: 9px; } .vh-slider { width: 100%; grid-column: 1 / 3; cursor: pointer; } .vh-threshold-input { width: 56px; background: var(--vh-btn-bg); color: inherit; border: 1px solid var(--vh-border); border-radius: 2px; padding: 3px 5px; font: inherit; box-sizing: border-box; } .vh-checkbox-row { display: flex; align-items: flex-start; gap: 7px; cursor: pointer; margin-bottom: 9px; } .vh-checkbox-row input { margin: 2px 0 0; cursor: pointer; } .vh-checkbox-text b { display: inline; } .vh-checkbox-text span { opacity: .65; } .vh-hash-heading { font-weight: 600; margin: 4px 0 6px; } .vh-hash-buttons { display: flex; gap: 5px; } .vh-stats { margin-top: 8px; padding-top: 7px; border-top: 1px solid var(--vh-border-soft); opacity: .55; font-size: 11px; } .vh-keybinds { margin: 0 0 9px; padding-bottom: 9px; border-bottom: 1px solid var(--vh-border-soft); font-size: 11px; line-height: 1.6; } .vh-keybinds div { display: grid; grid-template-columns: 78px 1fr; gap: 6px; } .vh-keybinds b { opacity: .85; } .vh-hashlist { max-height: 120px; overflow-y: auto; margin-bottom: 4px; border: 1px solid var(--vh-border-soft); border-radius: 2px; } .vh-hashlist-empty { padding: 6px 7px; opacity: .55; font-size: 11px; } .vh-hashlist-row { display: flex; align-items: center; gap: 6px; padding: 4px 6px; font-size: 11px; font-family: monospace; } .vh-hashlist-row:not(:last-child) { border-bottom: 1px solid var(--vh-border-soft); } .vh-hashlist-hash { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .vh-hashlist-tag { opacity: .55; font-size: 10px; font-family: sans-serif; } .vh-hashlist-remove { appearance: none; border: 1px solid var(--vh-border); border-radius: 2px; background: var(--vh-btn-bg); color: inherit; cursor: pointer; width: 18px; height: 18px; line-height: 1; font-size: 12px; flex-shrink: 0; } .vh-hashlist-remove:hover { background: var(--vh-danger-bg); } .vh-tab { position: fixed; left: 12px; bottom: 32px; z-index: 999999; background: var(--vh-bg); color: var(--vh-fg); width: 27px; height: 27px; display: none; align-items: center; justify-content: center; border: 1px solid var(--vh-border); border-radius: 2px; cursor: pointer; font: 14px sans-serif; box-shadow: 0 4px 18px var(--vh-shadow); box-sizing: border-box; } .vh-tab-icon { width: 16px; height: 16px; object-fit: contain; pointer-events: none; } `; document.head.appendChild(style); } /* * ------------------------------------------------------------ * UI HELPERS * ------------------------------------------------------------ */ function setStatus(text, timeout) { const status = document.getElementById('vichan-hider-status'); if (!status) return; status.textContent = text; status.style.display = text ? 'inline' : 'none'; if (timeout) { setTimeout(() => { status.textContent = ''; status.style.display = 'none'; }, timeout); } } function makeButton(text, title, extraClass) { const button = document.createElement('button'); button.textContent = text; button.title = title || ''; button.className = 'vh-btn' + (extraClass ? ' ' + extraClass : ''); return button; } /* * ------------------------------------------------------------ * SETTINGS PANEL * ------------------------------------------------------------ */ function createSettingsPanel(parent) { const currentData = getStorage(); const settings = document.createElement('div'); settings.id = 'vichan-hider-settings'; settings.className = 'vh-settings'; const heading = document.createElement('div'); heading.textContent = 'Settings'; heading.className = 'vh-settings-heading'; settings.appendChild(heading); /* * Keybinds */ const keybinds = document.createElement('div'); keybinds.className = 'vh-keybinds'; keybinds.innerHTML = [ ['Shift+click', 'Hide thread'], ['Ctrl/Cmd+click', 'Hashban image'], ['Alt+click', 'Remove hashban'], [GUI_TOGGLE_SHORTCUT, 'Show/hide panel'] ].map(([key, action]) => `
${key}${action}
`).join(''); settings.appendChild(keybinds); /* * Opacity */ const opacityRow = document.createElement('div'); opacityRow.className = 'vh-row'; const opacityLabel = document.createElement('label'); opacityLabel.textContent = 'Panel opacity'; const opacityValue = document.createElement('span'); const opacitySlider = document.createElement('input'); opacitySlider.type = 'range'; opacitySlider.min = '25'; opacitySlider.max = '100'; opacitySlider.step = '5'; opacitySlider.className = 'vh-slider'; opacitySlider.value = Math.round(currentData.settings.panelOpacity * 100); opacityValue.textContent = opacitySlider.value + '%'; opacitySlider.addEventListener('input', () => { const value = Number(opacitySlider.value); opacityValue.textContent = value + '%'; const data = getStorage(); data.settings.panelOpacity = value / 100; saveStorage(data); applyPanelOpacity(); }); opacityRow.appendChild(opacityLabel); opacityRow.appendChild(opacityValue); opacityRow.appendChild(opacitySlider); settings.appendChild(opacityRow); /* * Reset position (in case the panel gets dragged somewhere awkward) */ const resetPositionButton = makeButton('Reset panel position', 'Move the panel back to its default corner'); resetPositionButton.style.width = '100%'; resetPositionButton.style.marginBottom = '9px'; resetPositionButton.addEventListener('click', () => { const data = getStorage(); data.settings.panelPosition = null; saveStorage(data); applyPanelPosition(); }); settings.appendChild(resetPositionButton); /* * Aggressive (similarity) hashing + manual threshold */ const aggressiveRow = document.createElement('label'); aggressiveRow.className = 'vh-checkbox-row'; const aggressiveCheckbox = document.createElement('input'); aggressiveCheckbox.type = 'checkbox'; aggressiveCheckbox.checked = currentData.settings.aggressiveHashing; const aggressiveText = document.createElement('span'); aggressiveText.className = 'vh-checkbox-text'; aggressiveText.innerHTML = 'Similarity hashing
' + 'Also detects resized/recompressed versions. Uses more image downloads.'; aggressiveRow.appendChild(aggressiveCheckbox); aggressiveRow.appendChild(aggressiveText); settings.appendChild(aggressiveRow); const thresholdRow = document.createElement('div'); thresholdRow.className = 'vh-row'; thresholdRow.style.display = currentData.settings.aggressiveHashing ? 'grid' : 'none'; const thresholdLabel = document.createElement('label'); thresholdLabel.textContent = 'Similarity threshold'; thresholdLabel.title = '0 = identical, 64 = completely different. Default is 10.'; const thresholdInput = document.createElement('input'); thresholdInput.type = 'number'; thresholdInput.min = '0'; thresholdInput.max = '64'; thresholdInput.step = '1'; thresholdInput.className = 'vh-threshold-input'; thresholdInput.value = currentData.settings.similarityThreshold; thresholdInput.addEventListener('change', () => { let value = parseInt(thresholdInput.value, 10); if (Number.isNaN(value)) value = 10; value = clamp(value, 0, 64); thresholdInput.value = value; const data = getStorage(); data.settings.similarityThreshold = value; saveStorage(data); }); thresholdRow.appendChild(thresholdLabel); thresholdRow.appendChild(thresholdInput); settings.appendChild(thresholdRow); aggressiveCheckbox.addEventListener('change', () => { const data = getStorage(); data.settings.aggressiveHashing = aggressiveCheckbox.checked; saveStorage(data); thresholdRow.style.display = aggressiveCheckbox.checked ? 'grid' : 'none'; setStatus('Similarity hashing ' + (aggressiveCheckbox.checked ? 'on' : 'off'), 1200); }); /* * Import / export */ const hashHeading = document.createElement('div'); hashHeading.textContent = 'Hashban list'; hashHeading.className = 'vh-hash-heading'; settings.appendChild(hashHeading); const hashButtons = document.createElement('div'); hashButtons.className = 'vh-hash-buttons'; const exportButton = makeButton('Export', 'Export your hashban list as JSON'); const importButton = makeButton('Import', 'Import a previously exported hashban list'); exportButton.addEventListener('click', exportHashbans); importButton.addEventListener('click', importHashbans); hashButtons.appendChild(exportButton); hashButtons.appendChild(importButton); settings.appendChild(hashButtons); /* * Manageable hash list - lets you remove individual bans (e.g. once * the thread with the offending image has slid off) without editing * storage by hand. */ const hashList = document.createElement('div'); hashList.className = 'vh-hashlist'; settings.appendChild(hashList); function removeHashFromList(hash, kind) { const data = getStorage(); if (kind === 'exact') { data.hashbans = data.hashbans.filter(value => value !== hash); } else { data.similarityBans = data.similarityBans.filter(value => value !== hash); } saveStorage(data); // A reload (rather than a live refresh) ensures any post that // was hidden because of this hash actually reappears - we don't // track which specific element(s) a given hash hid. location.reload(); } function renderHashList() { const data = getStorage(); hashList.innerHTML = ''; const entries = [ ...data.hashbans.map(hash => ({ hash, kind: 'exact' })), ...data.similarityBans.map(hash => ({ hash, kind: 'similarity' })) ]; if (!entries.length) { const empty = document.createElement('div'); empty.className = 'vh-hashlist-empty'; empty.textContent = 'No hashbans yet.'; hashList.appendChild(empty); return; } entries.forEach(({ hash, kind }) => { const row = document.createElement('div'); row.className = 'vh-hashlist-row'; const label = document.createElement('span'); label.className = 'vh-hashlist-hash'; label.textContent = hash; label.title = hash; const tag = document.createElement('span'); tag.className = 'vh-hashlist-tag'; tag.textContent = kind === 'exact' ? 'exact' : 'sim'; const remove = document.createElement('button'); remove.className = 'vh-hashlist-remove'; remove.textContent = '×'; remove.title = 'Remove this hashban'; remove.addEventListener('click', () => removeHashFromList(hash, kind)); row.appendChild(label); row.appendChild(tag); row.appendChild(remove); hashList.appendChild(row); }); } /* * Statistics */ const stats = document.createElement('div'); stats.id = 'vichan-hider-stats'; stats.className = 'vh-stats'; settings.appendChild(stats); settings.updateStats = function updateStats() { const data = getStorage(); stats.textContent = data.hashbans.length + ' exact • ' + data.similarityBans.length + ' similarity • ' + data.hiddenThreads.length + ' hidden'; renderHashList(); }; parent.appendChild(settings); return settings; } function applyPanelPosition() { const data = getStorage(); const box = document.getElementById('vichan-hider-controls'); if (!box) return; const position = data.settings.panelPosition; if (position) { box.style.left = position.left + 'px'; box.style.top = position.top + 'px'; box.style.bottom = 'auto'; } else { box.style.left = ''; box.style.top = ''; box.style.bottom = ''; } } function makePanelDraggable(box, handle) { let dragging = false; let startX = 0; let startY = 0; let originLeft = 0; let originTop = 0; handle.addEventListener('mousedown', event => { // Left button only, and don't start a drag from a click that's // actually meant for a button/input inside the handle. if (event.button !== 0) return; dragging = true; box.classList.add('vh-dragging'); const rect = box.getBoundingClientRect(); originLeft = rect.left; originTop = rect.top; startX = event.clientX; startY = event.clientY; event.preventDefault(); }); document.addEventListener('mousemove', event => { if (!dragging) return; const left = clamp(originLeft + (event.clientX - startX), 0, window.innerWidth - box.offsetWidth); const top = clamp(originTop + (event.clientY - startY), 0, window.innerHeight - box.offsetHeight); box.style.left = left + 'px'; box.style.top = top + 'px'; box.style.bottom = 'auto'; }); document.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; box.classList.remove('vh-dragging'); const rect = box.getBoundingClientRect(); const data = getStorage(); data.settings.panelPosition = { left: Math.round(rect.left), top: Math.round(rect.top) }; saveStorage(data); }); } function applyPanelOpacity() { const data = getStorage(); const box = document.getElementById('vichan-hider-controls'); const tab = document.getElementById('vichan-hider-tab'); if (box) box.style.opacity = data.settings.panelOpacity; if (tab) tab.style.opacity = data.settings.panelOpacity; } /* * ------------------------------------------------------------ * MAIN UI * ------------------------------------------------------------ */ function createControls() { if (document.getElementById('vichan-hider-controls')) return; const data = getStorage(); const box = document.createElement('div'); box.id = 'vichan-hider-controls'; box.className = 'vh-controls'; box.style.display = data.settings.guiHidden ? 'none' : 'flex'; const icon = document.createElement('img'); icon.src = VICHAN_ICON; icon.alt = 'vichan'; icon.title = 'vichan hider'; icon.className = 'vh-icon'; icon.onerror = () => { icon.style.display = 'none'; }; const title = document.createElement('span'); title.textContent = 'vichan hider'; title.title = 'Drag to move • see ⚙ for the full keybind list'; title.className = 'vh-title'; const hiddenButton = makeButton('Show hidden', 'Show or hide manually hidden threads/posts'); hiddenButton.id = 'vichan-hider-toggle'; hiddenButton.addEventListener('click', () => { hiddenRevealed = !hiddenRevealed; hiddenButton.textContent = hiddenRevealed ? 'Hide hidden' : 'Show hidden'; refreshVisibility(); }); const hashedButton = makeButton('Show hashed', 'Show or hide hashbanned images/posts'); hashedButton.id = 'vichan-hider-hash-toggle'; hashedButton.addEventListener('click', () => { hashbansRevealed = !hashbansRevealed; hashedButton.textContent = hashbansRevealed ? 'Hide hashed' : 'Show hashed'; refreshVisibility(); }); // Wrench icon for settings (was a gear). const settingsButton = makeButton('🔧', 'Vichan Hider settings', 'vh-btn-icon'); const settingsWrapper = document.createElement('div'); settingsWrapper.className = 'vh-settings-wrapper'; const settings = createSettingsPanel(settingsWrapper); settingsButton.addEventListener('click', event => { event.stopPropagation(); const visible = settings.style.display !== 'none'; settings.style.display = visible ? 'none' : 'block'; if (settings.updateStats) settings.updateStats(); }); settingsWrapper.appendChild(settingsButton); const clear = makeButton('Clear', 'Remove all locally hidden threads and hashbans', 'vh-btn-danger'); clear.addEventListener('click', () => { if (!confirm('Clear all locally hidden threads and hashbans?')) return; localStorage.removeItem(STORAGE_KEY); location.reload(); }); const status = document.createElement('span'); status.id = 'vichan-hider-status'; status.className = 'vh-status'; // Collapse button. const hideGui = makeButton('×', 'Hide panel (' + GUI_TOGGLE_SHORTCUT + ')', 'vh-btn-close'); hideGui.addEventListener('click', () => setGuiHidden(true)); box.appendChild(icon); box.appendChild(title); box.appendChild(hiddenButton); box.appendChild(hashedButton); box.appendChild(settingsWrapper); box.appendChild(clear); box.appendChild(status); box.appendChild(hideGui); // Restore tab - uses the same vichan icon as the main panel, // falling back to plain "v" text if the icon can't load. const tab = document.createElement('div'); tab.id = 'vichan-hider-tab'; tab.className = 'vh-tab'; tab.title = 'Show Vichan Hider (' + GUI_TOGGLE_SHORTCUT + ')'; tab.style.display = data.settings.guiHidden ? 'flex' : 'none'; const tabIcon = document.createElement('img'); tabIcon.src = VICHAN_ICON; tabIcon.alt = 'vichan'; tabIcon.className = 'vh-tab-icon'; tabIcon.onerror = () => { tabIcon.remove(); tab.textContent = 'v'; }; tab.appendChild(tabIcon); tab.addEventListener('click', () => setGuiHidden(false)); document.body.appendChild(tab); document.body.appendChild(box); // Close settings when clicking elsewhere. document.addEventListener('click', event => { if (!settingsWrapper.contains(event.target)) { settings.style.display = 'none'; } }); applyPanelOpacity(); applyPanelPosition(); makePanelDraggable(box, title); } /* * ------------------------------------------------------------ * GUI HIDE / SHOW * ------------------------------------------------------------ */ function setGuiHidden(hidden) { const data = getStorage(); data.settings.guiHidden = hidden; saveStorage(data); const box = document.getElementById('vichan-hider-controls'); const tab = document.getElementById('vichan-hider-tab'); if (box) box.style.display = hidden ? 'none' : 'flex'; if (tab) tab.style.display = hidden ? 'flex' : 'none'; } /* * ------------------------------------------------------------ * KEYBOARD SHORTCUT * ------------------------------------------------------------ */ document.addEventListener('keydown', function (event) { if (event.ctrlKey && event.altKey && event.key.toLowerCase() === 'h') { event.preventDefault(); const data = getStorage(); setGuiHidden(!data.settings.guiHidden); } }); /* * ------------------------------------------------------------ * CLICK HANDLER * ------------------------------------------------------------ */ document.addEventListener('click', function (event) { const image = event.target.closest('img'); if (!image) return; if (image.closest('#vichan-hider-controls')) return; // SHIFT = normal hide (whole thread, by design) if (event.shiftKey) { event.preventDefault(); event.stopPropagation(); if (isCatalog()) { const container = getImageContainer(image); if (container) hideCatalogThread(container); } if (isThread()) { hideCurrentThread(); } return; } // CTRL / CMD = hashban (single post only) if (event.ctrlKey || event.metaKey) { event.preventDefault(); event.stopPropagation(); hashbanImage(image); return; } // ALT = remove hashban if (event.altKey) { event.preventDefault(); event.stopPropagation(); removeHashban(image); } }); /* * ------------------------------------------------------------ * LIVE THREAD UPDATES * ------------------------------------------------------------ */ 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 }); } /* * ------------------------------------------------------------ * INIT * ------------------------------------------------------------ */ function init() { console.log('[Vichan Hider] v5.2.0 loaded on', location.href); injectStyles(); createControls(); applyThreadHides(); applyHashbans(); if (isThread()) observeThreadUpdates(); } if (document.readyState === 'complete' || document.readyState === 'interactive') { init(); } else { document.addEventListener('DOMContentLoaded', init); } /* * ------------------------------------------------------------ * DEBUG * ------------------------------------------------------------ */ // Non-destructive diagnostic: resolves (but never hides/hashbans) the // container for a given , so we can see exactly what element the // hider would act on without side effects. Select an image in the // Elements panel (so it becomes $0), switch to Console, and run: // window.__vichanHiderDebug.inspectImage($0) function inspectImage(image) { if (!image || image.tagName !== 'IMG') { console.warn('[Vichan Hider] inspectImage: pass an element (e.g. $0 after selecting it in Elements).'); return null; } const container = getImageContainer(image); const postNumber = extractPostNumber(container); const describe = el => el ? { tag: el.tagName, id: el.id || null, className: el.className || null, outerHTMLPreview: el.outerHTML.slice(0, 300) } : null; const result = { page: isCatalog() ? 'catalog' : (isThread() ? 'thread' : 'other'), imageSrc: image.src, resolvedContainer: describe(container), resolvedPostNumber: postNumber, isThreadRoot: isThreadRoot(container), closestPost: describe(image.closest('.post')), closestReply: describe(image.closest('.reply')), closestPostContainer: describe(image.closest('.postContainer')), closestDataPostId: describe(image.closest('[data-post-id]')), closestArticle: describe(image.closest('article')) }; console.log('[Vichan Hider] inspectImage result:', result); return result; } window.__vichanHiderDebug = { getStorage, fetchThreadJSON, fetchCatalogJSON, applyHashbans, applyThreadHides, calculateSimilarityHash, extractPostNumber, isThreadRoot, getImageContainer, inspectImage }; })();