// ==UserScript== // @name craber(DeepSeek 导出) // @namespace deepseek-craber // @version 0.1.0 // @description craber:导出 DeepSeek 对话为 Markdown。支持单条导出、批量 zip 导出、多会话导出,适配正文/代码/深度思考等。 // @author craber // @homepageURL https://github.com/yixing233/GPTCraber // @supportURL https://github.com/yixing233/GPTCraber/issues // @downloadURL https://raw.githubusercontent.com/yixing233/GPTCraber/main/deepseek-md-exporter.user.js // @updateURL https://raw.githubusercontent.com/yixing233/GPTCraber/main/deepseek-md-exporter.user.js // @match https://chat.deepseek.com/* // @grant unsafeWindow // @run-at document-idle // ==/UserScript== (function () { 'use strict'; /* ============================================================ * 常量与工具 * ========================================================== */ // 悬浮球用的螃蟹图标(内联 SVG)。fill 用 currentColor,蟹身颜色由容器的 // color 决定(.craber-fab-ball 里设为绿色),换平台时也统一走这一处。 const CRAB_SVG = ''; const SETTINGS_KEY = 'deepseek_craber_settings'; const DEFAULT_SETTINGS = { mode: 'qa', // 'qa' = 问答对;'ai' = 仅 AI 回复 includeThinking: false // 是否导出深度思考过程(thinking_content) }; function loadSettings() { try { const raw = localStorage.getItem(SETTINGS_KEY); return Object.assign({}, DEFAULT_SETTINGS, raw ? JSON.parse(raw) : {}); } catch (e) { return Object.assign({}, DEFAULT_SETTINGS); } } function saveSettings(s) { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)); } catch (e) {} } let settings = loadSettings(); // DeepSeek 对话页 URL:/a/chat/s/{sessionId} 或 /chat/{sessionId} function getConvId() { const m = location.pathname.match(/\/(?:a\/chat\/s|chat\/s|chat)\/([0-9a-f-]{16,})/i); return m ? m[1] : null; } function sanitizeFilename(s) { s = (s || '').replace(/[\\/:*?"<>|\n\r\t]/g, ' ').replace(/\s+/g, ' ').trim(); if (s.length > 60) s = s.slice(0, 60).trim(); return s || 'untitled'; } function triggerDownload(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1500); } function uuid() { // 简易 UUID v4(用于 sequence_id) return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } /* ============================================================ * 接口封装(豆包 IM 协议,POST + cookie 鉴权,无签名头) * /im/chain/recent_conv (cmd 3200) 会话列表 * /im/chain/single (cmd 3100) 单会话消息链 * ========================================================== */ // DeepSeek 用 Bearer token 鉴权,token 存在 localStorage.userToken 的 .value 字段。 function getToken() { try { const raw = localStorage.getItem('userToken'); if (!raw) return null; const obj = JSON.parse(raw); return obj && obj.value ? obj.value : null; } catch (e) { return null; } } // 统一 GET 请求:带 Bearer token,返回 biz_data。 async function dsGet(path) { const token = getToken(); if (!token) throw new Error('未登录或找不到 token(localStorage.userToken)'); const r = await fetch(path, { method: 'GET', credentials: 'include', headers: { authorization: 'Bearer ' + token } }); if (!r.ok) throw new Error('接口请求失败 ' + path + ': ' + r.status); const j = await r.json(); if (j.code !== 0) throw new Error('接口返回错误 code=' + j.code + ' ' + (j.msg || '')); return (j.data && j.data.biz_data) || {}; } const API = { // 拉一页会话列表。用游标 lte_cursor 翻页(返回 has_more)。 // 首页不带 cursor;后续用上一页最后一个会话的 updated_at 作为游标。 async getConversationsPage(cursor) { let path = '/api/v0/chat_session/fetch_page?count=100'; if (cursor) path += '<e_cursor.updated_at=' + encodeURIComponent(cursor) + '<e_cursor.pinned=false'; const b = await dsGet(path); const sessions = b.chat_sessions || []; return { sessions: sessions, hasMore: !!b.has_more, nextCursor: sessions.length ? sessions[sessions.length - 1].updated_at : null }; }, // 拉单会话的全部消息(DeepSeek 一次返回整段消息链,无需翻页)。 async getMessages(sessionId) { const b = await dsGet('/api/v0/chat/history_messages?chat_session_id=' + encodeURIComponent(sessionId)); return { messages: b.chat_messages || [] }; } }; // 用 GM_xmlhttpRequest 拉图(绕过 CORS),返回 Blob function gmFetchBlob(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'blob', onload: (res) => { if (res.status >= 200 && res.status < 300 && res.response) resolve(res.response); else reject(new Error('图片请求失败: ' + res.status)); }, onerror: () => reject(new Error('图片网络错误')) }); }); } function blobToDataURI(blob) { return new Promise((resolve, reject) => { const fr = new FileReader(); fr.onload = () => resolve(fr.result); fr.onerror = () => reject(fr.error || new Error('读取图片失败')); fr.readAsDataURL(blob); }); } function extFromMime(mime) { const map = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/svg+xml': 'svg', 'image/bmp': 'bmp' }; return map[(mime || '').toLowerCase()] || 'png'; } // 从图片 URL 猜扩展名(豆包图片 URL 形如 ...59065abec.png~tplv-...image.png?...) function extFromUrl(url) { const m = String(url || '').match(/\.(png|jpe?g|gif|webp|svg|bmp)(?![a-z])/i); return m ? m[1].toLowerCase().replace('jpeg', 'jpg') : 'png'; } /* ============================================================ * 内联 ZIP 打包器(仅 STORE 模式,零外部依赖、零 eval) * ========================================================== */ let _crcTable = null; function crcTable() { if (_crcTable) return _crcTable; const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); t[n] = c >>> 0; } _crcTable = t; return t; } function crc32(bytes) { const t = crcTable(); let c = 0xFFFFFFFF; for (let i = 0; i < bytes.length; i++) c = t[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; } function createZip() { const files = []; const encoder = new TextEncoder(); return { add(path, data) { files.push({ nameBytes: encoder.encode(path), data: data, crc: crc32(data) }); }, generate() { const localParts = []; const central = []; let offset = 0; for (const f of files) { const nameLen = f.nameBytes.length; const size = f.data.length; const lh = new DataView(new ArrayBuffer(30)); lh.setUint32(0, 0x04034b50, true); lh.setUint16(4, 20, true); lh.setUint16(6, 0, true); lh.setUint16(8, 0, true); lh.setUint16(10, 0, true); lh.setUint16(12, 0, true); lh.setUint32(14, f.crc, true); lh.setUint32(18, size, true); lh.setUint32(22, size, true); lh.setUint16(26, nameLen, true); lh.setUint16(28, 0, true); localParts.push(new Uint8Array(lh.buffer), f.nameBytes, f.data); const ch = new DataView(new ArrayBuffer(46)); ch.setUint32(0, 0x02014b50, true); ch.setUint16(4, 20, true); ch.setUint16(6, 20, true); ch.setUint16(8, 0, true); ch.setUint16(10, 0, true); ch.setUint16(12, 0, true); ch.setUint16(14, 0, true); ch.setUint32(16, f.crc, true); ch.setUint32(20, size, true); ch.setUint32(24, size, true); ch.setUint16(28, nameLen, true); ch.setUint16(30, 0, true); ch.setUint16(32, 0, true); ch.setUint16(34, 0, true); ch.setUint16(36, 0, true); ch.setUint32(38, 0, true); ch.setUint32(42, offset, true); central.push(new Uint8Array(ch.buffer), f.nameBytes); offset += 30 + nameLen + size; } let centralSize = 0; for (const p of central) centralSize += p.length; const centralOffset = offset; const eocd = new DataView(new ArrayBuffer(22)); eocd.setUint32(0, 0x06054b50, true); eocd.setUint16(4, 0, true); eocd.setUint16(6, 0, true); eocd.setUint16(8, files.length, true); eocd.setUint16(10, files.length, true); eocd.setUint32(12, centralSize, true); eocd.setUint32(16, centralOffset, true); eocd.setUint16(20, 0, true); const parts = localParts.concat(central, [new Uint8Array(eocd.buffer)]); return new Blob(parts, { type: 'application/zip' }); } }; } const _enc = new TextEncoder(); /* ============================================================ * 消息拉取 + 回合分组 * DeepSeek 的 history_messages 一次返回整个消息链(chat_messages[]), * 每条含 role(USER/ASSISTANT)、content、thinking_content、files 等, * 靠 message_id 顺序即可,无需翻页。 * ========================================================== */ // 拉取单会话全部消息,按 message_id 升序返回 async function fetchAllMessages(convId, onProgress) { const { messages } = await API.getMessages(convId); const all = (messages || []).slice(); all.sort((a, b) => Number(a.message_id) - Number(b.message_id)); if (onProgress) onProgress(all.length); return all; } // 按回合分组:一条 USER 提问 + 其后紧跟的 ASSISTANT 回复为一个回合。 // turn = { question: 首条user消息, questionMsgs: [全部user消息], answers: [assistant消息] } function groupTurns(messages) { const turns = []; let cur = null; let lastWasBot = true; // 让首条 user 消息能开启回合 for (const m of messages) { if (m.role === 'USER') { if (lastWasBot || !cur) { cur = { question: m, questionMsgs: [m], answers: [] }; turns.push(cur); } else { cur.questionMsgs.push(m); if (!cur.question) cur.question = m; } lastWasBot = false; } else if (m.role === 'ASSISTANT') { if (!cur) { cur = { question: null, questionMsgs: [], answers: [] }; turns.push(cur); } cur.answers.push(m); lastWasBot = true; } } return turns; } /* ============================================================ * 渲染 * ========================================================== */ // 清洗正文:删掉联网引用标记 [reference:0] / [!reference:3](接口不返回引用源, // 保留标记只会污染正文),并压掉因此产生的多余空格。 function cleanContent(text) { if (!text) return ''; return String(text) .replace(/\[!?reference:\d+\]/g, '') .replace(/[ \t]+\n/g, '\n') .trim(); } // 渲染一条消息的正文 + 附件。DeepSeek 的 content 本身就是 Markdown。 // files[] 只有文件名(无下载 URL),按文件名标注。 function renderMessage(msg) { let out = ''; const files = (msg && msg.files) || []; for (const f of files) { const name = (f && (f.file_name || f.name)) || '附件'; out += '📎 ' + name + '\n\n'; } const body = cleanContent(msg && msg.content); if (body) out += body + '\n\n'; return out; } // 取一条消息的纯文本(用于标题) function messageText(msg) { return cleanContent(msg && msg.content); } // 取一个回合的标题:首条有文本的提问;没有则用附件名,再退回回复首行。 function turnTitle(turn) { const msgs = (turn && turn.questionMsgs) || (turn && turn.question ? [turn.question] : []); for (const m of msgs) { const t = messageText(m); if (t) return t.split('\n')[0]; } for (const m of msgs) { const files = (m && m.files) || []; if (files.length) return files[0].file_name || files[0].name || '[附件]'; } for (const m of (turn.answers || [])) { const t = messageText(m); if (t) return t.split('\n')[0]; } return ''; } // 图片 sink 空实现:DeepSeek 无图片 URL,无需下载/打包,仅为兼容导出流程的调用签名。 function makeDataUriSink() { return null; } function makeZipImageSink() { return null; } // 渲染一个回合为 markdown,返回 { title, md } async function renderTurn(turn) { const title = turnTitle(turn); let md = ''; if (settings.mode === 'qa') { md += '## 🧑 问题\n\n'; const qMsgs = turn.questionMsgs || (turn.question ? [turn.question] : []); let qOut = ''; for (const qm of qMsgs) qOut += renderMessage(qm); md += (qOut.trim() || '(无文字提问)') + '\n\n'; md += '## 🤖 回答\n\n'; } for (const node of turn.answers) { if (settings.includeThinking && node.thinking_content && node.thinking_content.trim()) { const secs = node.thinking_elapsed_secs ? '(' + Math.round(node.thinking_elapsed_secs) + ' 秒)' : ''; md += '> 💭 思考过程' + secs + '\n>\n> ' + cleanContent(node.thinking_content).replace(/\n/g, '\n> ') + '\n\n'; } md += renderMessage(node); } return { title, md: md.trim() + '\n' }; } // 把整个会话渲染成单个 markdown async function renderConversationToMd(convId, convName, sink, onProgress) { const messages = await fetchAllMessages(convId); const turns = groupTurns(messages); let md = '# ' + (convName || '未命名会话') + '\n\n'; for (let i = 0; i < turns.length; i++) { const r = await renderTurn(turns[i], null, sink); md += r.md.trim() + '\n\n---\n\n'; if (onProgress) onProgress(i + 1, turns.length); } md = md.replace(/\n+---\n+$/, '\n'); return { title: convName || '未命名会话', md: md.trim() + '\n', turnCount: turns.length }; } /* ============================================================ * 状态:按 convId 缓存会话结构 * ========================================================== */ let state = { convId: null, name: null, turns: [], msgIndex: {} }; async function ensureState(force) { const convId = getConvId(); if (!convId) throw new Error('请先打开一个具体对话(URL 含 /chat/...)'); if (!force && state.convId === convId && state.turns.length) return state; const messages = await fetchAllMessages(convId); const turns = groupTurns(messages); const msgIndex = {}; turns.forEach((t) => { (t.questionMsgs || (t.question ? [t.question] : [])).forEach((q) => { if (q && q.message_id) msgIndex[q.message_id] = t; }); t.answers.forEach((a) => { if (a && a.message_id) msgIndex[a.message_id] = t; }); }); state = { convId, name: null, turns, msgIndex }; return state; } /* ============================================================ * 导出动作 * ========================================================== */ async function exportSingleByMessageId(messageId) { const st = await ensureState(); const turn = st.msgIndex[messageId]; if (!turn) throw new Error('未在会话结构中找到该消息,试试刷新页面'); const { title, md } = await renderTurn(turn); triggerDownload(new Blob([md], { type: 'text/markdown;charset=utf-8' }), sanitizeFilename(title) + '.md'); } async function exportBatch(selectedTurns, onProgress) { // 只选一轮:直接导出单个 md(图片 base64 内嵌),不打包 zip if (selectedTurns.length === 1) { const turn = selectedTurns[0]; const sink = makeDataUriSink(); const { title, md } = await renderTurn(turn, onProgress, sink); const seq = (state.turns ? state.turns.indexOf(turn) + 1 : 0) || 1; const pad = String((state.turns && state.turns.length) || 1).length; const name = String(seq).padStart(pad, '0') + '_' + sanitizeFilename(title); if (onProgress) onProgress(1, 1, '完成'); triggerDownload(new Blob([md], { type: 'text/markdown;charset=utf-8' }), name + '.md'); return; } const zip = createZip(); const sink = makeZipImageSink(zip); const used = {}; const pad = String((state.turns && state.turns.length) || selectedTurns.length).length; let i = 0; for (const turn of selectedTurns) { const { title, md } = await renderTurn(turn, null, sink); const seq = ((state.turns ? state.turns.indexOf(turn) : -1) + 1) || (i + 1); const prefix = String(seq).padStart(pad, '0') + '_'; let name = prefix + sanitizeFilename(title); if (used[name] != null) { used[name]++; name = name + ' (' + used[name] + ')'; } else used[name] = 0; zip.add(name + '.md', _enc.encode(md)); i++; if (onProgress) onProgress(i, selectedTurns.length); } if (onProgress) onProgress(selectedTurns.length, selectedTurns.length, '打包中…'); const blob = zip.generate(); const zipName = sanitizeFilename(state.name) || 'doubao-export'; triggerDownload(blob, zipName + '.zip'); } /* ============================================================ * 多会话:列表获取与批量导出 * ========================================================== */ // 从 DeepSeek 的 chat_session 提取会话元数据 function cellToMeta(s) { return { id: s.id, title: s.title || '未命名会话', createTime: Number(s.inserted_at) || 0, updateTime: Number(s.updated_at) || 0, badge: 0 }; } async function fetchAllConversations(onProgress) { const all = []; const seen = {}; let cursor = null; let guard = 0; while (guard++ < 200) { const page = await API.getConversationsPage(cursor); const sessions = page.sessions || []; let added = 0; for (const s of sessions) { if (!s.id || seen[s.id]) continue; // 去重:服务端游标偶尔返回重复 seen[s.id] = 1; all.push(cellToMeta(s)); added++; } if (onProgress) onProgress(all.length); // 没有新增(游标未推进/服务端忽略游标)或没有更多,停止,防死循环 if (!page.hasMore || !sessions.length || added === 0) break; if (!page.nextCursor || page.nextCursor === cursor) break; cursor = page.nextCursor; } return all; } // 导出多个会话:每个会话合并为单个 md,放进以会话标题命名的子文件夹(内含 images/) async function exportConversations(convMetas, onProgress) { const zip = createZip(); const used = {}; const pad = String(convMetas.length).length; let i = 0; for (const meta of convMetas) { i++; const seq = String(i).padStart(pad, '0'); let folder = seq + '_' + sanitizeFilename(meta.title); if (used[folder] != null) { used[folder]++; folder = folder + ' (' + used[folder] + ')'; } else used[folder] = 0; const prefix = folder + '/'; const sink = makeZipImageSink(zip, prefix); try { const { md } = await renderConversationToMd(meta.id, meta.title, sink, (d, t) => { if (onProgress) onProgress(i, convMetas.length, '(' + meta.title + ':' + d + '/' + t + ')'); }); zip.add(prefix + sanitizeFilename(meta.title) + '.md', _enc.encode(md)); } catch (e) { console.warn('[doubao-craber] 会话导出失败', meta.id, e); } if (onProgress) onProgress(i, convMetas.length); } if (onProgress) onProgress(convMetas.length, convMetas.length, '打包中…'); const blob = zip.generate(); triggerDownload(blob, 'doubao-conversations.zip'); } /* ============================================================ * UI:样式 * ========================================================== */ const style = document.createElement('style'); style.textContent = ` :host{ --craber-accent:#4b5bd6; --craber-accent-2:#3d4bc0; --craber-bg:#ffffff; --craber-fg:#1f2328; --craber-sub:#8a9099; --craber-line:#ececf0; --craber-hover:#f5f6f8; --craber-ghost:#f1f2f4; --craber-skeleton:#eceef1; --craber-skeleton-hi:#f6f7f9; all:initial; } /* 深色变量:跟随系统(媒体查询)或页面实际主题(.craber-dark 类,由 JS 检测 页面背景亮度后加到 host 上)。DeepSeek 等平台用的是页面自身深色主题,不改 系统设置,故媒体查询不够,必须靠 .craber-dark 兜底。 */ @media (prefers-color-scheme:dark){ :host{ --craber-bg:#26282c; --craber-fg:#e8eaed; --craber-sub:#9aa0a8; --craber-line:#3a3d43; --craber-hover:#2f3237; --craber-ghost:#34373d; --craber-skeleton:#33363b; --craber-skeleton-hi:#3c4046; } } :host(.craber-dark){ --craber-bg:#26282c; --craber-fg:#e8eaed; --craber-sub:#9aa0a8; --craber-line:#3a3d43; --craber-hover:#2f3237; --craber-ghost:#34373d; --craber-skeleton:#33363b; --craber-skeleton-hi:#3c4046; } @keyframes craber-fade-in{from{opacity:0}to{opacity:1}} @keyframes craber-pop-in{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}} @keyframes craber-shimmer{0%{background-position:-360px 0}100%{background-position:360px 0}} @keyframes craber-row-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}} @keyframes craber-spin{to{transform:rotate(360deg)}} /* 悬浮球:可拖拽、双击展开菜单。位置由 JS 用 left/top 定位并存 localStorage。 蟹图标用内联 SVG,蟹身填 currentColor(统一蟹绿),球底为白/浅色衬托。 */ .craber-fab-ball{position:fixed;z-index:99998;width:52px;height:52px;border-radius:50%; background:rgba(255,255,255,.3);color:#22a06b;border:none;cursor:grab; display:flex;align-items:center;justify-content:center; -webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px); box-shadow:0 4px 14px rgba(0,0,0,.22);user-select:none;touch-action:none; font-family:system-ui,sans-serif;transition:box-shadow .15s ease,transform .12s ease} .craber-fab-ball svg{width:30px;height:30px;pointer-events:none} .craber-fab-ball:hover{box-shadow:0 6px 20px rgba(0,0,0,.3)} .craber-fab-ball:active{cursor:grabbing} .craber-fab-ball.craber-dragging{transition:none;transform:scale(1.08)} /* 菜单展开/收起过渡:用 opacity+transform(display:none 无法过渡)。 收起态 pointer-events:none 防止不可见时误点;展开态各项交错淡入上滑。 */ /* 父级不做透明度过渡:否则收起时父级整体淡出会盖掉子项的交错,看起来“一次性消失”。 可见性完全交给各子项自己的 opacity,父级只用 pointer-events 管交互。 */ .craber-fab-menu{position:fixed;z-index:99998;display:flex;flex-direction:column;gap:8px; pointer-events:none} .craber-fab-menu.craber-open{pointer-events:auto} .craber-fab-item{background:var(--craber-bg);color:var(--craber-fg);border:none;border-radius:22px; padding:11px 18px;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap; box-shadow:0 4px 14px rgba(0,0,0,.18);font-family:system-ui,sans-serif; opacity:0; transition:background .15s ease,opacity .24s ease,transform .24s cubic-bezier(.2,.8,.25,1)} /* 项的初始位移方向跟随展开方向:向上展开(菜单在球上方)时项从下方滑入(+12px); 向下展开时从上方滑入(-12px)。动画方向与展开方向一致,视觉更自然。 */ .craber-fab-menu.craber-up .craber-fab-item{transform:translateY(12px) scale(.9)} .craber-fab-menu.craber-down .craber-fab-item{transform:translateY(-12px) scale(.9)} .craber-fab-menu.craber-open .craber-fab-item{opacity:1;transform:none} /* 交错延迟由 JS 逐项设内联 transition-delay(开合方向不同,见 setStagger), CSS 特异度那套在 shadow DOM 里不稳,改由 JS 精确控制。 */ .craber-fab-item:hover{background:var(--craber-hover)} .craber-mask{position:fixed;inset:0;background:rgba(15,18,20,.55); z-index:99999;display:flex;align-items:center;justify-content:center; font-family:system-ui,sans-serif;animation:craber-fade-in .18s ease} .craber-panel{background:var(--craber-bg);color:var(--craber-fg);width:580px;max-width:92vw;max-height:84vh; border-radius:16px;display:flex;flex-direction:column;overflow:hidden; box-shadow:0 20px 60px rgba(0,0,0,.3);animation:craber-pop-in .22s cubic-bezier(.2,.8,.25,1)} .craber-hd{padding:16px 20px;border-bottom:1px solid var(--craber-line); display:flex;align-items:center;justify-content:space-between} .craber-hd h3{margin:0;font-size:15px;font-weight:600;letter-spacing:.2px} .craber-x{border:none;background:none;font-size:22px;cursor:pointer;color:var(--craber-sub); line-height:1;width:30px;height:30px;border-radius:8px;transition:background .15s,color .15s} .craber-x:hover{background:var(--craber-hover);color:var(--craber-fg)} .craber-opts{padding:14px 20px;border-bottom:1px solid var(--craber-line); display:flex;flex-direction:column;gap:12px;font-size:13px} .craber-group{display:flex;align-items:flex-start;gap:12px} .craber-group-label{flex:none;width:56px;padding-top:9px;font-size:12px; color:var(--craber-sub);font-weight:500;line-height:1} .craber-chips{display:flex;flex-wrap:wrap;gap:8px;flex:1} .craber-search{width:100%;box-sizing:border-box;padding:9px 12px;font-size:13px; border:1px solid var(--craber-line);border-radius:10px;background:var(--craber-bg); color:var(--craber-fg);outline:none;transition:border-color .15s} .craber-search:focus{border-color:var(--craber-accent)} .craber-search::placeholder{color:var(--craber-sub)} .craber-filter-row{display:flex;flex-direction:column;gap:8px} .craber-date-custom{display:flex;align-items:center;gap:8px;margin-top:2px} .craber-date-custom[hidden]{display:none} .craber-date-input{padding:7px 10px;font-size:12px;border:1px solid var(--craber-line); border-radius:8px;background:var(--craber-bg);color:var(--craber-fg);outline:none; color-scheme:light dark;font-family:inherit;cursor:pointer;transition:border-color .15s,box-shadow .15s} .craber-date-input:hover{border-color:var(--craber-accent)} .craber-date-input:focus{border-color:var(--craber-accent);box-shadow:0 0 0 3px rgba(75,91,214,.12)} .craber-date-sep{color:var(--craber-sub);font-size:12px} .craber-chip{display:inline-flex;align-items:center;gap:7px;cursor:pointer;user-select:none; padding:7px 12px;border:1px solid var(--craber-line);border-radius:20px; color:var(--craber-fg);transition:border-color .15s,background .15s} .craber-chip:hover{background:var(--craber-hover)} .craber-chip input{position:absolute;opacity:0;width:0;height:0} .craber-box{width:16px;height:16px;border:1.5px solid var(--craber-sub);border-radius:5px; flex:none;box-sizing:border-box;position:relative; transition:background .15s,border-color .15s} .craber-chip input[type=radio]+.craber-box{border-radius:50%} .craber-box::after{content:'';position:absolute;opacity:0;transition:opacity .12s} .craber-chip input[type=checkbox]:checked+.craber-box::after, .craber-item input[type=checkbox]:checked+.craber-box::after{ opacity:1;left:0;right:0;top:-1px;bottom:0;margin:auto;width:4px;height:8px; border:solid #fff;border-width:0 2px 2px 0;transform:rotate(45deg)} .craber-chip input[type=radio]:checked+.craber-box::after{ opacity:1;inset:0;margin:auto;width:6px;height:6px;border-radius:50%;background:#fff} .craber-chip input:checked+.craber-box, .craber-item input:checked+.craber-box{background:var(--craber-accent);border-color:var(--craber-accent)} .craber-chip:has(input:checked){border-color:var(--craber-accent); background:color-mix(in srgb,var(--craber-accent) 12%,transparent);color:var(--craber-accent)} .craber-chip input:focus-visible+.craber-box{outline:2px solid var(--craber-accent);outline-offset:2px} .craber-list{overflow-y:auto;padding:6px 12px;flex:1;min-height:120px} /* 细滚动条:作用于列表与预览正文。Firefox 用 scrollbar-*,WebKit 用伪元素 */ .craber-list,.craber-preview-body{ scrollbar-width:thin;scrollbar-color:var(--craber-line) transparent} .craber-list::-webkit-scrollbar,.craber-preview-body::-webkit-scrollbar{width:8px;height:8px} .craber-list::-webkit-scrollbar-track,.craber-preview-body::-webkit-scrollbar-track{background:transparent} .craber-list::-webkit-scrollbar-thumb,.craber-preview-body::-webkit-scrollbar-thumb{ background:var(--craber-line);border-radius:8px;border:2px solid transparent; background-clip:content-box} .craber-list:hover::-webkit-scrollbar-thumb,.craber-preview-body:hover::-webkit-scrollbar-thumb{ background:var(--craber-sub);background-clip:content-box} .craber-item{display:flex;align-items:flex-start;gap:11px;padding:11px 10px;border-radius:10px; font-size:13px;cursor:pointer;transition:background .12s;animation:craber-row-in .28s ease both; position:relative} .craber-item:hover{background:var(--craber-hover)} .craber-item .craber-box{margin-top:1px} .craber-item input{position:absolute;opacity:0;width:0;height:0} .craber-item .q{flex:1;line-height:1.5;color:var(--craber-fg);word-break:break-word} .craber-item .meta{color:var(--craber-sub);font-size:11px;margin-top:3px} .craber-sk{padding:11px 10px;display:flex;gap:11px;align-items:flex-start} .craber-sk .b{border-radius:6px; background:linear-gradient(90deg,var(--craber-skeleton) 25%,var(--craber-skeleton-hi) 37%,var(--craber-skeleton) 63%); background-size:720px 100%;animation:craber-shimmer 1.3s linear infinite} .craber-sk .box{width:16px;height:16px;border-radius:5px;flex:none;margin-top:1px} .craber-sk .lines{flex:1} .craber-sk .l1{height:12px;width:82%;margin-bottom:8px} .craber-sk .l2{height:9px;width:38%} .craber-empty{padding:36px 18px;text-align:center;color:var(--craber-sub);font-size:13px} .craber-ft{padding:13px 20px;border-top:1px solid var(--craber-line); display:flex;align-items:center;gap:10px} .craber-ft .spacer{flex:1} .craber-btn{border:none;border-radius:10px;padding:9px 16px;font-size:13px;font-weight:500; cursor:pointer;transition:background .15s,transform .1s,opacity .15s} .craber-btn:active{transform:scale(.97)} .craber-btn.primary{background:var(--craber-accent);color:#fff;box-shadow:0 2px 8px rgba(75,91,214,.3)} .craber-btn.primary:hover{background:var(--craber-accent-2)} .craber-btn.primary:disabled{opacity:.6;cursor:default;box-shadow:none} .craber-btn.ghost{background:var(--craber-ghost);color:var(--craber-fg)} .craber-btn.ghost:hover{background:var(--craber-hover)} .craber-status{font-size:12px;color:var(--craber-sub);display:inline-flex;align-items:center;gap:6px} .craber-spin{width:12px;height:12px;border:2px solid var(--craber-sub);border-top-color:transparent; border-radius:50%;animation:craber-spin .7s linear infinite;display:inline-block} .craber-preview-btn{position:absolute;top:8px;right:8px;flex:none; border:1px solid var(--craber-line);background:var(--craber-bg);color:var(--craber-sub); border-radius:8px;padding:3px 10px;font-size:11px;cursor:pointer; opacity:0;transform:translateX(4px);pointer-events:none; transition:opacity .15s,transform .15s,background .15s,color .15s,border-color .15s} .craber-item:hover .craber-preview-btn{opacity:1;transform:none;pointer-events:auto} .craber-preview-btn:hover{background:var(--craber-accent);color:#fff;border-color:var(--craber-accent)} .craber-preview-mask{position:fixed;inset:0;background:rgba(15,18,20,.55); z-index:100000;display:flex;align-items:center;justify-content:center; font-family:system-ui,sans-serif;animation:craber-fade-in .15s ease} .craber-preview-panel{background:var(--craber-bg);color:var(--craber-fg); width:720px;max-width:92vw;max-height:86vh;border-radius:16px; display:flex;flex-direction:column;overflow:hidden; box-shadow:0 20px 60px rgba(0,0,0,.3);animation:craber-pop-in .2s cubic-bezier(.2,.8,.25,1)} .craber-preview-body{overflow-y:auto;padding:20px 24px;flex:1;line-height:1.7;font-size:14px} .craber-preview-body img{max-width:100%;height:auto;border-radius:8px;margin:8px 0} .craber-preview-body pre{background:var(--craber-ghost);padding:12px 14px;border-radius:8px; overflow-x:auto;font-size:12.5px} .craber-preview-body code{background:var(--craber-ghost);padding:1px 5px;border-radius:4px;font-size:12.5px} .craber-preview-body pre code{background:none;padding:0} .craber-preview-body h1,.craber-preview-body h2,.craber-preview-body h3{margin:.8em 0 .4em} .craber-preview-body blockquote{border-left:3px solid var(--craber-line); margin:.6em 0;padding:.2em 0 .2em 12px;color:var(--craber-sub)} .craber-preview-body table{border-collapse:collapse;margin:.6em 0;font-size:13px} .craber-preview-body th,.craber-preview-body td{border:1px solid var(--craber-line);padding:5px 10px} .craber-preview-body a{color:var(--craber-accent);text-decoration:none} .craber-preview-body a:hover{text-decoration:underline} `; // Shadow DOM 隔离:豆包是 React SPA,会在重渲染时清掉它 diff 不到的 DOM 节点, // 无论挂到 body 还是 html 下的普通节点都会被反复删除,表现为面板“闪没又出现”。 // 解法:建一个 host 挂到 html 下,在其 shadow root 内放样式与所有 UI。 // shadow root 的内容对 React 的 diff 完全不可见,永远不会被清理。 // craberRoot() 返回该 shadow root,所有面板/按钮都 append 到它里面。 // 探测页面「实际」主题:媒体查询只认系统设置,但平台常有自己的明暗主题 // (如系统浅色、DeepSeek 深色)。故读 body/html 背景色的亮度判断,深色时给 // host 加 .craber-dark 类,CSS 里 :host(.craber-dark) 覆盖出深色变量。 function detectDark() { try { const bodyBg = getComputedStyle(document.body).backgroundColor || getComputedStyle(document.documentElement).backgroundColor; const m = String(bodyBg).match(/rgba?\(([^)]+)\)/); if (m) { const p = m[1].split(',').map((s) => parseFloat(s)); // 透明背景(alpha 0)当作浅色处理,避免误判 if (p.length >= 4 && p[3] === 0) return false; const lum = 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]; return lum < 128; } } catch (e) {} return window.matchMedia && window.matchMedia('(prefers-color-scheme:dark)').matches; } let _craberShadow = null; function craberRoot() { if (_craberShadow && _craberShadow.host && _craberShadow.host.isConnected) { applyTheme(); return _craberShadow; } const host = document.createElement('div'); host.id = 'craber-host'; // host 本身不占布局,真正的定位由内部 .craber-mask/.craber-fab-wrap 的 fixed 完成 host.style.cssText = 'all:initial'; const shadow = host.attachShadow({ mode: 'open' }); shadow.appendChild(style); document.documentElement.appendChild(host); _craberShadow = shadow; _craberHost = host; applyTheme(); return shadow; } let _craberHost = null; // 每次打开 UI 时按当前页面主题刷新 host 类(用户中途切主题也能跟上) function applyTheme() { if (_craberHost) _craberHost.classList.toggle('craber-dark', detectDark()); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } /* ============================================================ * UI:批量导出面板(当前会话) * ========================================================== */ function openPanel() { const mask = document.createElement('div'); mask.className = 'craber-mask'; mask.innerHTML = `
' + c + '');
return 'CBMDCODE' + (codes.length - 1) + 'ENDCODE';
});
t = t.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (m, alt, src) =>
'' + para.map((l) => inlineMd(escapeHtml(l))).join('
') + '
' + escapeHtml(code.join('\n')) + '');
continue;
}
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) {
flushAll();
out.push('' + inlineMd(escapeHtml(quote.join('\n'))).replace(/\n/g, ''); continue; } if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|?[\s:-]*\|[\s:|-]*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) { flushAll(); const parseRow = (r) => r.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim()); const head = parseRow(line); i += 2; const rows = []; while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(parseRow(lines[i])); i++; } let tbl = '
') + '
| ' + inlineMd(escapeHtml(c)) + ' | ').join('') + '
|---|
| ' + inlineMd(escapeHtml(c)) + ' | ').join('') + '