mikecoding / 知乎复制公式到word

// ==UserScript==
// @name         知乎复制公式到word
// @namespace    http://*.zhihu.com
// @version      2.0
// @description  一键复制知乎文章或回答为Markdown格式,自动转换LaTeX公式,适配2026年新版知乎。
// @author       mikecoding
// @match        https://*.zhihu.com/*
// @copyright 2021, mikecoding (https://openuserjs.org/users/mikecoding)
// @require      https://code.jquery.com/jquery-3.6.0.min.js
// @require      https://unpkg.com/turndown@7.1.1/dist/turndown.js
// @license MIT
// @grant GM_setClipboard
// ==/UserScript==

(function() {
    'use strict';

    // HTML to Markdown 转换器
    let turndownService = null;

    // 初始化 Turndown
    function initTurndown() {
        if (typeof TurndownService === 'undefined') {
            console.warn('TurndownService 未加载,使用简化版本');
            return null;
        }

        turndownService = new TurndownService({
            headingStyle: 'atx',
            codeBlockStyle: 'fenced',
            emDelimiter: '*',
            strongDelimiter: '**',
            bulletListMarker: '-'
        });

        // 自定义规则:处理知乎公式
        turndownService.addRule('zhihuFormula', {
            filter: function(node) {
                return node.classList && (
                    node.classList.contains('ztext-math') ||
                    node.hasAttribute('data-tex')
                );
            },
            replacement: function(content, node) {
                const tex = node.getAttribute('data-tex');
                if (!tex) return content;

                // 判断是行内还是行间公式
                const isDisplay = tex.includes('\\begin{') ||
                                 tex.includes('aligned') ||
                                 tex.includes('\\\\') ||
                                 tex.length > 60;

                if (isDisplay) {
                    return '\n\n$$\n' + tex + '\n$$\n\n';
                } else {
                    return ' $' + tex + '$ ';
                }
            }
        });

        // 处理图片
        turndownService.addRule('images', {
            filter: 'img',
            replacement: function(content, node) {
                // 如果是公式图片,跳过(已由上面的规则处理)
                if (node.hasAttribute('data-formula') || node.classList.contains('ztext-math')) {
                    const tex = node.getAttribute('data-formula');
                    if (tex) {
                        return ' $' + tex + '$ ';
                    }
                }

                // 提取图片信息
                const alt = node.getAttribute('alt') ||
                           node.getAttribute('data-caption') ||
                           '图片';

                // 优先使用原图URL
                const src = node.getAttribute('data-original') ||
                           node.getAttribute('src') ||
                           node.getAttribute('data-actualsrc') ||
                           '';

                const title = node.getAttribute('title') || '';

                if (!src) return '';

                const titlePart = title ? ' "' + title + '"' : '';
                return '\n\n![' + alt + '](' + src + titlePart + ')\n\n';
            }
        });

        // 处理链接
        turndownService.addRule('links', {
            filter: 'a',
            replacement: function(content, node) {
                const href = node.getAttribute('href') || '';
                const title = node.getAttribute('title') || '';

                if (!href) return content;

                // 处理知乎内部链接
                let fullHref = href;
                if (href.startsWith('//')) {
                    fullHref = 'https:' + href;
                } else if (href.startsWith('/')) {
                    fullHref = 'https://www.zhihu.com' + href;
                }

                const titlePart = title ? ' "' + title + '"' : '';
                return '[' + content + '](' + fullHref + titlePart + ')';
            }
        });

        return turndownService;
    }

    // 修复公式中的转义字符(完全版)
    function unescapeFormulas(markdown) {
        // 处理行间公式 $$...$$
        markdown = markdown.replace(/\$\$([\s\S]*?)\$\$/g, function(match, formula) {
            // 第一步:用占位符保护 \\\\ (四个反斜杠,代表 LaTeX 的 \\)
            let unescaped = formula.replace(/\\\\\\\\/g, '<<<LATEX_NEWLINE>>>');

            // 第二步:替换 \\ (两个反斜杠) 为 \ (一个反斜杠)
            // 这会修复 \\begin -> \begin, \\text -> \text 等
            unescaped = unescaped.replace(/\\\\/g, '\\');

            // 第三步:恢复 LaTeX 换行符
            unescaped = unescaped.replace(/<<<LATEX_NEWLINE>>>/g, '\\\\');

            // 第四步:修复其他转义字符
            unescaped = unescaped
                .replace(/\\_/g, '_')           // 下划线
                .replace(/\\\*/g, '*')          // 星号
                .replace(/\\\[/g, '[')          // 左方括号
                .replace(/\\\]/g, ']')          // 右方括号
                .replace(/\\\{/g, '{')          // 左花括号
                .replace(/\\\}/g, '}')          // 右花括号
                .replace(/\\\(/g, '(')          // 左圆括号
                .replace(/\\\)/g, ')')          // 右圆括号
                .replace(/\\\|/g, '|')          // 竖线
                .replace(/\\>/g, '>')           // 大于号
                .replace(/\\</g, '<')           // 小于号
                .replace(/\\&/g, '&')           // &符号
                .replace(/\\\$/g, '$');         // 美元符号

            return '$$' + unescaped + '$$';
        });

        // 处理行内公式 $...$
        markdown = markdown.replace(/\$([^\$\n]+?)\$/g, function(match, formula) {
            // 行内公式一般不会有 \\ 换行符,直接处理
            let unescaped = formula
                .replace(/\\\\/g, '\\')         // 双反斜杠 -> 单反斜杠
                .replace(/\\_/g, '_')
                .replace(/\\\*/g, '*')
                .replace(/\\\[/g, '[')
                .replace(/\\\]/g, ']')
                .replace(/\\\{/g, '{')
                .replace(/\\\}/g, '}')
                .replace(/\\\(/g, '(')
                .replace(/\\\)/g, ')')
                .replace(/\\\|/g, '|')
                .replace(/\\>/g, '>')
                .replace(/\\</g, '<')
                .replace(/\\&/g, '&');

            return '$' + unescaped + '$';
        });

        return markdown;
    }

    // 简化版 HTML to Markdown 转换(备用)
    function simpleHtmlToMarkdown(html) {
        let markdown = html;

        // 处理图片(在处理其他标签之前)
        markdown = markdown.replace(
            /<img[^>]*>/gi,
            function(match) {
                // 提取属性
                const srcMatch = match.match(/data-original="([^"]*)"|src="([^"]*)"/);
                const altMatch = match.match(/alt="([^"]*)"|data-caption="([^"]*)"/);

                const src = (srcMatch && (srcMatch[1] || srcMatch[2])) || '';
                const alt = (altMatch && (altMatch[1] || altMatch[2])) || '图片';

                if (!src) return '';

                return '\n\n![' + alt + '](' + src + ')\n\n';
            }
        );

        // 处理标题
        markdown = markdown.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '\n# $1\n\n');
        markdown = markdown.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '\n## $1\n\n');
        markdown = markdown.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '\n### $1\n\n');
        markdown = markdown.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '\n#### $1\n\n');

        // 处理段落
        markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');

        // 处理粗体和斜体
        markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
        markdown = markdown.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
        markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
        markdown = markdown.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');

        // 处理链接
        markdown = markdown.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');

        // 处理列表
        markdown = markdown.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n');
        markdown = markdown.replace(/<ul[^>]*>(.*?)<\/ul>/gi, '\n$1\n');
        markdown = markdown.replace(/<ol[^>]*>(.*?)<\/ol>/gi, '\n$1\n');

        // 处理代码
        markdown = markdown.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`');
        markdown = markdown.replace(/<pre[^>]*>(.*?)<\/pre>/gi, '\n```\n$1\n```\n');

        // 处理换行
        markdown = markdown.replace(/<br[^>]*>/gi, '\n');

        // 移除所有剩余的 HTML 标签
        markdown = markdown.replace(/<[^>]+>/g, '');

        // 清理多余的空行
        markdown = markdown.replace(/\n{3,}/g, '\n\n');

        return markdown.trim();
    }

    // 转换知乎公式为 Markdown
    function convertFormulas(element) {
        const clonedElement = element.cloneNode(true);

        // 处理所有公式元素
        clonedElement.querySelectorAll('[data-tex]').forEach(node => {
            const tex = node.getAttribute('data-tex');
            if (!tex) return;

            // 判断是否为行间公式
            const isDisplay = tex.includes('\\begin{') ||
                             tex.includes('aligned') ||
                             tex.includes('\\\\') ||
                             tex.length > 60;

            const markdownFormula = isDisplay ?
                `\n\n$$\n${tex}\n$$\n\n` :
                ` $${tex}$ `;

            const textNode = document.createTextNode(markdownFormula);
            node.parentNode.replaceChild(textNode, node);
        });

        // 处理图片中的公式(备用方法)
        clonedElement.querySelectorAll('img[data-formula]').forEach(node => {
            const tex = node.getAttribute('data-formula');
            if (!tex) return;

            const isDisplay = tex.includes('\\begin{') || tex.length > 60;
            const markdownFormula = isDisplay ?
                `\n\n$$\n${tex}\n$$\n\n` :
                ` $${tex}$ `;

            const textNode = document.createTextNode(markdownFormula);
            node.parentNode.replaceChild(textNode, node);
        });

        return clonedElement;
    }

    // 提取文章内容
    function extractArticleContent() {
        // 尝试找到文章主体
        const contentSelectors = [
            '.Post-RichText',           // 文章
            '.RichText.ztext',          // 回答
            '.Post-Main .RichContent',
            'article .RichText',
            '[itemprop="text"]',
        ];

        let contentElement = null;
        for (const selector of contentSelectors) {
            contentElement = document.querySelector(selector);
            if (contentElement) break;
        }

        if (!contentElement) {
            showToast('未找到文章内容', 'error');
            return null;
        }

        return contentElement;
    }

    // 提取文章标题
    function extractTitle() {
        const titleSelectors = [
            '.Post-Title',
            '.QuestionHeader-title',
            'h1.Post-Title',
            '[itemprop="name"]',
        ];

        for (const selector of titleSelectors) {
            const titleElement = document.querySelector(selector);
            if (titleElement) {
                return titleElement.textContent.trim();
            }
        }

        return '知乎文章';
    }

    // 标记公式(点击复制)
    let isHighlighted = false;
    function highlightFormulas() {
        const formulas = document.querySelectorAll('[data-tex]');

        if (formulas.length === 0) {
            showToast('未找到公式', 'error');
            return;
        }

        if (!isHighlighted) {
            formulas.forEach(formula => {
                formula.style.backgroundColor = '#fff59d';
                formula.style.cursor = 'pointer';
                formula.style.padding = '2px 4px';
                formula.style.borderRadius = '3px';

                formula.onclick = function() {
                    const tex = this.getAttribute('data-tex');
                    if (tex) {
                        const isDisplay = tex.includes('\\begin{') || tex.length > 60;
                        const markdownFormula = isDisplay ?
                            `$$\n${tex}\n$$` :
                            `$${tex}$`;

                        copyToClipboard(markdownFormula);
                        showToast('已复制公式', 'success');
                    }
                };
            });
            isHighlighted = true;
            showToast(`已标记 ${formulas.length} 个公式(点击可复制)`, 'success');
        } else {
            formulas.forEach(formula => {
                formula.style.backgroundColor = '';
                formula.style.cursor = '';
                formula.style.padding = '';
                formula.onclick = null;
            });
            isHighlighted = false;
            showToast('已取消标记', 'success');
        }
    }

    // 复制所有公式
    function copyAllFormulas() {
        const formulas = document.querySelectorAll('[data-tex]');

        if (formulas.length === 0) {
            showToast('未找到公式', 'error');
            return;
        }

        const formulaSet = new Set();
        const formulaList = [];

        formulas.forEach(formula => {
            const tex = formula.getAttribute('data-tex');
            if (tex && !formulaSet.has(tex)) {
                formulaSet.add(tex);

                const isDisplay = tex.includes('\\begin{') ||
                                 tex.includes('aligned') ||
                                 tex.includes('\\\\') ||
                                 tex.length > 60;

                const markdownFormula = isDisplay ?
                    `$$\n${tex}\n$$` :
                    `$${tex}$`;

                formulaList.push(markdownFormula);
            }
        });

        const allFormulas = formulaList.join('\n\n');
        copyToClipboard(allFormulas);
        showToast(`已复制 ${formulaList.length} 个公式`, 'success');
    }

    // 复制为 Markdown
    function copyAsMarkdown() {
        const contentElement = extractArticleContent();
        if (!contentElement) return;

        const title = extractTitle();
        const processedContent = convertFormulas(contentElement);

        let markdown;
        if (turndownService) {
            try {
                markdown = turndownService.turndown(processedContent.innerHTML);
                // 修复公式转义问题(完全版)
                markdown = unescapeFormulas(markdown);
            } catch (e) {
                console.warn('Turndown 转换失败,使用简化版本', e);
                markdown = simpleHtmlToMarkdown(processedContent.innerHTML);
            }
        } else {
            markdown = simpleHtmlToMarkdown(processedContent.innerHTML);
        }

        // 添加标题
        const fullMarkdown = `# ${title}\n\n${markdown}`;

        // 复制到剪贴板
        copyToClipboard(fullMarkdown);
        showToast(`已复制 "${title}" 为 Markdown 格式`, 'success');
    }

    // 复制纯文本(含公式)
    function copyAsText() {
        const contentElement = extractArticleContent();
        if (!contentElement) return;

        const title = extractTitle();
        const processedContent = convertFormulas(contentElement);

        // 提取纯文本
        let text = processedContent.textContent.trim();

        // 清理多余空白
        text = text.replace(/\n{3,}/g, '\n\n');
        text = text.replace(/  +/g, ' ');

        const fullText = `${title}\n\n${'='.repeat(title.length)}\n\n${text}`;

        copyToClipboard(fullText);
        showToast(`已复制 "${title}" 为纯文本格式`, 'success');
    }

    // 复制到剪贴板
    function copyToClipboard(text) {
        if (typeof GM_setClipboard !== 'undefined') {
            GM_setClipboard(text);
        } else if (navigator.clipboard) {
            navigator.clipboard.writeText(text).catch(() => {
                fallbackCopy(text);
            });
        } else {
            fallbackCopy(text);
        }
    }

    // 备用复制方法
    function fallbackCopy(text) {
        const textarea = document.createElement('textarea');
        textarea.value = text;
        textarea.style.position = 'fixed';
        textarea.style.opacity = '0';
        document.body.appendChild(textarea);
        textarea.select();
        document.execCommand('copy');
        document.body.removeChild(textarea);
    }

    // 显示提示
    function showToast(message, type = 'success') {
        const toast = document.createElement('div');
        toast.textContent = message;

        const bgColor = type === 'success' ? '#10B981' : '#EF4444';

        toast.style.cssText = `
            position: fixed;
            top: 20px;
            right: 20px;
            background: ${bgColor};
            color: white;
            padding: 12px 20px;
            border-radius: 8px;
            z-index: 10000;
            font-size: 14px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.15);
            max-width: 400px;
        `;
        document.body.appendChild(toast);

        setTimeout(() => {
            toast.style.opacity = '0';
            toast.style.transition = 'opacity 0.3s';
            setTimeout(() => toast.remove(), 300);
        }, 3000);
    }

    // 添加控制面板
    function addControlPanel() {
        const panel = document.createElement('div');
        panel.id = 'zhihu-markdown-panel';
        panel.style.cssText = `
            position: fixed;
            bottom: 80px;
            right: 20px;
            z-index: 9999;
            display: flex;
            flex-direction: column;
            gap: 10px;
            background: white;
            padding: 15px;
            border-radius: 12px;
            box-shadow: 0 4px 20px rgba(0,0,0,0.15);
        `;

        // 标题
        const panelTitle = document.createElement('div');
        panelTitle.textContent = '📝 知乎转换工具';
        panelTitle.style.cssText = `
            font-weight: bold;
            font-size: 14px;
            color: #333;
            margin-bottom: 5px;
        `;
        panel.appendChild(panelTitle);

        // 文章转换区域
        const articleSection = document.createElement('div');
        articleSection.style.cssText = `
            border-bottom: 1px solid #eee;
            padding-bottom: 10px;
            margin-bottom: 10px;
        `;

        const articleLabel = document.createElement('div');
        articleLabel.textContent = '文章转换';
        articleLabel.style.cssText = `
            font-size: 12px;
            color: #666;
            margin-bottom: 8px;
        `;
        articleSection.appendChild(articleLabel);

        // 复制为 Markdown 按钮
        const markdownBtn = createButton('📋 复制为 Markdown', '#0084ff', copyAsMarkdown);
        articleSection.appendChild(markdownBtn);

        // 复制为纯文本按钮
        const textBtn = createButton('📄 复制为纯文本', '#00a600', copyAsText);
        articleSection.appendChild(textBtn);

        panel.appendChild(articleSection);

        // 公式操作区域
        const formulaSection = document.createElement('div');

        const formulaLabel = document.createElement('div');
        formulaLabel.textContent = '公式操作';
        formulaLabel.style.cssText = `
            font-size: 12px;
            color: #666;
            margin-bottom: 8px;
        `;
        formulaSection.appendChild(formulaLabel);

        // 标记公式按钮
        const highlightBtn = createButton('📐 标记公式', '#ff9800', highlightFormulas);
        formulaSection.appendChild(highlightBtn);

        // 复制所有公式按钮
        const copyAllBtn = createButton('📑 复制所有公式', '#9c27b0', copyAllFormulas);
        formulaSection.appendChild(copyAllBtn);

        panel.appendChild(formulaSection);

        // 折叠/展开按钮
        const toggleBtn = document.createElement('div');
        toggleBtn.textContent = '−';
        toggleBtn.style.cssText = `
            position: absolute;
            top: 5px;
            right: 10px;
            cursor: pointer;
            font-size: 20px;
            color: #666;
            user-select: none;
        `;
        toggleBtn.onclick = function() {
            const isCollapsed = panel.style.height === '40px';
            if (isCollapsed) {
                panel.style.height = 'auto';
                toggleBtn.textContent = '−';
            } else {
                panel.style.height = '40px';
                panel.style.overflow = 'hidden';
                toggleBtn.textContent = '+';
            }
        };
        panel.appendChild(toggleBtn);

        document.body.appendChild(panel);
    }

    // 创建按钮
    function createButton(text, color, onClick) {
        const btn = document.createElement('button');
        btn.textContent = text;
        btn.style.cssText = `
            background: ${color};
            color: white;
            border: none;
            padding: 8px 12px;
            margin-top: 5px;
            border-radius: 6px;
            cursor: pointer;
            font-size: 13px;
            font-weight: 500;
            transition: all 0.2s;
            white-space: nowrap;
            width: 100%;
        `;

        btn.onmouseover = function() {
            btn.style.opacity = '0.9';
            btn.style.transform = 'translateY(-1px)';
        };

        btn.onmouseout = function() {
            btn.style.opacity = '1';
            btn.style.transform = 'translateY(0)';
        };

        btn.onclick = onClick;

        return btn;
    }

    // 初始化
    function init() {
        // 延迟执行,确保页面加载完成
        setTimeout(() => {
            initTurndown();
            addControlPanel();
            console.log('知乎文章/回答转Markdown工具已加载(v4.0.2 完全修复版)');
        }, 1500);
    }

    // 页面加载完成后执行
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();