Kemono/Coomer Blacklist with backup

Hide posts and authors in lists/search on kemono.cr and coomer.st. Export/import blacklist as JSON file.

Fra og med 20.08.2025. Se den nyeste version.

// ==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();
})();