Greasy Fork

Greasy Fork is available in English.

腾讯会议转写纪要导出神器 (Tencent Meeting Transcript Exporter)

一键导出腾讯会议录制视频的转写内容和纪要,支持Markdown、HTML、TXT格式导出和复制

// ==UserScript==
// @name         腾讯会议转写纪要导出神器 (Tencent Meeting Transcript Exporter)
// @namespace    https://github.com/awesome-tampermonkey
// @version      1.0.0
// @description  一键导出腾讯会议录制视频的转写内容和纪要,支持Markdown、HTML、TXT格式导出和复制
// @author       东哥说AI
// @match        https://meeting.tencent.com/cw/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // 配置常量
    const CONFIG = {
        BUTTON_STYLE: `
            position: fixed;
            top: 20px;
            right: 20px;
            z-index: 10000;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            border: none;
            border-radius: 8px;
            padding: 12px 20px;
            font-size: 14px;
            font-weight: 600;
            cursor: pointer;
            box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
            transition: all 0.3s ease;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        `,
        MODAL_STYLE: `
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0, 0, 0, 0.7);
            z-index: 10001;
            display: flex;
            justify-content: center;
            align-items: center;
        `,
        MODAL_CONTENT_STYLE: `
            background: white;
            border-radius: 12px;
            padding: 30px;
            max-width: 500px;
            width: 90%;
            box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        `
    };

    // 工具函数
    const utils = {
        // 获取会议标题
        getMeetingTitle() {
            const titleElement = document.querySelector('.meeting-main-subject .subject') || 
                               document.querySelector('.meeting-subject') ||
                               document.querySelector('.meeting-title');
            return titleElement ? titleElement.textContent.trim() : '腾讯会议转写';
        },
        
        // 获取录制时间
        getRecordingTime() {
            const timeElement = document.querySelector('.meeting-begin-time-in-date');
            if (timeElement) {
                const timeText = timeElement.textContent.trim();
                // 格式:2025/05/21 21:35:16
                return timeText;
            }
            return new Date().toLocaleString('zh-CN');
        },
        
        // 获取会议关键词/标签
        getMeetingKeywords() {
            const keywords = [];
            const topicElements = document.querySelectorAll('.topicTag .topicText');
            topicElements.forEach(element => {
                const keyword = element.textContent.trim();
                if (keyword) {
                    keywords.push(keyword);
                }
            });
            return keywords;
        },

        // 获取当前激活的Tab类型
        getCurrentTabType() {
            const activeTab = document.querySelector('.met-tabs__tabitem.is-active .tab');
            if (!activeTab) return 'transcript';
            
            const tabText = activeTab.textContent.trim();
            if (tabText.includes('转写')) return 'transcript';
            if (tabText.includes('纪要')) return 'summary';
            return 'transcript';
        },

        // 获取转写内容(支持虚拟滚动)
        async getTranscriptContent() {
            // 查找虚拟滚动容器
            const scrollContainer = document.querySelector('.auto-meeting-minutes .minutes-module-list');
            if (!scrollContainer) {
                // 降级到原始方法
                return this.getTranscriptContentFallback();
            }
            
            const originalScrollTop = scrollContainer.scrollTop;
            let lastScrollTop = -1;
            let allContent = new Map(); // 使用Map避免重复
            
            try {
                // 滚动到顶部开始
                scrollContainer.scrollTop = 0;
                await this.sleep(200);
                
                // 持续滚动直到底部,收集所有内容
                while (scrollContainer.scrollTop !== lastScrollTop) {
                    lastScrollTop = scrollContainer.scrollTop;
                    
                    // 收集当前可见的转写内容
                    const currentElements = scrollContainer.querySelectorAll('.paragraph-module_detail-page-style__Lhz8l');
                    currentElements.forEach(element => {
                        const pidAttr = element.getAttribute('data-pid');
                        if (pidAttr && !allContent.has(pidAttr)) {
                            const timeElement = element.querySelector('.paragraph-module_p-start-time__QAWWl');
                            const speakerElement = element.querySelector('.paragraph-module_speaker-name__afSbd');
                            const textElement = element.querySelector('.paragraph-module_sentences__zK2oL');
                            
                            if (textElement && textElement.textContent.trim()) {
                                const time = timeElement ? timeElement.textContent.trim() : '';
                                const speaker = speakerElement ? speakerElement.textContent.trim() : '未知发言人';
                                const text = textElement.textContent.trim();
                                
                                allContent.set(pidAttr, {
                                    pid: parseInt(pidAttr),
                                    time,
                                    speaker,
                                    text
                                });
                            }
                        }
                    });
                    
                    // 向下滚动一屏
                    scrollContainer.scrollTop += scrollContainer.clientHeight;
                    await this.sleep(100); // 等待内容加载
                }
                
                // 恢复原始滚动位置
                scrollContainer.scrollTop = originalScrollTop;
                
                // 按pid排序并返回
                return Array.from(allContent.values()).sort((a, b) => a.pid - b.pid);
                
            } catch (error) {
                console.error('获取转写内容时出错:', error);
                // 恢复滚动位置
                scrollContainer.scrollTop = originalScrollTop;
                // 降级到原始方法
                return this.getTranscriptContentFallback();
            }
        },
        
        // 降级方法:获取当前可见的转写内容
        getTranscriptContentFallback() {
            const transcriptElements = document.querySelectorAll('.auto-meeting-minutes .paragraph-module_detail-page-style__Lhz8l');
            let content = [];
            
            transcriptElements.forEach(element => {
                const timeElement = element.querySelector('.paragraph-module_p-start-time__QAWWl');
                const speakerElement = element.querySelector('.paragraph-module_speaker-name__afSbd');
                const textElement = element.querySelector('.paragraph-module_sentences__zK2oL');
                
                if (textElement && textElement.textContent.trim()) {
                    const time = timeElement ? timeElement.textContent.trim() : '';
                    const speaker = speakerElement ? speakerElement.textContent.trim() : '未知发言人';
                    const text = textElement.textContent.trim();
                    
                    content.push({
                        time,
                        speaker,
                        text
                    });
                }
            });
            
            return content;
        },
        
        // 延时函数
        sleep(ms) {
            return new Promise(resolve => setTimeout(resolve, ms));
        },

        // 获取纪要内容
        getSummaryContent() {
            const summaryContainer = document.querySelector('.summary-content-wrap');
            if (!summaryContainer) return null;
            
            const summaryElements = summaryContainer.querySelectorAll('h4, p, li, div[contenteditable="true"]');
            let content = [];
            
            summaryElements.forEach(element => {
                const text = element.textContent.trim();
                if (text && text.length > 0) {
                    const tagName = element.tagName.toLowerCase();
                    content.push({
                        type: tagName,
                        text: text
                    });
                }
            });
            
            return content;
        },

        // 格式化为Markdown
        formatAsMarkdown(data, type) {
            const title = this.getMeetingTitle();
            const recordingTime = this.getRecordingTime();
            const keywords = this.getMeetingKeywords();
            const exportTime = new Date().toLocaleString('zh-CN');
            
            let markdown = `# ${title}\n\n`;
            markdown += `**录制时间**: ${recordingTime}\n`;
            markdown += `**导出时间**: ${exportTime}\n`;
            markdown += `**内容类型**: ${type === 'transcript' ? '转写内容' : '会议纪要'}\n`;
            
            // 添加关键词标签
            if (keywords.length > 0) {
                markdown += `**会议关键词**: ${keywords.join('、')}\n`;
            }
            markdown += `\n`;
            
            if (type === 'transcript' && Array.isArray(data)) {
                markdown += `## 转写内容\n\n`;
                data.forEach(item => {
                    markdown += `### ${item.speaker}${item.time ? ` (${item.time})` : ''}\n\n`;
                    markdown += `${item.text}\n\n`;
                });
            } else if (type === 'summary' && Array.isArray(data)) {
                markdown += `## 会议纪要\n\n`;
                data.forEach(item => {
                    if (item.type === 'h4') {
                        markdown += `### ${item.text}\n\n`;
                    } else if (item.type === 'li') {
                        markdown += `- ${item.text}\n`;
                    } else {
                        markdown += `${item.text}\n\n`;
                    }
                });
            }
            
            return markdown;
        },

        // 格式化为HTML
        formatAsHTML(data, type) {
            const title = this.getMeetingTitle();
            const recordingTime = this.getRecordingTime();
            const keywords = this.getMeetingKeywords();
            const exportTime = new Date().toLocaleString('zh-CN');
            
            let html = `<!DOCTYPE html>\n<html lang="zh-CN">\n<head>\n`;
            html += `    <meta charset="UTF-8">\n`;
            html += `    <meta name="viewport" content="width=device-width, initial-scale=1.0">\n`;
            html += `    <title>${title}</title>\n`;
            html += `    <style>\n`;
            html += `        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; }\n`;
            html += `        .header { border-bottom: 2px solid #eee; padding-bottom: 20px; margin-bottom: 30px; }\n`;
            html += `        .transcript-item { margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px; }\n`;
            html += `        .speaker { font-weight: bold; color: #2c3e50; }\n`;
            html += `        .time { color: #7f8c8d; font-size: 0.9em; }\n`;
            html += `        .content { margin-top: 8px; }\n`;
            html += `        .keywords { margin: 10px 0; }\n`;
            html += `        .keyword-tag { display: inline-block; background: #e3f2fd; color: #1976d2; padding: 4px 8px; margin: 2px; border-radius: 4px; font-size: 0.9em; }\n`;
            html += `    </style>\n</head>\n<body>\n`;
            
            html += `    <div class="header">\n`;
            html += `        <h1>${title}</h1>\n`;
            html += `        <p><strong>录制时间</strong>: ${recordingTime}</p>\n`;
            html += `        <p><strong>导出时间</strong>: ${exportTime}</p>\n`;
            html += `        <p><strong>内容类型</strong>: ${type === 'transcript' ? '转写内容' : '会议纪要'}</p>\n`;
            if (keywords.length > 0) {
                html += `        <div class="keywords">\n`;
                html += `            <strong>会议关键词</strong>: `;
                keywords.forEach(keyword => {
                    html += `<span class="keyword-tag">${keyword}</span>`;
                });
                html += `\n        </div>\n`;
            }
            html += `    </div>\n`;
            
            if (type === 'transcript' && Array.isArray(data)) {
                html += `    <h2>转写内容</h2>\n`;
                data.forEach(item => {
                    html += `    <div class="transcript-item">\n`;
                    html += `        <div class="speaker">${item.speaker}${item.time ? ` <span class="time">(${item.time})</span>` : ''}</div>\n`;
                    html += `        <div class="content">${item.text}</div>\n`;
                    html += `    </div>\n`;
                });
            } else if (type === 'summary' && Array.isArray(data)) {
                html += `    <h2>会议纪要</h2>\n`;
                data.forEach(item => {
                    if (item.type === 'h4') {
                        html += `    <h3>${item.text}</h3>\n`;
                    } else if (item.type === 'li') {
                        html += `    <li>${item.text}</li>\n`;
                    } else {
                        html += `    <p>${item.text}</p>\n`;
                    }
                });
            }
            
            html += `</body>\n</html>`;
            return html;
        },

        // 格式化为TXT
        formatAsTXT(data, type) {
            const title = this.getMeetingTitle();
            const recordingTime = this.getRecordingTime();
            const keywords = this.getMeetingKeywords();
            const exportTime = new Date().toLocaleString('zh-CN');
            
            let txt = `${title}\n`;
            txt += `${'='.repeat(title.length)}\n\n`;
            txt += `录制时间: ${recordingTime}\n`;
            txt += `导出时间: ${exportTime}\n`;
            txt += `内容类型: ${type === 'transcript' ? '转写内容' : '会议纪要'}\n`;
            
            // 添加关键词标签
            if (keywords.length > 0) {
                txt += `会议关键词: ${keywords.join('、')}\n`;
            }
            txt += `\n`;
            
            if (type === 'transcript' && Array.isArray(data)) {
                txt += `转写内容\n${'-'.repeat(10)}\n\n`;
                data.forEach(item => {
                    txt += `${item.speaker}${item.time ? ` (${item.time})` : ''}\n`;
                    txt += `${item.text}\n\n`;
                });
            } else if (type === 'summary' && Array.isArray(data)) {
                txt += `会议纪要\n${'-'.repeat(10)}\n\n`;
                data.forEach(item => {
                    if (item.type === 'h4') {
                        txt += `\n${item.text}\n${'-'.repeat(item.text.length)}\n`;
                    } else if (item.type === 'li') {
                        txt += `• ${item.text}\n`;
                    } else {
                        txt += `${item.text}\n\n`;
                    }
                });
            }
            
            return txt;
        },

        // 下载文件
        downloadFile(content, filename, type) {
            const mimeTypes = {
                'md': 'text/markdown',
                'html': 'text/html',
                'txt': 'text/plain'
            };
            
            const blob = new Blob([content], { type: mimeTypes[type] || 'text/plain' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
        },

        // 复制到剪贴板
        async copyToClipboard(text) {
            try {
                await navigator.clipboard.writeText(text);
                return true;
            } catch (err) {
                // 降级方案
                const textArea = document.createElement('textarea');
                textArea.value = text;
                document.body.appendChild(textArea);
                textArea.select();
                const success = document.execCommand('copy');
                document.body.removeChild(textArea);
                return success;
            }
        },

        // 显示提示消息
        showMessage(message, type = 'success') {
            const messageDiv = document.createElement('div');
            const colors = {
                'success': '#10b981',
                'error': '#ef4444',
                'info': '#3b82f6'
            };
            messageDiv.style.cssText = `
                position: fixed;
                top: 80px;
                right: 20px;
                z-index: 10002;
                padding: 12px 20px;
                border-radius: 6px;
                color: white;
                font-weight: 600;
                font-size: 14px;
                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
                box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
                transition: all 0.3s ease;
                background: ${colors[type] || colors.success};
            `;
            messageDiv.textContent = message;
            document.body.appendChild(messageDiv);
            
            setTimeout(() => {
                messageDiv.style.opacity = '0';
                messageDiv.style.transform = 'translateX(100%)';
                setTimeout(() => {
                    if (messageDiv.parentNode) {
                        messageDiv.parentNode.removeChild(messageDiv);
                    }
                }, 300);
            }, 3000);
        }
    };

    // 主要功能类
    class TencentMeetingExporter {
        constructor() {
            this.init();
        }

        init() {
            this.createExportButton();
        }

        createExportButton() {
            const button = document.createElement('button');
            button.textContent = '📝 导出转写/纪要';
            button.style.cssText = CONFIG.BUTTON_STYLE;
            
            button.addEventListener('mouseenter', () => {
                button.style.transform = 'translateY(-2px)';
                button.style.boxShadow = '0 6px 20px rgba(102, 126, 234, 0.6)';
            });
            
            button.addEventListener('mouseleave', () => {
                button.style.transform = 'translateY(0)';
                button.style.boxShadow = '0 4px 15px rgba(102, 126, 234, 0.4)';
            });
            
            button.addEventListener('click', () => {
                this.showExportModal();
            });
            
            document.body.appendChild(button);
        }

        showExportModal() {
            const modal = document.createElement('div');
            modal.style.cssText = CONFIG.MODAL_STYLE;
            
            const modalContent = document.createElement('div');
            modalContent.style.cssText = CONFIG.MODAL_CONTENT_STYLE;
            
            const currentTab = utils.getCurrentTabType();
            const tabName = currentTab === 'transcript' ? '转写内容' : '会议纪要';
            
            modalContent.innerHTML = `
                <h2 style="margin: 0 0 20px 0; color: #2c3e50; font-size: 24px;">导出${tabName}</h2>
                <p style="margin: 0 0 25px 0; color: #7f8c8d; line-height: 1.5;">选择导出格式,文件名将自动使用会议标题</p>
                
                <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 15px; margin-bottom: 25px;">
                    <button id="export-md" style="padding: 15px; border: 2px solid #3498db; background: white; color: #3498db; border-radius: 8px; cursor: pointer; font-weight: 600; transition: all 0.3s ease;">📄 Markdown</button>
                    <button id="export-html" style="padding: 15px; border: 2px solid #e74c3c; background: white; color: #e74c3c; border-radius: 8px; cursor: pointer; font-weight: 600; transition: all 0.3s ease;">🌐 HTML</button>
                    <button id="export-txt" style="padding: 15px; border: 2px solid #f39c12; background: white; color: #f39c12; border-radius: 8px; cursor: pointer; font-weight: 600; transition: all 0.3s ease;">📝 TXT</button>
                    <button id="copy-md" style="padding: 15px; border: 2px solid #9b59b6; background: white; color: #9b59b6; border-radius: 8px; cursor: pointer; font-weight: 600; transition: all 0.3s ease;">📋 复制Markdown</button>
                </div>
                
                <div style="display: flex; justify-content: flex-end; gap: 10px;">
                    <button id="modal-close" style="padding: 10px 20px; border: 1px solid #bdc3c7; background: white; color: #7f8c8d; border-radius: 6px; cursor: pointer;">取消</button>
                </div>
            `;
            
            // 添加按钮悬停效果
            const buttons = modalContent.querySelectorAll('button[id^="export-"], button[id^="copy-"]');
            buttons.forEach(btn => {
                btn.addEventListener('mouseenter', () => {
                    const color = btn.style.color;
                    btn.style.background = color;
                    btn.style.color = 'white';
                });
                btn.addEventListener('mouseleave', () => {
                    const borderColor = btn.style.borderColor;
                    btn.style.background = 'white';
                    btn.style.color = borderColor;
                });
            });
            
            // 绑定事件
            modalContent.querySelector('#export-md').addEventListener('click', () => this.exportContent('md'));
            modalContent.querySelector('#export-html').addEventListener('click', () => this.exportContent('html'));
            modalContent.querySelector('#export-txt').addEventListener('click', () => this.exportContent('txt'));
            modalContent.querySelector('#copy-md').addEventListener('click', () => this.copyContent());
            modalContent.querySelector('#modal-close').addEventListener('click', () => this.closeModal(modal));
            
            modal.addEventListener('click', (e) => {
                if (e.target === modal) {
                    this.closeModal(modal);
                }
            });
            
            modal.appendChild(modalContent);
            document.body.appendChild(modal);
        }

        async exportContent(format) {
            try {
                const currentTab = utils.getCurrentTabType();
                let data, content, filename;
                
                if (currentTab === 'transcript') {
                    // 显示加载提示
                    utils.showMessage('正在获取转写内容,请稍候...', 'info');
                    
                    data = await utils.getTranscriptContent();
                    if (!data || data.length === 0) {
                        utils.showMessage('未找到转写内容,请确保页面已加载完成', 'error');
                        return;
                    }
                    
                    // 显示成功提示
                    utils.showMessage(`获取成功!共找到 ${data.length} 条转写记录`, 'success');
                } else {
                    data = utils.getSummaryContent();
                    if (!data || data.length === 0) {
                        utils.showMessage('未找到纪要内容,请确保页面已加载完成', 'error');
                        return;
                    }
                }
                
                const title = utils.getMeetingTitle();
                const recordingTime = utils.getRecordingTime();
                
                // 使用录制时间生成时间戳,如果获取不到则使用当前时间
                let timestamp;
                if (recordingTime) {
                    // 将录制时间格式从 "2025/05/21 21:35:16" 转换为 "20250521_213516"
                    timestamp = recordingTime.replace(/[\s\/\:]/g, '').replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1$2$3_$4$5$6');
                } else {
                    // 降级到使用当前时间
                    const now = new Date();
                    timestamp = now.getFullYear().toString() + 
                        (now.getMonth() + 1).toString().padStart(2, '0') + 
                        now.getDate().toString().padStart(2, '0') + '_' +
                        now.getHours().toString().padStart(2, '0') + 
                        now.getMinutes().toString().padStart(2, '0') + 
                        now.getSeconds().toString().padStart(2, '0');
                }
                
                const tabName = currentTab === 'transcript' ? '转写' : '纪要';
                filename = `${title}_${tabName}_${timestamp}.${format}`;
                
                switch (format) {
                    case 'md':
                        content = utils.formatAsMarkdown(data, currentTab);
                        break;
                    case 'html':
                        content = utils.formatAsHTML(data, currentTab);
                        break;
                    case 'txt':
                        content = utils.formatAsTXT(data, currentTab);
                        break;
                }
                
                utils.downloadFile(content, filename, format);
                utils.showMessage(`${format.toUpperCase()}文件导出成功!`);
                
                // 关闭模态框
                const modal = document.querySelector('div[style*="position: fixed"][style*="z-index: 10001"]');
                if (modal) this.closeModal(modal);
                
            } catch (error) {
                console.error('导出失败:', error);
                utils.showMessage('导出失败,请重试', 'error');
            }
        }

        async copyContent() {
            try {
                const currentTab = utils.getCurrentTabType();
                let data;
                
                if (currentTab === 'transcript') {
                    // 显示加载提示
                    utils.showMessage('正在获取转写内容,请稍候...', 'info');
                    
                    data = await utils.getTranscriptContent();
                    if (!data || data.length === 0) {
                        utils.showMessage('未找到转写内容,请确保页面已加载完成', 'error');
                        return;
                    }
                    
                    // 显示成功提示
                    utils.showMessage(`获取成功!共找到 ${data.length} 条转写记录`, 'success');
                } else {
                    data = utils.getSummaryContent();
                    if (!data || data.length === 0) {
                        utils.showMessage('未找到纪要内容,请确保页面已加载完成', 'error');
                        return;
                    }
                }
                
                const content = utils.formatAsMarkdown(data, currentTab);
                const success = await utils.copyToClipboard(content);
                
                if (success) {
                    utils.showMessage('Markdown内容已复制到剪贴板!');
                } else {
                    utils.showMessage('复制失败,请重试', 'error');
                }
                
                // 关闭模态框
                const modal = document.querySelector('div[style*="position: fixed"][style*="z-index: 10001"]');
                if (modal) this.closeModal(modal);
                
            } catch (error) {
                console.error('复制失败:', error);
                utils.showMessage('复制失败,请重试', 'error');
            }
        }

        closeModal(modal) {
            modal.style.opacity = '0';
            setTimeout(() => {
                if (modal.parentNode) {
                    modal.parentNode.removeChild(modal);
                }
            }, 300);
        }
    }

    // 等待页面加载完成后初始化
    function init() {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', () => {
                setTimeout(() => new TencentMeetingExporter(), 2000);
            });
        } else {
            setTimeout(() => new TencentMeetingExporter(), 2000);
        }
    }

    init();

})();