// ==UserScript== // @name 智谱清言对话导出 Markdown // @namespace https://local/zmm // @version 1.2 // @description 在智谱清言网页版添加「导出 MD」按钮,将当前对话导出为 Markdown 文件(零外部依赖) // @match https://chatglm.cn/* // @match https://www.chatglm.cn/* // @match https://*.chatglm.cn/* // @grant none // @run-at document-idle // @license MPL-2.0 // ==/UserScript== (function () { 'use strict'; console.log('[ChatGLM导出] 脚本已加载', location.href); /* ---------------- HTML -> Markdown(自实现,无外部依赖) ---------------- */ const ELEM = 1, TEXT = 3; // 行内元素转换:粗体/斜体/删除线/行内代码/链接/图片/换行/公式 function inlineMD(node) { let out = ''; node.childNodes.forEach((n) => { if (n.nodeType === TEXT) { out += n.textContent.replace(/\s+/g, ' '); return; } if (n.nodeType !== ELEM) return; const tag = n.tagName.toLowerCase(); const inner = () => inlineMD(n).trim(); if (tag === 'strong' || tag === 'b') out += '**' + inner() + '**'; else if (tag === 'em' || tag === 'i') out += '*' + inner() + '*'; else if (tag === 'del' || tag === 's') out += '~~' + inner() + '~~'; else if (tag === 'code') out += '`' + n.textContent + '`'; else if (tag === 'a') out += '[' + inner() + '](' + (n.getAttribute('href') || '') + ')'; else if (tag === 'img') out += '![' + (n.getAttribute('alt') || '图片') + '](' + (n.getAttribute('src') || '') + ')'; else if (tag === 'br') out += '\n'; else if (n.classList && n.classList.contains('katex')) { // KaTeX 公式:从 annotation 中取原始 LaTeX const anno = n.querySelector('annotation[encoding]'); out += anno ? '$' + anno.textContent.trim() + '$' : n.textContent; } else out += inlineMD(n); }); return out; } // 容器(div 等):逐个子块转换后拼接 function containerMD(el, indent) { const parts = []; el.childNodes.forEach((n) => { if (n.nodeType === TEXT) { const t = n.textContent.trim(); if (t) parts.push(indent + t); } else if (n.nodeType === ELEM) { const s = blockMD(n, indent); if (s) parts.push(s); } }); return parts.join('\n\n'); } // 列表(支持嵌套、有序/无序) function listMD(el, indent) { const ordered = el.tagName.toLowerCase() === 'ol'; const items = [...el.children].filter((c) => c.tagName.toLowerCase() === 'li'); const lines = []; items.forEach((li, i) => { const marker = ordered ? (i + 1) + '. ' : '- '; const subIndent = indent + ' '.repeat(marker.length); const itemParts = []; li.childNodes.forEach((c) => { if (c.nodeType === TEXT) { const t = c.textContent.trim(); if (t) itemParts.push(indent + marker + t); } else if (c.nodeType === ELEM) { const tag = c.tagName.toLowerCase(); if (tag === 'ul' || tag === 'ol') { const sub = listMD(c, subIndent); if (sub) itemParts.push(sub); } else if (tag === 'p') { itemParts.push(indent + marker + inlineMD(c).trim()); } else { const s = blockMD(c, subIndent); if (s) itemParts.push(indent + marker + s.replace(/\n/g, '\n' + subIndent)); } } }); if (itemParts.length) lines.push(itemParts.join('\n')); }); return lines.join('\n'); } // 表格(GFM 格式) function tableMD(el, indent) { const rows = [...el.querySelectorAll('tr')].map((tr) => [...tr.querySelectorAll('th,td')].map((c) => inlineMD(c).trim().replace(/\|/g, '\\|').replace(/\n/g, ' ') ) ); if (!rows.length) return ''; const width = Math.max(...rows.map((r) => r.length)); const pad = (r) => { while (r.length < width) r.push(''); return r; }; const line = (r) => indent + '| ' + pad(r).join(' | ') + ' |'; return [line(rows[0]), indent + '|' + ' --- |'.repeat(width), ...rows.slice(1).map(line)].join('\n'); } // 块级元素转换 function blockMD(el, indent = '') { const tag = el.tagName.toLowerCase(); if (/^h[1-6]$/.test(tag)) return indent + '#'.repeat(+tag[1]) + ' ' + inlineMD(el).trim(); if (tag === 'p') { const t = inlineMD(el).trim(); return t ? indent + t : ''; } if (tag === 'pre') { const code = el.querySelector('code'); const m = ((code && code.className) || '').match(/language-([\w+#-]+)/); const lang = m ? m[1] : ''; const text = ((code || el).textContent || '').replace(/\n$/, ''); return indent + '```' + lang + '\n' + text + '\n' + indent + '```'; } if (tag === 'ul' || tag === 'ol') return listMD(el, indent); if (tag === 'table') return tableMD(el, indent); if (tag === 'blockquote') { const inner = containerMD(el, ''); return inner.split('\n').map((l) => indent + '> ' + l).join('\n'); } if (tag === 'hr') return indent + '---'; if (tag === 'br') return ''; return containerMD(el, indent); } function htmlToMD(root) { const parts = []; root.childNodes.forEach((n) => { if (n.nodeType === TEXT) { const t = n.textContent.trim(); if (t) parts.push(t); } else if (n.nodeType === ELEM) { const s = blockMD(n, ''); if (s) parts.push(s); } }); return parts.join('\n\n'); } /* ---------------- 导出逻辑 ---------------- */ // 获取当前对话标题(侧边栏 .conversation-name;找不到了回落到页面标题) function getConversationTitle() { const names = [...document.querySelectorAll('.conversation-name')]; if (!names.length) return (document.title || '对话记录').trim(); // 优先取激活状态(active/selected/current)的会话名 const active = names.find((n) => n.closest('[class*="active"], [class*="selected"], [class*="current"]')) || names.find((n) => getComputedStyle(n).color !== 'rgba(0, 0, 0, 0)' && n.offsetParent !== null) || names[0]; const t = active.textContent.trim(); return t || (document.title || '对话记录').trim(); } function buildMarkdown(title) { const items = document.querySelectorAll('.conversation-item'); if (!items.length) return null; const lines = []; lines.push('# ' + title); lines.push(''); lines.push('> 导出时间:' + new Date().toLocaleString()); lines.push(''); lines.push('---'); lines.push(''); items.forEach((item) => { // 用户提问 const q = item.querySelector('.conversation.question .question-txt'); if (q && q.textContent.trim()) { lines.push('## 🧑 用户'); lines.push(''); lines.push(q.textContent.trim()); lines.push(''); } // AI 回答 item.querySelectorAll('.answer .markdown-body').forEach((md) => { const text = htmlToMD(md).trim(); if (!text) return; lines.push('## 🤖 ChatGLM'); lines.push(''); lines.push(text); lines.push(''); }); lines.push('---'); lines.push(''); }); return lines.join('\n'); } // 文件名:标题 + yyyyMMddHHmmss function timestamp() { const p = (n) => String(n).padStart(2, '0'); const d = new Date(); return ( d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()) ); } function download(md, title) { const safeTitle = title.replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 50) || '对话记录'; const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = safeTitle + '_' + timestamp() + '.md'; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); } function exportConversation() { const title = getConversationTitle(); const md = buildMarkdown(title); if (!md) { alert('未找到对话内容,请确认已打开一个对话页面'); return; } console.log('[ChatGLM导出] 标题:', title); download(md, title); } /* ---------------- 按钮注入 ---------------- */ function addExportButton() { if (document.getElementById('mc-export-md-btn')) return; if (!document.body) return; const btn = document.createElement('button'); btn.id = 'mc-export-md-btn'; btn.textContent = '导出 MD'; btn.style.cssText = [ 'position:fixed', 'right:24px', 'bottom:120px', 'z-index:2147483647', 'padding:8px 14px', 'border:none', 'border-radius:8px', 'background:#3b82f6', 'color:#fff', 'font-size:13px', 'cursor:pointer', 'box-shadow:0 2px 8px rgba(0,0,0,.25)', ].join(';'); btn.onclick = exportConversation; document.body.appendChild(btn); console.log('[ChatGLM导出] 按钮已添加'); } addExportButton(); setInterval(addExportButton, 2000); })();