Greasy Fork

来自缓存

Greasy Fork is available in English.

知乎收藏夹导出

将知乎收藏夹导出为MarkDown文档,带有导出进度显示

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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 `![图片](${fullSrc})\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();

})();