// ==UserScript==
// @name Kemono/Coomer Blacklist with backup
// @namespace https://tampermonkey.net/
// @version 1.1.0
// @description Hide posts and authors in lists/search on kemono.cr and coomer.st. Export/import blacklist as JSON file.
// @author glauthentica
// @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.registerMenuCommand
// @grant GM.download
// @grant GM_download
// @license MIT
// @homepageURL https://boosty.to/glauthentica
// @contributionURL https://boosty.to/glauthentica
// ==/UserScript==
(function() {
'use strict';
const STORAGE_KEY = 'kemono_coomer_blacklist_v1';
let BL = {}; // { "service:user": {service,user,label,addedAt} }
let initialized = false;
let SHOW_HIDDEN = false; // reveal hidden items softly
let lastHiddenCount = 0;
const qs = (s, r=document) => r.querySelector(s);
const qsa = (s, r=document) => Array.from(r.querySelectorAll(s));
// === Helpers: dates, download, file-pick, import/merge ===
function safeDateStamp() {
const d = new Date();
const 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 blob = new Blob([text], { type: mime });
const url = URL.createObjectURL(blob);
const details = { url, name: filename, saveAs: true };
try {
const dl = (typeof GM !== 'undefined' && GM && typeof GM.download === 'function')
? GM.download
: (typeof GM_download === 'function' ? GM_download : null);
if (dl) {
dl(details);
} else {
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
}
} catch (e) {
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
} finally {
setTimeout(() => URL.revokeObjectURL(url), 20000);
}
}
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 = () => {
const file = input.files && input.files[0];
if (!file) {
reject(new Error('Файл не выбран'));
input.remove();
return;
}
const reader = new FileReader();
reader.onload = () => { resolve(String(reader.result || '')); input.remove(); };
reader.onerror = () => { reject(reader.error || new Error('Ошибка чтения файла')); input.remove(); };
reader.readAsText(file);
};
document.body.appendChild(input);
input.click();
});
}
function mergeImportedObject(obj) {
let added = 0, updated = 0, skipped = 0;
for (const [key, entry] of Object.entries(obj)) {
if (!/^[a-z0-9_-]+:[^:]+$/i.test(key)) { skipped++; continue; }
const [svc, usr] = key.split(':');
const record = {
service: (entry.service || svc || '').toString(),
user: (entry.user || usr || '').toString(),
label: ((entry.label && String(entry.label).trim()) || `${svc}/${usr}`),
addedAt: entry.addedAt || new Date().toISOString()
};
if (BL[key]) updated++; else added++;
BL[key] = record;
}
return { added, updated, skipped };
}
async function importFromJsonText(text) {
const obj = JSON.parse(text);
if (!obj || typeof obj !== 'object') throw new Error('Неверный формат JSON');
const res = mergeImportedObject(obj);
await saveBL();
scheduleRefresh();
return res;
}
async function pickAndImportJsonFile() {
const text = await pickJsonFileText();
return importFromJsonText(text);
}
// === end helpers ===
function getCreatorKeyFromPath(pathname) {
try {
const m = pathname.match(/^\/([a-z0-9_-]+)\/user\/([^\/?#]+)(?:\/?|$)/i);
if (!m) return null;
const service = m[1].toLowerCase();
const user = decodeURIComponent(m[2]);
return `${service}:${user}`;
} catch { return null; }
}
function parseUrlToPathname(href) {
try {
if (!href) return '';
if (href.startsWith('http')) return new URL(href).pathname;
return new URL(href, location.origin).pathname;
} catch { return ''; }
}
function getCreatorKeyFromHref(href) {
const path = parseUrlToPathname(href);
return getCreatorKeyFromPath(path);
}
function currentAuthorKey() {
return getCreatorKeyFromPath(location.pathname);
}
function onAuthorRootPage() {
return /^\/([a-z0-9_-]+)\/user\/([^\/?#]+)\/?$/i.test(location.pathname);
}
function hasBL(key) { return key && Object.prototype.hasOwnProperty.call(BL, key); }
async function saveBL() { await GM.setValue(STORAGE_KEY, BL); }
async function loadBL() {
BL = await GM.getValue(STORAGE_KEY, {});
if (!BL || typeof BL !== 'object') BL = {};
}
function addToBL(key, meta={}) {
if (!key) return;
const [service, user] = key.split(':');
const now = new Date().toISOString();
const prev = BL[key] || {};
BL[key] = {
service,
user,
label: (meta.label || prev.label || `${service}/${user}`).trim(),
addedAt: prev.addedAt || now
};
return saveBL();
}
function removeFromBL(key) {
if (!key) return;
delete BL[key];
return saveBL();
}
function formatLabel(entry) {
if (!entry) return '';
const base = `${entry.service}/${entry.user}`;
return entry.label && entry.label !== base ? `${entry.label} (${base})` : base;
}
// Styles
let cssInserted = false;
function insertStyles() {
if (cssInserted) return;
cssInserted = true;
const style = document.createElement('style');
style.textContent = `
.kcbl-rel { position: relative !important; }
.kcbl-inline-btn {
position: absolute; top: 6px; right: 6px;
width: 24px; height: 24px; display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.55); color: #fff; border: 1px solid rgba(255,255,255,0.25);
border-radius: 50%; font-size: 14px; line-height: 1; cursor: pointer; z-index: 3;
user-select: none;
}
.kcbl-inline-btn:hover { background: rgba(0,0,0,0.75); }
.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; line-height: 1;
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-reveal-toggle {
position: fixed; bottom: 16px; right: 16px; z-index: 999999;
padding: 8px 10px; font-size: 13px;
border-radius: 8px; border: 1px solid rgba(0,0,0,0.25);
background: rgba(32,32,32,0.9); color: #fff; cursor: pointer;
box-shadow: 0 6px 16px rgba(0,0,0,0.35);
}
#kcbl-reveal-toggle:hover { background: rgba(32,32,32,1); }
`;
(document.head || document.documentElement).appendChild(style);
}
// Header toggle button (author page)
function ensureBlacklistToggleButton() {
const key = currentAuthorKey();
const actions = qs('.user-header__actions');
if (!actions || !key) return;
let btn = qs('#kcbl-toggle');
if (!btn) {
let favoriteBtn = null;
for (const el of actions.querySelectorAll('button,a')) {
const txt = (el.textContent || '').trim().toLowerCase();
if (txt.includes('favorite')) { favoriteBtn = el; break; }
}
btn = document.createElement('button');
btn.id = 'kcbl-toggle';
btn.type = 'button';
btn.style.cssText = `
margin-left: 8px; font-size: 18px; line-height: 1;
padding: 4px 6px; border: 1px solid rgba(0,0,0,0.2);
border-radius: 6px; background: transparent; cursor: pointer;
`;
btn.addEventListener('click', async (e) => {
e.preventDefault();
const k = currentAuthorKey();
if (!k) return;
if (hasBL(k)) {
await removeFromBL(k);
} else {
const nameEl = qs('.user-header__name [itemprop="name"], .user-header__name');
const label = nameEl ? nameEl.textContent.trim() : k.replace(':','/');
await addToBL(k, { label });
}
updateToggleVisual(btn, currentAuthorKey());
scheduleRefresh();
});
if (favoriteBtn && favoriteBtn.parentElement === actions) {
favoriteBtn.insertAdjacentElement('afterend', btn);
} else {
actions.appendChild(btn);
}
}
updateToggleVisual(btn, key);
}
function updateToggleVisual(btn, key) {
const inBL = hasBL(key);
btn.textContent = inBL ? '✅' : '🚫';
btn.title = inBL ? 'Remove from blacklist' : 'Add to blacklist';
}
// Mini-buttons on author cards
function ensureInlineButtonsForAuthorCards() {
const cards = qsa('a.user-card[href*="/user/"]');
for (const card of cards) {
let key = card.dataset.kcblKey;
if (!key) {
const svc = card.dataset.service;
const id = card.dataset.id;
if (svc && id) key = `${svc}:${id}`;
else key = getCreatorKeyFromHref(card.getAttribute('href'));
if (key) card.dataset.kcblKey = key;
}
if (!key) continue;
if (!card.classList.contains('kcbl-rel')) card.classList.add('kcbl-rel');
let btn = card.querySelector('.kcbl-inline-btn');
if (!btn) {
btn = document.createElement('button');
btn.type = 'button';
btn.className = 'kcbl-inline-btn';
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const k = card.dataset.kcblKey || key;
if (!k) return;
if (hasBL(k)) {
await removeFromBL(k);
} else {
const nameEl = card.querySelector('.user-card__name');
const label = nameEl ? nameEl.textContent.trim() : k.replace(':','/');
await addToBL(k, { label });
}
updateInlineBtnVisual(btn, k);
scheduleRefresh();
});
card.appendChild(btn);
}
updateInlineBtnVisual(btn, key);
}
}
function updateInlineBtnVisual(btn, key) {
const inBL = hasBL(key);
btn.textContent = inBL ? '✅' : '🚫';
btn.title = inBL ? 'Remove from blacklist' : 'Add to blacklist';
}
// Floating "Show hidden" button
function ensureRevealToggleButton() {
let btn = qs('#kcbl-reveal-toggle');
if (!btn) {
btn = document.createElement('button');
btn.id = 'kcbl-reveal-toggle';
btn.addEventListener('click', () => {
SHOW_HIDDEN = !SHOW_HIDDEN;
scheduleRefresh();
});
(document.body || document.documentElement).appendChild(btn);
}
updateRevealToggleButton();
}
function updateRevealToggleButton() {
const btn = qs('#kcbl-reveal-toggle');
if (!btn) return;
const onAuthorPage = onAuthorRootPage();
if (onAuthorPage || lastHiddenCount === 0) {
btn.style.display = 'none';
return;
}
btn.style.display = '';
btn.textContent = SHOW_HIDDEN
? `Hide hidden (${lastHiddenCount})`
: `Show hidden (${lastHiddenCount})`;
btn.title = SHOW_HIDDEN
? 'Turn off revealing hidden cards/posts'
: 'Turn on revealing hidden cards/posts';
}
// Hiding logic
function hideContainer(el, shouldHide) {
if (!el) return;
if (shouldHide) {
el.classList.add('kcbl-hidden');
if (SHOW_HIDDEN) {
el.classList.add('kcbl-soft');
el.style.display = '';
} else {
el.classList.remove('kcbl-soft');
el.style.display = 'none';
}
} else {
el.classList.remove('kcbl-hidden');
el.classList.remove('kcbl-soft');
el.style.display = '';
}
}
function refreshHiding() {
try {
insertStyles();
// Do not hide anything on author root page
if (onAuthorRootPage()) {
for (const el of qsa('.kcbl-hidden, .kcbl-soft')) hideContainer(el, false);
lastHiddenCount = 0;
updateRevealToggleButton();
return;
}
let hiddenCount = 0;
// Author cards
const authorCards = qsa('a.user-card[href*="/user/"]');
for (const card of authorCards) {
let key = card.dataset.kcblKey;
if (!key) {
const svc = card.dataset.service;
const id = card.dataset.id;
if (svc && id) key = `${svc}:${id}`;
else key = getCreatorKeyFromHref(card.getAttribute('href'));
if (key) card.dataset.kcblKey = key;
}
if (!key) continue;
// Update label guess if missing
if (hasBL(key)) {
const entry = BL[key];
const base = `${entry.service}/${entry.user}`;
if (!entry.label || entry.label === base) {
const nameEl = card.querySelector('.user-card__name');
const guess = nameEl ? nameEl.textContent.trim() : '';
if (guess) entry.label = guess;
}
}
const toHide = hasBL(key);
hideContainer(card, toHide);
if (toHide) hiddenCount++;
}
// Post cards
const postCards = qsa('article.post-card, .post-card');
for (const card of postCards) {
let key = card.dataset.kcblKey;
if (!key) {
const svc = card.dataset.service;
const usr = card.dataset.user;
if (svc && usr) key = `${svc}:${usr}`;
}
if (!key) {
const a = card.querySelector('a[href*="/user/"][href*="/post/"]');
if (a) key = getCreatorKeyFromHref(a.getAttribute('href'));
}
if (key) card.dataset.kcblKey = key;
if (!key) continue;
const toHide = hasBL(key);
hideContainer(card, toHide);
if (toHide) hiddenCount++;
}
// Fallback: by post anchors
const postAnchors = qsa('a[href*="/user/"][href*="/post/"]');
for (const a of postAnchors) {
let key = a.dataset.kcblKey || getCreatorKeyFromHref(a.getAttribute('href'));
if (key) a.dataset.kcblKey = key;
if (!key) continue;
let container = a.closest('article.post-card, .post-card, li');
if (!container) {
let el = a;
for (let i=0; i<6 && el && el !== document.body; i++) {
if (/post-card/i.test(el.className || '') || el.tagName === 'ARTICLE' || el.tagName === 'LI') { container = el; break; }
el = el.parentElement;
}
}
if (!container) continue;
const toHide = hasBL(key);
hideContainer(container, toHide);
if (toHide) hiddenCount++;
}
lastHiddenCount = hiddenCount;
ensureRevealToggleButton();
ensureInlineButtonsForAuthorCards();
updateRevealToggleButton();
} catch (e) {
console.error('[KC-BL] refreshHiding error:', e);
}
}
let rafScheduled = false;
function scheduleRefresh() {
if (rafScheduled) return;
rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
ensureBlacklistToggleButton();
refreshHiding();
});
}
// Blacklist modal (view/export/import)
function showBlacklistModal() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:999999;display:flex;align-items:center;justify-content:center;';
const modal = document.createElement('div');
modal.style.cssText = 'background:#111;color:#eee;max-width:720px;width:90%;max-height:80vh;overflow:auto;border-radius:10px;padding:16px;box-shadow:0 10px 30px rgba(0,0,0,0.5);font-family:system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;';
const title = document.createElement('div');
title.textContent = `Blacklist (${Object.keys(BL).length})`;
title.style.cssText = 'font-weight:700;font-size:18px;margin-bottom:10px;';
const list = document.createElement('div');
if (Object.keys(BL).length === 0) {
list.textContent = 'Blacklist is empty.';
list.style.margin = '8px 0 12px';
} else {
for (const [key, entry] of Object.entries(BL)) {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 0;border-bottom:1px solid rgba(255,255,255,0.08);';
const left = document.createElement('div');
left.textContent = formatLabel(entry);
left.style.cssText = 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:75%;';
const right = document.createElement('div');
const openBtn = document.createElement('a');
openBtn.href = `/${entry.service}/user/${encodeURIComponent(entry.user)}`;
openBtn.textContent = 'Open';
openBtn.target = '_blank';
openBtn.style.cssText = 'color:#8ab4f8;text-decoration:none;margin-right:10px;';
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;';
rmBtn.addEventListener('click', async () => {
await removeFromBL(key);
row.remove();
title.textContent = `Blacklist (${Object.keys(BL).length})`;
scheduleRefresh();
});
right.appendChild(openBtn);
right.appendChild(rmBtn);
row.appendChild(left);
row.appendChild(right);
list.appendChild(row);
}
}
const controls = document.createElement('div');
controls.style.cssText = 'display:flex;gap:8px;margin-top:12px;flex-wrap:wrap;';
const btnClose = document.createElement('button');
btnClose.textContent = 'Close';
btnClose.style.cssText = 'padding:6px 10px;border-radius:6px;border:1px solid #333;background:#222;color:#fff;cursor:pointer;';
btnClose.addEventListener('click', () => overlay.remove());
const btnExport = document.createElement('button');
btnExport.textContent = 'Export JSON';
btnExport.style.cssText = 'padding:6px 10px;border-radius:6px;border:1px solid #333;background:#222;color:#fff;cursor:pointer;';
btnExport.addEventListener('click', async () => {
const json = JSON.stringify(BL, null, 2);
const filename = `kemono_coomer_blacklist_${safeDateStamp()}.json`;
downloadTextFile(json, filename);
alert('Экспорт начат: файл будет сохранен в вашу папку загрузок (или вас попросят выбрать место).');
});
const btnImportFile = document.createElement('button');
btnImportFile.textContent = 'Import JSON file';
btnImportFile.style.cssText = 'padding:6px 10px;border-radius:6px;border:1px solid #333;background:#222;color:#fff;cursor:pointer;';
btnImportFile.addEventListener('click', async () => {
try {
const { added, updated, skipped } = await pickAndImportJsonFile();
alert(`Импорт завершён. Добавлено: ${added}, обновлено: ${updated}${skipped ? ', пропущено: ' + skipped : ''}.`);
overlay.remove();
} catch (e) {
alert('Ошибка импорта: ' + (e && e.message ? e.message : e));
}
});
const btnImportPaste = document.createElement('button');
btnImportPaste.textContent = 'Import JSON (paste)';
btnImportPaste.style.cssText = 'padding:6px 10px;border-radius:6px;border:1px solid #333;background:#222;color:#fff;cursor:pointer;';
btnImportPaste.addEventListener('click', async () => {
const text = prompt('Вставьте JSON с чёрным списком:');
if (!text) return;
try {
const { added, updated, skipped } = await importFromJsonText(text);
alert(`Импорт завершён. Добавлено: ${added}, обновлено: ${updated}${skipped ? ', пропущено: ' + skipped : ''}.`);
overlay.remove();
} catch (e) {
alert('Ошибка импорта: ' + (e && e.message ? e.message : e));
}
});
controls.appendChild(btnClose);
controls.appendChild(btnExport);
controls.appendChild(btnImportFile);
controls.appendChild(btnImportPaste);
modal.appendChild(title);
modal.appendChild(list);
modal.appendChild(controls);
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
function setupMenu() {
GM.registerMenuCommand(`Show blacklist (${Object.keys(BL).length})`, showBlacklistModal);
GM.registerMenuCommand('Export blacklist (JSON)', async () => {
const json = JSON.stringify(BL, null, 2);
const filename = `kemono_coomer_blacklist_${safeDateStamp()}.json`;
downloadTextFile(json, filename);
alert('Экспорт начат: файл будет сохранен в вашу папку загрузок (или вас попросят выбрать место).');
});
GM.registerMenuCommand('Import blacklist from JSON file', async () => {
try {
const { added, updated, skipped } = await pickAndImportJsonFile();
alert(`Импорт завершён. Добавлено: ${added}, обновлено: ${updated}${skipped ? ', пропущено: ' + skipped : ''}.`);
} catch (e) {
alert('Ошибка импорта: ' + (e && e.message ? e.message : e));
}
});
GM.registerMenuCommand('Import blacklist (paste JSON)', async () => {
const text = prompt('Вставьте JSON с чёрным списком:');
if (!text) return;
try {
const { added, updated, skipped } = await importFromJsonText(text);
alert(`Импорт завершён. Добавлено: ${added}, обновлено: ${updated}${skipped ? ', пропущено: ' + skipped : ''}.`);
} catch (e) {
alert('Ошибка импорта: ' + (e && e.message ? e.message : e));
}
});
}
let observer = null;
function startObserver() {
if (observer) return;
observer = new MutationObserver(() => scheduleRefresh());
observer.observe(document.documentElement, { childList: true, subtree: true });
window.addEventListener('popstate', scheduleRefresh, { passive: true });
}
async function init() {
if (initialized) return;
initialized = true;
await loadBL();
setupMenu();
insertStyles();
scheduleRefresh();
startObserver();
setTimeout(() => saveBL(), 3000);
}
init();
})();