// ==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 => ({'&':'&','<':'<','>':'>'}[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();
})();