skebetter-blacklist

skebetterにブラックリスト機能を追加します

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @license MIT
// @name        skebetter-blacklist
// @namespace    https://skebetter.com/
// @version      1.1.0
// @description  skebetterにブラックリスト機能を追加します
// @match        https://skebetter.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // =====================
  //  ストレージ
  //  muted_users: [{name, xId, authorId, url}]
  //    name     = 表示名
  //    xId      = X の @ハンドル (例: SAIO_GA_USHI)
  //    authorId = skebetter の数値ID (例: 12345)
  //    url      = https://skebetter.com/author/12345
  //
  //  muted_posts: {authorId: {name, xId, posts:[{postId, date, text}]}}
  // =====================
  const KEY_USERS   = 'skebetter_muted_users';
  const KEY_POSTS   = 'skebetter_muted_posts';
  const KEY_OPTIONS = 'skebetter_options';

  const DEFAULT_OPTS = {
    muteUsers: true, mutePosts: true,
    hideAds: true, hideRtLike: true, hideProfile: true, hidePostText: true
  };

  function getOpts()    { try { return Object.assign({}, DEFAULT_OPTS, JSON.parse(GM_getValue(KEY_OPTIONS, '{}'))); } catch { return {...DEFAULT_OPTS}; } }
  function saveOpts(o)  { GM_setValue(KEY_OPTIONS, JSON.stringify(o)); }
  function getUsers()   { try { return JSON.parse(GM_getValue(KEY_USERS, '[]')); } catch { return []; } }
  function saveUsers(a) { GM_setValue(KEY_USERS, JSON.stringify(a)); }
  function getPosts()   { try { return JSON.parse(GM_getValue(KEY_POSTS, '{}')); } catch { return {}; } }
  function savePosts(m) { GM_setValue(KEY_POSTS, JSON.stringify(m)); }

  // =====================
  //  BL 操作
  // =====================
  function muteUser(name, xId, authorId, url) {
    const a = getUsers();
    if (!a.some(u => u.authorId === authorId)) {
      a.push({ name, xId, authorId, url });
      saveUsers(a);
    }
  }
  function unmuteUser(authorId) {
    saveUsers(getUsers().filter(u => u.authorId !== authorId));
  }
  function mutePost(name, xId, authorId, postId, date, text) {
    const m = getPosts();
    if (!m[authorId]) m[authorId] = { name, xId, posts: [] };
    if (!m[authorId].posts.some(p => p.postId === postId)) {
      m[authorId].posts.push({ postId, date, text });
      savePosts(m);
    }
  }
  function unmutePost(authorId, postId) {
    const m = getPosts();
    if (!m[authorId]) return;
    m[authorId].posts = m[authorId].posts.filter(p => p.postId !== postId);
    if (!m[authorId].posts.length) delete m[authorId];
    savePosts(m);
  }
  function isUserMuted(authorId)           { return getUsers().some(u => u.authorId === authorId); }
  function isPostMuted(authorId, postId)   { const m = getPosts(); return !!(m[authorId] && m[authorId].posts.some(p => p.postId === postId)); }

  // =====================
  //  DOM ユーティリティ
  // =====================
  function esc(s) {
    return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  }
  function parseAuthorId(href) {
    const m = (href||'').match(/\/author\/(\d+)/); return m ? m[1] : null;
  }
  function parsePostIds(href) {
    const m = (href||'').match(/\/author\/(\d+)\/[^/]+\/(\d+)/);
    return m ? { authorId: m[1], postId: m[2] } : null;
  }
  function parseTweetId(href) {
    const m = (href||'').match(/tweet_id=(\d+)/); return m ? m[1] : null;
  }
  function toAbs(path) { return path.startsWith('http') ? path : 'https://skebetter.com' + path; }

  function findCard(el) {
    let n = el.parentElement;
    while (n && n !== document.body) {
      if (n.classList.contains('rounded-xl') &&
          (n.className.includes('bg-white') || n.className.includes('bg-gray-800'))) return n;
      n = n.parentElement;
    }
    return null;
  }
  function findOuter(card) {
    let n = card.parentElement;
    while (n && n !== document.body) {
      const c = n.className || '';
      if (c.includes('max-w-[') || c.includes('h-card')) return n;
      n = n.parentElement;
    }
    return null;
  }

  // カードから表示名・xId・authorId を取得
  function extractCardMeta(card, authorId) {
    let name = '', xId = '';

    // 表示名: authorId を含むテキストリンクから
    card.querySelectorAll(`a[href*="/author/${authorId}"]`).forEach(a => {
      const t = a.textContent.trim();
      if (t && t.length <= 60 && !a.querySelector('img')) name = t;
    });

    // xId: href に twitter.com/intent や x.com/ が含まれるリンクから @ハンドルを探す
    card.querySelectorAll('a[href*="twitter.com/"], a[href*="x.com/"]').forEach(a => {
      const m = (a.getAttribute('href') || '').match(/(?:twitter|x)\.com\/(?!intent\/)([A-Za-z0-9_]{1,50})/);
      if (m && m[1] !== 'intent') xId = m[1];
    });
    // フォールバック: カード内 @mention テキスト
    if (!xId) {
      card.querySelectorAll('*').forEach(el => {
        if (el.children.length) return;
        const t = el.textContent.trim();
        const m = t.match(/^@([A-Za-z0-9_]{1,50})$/);
        if (m) xId = m[1];
      });
    }

    if (!name) name = xId || ('author_' + authorId);
    return { name, xId: xId || authorId };
  }

  function getPostText(card) {
    let text = '';
    card.querySelectorAll('span.p-2, span.p-5').forEach(s => {
      const t = s.textContent.trim();
      if (t && t.length > text.length) text = t;
    });
    return text.slice(0, 80);
  }
  function getPostDate(card) {
    let date = '';
    card.querySelectorAll('time, [datetime]').forEach(el => {
      const d = el.getAttribute('datetime') || el.textContent.trim();
      if (d) date = d;
    });
    return date || new Date().toISOString().slice(0, 16).replace('T', ' ');
  }

  // =====================
  //  スタイル
  // =====================
  GM_addStyle(`
    .sb-btn-row{display:flex;flex-wrap:wrap;gap:4px;padding:6px 8px}
    .sb-card-btn{display:inline-flex;align-items:center;padding:2px 8px;border:1px solid rgba(128,128,128,0.35);border-radius:4px;background:transparent;color:#888;font-size:11px;line-height:1.6;cursor:pointer;white-space:nowrap;transition:background .15s,color .15s;user-select:none}
    .sb-card-btn:hover{background:rgba(192,57,43,0.85);border-color:#c0392b;color:#fff}
    [data-sb-card="1"],[data-sb-outer="1"]{height:auto!important;max-height:none!important;overflow:visible!important}
    .sb-hide-rtlike a[href*="intent/retweet"],
    .sb-hide-rtlike a[href*="intent/favorite"],
    .sb-hide-rtlike a[href*="intent/retweet"]+span,
    .sb-hide-rtlike a[href*="intent/favorite"]+span{display:none!important}
    .sb-hide-post-text [data-sb-card="1"] span.p-2,
    .sb-hide-post-text [data-sb-card="1"] span.p-5{display:none!important}

    #sb-fab{position:fixed;bottom:22px;right:22px;z-index:2147483638;width:40px;height:40px;border-radius:50%;background:#1e293b;color:#fff;border:none;font-size:17px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.3);transition:background .15s}
    #sb-fab:hover{background:#334155}

    #sb-panel{
      position:fixed;bottom:70px;right:16px;
      z-index:2147483639;
      width:440px;max-height:calc(100vh - 100px);min-height:400px;
      background:#fff;color:#111;
      font-family:system-ui,-apple-system,sans-serif;font-size:13px;
      border-radius:12px;
      border:1px solid #d1d5db;
      box-shadow:0 4px 24px rgba(0,0,0,0.15);
      display:none;flex-direction:column;overflow:hidden;
    }
    #sb-panel.open{display:flex}
    @media(prefers-color-scheme:dark){
      #sb-panel{background:#1c1c1e;color:#e5e5e7;border-color:#3a3a3c;box-shadow:0 4px 24px rgba(0,0,0,0.6)}
      #sb-panel .sb-hdr,#sb-panel .sb-sec-hdr{background:#111113;border-color:#3a3a3c}
      .sb-entry,.sb-grp,.sb-grp-hdr,.sb-post-row,.sb-opt-row,.sb-sec-body{border-color:#3a3a3c!important}
      .sb-entry:hover,.sb-post-row:hover{background:#2c2c2e!important}
      .sb-grp-hdr{background:#232325!important}
      #sb-panel input[type=text]{background:#2c2c2e;color:#e5e5e7;border-color:#48484a}
    }
    .sb-hdr{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid #e5e7eb;background:#f9fafb;flex-shrink:0}
    .sb-hdr-title{font-size:14px;font-weight:600;display:flex;align-items:center;gap:7px}
    .sb-hdr-close{background:none;border:none;cursor:pointer;color:#9ca3af;font-size:16px;padding:4px 8px;border-radius:5px;line-height:1}
    .sb-hdr-close:hover{background:#fee2e2;color:#dc2626}
    .sb-scroll{overflow-y:auto;flex:1}
    .sb-section{border-bottom:1px solid #e5e7eb}
    .sb-section:last-child{border-bottom:none}
    .sb-sec-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 14px;cursor:pointer;user-select:none;background:#f9fafb;border-bottom:1px solid transparent}
    .sb-sec-hdr:hover{background:#f3f4f6}
    @media(prefers-color-scheme:dark){.sb-sec-hdr:hover{background:#232325}}
    .sb-sec-label{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:500}
    .sb-sec-right{display:flex;align-items:center;gap:8px}
    .sb-badge{font-size:11px;padding:1px 7px;border-radius:10px;background:#fee2e2;color:#b91c1c;font-weight:600;min-width:18px;text-align:center}
    .sb-chev{font-size:11px;color:#9ca3af;transition:transform .2s;display:inline-block}
    .sb-chev.open{transform:rotate(180deg)}
    .sb-toggle{position:relative;width:34px;height:19px;flex-shrink:0}
    .sb-toggle input{opacity:0;width:0;height:0;position:absolute}
    .sb-tslider{position:absolute;inset:0;background:#d1d5db;border-radius:10px;cursor:pointer;transition:.2s}
    .sb-tslider::before{content:"";position:absolute;width:13px;height:13px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.2s}
    .sb-toggle input:checked+.sb-tslider{background:#2563eb}
    .sb-toggle input:checked+.sb-tslider::before{transform:translateX(15px)}
    .sb-sec-body{display:none;padding:8px 12px;flex-direction:column;gap:4px}
    .sb-sec-body.open{display:flex}
    .sb-entry{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 10px;border:1px solid #e5e7eb;border-radius:6px;transition:background .1s}
    .sb-entry:hover{background:#f9fafb}
    .sb-entry-name{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
    .sb-entry-sub{font-size:11px;color:#9ca3af;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:1px}
    .sb-del{background:none;border:1px solid #e5e7eb;border-radius:4px;padding:3px 8px;font-size:11px;cursor:pointer;color:#6b7280;white-space:nowrap;flex-shrink:0}
    .sb-del:hover{background:#fee2e2;color:#b91c1c;border-color:#fca5a5}
    .sb-grp{border:1px solid #e5e7eb;border-radius:6px;overflow:hidden}
    .sb-grp-hdr{padding:5px 10px;background:#f3f4f6;font-size:12px;font-weight:500;display:flex;align-items:center;gap:5px}
    .sb-grp-xid{font-size:11px;color:#9ca3af;font-weight:400}
    .sb-grp-aid{font-size:10px;color:#c4b5fd;margin-left:auto}
    .sb-post-row{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;padding:5px 10px 5px 14px;border-top:1px solid #e5e7eb;transition:background .1s}
    .sb-post-row:hover{background:#f9fafb}
    .sb-post-text{font-size:12px;color:#4b5563;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:220px}
    @media(prefers-color-scheme:dark){.sb-post-text{color:#9ca3af}}
    .sb-post-meta{font-size:11px;color:#9ca3af;margin-top:2px;display:flex;gap:8px;flex-wrap:wrap}
    .sb-empty{text-align:center;padding:18px;color:#9ca3af;font-size:12px}
    .sb-opt-row{display:flex;align-items:center;justify-content:space-between;padding:8px 14px;border-bottom:1px solid #e5e7eb}
    .sb-opt-row:last-child{border-bottom:none}
    .sb-opt-label{font-size:13px;font-weight:500}
    .sb-opt-sub{font-size:11px;color:#9ca3af;margin-top:1px}
  `);

  // =====================
  //  パネル構築
  // =====================
  let panelEl = null;

  function buildPanel() {
    if (panelEl) return;
    const el = document.createElement('div');
    el.id = 'sb-panel';
    el.setAttribute('role', 'dialog');
    el.setAttribute('aria-label', 'ブラックリスト管理');
    el.innerHTML = `
      <div class="sb-hdr">
        <div class="sb-hdr-title">🛡 ブラックリスト管理</div>
        <button class="sb-hdr-close" id="sb-close" aria-label="閉じる">✕</button>
      </div>
      <div class="sb-scroll">
        <div class="sb-section" id="sec-users"></div>
        <div class="sb-section" id="sec-posts"></div>
        <div class="sb-section" id="sec-opts"></div>
      </div>`;
    document.body.appendChild(el);
    panelEl = el;
    el.querySelector('#sb-close').addEventListener('click', closePanel);
    renderPanel();
  }

  function renderPanel() {
    if (!panelEl) return;
    renderUsers();
    renderPosts();
    renderOpts();
  }

  function openPanel()  {
    buildPanel();
    panelEl.classList.add('open');
    renderPanel();
  }
  function closePanel() {
    if (panelEl) panelEl.classList.remove('open');
  }
  function togglePanel() {
    if (!panelEl || !panelEl.classList.contains('open')) openPanel();
    else closePanel();
  }

  // ── 投稿者セクション ──
  function renderUsers() {
    const sec = document.getElementById('sec-users');
    if (!sec) return;
    const opts  = getOpts();
    const users = getUsers();
    const open  = sec.dataset.open !== 'false';
    sec.innerHTML = '';

    const hdr = mkEl('div', 'sb-sec-hdr');
    hdr.innerHTML = `
      <div class="sb-sec-label">
        <label class="sb-toggle"><input type="checkbox" ${opts.muteUsers?'checked':''}><span class="sb-tslider"></span></label>
        投稿者ブラックリスト
      </div>
      <div class="sb-sec-right">
        <span class="sb-badge">${users.length}</span>
        <span class="sb-chev ${open?'open':''}">▾</span>
      </div>`;

    const body = mkEl('div', 'sb-sec-body' + (open?' open':''));
    if (!users.length) {
      body.innerHTML = '<div class="sb-empty">ミュートユーザーはいません</div>';
    } else {
      users.forEach(u => {
        const row = mkEl('div', 'sb-entry');
        row.innerHTML = `
          <div style="flex:1;min-width:0">
            <div class="sb-entry-name">${esc(u.name)}</div>
            <div class="sb-entry-sub">@${esc(u.xId)}</div>
          </div>
          <button class="sb-del">削除</button>`;
        row.querySelector('.sb-del').addEventListener('click', () => {
          unmuteUser(u.authorId); renderUsers(); applyHiding();
        });
        body.appendChild(row);
      });
    }

    hdr.addEventListener('click', e => {
      if (e.target.closest('.sb-toggle')) return;
      const isOpen = body.classList.toggle('open');
      sec.dataset.open = isOpen;
      hdr.querySelector('.sb-chev').classList.toggle('open', isOpen);
    });
    hdr.querySelector('input[type=checkbox]').addEventListener('change', e => {
      const o = getOpts(); o.muteUsers = e.target.checked; saveOpts(o); applyHiding();
    });

    sec.appendChild(hdr);
    sec.appendChild(body);
  }

  // ── ポストセクション ──
  function renderPosts() {
    const sec = document.getElementById('sec-posts');
    if (!sec) return;
    const opts  = getOpts();
    const posts = getPosts();
    const keys  = Object.keys(posts);
    const total = keys.reduce((n, k) => n + (posts[k].posts||[]).length, 0);
    const open  = sec.dataset.open !== 'false';
    sec.innerHTML = '';

    const hdr = mkEl('div', 'sb-sec-hdr');
    hdr.innerHTML = `
      <div class="sb-sec-label">
        <label class="sb-toggle"><input type="checkbox" ${opts.mutePosts?'checked':''}><span class="sb-tslider"></span></label>
        ポストブラックリスト
      </div>
      <div class="sb-sec-right">
        <span class="sb-badge">${total}</span>
        <span class="sb-chev ${open?'open':''}">▾</span>
      </div>`;

    const body = mkEl('div', 'sb-sec-body' + (open?' open':''));
    if (!total) {
      body.innerHTML = '<div class="sb-empty">ミュートポストはありません</div>';
    } else {
      keys.forEach(authorId => {
        const entry = posts[authorId];
        const pList = entry.posts || [];
        if (!pList.length) return;

        const grp = mkEl('div', 'sb-grp');
        const gh  = mkEl('div', 'sb-grp-hdr');
        gh.innerHTML = `<span>${esc(entry.name||authorId)}</span><span class="sb-grp-xid">@${esc(entry.xId||'')}</span>`;
        grp.appendChild(gh);

        pList.forEach(p => {
          const row = mkEl('div', 'sb-post-row');
          row.innerHTML = `
            <div style="flex:1;min-width:0">
              <div class="sb-post-text">${esc(p.text||'(本文なし)')}</div>
              <div class="sb-post-meta">
                <span>${esc(p.date||'')}</span>
                <span>postId: ${esc(p.postId)}</span>
              </div>
            </div>
            <button class="sb-del">削除</button>`;
          row.querySelector('.sb-del').addEventListener('click', () => {
            unmutePost(authorId, p.postId); renderPosts(); applyHiding();
          });
          grp.appendChild(row);
        });
        body.appendChild(grp);
      });
    }

    hdr.addEventListener('click', e => {
      if (e.target.closest('.sb-toggle')) return;
      const isOpen = body.classList.toggle('open');
      sec.dataset.open = isOpen;
      hdr.querySelector('.sb-chev').classList.toggle('open', isOpen);
    });
    hdr.querySelector('input[type=checkbox]').addEventListener('change', e => {
      const o = getOpts(); o.mutePosts = e.target.checked; saveOpts(o); applyHiding();
    });

    sec.appendChild(hdr);
    sec.appendChild(body);
  }

  // ── 非表示設定セクション ──
  function renderOpts() {
    const sec = document.getElementById('sec-opts');
    if (!sec) return;
    const opts = getOpts();
    const open = sec.dataset.open !== 'false';

    const ROWS = [
      { key:'hideAds',      label:'広告カードを非表示',         sub:'fanza等の広告グリッドカードを除去' },
      { key:'hideRtLike',   label:'いいね・RTを非表示',         sub:'ポストのいいね・リツイートリンクを隠す' },
      { key:'hideProfile',  label:'投稿者プロフィールを非表示', sub:'著者ページのプロフィール欄を隠す' },
      { key:'hidePostText', label:'ポスト本文テキストを非表示', sub:'カード内の本文テキストを隠す' },
    ];

    sec.innerHTML = `
      <div class="sb-sec-hdr" id="opts-hdr">
        <div class="sb-sec-label">非表示設定</div>
        <span class="sb-chev ${open?'open':''}">▾</span>
      </div>
      <div class="sb-sec-body${open?' open':''}" id="opts-body" style="padding:0;gap:0">
        ${ROWS.map(r => `
          <div class="sb-opt-row">
            <div>
              <div class="sb-opt-label">${esc(r.label)}</div>
              <div class="sb-opt-sub">${esc(r.sub)}</div>
            </div>
            <label class="sb-toggle"><input type="checkbox" data-key="${r.key}" ${opts[r.key]?'checked':''}><span class="sb-tslider"></span></label>
          </div>`).join('')}
      </div>`;

    sec.querySelector('#opts-hdr').addEventListener('click', () => {
      const b = sec.querySelector('#opts-body');
      const isOpen = b.classList.toggle('open');
      sec.dataset.open = isOpen;
      sec.querySelector('.sb-chev').classList.toggle('open', isOpen);
    });
    sec.querySelectorAll('input[data-key]').forEach(cb => {
      cb.addEventListener('change', () => {
        const o = getOpts(); o[cb.dataset.key] = cb.checked; saveOpts(o);
        applyOptions();
        if (cb.dataset.key === 'hideAds') applyHiding();
      });
    });
  }

  function mkEl(tag, cls) {
    const el = document.createElement(tag);
    if (cls) el.className = cls;
    return el;
  }

  // =====================
  //  カード処理
  // =====================
  function processCards() {
    document.querySelectorAll('a[href*="/author/"]:not([data-sb])').forEach(link => {
      link.setAttribute('data-sb', '1');
      const href     = link.getAttribute('href');
      const authorId = parseAuthorId(href);
      if (!authorId) return;
      const card = findCard(link);
      if (!card || card.dataset.sbCard) return;

      let postId = null;
      card.querySelectorAll('a[href*="/author/"]').forEach(a => {
        const p = parsePostIds(a.getAttribute('href'));
        if (p && p.authorId === authorId) postId = p.postId;
      });
      if (!postId) {
        card.querySelectorAll('a[href*="tweet_id="]').forEach(a => {
          const t = parseTweetId(a.getAttribute('href'));
          if (t) postId = 'tweet_' + t;
        });
      }

      const { name, xId } = extractCardMeta(card, authorId);
      const url = toAbs('/author/' + authorId);

      card.dataset.sbCard     = '1';
      card.dataset.sbAuthorId = authorId;
      card.dataset.sbXid      = xId;
      card.dataset.sbName     = name;
      if (postId) card.dataset.sbPostId = postId;

      const outer = findOuter(card);
      if (outer) outer.dataset.sbOuter = '1';

      const block = card.children[1] || card;
      const row   = mkEl('div', 'sb-btn-row');

      const uBtn = mkEl('button', 'sb-card-btn');
      uBtn.textContent = 'ユーザーをミュート';
      uBtn.addEventListener('click', e => {
        e.preventDefault(); e.stopPropagation();
        muteUser(name, xId, authorId, url);
        applyHiding();
      });
      row.appendChild(uBtn);

      if (postId) {
        const pBtn = mkEl('button', 'sb-card-btn');
        pBtn.textContent = 'このポストをミュート';
        pBtn.addEventListener('click', e => {
          e.preventDefault(); e.stopPropagation();
          mutePost(name, xId, authorId, postId, getPostDate(card), getPostText(card));
          applyHiding();
        });
        row.appendChild(pBtn);
      }

      block.appendChild(row);
    });
  }

  // =====================
  //  非表示適用
  // =====================
  function applyOptions() {
    const o = getOpts();
    document.documentElement.classList.toggle('sb-hide-rtlike',    !!o.hideRtLike);
    document.documentElement.classList.toggle('sb-hide-post-text', !!o.hidePostText);
  }

  function applyHiding() {
    const o = getOpts();
    if (o.hideAds) {
      document.querySelectorAll('.grid > *').forEach(el => {
        if (el.querySelector('a[href*="al.fanza.co.jp"]')) el.style.display = 'none';
      });
    }
    document.querySelectorAll('[data-sb-card="1"]').forEach(card => {
      const { sbAuthorId, sbPostId } = card.dataset;
      const hide =
        (o.muteUsers && isUserMuted(sbAuthorId)) ||
        (o.mutePosts && sbPostId && isPostMuted(sbAuthorId, sbPostId));
      if (hide) {
        const outer = findOuter(card);
        (outer || card).style.display = 'none';
      }
    });
  }

  function hideAuthorProfile() {
    const o = getOpts();
    if (!o.hideProfile) return;
    if (!location.pathname.startsWith('/author/')) return;
    const main = document.querySelector('main');
    if (!main) return;
    [...(main.children[0]?.children ?? [])].forEach(el => {
      if ((el.className || '').includes('dark:bg-black')) el.style.display = 'none';
    });
  }

  // =====================
  //  FAB
  // =====================
  function buildFab() {
    const fab = mkEl('button', '');
    fab.id = 'sb-fab';
    fab.title = 'ブラックリスト管理';
    fab.textContent = '🛡';
    fab.addEventListener('click', e => { e.stopPropagation(); togglePanel(); });
    document.body.appendChild(fab);
    document.addEventListener('click', e => {
      if (!panelEl || !panelEl.classList.contains('open')) return;
      if (!panelEl.contains(e.target) && e.target !== fab) closePanel();
    }, true);
  }

  // =====================
  //  MutationObserver
  // =====================
  let timer = null;
  new MutationObserver(() => {
    clearTimeout(timer);
    timer = setTimeout(() => { hideAuthorProfile(); processCards(); applyHiding(); }, 250);
  }).observe(document.body, { childList: true, subtree: true });

  // =====================
  //  初期化
  // =====================
  function init() {
    applyOptions();
    hideAuthorProfile();
    processCards();
    applyHiding();
    buildFab();
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();