Greasy Fork is available in English.
将“复制 Markdown”按钮移动到回答/文章顶部(赞同数旁边),防止被遮挡。支持批量抓取/导出 HTML/JSON/Markdown。
当前为
// ==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\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(); })();