// ==UserScript== // @name 语雀文档简易下载器 // @namespace https://scriptcat.org/ // @version 0.6 // @description 点击按钮下载当前语雀文档为Markdown格式 // @author Aslan // @match https://www.yuque.com/* // @grant none // ==/UserScript== (function() { 'use strict'; // 添加下载按钮 function addDownloadButton() { // 检查是否已经添加了按钮 if (document.getElementById('yuque-download-btn')) { return; } // 创建按钮元素 const button = document.createElement('button'); button.id = 'yuque-download-btn'; button.textContent = '下载为MD'; button.style.cssText = ` position: fixed; top: 100px; right: 20px; z-index: 9999; padding: 10px 15px; background-color: #1890ff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); `; // 添加点击事件 button.addEventListener('click', async function() { try { // 显示加载状态 button.textContent = '加载中...'; button.disabled = true; // 获取当前文档的slug const currentUrl = window.location.href; const slugMatch = currentUrl.match(/yuque\.com\/[^\/]+\/[^\/]+\/([^\/?#]+)/); if (!slugMatch) { throw new Error('无法获取文档ID'); } const slug = slugMatch[1]; // 获取book_id const bookId = await getBookId(); if (!bookId) { throw new Error('无法获取书籍ID'); } // 获取文档内容 const docContent = await getDocContent(slug, bookId); // 检查是否获取到内容 if (!docContent || !docContent.content) { throw new Error('无法获取文档内容'); } // 转换为Markdown const markdown = convertToMarkdown(docContent); // 下载文件 downloadFile(markdown, `${docContent.title}.md`); // 恢复按钮状态 button.textContent = '下载为MD'; button.disabled = false; } catch (error) { console.error('下载失败:', error); button.textContent = '下载失败'; button.disabled = false; setTimeout(() => { button.textContent = '下载为MD'; }, 2000); } }); // 添加到页面 document.body.appendChild(button); } // 获取书籍ID async function getBookId() { // 尝试从页面中获取book_id // 1. 检查页面中的script标签 const scripts = document.querySelectorAll('script'); for (const script of scripts) { if (script.textContent) { const bookIdMatch = script.textContent.match(/book_id\s*:\s*(\d+)/); if (bookIdMatch) { return bookIdMatch[1]; } const bookIdMatch2 = script.textContent.match(/"book_id"\s*:\s*(\d+)/); if (bookIdMatch2) { return bookIdMatch2[1]; } } } // 2. 尝试从API响应中获取 // 这里使用一个默认值,实际使用时可能需要根据具体情况调整 return '63747238'; } // 获取文档内容 async function getDocContent(slug, bookId) { const apiUrl = `https://www.yuque.com/api/docs/${slug}?book_id=${bookId}&include_contributors=true&include_like=true&include_hits=true&merge_dynamic_data=false`; const response = await fetch(apiUrl, { method: 'GET', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'include' // 包含cookie }); if (!response.ok) { throw new Error(`API请求失败: ${response.status}`); } const data = await response.json(); if (!data.data) { throw new Error('API返回数据格式错误'); } return data.data; } // 解码URL编码的字符串 function decodeUrlEncoded(str) { try { return decodeURIComponent(str); } catch (e) { return str; } } // 处理语雀特殊的card标签(代码块) function handleYuqueCardTags(html) { if (!html) { return html; } // 处理codeblock类型的card标签 let result = html.replace(//g, function(match, value) { // 解码value值 const decodedValue = decodeUrlEncoded(value); // 提取data部分 const dataMatch = decodedValue.match(/^data:(.+)$/); if (dataMatch) { try { // 解析JSON const codeData = JSON.parse(dataMatch[1]); if (codeData.code) { // 提取代码内容 const codeContent = codeData.code; // 提取语言(如果有) const language = codeData.mode || ''; // 返回Markdown代码块 return '```' + language + '\n' + codeContent + '\n```\n\n'; } } catch (e) { console.error('解析codeblock失败:', e); } } return ''; }); // 处理image类型的card标签(使用更简单的正则表达式,确保匹配所有image标签) result = result.replace(/]+name="image"[^>]+value="([^"]+)"[^>]*>/g, function(match, value) { // 解码value值 const decodedValue = decodeUrlEncoded(value); // 提取data部分 const dataMatch = decodedValue.match(/^data:(.+)$/); if (dataMatch) { try { // 解析JSON const imageData = JSON.parse(dataMatch[1]); if (imageData.src) { // 提取图片URL const imageUrl = imageData.src; // 提取图片名称(如果有) const imageName = imageData.name || ''; // 返回Markdown图片语法 return '![' + imageName + '](' + imageUrl + ')\n\n'; } } catch (e) { console.error('解析image失败:', e); } } return ''; }); // 处理localdoc类型的card标签(附件) result = result.replace(/]+name="localdoc"[^>]+value="([^"]+)"[^>]*>/g, function(match, value) { // 解码value值 const decodedValue = decodeUrlEncoded(value); // 提取data部分 const dataMatch = decodedValue.match(/^data:(.+)$/); if (dataMatch) { try { // 解析JSON const docData = JSON.parse(dataMatch[1]); if (docData.src && docData.name) { // 提取附件URL const docUrl = docData.src; // 提取附件名称 const docName = docData.name; // 返回Markdown链接语法 return '[' + docName + '](' + docUrl + ')\n\n'; } } catch (e) { console.error('解析localdoc失败:', e); } } return ''; }); return result; } // 清理Markdown中的问题 function cleanMarkdown(markdown) { if (!markdown) { return markdown; } // 清理多余的代码块标记 markdown = markdown.replace(/``````/g, '```'); // 清理代码块结尾的特殊字符 markdown = markdown.replace(/(```[\s\S]*?)[\u200b\ufeff\s]*$/gm, '$1'); // 清理代码块之间的空行 markdown = markdown.replace(/```\s+```/g, '```\n```'); // 清理多余的空行 markdown = markdown.replace(/\n{3,}/g, '\n\n'); // 清理特殊字符 markdown = markdown.replace(/\u200b/g, ''); // 零宽空格 markdown = markdown.replace(/\ufeff/g, ''); // BOM // 清理行尾空白 markdown = markdown.replace(/\s+$/gm, ''); return markdown.trim(); } // HTML转Markdown function htmlToMarkdown(html) { if (!html) { return ''; } // 先处理语雀特殊的card标签 let markdown = handleYuqueCardTags(html); // 处理传统代码块(pre标签) markdown = markdown.replace(/]*>([\s\S]*?)<\/pre>/g, function(match, content) { // 提取code标签内容 const codeMatch = content.match(/]*>([\s\S]*?)<\/code>/); const codeContent = codeMatch ? codeMatch[1] : content; // 清理HTML实体 const cleanedCode = codeContent .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, "'"); return '```\n' + cleanedCode + '\n```\n\n'; }); // 处理行内代码 markdown = markdown.replace(/]*>([\s\S]*?)<\/code>/g, function(match, content) { // 提取span标签内容(如果有),保留style属性 const spanWithStyleMatch = content.match(/]+style="([^"]+)"[^>]*>([\s\S]*?)<\/span>/); if (spanWithStyleMatch) { const style = spanWithStyleMatch[1]; const codeContent = spanWithStyleMatch[2]; // 清理HTML实体 const cleanedCode = codeContent .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, "'") .trim(); // 保留带style的span标签 return '`' + cleanedCode + '`'; } // 普通span标签处理 const spanMatch = content.match(/]*>([\s\S]*?)<\/span>/); const codeContent = spanMatch ? spanMatch[1] : content; // 清理HTML实体 const cleanedCode = codeContent .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, "'") .trim(); return '`' + cleanedCode + '`'; }); // 处理带style的span标签(保留颜色信息) markdown = markdown.replace(/]+style="([^"]+)"[^>]*>([\s\S]*?)<\/span>/g, function(match, style, content) { // 清理HTML实体 const cleanedContent = content .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, "'") .trim(); // 保留带style的span标签 return '' + cleanedContent + ''; }); // 处理标题 markdown = markdown.replace(/]*>([\s\S]*?)<\/h1>/g, '# $1\n\n'); markdown = markdown.replace(/]*>([\s\S]*?)<\/h2>/g, '## $1\n\n'); markdown = markdown.replace(/]*>([\s\S]*?)<\/h3>/g, '### $1\n\n'); markdown = markdown.replace(/]*>([\s\S]*?)<\/h4>/g, '#### $1\n\n'); markdown = markdown.replace(/]*>([\s\S]*?)<\/h5>/g, '##### $1\n\n'); markdown = markdown.replace(/]*>([\s\S]*?)<\/h6>/g, '###### $1\n\n'); // 处理段落 markdown = markdown.replace(/]*>([\s\S]*?)<\/p>/g, function(match, content) { // 清理内容 const cleanedContent = content .replace(//g, '\n') .trim(); if (!cleanedContent) { return ''; } return cleanedContent + '\n\n'; }); // 处理无序列表 markdown = markdown.replace(/]*>([\s\S]*?)<\/ul>/g, function(match, content) { const items = content.match(/]*>([\s\S]*?)<\/li>/g) || []; if (items.length === 0) { return ''; } return items.map(item => { const cleanedItem = item.replace(/]*>([\s\S]*?)<\/li>/g, '$1').trim(); return `- ${cleanedItem}`; }).join('\n') + '\n\n'; }); // 处理有序列表 markdown = markdown.replace(/]*>([\s\S]*?)<\/ol>/g, function(match, content) { const items = content.match(/]*>([\s\S]*?)<\/li>/g) || []; if (items.length === 0) { return ''; } return items.map((item, index) => { const cleanedItem = item.replace(/]*>([\s\S]*?)<\/li>/g, '$1').trim(); return `${index + 1}. ${cleanedItem}`; }).join('\n') + '\n\n'; }); // 处理粗体 markdown = markdown.replace(/]*>([\s\S]*?)<\/strong>/g, '**$1**'); markdown = markdown.replace(/]*>([\s\S]*?)<\/b>/g, '**$1**'); // 处理斜体 markdown = markdown.replace(/]*>([\s\S]*?)<\/em>/g, '*$1*'); markdown = markdown.replace(/]*>([\s\S]*?)<\/i>/g, '*$1*'); // 处理引用 markdown = markdown.replace(/]*>([\s\S]*?)<\/blockquote>/g, function(match, content) { // 清理引用内容 const cleanedContent = content .replace(/]*>([\s\S]*?)<\/p>/g, '$1') .trim(); if (!cleanedContent) { return ''; } return cleanedContent.split('\n').map(line => `> ${line}`).join('\n') + '\n\n'; }); // 处理链接 markdown = markdown.replace(/]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g, function(match, href, text) { // 清理href中的HTML实体 const cleanedHref = href.replace(/&/g, '&'); return `[${text}](${cleanedHref})`; }); // 处理换行 markdown = markdown.replace(//g, '\n'); // 清理多余的标签 markdown = markdown.replace(/<[^>]*>/g, ''); // 清理HTML实体 markdown = markdown .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, "'"); // 清理Markdown中的问题 markdown = cleanMarkdown(markdown); return markdown; } // 转换为Markdown function convertToMarkdown(docContent) { let markdown = `# ${docContent.title}\n\n`; // 处理内容 if (docContent.content) { markdown += htmlToMarkdown(docContent.content); } // 最终清理 return cleanMarkdown(markdown); } // 下载文件 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; a.click(); URL.revokeObjectURL(url); } // 监听页面加载完成 window.addEventListener('load', addDownloadButton); // 监听页面导航变化(单页应用) let lastUrl = location.href; new MutationObserver(() => { const currentUrl = location.href; if (currentUrl !== lastUrl) { lastUrl = currentUrl; addDownloadButton(); } }).observe(document, { subtree: true, childList: true }); })();