// ==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 = [
'✅ 完成!',
'