有道云笔记导出为markdown

在有道云笔记分享页面添加一个按钮,用于将笔记内容导出为Markdown文件。智能识别富文本和Markdown两种页面类型并进行相应处理。

// ==UserScript==
// @name         有道云笔记导出为markdown
// @name:en      Youdao Note Exporter to Markdown
// @namespace    https://github.com/your-username/youdao-note-exporter
// @version      3.0
// @description  在有道云笔记分享页面添加一个按钮,用于将笔记内容导出为Markdown文件。智能识别富文本和Markdown两种页面类型并进行相应处理。
// @description:en Add a button on Youdao Note share pages to export content as a Markdown file. Intelligently handles both rich text and Markdown note types.
// @author       SkyForever
// @match        https://share.note.youdao.com/*
// @match        https://note.youdao.com/ynoteshare/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- 富文本解析模块 (旧版页面) ---
    const richTextParser = {
        parseNode: function(node) {
            if (node.nodeType === 3) return node.textContent;
            if (node.nodeType !== 1) return '';

            let innerMarkdown = Array.from(node.childNodes).map(child => this.parseNode(child)).join('');
            const isBold = node.style.fontWeight === 'bold' || node.classList.contains('bold');
            if (isBold && innerMarkdown.trim() !== '') {
                if (!innerMarkdown.startsWith('**') && !innerMarkdown.endsWith('**')) {
                    innerMarkdown = `**${innerMarkdown}**`;
                }
            }
            if (node.tagName.toLowerCase() === 'a' && node.hasAttribute('href')) {
                return `[${innerMarkdown}](${node.getAttribute('href')})`;
            }
            if (node.tagName.toLowerCase() === 'br') {
                return '\n';
            }
            return innerMarkdown;
        },

        convertHeading: function(block) {
            const level = parseInt(block.dataset.headingLevel.replace('h', ''), 10);
            return `${'#'.repeat(level)} ${this.parseNode(block).trim()}`;
        },

        convertParagraph: function(block) {
            const text = this.parseNode(block).trim();
            return (text === '' && (block.innerHTML.includes(' ') || block.textContent.trim() === '')) ? '' : text;
        },

        convertImage: function(block) {
            const img = block.querySelector('img');
            return img ? `![image](${img.getAttribute('src')})` : '';
        },

        convertListItem: function(block) {
            const li = block.querySelector('li');
            if (!li) return null;
            const text = this.parseNode(li).trim();
            let prefix = '* ';
            const placeholder = block.querySelector('.item-list-placeholder');
            let indent = '';
            if (placeholder && placeholder.style.left) {
                const left = parseInt(placeholder.style.left, 10);
                if (left > 0) indent = '    '.repeat(Math.round(left / 28));
            }
            if (block.hasAttribute('start')) {
                prefix = `${placeholder ? placeholder.textContent.trim() : '1.'} `;
            }
            return `${indent}${prefix}${text}`;
        },

        convertCodeBlock: function(block) {
            const langElement = block.querySelector('.change-language .css-u30aqu');
            const lang = langElement ? langElement.textContent.trim().toLowerCase() : '';
            const codeLines = Array.from(block.querySelectorAll('pre[data-block-type="code-line"]')).map(pre => pre.textContent);
            return `\`\`\`${lang}\n${codeLines.join('\n')}\n\`\`\``;
        },

        run: function(editor) {
            const blocks = Array.from(editor.children);
            const markdownLines = [];
            blocks.forEach(block => {
                let mdLine = null;
                const blockType = block.dataset.blockType;
                if (block.tagName.toLowerCase() === 'ul' && blockType === 'list-item') {
                    mdLine = this.convertListItem(block);
                } else {
                    switch (blockType) {
                        case 'heading': mdLine = this.convertHeading(block); break;
                        case 'paragraph': mdLine = this.convertParagraph(block); break;
                        case 'image': mdLine = this.convertImage(block); break;
                        case 'code': mdLine = this.convertCodeBlock(block); break;
                        default:
                            if (block.textContent && block.textContent.trim()) {
                                mdLine = this.parseNode(block);
                            }
                            break;
                    }
                }
                if (mdLine !== null) markdownLines.push(mdLine);
            });
            return formatMarkdownOutput(markdownLines);
        }
    };

    // --- Markdown 原生页面解析模块 (新版页面) ---
    const markdownParser = {
        // 由于是原生Markdown渲染,直接从HTML标签转换
        run: function(editor) {
            const TurndownService = window.TurndownService || (function() {
                // 内联一个简化的turndown版本或提示用户安装
                alert('Turndown library not found. Please add `@require https://unpkg.com/turndown/dist/turndown.js` to the script header.');
                return function() { this.turndown = text => text; };
            })();
            const turndownService = new TurndownService({ codeBlockStyle: 'fenced' });
            return turndownService.turndown(editor.innerHTML);
        }
    };

    /**
     * 格式化Markdown输出,处理换行
     */
    function formatMarkdownOutput(lines) {
        let fullMarkdown = '';
        for (let i = 0; i < lines.length; i++) {
            if (lines[i].trim() === '' && (i === 0 || lines[i - 1].trim() === '')) continue;
            fullMarkdown += lines[i];
            const isCurrentList = /^((\s*[-*]|\s*\d+\.)\s)/.test(lines[i]);
            const isNextList = (i + 1 < lines.length) && /^((\s*[-*]|\s*\d+\.)\s)/.test(lines[i + 1]);
            fullMarkdown += (isCurrentList && isNextList) ? '\n' : '\n\n';
        }
        return fullMarkdown.trim();
    }

    /**
     * 主导出函数
     */
    function exportToMarkdown() {
        const iframe = document.getElementById('content-body');
        if (!iframe) {
            alert('错误:未找到笔记内容的iframe框架!');
            return;
        }

        const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
        if (!iframeDoc) {
            alert('错误:无法访问iframe的文档内容!');
            return;
        }

        let markdownContent = '';
        const noteTitle = document.querySelector('.file-name')?.title || 'youdao-note';

        // 判断页面类型
        if (iframeDoc.querySelector('.bulb-editor-inner')) { // 旧版富文本
            console.log("检测到富文本笔记,使用富文本解析器。");
            const editor = iframeDoc.querySelector('.bulb-editor-inner');
            markdownContent = richTextParser.run(editor);
        } else if (iframeDoc.querySelector('.markdown-body')) { // 新版Markdown
            console.log("检测到Markdown笔记,使用Turndown解析器。");
            // 动态加载turndown库
            loadScript('https://unpkg.com/turndown/dist/turndown.js', () => {
                const editor = iframeDoc.querySelector('.markdown-body');
                markdownContent = markdownParser.run(editor);
                downloadMarkdown(markdownContent, noteTitle);
            });
            return; // 异步执行,直接返回
        } else {
            alert('错误:未识别的笔记类型或内容未加载!');
            return;
        }

        downloadMarkdown(markdownContent, noteTitle);
    }

    /**
     * 统一的下载处理函数
     */
    function downloadMarkdown(content, title) {
        const safeFilename = title.replace(/[\\/:*?"<>|]/g, '-').replace(/\s+/g, '_') || 'youdao-note';
        downloadFile(content, `${safeFilename}.md`);
    }

    /**
     * 下载文件工具函数
     */
    function downloadFile(content, filename) {
        const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
        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);
    }

    /**
     * 动态加载外部JS脚本
     */
    function loadScript(url, callback) {
        if (window.TurndownService) { // 如果已加载,直接回调
            callback();
            return;
        }
        const script = document.createElement('script');
        script.src = url;
        script.onload = callback;
        script.onerror = () => alert('加载Turndown库失败,请检查网络连接或脚本设置。');
        document.head.appendChild(script);
    }


    /**
     * 在页面上创建并添加导出按钮
     */
    function addExportButton() {
        if (document.getElementById('export-md-button')) return;

        const button = document.createElement('button');
        button.id = 'export-md-button';
        button.textContent = '导出为MD';
        button.style.position = 'fixed';
        button.style.bottom = '20px';
        button.style.right = '20px';
        button.style.zIndex = '9999';
        button.style.padding = '10px 15px';
        button.style.backgroundColor = '#4CAF50';
        button.style.color = 'white';
        button.style.border = 'none';
        button.style.borderRadius = '5px';
        button.style.cursor = 'pointer';
        button.style.fontSize = '14px';
        button.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';

        button.addEventListener('mouseover', () => button.style.backgroundColor = '#45a049');
        button.addEventListener('mouseout', () => button.style.backgroundColor = '#4CAF50');
        button.addEventListener('click', exportToMarkdown);

        document.body.appendChild(button);
    }

    // 使用MutationObserver来监控DOM变化,确保iframe加载后能及时添加按钮
    const observer = new MutationObserver((mutations, obs) => {
        const iframe = document.getElementById('content-body');
        if (iframe) {
            const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
            if (iframeDoc && (iframeDoc.querySelector('.bulb-editor-inner') || iframeDoc.querySelector('.markdown-body'))) {
                addExportButton();
                obs.disconnect(); // 找到后停止观察
            }
        }
    });

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

})();