Greasy Fork

Greasy Fork is available in English.

知乎全能助手 (Markdown复制+批量导出) - 顶部按钮版

将“复制 Markdown”按钮移动到回答/文章顶部(赞同数旁边),防止被遮挡。支持批量抓取/导出 HTML/JSON/Markdown。

当前为 2025-12-23 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         知乎全能助手 (Markdown复制+批量导出) - 顶部按钮版
// @namespace    http://qtqz.zhihu/
// @version      3.4
// @description  将“复制 Markdown”按钮移动到回答/文章顶部(赞同数旁边),防止被遮挡。支持批量抓取/导出 HTML/JSON/Markdown。
// @author       AI & qtqz logic
// @match        *://www.zhihu.com/*
// @match        *://zhuanlan.zhihu.com/*
// @icon         https://static.zhihu.com/heifetz/favicon.ico
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/FileSaver.min.js
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_setClipboard
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // ==========================================
    // 模块一:核心 Markdown 转换引擎 (通用)
    // ==========================================
    function convertHtmlToMarkdown(html, url = '') {
        if (!html) return '';

        const div = document.createElement('div');
        div.innerHTML = html;

        // 1. 处理公式 (MathJax)
        div.querySelectorAll('.ztext-math').forEach(el => {
            const tex = el.getAttribute('data-tex');
            if (tex) el.replaceWith(` $${tex}$ `);
        });

        // 2. 处理代码块
        div.querySelectorAll('pre, .highlight').forEach(el => {
            const codeEl = el.querySelector('code');
            let lang = '';
            if (codeEl && codeEl.className) {
                const match = codeEl.className.match(/language-(\w+)/);
                if (match) lang = match[1];
            }
            const codeText = el.innerText;
            el.replaceWith(`\n\`\`\`${lang}\n${codeText}\n\`\`\`\n`);
        });

        // 3. 处理引用
        div.querySelectorAll('blockquote').forEach(el => {
            el.innerHTML = '> ' + el.innerHTML.replace(/<br\s*\/?>/gi, '\n> ');
        });

        // 4. 处理链接
        div.querySelectorAll('a').forEach(el => {
            let href = el.getAttribute('href') || '';
            const text = el.innerText;
            if (el.querySelector('img')) return;
            if (href.includes('link.zhihu.com')) {
                try {
                    const urlObj = new URL(href);
                    const target = urlObj.searchParams.get('target');
                    if (target) href = decodeURIComponent(target);
                } catch(e){}
            }
            if (href.startsWith('/')) href = 'https://www.zhihu.com' + href;
            el.replaceWith(` [${text}](${href}) `);
        });

        // 5. 处理标题
        ['h1','h2','h3','h4','h5','h6'].forEach((tag, idx) => {
            div.querySelectorAll(tag).forEach(el => {
                const prefix = '#'.repeat(idx + 1);
                el.replaceWith(`\n${prefix} ${el.innerText}\n`);
            });
        });

        // 6. 处理列表
        div.querySelectorAll('li').forEach(el => {
            el.replaceWith(`- ${el.innerText}\n`);
        });
        div.querySelectorAll('ul, ol').forEach(el => {
            el.replaceWith(el.innerHTML + '\n');
        });

        // 7. 处理图片
        div.querySelectorAll('img').forEach(el => {
            const src = el.getAttribute('data-actualsrc') ||
                        el.getAttribute('data-original') ||
                        el.getAttribute('src');
            if (src && !src.startsWith('data:')) {
                el.replaceWith(`\n![](${src})\n`);
            } else {
                el.remove();
            }
        });

        // 8. 清理格式
        let md = div.innerHTML;
        md = md.replace(/<b>(.*?)<\/b>/gi, '**$1**')
               .replace(/<strong>(.*?)<\/strong>/gi, '**$1**')
               .replace(/<i>(.*?)<\/i>/gi, '*$1*')
               .replace(/<em>(.*?)<\/em>/gi, '*$1*')
               .replace(/<br\s*\/?>/gi, '\n')
               .replace(/<p>(.*?)<\/p>/gi, '\n$1\n\n')
               .replace(/<[^>]+>/g, '');

        const textArea = document.createElement('textarea');
        textArea.innerHTML = md;
        md = textArea.value;

        if (url) md += `\n\n> 来源: [${url}](${url})\n`;

        return md.trim();
    }

    // ==========================================
    // 模块二:顶部“一键复制”按钮注入 (位置已修改)
    // ==========================================

function injectCopyButtons() {
    // 选择内容容器
    const items = document.querySelectorAll('.ContentItem, .Post-content');

    items.forEach(item => {
        if (item.getAttribute('data-md-btn-added')) return;
        item.setAttribute('data-md-btn-added', 'true');

        // --- 修改核心:寻找顶部的 Meta 区域 ---
        // 1. 尝试找 .ContentItem-meta (回答/文章列表页通常都有这个,包含赞同数)
        // 2. 尝试找 .Post-Header (专栏文章详情页)
        let targetArea = item.querySelector('.ContentItem-meta');

        // 专栏文章详情页的特殊处理
        if (!targetArea && item.classList.contains('Post-content')) {
            targetArea = document.querySelector('.Post-Header');
        }

        // 如果还没找到,尝试找标题下方
        if (!targetArea) {
            targetArea = item.querySelector('.QuestionHeader-title'); // 问题页备选
        }

        if (targetArea) {
            const btn = document.createElement('span');
            btn.className = 'zbc-copy-btn';
            // 核心修改:1. 向下挪(margin-top) 2. 简约风格(简化样式、文字、间距)
            btn.style.cssText = `
                margin-left: 2px;
                margin-top: 8px; /* 向下偏移2px,可根据需要调整为3/4px */
                display: inline-flex;
                align-items: center;
                cursor: pointer;
                background-color: #f0f2f5; /* 截图的浅灰背景 */
                color: #374151; /* 截图的深灰文字 */
                font-size: 13px;
                line-height: 1.4;
                padding: 2px 8px; /* 按钮内边距,让样式更饱满 */
                border-radius: 4px; /* 圆角,匹配截图的柔和感 */
                transition: all 0.2s ease; /* 悬停过渡更丝滑 */
            `;
            // 恢复“复制为Markdown”文字,匹配截图内容
            btn.innerHTML = `
                <svg viewBox="0 0 24 24" width="12" height="12" style="margin-right:4px; fill:currentColor">
                    <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
                </svg> 复制为Markdown
            `;

            // 悬停效果:背景加深+文字变蓝,更有交互感
            btn.onmouseover = () => {
                btn.style.backgroundColor = '#e5e7eb';
                btn.style.color = '#1772f6';
            };
            btn.onmouseout = () => {
                if(!btn.innerText.includes('已复制') && !btn.innerText.includes('失败')) {
                    btn.style.backgroundColor = '#f0f2f5';
                    btn.style.color = '#374151';
                }
            };

            btn.onclick = (e) => {
                e.stopPropagation();
                const originalHTML = btn.innerHTML;
                // 加载状态保持按钮样式
                btn.innerHTML = `
                    <svg viewBox="0 0 24 24" width="12" height="12" style="margin-right:4px; fill:#1772f6">
                        <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
                    </svg> <span style="color:#1772f6">处理中...</span>
                `;
                btn.style.backgroundColor = '#e5e7eb'; // 加载时保持悬停背景

                try {
                    let contentHtml = '';
                    let url = '';
                    let title = '';

                    const richText = item.querySelector('.RichContent-inner') || item.querySelector('.Post-RichText');
                    if (richText) contentHtml = richText.innerHTML;

                    const titleEl = item.querySelector('.ContentItem-title') || document.querySelector('.QuestionHeader-title') || document.querySelector('.Post-Title');
                    title = titleEl ? titleEl.innerText : '无标题';

                    const metaUrl = item.querySelector('meta[itemprop="url"]');
                    url = metaUrl ? metaUrl.content : window.location.href;

                    if (!contentHtml) throw new Error('需展开全文');

                    let markdown = `# ${title}\n\n` + convertHtmlToMarkdown(contentHtml, url);
                    GM_setClipboard(markdown);

                    // 成功状态样式
                    btn.innerHTML = `
                        <svg viewBox="0 0 24 24" width="12" height="12" style="margin-right:4px; fill:#00a65e">
                            <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
                        </svg> <span style="color:#00a65e">已复制!</span>
                    `;
                    btn.style.backgroundColor = '#f0f2f5'; // 恢复原背景
                    setTimeout(() => {
                        btn.innerHTML = originalHTML;
                        btn.style.backgroundColor = '#f0f2f5';
                        btn.style.color = '#444';
                    }, 2000);

                } catch (err) {
                    console.error(err);
                    // 失败状态样式
                    btn.innerHTML = `
                        <svg viewBox="0 0 24 24" width="12" height="12" style="margin-right:4px; fill:red">
                            <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
                        </svg> <span style="color:red">失败</span>
                    `;
                    btn.style.backgroundColor = '#f0f2f5'; // 恢复原背景
                    setTimeout(() => { btn.innerHTML = originalHTML; }, 2000);
                }
            };

            targetArea.appendChild(btn);
        }
    });
}
    const observer = new MutationObserver((mutations) => { injectCopyButtons(); });
    observer.observe(document.body, { childList: true, subtree: true });
    setTimeout(injectCopyButtons, 1000);

    // ==========================================
    // 模块三:批量导出面板 (保持不变)
    // ==========================================

    const CONFIG = { commentLimit: 20, minDelay: 1500, maxDelay: 3000 };
    const STATE = { isRunning: false, items: [], currentType: '', id: '', cancel: false };
    const UI = { panel: null, logArea: null, progressBar: null };

    function initPanelUI() {
        const style = document.createElement('style');
        style.textContent = `
            #zbc-panel { position: fixed; top: 100px; right: 20px; width: 340px; background: #fff; box-shadow: 0 4px 12px rgba(0,0,0,0.15); border-radius: 8px; z-index: 9999; font-family: sans-serif; border: 1px solid #ebebeb; display: none; font-size: 14px; }
            #zbc-header { padding: 12px 16px; border-bottom: 1px solid #f0f0f0; background: #f6f6f6; border-radius: 8px 8px 0 0; font-weight: bold; color: #1772f6; display: flex; justify-content: space-between; align-items: center; }
            #zbc-body { padding: 16px; }
            .zbc-btn { display: block; width: 100%; padding: 8px; margin-bottom: 8px; border: 1px solid #1772f6; color: #1772f6; background: #fff; border-radius: 4px; cursor: pointer; text-align: center; transition: 0.2s; font-size: 13px; }
            .zbc-btn:hover { background: #eef6ff; }
            .zbc-btn:disabled { border-color: #ccc; color: #ccc; cursor: not-allowed; background: #f9f9f9;}
            .zbc-btn.primary { background: #1772f6; color: #fff; }
            .zbc-btn.primary:hover { background: #1062d6; }
            #zbc-log { height: 120px; overflow-y: auto; background: #f9f9f9; border: 1px solid #eee; padding: 8px; font-size: 12px; margin-bottom: 10px; color: #666; line-height: 1.4; }
            .zbc-progress { height: 4px; background: #eee; width: 100%; margin-bottom: 10px; }
            .zbc-progress-bar { height: 100%; background: #1772f6; width: 0%; transition: width 0.3s; }
            .zbc-close { cursor: pointer; color: #999; font-size: 18px; line-height: 1; }
        `;
        document.head.appendChild(style);

        const panel = document.createElement('div');
        panel.id = 'zbc-panel';
        panel.innerHTML = `
            <div id="zbc-header"><span>知乎批量导出助手</span><span class="zbc-close" onclick="document.getElementById('zbc-panel').style.display='none'">×</span></div>
            <div id="zbc-body">
                <div id="zbc-status" style="margin-bottom:10px;">请进入用户主页或收藏夹</div>
                <div class="zbc-progress"><div class="zbc-progress-bar" id="zbc-bar"></div></div>
                <div id="zbc-log"></div>
                <div style="display:flex; gap:5px;"><button id="zbc-start" class="zbc-btn primary">开始批量抓取</button><button id="zbc-stop" class="zbc-btn" disabled>停止</button></div>
                <hr style="border:0; border-top:1px solid #eee; margin: 10px 0;">
                <button id="zbc-export-md" class="zbc-btn" disabled>📝 批量导出 Markdown</button>
                <button id="zbc-export-html" class="zbc-btn" disabled>💾 批量导出 HTML</button>
                <button id="zbc-export-json" class="zbc-btn" disabled>⚙️ 批量导出 JSON</button>
            </div>
        `;
        document.body.appendChild(panel);

        UI.panel = panel;
        UI.logArea = document.getElementById('zbc-log');
        UI.progressBar = document.getElementById('zbc-bar');

        document.getElementById('zbc-start').onclick = startScraping;
        document.getElementById('zbc-stop').onclick = () => { STATE.cancel = true; log('正在停止...'); };
        document.getElementById('zbc-export-html').onclick = () => exportSingleHTML();
        document.getElementById('zbc-export-json').onclick = () => exportJSON();
        document.getElementById('zbc-export-md').onclick = () => exportBatchMarkdown();

        const toggleBtn = document.createElement('div');
        toggleBtn.innerText = '📂';
        toggleBtn.title = '打开批量导出面板';
        toggleBtn.style.cssText = 'position:fixed; bottom:80px; right:20px; width:40px; height:40px; background:#1772f6; color:#fff; border-radius:50%; text-align:center; line-height:40px; cursor:pointer; z-index:9998; box-shadow:0 2px 10px rgba(0,0,0,0.2); font-size:20px;';
        toggleBtn.onclick = () => { panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; detectPage(); };
        document.body.appendChild(toggleBtn);
    }

    function log(msg) { const p = document.createElement('div'); p.innerText = `[${new Date().toLocaleTimeString()}] ${msg}`; UI.logArea.prepend(p); }
    function detectPage() {
        const url = window.location.href; const statusDiv = document.getElementById('zbc-status');
        if (url.includes('/collection/')) { STATE.currentType = 'collection'; STATE.id = url.match(/collection\/(\d+)/)[1]; statusDiv.innerText = `当前:收藏夹 (ID: ${STATE.id})`; return true; }
        else if (url.includes('/people/')) {
            const match = url.match(/people\/([^/]+)/);
            if(match) { STATE.id = match[1]; STATE.currentType = url.includes('/posts')||url.includes('/articles') ? 'people_articles' : 'people_answers'; statusDiv.innerText = `当前:用户 (${STATE.id})`; return true; }
        }
        statusDiv.innerText = '批量模式:请进入用户主页或收藏夹'; return false;
    }

    async function startScraping() {
        if (!detectPage()) { alert('请先进入用户主页或收藏夹页面使用批量功能'); return; }
        STATE.isRunning = true; STATE.cancel = false; STATE.items = [];
        document.getElementById('zbc-start').disabled = true; document.getElementById('zbc-stop').disabled = false; toggleExportBtns(false); UI.logArea.innerHTML = '';
        let offset = 0; let isEnd = false; const limit = 20;
        try {
            while (!isEnd && !STATE.cancel) {
                let apiUrl = '';
                if (STATE.currentType === 'collection') apiUrl = `https://www.zhihu.com/api/v4/collections/${STATE.id}/items?offset=${offset}&limit=${limit}`;
                else if (STATE.currentType === 'people_answers') apiUrl = `https://www.zhihu.com/api/v4/members/${STATE.id}/answers?include=data%5B*%5D.is_normal%2Ccontent%2Cvoteup_count%2Ccomment_count%2Ccreated_time%2Cupdated_time&offset=${offset}&limit=${limit}&sort_by=created`;
                else if (STATE.currentType === 'people_articles') apiUrl = `https://www.zhihu.com/api/v4/members/${STATE.id}/articles?include=data%5B*%5D.content%2Cvoteup_count%2Ccomment_count&offset=${offset}&limit=${limit}&sort_by=created`;

                log(`请求列表 offset: ${offset}...`);
                const data = await fetchAPI(apiUrl);
                if (data.data && data.data.length > 0) {
                    for (const item of data.data) {
                        if (STATE.cancel) break;
                        const realItem = item.content ? item.content : item; if (!realItem || !realItem.id) continue;
                        STATE.items.push(processItem(realItem));
                        log(`已获取: ${realItem.title.slice(0,8)}...`);
                        await sleep(Math.floor(Math.random() * (CONFIG.maxDelay - CONFIG.minDelay + 1) + CONFIG.minDelay));
                    }
                    offset += limit; isEnd = data.paging.is_end; UI.progressBar.style.width = '50%';
                } else { isEnd = true; }
            }
        } catch (e) { log('错误: ' + e.message); }
        STATE.isRunning = false; document.getElementById('zbc-start').disabled = false; document.getElementById('zbc-stop').disabled = true; UI.progressBar.style.width = '100%';
        if (STATE.items.length > 0) { log(`抓取完成,共 ${STATE.items.length} 条`); toggleExportBtns(true); }
    }

    function fetchAPI(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, onload: (res) => { if (res.status === 200) try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(e); } else reject(new Error(res.status)); }, onerror: (err) => reject(err) }); }); }
    function processItem(item) {
        let content = item.content || ''; content = content.replace(/<img [^>]*data-actualsrc="([^"]+)"[^>]*>/g, '<img src="$1" referrerpolicy="no-referrer">').replace(/<img [^>]*data-original="([^"]+)"[^>]*>/g, '<img src="$1" referrerpolicy="no-referrer">');
        let title = item.title; if (!title && item.question) title = item.question.title; if (!title) title = "无标题";
        return { id: item.id, type: item.type, title: title, author: item.author ? item.author.name : '匿名', content: content, voteup_count: item.voteup_count, created_time: new Date(item.created_time * 1000).toLocaleString(), url: item.url.replace("api.zhihu.com", "www.zhihu.com") };
    }
    function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
    function sanitizeFileName(name) { return name.replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, " ").trim(); }
    function toggleExportBtns(enable) { document.getElementById('zbc-export-html').disabled = !enable; document.getElementById('zbc-export-json').disabled = !enable; document.getElementById('zbc-export-md').disabled = !enable; }
    function exportBatchMarkdown() {
        log('生成 Markdown...'); const title = `知乎批量_${STATE.currentType}_${sanitizeFileName(STATE.id)}`;
        let md = `# ${title}\n\n[TOC]\n\n`;
        STATE.items.forEach((item) => { md += `# ${item.title}\n\n> 作者: ${item.author} | 赞同: ${item.voteup_count} | 链接: [${item.url}](${item.url})\n\n${convertHtmlToMarkdown(item.content, item.url)}\n\n---\n\n`; });
        saveAs(new Blob([md], { type: "text/markdown;charset=utf-8" }), `${title}.md`); log('Markdown 已导出');
    }
    function exportSingleHTML() {
        log('生成 HTML...'); const title = `知乎批量_${STATE.currentType}_${sanitizeFileName(STATE.id)}`;
        let html = `<html><head><meta charset="UTF-8"><meta name="referrer" content="no-referrer"><title>${title}</title></head><body><h1>${title}</h1>`;
        STATE.items.forEach((item) => { html += `<div style="margin-bottom:50px;border-bottom:1px solid #eee;padding-bottom:20px;"><h2><a href="${item.url}">${item.title}</a></h2><p>作者:${item.author}</p>${item.content}</div>`; });
        html += '</body></html>'; saveAs(new Blob([html], { type: "text/html;charset=utf-8" }), `${title}.html`); log('HTML 已导出');
    }
    function exportJSON() { const title = `知乎数据_${STATE.currentType}_${STATE.id}`; saveAs(new Blob([JSON.stringify(STATE.items, null, 2)], { type: "application/json;charset=utf-8" }), `${title}.json`); log('JSON 已导出'); }

    initPanelUI();
})();