Bilibili 主页推荐卡片版权内容屏蔽

屏蔽B站主页推荐流中的版权内容卡片(电影、课堂、电视剧、国创 等等)

// ==UserScript==
// @name         Bilibili 主页推荐卡片版权内容屏蔽
// @namespace    https://github.com/rainpenber/mktk-my-tampermonkey-scripts/tree/main/scripts/bilibili_feedcard_filter
// @version      1.0.0
// @description  屏蔽B站主页推荐流中的版权内容卡片(电影、课堂、电视剧、国创 等等)
// @author       Rainpenber
// @license      MIT
// @match        https://www.bilibili.com/
// @match        https://www.bilibili.com/?*
// @icon         https://www.bilibili.com/favicon.ico
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addValueChangeListener
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  const CATEGORY_LABELS = [
    '电影',
    '课堂',
    '电视剧',
    '国创',
    '综艺',
    '纪录片',
    '漫画',
    '直播',
    '番剧',
  ];

  const STORAGE_KEY = 'bili_filter_blocked_categories_v1';

  function readBlocked() {
    const saved = GM_getValue(STORAGE_KEY, []);
    if (Array.isArray(saved)) return new Set(saved);
    return new Set();
  }

  function writeBlocked(blockedSet) {
    GM_setValue(STORAGE_KEY, Array.from(blockedSet));
  }

  function hideCard(card) {
    if (!card) return;
    card.style.display = 'none';
    card.setAttribute('data-bili-filter-hidden', '1');
  }

  function showCard(card) {
    if (!card) return;
    card.style.display = '';
    card.removeAttribute('data-bili-filter-hidden');
  }

  function getCardTitleElement(root) {
    // 结构:div.container.is-version8 内部的 div.floor-single-card > div.floor-card-inner > div.cover-container > a > div.badge > span.floor-title
    // 这里尽量兼容 class 变化,使用层级选择器并校验类名包含关系
    const badgeSpan = root.querySelector(
      ':scope div.floor-card-inner div.cover-container a div.badge span'
    );
    return badgeSpan;
  }

  function getCardCategoryText(card) {
    try {
      const span = getCardTitleElement(card);
      if (!span) return '';
      return (span.textContent || '').trim();
    } catch (_) {
      return '';
    }
  }

  function isTargetCard(node) {
    if (!(node instanceof HTMLElement)) return false;
    if (!node.classList) return false;
    // 只处理 floor-single-card
    return node.classList.contains('floor-single-card');
  }

  function filterOneCard(card, blockedSet) {
    const category = getCardCategoryText(card);
    if (!category) {
      // 无法识别类别则不处理
      return;
    }
    if (blockedSet.has(category)) {
      hideCard(card);
    } else {
      showCard(card);
    }
  }

  function scanAndFilter(container, blockedSet) {
    if (!container) return;
    const cards = container.querySelectorAll('div.floor-single-card');
    cards.forEach((card) => filterOneCard(card, blockedSet));
  }

  function setupObserver(container, blockedSetRef) {
    if (!container) return;
    const observer = new MutationObserver((mutations) => {
      for (const m of mutations) {
        if (m.type === 'childList') {
          m.addedNodes.forEach((node) => {
            if (isTargetCard(node)) {
              filterOneCard(node, blockedSetRef.current);
            } else if (node instanceof HTMLElement) {
              // 子树中可能也有新增卡片
              const innerCards = node.querySelectorAll('div.floor-single-card');
              innerCards.forEach((c) => filterOneCard(c, blockedSetRef.current));
            }
          });
        }
      }
    });
    observer.observe(container, { childList: true, subtree: true });
    return observer;
  }

  function ensureStyles() {
    GM_addStyle(`
      .bili-filter-dialog-mask { position: fixed; inset: 0; background: rgba(0,0,0,.25); z-index: 99998; }
      .bili-filter-dialog { position: fixed; z-index: 99999; width: 360px; max-width: 92vw; background: #fff; color: #222; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.18); left: 50%; top: 64px; transform: translateX(-50%); font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, PingFang SC, Microsoft YaHei, sans-serif; }
      .bili-filter-dialog header { padding: 12px 16px; font-weight: 600; border-bottom: 1px solid rgba(0,0,0,.06); display: flex; align-items: center; justify-content: space-between; }
      .bili-filter-dialog .content { padding: 12px 16px; max-height: 60vh; overflow: auto; }
      .bili-filter-dialog .actions { padding: 12px 16px; display: flex; gap: 8px; justify-content: flex-end; border-top: 1px solid rgba(0,0,0,.06); }
      .bili-filter-chip-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
      .bili-filter-chip { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border: 1px solid #e5e7eb; border-radius: 8px; cursor: pointer; user-select: none; }
      .bili-filter-chip input { pointer-events: none; }
      .bili-filter-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid #e5e7eb; background: #fff; cursor: pointer; }
      .bili-filter-btn.primary { background: #00AEEC; color: #fff; border-color: #00AEEC; }
      .bili-filter-close { cursor: pointer; opacity: .7; }
      .bili-filter-close:hover { opacity: 1; }
    `);
  }

  function openDialog(blockedSet, onChange, onClose) {
    ensureStyles();

    const mask = document.createElement('div');
    mask.className = 'bili-filter-dialog-mask';
    const dialog = document.createElement('div');
    dialog.className = 'bili-filter-dialog';

    const header = document.createElement('header');
    header.innerHTML = '<span>屏蔽推荐类型</span><span class="bili-filter-close">✕</span>';

    const content = document.createElement('div');
    content.className = 'content';

    const grid = document.createElement('div');
    grid.className = 'bili-filter-chip-grid';

    const checkboxRefs = new Map();
    CATEGORY_LABELS.forEach((label) => {
      const chip = document.createElement('label');
      chip.className = 'bili-filter-chip';
      const input = document.createElement('input');
      input.type = 'checkbox';
      input.checked = blockedSet.has(label);
      const span = document.createElement('span');
      span.textContent = label;
      chip.appendChild(input);
      chip.appendChild(span);
      chip.addEventListener('click', (e) => {
        // label 默认会切换 checked,此处延迟读取
        setTimeout(() => {
          if (input.checked) {
            blockedSet.add(label);
          } else {
            blockedSet.delete(label);
          }
          onChange(new Set(blockedSet));
        }, 0);
      });
      grid.appendChild(chip);
      checkboxRefs.set(label, input);
    });

    content.appendChild(grid);

    const actions = document.createElement('div');
    actions.className = 'actions';
    const btnSelectAll = document.createElement('button');
    btnSelectAll.className = 'bili-filter-btn';
    btnSelectAll.textContent = '一键全选';
    btnSelectAll.addEventListener('click', () => {
      CATEGORY_LABELS.forEach((l) => {
        blockedSet.add(l);
        const ref = checkboxRefs.get(l); if (ref) ref.checked = true;
      });
      onChange(new Set(blockedSet));
    });

    const btnDisableAll = document.createElement('button');
    btnDisableAll.className = 'bili-filter-btn';
    btnDisableAll.textContent = '一键禁用';
    btnDisableAll.addEventListener('click', () => {
      blockedSet.clear();
      CATEGORY_LABELS.forEach((l) => { const ref = checkboxRefs.get(l); if (ref) ref.checked = false; });
      onChange(new Set(blockedSet));
    });

    const btnOk = document.createElement('button');
    btnOk.className = 'bili-filter-btn primary';
    btnOk.textContent = '完成';
    btnOk.addEventListener('click', close);

    actions.appendChild(btnSelectAll);
    actions.appendChild(btnDisableAll);
    actions.appendChild(btnOk);

    dialog.appendChild(header);
    dialog.appendChild(content);
    dialog.appendChild(actions);

    function close() {
      document.body.removeChild(mask);
      document.body.removeChild(dialog);
      onClose && onClose();
    }

    mask.addEventListener('click', close);
    header.querySelector('.bili-filter-close')?.addEventListener('click', close);

    document.body.appendChild(mask);
    document.body.appendChild(dialog);
  }

  function main() {
    const container = document.querySelector('div.container.is-version8');
    if (!container) {
      // 等待首页容器出现
      const readyObserver = new MutationObserver(() => {
        const c = document.querySelector('div.container.is-version8');
        if (c) {
          readyObserver.disconnect();
          initWithContainer(c);
        }
      });
      readyObserver.observe(document.documentElement || document.body, {
        childList: true,
        subtree: true,
      });
      return;
    }
    initWithContainer(container);
  }

  function initWithContainer(container) {
    const blockedSet = readBlocked();
    const blockedRef = { current: blockedSet };

    // 初始扫描
    scanAndFilter(container, blockedRef.current);

    // 观察新增
    const obs = setupObserver(container, blockedRef);

    // 存储变更联动
    GM_addValueChangeListener(STORAGE_KEY, (_k, _o, n) => {
      const next = new Set(Array.isArray(n) ? n : []);
      blockedRef.current = next;
      scanAndFilter(container, blockedRef.current);
    });

    // 菜单项
    GM_registerMenuCommand('设置屏蔽的推荐类型', () => {
      const working = new Set(blockedRef.current);
      openDialog(working, (updated) => {
        blockedRef.current = new Set(updated);
        writeBlocked(blockedRef.current);
        scanAndFilter(container, blockedRef.current);
      });
    });
  }

  // 尝试尽早启动
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', main);
  } else {
    main();
  }
})();