B站视频外挂字幕工具(自动版)

为B站视频添加外挂字幕功能,支持手动选择字幕和样式调整

// ==UserScript==
// @name         B站视频外挂字幕工具(自动版)
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  为B站视频添加外挂字幕功能,支持手动选择字幕和样式调整
// @author       wuwu
// @match        https://www.bilibili.com/video/*
// @license      MIT
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// ==/UserScript==

(function() {
    'use strict';

    let currentSubtitle = [];
    let subtitleIndex = 0;
    let subtitleContainer = null;
    let settingsPanel = null;
    let settingsVisible = false;
    let offsetTime = 0;
    let isManualSelection = false; // 添加这个缺失的变量
    let currentPart = null;
    let partObserver = null;
    let episodeTitles = [];
    function init() {
        GM_addStyle(`
            .custom-subtitle-container {
                position: absolute;
                bottom: 120px;
                left: 50%;
                transform: translateX(-50%);
                width: 80%;
                text-align: center;
                z-index: 9999;
                pointer-events: none;
                transition: all 0.3s;
            }
            .custom-subtitle {
                display: inline-block;
                max-width: 100%;
                padding: 8px 16px;
                border-radius: 4px;
                font-size: 24px;
                color: white;
                text-shadow: 1px 1px 2px black;
                background-color: rgba(0, 0, 0, 0.7);
                margin-bottom: 10px;
                line-height: 1.4;
                transition: all 0.3s;
            }
            .subtitle-settings-btn {
                position: fixed;
                bottom: 20px;
                right: 20px;
                z-index: 10000;
                background-color: #fb7299;
                color: white;
                border: none;
                border-radius: 50%;
                width: 50px;
                height: 50px;
                font-size: 20px;
                cursor: pointer;
                display: flex;
                align-items: center;
                justify-content: center;
                box-shadow: 0 2px 5px rgba(0,0,0,0.2);
            }
            .subtitle-settings-panel {
                position: fixed;
                bottom: 80px;
                right: 20px;
                z-index: 10000;
                background-color: white;
                border-radius: 8px;
                padding: 15px;
                width: 300px;
                box-shadow: 0 2px 10px rgba(0,0,0,0.2);
                display: none;
            }
            .subtitle-settings-panel.visible {
                display: block;
            }
            .subtitle-settings-panel h3 {
                margin-top: 0;
                color: #fb7299;
            }
            .subtitle-settings-panel label {
                display: block;
                margin: 10px 0 5px;
            }
            .subtitle-settings-panel input[type="range"] {
                width: 100%;
            }
            .subtitle-settings-panel button {
                background-color: #fb7299;
                color: white;
                border: none;
                padding: 8px 15px;
                border-radius: 4px;
                margin-top: 10px;
                cursor: pointer;
            }
            .subtitle-file-input {
                display: none;
            }
            .subtitle-upload-btn {
                background-color: #23ade5;
                color: white;
                border: none;
                padding: 8px 15px;
                border-radius: 4px;
                margin-top: 10px;
                cursor: pointer;
                width: 100%;
            }
            .subtitle-selector {
                width: 100%;
                padding: 8px;
                margin: 10px 0;
                border-radius: 4px;
                border: 1px solid #ddd;
            }
            .auto-match-info{font-size:12px;color:#666;margin-top:5px;}

        `);

        subtitleContainer = document.createElement('div');
        subtitleContainer.className = 'custom-subtitle-container';
        document.body.appendChild(subtitleContainer);

        const settingsBtn = document.createElement('button');
        settingsBtn.className = 'subtitle-settings-btn';
        settingsBtn.innerHTML = '字';
        settingsBtn.title = '字幕设置';
        settingsBtn.addEventListener('click', toggleSettingsPanel);
        document.body.appendChild(settingsBtn);

        createSettingsPanel();
        setupVideoListener();
        loadSettings();
        updateSubtitleSelector();
        loadEpisodeTitles();
        setupPartSwitchListener();
        setTimeout(tryAutoMatchSubtitle, 2000);

    }
    // 新增分P标题加载函数
    function loadEpisodeTitles() {
        // 方法1:从页面预加载数据获取
        const scriptData = document.getElementById('__NEXT_DATA__');
        if (scriptData) {
            try {
                const jsonData = JSON.parse(scriptData.textContent);
                episodeTitles = jsonData?.props?.pageProps?.dehydratedState?.queries?.[0]?.state?.data?.pages?.map(p => p.part) || [];
                return;
            } catch (e) {}
        }

        // 方法2:从初始化状态获取
        if (typeof __INITIAL_STATE__ !== 'undefined') {
            episodeTitles = __INITIAL_STATE__?.videoData?.pages?.map(p => p.part) || [];
            return;
        }

        // 方法3:DOM解析(最终回退)
        episodeTitles = Array.from(document.querySelectorAll('.list-box [data-title]')).map(el => el.dataset.title);
    }


    function createSettingsPanel() {
        settingsPanel = document.createElement('div');
        settingsPanel.className = 'subtitle-settings-panel';
        settingsPanel.innerHTML = `
            <h3>字幕设置</h3>
            <label>选择字幕文件:</label>
            <select class="subtitle-selector" id="subtitle-selector">
                <option value="">-- 选择字幕 --</option>
            </select>
            <label>垂直位置: <span id="vertical-value">120</span>px</label>
            <input type="range" id="subtitle-vertical" min="20" max="300" value="120">
            <label>水平位置: <span id="horizontal-value">50</span>%</label>
            <input type="range" id="subtitle-horizontal" min="0" max="100" value="50">
            <label>字体大小: <span id="size-value">24</span>px</label>
            <input type="range" id="subtitle-size" min="12" max="48" value="24">
            <label>背景透明度: <span id="opacity-value">70</span>%</label>
            <input type="range" id="subtitle-opacity" min="0" max="100" value="70">
            <label>时间偏移: <span id="offset-value">0</span>秒</label>
            <input type="range" id="subtitle-offset" min="-5" max="5" step="0.1" value="0">
            <button id="reset-settings">重置设置</button>
            <input type="file" id="subtitle-file-input" class="subtitle-file-input" multiple accept=".srt">
            <button class="subtitle-upload-btn" id="upload-subtitles">批量上传字幕文件</button>
            <button class="subtitle-upload-btn" id="clear-subtitles">清除所有字幕</button>
        `;
        settingsPanel.innerHTML+=`<div id="auto-match-info" class="auto-match-info"></div>`;

        document.body.appendChild(settingsPanel);

        document.getElementById('subtitle-vertical').addEventListener('input', updateVerticalPosition);
        document.getElementById('subtitle-horizontal').addEventListener('input', updateHorizontalPosition);
        document.getElementById('subtitle-size').addEventListener('input', updateSize);
        document.getElementById('subtitle-opacity').addEventListener('input', updateOpacity);
        document.getElementById('subtitle-offset').addEventListener('input', updateOffset);
        document.getElementById('reset-settings').addEventListener('click', resetSettings);
        document.getElementById('upload-subtitles').addEventListener('click', () => {
            document.getElementById('subtitle-file-input').click();
        });
        document.getElementById('subtitle-file-input').addEventListener('change', handleSubtitleUpload);
        document.getElementById('clear-subtitles').addEventListener('click', clearSubtitles);
        document.getElementById('subtitle-selector').addEventListener('change', selectSubtitle);
    }

    function toggleSettingsPanel() {
        settingsVisible = !settingsVisible;
        settingsPanel.classList.toggle('visible', settingsVisible);
    }

    function updateVerticalPosition(e) {
        const value = e.target.value;
        document.getElementById('vertical-value').textContent = value;
        subtitleContainer.style.bottom = `${value}px`;
        GM_setValue('subtitleVertical', value);
    }
    // 修改后的setupPartSwitchListener
    function setupPartSwitchListener() {
        if (partObserver) {
            partObserver.disconnect();
        }

        // 扩展更多可能的分P容器选择器
        const containerSelectors = [
            '.list-box',
            '.section',
            '.video-section-list',
            '.part-list',
            '.video-tab-page',
            '.multi-page',
            '.episode-list'
        ];

        let container = null;
        for (const selector of containerSelectors) {
            container = document.querySelector(selector);
            if (container) {
                console.log(`找到分P容器: ${selector}`);
                break;
            }
        }

        if (!container) {
            console.log('未找到分P容器,将在1秒后重试...');
            setTimeout(setupPartSwitchListener, 1000);
            return;
        }

        partObserver = new MutationObserver(() => {
            const newPart = document.querySelector('.part.on,.cur-list.on,.episode-item.on,.episode-list__item.active');
            if (newPart && (!currentPart || newPart !== currentPart)) {
                console.log('检测到分P切换,新分P内容:', newPart.textContent.trim());
                currentPart = newPart;
                setTimeout(() => {
                    tryAutoMatchSubtitle();
                }, 500);
            }
        });

        partObserver.observe(container, {
            childList: true,
            subtree: true
        });
    }

    function updateHorizontalPosition(e) {
        const value = e.target.value;
        document.getElementById('horizontal-value').textContent = value;
        subtitleContainer.style.left = `${value}%`;
        subtitleContainer.style.transform = `translateX(-${value}%)`;
        GM_setValue('subtitleHorizontal', value);
    }

    function updateSize(e) {
        const value = e.target.value;
        document.getElementById('size-value').textContent = value;
        const subtitleElement = subtitleContainer.querySelector('.custom-subtitle');
        if (subtitleElement) {
            subtitleElement.style.fontSize = `${value}px`;
        }
        GM_setValue('subtitleSize', value);
    }

    function updateOpacity(e) {
        const value = e.target.value;
        document.getElementById('opacity-value').textContent = value;
        const opacity = value / 100;
        const subtitleElement = subtitleContainer.querySelector('.custom-subtitle');
        if (subtitleElement) {
            subtitleElement.style.backgroundColor = `rgba(0, 0, 0, ${opacity})`;
        }
        GM_setValue('subtitleOpacity', value);
    }

    function updateOffset(e) {
        const value = parseFloat(e.target.value);
        document.getElementById('offset-value').textContent = value;
        offsetTime = value;
        GM_setValue('subtitleOffset', value);
    }

    function resetSettings() {
        GM_setValue('subtitleVertical', 120);
        GM_setValue('subtitleHorizontal', 50);
        GM_setValue('subtitleSize', 24);
        GM_setValue('subtitleOpacity', 70);
        GM_setValue('subtitleOffset', 0);

        document.getElementById('subtitle-vertical').value = 120;
        document.getElementById('subtitle-horizontal').value = 50;
        document.getElementById('subtitle-size').value = 24;
        document.getElementById('subtitle-opacity').value = 70;
        document.getElementById('subtitle-offset').value = 0;

        document.getElementById('vertical-value').textContent = '120';
        document.getElementById('horizontal-value').textContent = '50';
        document.getElementById('size-value').textContent = '24';
        document.getElementById('opacity-value').textContent = '70';
        document.getElementById('offset-value').textContent = '0';

        subtitleContainer.style.bottom = '120px';
        subtitleContainer.style.left = '50%';
        subtitleContainer.style.transform = 'translateX(-50%)';

        const subtitleElement = subtitleContainer.querySelector('.custom-subtitle');
        if (subtitleElement) {
            subtitleElement.style.fontSize = '24px';
            subtitleElement.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
        }

        offsetTime = 0;
    }

    function loadSettings() {
        const vertical = GM_getValue('subtitleVertical', 120);
        const horizontal = GM_getValue('subtitleHorizontal', 50);
        const size = GM_getValue('subtitleSize', 24);
        const opacity = GM_getValue('subtitleOpacity', 70);
        const offset = GM_getValue('subtitleOffset', 0);

        document.getElementById('subtitle-vertical').value = vertical;
        document.getElementById('subtitle-horizontal').value = horizontal;
        document.getElementById('subtitle-size').value = size;
        document.getElementById('subtitle-opacity').value = opacity;
        document.getElementById('subtitle-offset').value = offset;

        document.getElementById('vertical-value').textContent = vertical;
        document.getElementById('horizontal-value').textContent = horizontal;
        document.getElementById('size-value').textContent = size;
        document.getElementById('opacity-value').textContent = opacity;
        document.getElementById('offset-value').textContent = offset;

        subtitleContainer.style.bottom = `${vertical}px`;
        subtitleContainer.style.left = `${horizontal}%`;
        subtitleContainer.style.transform = `translateX(-${horizontal}%)`;

        const subtitleElement = subtitleContainer.querySelector('.custom-subtitle');
        if (subtitleElement) {
            subtitleElement.style.fontSize = `${size}px`;
            subtitleElement.style.backgroundColor = `rgba(0, 0, 0, ${opacity / 100})`;
        }

        offsetTime = offset;
    }

    function handleSubtitleUpload(e) {
        const files = e.target.files;
        if (files.length === 0) return;

        for (let i = 0; i < files.length; i++) {
            const file = files[i];
            const reader = new FileReader();

            reader.onload = function(event) {
                const content = event.target.result;
                try {
                    const subtitles = parseSrt(content);
                    const fileName = file.name.replace('.srt', '');

                    GM_setValue(`subtitle_${fileName}`, JSON.stringify({
                        name: fileName,
                        data: subtitles
                    }));

                    console.log(`字幕上传成功: ${fileName}`);
                    updateSubtitleSelector();
                } catch (error) {
                    console.error('解析字幕文件出错:', file.name, error);
                }
            };

            reader.readAsText(file);
        }

        e.target.value = '';
    }

    function updateSubtitleSelector() {
        const selector = document.getElementById('subtitle-selector');
        if (!selector) return;

        const currentValue = selector.value;
        selector.innerHTML = '<option value="">-- 选择字幕 --</option>';

        const savedKeys = GM_listValues().filter(key => key.startsWith('subtitle_'));

        savedKeys.forEach(key => {
            try {
                const subtitleData = JSON.parse(GM_getValue(key));
                const option = document.createElement('option');
                option.value = subtitleData.name;
                option.textContent = subtitleData.name;
                selector.appendChild(option);
            } catch (error) {
                console.error('加载字幕出错:', key, error);
            }
        });

        if (currentValue && [...selector.options].some(opt => opt.value === currentValue)) {
            selector.value = currentValue;
        }
    }

    function selectSubtitle(e){
        const file=e.target.value;
        if(!file){currentSubtitle=[];isManualSelection=false;return;}
        const data=JSON.parse(GM_getValue(`subtitle_${file}`));
        currentSubtitle=data.data;
        isManualSelection=true;
        document.getElementById('auto-match-info').textContent=`手动选择:${file}`;
    }

    // 修改后的tryAutoMatchSubtitle函数
    function tryAutoMatchSubtitle() {
        if (isManualSelection) {
            console.log('当前为手动选择模式,跳过自动匹配');
            return;
        }

        const videoTitle = getCurrentPartTitle();
        console.log('当前获取的视频标题:', videoTitle); // 调试输出

        if (!videoTitle) {
            console.log('未能获取视频标题');
            return;
        }

        const savedKeys = GM_listValues().filter(k => k.startsWith('subtitle_'));
        console.log('现有字幕文件:', savedKeys.map(k => k.replace('subtitle_', ''))); // 调试输出

        let bestMatch = null, bestScore = 0;
        savedKeys.forEach(key => {
            const name = key.replace('subtitle_', '');
            const score = calculateMatchScore(videoTitle, name);
            console.log(`匹配测试: "${videoTitle}" vs "${name}" => ${score.toFixed(2)}`); // 调试输出

            if (score > bestScore) {
                bestScore = score;
                bestMatch = name;
            }
        });

        if (bestMatch && bestScore > 0.3) {
            console.log(`自动匹配成功: ${bestMatch} (匹配度: ${bestScore.toFixed(2)})`);
            loadSubtitle(bestMatch);
            document.getElementById('auto-match-info').textContent = `自动匹配: ${bestMatch}`;
        } else {
            console.log('没有找到合适的匹配字幕');
            document.getElementById('auto-match-info').textContent = '未找到匹配的字幕';
        }
    }

    // 修改获取当前分P标题函数
    function getCurrentPartTitle() {
        // 获取当前分P索引
        const currentIndex = getCurrentPartIndex();
        if (currentIndex >= 0 && episodeTitles[currentIndex]) {
            return episodeTitles[currentIndex];
        }

        // 回退方案
        return document.querySelector('.part.on a,.cur-list.on a')?.textContent.trim() || document.querySelector('.video-title')?.textContent.trim() || '';
    }


    // 新增加载字幕函数
    function loadSubtitle(name) {
        try {
            const data = JSON.parse(GM_getValue(`subtitle_${name}`));
            currentSubtitle = data.data;
            subtitleIndex = 0;
            const selector = document.getElementById('subtitle-selector');
            if (selector) selector.value = name;
        } catch (e) {
            console.error('加载字幕失败:', e);
        }
    }
    // 修改匹配度计算函数
    function calculateMatchScore(videoTitle, subtitleName) {
        // 提取分P序号 (如 "1.01.目标(P1)" -> "01")
        const partNumMatch = subtitleName.match(/(\d+)\./);
        const currentIndex = getCurrentPartIndex();

        // 如果序号匹配当前分P,直接高分
        if (partNumMatch && currentIndex >= 0) {
            const partNum = parseInt(partNumMatch[1]);
            if (partNum === currentIndex + 1) {
                return 0.8; // 序号匹配基础分
            }
        }

        // 常规关键词匹配
        const cleanVideoTitle = videoTitle.replace(/[^\w\u4e00-\u9fa5]+/g, ' ');
        const cleanSubtitle = subtitleName.replace(/[^\w\u4e00-\u9fa5]+/g, ' ');

        const videoWords = cleanVideoTitle.toLowerCase().split(/\s+/);
        const subWords = cleanSubtitle.toLowerCase().split(/\s+/);

        let matches = 0;
        videoWords.forEach(w => {
            if (subWords.includes(w)) matches++;
        });

        const wordScore = matches / Math.max(1, videoWords.length);
        const lengthScore = 1 - Math.abs(videoTitle.length - subtitleName.length) / Math.max(videoTitle.length, subtitleName.length, 1);

        return wordScore * 0.6 + lengthScore * 0.4;
    }
    // 新增获取当前分P索引函数
    function getCurrentPartIndex() {
        // 方法1:从URL获取
        const urlMatch = window.location.href.match(/[?&]p=(\d+)/);
        if (urlMatch) return parseInt(urlMatch[1]) - 1;

        // 方法2:从激活的分P元素获取
        const activeItem = document.querySelector('.part.on,.cur-list.on,.episode-item.on');
        if (activeItem) {
            const indexAttr = activeItem.getAttribute('data-index') || activeItem.getAttribute('data-episode');
            if (indexAttr) return parseInt(indexAttr);

            // 通过兄弟元素计算位置
            const parent = activeItem.parentElement;
            if (parent) {
                return Array.from(parent.children).indexOf(activeItem);
            }
        }

        return -1;
    }

    function parseSrt(srtText) {
        const lines = srtText.split(/\r?\n/);
        const subtitles = [];
        let currentSubtitle = null;

        for (const line of lines) {
            if (!line.trim()) {
                if (currentSubtitle) {
                    subtitles.push(currentSubtitle);
                    currentSubtitle = null;
                }
                continue;
            }

            if (!currentSubtitle) {
                currentSubtitle = { id: line.trim() };
            } else if (!currentSubtitle.startTime) {
                const timeMatch = line.match(/(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})/);
                if (timeMatch) {
                    currentSubtitle.startTime = timeMatch[1];
                    currentSubtitle.endTime = timeMatch[2];
                }
            } else {
                currentSubtitle.text = currentSubtitle.text ?
                    currentSubtitle.text + '\n' + line : line;
            }
        }

        if (currentSubtitle) {
            subtitles.push(currentSubtitle);
        }

        return subtitles;
    }

    function clearSubtitles() {
        const savedKeys = GM_listValues().filter(key => key.startsWith('subtitle_'));

        savedKeys.forEach(key => {
            GM_deleteValue(key);
        });

        currentSubtitle = [];
        subtitleIndex = 0;
        updateSubtitleDisplay('');
        updateSubtitleSelector();
        console.log('所有字幕已清除');
    }



    function updateSubtitle() {
        if (currentSubtitle.length === 0) {
            updateSubtitleDisplay('');
            return;
        }

        const video = document.querySelector('video');
        if (!video) return;

        const currentTime = video.currentTime + offsetTime;

        while (subtitleIndex > 0 && currentTime < parseTime(currentSubtitle[subtitleIndex].startTime)) {
            subtitleIndex--;
        }

        while (subtitleIndex < currentSubtitle.length - 1 && currentTime > parseTime(currentSubtitle[subtitleIndex + 1].startTime)) {
            subtitleIndex++;
        }

        if (subtitleIndex >= 0 && subtitleIndex < currentSubtitle.length) {
            const subtitle = currentSubtitle[subtitleIndex];
            const startTime = parseTime(subtitle.startTime);
            const endTime = parseTime(subtitle.endTime);

            if (currentTime >= startTime && currentTime <= endTime) {
                updateSubtitleDisplay(subtitle.text);
                return;
            }
        }

        updateSubtitleDisplay('');
    }

    function updateSubtitleDisplay(text) {
        if (!subtitleContainer) return;

        if (text) {
            subtitleContainer.innerHTML = `<div class="custom-subtitle">${text.replace(/\n/g, '<br>')}</div>`;
            const subtitleElement = subtitleContainer.querySelector('.custom-subtitle');
            if (subtitleElement) {
                const size = document.getElementById('subtitle-size').value;
                const opacity = document.getElementById('subtitle-opacity').value / 100;
                subtitleElement.style.fontSize = `${size}px`;
                subtitleElement.style.backgroundColor = `rgba(0, 0, 0, ${opacity})`;
            }
        } else {
            subtitleContainer.innerHTML = '';
        }
    }
    // 修改视频监听函数
    function setupVideoListener() {
        const videoObserver = new MutationObserver((mutations, obs) => {
            const video = document.querySelector('video');
            if (video) {
                // 先移除旧监听器避免重复绑定
                video.removeEventListener('timeupdate', updateSubtitle);
                video.addEventListener('timeupdate', updateSubtitle);

                // 处理分P切换后的视频重载
                video.addEventListener('loadedmetadata', () => {
                    setTimeout(tryAutoMatchSubtitle, 300);
                });

                obs.disconnect();
            }
        });

        videoObserver.observe(document.body, {
            childList: true,
            subtree: true
        });
    }

    function parseTime(timeStr) {
        const parts = timeStr.split(/[:,]/);
        const hh = parseInt(parts[0]) || 0;
        const mm = parseInt(parts[1]) || 0;
        const ss = parseFloat(parts[2]) || 0;
        return hh * 3600 + mm * 60 + ss;
    }

    if (document.readyState === 'complete') {
        init();
    } else {
        window.addEventListener('load', init);
    }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址