// ==UserScript== // @name 文档批量下载器 (VitePress + VuePress) // @namespace https://github.com/ai-assistant/document-downloader // @version 0.2.0 // @description 通用 VitePress/VuePress 文档批量下载,直接抓取 .md 源码,保存到本地文件夹 // @author AI Assistant // @match *://*/* // @grant GM_notification // @grant GM_download // @run-at document-idle // ==/UserScript== (function() { 'use strict'; // ==================== 配置 ==================== var CONFIG = { DELAY: 150, // 请求间隔 (ms) TIMEOUT: 20000, // 单个请求超时 (ms) MAX_RETRY: 3, // 最大重试次数 RETRY_DELAY: 1000 // 重试间隔 (ms) }; var SITE_TYPE = null; // 'vitepress' 或 'vuepress' var CURRENT_FOLDER = null; var BASE_URL = ''; // ==================== 工具函数 ==================== function log(msg, data) { console.log('[DocDL]', msg, data || ''); } function delay(ms) { return new Promise(function(r) { setTimeout(r, ms); }); } // ==================== 站点检测 ==================== function detectSiteType() { // 检测 VitePress if (document.documentElement.outerHTML.indexOf('__VP_HASH_MAP__') > -1 || document.documentElement.outerHTML.indexOf('vitepress') > -1 || document.querySelector('meta[name="generator"][content*="VitePress"]') || document.querySelector('.VPSidebar')) { return 'vitepress'; } // 检测 VuePress if (document.querySelector('meta[name="generator"][content*="VuePress"]') || document.querySelector('.vp-sidebar') || document.querySelector('.theme-hope-content')) { return 'vuepress'; } return null; } // ==================== 提取页面列表 ==================== function extractPages() { if (SITE_TYPE === 'vitepress') { return extractVitePressPages(); } else if (SITE_TYPE === 'vuepress') { return extractVuePressPages(); } return []; } // VitePress 页面提取 function extractVitePressPages() { var pages = []; var seen = new Set(); var groups = document.querySelectorAll('.VPSidebarItem.level-0'); if (groups.length) { groups.forEach(function(g) { var folderEl = g.querySelector(':scope > .item > .text'); var folder = folderEl ? folderEl.textContent.trim() : ''; var links = g.querySelectorAll('.VPSidebarItem.level-1.is-link a[href]'); links.forEach(function(a) { var href = a.getAttribute('href'); var titleEl = a.querySelector('.text'); var title = titleEl ? titleEl.textContent.trim() : ''; if (href && !seen.has(href)) { seen.add(href); pages.push({ title: title, url: href, folder: folder }); } }); }); } else { var sidebar = document.querySelector('.VPSidebar nav') || document.querySelector('aside nav'); if (sidebar) { sidebar.querySelectorAll('a[href]').forEach(function(a) { var href = a.getAttribute('href'); if (href && !seen.has(href) && href !== '#' && !href.startsWith('http')) { seen.add(href); pages.push({ title: a.textContent.trim(), url: href, folder: '' }); } }); } } log('VitePress 提取到 ' + pages.length + ' 个页面'); return pages; } // VuePress 页面提取 function extractVuePressPages() { var pages = []; var seen = new Set(); // VuePress Theme Hope 侧边栏结构 var sidebarGroups = document.querySelectorAll('.vp-sidebar-links > li'); if (sidebarGroups.length) { sidebarGroups.forEach(function(group) { // 获取一级标题(如 "一、前言") var topLink = group.querySelector(':scope > .vp-sidebar-link'); var topFolder = topLink ? topLink.textContent.trim() : ''; if (topLink && topLink.getAttribute('href') && !seen.has(topLink.getAttribute('href'))) { seen.add(topLink.getAttribute('href')); pages.push({ title: topLink.textContent.trim(), url: topLink.getAttribute('href'), folder: '' }); } // 获取子菜单 var subGroups = group.querySelectorAll(':scope > .vp-sidebar-group'); subGroups.forEach(function(subGroup) { var groupTitleEl = subGroup.querySelector(':scope > .vp-sidebar-header .vp-sidebar-title'); var groupTitle = groupTitleEl ? groupTitleEl.textContent.trim() : ''; // 如果标题是链接 var groupLink = subGroup.querySelector(':scope > .vp-sidebar-header a.vp-sidebar-title'); if (groupLink && groupLink.getAttribute('href') && !seen.has(groupLink.getAttribute('href'))) { seen.add(groupLink.getAttribute('href')); pages.push({ title: groupTitle, url: groupLink.getAttribute('href'), folder: topFolder }); } // 获取该组下的所有子页面 var links = subGroup.querySelectorAll('ul.vp-sidebar-links .vp-sidebar-link'); links.forEach(function(a) { var href = a.getAttribute('href'); var title = a.textContent.trim(); if (href && !seen.has(href) && href !== '#') { seen.add(href); pages.push({ title: title, url: href, folder: groupTitle || topFolder }); } }); }); // 直接二级链接(不带 group 的情况) var directLinks = group.querySelectorAll(':scope > ul.vp-sidebar-links > li > a.vp-sidebar-link'); directLinks.forEach(function(a) { var href = a.getAttribute('href'); var title = a.textContent.trim(); if (href && !seen.has(href) && href !== '#') { seen.add(href); pages.push({ title: title, url: href, folder: topFolder }); } }); }); } else { // 备用方案:直接查找所有侧边栏链接 var allLinks = document.querySelectorAll('.vp-sidebar-links .vp-sidebar-link, .sidebar-link'); allLinks.forEach(function(a) { var href = a.getAttribute('href'); var title = a.textContent.trim(); if (href && !seen.has(href) && href !== '#' && !href.startsWith('http')) { seen.add(href); pages.push({ title: title, url: href, folder: '' }); } }); } log('VuePress 提取到 ' + pages.length + ' 个页面'); return pages; } // ==================== URL 处理 ==================== function getBaseUrl() { return location.origin; } function getSiteName() { var title = document.querySelector('title'); if (!title) return location.hostname.replace('www.', '').split('.')[0]; // 处理 VuePress 标题格式: "页面标题 | 站点名称" var parts = title.textContent.split(/\s*\|\s*/); var name = parts.length > 1 ? parts[parts.length - 1] : parts[0]; name = name.split(/\s+[-–—]\s+/).pop(); return name.trim(); } // 获取 .md 文件 URL function getMdUrl(pageUrl) { // 移除 .html 后缀,添加 .md var path = pageUrl.replace(/\.html$/, ''); if (path.endsWith('/')) path += 'index'; return BASE_URL + (path.startsWith('/') ? '' : '/') + path + '.md'; } function sanitizeFileName(name) { return name .replace(/[<>:"/\\|?*]/g, '-') .replace(/[\x00-\x1f]/g, '') .replace(/\s+/g, ' ') .trim() .substring(0, 200); } // ==================== 带重试的 fetch ==================== async function fetchWithRetry(url, retries) { var lastError; for (var i = 0; i <= (retries || CONFIG.MAX_RETRY); i++) { try { var data = await fetchMarkdown(url); return data; } catch (e) { lastError = e; log(`重试 ${i + 1}/${CONFIG.MAX_RETRY}: ${url}`, e.message); if (i < CONFIG.MAX_RETRY) await delay(CONFIG.RETRY_DELAY); } } throw lastError; } // ==================== iframe 加载 .md 文件 ==================== function fetchMarkdown(url) { return new Promise(function(resolve, reject) { var iframe = document.createElement('iframe'); var timer; var isDone = false; iframe.style.cssText = 'position:fixed;top:-9999px;left:-9999px;width:1px;height:1px;border:none;'; iframe.src = url; function cleanup() { if (timer) clearTimeout(timer); if (iframe.parentNode) iframe.parentNode.removeChild(iframe); } timer = setTimeout(function() { if (!isDone) { isDone = true; cleanup(); reject(new Error('请求超时: ' + url)); } }, CONFIG.TIMEOUT); iframe.onload = function() { if (isDone) return; isDone = true; clearTimeout(timer); try { var doc = iframe.contentDocument || iframe.contentWindow.document; var body = doc.body; if (!body) { cleanup(); reject(new Error('无法读取文档内容: ' + url)); return; } var pre = body.querySelector('pre'); var rawText = pre ? pre.textContent : (body.textContent || body.innerText); if (!rawText || rawText.trim() === '') { cleanup(); reject(new Error('文档内容为空: ' + url)); return; } var title = extractTitle(rawText); cleanup(); resolve({ title: title, md: rawText.trim() }); } catch (e) { cleanup(); reject(new Error('解析失败: ' + e.message)); } }; iframe.onerror = function() { if (!isDone) { isDone = true; cleanup(); reject(new Error('网络错误: ' + url)); } }; document.body.appendChild(iframe); }); } function extractTitle(mdContent) { // 方法1: 匹配第一个 # 标题 var h1Match = mdContent.match(/^#\s+(.+)$/m); if (h1Match) return h1Match[1].trim(); // 方法2: 提取 frontmatter 中的 title if (mdContent.startsWith('---')) { var end = mdContent.indexOf('---', 3); if (end > -1) { var fm = mdContent.substring(3, end); var fmMatch = fm.match(/^title:\s*(.+)$/m); if (fmMatch) return fmMatch[1].trim(); } } return ''; } // ==================== 下载文件到文件夹 ==================== function downloadToFolder(content, filename, folderName) { return new Promise(function(resolve, reject) { var fullPath = folderName + '/' + filename; var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }); var url = URL.createObjectURL(blob); GM_download({ url: url, name: fullPath, saveAs: false, onload: function() { URL.revokeObjectURL(url); log('✅ 已保存: ' + fullPath); resolve(); }, onerror: function(error) { URL.revokeObjectURL(url); log('❌ 保存失败: ' + fullPath, error); reject(error); } }); }); } // ==================== 创建文件夹 ==================== async function ensureFolder(folderName) { if (CURRENT_FOLDER === folderName) return true; var keepContent = '# This folder is created by Document Downloader\n'; return new Promise(function(resolve) { var blob = new Blob([keepContent], { type: 'text/plain;charset=utf-8' }); var url = URL.createObjectURL(blob); GM_download({ url: url, name: folderName + '/.keep', saveAs: false, onload: function() { URL.revokeObjectURL(url); CURRENT_FOLDER = folderName; log('📁 文件夹已创建: ' + folderName); resolve(true); }, onerror: function(error) { URL.revokeObjectURL(url); log('⚠️ 创建文件夹失败,将保存到默认位置', error); resolve(false); } }); }); } // ==================== UI 创建 ==================== function createUI(pages, siteName) { if (document.getElementById('doc-dl-btn')) return; var btn = document.createElement('button'); btn.id = 'doc-dl-btn'; btn.innerHTML = '📥
' + pages.length + '篇'; btn.title = '下载 ' + siteName + ' 文档 (.md 源码)'; btn.style.cssText = 'position:fixed;bottom:24px;right:24px;z-index:2147483646;padding:10px 16px;background:#6366f1;color:#fff;border:none;border-radius:12px;font-size:14px;font-weight:600;cursor:pointer;box-shadow:0 4px 20px rgba(99,102,241,.45);font-family:-apple-system,BlinkMacSystemFont,sans-serif;transition:all .2s;line-height:1.3'; btn.onmouseenter = function() { btn.style.transform = 'scale(1.05)'; }; btn.onmouseleave = function() { btn.style.transform = 'scale(1)'; }; var statusDiv = document.createElement('div'); statusDiv.id = 'doc-dl-status'; statusDiv.style.cssText = 'position:fixed;bottom:100px;right:24px;z-index:2147483646;background:#fff;border-radius:10px;padding:14px 18px;box-shadow:0 4px 24px rgba(0,0,0,.15);font-size:13px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;min-width:240px;display:none;line-height:1.6;color:#333'; document.body.appendChild(statusDiv); btn.onclick = function() { startDownload(pages, siteName, btn, statusDiv); }; document.body.appendChild(btn); log('UI 就绪 — ' + siteName + ' (' + SITE_TYPE + ') — ' + pages.length + ' 篇'); } // ==================== 下载流程 ==================== async function startDownload(pages, siteName, btn, statusDiv) { if (btn._running) return; btn._running = true; btn.style.opacity = '0.6'; btn.style.pointerEvents = 'none'; statusDiv.style.display = 'block'; var total = pages.length; var ok = 0, fail = 0; var startTime = Date.now(); BASE_URL = getBaseUrl(); var folderName = siteName + '_docs'; await ensureFolder(folderName); var usedNames = {}; for (var i = 0; i < total; i++) { var page = pages[i]; var url = getMdUrl(page.url); updateStatus(statusDiv, i, total, page.title, ok, fail); try { var data = await fetchWithRetry(url, CONFIG.MAX_RETRY); var title = data.title || page.title || 'untitled'; var safeName = sanitizeFileName(title); var folder = page.folder ? sanitizeFileName(page.folder) + '/' : ''; var fname = folder + safeName + '.md'; if (usedNames[fname] !== undefined) { usedNames[fname]++; fname = folder + safeName + ' (' + usedNames[fname] + ').md'; } else { usedNames[fname] = 0; } await downloadToFolder(data.md, fname, folderName); ok++; } catch (e) { fail++; log('❌ 失败: ' + (page.title || url), e.message); } if (i < total - 1) await delay(CONFIG.DELAY); } var elapsed = ((Date.now() - startTime) / 1000).toFixed(1); statusDiv.innerHTML = [ '✅ 完成!', '
成功 ' + ok + ' / 失败 ' + fail + ' / ' + total + '
', '
📁 保存到: ' + folderName + '/
', '
⏱ ' + elapsed + 's · .md 源码
' ].join(''); log('✅ 下载完成, 成功: ' + ok + ', 失败: ' + fail); setTimeout(function() { statusDiv.style.display = 'none'; }, 10000); btn._running = false; btn.style.opacity = '1'; btn.style.pointerEvents = 'auto'; } function updateStatus(statusDiv, index, total, title, ok, fail) { var percent = ((index / total) * 100); var shortTitle = (title || '').substring(0, 40); statusDiv.innerHTML = [ '📥 ' + (index + 1) + '/' + total + '', '' + shortTitle + '', '
', '
', '
', '
✅ ' + ok + '   ❌ ' + fail + '
' ].join(''); } // ==================== SPA 导航监听 ==================== function initNavigationWatch() { var lastPath = location.pathname; function checkNav() { if (location.pathname !== lastPath) { lastPath = location.pathname; setTimeout(function() { var newType = detectSiteType(); if (newType && !document.getElementById('doc-dl-btn')) { SITE_TYPE = newType; var pages = extractPages(); if (pages.length) { var siteName = getSiteName(); createUI(pages, siteName); } } }, 1000); } } var observer = new MutationObserver(checkNav); observer.observe(document.body, { childList: true, subtree: true }); window.addEventListener('popstate', function() { setTimeout(checkNav, 500); }); } // ==================== 初始化 ==================== async function init() { SITE_TYPE = detectSiteType(); if (!SITE_TYPE) { log('未检测到 VitePress 或 VuePress 站点,跳过'); return; } log('检测到站点类型: ' + SITE_TYPE); var siteName = getSiteName(); var pages = extractPages(); if (!pages.length) { setTimeout(function() { var retryPages = extractPages(); if (retryPages.length) createUI(retryPages, siteName); }, 2000); return; } createUI(pages, siteName); initNavigationWatch(); } // ==================== 启动 ==================== if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function() { setTimeout(init, 500); }); } else { setTimeout(init, 500); } })();