B站弹幕屏蔽词导入器

一个可以快速导入Bilibili弹幕屏蔽词的油猴脚本,网页端导入并同步至移动端

// ==UserScript==
// @name         B站弹幕屏蔽词导入器
// @namespace    https://github.com/xingguang2333/BiliBlocklistImporter/
// @version      1.0.1
// @description  一个可以快速导入Bilibili弹幕屏蔽词的油猴脚本,网页端导入并同步至移动端
// @author       StarsOcean
// @match        https://www.bilibili.com/video/*
// @match        https://www.bilibili.com/
// @icon         https://www.bilibili.com/favicon.ico
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_notification
// @grant        GM_download
// @connect      *
// @license      GPLv3
// ==/UserScript==

(function() {
    'use strict';

    // 配置项
    const CONFIG = {
        CHECK_INTERVAL: 300,      // 操作间隔时间(ms)
        MAX_WAIT_TIME: 5000,     // 元素等待最大时间(ms)
        RETRY_TIMES: 3,           // 失败重试次数
        FIRST_USE_KEY: 'BHELPER_PRO_FIRST_USE' // 本地存储键名
    };

    // 元素选择器
    const SELECTORS = {
        DROPDOWN_WRAP: '.bui-dropdown-wrap',              // 设置面板容器
        CURRENT_SETTING: '.bui-dropdown-name',           // 当前设置显示
        MENU_ITEM: '.bui-dropdown-item',                  // 菜单项
        BLOCK_INPUT: '.bpx-player-block-add-input',       // 规则输入框
        ADD_BUTTON: '.bui-area.bui-button-gray',          // 添加按钮
        SETTING_BUTTON: '.video-settings-button'           // 设置按钮
    };

    // 样式注入
    GM_addStyle(`
    .bili-helper-pro {
        position: fixed;
        right: 25px;
        bottom: 25px;
        z-index: 2147483647;
    }
    .main-btn-pro {
        width: 56px;
        height: 56px;
        background: #00a1d6;
        border-radius: 50%;
        cursor: pointer;
        box-shadow: 0 4px 12px rgba(0,0,0,0.2);
        display: flex;
        align-items: center;
        justify-content: center;
        transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
    }
    .main-btn-pro:hover {
        transform: scale(1.15) rotate(15deg);
        background: #0091c8;
    }
    .menu-pro {
        position: absolute;
        right: 0;
        bottom: 70px;
        width: 200px;
        background: #fff;
        border-radius: 12px;
        box-shadow: 0 8px 24px rgba(0,0,0,0.15);
        opacity: 0;
        transform: translateY(10px);
        transition: all 0.3s ease;
    }
    .menu-pro.show {
        opacity: 1;
        transform: translateY(0);
    }
    .menu-item-pro {
        padding: 14px 20px;
        font-size: 14px;
        color: #1a1a1a;
        cursor: pointer;
        transition: all 0.2s;
        display: flex;
        align-items: center;
    }
    .menu-item-pro:hover {
        background: #f5f5f7;
        padding-left: 25px;
    }
    .input-modal-pro {
        /* 保持原有输入框样式 */
    }
    .input-modal-pro {
        position: fixed;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        background: #fff;
        padding: 20px;
        border-radius: 12px;
        box-shadow: 0 8px 24px rgba(0,0,0,0.15);
        z-index: 2147483647;
        min-width: 300px;
    }
    .url-input {
        width: 100%;
        padding: 8px;
        margin: 10px 0;
        border: 1px solid #ddd;
    }
    .btn-group {
        display: flex;
        gap: 10px;
        margin-top: 15px;
    }
    .confirm-btn, .cancel-btn {
        flex:1;
        padding: 8px;
        background: #00a1d6;
        color: white;
        border: none;
        border-radius: 4px;
    }
    .cancel-btn {
        background: #f0f0f0;
        color: #333;
    }
    .close-btn {
        margin-top: 15px;
        padding: 8px 16px;
        background: #00a1d6;
        color: white;
        border-radius: 4px;
    }
    .about-modal-pro {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background: #fff;
    padding: 20px;
    border-radius: 12px;
    box-shadow: 0 8px 24px rgba(0,0,0,0.15);
    z-index: 2147483647;
    min-width: 400px;
    max-width: 500px;
    font-family: Arial, sans-serif;
    cursor: move;
    }
    .about-modal-pro h3 {
        margin: 0 0 15px;
        font-size: 20px;
        color: #00a1d6;
    }

    .about-modal-pro p {
        margin: 10px 0;
        font-size: 14px;
        color: #333;
    }

    .about-modal-pro ul {
        margin: 10px 0;
        padding-left: 20px;
    }

    .about-modal-pro ul li {
        margin: 5px 0;
        font-size: 14px;
        color: #555;
    }

    .about-modal-pro a {
        color: #00a1d6;
        text-decoration: none;
    }

    .about-modal-pro a:hover {
        text-decoration: underline;
    }

    .close-btn {
        position: absolute;
        top: 10px;
        right: 10px;
        background: #f0f0f0;
        border: none;
        border-radius: 50%;
        width: 30px;
        height: 30px;
        cursor: pointer;
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 16px;
        color: #333;
    }

    .close-btn:hover {
        background: #e0e0e0;
    }
    .guide-modal-pro {
        position: fixed;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        background: #fff;
        padding: 25px;
        border-radius: 12px;
        box-shadow: 0 8px 24px rgba(0,0,0,0.15);
        z-index: 2147483647;
        min-width: 500px;
        max-width: 600px;
        font-family: Arial, sans-serif;
        cursor: move;
    }
    .guide-modal-pro h2 {
        color: #00a1d6;
        margin: 0 0 15px;
        border-bottom: 2px solid #f0f0f0;
        padding-bottom: 10px;
    }
    .guide-content {
        max-height: 60vh;
        overflow-y: auto;
        padding-right: 10px;
    }
    .dont-show-again {
        display: flex;
        align-items: center;
        margin: 15px 0;
        font-size: 14px;
    }
    .dont-show-again input {
        margin-right: 8px;
    }
    .progress-container {
        position: fixed;
        left: 20px;
        bottom: 20px;
        width: 300px;
        background: rgba(255,255,255,0.9);
        border-radius: 8px;
        padding: 12px;
        box-shadow: 0 4px 12px rgba(0,0,0,0.15);
        z-index: 2147483646;
        display: none;
    }
    .progress-bar {
        height: 12px;
        background: #e0e0e0;
        border-radius: 6px;
        overflow: hidden;
        margin: 8px 0;
    }
    .progress-fill {
        height: 100%;
        background: #00a1d6;
        width: 0%;
        transition: width 0.3s ease;
    }
    .progress-text {
        font-size: 12px;
        color: #666;
        text-align: center;
    }
    `);

    // 主界面
    const createUI = () => {
        const container = document.createElement('div');
        container.className = 'bili-helper-pro';

        const mainBtn = document.createElement('div');
        mainBtn.className = 'main-btn-pro';
        mainBtn.innerHTML = '🛡️';

        const menu = document.createElement('div');
        menu.className = 'menu-pro';

        // 菜单项
        const menuItems = [
            { name: '使用必看', icon: '📘', action: showGuide },
            { name: 'TXT导入', icon: '📁', action: handleLocalImport },
            { name: '在线导入', icon: '🌐', action: handleWebImport },
            {
                name: '屏蔽词库',
                icon: '📚',
                action: () => {
                    // 在新标签页打开规则仓库
                    window.open(
                        'https://github.com/xingguang2333/BiliBlocklistImporter/tree/main/Blocklist',
                        '_blank',
                        'noopener,noreferrer'
                    );
                    // 可选:添加点击统计
                    console.log('[统计] 屏蔽词库菜单点击');
                }
            },
            { name: '关于', icon: 'ℹ️', action: showAbout }
        ];

        menuItems.forEach(item => {
            const btn = document.createElement('div');
            btn.className = 'menu-item-pro';
            btn.innerHTML = `${item.icon} ${item.name}`;
            btn.addEventListener('click', item.action);
            menu.appendChild(btn);
        });

        // 事件绑定
        mainBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            menu.classList.toggle('show');
        });

        document.addEventListener('click', () => {
            menu.classList.remove('show');
        });

        container.appendChild(menu);
        container.appendChild(mainBtn);
        document.body.appendChild(container);

        const progressContainer = document.createElement('div');
        progressContainer.className = 'progress-container';
        progressContainer.innerHTML = `
            <div class="progress-bar">
                <div class="progress-fill"></div>
            </div>
            <div class="progress-text">准备就绪</div>
        `;
        document.body.appendChild(progressContainer);
    };

    // 本地导入处理
    const handleLocalImport = async () => {
        const input = document.createElement('input');
        input.type = 'file';
        input.accept = '.txt';
        input.onchange = async (e) => {
            const file = e.target.files[0];
            try {
                const text = await file.text();
                await processRules(text);
                showToast('本地导入成功!', 'success');
            } catch (err) {
                showToast(`导入失败: ${err}`, 'error');
            }
        };
        input.click();
    };

    // 在线导入处理
    const handleWebImport = () => {
        const modal = document.createElement('div');
        modal.className = 'input-modal-pro';
        modal.innerHTML = `
            <h3>在线导入</h3>
            <input type="url" placeholder="输入TXT文件URL" class="url-input">
            <div class="btn-group">
                <button class="confirm-btn">确定</button>
                <button class="cancel-btn">取消</button>
            </div>
        `;

        modal.querySelector('.confirm-btn').onclick = async () => {
            const url = modal.querySelector('.url-input').value;
            if (!url) return;

            try {
                const text = await fetchWithRetry(url, CONFIG.RETRY_TIMES);
                await processRules(text);
                showToast('在线导入成功!', 'success');
            } catch (err) {
                showToast(`导入失败: ${err}`, 'error');
            }
            modal.remove();
        };

        modal.querySelector('.cancel-btn').onclick = () => modal.remove();
        document.body.appendChild(modal);
    };

    const processRules = async (text) => {
        const progressContainer = document.querySelector('.progress-container');
        const progressFill = document.querySelector('.progress-fill');
        const progressText = document.querySelector('.progress-text');

        try {
            // 显示进度条
            progressContainer.style.display = 'block';
            progressFill.style.width = '0%';
            progressText.textContent = '初始化中...';

            // 展开屏蔽设置
            if (!await checkCurrentSetting()) {
                await expandSettingsPanel();
                await selectBlockSetting();
            }

            // 执行规则导入
            const rules = text.split('\n').filter(l => l.trim());
            let successCount = 0;
            const total = rules.length;

            for (let i = 0; i < rules.length; i++) {
                const rule = rules[i];
                try {
                    await waitForElement(SELECTORS.BLOCK_INPUT, CONFIG.MAX_WAIT_TIME);
                    const input = document.querySelector(SELECTORS.BLOCK_INPUT);
                    const addBtn = document.querySelector(SELECTORS.ADD_BUTTON);

                    if (!input || !addBtn) throw new Error('页面元素未找到');

                    input.value = rule.trim();
                    addBtn.click();
                    successCount++;

                    // 更新进度
                    const progress = ((i + 1) / total * 100).toFixed(1);
                    progressFill.style.width = `${progress}%`;
                    progressText.textContent = `处理中 ${i + 1}/${total} (${progress}%)`;

                    await wait(CONFIG.CHECK_INTERVAL);
                } catch (err) {
                    console.error(`规则 "${rule}" 导入失败:`, err);
                }
            }

            // 完成时更新状态
            progressFill.style.width = '100%';
            progressText.textContent = `完成!成功导入 ${successCount}/${total} 条规则`;

            // 3秒后隐藏进度条
            setTimeout(() => {
                progressContainer.style.display = 'none';
            }, 3000);

        } catch (error) {
            // 错误处理
            progressContainer.style.display = 'none';
            showToast(`导入中断: ${error.message}`, 'error');
            throw error;
        }
    };

    // 辅助函数
    const checkCurrentSetting = async () => {
        const current = document.querySelector(SELECTORS.CURRENT_SETTING);
        return current?.textContent.includes('屏蔽设定');
    };

    const expandSettingsPanel = async () => {
        const dropdown = document.querySelector(SELECTORS.DROPDOWN_WRAP);
        if (dropdown) {
            dropdown.classList.add('bui-dropdown-unfold');
            await wait(800);
        }
    };

    const selectBlockSetting = async () => {
        const items = await waitForElements(SELECTORS.MENU_ITEM);
        const target = Array.from(items).find(el =>
            el.textContent.match(/屏蔽设定|block setting/i)
        );

        if (target) {
            target.click();
            await wait(1000);
        }
    };

    const wait = (ms) => new Promise(r => setTimeout(r, ms));

    const waitForElement = (selector, timeout = 5000) => {
        return new Promise((resolve, reject) => {
            const start = Date.now();
            const check = () => {
                const el = document.querySelector(selector);
                if (el) return resolve(el);
                if (Date.now() - start > timeout) {
                    reject(new Error(`Element ${selector} not found`));
                } else {
                    setTimeout(check, 100);
                }
            };
            check();
        });
    };

    const waitForElements = (selector) => waitForElement(selector).then(() =>
        document.querySelectorAll(selector)
    );

    const fetchWithRetry = async (url, retries = 3) => {
        for (let i = 0; i < retries; i++) {
            try {
                return await new Promise((resolve, reject) => {
                    GM_xmlhttpRequest({
                        method: 'GET',
                        url: `https://api.codetabs.com/v1/proxy?quest=${encodeURIComponent(url)}`,
                        onload: (res) => res.status === 200 ?
                            resolve(res.responseText) :
                            reject(new Error(`HTTP ${res.status}`)),
                        onerror: reject
                    });
                });
            } catch (err) {
                if (i === retries - 1) throw err;
                await wait(1000 * (i + 1));
            }
        }
    };

    const showToast = (message, type = 'info') => {
        const colors = {
            info: '#2196F3',
            success: '#4CAF50',
            error: '#F44336'
        };
        const toast = document.createElement('div');
        toast.textContent = message;
        toast.style.bottom = '150px';
        toast.style.cssText = `
            position: fixed;
            bottom: 150px;
            right: 30px;
            background: ${colors[type]};
            color: white;
            padding: 12px 24px;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.2);
            animation: slideIn 0.3s ease-out;
        `;
        document.body.appendChild(toast);
        setTimeout(() => toast.remove(), 3000);
    };

    const showGuide = (isFirstTime = false) => {
        // 添加首页元素存在性检测
        if (!document.querySelector(SELECTORS.SETTING_BUTTON) && window.location.pathname === '/') {
            console.log('检测到当前为首页,跳过播放器元素检测');
        }
        const modal = document.createElement('div');
        modal.className = 'guide-modal-pro';
        modal.innerHTML = `
            <h2>📖 使用必读指南</h2>
            <div class="guide-content">
                <p><strong>重要提示:</strong>使用本插件前请仔细阅读以下内容</p>
                <ul>
                    <li>🔒 本插件仅用于学习交流,请勿用于商业用途</li>
                    <li>⚙️ 导入规则前请确保格式为每行一个关键词</li>
                    <li>🔄 更新插件后建议清除旧规则重新导入</li>
                    <li>⚠️ 必须在视频页面导入才有效果</li>
                </ul>
                <p><strong>你可以用下面方式获得规则:</strong></p>
                <ol>
                    <li><a href="https://github.com/xingguang2333/BiliBlocklistImporter/tree/main/Blocklist" target="_blank">屏蔽词库</a></li>
                    <li></li>
                    <li>热心b友分享,并把他们保存在一个txt文件,一行一个屏蔽词</li>
                </ol>
            </div>
            ${isFirstTime ? `
            <div class="dont-show-again">
                <input type="checkbox" id="dontShow">
                <label for="dontShow">不再显示此提示</label>
            </div>
            ` : ''}
            <button class="bui-button bui-button-blue" style="width:100%">
                ${isFirstTime ? '我已知晓,开始使用' : '关闭'}
            </button>
            <button class="close-btn">×</button>
        `;

        // 关闭功能
        const closeModal = () => {
            if (isFirstTime) {
                const dontShow = modal.querySelector('#dontShow').checked;
                if (dontShow) {
                    localStorage.setItem(CONFIG.FIRST_USE_KEY, '1');
                }
            }
            modal.remove();
        };

        modal.querySelector('button.bui-button').onclick = closeModal;
        modal.querySelector('.close-btn').onclick = closeModal;

        // 拖动功能(复用about窗口的拖动逻辑)
        let isDragging = false;
        let offsetX, offsetY;
        modal.addEventListener('mousedown', (e) => {
            if (!e.target.closest('button, input, a')) {
                isDragging = true;
                offsetX = e.clientX - modal.offsetLeft;
                offsetY = e.clientY - modal.offsetTop;
            }
        });
        document.addEventListener('mousemove', (e) => {
            if (isDragging) {
                modal.style.left = `${e.clientX - offsetX}px`;
                modal.style.top = `${e.clientY - offsetY}px`;
            }
        });
        document.addEventListener('mouseup', () => isDragging = false);

        document.body.appendChild(modal);
    };

    // 新增:首次访问检测
    const checkFirstUse = () => {
        if (!localStorage.getItem(CONFIG.FIRST_USE_KEY)) {
            showGuide(true);
        }
    };

    const showAbout = () => {
        const modal = document.createElement('div');
        modal.className = 'about-modal-pro';
        modal.innerHTML = `
            <h3>B站弹幕屏蔽词导入 v1.0</h3>
            <p>一个可以快速导入Bilibili弹幕屏蔽词的油猴脚本,网页端导入并同步至移动端。A Tampermonkey script that can quickly import Bilibili subtitle blocking words, import on the web page and synchronize to the mobile terminal.</p>
            <p>相关链接:</p>
            <ul>
                <li><a href="https://moestars.top" target="_blank">个人网站</a></li>
                <li><a href="https://github.com/xingguang2333/BiliBlocklistImporter/" target="_blank">GitHub</a></li>
                <li><a href="https://greasyfork.org/zh-CN/scripts/your-script" target="_blank">GreasyFork</a></li>
                <li><a href="https://github.com/xingguang2333/BiliBlocklistImporter/tree/main/Blocklist" target="_blank">屏蔽词库</a></li>
            </ul>
            <button class="close-btn">×</button>
        `;

        // 关闭按钮
        modal.querySelector('.close-btn').onclick = () => modal.remove();

        // 拖动功能
        let isDragging = false;
        let offsetX, offsetY;

        modal.addEventListener('mousedown', (e) => {
            if (e.target.tagName.toLowerCase() !== 'a') { // 避免拖动时误点击链接
                isDragging = true;
                offsetX = e.clientX - modal.offsetLeft;
                offsetY = e.clientY - modal.offsetTop;
            }
        });

        document.addEventListener('mousemove', (e) => {
            if (isDragging) {
                modal.style.left = `${e.clientX - offsetX}px`;
                modal.style.top = `${e.clientY - offsetY}px`;
            }
        });

        document.addEventListener('mouseup', () => {
            isDragging = false;
        });

        document.body.appendChild(modal);
    };

    // 初始化
    (function init() {
        const checkDOMLoaded = () => {
            if (document.readyState === 'complete' || document.readyState === 'interactive') {
                createUI();
                checkFirstUse();
            } else {
                document.addEventListener('DOMContentLoaded', () => {
                    createUI();
                    checkFirstUse();
                });
            }
        };

        // 无论当前页面类型都执行初始化
        checkDOMLoaded();
    })();

})();