腾讯会议字幕导出

修改流程,读取字幕后直接选择格式导出,无需确认,并可重复导出不同格式。

// ==UserScript==
// @name         腾讯会议字幕导出
// @namespace    http://tampermonkey.net/
// @version      5.0
// @description  修改流程,读取字幕后直接选择格式导出,无需确认,并可重复导出不同格式。
// @match        https://meeting.tencent.com/cw/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // 填入字幕列表容器的 CSS 选择器
    const YOUR_SELECTOR = '.minutes-module-list';

    let subtitlesData = []; // 用于存储读取后的字幕数据
    let uiInjected = false;
    let appContext = null;
    const ui = {};

    // 重置UI到初始状态
    function resetUI() {
        if (!ui.readButton) return;
        ui.readButton.style.display = 'block';
        ui.readButton.disabled = false;
        ui.readButton.textContent = '读取字幕';

        ui.exportSrtButton.style.display = 'none';
        ui.exportTxtButton.style.display = 'none';
        ui.progressBarContainer.style.display = 'none';

        subtitlesData = [];
    }

    // 注入导出控件
    function initializeUI(doc) {
        appContext = doc;
        const controlsContainer = doc.createElement('div');
        controlsContainer.id = 'srt-exporter-container';
        Object.assign(controlsContainer.style, {
            position: 'fixed', top: '120px', right: '20px', zIndex: '99999',
            backgroundColor: 'white', border: '1px solid #ccc', borderRadius: '8px',
            padding: '12px', boxShadow: '0 4px 8px rgba(0,0,0,0.15)',
            fontFamily: 'sans-serif', width: '160px', textAlign: 'center'
        });

        // 读取/重新读取字幕按钮
        ui.readButton = doc.createElement('button');
        ui.readButton.textContent = '读取字幕';
        Object.assign(ui.readButton.style, {
            display: 'block', width: '100%', padding: '10px',
            backgroundColor: '#007bff', color: 'white',
            borderRadius: '5px', cursor: 'pointer', fontSize: '14px', border: 'none',
            marginBottom: '8px'
        });
        ui.readButton.onclick = startReadingSubtitles;

        // 导出SRT按钮
        ui.exportSrtButton = doc.createElement('button');
        ui.exportSrtButton.textContent = '导出SRT字幕';
        Object.assign(ui.exportSrtButton.style, {
            display: 'none', width: '100%', padding: '10px', // Initially hidden
            backgroundColor: '#007bff', color: 'white',
            borderRadius: '5px', cursor: 'pointer', fontSize: '14px', border: 'none',
            marginBottom: '8px'
        });
        ui.exportSrtButton.onclick = () => exportAndDownload('srt');

        // 导出TXT按钮
        ui.exportTxtButton = doc.createElement('button');
        ui.exportTxtButton.textContent = '导出TXT文本';
        Object.assign(ui.exportTxtButton.style, {
            display: 'none', width: '100%', padding: '10px', // Initially hidden
            backgroundColor: '#17a2b8', color: 'white',
            borderRadius: '5px', cursor: 'pointer', fontSize: '14px', border: 'none'
        });
        ui.exportTxtButton.onclick = () => exportAndDownload('txt');

        // 进度条
        ui.progressBarContainer = doc.createElement('div');
        ui.progressBarContainer.style.display = 'none';
        ui.progressBarContainer.style.marginTop = '10px';
        const progressBar = doc.createElement('div');
        Object.assign(progressBar.style, {
            width: '100%', height: '8px', backgroundColor: '#e9ecef',
            borderRadius: '4px', overflow: 'hidden'
        });
        ui.progressFill = doc.createElement('div');
        Object.assign(ui.progressFill.style, {
            width: '0%', height: '100%', backgroundColor: '#4caf50',
            transition: 'width 0.2s ease-in-out'
        });
        ui.progressText = doc.createElement('div');
        ui.progressText.textContent = '加载中...';
        Object.assign(ui.progressText.style, {
            fontSize: '12px', color: '#6c757d', marginTop: '5px'
        });
        progressBar.appendChild(ui.progressFill);
        ui.progressBarContainer.appendChild(ui.progressText);
        ui.progressBarContainer.appendChild(progressBar);

        controlsContainer.appendChild(ui.readButton);
        controlsContainer.appendChild(ui.exportSrtButton);
        controlsContainer.appendChild(ui.exportTxtButton);
        controlsContainer.appendChild(ui.progressBarContainer);
        doc.body.appendChild(controlsContainer);
        console.log('字幕导出控件已注入');
    }

    // 毫秒 → SRT 时间格式
    function formatSRTTime(ms) {
        const date = new Date(ms);
        return `${date.getUTCHours().toString().padStart(2, '0')}:` +
               `${date.getUTCMinutes().toString().padStart(2, '0')}:` +
               `${date.getUTCSeconds().toString().padStart(2, '0')},` +
               `${date.getUTCMilliseconds().toString().padStart(3, '0')}`;
    }

    // 解析字幕时间段
    function parseTimeOffset(timeOffset) {
        const [startMs, endMs] = timeOffset.split('-').map(t => parseInt(t));
        return { start: formatSRTTime(startMs), end: formatSRTTime(endMs) };
    }

    // 生成内容并直接触发下载
    function exportAndDownload(format) {
        if (subtitlesData.length === 0) {
            alert('没有已读取的字幕数据,请先读取字幕。');
            return;
        }

        let generatedContent = '';
        if (format === 'srt') {
            let seq = 1;
            subtitlesData.forEach(s => {
                if (s.timeOffset && s.text.trim()) {
                    try {
                        const { start, end } = parseTimeOffset(s.timeOffset);
                        generatedContent += `${seq++}\n${start} --> ${end}\n${s.text.trim()}\n\n`;
                    } catch (e) {
                        console.warn(`解析时间失败: ${s.timeOffset}`, e);
                    }
                }
            });
        } else if (format === 'txt') {
             subtitlesData.forEach(s => {
                if (s.text.trim()) {
                   generatedContent += `${s.text.trim()}\n`;
                }
            });
        }

        if (!generatedContent.trim()) {
            alert('未能生成任何内容,请检查字幕数据');
            return;
        }
        
        downloadFile(format, generatedContent);
    }

    // 开始读取字幕 (主函数)
    function startReadingSubtitles() {
        if (!appContext) return alert('脚本上下文丢失,请刷新页面!');

        subtitlesData = []; // 清空旧数据
        ui.readButton.disabled = true;
        ui.readButton.textContent = '正在读取...';
        ui.progressBarContainer.style.display = 'block';
        ui.exportSrtButton.style.display = 'none';
        ui.exportTxtButton.style.display = 'none';
        ui.progressFill.style.width = '0%';
        ui.progressText.textContent = '加载进度: 0% (已读取 0 条)';

        const container = appContext.querySelector(YOUR_SELECTOR);
        if (!container) {
            alert('错误:未找到字幕容器');
            resetUI();
            return;
        }

        let scriptIsActive = true;
        let maxScrollTop = 0;
        const scrollGuard = () => {
            if (!scriptIsActive) return;
            if (container.scrollTop > maxScrollTop) {
                maxScrollTop = container.scrollTop;
            } else if (container.scrollTop < maxScrollTop) {
                container.scrollTop = maxScrollTop;
            }
        };
        container.addEventListener('scroll', scrollGuard);

        const allSubtitles = new Map();
        let lastScrollTop = -1;

        const processAndFinalize = () => {
            scriptIsActive = false;
            container.removeEventListener('scroll', scrollGuard);

            subtitlesData = Array.from(allSubtitles.values())
                .sort((a, b) => a.pid - b.pid || a.sid - b.sid);

            if (subtitlesData.length === 0) {
                alert('未能读取到任何字幕内容');
                resetUI();
                return;
            }

            // 读取完成,显示导出选项,并允许重新读取
            ui.progressBarContainer.style.display = 'none';
            ui.exportSrtButton.style.display = 'block';
            ui.exportTxtButton.style.display = 'block';
            ui.readButton.textContent = '重新读取字幕';
            ui.readButton.disabled = false;
            ui.readButton.style.display = 'block';
        };

        const loadContent = () => {
            const totalHeight = container.scrollHeight;
            const viewportHeight = container.clientHeight;

            appContext.querySelectorAll('[data-pid]').forEach(paragraph => {
                const pid = paragraph.getAttribute('data-pid');
                if (!pid) return;
                paragraph.querySelectorAll('[data-sid]').forEach(sentence => {
                    const sid = sentence.getAttribute('data-sid');
                    const timeOffset = sentence.getAttribute('data-time-offset');
                    if (sid && timeOffset) {
                        const words = sentence.querySelectorAll('[class*="word-module_word"]');
                        const text = Array.from(words).map(w => w.textContent.trim()).join('');
                        if (text) {
                            const key = `${pid}-${sid}`;
                            if (!allSubtitles.has(key)) {
                                allSubtitles.set(key, { pid: +pid, sid: +sid, text, timeOffset });
                            }
                        }
                    }
                });
            });

            const progress = Math.min(((container.scrollTop + viewportHeight) / totalHeight) * 100, 100);
            ui.progressFill.style.width = `${progress.toFixed(1)}%`;
            ui.progressText.textContent = `加载进度: ${progress.toFixed(1)}% (已读取 ${allSubtitles.size} 条)`;

            if ((container.scrollTop + viewportHeight + 5) >= totalHeight || container.scrollTop === lastScrollTop) {
                console.log('滚动完成。');
                processAndFinalize();
            } else {
                lastScrollTop = container.scrollTop;
                container.scrollTop += viewportHeight;
                setTimeout(loadContent, 800);
            }
        };

        console.log('正在重置滚动条到顶部...');
        container.scrollTop = 0;
        maxScrollTop = 0;
        setTimeout(() => {
            console.log(`滚动条已重置, 当前位置: ${container.scrollTop}px. 开始加载字幕.`);
            maxScrollTop = container.scrollTop;
            loadContent();
        }, 500);
    }

    // 下载字幕文件 (不再重置UI)
    function downloadFile(format, content) {
        if (!content) return alert('没有可下载的内容');
        const blob = new Blob(['\uFEFF' + content], { type: 'text/plain;charset=utf-8' });
        const url = URL.createObjectURL(blob);
        const a = appContext.createElement('a');
        a.href = url;
        const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, '');
        a.download = `腾讯会议字幕_${timestamp}.${format}`;
        appContext.body.appendChild(a);
        a.click();
        appContext.body.removeChild(a);
        URL.revokeObjectURL(url);
        console.log(`${format.toUpperCase()} 文件已导出。`);
    }

    // 检测字幕容器并注入UI
    function findTargetAndInject() {
        if (uiInjected || !YOUR_SELECTOR) return;
        let targetDoc = null;
        if (document.querySelector(YOUR_SELECTOR)) {
            targetDoc = document;
        } else {
            for (const iframe of document.querySelectorAll('iframe')) {
                try {
                    const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
                    if (iframeDoc && iframeDoc.querySelector(YOUR_SELECTOR)) {
                        targetDoc = iframeDoc;
                        break;
                    }
                } catch (e) { /* 忽略跨域iframe错误 */ }
            }
        }
        if (targetDoc) {
            uiInjected = true;
            observer.disconnect();
            initializeUI(targetDoc);
        }
    }

    const observer = new MutationObserver(findTargetAndInject);
    observer.observe(document.body, { childList: true, subtree: true });
    findTargetAndInject();
})();

QingJ © 2025

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