// ==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
};
})();