Bilibili AI弹幕过滤器

使用 AI (OpenAI/Ollama) 实时筛查 Bilibili 弹幕。

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Bilibili AI弹幕过滤器
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  使用 AI (OpenAI/Ollama) 实时筛查 Bilibili 弹幕。
// @author       Yesaye
// @license      MIT
// @match        https://www.bilibili.com/video/*
// @match        https://www.bilibili.com/bangumi/play/*
// @connect      api.openai.com
// @connect      localhost
// @connect      127.0.0.1
// @connect      *
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @icon         https://www.bilibili.com/favicon.ico?v=1
// ==/UserScript==

(function () {
    'use strict';

    // --- 配置与常量 ---
    const CACHE_LIMIT = 5000;
    const danmakuCache = new Map(); // Text -> Boolean

    const DEFAULT_CONFIG = {
        provider: 'openai',
        apiUrl: 'https://api.openai.com/v1/chat/completions',
        apiKey: '',
        model: 'gpt-3.5-turbo',
        prompt: '你是一个专业的弹幕审核员,你需要判断弹幕是否符合一下条件,若不符合则回复 BLOCK,否则回复 PASS:1. 弹幕内容应与剧情发展有关,不得讨论与剧情无关的话题。2. 不得是无意义的,无聊的,或者重复的内容。3. 不得包含任何形式的攻击性语言,侮辱,歧视,或不尊重他人的内容。',
        enableLog: true,
        showButton: true
    };

    let config = { ...DEFAULT_CONFIG, ...GM_getValue('ai_dm_config', {}) };
    let observer = null;

    // --- 注册油猴菜单 ---
    GM_registerMenuCommand("⚙️ 打开 AI 弹幕设置", () => {
        const panel = document.getElementById('ai-dm-settings');
        if (panel) panel.style.display = 'block';
    });

    // --- 日志系统 ---
    const LOG_STYLES = {
        PASS: 'color: #0f5132; background-color: #d1e7dd; padding: 2px 5px; border-radius: 4px; font-weight: bold;',
        BLOCK: 'color: #842029; background-color: #f8d7da; padding: 2px 5px; border-radius: 4px; font-weight: bold;',
        INFO: 'color: #055160; background-color: #cff4fc; padding: 2px 5px; border-radius: 4px;',
        ERR:  'color: #fff; background-color: #dc3545; padding: 2px 5px; border-radius: 4px;'
    };

    function log(type, msg, detail = '') {
        if (!config.enableLog) return;
        const style = LOG_STYLES[type] || '';
        console.log(`%c[${type}] ${msg}`, style, detail);
    }

    // --- UI 界面 (保持不变) ---
    const UI_HTML = `
        <div id="ai-dm-settings" style="display:none; position:fixed; bottom:50px; right:20px; width:340px; background:#1f1f1f; color:#e0e0e0; padding:15px; border-radius:8px; z-index:99999; font-family:'Segoe UI', sans-serif; box-shadow: 0 4px 20px rgba(0,0,0,0.6); border: 1px solid #333;">
            <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px; border-bottom:1px solid #444; padding-bottom:10px;">
                <h3 style="margin:0; font-size:16px; color:#00a1d6;">🤖 AI 弹幕过滤配置</h3>
                <span id="ai-dm-close" style="cursor:pointer; font-size:20px;">&times;</span>
            </div>

            <div style="font-size:12px; margin-bottom:10px;">
                <label>API 提供商:</label>
                <select id="ai-dm-provider" style="width:100%; padding:5px; margin-top:4px; background:#333; color:#fff; border:1px solid #555;">
                    <option value="openai">OpenAI (ChatGPT)</option>
                    <option value="ollama">Ollama (Local)</option>
                </select>
            </div>

            <div style="font-size:12px; margin-bottom:10px;">
                <label>API URL:</label>
                <input type="text" id="ai-dm-url" style="width:100%; padding:5px; margin-top:4px; background:#333; color:#fff; border:1px solid #555; box-sizing:border-box;">
            </div>

            <div style="font-size:12px; margin-bottom:10px;">
                <label>API Key (Ollama可空):</label>
                <input type="password" id="ai-dm-key" style="width:100%; padding:5px; margin-top:4px; background:#333; color:#fff; border:1px solid #555; box-sizing:border-box;">
            </div>

            <div style="font-size:12px; margin-bottom:10px;">
                <label>模型名称 (Model):</label>
                <input type="text" id="ai-dm-model" style="width:100%; padding:5px; margin-top:4px; background:#333; color:#fff; border:1px solid #555; box-sizing:border-box;">
            </div>

            <div style="font-size:12px; margin-bottom:10px;">
                <label>判断提示词 (System Prompt):</label>
                <textarea id="ai-dm-prompt" rows="3" style="width:100%; padding:5px; margin-top:4px; background:#333; color:#fff; border:1px solid #555; box-sizing:border-box; font-family:monospace;"></textarea>
            </div>

             <div style="font-size:12px; margin-bottom:15px; display:flex; align-items:center;">
                <input type="checkbox" id="ai-dm-show-btn" style="margin-right:5px;">
                <label for="ai-dm-show-btn">显示页面右下角悬浮按钮</label>
            </div>

            <button id="ai-dm-save" style="width:100%; padding:8px; background:#00a1d6; border:none; color:white; cursor:pointer; border-radius:4px; font-weight:bold;">💾 保存配置</button>
        </div>

        <button id="ai-dm-toggle-btn" style="display:none; position:fixed; bottom:20px; right:20px; z-index:99998; background:#00a1d6; color:white; border:none; padding:8px 12px; border-radius:20px; cursor:pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.3); transition: transform 0.2s;">
            AI 🛡️
        </button>
    `;

    function initUI() {
        if(document.getElementById('ai-dm-settings')) return;
        const div = document.createElement('div');
        div.innerHTML = UI_HTML;
        document.body.appendChild(div);

        const panel = document.getElementById('ai-dm-settings');
        const btn = document.getElementById('ai-dm-toggle-btn');
        const closeBtn = document.getElementById('ai-dm-close');
        const saveBtn = document.getElementById('ai-dm-save');

        document.getElementById('ai-dm-provider').value = config.provider;
        document.getElementById('ai-dm-url').value = config.apiUrl;
        document.getElementById('ai-dm-key').value = config.apiKey;
        document.getElementById('ai-dm-model').value = config.model;
        document.getElementById('ai-dm-prompt').value = config.prompt;
        document.getElementById('ai-dm-show-btn').checked = config.showButton;

        if (config.showButton) btn.style.display = 'block';

        btn.onclick = () => panel.style.display = 'block';
        closeBtn.onclick = () => panel.style.display = 'none';

        document.getElementById('ai-dm-provider').onchange = (e) => {
            const urlInput = document.getElementById('ai-dm-url');
            if (e.target.value === 'ollama' && urlInput.value.includes('openai')) {
                urlInput.value = 'http://localhost:11434/api/chat';
            } else if (e.target.value === 'openai' && urlInput.value.includes('localhost')) {
                urlInput.value = 'https://api.openai.com/v1/chat/completions';
            }
        };

        saveBtn.onclick = () => {
            config.provider = document.getElementById('ai-dm-provider').value;
            config.apiUrl = document.getElementById('ai-dm-url').value;
            config.apiKey = document.getElementById('ai-dm-key').value;
            config.model = document.getElementById('ai-dm-model').value;
            config.prompt = document.getElementById('ai-dm-prompt').value;
            config.showButton = document.getElementById('ai-dm-show-btn').checked;

            GM_setValue('ai_dm_config', config);
            btn.style.display = config.showButton ? 'block' : 'none';
            danmakuCache.clear();
            log('INFO', '配置已保存,缓存已清空');
            panel.style.display = 'none';
        };
    }

    // --- AI 核心逻辑 ---
    let pendingRequests = 0;
    const MAX_CONCURRENT = 50;

    function checkDanmakuWithAI(rawText) {
        return new Promise((resolve) => {
            const text = rawText.replace(/\s+/g, ''); // 预处理:去空格

            if (!text) {
                resolve(true);
                return;
            }

            if (danmakuCache.has(text)) {
                const passed = danmakuCache.get(text);
                // 命中缓存时不需要重复打印日志,除非你想调试
                if (!passed) log('BLOCK', `缓存拦截: ${text}`);
                resolve(passed);
                return;
            }

            if (pendingRequests >= MAX_CONCURRENT) {
                log('PASS', `限流保护: ${text}`);
                resolve(true);
                return;
            }

            pendingRequests++;
            const headers = { "Content-Type": "application/json" };
            if (config.provider === 'openai') {
                headers["Authorization"] = `Bearer ${config.apiKey}`;
            }

            const data = {
                model: config.model,
                messages: [
                    { role: "system", content: config.prompt },
                    { role: "user", content: text }
                ],
                stream: false,
                temperature: 0.1
            };

            GM_xmlhttpRequest({
                method: "POST",
                url: config.apiUrl,
                headers: headers,
                data: JSON.stringify(data),
                onload: function (response) {
                    pendingRequests--;
                    try {
                        if (response.status !== 200) {
                            log('ERR', `API Error ${response.status}`);
                            resolve(true);
                            return;
                        }

                        const json = JSON.parse(response.responseText);
                        let aiReply = "";
                        if (json.choices && json.choices[0]?.message) {
                            aiReply = json.choices[0].message.content.trim();
                        } else if (json.message?.content) {
                            aiReply = json.message.content.trim();
                        }

                        const isBlock = aiReply.toUpperCase().includes("BLOCK");
                        const passed = !isBlock;

                        if (danmakuCache.size > CACHE_LIMIT) danmakuCache.delete(danmakuCache.keys().next().value);
                        danmakuCache.set(text, passed);

                        if (!passed) log('BLOCK', `${text}`, `AI回复: ${aiReply}`);
                        else log('PASS', `${text}`);

                        resolve(passed);
                    } catch (e) {
                        pendingRequests--;
                        resolve(true);
                    }
                },
                onerror: function (err) {
                    pendingRequests--;
                    resolve(true);
                }
            });
        });
    }

    // --- DOM 操作 (修改版) ---

    function processNode(node) {
        // 必须是元素节点
        if (node.nodeType !== 1) return;

        // 获取文本内容
        let rawText = node.innerText || node.textContent || "";
        if (!rawText.trim()) return;

        // 性能优化:如果该节点当前显示的文本已经检查过且通过,则跳过
        // 我们在节点上挂载一个自定义属性来记录上次检查的文本
        if (node.dataset.aiCheckedText === rawText) {
            return;
        }

        // 暂时隐藏 (使用 visibility 保持占位,或 opacity)
        // 注意:不要使用 display:none,这可能会导致B站计算弹幕位置错误
        node.style.visibility = 'hidden';

        checkDanmakuWithAI(rawText).then(allow => {
            // 记录当前检查通过的文本,防止重复检查
            node.dataset.aiCheckedText = rawText;

            if (allow) {
                node.style.visibility = 'visible';
                node.style.border = ''; // 清除可能残留的标记
            } else {
                // 核心修改:不 remove,而是隐藏。这样 DOM 结构还在,下次变动还能被检测。
                node.style.visibility = 'hidden';
                // 可选:给被屏蔽的弹幕加个标记方便调试
                // node.style.border = '1px solid red';
            }
        });
    }

    function handleMutations(mutations) {
        for (const mutation of mutations) {
            // 情况1: 新增的节点 (Added Nodes)
            if (mutation.type === 'childList') {
                for (const node of mutation.addedNodes) {
                    if (node.nodeType === 1) {
                        if (node.classList.contains('bili-danmaku-x-dm')) {
                            processNode(node);
                        } else if (node.querySelectorAll) {
                            const children = node.querySelectorAll('.bili-danmaku-x-dm');
                            children.forEach(processNode);
                        }
                    }
                }

                // 补充情况1.1: 现有节点的文本节点被替换 (例如 .innerText = "new")
                // 此时 target 是弹幕元素本身
                 if (mutation.target.nodeType === 1 &&
                     mutation.target.classList.contains('bili-danmaku-x-dm')) {
                     processNode(mutation.target);
                 }
            }

            // 情况2: 文本内容变化 (CharacterData)
            // 当文本节点的内容发生变化时,target 是文本节点,parentElement 是弹幕元素
            if (mutation.type === 'characterData') {
                const textNode = mutation.target;
                const parent = textNode.parentElement;
                if (parent && parent.nodeType === 1 && parent.classList.contains('bili-danmaku-x-dm')) {
                    processNode(parent);
                }
            }
        }
    }

    // --- 初始化 ---
    function start() {
        // 寻找弹幕容器 (通常是 .bpx-player-render-dm-wrap 或 .bilibili-player-video-danmaku)
        // 这里使用较为通用的选择器策略
        const container = document.querySelector('.bpx-player-render-dm-wrap') ||
                          document.querySelector('.bilibili-player-video-danmaku');

        if (!container) {
            // 如果还没加载出来,稍后重试
            setTimeout(start, 1000);
            return;
        }

        log('INFO', 'AI 弹幕监控器已启动 (支持动态变化)', container);

        if (observer) observer.disconnect();
        observer = new MutationObserver(handleMutations);

        // 核心修改:开启 characterData 和 subtree 以监听深层文本变化
        observer.observe(container, {
            childList: true,
            subtree: true,
            characterData: true // 监听文本内容变动
        });
    }

    // 等待播放器框架加载
    const timer = setInterval(() => {
        if (document.querySelector('.bpx-player-video-wrap') || document.querySelector('video')) {
            clearInterval(timer);
            setTimeout(() => {
                initUI();
                start();
            }, 2000);
        }
    }, 1000);

})();