Kemono/Coomer Blacklist with backup

Hide posts and authors in lists/search on kemono.cr and coomer.st. Backup/import your Blacklist.

As of 2025-08-20. See the latest version.

// ==UserScript==
// @name             Kemono/Coomer Blacklist with backup
// @version          1.2
// @description      Hide posts and authors in lists/search on kemono.cr and coomer.st. Backup/import your Blacklist.
// @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.download
// @grant            GM_download
// @license          MIT
// @homepageURL      https://boosty.to/glauthentica
// @contributionURL  https://boosty.to/glauthentica
// @namespace https://tampermonkey.net/
// ==/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));

  // Safer insertion helper: avoids appendChild
  function insertEnd(parent, node) {
    if (!parent || !node) return;
    try {
      parent.insertAdjacentElement('beforeend', node);
    } catch {}
  }

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

  // Без appendChild/a.click — безопасно для сайтов, которые патчат DOM-методы
  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);

    // 1) Tampermonkey-путь
    try {
      const url = makeBlobUrl();
      const details = { url, name: filename, saveAs: true };
      if (typeof GM !== 'undefined' && GM && typeof GM.download === 'function') {
        GM.download(details);
        revokeLater(url);
        return;
      }
      if (typeof GM_download === 'function') {
        GM_download(details);
        revokeLater(url);
        return;
      }
      URL.revokeObjectURL(url);
    } catch {}

    // 2) Открыть blob-URL в новом окне/вкладке (часто запускает скачивание)
    try {
      const url = makeBlobUrl();
      const win = window.open(url, '_blank', 'noopener');
      if (!win) {
        // Попытка инициировать загрузку синтетическим кликом без вставки в DOM
        const 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);
      return;
    } catch {}

    // 3) Последний вариант — буфер обмена или окно с JSON
    (async () => {
      try {
        await navigator.clipboard.writeText(String(text));
        alert('Не удалось сохранить файл автоматически. JSON скопирован в буфер обмена.');
      } catch {
        const win = window.open('', '_blank');
        if (win && win.document) {
          const esc = s => s.replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
          win.document.write('<pre style="white-space:pre-wrap">' + esc(String(text)) + '</pre>');
          win.document.close();
          alert('Открылось окно с JSON — сохраните его вручную (Файл → Сохранить как…).');
        } else {
          alert('Не удалось сохранить файл. Скопируйте JSON из следующего окна:\n\n' + String(text));
        }
      }
    })();
  }

  // Без добавления input в DOM (с запасным вариантом не используется)
  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 && input.files[0];
          if (!file) { reject(new Error('Файл не выбран')); return; }
          const reader = new FileReader();
          reader.onload = () => resolve(String(reader.result || ''));
          reader.onerror = () => reject(reader.error || new Error('Ошибка чтения файла'));
          reader.readAsText(file);
        } catch (e) {
          reject(e);
        }
      };

      try {
        // Большинство браузеров позволяют click() без вставки в DOM
        input.click();
      } catch (e) {
        // Если браузер не даёт кликнуть без DOM — сообщим об ошибке
        reject(new Error('Браузер блокирует выбор файла. Разрешите всплывающие окна или попробуйте другой браузер.'));
      }
    });
  }

  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 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 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 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; }

      /* Favorite-like buttons */
      .kcbl-btn {
        display: inline-flex; align-items: center; gap: 0px;
        padding: 0px 0px;
        background-color: transparent !important;
        border: transparent !important;
        color: #fff;
        font-weight: 700;
        text-shadow:
          hsl(0, 0%, 0%) 0px 0px 3px,
          hsl(0, 0%, 0%) -1px -1px 0px,
          hsl(0, 0%, 0%) 1px 1px 0px;
        cursor: pointer; user-select: none;
      }
      .kcbl-btn.kcbl--un { color: #ff0000; }
      .kcbl-btn__icon { width: 22px; height: 22px; display: block; color: currentColor; }
      .kcbl-btn__label { line-height: 1; }

      .kcbl-inline-btn { position: absolute; top: 6px; right: 6px; padding: 2px 4px; }
      .kcbl-inline-btn .kcbl-btn__icon { width: 24px; height: 24px; }
      .kcbl-inline-btn .kcbl-btn__label { display: none; }

      .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;
      }

      /* Floating "Show hidden" moved higher to avoid overlap with Blacklist FAB */
      #kcbl-reveal-toggle {
        position: fixed; bottom: 60px; 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); }

      /* Floating Blacklist button (FAB) */
      #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 rgba(0,0,0,0.25);
        background: rgba(32,32,32,0.92); color: #fff; cursor: pointer;
        box-shadow: 0 6px 16px rgba(0,0,0,0.35);
        user-select: none;
      }
      #kcbl-fab:hover { background: rgba(32,32,32,1); }
      #kcbl-fab .kcbl-fab__icon { width: 18px; height: 18px; color: currentColor; display: block; }
    `;
    const head = document.head || document.querySelector('head') || document.documentElement;
    insertEnd(head, style);
  }

  // SVG icon (ban/prohibited), uses currentColor
  function blacklistIconSvg() {
    return `
      <svg class="kcbl-btn__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
        <circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"></circle>
        <line x1="6.8" y1="6.8" x2="17.2" y2="17.2" stroke="currentColor" stroke-width="2"></line>
      </svg>
    `;
  }
  function blacklistIconSmall() {
    return `
      <svg class="kcbl-fab__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
        <circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"></circle>
        <line x1="6.8" y1="6.8" x2="17.2" y2="17.2" stroke="currentColor" stroke-width="2"></line>
      </svg>
    `;
  }

  function applyBLBtnVisual(btn, inBL) {
    btn.classList.add('kcbl-btn');
    btn.classList.toggle('kcbl--un', inBL);
    btn.innerHTML = `${blacklistIconSvg()}<span class="kcbl-btn__label">${inBL ? 'Unblacklist' : 'Blacklist'}</span>`;
    btn.title = inBL ? 'Remove from blacklist' : 'Add to blacklist';
  }

  // 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;';
      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 {
        insertEnd(actions, btn);
      }
    }
    updateToggleVisual(btn, key);
  }
  function updateToggleVisual(btn, key) {
    const inBL = hasBL(key);
    applyBLBtnVisual(btn, inBL);
  }

  // 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 kcbl-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();
        });
        insertEnd(card, btn);
      }
      updateInlineBtnVisual(btn, key);
    }
  }
  function updateInlineBtnVisual(btn, key) {
    const inBL = hasBL(key);
    applyBLBtnVisual(btn, inBL);
  }

  // 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();
      });
      insertEnd(document.body || document.documentElement, 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';
  }

  // Floating Blacklist FAB button
  function ensureBlacklistFAB() {
    let fab = qs('#kcbl-fab');
    if (!fab) {
      fab = document.createElement('button');
      fab.id = 'kcbl-fab';
      fab.type = 'button';
      fab.innerHTML = `${blacklistIconSmall()} <span>Blacklist</span>`;
      fab.title = 'Open blacklist manager';
      fab.addEventListener('click', (e) => {
        e.preventDefault();
        showBlacklistModal();
      });
      insertEnd(document.body || document.documentElement, fab);
    }
  }

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

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

        insertEnd(right, openBtn);
        insertEnd(right, rmBtn);
        insertEnd(row, left);
        insertEnd(row, right);
        insertEnd(list, 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('Экспорт: файл будет сохранён через Tampermonkey (или откроется новая вкладка/окно).');
    });

    const btnCopy = document.createElement('button');
    btnCopy.textContent = 'Copy JSON';
    btnCopy.style.cssText = 'padding:6px 10px;border-radius:6px;border:1px solid #333;background:#222;color:#fff;cursor:pointer;';
    btnCopy.addEventListener('click', async () => {
      try {
        await navigator.clipboard.writeText(JSON.stringify(BL, null, 2));
        alert('JSON скопирован в буфер обмена.');
      } catch (e) {
        alert('Не удалось скопировать: ' + (e && e.message ? e.message : e));
      }
    });

    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}${скipped ? ', пропущено: ' + skipped : ''}.`);
        overlay.remove();
      } catch (e) {
        alert('Ошибка импорта: ' + (e && e.message ? e.message : e));
      }
    });

    insertEnd(controls, btnClose);
    insertEnd(controls, btnExport);
    insertEnd(controls, btnCopy);
    insertEnd(controls, btnImportFile);
    insertEnd(controls, btnImportPaste);

    insertEnd(modal, title);
    insertEnd(modal, list);
    insertEnd(modal, controls);
    insertEnd(overlay, modal);
    insertEnd(document.body || document.documentElement, overlay);
  }

  // Removed Tampermonkey menu entries (in-page control only)

  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();
    insertStyles();
    ensureBlacklistFAB();
    scheduleRefresh();
    startObserver();
    setTimeout(() => saveBL(), 3000);
  }

  init();
})();