Bilibili 评论区等级过滤器

优雅地过滤Bilibili评论区,支持拖动调节等级阈值与自动隐藏功能

// ==UserScript==
// @name         Bilibili 评论区等级过滤器
// @namespace    https://github.com/Misasasasasaka
// @version      2.1
// @description  优雅地过滤Bilibili评论区,支持拖动调节等级阈值与自动隐藏功能
// @author       Misasasasasaka
// @match        https://www.bilibili.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        unsafeWindow
// @license      MIT
// ==/UserScript==

;(function() {
  'use strict';

  // 存储键和全局配置
  const CONFIG = {
    storageKey: 'bili_comment_level_threshold',
    defaultThreshold: 0,
    colors: {
      primary: '#fb7299',    // B站粉色
      secondary: '#23ade5',  // B站蓝色
      background: '#ffffff',
      text: '#333333',
      border: '#e1e1e1'
    },
    animation: {
      duration: '0.3s'
    }
  };

  // 获取用户设置
  let threshold = GM_getValue(CONFIG.storageKey, CONFIG.defaultThreshold);

  // 核心样式定义
  const styleDefinition = `
    @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500&display=swap');

    #bili-level-filter-container {
      position: fixed;
      bottom: 20px;
      right: 20px;
      z-index: 9999;
      font-family: 'Noto Sans SC', sans-serif;
      transition: all ${CONFIG.animation.duration} ease;
      opacity: 0.3;
    }

    #bili-level-filter-container:hover {
      opacity: 1;
    }

    #bili-level-filter-container.panel-open {
      opacity: 1;
    }

    #bili-level-filter-toggle {
      width: 36px;
      height: 36px;
      background: ${CONFIG.colors.primary};
      border-radius: 50%;
      box-shadow: 0 2px 8px rgba(0,0,0,0.15);
      color: white;
      border: none;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      outline: none;
      transition: transform ${CONFIG.animation.duration} ease, background ${CONFIG.animation.duration} ease;
      position: relative;
      z-index: 10001;
    }

    #bili-level-filter-toggle:hover {
      background: ${CONFIG.colors.secondary};
      transform: scale(1.1);
    }

    #bili-level-filter-toggle svg {
      width: 18px;
      height: 18px;
      fill: white;
    }

    #bili-level-filter-panel {
      position: absolute;
      bottom: 50px;
      right: 0;
      background: ${CONFIG.colors.background};
      border-radius: 8px;
      box-shadow: 0 3px 15px rgba(0,0,0,0.12);
      padding: 16px;
      width: 260px;
      border: 1px solid ${CONFIG.colors.border};
      display: flex;
      flex-direction: column;
      gap: 12px;
      opacity: 0;
      visibility: hidden;
      transform: translateY(10px);
      transition: opacity ${CONFIG.animation.duration} ease,
                  visibility ${CONFIG.animation.duration} ease,
                  transform ${CONFIG.animation.duration} ease;
    }

    #bili-level-filter-panel.visible {
      opacity: 1;
      visibility: visible;
      transform: translateY(0);
    }

    #bili-level-filter-panel h4 {
      margin: 0 0 8px;
      color: ${CONFIG.colors.primary};
      display: flex;
      align-items: center;
      font-size: 15px;
      font-weight: 500;
    }

    #bili-level-filter-panel h4 svg {
      margin-right: 6px;
    }

    .level-filter-row {
      display: flex;
      flex-direction: column;
      margin-bottom: 12px;
    }

    .level-filter-row label {
      display: block;
      margin-bottom: 6px;
      font-size: 13px;
      color: ${CONFIG.colors.text};
    }

    .slider-container {
      display: flex;
      align-items: center;
      gap: 10px;
    }

    .range-slider {
      flex: 1;
      height: 5px;
      -webkit-appearance: none;
      appearance: none;
      background: #e6e6e6;
      outline: none;
      border-radius: 3px;
    }

    .range-slider::-webkit-slider-thumb {
      -webkit-appearance: none;
      appearance: none;
      width: 16px;
      height: 16px;
      background: ${CONFIG.colors.primary};
      border-radius: 50%;
      cursor: pointer;
      transition: all 0.15s ease;
    }

    .range-slider::-webkit-slider-thumb:hover {
      transform: scale(1.2);
      background: ${CONFIG.colors.secondary};
    }

    .value-display {
      background: ${CONFIG.colors.primary}22;
      min-width: 35px;
      height: 24px;
      border-radius: 4px;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 13px;
      font-weight: 500;
      color: ${CONFIG.colors.primary};
    }

    .button-row {
      display: flex;
      justify-content: space-between;
      margin-top: 8px;
    }

    .bili-btn {
      padding: 6px 14px;
      border: none;
      border-radius: 4px;
      font-size: 13px;
      cursor: pointer;
      transition: all 0.2s ease;
      font-weight: 500;
      font-family: inherit;
    }

    .bili-btn-primary {
      background: ${CONFIG.colors.primary};
      color: white;
    }

    .bili-btn-primary:hover {
      background: #e45f84;
      transform: translateY(-1px);
      box-shadow: 0 2px 5px rgba(0,0,0,0.1);
    }

    .bili-btn-secondary {
      background: transparent;
      color: ${CONFIG.colors.text};
      border: 1px solid #ddd;
    }

    .bili-btn-secondary:hover {
      border-color: #ccc;
      background: #f9f9f9;
    }

    .filter-status {
      padding: 6px 10px;
      border-radius: 4px;
      background: #f0f9ff;
      border: 1px solid #e0f0ff;
      font-size: 12px;
      color: #0077cc;
      margin-top: 6px;
      text-align: center;
      transition: all ${CONFIG.animation.duration} ease;
    }

    .filter-active {
      background: #ecfaf0;
      border: 1px solid #d5f0db;
      color: #00a651;
    }

    #bili-toast {
      position: fixed;
      bottom: 80px;
      right: 20px;
      background: rgba(0,0,0,0.7);
      color: white;
      padding: 8px 16px;
      border-radius: 4px;
      font-size: 13px;
      opacity: 0;
      transform: translateY(10px);
      transition: all 0.3s ease;
      z-index: 10000;
      pointer-events: none;
    }

    #bili-toast.show {
      opacity: 1;
      transform: translateY(0);
    }

    .hidden {
      display: none !important;
    }

    @media (max-width: 768px) {
      #bili-level-filter-panel {
        width: 220px;
      }
    }
  `;

  // 创建并添加插件UI
  function createFilterUI() {
    // 添加样式
    GM_addStyle(styleDefinition);

    // 创建主容器
    const container = document.createElement('div');
    container.id = 'bili-level-filter-container';

    // 添加切换按钮(小图标按钮)
    const toggleButton = document.createElement('button');
    toggleButton.id = 'bili-level-filter-toggle';
    toggleButton.setAttribute('aria-label', '打开评论过滤器设置');
    toggleButton.innerHTML = `
      <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
        <path d="M10 18V12L3 5V3H21V5L14 12V18L10 21V18Z" fill="currentColor"/>
      </svg>
    `;

    // 创建设置面板
    const panel = document.createElement('div');
    panel.id = 'bili-level-filter-panel';

    // 面板标题和图标
    panel.innerHTML = `
      <h4>
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
          <path d="M10 18V12L3 5V3H21V5L14 12V18L10 21V18Z" stroke="${CONFIG.colors.primary}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
        </svg>
        B站评论等级过滤器
      </h4>

      <div class="level-filter-row">
        <label for="bili-level-slider">最低用户等级 (0-6):</label>
        <div class="slider-container">
          <input type="range" id="bili-level-slider" class="range-slider" min="0" max="6" step="1" value="${threshold}">
          <div class="value-display" id="level-value">${threshold}</div>
        </div>
      </div>

      <div class="filter-status ${threshold > 0 ? 'filter-active' : ''}">
        <span id="status-message">${
          threshold > 0
            ? `当前已过滤 <b>Lv.${threshold}</b> 以下用户评论`
            : '过滤功能未启用'
        }</span>
      </div>

      <div class="button-row">
        <button id="bili-level-reset" class="bili-btn bili-btn-secondary">重置</button>
        <button id="bili-level-save" class="bili-btn bili-btn-primary">保存并应用</button>
      </div>
    `;

    // 创建toast通知
    const toast = document.createElement('div');
    toast.id = 'bili-toast';
    toast.textContent = '设置已保存';

    // 组装UI
    container.appendChild(toggleButton);
    container.appendChild(panel);
    document.body.appendChild(container);
    document.body.appendChild(toast);

    // 绑定事件
    setupEventListeners();
  }

  // 设置事件监听
  function setupEventListeners() {
    // 获取元素
    const container = document.getElementById('bili-level-filter-container');
    const toggleButton = document.getElementById('bili-level-filter-toggle');
    const panel = document.getElementById('bili-level-filter-panel');
    const slider = document.getElementById('bili-level-slider');
    const valueDisplay = document.getElementById('level-value');
    const saveButton = document.getElementById('bili-level-save');
    const resetButton = document.getElementById('bili-level-reset');
    const statusDiv = document.querySelector('.filter-status');
    const statusMessage = document.getElementById('status-message');

    // 控制面板显示隐藏
    let panelVisible = false;

    toggleButton.addEventListener('click', (e) => {
      e.stopPropagation();
      togglePanel(!panelVisible);
    });

    // 点击其他区域关闭面板
    document.addEventListener('click', (e) => {
      if (panelVisible && !panel.contains(e.target) && e.target !== toggleButton) {
        togglePanel(false);
      }
    });

    // 面板内点击不传播
    panel.addEventListener('click', (e) => {
      e.stopPropagation();
    });

    // 切换面板显示状态
    function togglePanel(show) {
      panelVisible = show;
      if (show) {
        panel.classList.add('visible');
        container.classList.add('panel-open');
      } else {
        panel.classList.remove('visible');
        container.classList.remove('panel-open');
      }
    }

    // 鼠标移出容器区域自动隐藏
    let hideTimeout;

    container.addEventListener('mouseenter', () => {
      clearTimeout(hideTimeout);
    });

    container.addEventListener('mouseleave', () => {
      if (panelVisible) {
        hideTimeout = setTimeout(() => {
          togglePanel(false);
        }, 1000); // 鼠标移出1秒后隐藏面板
      }
    });

    // 滑块值更新
    slider.addEventListener('input', () => {
      const value = slider.value;
      valueDisplay.textContent = value;

      // 实时预览状态变化
      if (value > 0) {
        statusDiv.classList.add('filter-active');
        statusMessage.innerHTML = `当前已过滤 <b>Lv.${value}</b> 以下用户评论`;
      } else {
        statusDiv.classList.remove('filter-active');
        statusMessage.innerHTML = '过滤功能未启用';
      }
    });

    // 保存设置
    saveButton.addEventListener('click', () => {
      const newThreshold = parseInt(slider.value);
      threshold = newThreshold;
      GM_setValue(CONFIG.storageKey, newThreshold);

      // 显示保存成功提示
      showToast(`设置已保存,将过滤 Lv.${newThreshold} 以下用户评论`);

      // 关闭面板
      togglePanel(false);

      // 刷新页面以应用更改
      setTimeout(() => {
        location.reload();
      }, 1000);
    });

    // 重置设置
    resetButton.addEventListener('click', () => {
      slider.value = CONFIG.defaultThreshold;
      valueDisplay.textContent = CONFIG.defaultThreshold;
      statusDiv.classList.remove('filter-active');
      statusMessage.innerHTML = '过滤功能未启用';

      if (threshold !== CONFIG.defaultThreshold) {
        threshold = CONFIG.defaultThreshold;
        GM_setValue(CONFIG.storageKey, CONFIG.defaultThreshold);
        showToast('设置已重置,过滤功能已关闭');

        // 关闭面板
        togglePanel(false);

        // 刷新页面以应用更改
        setTimeout(() => {
          location.reload();
        }, 1000);
      }
    });
  }

  // 显示toast通知
  function showToast(message, duration = 3000) {
    const toast = document.getElementById('bili-toast');
    toast.textContent = message;
    toast.classList.add('show');

    setTimeout(() => {
      toast.classList.remove('show');
    }, duration);
  }

  // 拦截并过滤评论请求
  function setupFetchInterceptor() {
    const origFetch = unsafeWindow.fetch;

    unsafeWindow.fetch = function(input, init) {
      let url = typeof input === 'string' ? input : input.url;

      // 针对评论接口进行处理
      if (url.includes('/x/v2/reply')) {
        return origFetch(input, init).then(res => {
          // 如果过滤级别为0,不进行过滤
          if (threshold <= 0) return res;

          const clone = res.clone();
          return clone.json().then(json => {
            // 确保响应格式正确
            if (json && json.code === 0 && json.data) {
              // 过滤主评论和热门评论
              ['replies', 'hots'].forEach(key => {
                if (Array.isArray(json.data[key])) {
                  const originalCount = json.data[key].length;
                  json.data[key] = json.data[key].filter(item => {
                    const lvl = item.member?.level_info?.current_level || 0;
                    return lvl >= threshold;
                  });

                  // 添加调试信息,在控制台显示过滤情况
                  if (originalCount > json.data[key].length) {
                    console.info(
                      `%c[B站评论过滤器] %c已过滤 ${originalCount - json.data[key].length} 条 Lv.${threshold} 以下用户评论`,
                      'color: #fb7299; font-weight: bold',
                      'color: #333'
                    );
                  }
                }
              });

              // 处理嵌套的回复
              if (json.data.replies) {
                json.data.replies.forEach(reply => {
                  if (Array.isArray(reply.replies)) {
                    const originalNestedCount = reply.replies.length;
                    reply.replies = reply.replies.filter(item => {
                      const lvl = item.member?.level_info?.current_level || 0;
                      return lvl >= threshold;
                    });
                  }
                });
              }
            }

            // 创建新的响应
            const blob = new Blob([JSON.stringify(json)], { type: 'application/json' });
            return new Response(blob, {
              status: res.status,
              statusText: res.statusText,
              headers: res.headers
            });
          });
        });
      }

      // 其它请求正常处理
      return origFetch(input, init);
    };
  }

  // 初始化函数
  function init() {
    // 创建UI
    createFilterUI();

    // 设置拦截器
    setupFetchInterceptor();

    // 打印初始化信息
    console.info(
      `%c[B站评论过滤器 v2.1] %c已启用 - 当前过滤 Lv.${threshold} 以下用户评论`,
      'color: #fb7299; font-weight: bold',
      'color: #333'
    );
  }

  // 当文档加载完成后初始化
  if (document.readyState === 'complete' || document.readyState === 'interactive') {
    init();
  } else {
    window.addEventListener('DOMContentLoaded', init);
  }
})();