// ==UserScript==
// @name Vichan Thread Hider + Automatic Image Hashban
// @namespace local.vichan.hider
// @version 4.1.0
// @description Hide threads and locally hashban images on vichan-style imageboards.
// @match *://*/*/catalog.html
// @match *://*/catalog.html
// @match *://*/*/res/*.html
// @match *://*/res/*.html
// @run-at document-idle
// @grant GM_xmlhttpRequest
// @connect *
// ==/UserScript==
(function () {
'use strict';
const STORAGE_KEY = 'vichan_hider_v4';
// ------------------------------------------------------------
// STORAGE
// ------------------------------------------------------------
function getStorage() {
try {
const data = JSON.parse(
localStorage.getItem(STORAGE_KEY)
);
return {
hiddenThreads:
Array.isArray(data?.hiddenThreads)
? data.hiddenThreads
: [],
hashbans:
Array.isArray(data?.hashbans)
? data.hashbans
: [],
hashCache:
data?.hashCache &&
typeof data.hashCache === 'object'
? data.hashCache
: {}
};
} catch {
return {
hiddenThreads: [],
hashbans: [],
hashCache: {}
};
}
}
function saveStorage(data) {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(data)
);
}
// ------------------------------------------------------------
// BOARD / URL
// ------------------------------------------------------------
function getBoard() {
if (document.body.dataset.board) {
return document.body.dataset.board;
}
if (typeof window.board_name !== 'undefined') {
return String(window.board_name);
}
const match = location.pathname.match(
/^\/([^/]+)\/(?:catalog\.html|res\/)/
);
return match
? match[1]
: location.hostname;
}
function getBoardRoot() {
const match = location.pathname.match(
/^\/([^/]+)\//
);
if (!match) {
return '/';
}
return '/' + match[1] + '/';
}
function isCatalog() {
return /\/catalog\.html$/i.test(
location.pathname
);
}
function isThread() {
return /\/res\/\d+\.html$/i.test(
location.pathname
);
}
function getCurrentThreadId() {
const match = location.pathname.match(
/\/res\/(\d+)\.html$/i
);
return match
? match[1]
: null;
}
// ------------------------------------------------------------
// MD5 IMPLEMENTATION
// ------------------------------------------------------------
//
// Pure JavaScript MD5.
// No external library required.
//
function md5(buffer) {
const bytes = new Uint8Array(buffer);
function leftRotate(x, amount) {
return (
(x << amount) |
(x >>> (32 - amount))
) >>> 0;
}
function add32(a, b) {
return (a + b) >>> 0;
}
const originalLength = bytes.length;
const bitLength =
originalLength * 8;
const paddedLength =
((originalLength + 8) >> 6 << 6) + 64;
const message =
new Uint8Array(paddedLength);
message.set(bytes);
message[originalLength] = 0x80;
const view =
new DataView(message.buffer);
view.setUint32(
paddedLength - 8,
bitLength >>> 0,
true
);
view.setUint32(
paddedLength - 4,
Math.floor(bitLength / 0x100000000),
true
);
let a0 = 0x67452301;
let b0 = 0xefcdab89;
let c0 = 0x98badcfe;
let d0 = 0x10325476;
const s = [
7, 12, 17, 22,
7, 12, 17, 22,
7, 12, 17, 22,
7, 12, 17, 22,
5, 9, 14, 20,
5, 9, 14, 20,
5, 9, 14, 20,
5, 9, 14, 20,
4, 11, 16, 23,
4, 11, 16, 23,
4, 11, 16, 23,
4, 11, 16, 23,
6, 10, 15, 21,
6, 10, 15, 21,
6, 10, 15, 21,
6, 10, 15, 21
];
const K = [];
for (let i = 0; i < 64; i++) {
K[i] =
Math.floor(
Math.abs(
Math.sin(i + 1)
) *
0x100000000
) >>> 0;
}
for (
let offset = 0;
offset < paddedLength;
offset += 64
) {
const M = new Uint32Array(16);
for (let i = 0; i < 16; i++) {
M[i] =
view.getUint32(
offset + i * 4,
true
);
}
let A = a0;
let B = b0;
let C = c0;
let D = d0;
for (let i = 0; i < 64; i++) {
let F;
let g;
if (i < 16) {
F =
(B & C) |
(~B & D);
g = i;
} else if (i < 32) {
F =
(D & B) |
(~D & C);
g =
(5 * i + 1) % 16;
} else if (i < 48) {
F =
B ^ C ^ D;
g =
(3 * i + 5) % 16;
} else {
F =
C ^ (B | ~D);
g =
(7 * i) % 16;
}
const temp = D;
D = C;
C = B;
const sum =
add32(
add32(
add32(A, F),
K[i]
),
M[g]
);
B =
add32(
B,
leftRotate(
sum,
s[i]
)
);
A = temp;
}
a0 = add32(a0, A);
b0 = add32(b0, B);
c0 = add32(c0, C);
d0 = add32(d0, D);
}
const output =
new Uint8Array(16);
const result =
new DataView(output.buffer);
result.setUint32(0, a0, true);
result.setUint32(4, b0, true);
result.setUint32(8, c0, true);
result.setUint32(12, d0, true);
return output;
}
function md5Hex(buffer) {
const hash = md5(buffer);
return Array.from(hash)
.map(
byte =>
byte
.toString(16)
.padStart(2, '0')
)
.join('');
}
/*
* Vichan/4chan API represents MD5 as
* packed binary -> base64.
*/
function md5PackedBase64(buffer) {
const hash = md5(buffer);
let binary = '';
for (const byte of hash) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
// ------------------------------------------------------------
// IMAGE / POST DETECTION
// ------------------------------------------------------------
function getImageContainer(image) {
if (!image) {
return null;
}
if (isCatalog()) {
return (
image.closest('.mix') ||
image.closest('.catalog-item') ||
image.closest('.thread') ||
image.closest('[data-id]')
);
}
return (
image.closest('.post') ||
image.closest('.reply') ||
image.closest('.postContainer') ||
image.closest('[data-post-id]')
);
}
/*
* Resolve the post/thread number a given
* container represents. This is the single
* source of truth for ID extraction - used
* both for thread-level hides and for matching
* a specific post against API-provided md5s.
*/
function extractPostNumber(container) {
if (!container) {
return null;
}
if (container.dataset.id) {
return container.dataset.id;
}
for (const attr of [
'data-thread',
'data-thread-id',
'data-post',
'data-post-id'
]) {
const value =
container.getAttribute(attr);
if (
value &&
/^\d+$/.test(value)
) {
return value;
}
}
const image =
container.querySelector(
'img[id^="img-"]'
);
if (image) {
const match =
image.id.match(
/^img-(\d+)$/
);
if (match) {
return match[1];
}
}
for (
const link
of container.querySelectorAll(
'a[href]'
)
) {
const match =
link.href.match(
/\/res\/(\d+)(?:\.html)?/
);
if (match) {
return match[1];
}
}
/*
* Many vichan themes set the post's own
* element id to its post number directly,
* e.g.
.
* The original script never checked this.
*/
if (container.id) {
const match =
container.id.match(/(\d+)$/);
if (match) {
return match[1];
}
}
return null;
}
/*
* Thread-level ID, used for hide/show-thread
* bookkeeping. In a thread page this is always
* the current thread, regardless of which post
* was clicked.
*/
function getThreadId(element) {
if (isThread()) {
return getCurrentThreadId();
}
return extractPostNumber(
getImageContainer(element)
);
}
/*
* Post-level ID: always the specific post the
* image belongs to, even inside a thread. This
* is what must be used to look up that post's
* md5 in the API - using getThreadId() here was
* the main reason hashbans matched the wrong post.
*/
function getPostNumber(element) {
return extractPostNumber(
getImageContainer(element)
);
}
// ------------------------------------------------------------
// FIND FULL IMAGE
// ------------------------------------------------------------
function getFullImageURL(image) {
if (!image) {
return null;
}
/*
* Best case: thumbnail is wrapped in a link
* pointing to the original file.
*/
let link =
image.closest('a[href]');
if (
link &&
link.href &&
!/\/res\/\d+\.html/i.test(
link.href
)
) {
return link.href;
}
/*
* Common vichan attributes.
*/
for (const attr of [
'data-original',
'data-full',
'data-full-image',
'data-src'
]) {
const value =
image.getAttribute(attr);
if (value) {
return new URL(
value,
location.href
).href;
}
}
/*
* If this is already the full image.
*/
if (
image.src &&
!/thumb/i.test(image.src)
) {
return image.src;
}
return image.src || null;
}
// ------------------------------------------------------------
// API SUPPORT
// ------------------------------------------------------------
function gmFetch(url) {
return new Promise(
(resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'text',
onload(response) {
if (
response.status >= 200 &&
response.status < 300
) {
resolve(
response.responseText
);
} else {
reject(
new Error(
'HTTP ' +
response.status
)
);
}
},
onerror() {
reject(
new Error(
'Network error'
)
);
}
});
}
);
}
async function fetchThreadJSON(threadId) {
const root =
location.origin +
getBoardRoot();
const urls = [
root + 'res/' + threadId + '.json',
root + 'thread/' + threadId + '.json'
];
let lastError = null;
for (const url of urls) {
try {
const text = await gmFetch(url);
const data = JSON.parse(text);
console.log(
'[Vichan Hider] Fetched thread JSON from',
url
);
return data;
} catch (error) {
console.warn(
'[Vichan Hider] Thread JSON fetch failed:',
url,
error.message
);
lastError = error;
}
}
throw lastError ||
new Error('Could not fetch thread JSON.');
}
async function fetchCatalogJSON() {
const root =
location.origin +
getBoardRoot();
const url = root + 'catalog.json';
const text =
await gmFetch(url);
console.log(
'[Vichan Hider] Fetched catalog JSON from',
url
);
const data = JSON.parse(text);
if (Array.isArray(data)) {
return data;
}
if (data && Array.isArray(data.threads)) {
return [{ threads: data.threads }];
}
return [];
}
/*
* Resolve the raw API post object (with md5,
* tim, ext, etc.) for the exact post an image
* belongs to - not just "some post in the
* thread" (that was the original bug).
*/
async function resolveAPIPost(image) {
const postNumber = getPostNumber(image);
if (!postNumber) {
console.warn(
'[Vichan Hider] Could not determine a post number for this image - check extractPostNumber().'
);
return null;
}
const threadIdForFetch =
isThread()
? getCurrentThreadId()
: postNumber; // in the catalog, the OP number IS the thread id
if (!threadIdForFetch) {
return null;
}
const threadData =
await fetchThreadJSON(threadIdForFetch);
const posts = threadData.posts || [];
const match = posts.find(
post => String(post.no) === String(postNumber)
);
if (!match) {
console.warn(
'[Vichan Hider] Post',
postNumber,
'was not found in thread JSON for',
threadIdForFetch
);
}
return match || null;
}
/*
* Canonical hash resolution, in priority order:
* 1. The API's own md5 field for this exact post
* (authoritative - this is what every other
* post's md5 will be compared against).
* 2. Download the ORIGINAL file reconstructed from
* the API's tim/ext fields, if md5 is missing.
* 3. Last resort: parse the DOM for a full-image URL
* and hash that. On catalog pages this can end up
* hashing the thumbnail instead of the original if
* the thumbnail is only linked to the thread page,
* which will NOT match other instances of the file -
* a warning is logged when this path is taken.
*/
async function getCanonicalHash(image) {
try {
const post = await resolveAPIPost(image);
if (post) {
if (post.md5) {
console.log(
'[Vichan Hider] Using API md5 for post',
post.no,
'->',
post.md5
);
return {
hex: null,
packed: post.md5,
source: 'api'
};
}
if (post.tim && post.ext) {
const root =
location.origin +
getBoardRoot();
const url =
root + 'src/' + post.tim + post.ext;
console.log(
'[Vichan Hider] Post has no md5 field, downloading reconstructed original:',
url
);
const buffer =
await downloadImage(url);
return {
hex: md5Hex(buffer),
packed: md5PackedBase64(buffer),
source: 'api-src'
};
}
}
} catch (error) {
console.warn(
'[Vichan Hider] API-based hash lookup failed, falling back to DOM parsing:',
error.message
);
}
try {
const local =
await calculateImageHash(image);
console.warn(
'[Vichan Hider] Using DOM-parsed image as hash source (fallback). ' +
'If this is a catalog thumbnail rather than the original file, ' +
'it will NOT match other posts of the same image.'
);
return {
hex: local.hex,
packed: local.packed,
source: 'dom-fallback'
};
} catch (error) {
console.error(
'[Vichan Hider] DOM-based fallback hashing also failed:',
error.message
);
return null;
}
}
// ------------------------------------------------------------
// DOWNLOAD IMAGE
// ------------------------------------------------------------
function downloadImage(url) {
return new Promise(
(resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'arraybuffer',
onload(response) {
if (
response.status >= 200 &&
response.status < 300 &&
response.response
) {
resolve(
response.response
);
} else {
reject(
new Error(
'Image download failed: HTTP ' +
response.status
)
);
}
},
onerror() {
reject(
new Error(
'Image download failed'
)
);
}
});
}
);
}
// ------------------------------------------------------------
// GET HASH
// ------------------------------------------------------------
async function calculateImageHash(
image
) {
const url =
getFullImageURL(image);
if (!url) {
throw new Error(
'Could not determine full image URL.'
);
}
/*
* Cache based on URL first.
*/
const data = getStorage();
if (data.hashCache[url]) {
return data.hashCache[url];
}
console.log(
'[Vichan Hider] Downloading image for MD5:',
url
);
const buffer =
await downloadImage(url);
const result = {
hex: md5Hex(buffer),
packed: md5PackedBase64(buffer)
};
data.hashCache[url] = result;
saveStorage(data);
console.log(
'[Vichan Hider] Image MD5:',
result.hex
);
console.log(
'[Vichan Hider] Vichan MD5:',
result.packed
);
return result;
}
// ------------------------------------------------------------
// HASHBAN (add)
// ------------------------------------------------------------
async function hashbanImage(
image
) {
const button =
document.getElementById(
'vichan-hider-status'
);
if (button) {
button.textContent =
'Hashing image...';
}
try {
const canonical =
await getCanonicalHash(image);
if (!canonical) {
throw new Error(
'No hash was obtained.'
);
}
const hashes = [];
if (canonical.packed) {
hashes.push(canonical.packed);
}
if (canonical.hex) {
hashes.push(canonical.hex);
}
if (!hashes.length) {
throw new Error(
'No hash was obtained.'
);
}
const data =
getStorage();
for (
const hash
of hashes
) {
if (
!data.hashbans.includes(
hash
)
) {
data.hashbans.push(
hash
);
}
}
saveStorage(data);
const container =
getImageContainer(image);
if (container) {
hideThread(container);
}
/*
* Immediately hide any other copies of
* this image already loaded on the page,
* not just the one that was clicked.
*/
await applyHashbans();
if (button) {
button.textContent =
'Hashbanned \u2713';
setTimeout(() => {
button.textContent =
'Show hidden';
}, 1500);
}
console.log(
'[Vichan Hider] Hashban added:',
hashes
);
} catch (error) {
console.error(
'[Vichan Hider] Hashing failed:',
error
);
if (button) {
button.textContent =
'Hash failed';
}
alert(
'Could not hash this image.\n\n' +
error.message +
'\n\n' +
'Check the browser console for details.'
);
}
}
// ------------------------------------------------------------
// HASHBAN (remove)
// ------------------------------------------------------------
async function removeHashban(
image
) {
try {
const canonical =
await getCanonicalHash(image);
const toRemove = new Set();
if (canonical) {
if (canonical.packed) {
toRemove.add(canonical.packed);
}
if (canonical.hex) {
toRemove.add(canonical.hex);
}
}
if (!toRemove.size) {
throw new Error(
"Could not determine this image's hash."
);
}
const data =
getStorage();
data.hashbans =
data.hashbans.filter(
value => !toRemove.has(value)
);
saveStorage(data);
location.reload();
} catch (error) {
console.error(
'[Vichan Hider] Could not remove hashban:',
error
);
alert(
'Could not calculate the image hash.'
);
}
}
// ------------------------------------------------------------
// APPLY HASHBANS TO CURRENT PAGE
// ------------------------------------------------------------
//
// This is the part that was completely missing before:
// nothing ever checked already-loaded images against the
// stored hashban list. Rather than downloading every full
// image on the page (slow, and hostile to the target site),
// this pulls the board's own catalog.json / res/ID.json,
// which already includes each post's md5, and matches that
// in bulk.
//
async function applyHashbans() {
const data = getStorage();
if (!data.hashbans || !data.hashbans.length) {
return;
}
const hashSet = new Set(data.hashbans);
try {
if (isCatalog()) {
const pages = await fetchCatalogJSON();
const md5ByPost = new Map();
for (const page of pages) {
const threads = page.threads || [];
for (const thread of threads) {
if (thread && thread.md5 && thread.no) {
md5ByPost.set(
String(thread.no),
thread.md5
);
}
}
}
let matched = 0;
document
.querySelectorAll(
'#Grid .mix,' +
'#Grid .thread,' +
'.mix,' +
'.catalog-item'
)
.forEach(container => {
const id = extractPostNumber(container);
if (!id) {
return;
}
const md5 = md5ByPost.get(String(id));
if (md5 && hashSet.has(md5)) {
hideThread(container);
matched++;
}
});
console.log(
'[Vichan Hider] Catalog scan: checked',
md5ByPost.size,
'threads,',
hashSet.size,
'hashbans stored,',
matched,
'hidden'
);
}
if (isThread()) {
const threadId = getCurrentThreadId();
if (!threadId) {
return;
}
const threadData = await fetchThreadJSON(threadId);
const posts = threadData.posts || [];
if (!posts.length) {
return;
}
// If the OP's image is hashbanned, hide the whole thread.
const op = posts[0];
if (op && op.md5 && hashSet.has(op.md5)) {
const threadEl =
document.querySelector('[id^="thread-"]') ||
document.querySelector('.thread') ||
document.querySelector('#thread');
if (threadEl) {
hideThread(threadEl);
return;
}
}
const md5ByPost = new Map();
for (const post of posts) {
if (post.md5 && post.no) {
md5ByPost.set(String(post.no), post.md5);
}
}
let matched = 0;
document
.querySelectorAll(
'.post, .reply, .postContainer, [data-post-id]'
)
.forEach(container => {
const id = extractPostNumber(container);
if (!id) {
return;
}
const md5 = md5ByPost.get(String(id));
if (md5 && hashSet.has(md5)) {
hideThread(container);
matched++;
}
});
console.log(
'[Vichan Hider] Thread scan: checked',
md5ByPost.size,
'posts,',
hashSet.size,
'hashbans stored,',
matched,
'hidden'
);
}
} catch (error) {
console.warn(
'[Vichan Hider] Could not apply hashbans from API:',
error
);
}
}
// ------------------------------------------------------------
// THREAD HIDING
// ------------------------------------------------------------
function hideThread(
container
) {
if (!container) {
return;
}
container.dataset.vichanHidden =
'true';
container.style.display =
'none';
}
function showThread(
container
) {
if (!container) {
return;
}
container.dataset.vichanHidden =
'false';
container.style.display =
'';
}
function hideCatalogThread(
container
) {
const id =
getThreadId(container);
if (!id) {
alert(
'Could not determine the thread ID.'
);
return;
}
const data =
getStorage();
const key =
getBoard() +
':' +
id;
if (
!data.hiddenThreads.includes(
key
)
) {
data.hiddenThreads.push(
key
);
}
saveStorage(data);
hideThread(container);
}
function hideCurrentThread() {
const id =
getCurrentThreadId();
if (!id) {
return;
}
const data =
getStorage();
const key =
getBoard() +
':' +
id;
if (
!data.hiddenThreads.includes(
key
)
) {
data.hiddenThreads.push(
key
);
}
saveStorage(data);
/*
* Keep our UI visible.
*/
const thread =
document.querySelector(
'[id^="thread-"]'
) ||
document.querySelector(
'.thread'
) ||
document.querySelector(
'#thread'
);
if (thread) {
hideThread(thread);
} else {
/*
* No recognizable wrapper:
* return to catalog.
*/
location.href =
location.pathname.replace(
/\/res\/\d+\.html$/i,
'/catalog.html'
);
}
}
// ------------------------------------------------------------
// APPLY SAVED THREAD HIDES
// ------------------------------------------------------------
function applyThreadHides() {
const data =
getStorage();
const board =
getBoard();
if (isCatalog()) {
const containers =
document.querySelectorAll(
'#Grid .mix,' +
'#Grid .thread,' +
'.mix,' +
'.catalog-item'
);
containers.forEach(
container => {
const id =
getThreadId(
container
);
if (!id) {
return;
}
const key =
board +
':' +
id;
if (
data.hiddenThreads.includes(
key
)
) {
hideThread(
container
);
}
}
);
}
if (isThread()) {
const id =
getCurrentThreadId();
const key =
board +
':' +
id;
if (
data.hiddenThreads.includes(
key
)
) {
const thread =
document.querySelector(
'[id^="thread-"]'
) ||
document.querySelector(
'.thread'
);
if (thread) {
hideThread(thread);
}
}
}
}
// ------------------------------------------------------------
// UI
// ------------------------------------------------------------
function createControls() {
if (
document.getElementById(
'vichan-hider-controls'
)
) {
return;
}
const box =
document.createElement('div');
box.id =
'vichan-hider-controls';
box.style.cssText = `
position: fixed;
left: 12px;
bottom: 12px;
z-index: 999999;
background: rgba(0,0,0,.85);
color: white;
padding: 8px 10px;
border-radius: 5px;
font: 13px sans-serif;
box-shadow: 0 2px 8px rgba(0,0,0,.4);
`;
const show =
document.createElement(
'button'
);
show.textContent =
'Show hidden';
show.style.cssText =
'cursor:pointer;margin-right:6px;';
show.addEventListener(
'click',
() => {
document
.querySelectorAll(
'[data-vichan-hidden="true"]'
)
.forEach(
showThread
);
}
);
const clear =
document.createElement(
'button'
);
clear.textContent =
'Clear filters';
clear.style.cssText =
'cursor:pointer;';
clear.addEventListener(
'click',
() => {
if (
!confirm(
'Clear all locally hidden threads and hashbans?'
)
) {
return;
}
localStorage.removeItem(
STORAGE_KEY
);
location.reload();
}
);
const status =
document.createElement(
'div'
);
status.id =
'vichan-hider-status';
status.style.cssText = `
margin-top:6px;
opacity:.8;
font-size:11px;
`;
status.textContent =
'Shift+click = hide \u2022 Ctrl+click = hashban \u2022 Alt+click = unhashban';
box.appendChild(show);
box.appendChild(clear);
box.appendChild(status);
document.body.appendChild(box);
}
// ------------------------------------------------------------
// CLICK HANDLER
// ------------------------------------------------------------
document.addEventListener(
'click',
function (event) {
const image =
event.target.closest(
'img'
);
if (!image) {
return;
}
if (
image.closest(
'#vichan-hider-controls'
)
) {
return;
}
/*
* SHIFT
*/
if (event.shiftKey) {
event.preventDefault();
event.stopPropagation();
if (isCatalog()) {
const container =
getImageContainer(
image
);
if (container) {
hideCatalogThread(
container
);
}
}
if (isThread()) {
hideCurrentThread();
}
return;
}
/*
* CTRL / CMD
*/
if (
event.ctrlKey ||
event.metaKey
) {
event.preventDefault();
event.stopPropagation();
hashbanImage(
image
);
return;
}
/*
* ALT
*/
if (event.altKey) {
event.preventDefault();
event.stopPropagation();
removeHashban(
image
);
return;
}
}
);
// ------------------------------------------------------------
// INIT
// ------------------------------------------------------------
function init() {
console.log(
'[Vichan Hider] v4.1.0 loaded on',
location.href
);
createControls();
applyThreadHides();
applyHashbans();
if (isThread()) {
observeThreadUpdates();
}
}
/*
* On boards with a live thread auto-updater,
* new replies get inserted into the DOM without
* a page reload. Re-check hashbans (debounced)
* whenever that happens, so new posts matching an
* existing hashban get hidden too.
*/
function observeThreadUpdates() {
const target =
document.querySelector('form[name="postform"]') ||
document.body;
let timer = null;
const observer = new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(() => {
applyHashbans();
}, 800);
});
observer.observe(target, {
childList: true,
subtree: true
});
}
if (
document.readyState === 'complete' ||
document.readyState === 'interactive'
) {
init();
} else {
document.addEventListener(
'DOMContentLoaded',
init
);
}
/*
* Manual debugging from devtools, e.g.:
* __vichanHiderDebug.getStorage()
* __vichanHiderDebug.fetchThreadJSON('12345').then(console.log)
* __vichanHiderDebug.fetchCatalogJSON().then(console.log)
* __vichanHiderDebug.applyHashbans()
* Useful for checking whether a given vichan fork's JSON
* actually matches the field names this script expects
* (posts[].no, posts[].md5, posts[].tim, posts[].ext).
*/
window.__vichanHiderDebug = {
getStorage,
fetchThreadJSON,
fetchCatalogJSON,
applyHashbans,
extractPostNumber
};
})();