Greasy Fork is available in English.
将知乎收藏夹导出为MarkDown文档,带有导出进度显示
// ==UserScript==
// @name 知乎收藏夹导出
// @namespace https://github.com/miao
// @version 1.2.0
// @description 将知乎收藏夹导出为MarkDown文档,带有导出进度显示
// @author miao
// @license MIT
// @match https://www.zhihu.com/collection/*
// @icon data:image/gif;base64,R_o0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant GM_download
// ==/UserScript==
(function() {
'use strict';
const myCollectionExport = {
ui: {
exportButton: null,
progressContainer: null,
progressBar: null,
progressText: null
},
init: function() {
this.createUI();
this.ui.exportButton.onclick = () => this.startExport();
},
createUI: function() {
const exportButton = document.createElement('button');
exportButton.textContent = '导出为Markdown';
Object.assign(exportButton.style, {
position: 'fixed',
top: '70px',
right: '10px',
zIndex: '1001',
padding: '10px 15px',
backgroundColor: '#0077FF', // 知乎蓝
color: 'white',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
fontSize: '14px',
boxShadow: '0 2px 5px rgba(0,0,0,0.2)'
});
document.body.appendChild(exportButton);
this.ui.exportButton = exportButton;
const progressContainer = document.createElement('div');
Object.assign(progressContainer.style, {
position: 'fixed', top: '60px', right: '10px', zIndex: '1000', width: '200px',
backgroundColor: '#f0f0f0', borderRadius: '5px', padding: '10px',
boxShadow: '0 2px 5px rgba(0,0,0,0.2)', display: 'none'
});
const progressBar = document.createElement('div');
Object.assign(progressBar.style, {
width: '0%', height: '10px', backgroundColor: '#2cbe60', borderRadius: '3px',
transition: 'width 0.2s ease-in-out'
});
const progressText = document.createElement('span');
progressText.textContent = '准备中...';
Object.assign(progressText.style, {
display: 'block', marginTop: '5px', fontSize: '12px', color: '#333', textAlign: 'center'
});
progressContainer.appendChild(progressBar);
progressContainer.appendChild(progressText);
document.body.appendChild(progressContainer);
this.ui.progressContainer = progressContainer;
this.ui.progressBar = progressBar;
this.ui.progressText = progressText;
},
updateProgress: function(processed, total) {
if (total === 0) return;
const percentage = Math.min((processed / total) * 100, 100).toFixed(2);
this.ui.progressBar.style.width = `${percentage}%`;
this.ui.progressText.textContent = `正在导出: ${processed} / ${total} (${percentage}%)`;
},
startExport: async function() {
this.ui.exportButton.disabled = true;
this.ui.exportButton.style.opacity = '0.6';
this.ui.exportButton.style.cursor = 'not-allowed';
this.ui.progressContainer.style.display = 'block';
this.updateProgress(0, 1);
this.ui.progressText.textContent = '正在获取收藏夹信息...';
try {
const pathname = location.pathname;
const matched = pathname.match(/(?<=\/collection\/)\d+/);
const collectionId = matched ? matched[0] : "";
if (!collectionId) throw new Error("无法获取收藏夹ID");
const collectionTitleElement = document.querySelector('.CollectionDetailPageHeader-title');
let collectionTitle = collectionTitleElement ? collectionTitleElement.innerText.trim() : '知乎收藏夹';
collectionTitle = collectionTitle.replace(/[\s\r\n]+/g, ' ').replace(/生成PDF.*/, '').trim();
const initialResponse = await fetch(`/api/v4/collections/${collectionId}/items?offset=0&limit=1`);
if (!initialResponse.ok) throw new Error(`API请求失败: ${initialResponse.status}`);
const initialData = await initialResponse.json();
const totalItems = initialData.paging.totals;
if (totalItems === 0) {
this.ui.progressText.textContent = '收藏夹为空,无需导出。';
this.resetUI(3000);
return;
}
let collectionsMarkdown = [];
let itemsProcessed = 0;
const limit = 20;
for (let offset = 0; offset < totalItems; offset += limit) {
const response = await fetch(`/api/v4/collections/${collectionId}/items?offset=${offset}&limit=${limit}`);
if (!response.ok) {
console.warn(`在 offset ${offset} 请求失败, 状态: ${response.status}。可能会跳过此页。`);
continue;
}
const res = await response.json();
if (!res.data || res.data.length === 0) break;
const pageMarkdown = res.data.map(item => {
try {
const { type, url, question, content, title } = item.content;
const itemTitle = title || (question ? question.title : '无标题');
switch (type) {
case "zvideo":
return `# 视频:${itemTitle}\n[视频链接](${url})\n`;
default:
return `# ${itemTitle}\n[原文链接](${url})\n\n${this.convertHtmlToMarkdown(content)}\n`;
}
} catch (e) {
console.error(`处理项目失败: ${item.content.url}`, e);
return `# [处理失败] ${item.content.title || '无标题'}\n原文链接: ${item.content.url}\n\n错误信息: ${e.message}\n`;
}
});
collectionsMarkdown.push(...pageMarkdown);
itemsProcessed += res.data.length;
this.updateProgress(itemsProcessed, totalItems);
}
this.ui.progressText.textContent = '导出完成,正在生成文件...';
const markdownContent = collectionsMarkdown.join("\n---\n\n");
const blob = new Blob([markdownContent], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const safeTitle = collectionTitle.replace(/[\\/:*?"<>|]/g, '_');
const fileName = `${safeTitle}_${itemsProcessed}个内容.md`;
this.downloadFile(url, fileName);
} catch (error) {
console.error('导出过程中发生严重错误:', error);
this.ui.progressText.textContent = `导出失败: ${error.message}`;
} finally {
this.resetUI(5000);
}
},
// --- 核心修复:优先使用 a.click() 下载 ---
downloadFile: function(url, fileName) {
console.log(`准备下载文件: ${fileName}`);
this.ui.progressText.textContent = `准备下载: ${fileName}`;
try {
console.log('尝试使用兼容模式 (a.click) 进行下载...');
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
this.ui.progressText.textContent = `下载已发起!`;
} catch (e) {
console.error(`兼容模式下载失败: ${e.message}.`);
this.ui.progressText.textContent = '下载失败,请检查控制台!';
} finally {
// 无论成功与否,都延迟释放URL,确保下载有时间启动
setTimeout(() => {
URL.revokeObjectURL(url);
console.log(`Blob URL for ${fileName} has been revoked.`);
}, 5000);
}
},
resetUI: function(delay = 0) {
setTimeout(() => {
this.ui.progressContainer.style.display = 'none';
this.ui.exportButton.disabled = false;
this.ui.exportButton.style.opacity = '1';
this.ui.exportButton.style.cursor = 'pointer';
}, delay);
},
convertHtmlToMarkdown: function(html) {
if (!html) return '';
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
function parseNode(node) {
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
if (node.nodeType !== Node.ELEMENT_NODE) return '';
let content = Array.from(node.childNodes).map(parseNode).join('');
const tag = node.tagName.toLowerCase();
switch (tag) {
case 'p': return content.trim() ? content + '\n\n' : '';
case 'img':
const src = node.getAttribute('data-original') || node.getAttribute('data-actualsrc') || node.src;
const fullSrc = src.startsWith('//') ? `https:${src}` : src;
return `\n\n`;
case 'b': case 'strong': return `**${content}**`;
case 'i': case 'em': return `*${content}*`;
case 'blockquote': return `> ${content.replace(/\n/g, '\n> ')}\n\n`;
case 'a': return `[${content}](${node.href})`;
case 'ul': return content + '\n';
case 'ol':
const listItems = Array.from(node.children);
return listItems.map((li, index) => `${index + 1}. ${parseNode(li).trim()}`).join('\n') + '\n\n';
case 'li': return `* ${content.trim()}\n`;
case 'h1': return `# ${content}\n\n`;
case 'h2': return `## ${content}\n\n`;
case 'h3': return `### ${content}\n\n`;
case 'h4': return `#### ${content}\n\n`;
case 'figure': return Array.from(node.childNodes).map(parseNode).join('');
case 'br': return '\n';
case 'hr': return '---\n\n';
default: return content;
}
}
let markdown = parseNode(tempDiv).trim();
return markdown.replace(/\n{3,}/g, '\n\n');
}
};
myCollectionExport.init();
})();