// ==UserScript==
// @name Kemono/Coomer Blacklist with backup
// @version 1.6.5
// @description Hide posts and authors in lists/search on kemono.cr and coomer.st. Syncs across tabs.
// @author glauthentica (updated by 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',
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 {} }
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();
});
}
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] = key.split(':');
if (svc.toLowerCase() !== CURRENT_SERVICE) {
skipped++;
continue;
}
const record = {
service: String(entry.service || svc || ''), user: String(entry.user || ''),
label: (String(entry.label || '').trim() || key),
addedAt: entry.addedAt || new Date().toISOString()
};
if (!BL[key]) added++; else updated++;
BL[key] = record;
}
return { added, updated, skipped };
}
async function pickAndImportJsonFile() {
const text = await pickJsonFileText();
const obj = JSON.parse(text);
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 = {}; };
const addToBL = (key, meta={}) => {
if (!key) return; const [service, user] = key.split(':'), prev = BL[key] || {};
BL[key] = { service, user, label: (meta.label || prev.label || key).trim(), addedAt: prev.addedAt || new Date().toISOString() };
return saveBL();
};
const removeFromBL = (key) => { if (key) { delete BL[key]; return saveBL(); } };
const formatLabel = (entry) => {
if (!entry) return ''; const base = `${entry.service}/${entry.user}`;
return (entry.label && entry.label !== base) ? `${entry.label} (${base})` : base;
};
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 { color: #ff4136 !important; }
.kcbl-btn__icon { width: 1em; height: 1em; }
.kcbl-inline-btn {
position: absolute; top: 8px; right: 6px;
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: px; /* <-- Вы можете менять это значение (напр. '1px', '-1px') */
}
`;
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 = ''; // Очищаем старое содержимое
btn.append(textSpan);
btn.title = isBlacklisted ? 'Remove from blacklist' : 'Add to blacklist';
btn.classList.toggle('kcbl--un', isBlacklisted);
}
function ensureBlacklistToggleButton() {
const key = currentAuthorKey(), actions = qs('.user-header__actions');
if (!actions || !key) 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 {
const nameEl = qs('.user-header__name [itemprop="name"], .user-header__name');
await addToBL(k, { label: nameEl ? nameEl.textContent.trim() : k.replace(':','/') });
}
updatePageButtonVisual(btn, !isBlacklisted);
scheduleRefresh();
};
favBtn ? favBtn.insertAdjacentElement('afterend', btn) : insertEnd(actions, btn);
}
updatePageButtonVisual(btn, hasBL(key));
}
// --- КОНЕЦ ИЗМЕНЕННОГО БЛОКА ---
function ensureInlineButtonsForAuthorCards() {
qsa(SELECTORS.USER_CARD).forEach(card => {
const key = getCreatorKeyFromHref(card.getAttribute('href')); if (!key) return;
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 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 {
const nameEl = card.querySelector('.user-card__name');
await addToBL(key, { label: nameEl ? nameEl.textContent.trim() : key.replace(':','/') });
}
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 { // fab
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 key = null;
if (card.matches(SELECTORS.USER_CARD)) {
key = getCreatorKeyFromHref(card.getAttribute('href'));
} else {
const anchor = card.querySelector('a[href*="/user/"]');
if (anchor) {
key = getCreatorKeyFromHref(anchor.getAttribute('href'));
}
}
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();
ensureBlacklistBadge();
refreshHiding();
updateUI();
});
}
function showBlacklistModal() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:#0009;z-index:999999;display:flex;align-items:center;justify-content:center;padding:16px;';
const modal = document.createElement('div');
modal.style.cssText = 'background:#111;color:#eee;max-width:720px;width:100%;display:flex;flex-direction:column;max-height:90vh;border-radius:10px;padding:16px;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;';
const onEsc = (e) => { if (e.key === 'Escape') closeModal(); };
const closeModal = () => { document.removeEventListener('keydown', onEsc, true); overlay.remove(); };
document.addEventListener('keydown', onEsc, true);
overlay.onclick = (e) => { if (e.target === overlay) closeModal(); };
const serviceName = CURRENT_SERVICE.charAt(0).toUpperCase() + CURRENT_SERVICE.slice(1);
const renderList = () => {
list.innerHTML = '';
const entries = Object.entries(BL).sort((a,b) => formatLabel(a[1]).localeCompare(formatLabel(b[1])));
(modal.querySelector('#kcbl-modal-title') || {}).textContent = `${serviceName} Blacklist (${entries.length})`;
if (entries.length === 0) { list.textContent = 'Blacklist is empty.'; return; }
for (const [key, entry] of entries) {
const row = document.createElement('div');
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;';
rmBtn.onclick = async () => { await removeFromBL(key); scheduleRefresh(); renderList(); };
row.append(authorLink, rmBtn); list.append(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('div[style*="display:flex"]').forEach(row => {
row.style.display = row.textContent.toLowerCase().includes(query) ? 'flex' : 'none';
});
};
const title = document.createElement('div'); title.id = 'kcbl-modal-title';
title.style.cssText = 'font-weight:700;font-size:18px;margin-bottom:10px;';
const controls = document.createElement('div');
controls.style.cssText = 'display:flex;gap:8px;margin-top:auto;flex-wrap:wrap;';
['Close', 'Export JSON', 'Import JSON'].forEach(label => {
const btn = document.createElement('button'); btn.textContent = label;
btn.style.cssText = 'padding:6px 10px;border-radius:6px;border:1px solid #333;background:#222;color:#fff;cursor:pointer;';
if (label === 'Close') btn.onclick = closeModal;
if (label === 'Export JSON') btn.onclick = () => downloadTextFile(JSON.stringify(BL, null, 2), `${CURRENT_SERVICE}_blacklist_${safeDateStamp()}.json`);
if (label === 'Import JSON') btn.onclick = async () => {
try {
const { added, updated, skipped } = await pickAndImportJsonFile();
alert(`Import complete for ${serviceName}.\nAdded: ${added}, Updated: ${updated}, Skipped (wrong service or invalid): ${skipped}.`);
renderList();
} catch (e) { alert('Import error: ' + (e.message || e)); }
};
controls.append(btn);
});
modal.append(title, searchInput, list, controls);
overlay.append(modal);
insertEnd(document.body, overlay);
renderList();
}
let observer = null;
function startObserver() {
if (observer) return;
const targetNode = qs(SELECTORS.CONTENT_CONTAINERS);
observer = new MutationObserver(() => scheduleRefresh());
observer.observe(targetNode || document.body, { 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 });
}
})();