// ==UserScript==
// @name Kemono/Coomer Blacklist with backup
// @version 2.3
// @description Add authors to Blacklist and hide them and their posts from site.
// @author glauthentica & Gemini
// @match https://kemono.cr/*
// @match https://*.kemono.cr/*
// @match https://coomer.st/*
// @match https://*.coomer.st/*
// @run-at document-idle
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.download
// @grant GM_download
// @grant GM_addValueChangeListener
// @license MIT
// @homepageURL https://boosty.to/glauthentica
// @contributionURL https://boosty.to/glauthentica
// @namespace https://tampermonkey.net/
// ==/UserScript==
(function() {
'use strict';
const getCurrentService = () => {
const host = location.hostname;
if (host.includes('kemono')) return 'kemono';
if (host.includes('coomer')) return 'coomer';
return 'unknown';
};
const CURRENT_SERVICE = getCurrentService();
const STORAGE_KEY = `blacklist_${CURRENT_SERVICE}_v2`;
let BL = {};
let initialized = false;
let SHOW_HIDDEN = false;
let lastHiddenCount = 0;
const SELECTORS = {
USER_CARD: 'a.user-card[href*="/user/"]',
POST_CARD: 'article.post-card, .post-card, li.card-list__item, .artist-card',
CONTENT_CONTAINERS: 'main .card-list__items, main, #user-content',
};
const qs = (s, r=document) => r.querySelector(s);
const qsa = (s, r=document) => Array.from(r.querySelectorAll(s));
function insertEnd(parent, node) { if (parent && node) try { parent.insertAdjacentElement('beforeend', node); } catch {} }
// Safer append helper (avoids issues with Element.append in some environments)
function append(parent, ...children) {
if (!parent) return;
for (const ch of children) {
try {
if (ch == null) continue;
if (ch.nodeType) parent.appendChild(ch);
else parent.appendChild(document.createTextNode(String(ch)));
} catch {}
}
}
function safeDateStamp() {
const d = new Date(), pad = n => String(n).padStart(2,'0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
}
function downloadTextFile(text, filename, mime='application/json;charset=utf-8') {
const makeBlobUrl = () => URL.createObjectURL(new Blob([text], { type: mime }));
const revokeLater = url => setTimeout(() => { try { URL.revokeObjectURL(url); } catch {} }, 20000);
try {
const url = makeBlobUrl(), details = { url, name: filename, saveAs: true };
if (typeof GM !== 'undefined' && GM.download) { GM.download(details); revokeLater(url); return; }
if (typeof GM_download === 'function') { GM_download(details); revokeLater(url); return; }
URL.revokeObjectURL(url);
} catch {}
try {
const url = makeBlobUrl(), a = document.createElement('a');
a.href = url; a.download = filename; a.rel = 'noopener';
a.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
revokeLater(url);
} catch (e) {
alert('Could not save file automatically. Please enable downloads/pop-ups for this site and try again.');
}
}
function pickJsonFileText() {
return new Promise((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file'; input.accept = 'application/json,.json'; input.style.display = 'none';
input.onchange = () => {
try {
const file = input.files?.[0];
if (!file) return reject(new Error('No file selected'));
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(reader.error || new Error('File reading error'));
reader.readAsText(file);
} catch (e) { reject(e); } finally { input.remove(); }
};
document.body.appendChild(input);
input.click();
});
}
// Import: accept any valid "service:user" keys
function mergeImportedObject(obj) {
let added = 0, updated = 0, skipped = 0;
for (const [rawKey, entry] of Object.entries(obj)) {
if (!/^[a-z0-9_-]+:[^:]+$/i.test(rawKey)) { skipped++; continue; }
const [svcFromKey, userFromKey] = rawKey.split(':');
const service = String((entry.service || svcFromKey || '')).toLowerCase().trim();
const user = String((entry.user || userFromKey || '')).trim();
if (!service || !user) { skipped++; continue; }
const key = `${service}:${user}`;
const existed = Object.prototype.hasOwnProperty.call(BL, key);
const prev = BL[key] || {};
BL[key] = {
service,
user,
label: (entry.label && String(entry.label).trim()) || prev.label || `${service}/${user}`,
addedAt: prev.addedAt || entry.addedAt || new Date().toISOString()
};
existed ? updated++ : added++;
}
return { added, updated, skipped };
}
async function pickAndImportJsonFile() {
const fileText = await pickJsonFileText();
const obj = JSON.parse(fileText);
if (!obj || typeof obj !== 'object') throw new Error('Invalid JSON format');
const result = mergeImportedObject(obj);
await saveBL();
scheduleRefresh();
return result;
}
function getCreatorKeyFromHref(href) {
try {
if (!href) return null;
const path = href.startsWith('http') ? new URL(href).pathname : new URL(href, location.origin).pathname;
const m = path.match(/^\/([a-z0-9_-]+)\/user\/([^\/?#]+)/i);
return m ? `${m[1].toLowerCase()}:${decodeURIComponent(m[2])}` : null;
} catch { return null; }
}
const currentAuthorKey = () => getCreatorKeyFromHref(location.pathname);
const onAuthorRootPage = () => /^\/([a-z0-9_-]+)\/user\/([^\/?#]+)\/?$/i.test(location.pathname);
const hasBL = (key) => key && Object.prototype.hasOwnProperty.call(BL, key);
const saveBL = async () => await GM.setValue(STORAGE_KEY, BL);
const loadBL = async () => { BL = (await GM.getValue(STORAGE_KEY, {})); if (!BL || typeof BL !== 'object') BL = {}; };
// Always store neutral label; show ID (user) in UI
const addToBL = (key) => {
if (!key) return;
const [service, user] = key.split(':');
const prev = BL[key] || {};
BL[key] = { service, user, label: `${service}/${user}`, addedAt: prev.addedAt || new Date().toISOString() };
return saveBL();
};
const removeFromBL = (key) => { if (key) { delete BL[key]; return saveBL(); } };
const formatLabel = (entry) => (entry && entry.user) ? String(entry.user) : '';
let cssInserted = false;
function insertStyles() {
if (cssInserted) return; cssInserted = true;
const style = document.createElement('style');
style.textContent = `
.kcbl-rel { position: relative !important; }
.kcbl-btn { display: inline-flex; align-items: center; cursor: pointer; user-select: none; }
.kcbl-btn.kcbl--un, .kcbl-btn.kcbl--un span { color: #ff4136 !important; }
.kcbl-btn__icon { width: 1em; height: 1em; }
.kcbl-inline-btn {
position: absolute; top: 8px; right: 6px; z-index: 5;
padding: 2px 4px; background: transparent; border: none;
color: #fff !important; text-shadow: 0 0 3px #000;
}
.kcbl-inline-btn .kcbl-btn__icon { width: 24px; height: 24px; }
.kcbl-soft { opacity: 0.35 !important; filter: grayscale(0.2); position: relative; }
.kcbl-soft::after { content: 'Hidden'; position: absolute; top: 6px; left: 6px; font-size: 11px; padding: 2px 6px; background: rgba(0,0,0,0.6); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 999px; z-index: 3; }
.kcbl-hidden { display: none !important; }
#kcbl-reveal-toggle { position: fixed; bottom: 60px; right: 16px; z-index: 999999; padding: 8px 10px; font-size: 13px; border-radius: 8px; border: 1px solid #0003; background: #202020e0; color: #fff; cursor: pointer; box-shadow: 0 6px 16px #00000059; }
#kcbl-fab { position: fixed; bottom: 16px; right: 16px; z-index: 999999; display: inline-flex; align-items: center; gap: 6px; padding: 8px 12px; font-size: 13px; font-weight: 600; border-radius: 999px; border: 1px solid #0003; background: #202020eb; color: #fff; cursor: pointer; box-shadow: 0 6px 16px #00000059; user-select: none; }
#kcbl-fab .kcbl-btn__icon { width: 18px; height: 18px; }
#kcbl-badge { position: fixed; bottom: 42px; right: 12px; z-index: 1000000; min-width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; padding: 0 5px; border-radius: 999px; background-color: #d93025; color: white; font-size: 11px; font-weight: 600; line-height: 1; box-shadow: 0 1px 3px #0000004d; pointer-events: none; }
.kcbl-badge--hidden { display: none !important; }
.kcbl-modal-link { color: #8ab4f8; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.kcbl-modal-link:hover { text-decoration: underline; }
#kcbl-toggle {
position: relative;
top: 0;
}
#kcbl-toggle, #kcbl-toggle span {
color: #fff !important;
}
#kcbl-toggle.kcbl--un, #kcbl-toggle.kcbl--un span {
color: #ff4136 !important;
}
`;
insertEnd(document.head, style);
}
const blacklistIconSvg = (klass) => `<svg class="${klass}" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2"><circle cx="12" cy="12" r="9"></circle><line x1="6.8" y1="6.8" x2="17.2" y2="17.2"></line></svg>`;
function updatePageButtonVisual(btn, isBlacklisted) {
if (!btn) return;
const textSpan = document.createElement('span');
textSpan.textContent = isBlacklisted ? 'Unblacklist' : 'Blacklist';
btn.innerHTML = '';
append(btn, textSpan);
btn.title = isBlacklisted ? 'Remove from blacklist' : 'Add to blacklist';
btn.classList.toggle('kcbl--un', isBlacklisted);
}
function ensureBlacklistToggleButton() {
const key = currentAuthorKey();
if (!key) return;
let attempts = 0;
const maxAttempts = 20;
const intervalId = setInterval(() => {
const actions = qs('.user-header__actions');
attempts++;
if (actions || attempts >= maxAttempts) {
clearInterval(intervalId);
if (!actions) {
console.log('[KC Blacklist] Could not find the actions container to add the button.');
return;
}
let btn = qs('#kcbl-toggle');
if (!btn) {
const favBtn = [...actions.querySelectorAll('button,a')].find(el => el.textContent?.trim().toLowerCase().includes('favorite'));
btn = document.createElement('button');
btn.id = 'kcbl-toggle';
btn.type = 'button';
btn.style.marginLeft = '8px';
if (favBtn) {
btn.className = favBtn.className;
}
btn.classList.add('kcbl-btn');
btn.onclick = async (e) => {
e.preventDefault(); const k = currentAuthorKey(); if (!k) return;
const isBlacklisted = hasBL(k);
if (isBlacklisted) { await removeFromBL(k); }
else { await addToBL(k); }
updatePageButtonVisual(btn, !isBlacklisted);
scheduleRefresh();
};
favBtn ? favBtn.insertAdjacentElement('afterend', btn) : insertEnd(actions, btn);
}
updatePageButtonVisual(btn, hasBL(key));
}
}, 250);
}
function ensureInlineButtonsForAuthorCards() {
qsa(SELECTORS.USER_CARD).forEach(card => {
if (card.querySelector('.kcbl-inline-btn')) return;
const key = getCreatorKeyFromHref(card.getAttribute('href')); if (!key) return;
card.classList.add('kcbl-rel');
let btn = document.createElement('button');
btn.type = 'button';
btn.className = 'kcbl-inline-btn kcbl-btn';
btn.innerHTML = blacklistIconSvg('kcbl-btn__icon');
btn.onclick = async (e) => {
e.preventDefault(); e.stopPropagation();
const isBlacklisted = hasBL(key);
if (isBlacklisted) { await removeFromBL(key); }
else { await addToBL(key); }
btn.classList.toggle('kcbl--un', !isBlacklisted);
scheduleRefresh();
};
insertEnd(card, btn);
btn.classList.toggle('kcbl--un', hasBL(key));
});
}
// Buttons on posts pages (/posts, /posts/popular, etc.)
function ensureInlineButtonsForPostCards() {
if (!/^\/posts(\/|$)/.test(location.pathname)) return;
qsa(SELECTORS.POST_CARD).forEach(card => {
if (card.querySelector('.kcbl-inline-btn')) return;
const anyLinkWithId = card.querySelector('a[href*="/user/"]');
if (!anyLinkWithId) return;
const key = getCreatorKeyFromHref(anyLinkWithId.getAttribute('href'));
if (!key) return;
card.classList.add('kcbl-rel');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'kcbl-inline-btn kcbl-btn';
btn.title = 'Toggle blacklist for author';
btn.innerHTML = blacklistIconSvg('kcbl-btn__icon');
btn.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
const isBlacklisted = hasBL(key);
if (isBlacklisted) { await removeFromBL(key); }
else { await addToBL(key); }
btn.classList.toggle('kcbl--un', !isBlacklisted);
scheduleRefresh();
};
insertEnd(card, btn);
btn.classList.toggle('kcbl--un', hasBL(key));
});
}
function ensureUI(type) {
let el = qs(`#kcbl-${type}`);
if (!el) {
el = document.createElement('button'); el.id = `kcbl-${type}`;
if (type === 'reveal-toggle') {
el.onclick = () => { SHOW_HIDDEN = !SHOW_HIDDEN; scheduleRefresh(); };
} else {
const serviceName = CURRENT_SERVICE.charAt(0).toUpperCase() + CURRENT_SERVICE.slice(1);
el.innerHTML = `${blacklistIconSvg('kcbl-btn__icon')} <span>${serviceName} Blacklist</span>`;
el.title = `Open ${serviceName} blacklist manager`;
el.onclick = (e) => { e.preventDefault(); showBlacklistModal(); };
}
insertEnd(document.body, el);
}
return el;
}
function ensureBlacklistBadge() {
let badge = qs('#kcbl-badge');
if (!badge) {
badge = document.createElement('div'); badge.id = 'kcbl-badge';
badge.className = 'kcbl-badge--hidden';
insertEnd(document.body, badge);
}
}
function updateUI() {
ensureUI('fab');
const badge = qs('#kcbl-badge'), revealBtn = ensureUI('reveal-toggle');
if (onAuthorRootPage() || lastHiddenCount === 0) {
revealBtn.style.display = 'none';
} else {
revealBtn.style.display = '';
revealBtn.textContent = `${SHOW_HIDDEN ? 'Hide' : 'Show'} hidden (${lastHiddenCount})`;
}
if (badge) {
const count = Object.keys(BL).length;
badge.textContent = count;
badge.classList.toggle('kcbl-badge--hidden', count === 0);
}
}
function refreshHiding() {
if (onAuthorRootPage()) {
qsa('.kcbl-hidden, .kcbl-soft').forEach(el => el.classList.remove('kcbl-hidden', 'kcbl-soft'));
lastHiddenCount = 0;
return;
}
const allCards = qsa(`${SELECTORS.USER_CARD}, ${SELECTORS.POST_CARD}`);
for (const card of allCards) {
let anchor = card.querySelector('a[href*="/user/"]');
if (!anchor && card.matches && card.matches('a[href*="/user/"]')) {
anchor = card;
}
const key = anchor ? getCreatorKeyFromHref(anchor.getAttribute('href')) : null;
const shouldHide = hasBL(key);
if (shouldHide) {
if (SHOW_HIDDEN) {
card.classList.remove('kcbl-hidden');
card.classList.add('kcbl-soft');
} else {
card.classList.remove('kcbl-soft');
card.classList.add('kcbl-hidden');
}
} else {
card.classList.remove('kcbl-hidden', 'kcbl-soft');
}
}
lastHiddenCount = qsa('.kcbl-hidden, .kcbl-soft').length;
}
let rafScheduled = false;
function scheduleRefresh() {
if (rafScheduled) return; rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
insertStyles();
ensureBlacklistToggleButton();
ensureInlineButtonsForAuthorCards();
ensureInlineButtonsForPostCards();
ensureBlacklistBadge();
refreshHiding();
updateUI();
});
}
// Right-side drawer modal (narrow panel)
function showBlacklistModal() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:#0009;z-index:999999;display:flex;align-items:stretch;justify-content:flex-end;padding:0;';
const modal = document.createElement('div');
modal.style.cssText = 'position:relative;background:#111;color:#eee;width:400px;max-width:90vw;height:100vh;display:flex;flex-direction:column;border-radius:10px 0 0 10px;padding:14px;box-shadow:0 10px 30px #00000080;font-family:system-ui,sans-serif;';
const list = document.createElement('div'); list.style.cssText = 'overflow-y:auto;margin-bottom:12px;flex:1 1 auto;';
const onEsc = (e) => { if (e.key === 'Escape') closeModal(); };
const closeModal = () => {
document.removeEventListener('keydown', onEsc, true);
overlay.remove();
const fab = qs('#kcbl-fab');
if (fab) fab.blur();
};
document.addEventListener('keydown', onEsc, true);
overlay.onclick = (e) => { if (e.target === overlay) closeModal(); };
let currentSort = 'date_desc';
const renderList = () => {
list.innerHTML = '';
let entries = Object.entries(BL);
switch (currentSort) {
case 'name_asc':
entries.sort((a,b) => (a[1].label || a[1].user).localeCompare(b[1].label || b[1].user));
break;
case 'date_desc':
default:
entries.sort((a,b) => new Date(b[1].addedAt || 0) - new Date(a[1].addedAt || 0));
break;
}
if (entries.length === 0) {
list.textContent = 'Blacklist is empty.';
list.style.minHeight = '100px';
return;
} else {
list.style.minHeight = '';
}
for (const [key, entry] of entries) {
const row = document.createElement('div');
row.className = 'kcbl-modal-row';
row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 0;';
const authorLink = document.createElement('a');
authorLink.className = 'kcbl-modal-link';
authorLink.href = `/${entry.service}/user/${encodeURIComponent(entry.user)}`;
authorLink.target = '_blank';
authorLink.textContent = authorLink.title = formatLabel(entry);
const rmBtn = document.createElement('button');
rmBtn.textContent = 'Remove';
rmBtn.style.cssText = 'padding:4px 8px;border-radius:6px;border:1px solid #333;background:#222;color:#fff;cursor:pointer;flex-shrink:0;';
rmBtn.onclick = async () => { await removeFromBL(key); scheduleRefresh(); renderList(); };
append(row, authorLink, rmBtn);
append(list, row);
}
};
const searchInput = document.createElement('input');
searchInput.placeholder = 'Search by name or ID...';
searchInput.style.cssText = 'width:100%;box-sizing:border-box;padding:8px;background:#222;border:1px solid #444;color:#fff;border-radius:6px;margin-bottom:10px;';
searchInput.oninput = () => {
const query = searchInput.value.toLowerCase().trim();
list.querySelectorAll('.kcbl-modal-row').forEach(row => {
row.style.display = row.textContent.toLowerCase().includes(query) ? 'flex' : 'none';
});
};
const sortContainer = document.createElement('div');
sortContainer.style.cssText = 'display:flex; align-items:center; gap:8px; margin-bottom:12px;';
const sortLabel = document.createElement('label');
sortLabel.textContent = 'Sort by:';
sortLabel.style.cssText = 'font-size:14px; color:#ccc;';
const sortSelect = document.createElement('select');
sortSelect.style.cssText = 'padding:4px 8px; border-radius:6px; border:1px solid #444; background:#222; color:#fff; cursor:pointer;';
sortSelect.innerHTML = `
<option value="date_desc">Date Added (Newest)</option>
<option value="name_asc">Name (A-Z)</option>
`;
sortSelect.onchange = () => {
currentSort = sortSelect.value;
renderList();
};
append(sortContainer, sortLabel, sortSelect);
const controls = document.createElement('div');
controls.style.cssText = 'display:flex;gap:8px;margin-top:auto;flex-wrap:wrap;';
const exportBtn = document.createElement('button');
exportBtn.textContent = 'Export JSON';
exportBtn.style.cssText = 'padding:6px 10px;border-radius:6px;border:1px solid #333;background:#222;color:#fff;cursor:pointer;';
exportBtn.onclick = () => downloadTextFile(JSON.stringify(BL, null, 2), `${CURRENT_SERVICE}_blacklist_${safeDateStamp()}.json`);
const importBtn = document.createElement('button');
importBtn.textContent = 'Import JSON';
importBtn.style.cssText = 'padding:6px 10px;border-radius:6px;border:1px solid #333;background:#222;color:#fff;cursor:pointer;';
importBtn.onclick = async () => {
try {
const { added, updated, skipped } = await pickAndImportJsonFile();
alert(`Import complete.\nAdded: ${added}, Updated: ${updated}, Skipped: ${skipped}.`);
renderList();
} catch (e) { alert('Import error: ' + (e.message || e)); }
};
const clearBtn = document.createElement('button');
clearBtn.textContent = 'Clear blacklist';
clearBtn.style.cssText = 'padding:6px 10px;border-radius:6px;border:1px solid #6b0000;background:#8b0000;color:#fff;cursor:pointer;';
clearBtn.onclick = async () => {
const count = Object.keys(BL).length;
if (!count) { alert('Blacklist is already empty.'); return; }
if (!confirm(`Clear ${count} entr${count === 1 ? 'y' : 'ies'} from blacklist? This cannot be undone.`)) return;
BL = {};
await saveBL();
scheduleRefresh();
renderList();
};
append(controls, exportBtn, importBtn, clearBtn);
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '×';
closeBtn.style.cssText = 'position:absolute;top:8px;right:12px;background:none;border:none;color:#aaa;font-size:24px;font-weight:bold;cursor:pointer;line-height:1;padding:0;';
closeBtn.onmouseover = () => { closeBtn.style.color = '#fff'; };
closeBtn.onmouseout = () => { closeBtn.style.color = '#aaa'; };
closeBtn.onclick = closeModal;
append(modal, closeBtn, sortContainer, searchInput, list, controls);
append(overlay, modal);
insertEnd(document.body, overlay);
renderList();
}
let observer = null;
function startObserver() {
if (observer) return;
const targetNode = document.body;
if (!targetNode) {
setTimeout(startObserver, 500);
return;
}
observer = new MutationObserver(() => scheduleRefresh());
observer.observe(targetNode, { childList: true, subtree: true });
window.addEventListener('popstate', scheduleRefresh, { passive: true });
}
async function init() {
if (initialized) return; initialized = true;
if (typeof GM_addValueChangeListener === 'function') {
GM_addValueChangeListener(STORAGE_KEY, (name, oldValue, newValue, remote) => {
if (remote) {
try {
let newBL = newValue;
if (typeof newBL === 'string') {
newBL = JSON.parse(newValue || '{}');
}
if (newBL && typeof newBL === 'object') {
BL = newBL;
scheduleRefresh();
}
} catch (err) {
console.error('[KC-BL Sync] Error processing storage change event:', err);
}
}
});
}
await loadBL();
scheduleRefresh();
startObserver();
}
if (document.readyState === 'complete' || document.readyState === 'interactive') {
init();
} else {
document.addEventListener('DOMContentLoaded', init, { once: true });
}
})();