// ==UserScript==
// @name Vichan Thread Hider + Automatic Image Hashban
// @namespace local.vichan.hider
// @version 5.0.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
};
/*
* The vichan icon supplied by the user.
*
* If a particular board blocks external images via CSP,
* the panel will simply fall back to the small "v" text.
*/
const VICHAN_ICON =
'https://comfy.guide/icons/vichan.png';
const GUI_TOGGLE_SHORTCUT =
'Ctrl+Alt+H';
/*
* ------------------------------------------------------------
* 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
: [],
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'
? Math.max(
0.25,
Math.min(
1,
data.settings.panelOpacity
)
)
: 1
}
};
} catch {
return {
hiddenThreads: [],
hashbans: [],
similarityBans: [],
hashCache: {},
similarityCache: {},
settings: {
...DEFAULT_SETTINGS
}
};
}
}
function saveStorage(data) {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(data)
);
}
/*
* ------------------------------------------------------------
* 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(
/^\/([^/]+)\//
);
if (!match) {
return '/';
}
return '/' + 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;
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) {
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
* ------------------------------------------------------------
*/
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]');
}
if (known) {
return known;
}
return findGenericContainer(
image
);
}
function findGenericContainer(image) {
let node =
image.parentElement;
let hops = 0;
while (
node &&
hops < 6
) {
if (
extractPostNumber(
node
)
) {
return node;
}
node =
node.parentElement;
hops++;
}
return (
image.closest('article') ||
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;
}
}
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|thread)\/(\d+)(?:\.html)?/
);
if (match) {
return match[1];
}
}
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
);
const data =
JSON.parse(
text
);
return data;
} 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
*
* This is deliberately separate from MD5.
*
* It creates a 64-bit dHash from a small grayscale
* representation of the image.
*
* Similar images can therefore 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++
) {
const x =
parseInt(
a[i],
16
) ^
parseInt(
b[i],
16
);
distance +=
bitCount(
x
);
}
return distance;
}
function bitCount(value) {
let count = 0;
while (value) {
value &=
value - 1;
count++;
}
return count;
}
/*
* Similarity threshold.
*
* 0 = identical
* 64 = completely different
*
* 10 is deliberately conservative.
*/
const SIMILARITY_THRESHOLD =
10;
/*
* ------------------------------------------------------------
* 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;
if (
type === 'hash'
) {
container.style.display =
hashbansRevealed
? ''
: 'none';
} else {
container.style.display =
hiddenRevealed
? ''
: 'none';
}
}
);
}
/*
* ------------------------------------------------------------
* NORMAL THREAD HIDING
* ------------------------------------------------------------
*/
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
);
hideElement(
container,
'normal'
);
}
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
);
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
);
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
)
);
/*
* Also remove the similarity hash if possible.
*/
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 {
/*
* CATALOG
*/
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
);
}
}
}
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
)
) {
hideElement(
container,
'hash'
);
}
}
);
/*
* Aggressive mode:
*
* Only run this when explicitly enabled.
*
* This downloads the actual catalog images,
* so it is intentionally opt-in.
*/
if (
data.settings
.aggressiveHashing &&
data.similarityBans.length
) {
await applySimilarityToContainers(
document.querySelectorAll(
'#Grid .mix,' +
'#Grid .thread,' +
'.mix,' +
'.catalog-item'
)
);
}
}
/*
* THREAD
*/
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
);
}
}
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
)
) {
hideElement(
container,
'hash'
);
}
}
);
/*
* Aggressive similarity matching.
*/
if (
data.settings
.aggressiveHashing &&
data.similarityBans.length
) {
await applySimilarityToContainers(
document.querySelectorAll(
'.post,' +
'.reply,' +
'.postContainer,' +
'[data-post-id]'
)
);
}
}
} catch (error) {
console.warn(
'[Vichan Hider] Could not apply hashbans:',
error
);
}
}
async function applySimilarityToContainers(
containers
) {
const data =
getStorage();
if (
!data.similarityBans.length
) {
return;
}
for (
const container
of containers
) {
const image =
container.querySelector(
'img'
);
if (!image) {
continue;
}
try {
const hash =
await calculateSimilarityHash(
image
);
let matched = false;
for (
const banned
of data.similarityBans
) {
if (
hammingDistance(
hash,
banned
) <=
SIMILARITY_THRESHOLD
) {
matched = true;
break;
}
}
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.'
);
}
/*
* Accept our format.
*/
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();
}
/*
* ------------------------------------------------------------
* 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
) {
const button =
document.createElement(
'button'
);
button.textContent =
text;
button.title =
title || '';
button.style.cssText = `
appearance: none;
border: 1px solid rgba(255,255,255,.16);
border-radius: 2px;
padding: 4px 7px;
min-height: 25px;
background: rgba(255,255,255,.08);
color: inherit;
font: inherit;
cursor: pointer;
white-space: nowrap;
box-sizing: border-box;
`;
button.addEventListener(
'mouseenter',
() => {
button.style.background =
'rgba(255,255,255,.16)';
}
);
button.addEventListener(
'mouseleave',
() => {
button.style.background =
'rgba(255,255,255,.08)';
}
);
return button;
}
/*
* ------------------------------------------------------------
* SETTINGS
* ------------------------------------------------------------
*/
function createSettingsPanel(
parent
) {
const settings =
document.createElement(
'div'
);
settings.id =
'vichan-hider-settings';
settings.style.cssText = `
position: absolute;
left: 0;
bottom: calc(100% + 6px);
min-width: 235px;
padding: 9px;
background: #181818;
color: #eee;
border: 1px solid rgba(255,255,255,.16);
border-radius: 2px;
box-shadow: 0 5px 20px rgba(0,0,0,.45);
font: 12px/1.3 sans-serif;
display: none;
box-sizing: border-box;
`;
const heading =
document.createElement(
'div'
);
heading.textContent =
'Settings';
heading.style.cssText = `
font-weight: 600;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(255,255,255,.1);
`;
settings.appendChild(
heading
);
/*
* Opacity
*/
const opacityRow =
document.createElement(
'div'
);
opacityRow.style.cssText = `
display: grid;
grid-template-columns: 1fr auto;
gap: 6px;
align-items: center;
margin-bottom: 9px;
`;
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';
const currentData =
getStorage();
opacitySlider.value =
Math.round(
currentData.settings
.panelOpacity *
100
);
opacityValue.textContent =
opacitySlider.value +
'%';
opacitySlider.style.cssText = `
width: 100%;
grid-column: 1 / 3;
cursor: pointer;
`;
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
);
/*
* Aggressive hashing.
*/
const aggressiveRow =
document.createElement(
'label'
);
aggressiveRow.style.cssText = `
display: flex;
align-items: flex-start;
gap: 7px;
cursor: pointer;
margin-bottom: 9px;
`;
const aggressiveCheckbox =
document.createElement(
'input'
);
aggressiveCheckbox.type =
'checkbox';
aggressiveCheckbox.checked =
currentData.settings
.aggressiveHashing;
aggressiveCheckbox.style.cssText = `
margin: 2px 0 0;
cursor: pointer;
`;
const aggressiveText =
document.createElement(
'span'
);
aggressiveText.innerHTML =
'Similarity hashing
' +
'' +
'Also detects resized/recompressed versions. ' +
'Uses more image downloads.' +
'';
aggressiveRow.appendChild(
aggressiveCheckbox
);
aggressiveRow.appendChild(
aggressiveText
);
aggressiveCheckbox.addEventListener(
'change',
() => {
const data =
getStorage();
data.settings
.aggressiveHashing =
aggressiveCheckbox.checked;
saveStorage(
data
);
if (
aggressiveCheckbox.checked
) {
setStatus(
'Similarity hashing on',
1200
);
} else {
setStatus(
'Similarity hashing off',
1200
);
}
}
);
settings.appendChild(
aggressiveRow
);
/*
* Import / export.
*/
const hashHeading =
document.createElement(
'div'
);
hashHeading.textContent =
'Hashban list';
hashHeading.style.cssText = `
font-weight: 600;
margin: 4px 0 6px;
`;
settings.appendChild(
hashHeading
);
const hashButtons =
document.createElement(
'div'
);
hashButtons.style.cssText = `
display: flex;
gap: 5px;
`;
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
);
/*
* Statistics.
*/
const stats =
document.createElement(
'div'
);
stats.id =
'vichan-hider-stats';
stats.style.cssText = `
margin-top: 8px;
padding-top: 7px;
border-top: 1px solid rgba(255,255,255,.1);
opacity: .55;
font-size: 11px;
`;
settings.appendChild(
stats
);
function updateStats() {
const data =
getStorage();
stats.textContent =
data.hashbans.length +
' exact • ' +
data.similarityBans.length +
' similarity • ' +
data.hiddenThreads.length +
' hidden';
}
settings.updateStats =
updateStats;
parent.appendChild(
settings
);
return settings;
}
function applyPanelOpacity() {
const data =
getStorage();
const box =
document.getElementById(
'vichan-hider-controls'
);
if (box) {
box.style.opacity =
data.settings
.panelOpacity;
}
const tab =
document.getElementById(
'vichan-hider-tab'
);
if (tab) {
tab.style.opacity =
data.settings
.panelOpacity;
}
}
/*
* ------------------------------------------------------------
* MAIN UI
* ------------------------------------------------------------
*/
function createControls() {
if (
document.getElementById(
'vichan-hider-controls'
)
) {
return;
}
const data =
getStorage();
/*
* Main panel.
*/
const box =
document.createElement(
'div'
);
box.id =
'vichan-hider-controls';
box.style.cssText = `
position: fixed;
left: 12px;
bottom: 32px;
z-index: 999999;
background: #181818;
color: #eee;
padding: 5px;
border: 1px solid rgba(255,255,255,.16);
border-radius: 2px;
font: 12px/1 sans-serif;
box-shadow: 0 4px 18px rgba(0,0,0,.4);
display: ${data.settings.guiHidden ? 'none' : 'flex'};
align-items: center;
gap: 4px;
box-sizing: border-box;
`;
/*
* Icon.
*/
const icon =
document.createElement(
'img'
);
icon.src =
VICHAN_ICON;
icon.alt =
'vichan';
icon.title =
'vichan hider';
icon.style.cssText = `
width: 22px;
height: 22px;
object-fit: contain;
display: block;
margin: 0 2px;
`;
icon.onerror =
() => {
icon.style.display =
'none';
};
/*
* Title.
*/
const title =
document.createElement(
'span'
);
title.textContent =
'vichan hider';
title.title =
'Shift+click: hide • Ctrl+click: hashban • Alt+click: unhashban';
title.style.cssText = `
font-weight: 600;
padding: 0 3px;
white-space: nowrap;
`;
/*
* Show hidden.
*/
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();
}
);
/*
* Show hashed.
*/
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();
}
);
/*
* Settings button.
*/
const settingsButton =
makeButton(
'⚙',
'Vichan Hider settings'
);
settingsButton.style.cssText += `
width: 26px;
padding: 3px;
font-size: 14px;
line-height: 1;
`;
/*
* Settings wrapper.
*/
const settingsWrapper =
document.createElement(
'div'
);
settingsWrapper.style.cssText = `
position: relative;
display: flex;
align-items: center;
`;
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
);
/*
* Clear.
*/
const clear =
makeButton(
'Clear',
'Remove all locally hidden threads and hashbans'
);
clear.style.background =
'rgba(180,40,40,.15)';
clear.addEventListener(
'click',
() => {
if (
!confirm(
'Clear all locally hidden threads and hashbans?'
)
) {
return;
}
localStorage.removeItem(
STORAGE_KEY
);
location.reload();
}
);
/*
* Status.
*/
const status =
document.createElement(
'span'
);
status.id =
'vichan-hider-status';
status.style.cssText = `
display: none;
opacity: .7;
font-size: 11px;
margin-left: 2px;
white-space: nowrap;
`;
/*
* Collapse button.
*/
const hideGui =
makeButton(
'×',
'Hide panel (' +
GUI_TOGGLE_SHORTCUT +
')'
);
hideGui.style.cssText += `
width: 24px;
padding: 3px;
font-size: 16px;
line-height: 1;
`;
hideGui.addEventListener(
'click',
() => setGuiHidden(
true
)
);
/*
* Assemble.
*/
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.
*/
const tab =
document.createElement(
'div'
);
tab.id =
'vichan-hider-tab';
tab.textContent =
'v';
tab.title =
'Show Vichan Hider (' +
GUI_TOGGLE_SHORTCUT +
')';
tab.style.cssText = `
position: fixed;
left: 12px;
bottom: 32px;
z-index: 999999;
background: #181818;
color: #eee;
width: 27px;
height: 27px;
display: ${data.settings.guiHidden ? 'flex' : 'none'};
align-items: center;
justify-content: center;
border: 1px solid rgba(255,255,255,.16);
border-radius: 2px;
cursor: pointer;
font: 14px sans-serif;
box-shadow: 0 4px 18px rgba(0,0,0,.4);
box-sizing: border-box;
`;
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();
}
/*
* ------------------------------------------------------------
* 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
*/
if (
event.shiftKey
) {
event.preventDefault();
event.stopPropagation();
if (
isCatalog()
) {
const container =
getImageContainer(
image
);
if (container) {
hideCatalogThread(
container
);
}
}
if (
isThread()
) {
hideCurrentThread();
}
return;
}
/*
* CTRL / CMD = hashban
*/
if (
event.ctrlKey ||
event.metaKey
) {
event.preventDefault();
event.stopPropagation();
hashbanImage(
image
);
return;
}
/*
* ALT = remove hashban
*/
if (
event.altKey
) {
event.preventDefault();
event.stopPropagation();
removeHashban(
image
);
return;
}
}
);
/*
* ------------------------------------------------------------
* 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.0.0 loaded on',
location.href
);
createControls();
applyThreadHides();
applyHashbans();
if (
isThread()
) {
observeThreadUpdates();
}
}
if (
document.readyState ===
'complete' ||
document.readyState ===
'interactive'
) {
init();
} else {
document.addEventListener(
'DOMContentLoaded',
init
);
}
/*
* ------------------------------------------------------------
* DEBUG
* ------------------------------------------------------------
*/
window.__vichanHiderDebug = {
getStorage,
fetchThreadJSON,
fetchCatalogJSON,
applyHashbans,
applyThreadHides,
calculateSimilarityHash,
extractPostNumber
};
})();