// ==UserScript==
// @name ChatGPT一键导出聊天到obsidian
// @namespace https://chatgpt.com/
// @version 1.0.0
// @description 在 ChatGPT 网页中一键导出当前会话到 Obsidian,或下载为 Markdown。消息内容通过网页“复制消息/复制回复”按钮获取。
// @author OpenAI
// @match https://chatgpt.com/*
// @match https://chat.openai.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_notification
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const STORE_PREFIX = 'chatgpt_export_obsidian_';
const BTN_OBSIDIAN_ID = 'chatgpt-export-obsidian-btn';
const BTN_MD_ID = 'chatgpt-download-markdown-btn';
const TOAST_ID = 'chatgpt-export-obsidian-toast';
const DEFAULT_FOLDER = 'ChatGPT';
// ---------- 存储 ----------
async function getValue(key, defaultValue) {
try {
if (typeof GM_getValue === 'function') {
return await Promise.resolve(GM_getValue(key, defaultValue));
}
} catch (_) {}
try {
const raw = localStorage.getItem(STORE_PREFIX + key);
return raw == null ? defaultValue : JSON.parse(raw);
} catch (_) {
return defaultValue;
}
}
async function setValue(key, value) {
try {
if (typeof GM_setValue === 'function') {
return await Promise.resolve(GM_setValue(key, value));
}
} catch (_) {}
try {
localStorage.setItem(STORE_PREFIX + key, JSON.stringify(value));
} catch (_) {}
}
// ---------- UI 优化版 (修复 notify 未定义问题) ----------
function toast(message, type = 'info', duration = 2800) {
let el = document.getElementById(TOAST_ID);
if (!el) {
el = document.createElement('div');
el.id = TOAST_ID;
document.body.appendChild(el);
Object.assign(el.style, {
position: 'fixed',
left: '50%',
bottom: '85px',
transform: 'translateX(-50%) translateY(20px)',
zIndex: '1000000',
padding: '12px 20px',
borderRadius: '12px',
color: '#fff',
fontSize: '14px',
fontWeight: '500',
boxShadow: '0 10px 25px -5px rgba(0,0,0,0.4)',
opacity: '0',
pointerEvents: 'none',
transition: 'all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
gap: '8px'
});
}
const colors = {
info: 'rgba(31, 41, 55, 0.95)',
success: 'rgba(16, 185, 129, 0.95)',
error: 'rgba(239, 68, 68, 0.95)',
warn: 'rgba(245, 158, 11, 0.95)'
};
el.textContent = (type === 'success' ? '✓ ' : '') + message;
el.style.background = colors[type] || colors.info;
el.style.opacity = '1';
el.style.transform = 'translateX(-50%) translateY(0)';
clearTimeout(el._timer);
el._timer = setTimeout(() => {
el.style.opacity = '0';
el.style.transform = 'translateX(-50%) translateY(20px)';
}, duration);
}
// 重新补回缺失的 notify 函数 [cite: 14]
function notify(text) {
if (typeof GM_notification === 'function') {
try {
GM_notification({
title: 'ChatGPT 导出',
text
});
} catch (_) {}
}
}
function buildMiniButton({ id, text, bg, icon }) {
let btn = document.getElementById(id);
if (btn) return btn;
btn = document.createElement('button');
btn.id = id;
btn.type = 'button';
// 结构优化:确保收缩时图标完美居中
btn.innerHTML = `
${icon || '•'}
${text}
`;
Object.assign(btn.style, {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
width: '44px',
height: '44px',
padding: '0',
borderRadius: '22px',
background: bg,
color: '#fff',
border: '1px solid rgba(255,255,255,0.1)',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
overflow: 'hidden',
outline: 'none'
});
btn.addEventListener('mouseenter', () => {
if (!btn.disabled) {
btn.style.boxShadow = '0 6px 20px rgba(0,0,0,0.4)';
btn.style.transform = 'translateY(-2px)';
btn.style.filter = 'brightness(1.1)';
}
});
btn.addEventListener('mouseleave', () => {
if (!btn.disabled) {
btn.style.transform = 'translateY(0)';
btn.style.filter = 'brightness(1)';
}
});
return btn;
}
function ensureButtons() {
if (!document.body) return;
let wrap = document.getElementById('chatgpt-export-floating-wrap');
if (!wrap) {
wrap = document.createElement('div');
wrap.id = 'chatgpt-export-floating-wrap';
Object.assign(wrap.style, {
position: 'fixed',
right: '24px',
bottom: '24px',
zIndex: '999999',
display: 'flex',
flexDirection: 'column-reverse',
alignItems: 'flex-end',
gap: '12px',
padding: '4px'
});
document.body.appendChild(wrap);
}
const mdBtn = buildMiniButton({
id: BTN_MD_ID,
text: '下载 Markdown',
bg: '#374151',
icon: '📥'
});
const obsidianBtn = buildMiniButton({
id: BTN_OBSIDIAN_ID,
text: '导出到 Obsidian',
bg: '#6c35b8',
icon: '💎'
});
if (!mdBtn.parentElement) wrap.appendChild(mdBtn);
if (!obsidianBtn.parentElement) wrap.appendChild(obsidianBtn);
function setExpanded(btn, expanded) {
const textEl = btn.querySelector('.cgpt-btn-text');
if (!textEl) return;
if (expanded) {
btn.style.width = '170px';
btn.style.paddingRight = '16px';
textEl.style.opacity = '1';
textEl.style.width = 'auto';
textEl.style.marginLeft = '4px';
} else {
btn.style.width = '44px';
btn.style.paddingRight = '0';
textEl.style.opacity = '0';
textEl.style.width = '0px';
textEl.style.marginLeft = '0';
}
}
if (!wrap.dataset.boundHover) {
wrap.dataset.boundHover = '1';
wrap.addEventListener('mouseenter', () => {
[mdBtn, obsidianBtn].forEach(b => setExpanded(b, true));
});
wrap.addEventListener('mouseleave', () => {
[mdBtn, obsidianBtn].forEach(b => setExpanded(b, false));
});
}
if (!obsidianBtn.dataset.bound) {
obsidianBtn.dataset.bound = '1';
obsidianBtn.addEventListener('click', exportToObsidian);
}
if (!mdBtn.dataset.bound) {
mdBtn.dataset.bound = '1';
mdBtn.addEventListener('click', downloadCurrentChatMarkdown);
}
}
function setButtonBusy(btn, busy, busyText, normalText) {
if (!btn) return;
const textEl = btn.querySelector('.cgpt-btn-text');
const iconEl = btn.querySelector('.cgpt-btn-icon');
btn.disabled = !!busy;
btn.style.opacity = busy ? '0.7' : '1';
btn.style.cursor = busy ? 'not-allowed' : 'pointer';
if (textEl && normalText && busyText) textEl.textContent = busy ? busyText : normalText;
if (busy) {
iconEl.style.animation = 'cgpt-spin 1s linear infinite';
if (!document.getElementById('cgpt-style-spin')) {
const style = document.createElement('style');
style.id = 'cgpt-style-spin';
style.textContent = '@keyframes cgpt-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }';
document.head.appendChild(style);
}
} else {
iconEl.style.animation = 'none';
}
}
// ---------- 配置 ----------
async function configure(force = false) {
let vault = await getValue('vault', '');
let folder = await getValue('folder', DEFAULT_FOLDER);
if (force || !vault) {
vault = (prompt('请输入你的 Obsidian Vault 名称(必须和 Obsidian 中显示的一致)', vault || '') || '').trim();
if (!vault) throw new Error('未设置 Vault 名称');
await setValue('vault', vault);
}
if (force || folder == null) {
const nextFolder = prompt('请输入导出目录(留空表示导出到根目录)', folder || DEFAULT_FOLDER);
folder = (nextFolder == null ? folder : nextFolder).trim();
await setValue('folder', folder);
}
return { vault, folder };
}
// ---------- 工具 ----------
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function pad2(n) {
return String(n).padStart(2, '0');
}
function formatLocalDateTime(d = new Date()) {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
}
function formatCompactDateTime(d = new Date()) {
return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
}
function getConversationId() {
const m = location.pathname.match(/\/c\/([a-zA-Z0-9-]+)/);
return m ? m[1] : '';
}
function getConversationTitle() {
const raw = document.title || 'ChatGPT 对话';
return raw.replace(/\s*-\s*ChatGPT\s*$/i, '').trim() || 'ChatGPT 对话';
}
function sanitizeSegment(name) {
return String(name || '')
.replace(/[<>:"\\|?*\u0000-\u001F]/g, ' ')
.replace(/\s+/g, ' ')
.replace(/\.+$/g, '')
.trim()
.slice(0, 120) || 'untitled';
}
function sanitizeFolderPath(folder) {
return String(folder || '')
.split('/')
.map(s => sanitizeSegment(s))
.filter(Boolean)
.join('/');
}
function yamlString(value) {
return JSON.stringify(String(value ?? ''));
}
function cleanWhitespace(text) {
return String(text || '')
.replace(/\r/g, '')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
async function readClipboardSafe() {
try {
return await navigator.clipboard.readText();
} catch (_) {
return '';
}
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (_) {}
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
Object.assign(ta.style, {
position: 'fixed',
top: '-1000px',
left: '-1000px',
opacity: '0'
});
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return !!ok;
} catch (_) {
return false;
}
}
function downloadMarkdown(filename, content) {
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename.endsWith('.md') ? filename : `${filename}.md`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
}
function openObsidian(vault, filePath, useClipboard = true, content = '') {
const params = new URLSearchParams();
params.set('vault', vault);
params.set('file', filePath);
params.set('overwrite', 'true');
if (useClipboard) {
params.set('clipboard', 'true');
} else {
params.set('content', content);
}
const url = `obsidian://new?${params.toString()}`;
window.location.href = url;
}
// ---------- DOM -> Markdown 兜底 ----------
function shouldIgnoreNode(node) {
if (!node || node.nodeType !== 1) return false;
const el = node;
const tag = el.tagName.toLowerCase();
if (['button', 'svg', 'path', 'textarea', 'input', 'select', 'option', 'nav', 'noscript', 'script', 'style'].includes(tag)) {
return true;
}
if (el.getAttribute('aria-hidden') === 'true') return true;
if (el.classList.contains('sr-only')) return true;
return false;
}
function detectCodeLang(el) {
const code = el.querySelector('code') || el;
const className = `${code.className || ''} ${el.className || ''}`;
const m = className.match(/language-([a-z0-9_+-]+)/i);
return m ? m[1] : '';
}
function serializeInlineChildren(node) {
return Array.from(node.childNodes).map(serializeNode).join('');
}
function serializeTable(table) {
const rows = Array.from(table.querySelectorAll('tr')).map(tr => {
return Array.from(tr.children).map(td => {
const cell = cleanWhitespace(serializeInlineChildren(td))
.replace(/\n/g, '
')
.replace(/\|/g, '\\|');
return cell || ' ';
});
});
if (!rows.length) return '';
const header = rows[0];
const sep = header.map(() => '---');
const lines = [
`| ${header.join(' | ')} |`,
`| ${sep.join(' | ')} |`,
...rows.slice(1).map(r => `| ${r.join(' | ')} |`)
];
return lines.join('\n');
}
function serializeList(listEl, depth = 0, ordered = false) {
const items = Array.from(listEl.children).filter(li => li.tagName && li.tagName.toLowerCase() === 'li');
return items.map((li, i) => serializeListItem(li, depth, ordered, i)).join('\n');
}
function serializeListItem(li, depth, ordered, index) {
const indent = ' '.repeat(depth);
const bullet = ordered ? `${index + 1}. ` : '- ';
let main = '';
let nested = '';
for (const child of Array.from(li.childNodes)) {
if (child.nodeType === 1) {
const tag = child.tagName.toLowerCase();
if (tag === 'ul') {
nested += '\n' + serializeList(child, depth + 1, false);
continue;
}
if (tag === 'ol') {
nested += '\n' + serializeList(child, depth + 1, true);
continue;
}
}
main += serializeNode(child);
}
main = cleanWhitespace(main).replace(/\n/g, '\n' + indent + ' ');
return `${indent}${bullet}${main}${nested}`;
}
function serializeNode(node) {
if (!node) return '';
if (node.nodeType === Node.TEXT_NODE) {
return node.nodeValue || '';
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return '';
}
if (shouldIgnoreNode(node)) {
return '';
}
const el = node;
const tag = el.tagName.toLowerCase();
switch (tag) {
case 'br':
return '\n';
case 'hr':
return '\n---\n\n';
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6': {
const level = Number(tag[1]);
const text = cleanWhitespace(serializeInlineChildren(el));
return `${'#'.repeat(level)} ${text}\n\n`;
}
case 'p': {
const text = cleanWhitespace(serializeInlineChildren(el));
return text ? `${text}\n\n` : '';
}
case 'strong':
case 'b': {
const text = cleanWhitespace(serializeInlineChildren(el));
return text ? `**${text}**` : '';
}
case 'em':
case 'i': {
const text = cleanWhitespace(serializeInlineChildren(el));
return text ? `*${text}*` : '';
}
case 'del':
case 's': {
const text = cleanWhitespace(serializeInlineChildren(el));
return text ? `~~${text}~~` : '';
}
case 'code': {
if (el.closest('pre')) return '';
return '`' + (el.textContent || '').replace(/`/g, '\\`') + '`';
}
case 'pre': {
const codeEl = el.querySelector('code') || el;
const lang = detectCodeLang(el);
const code = (codeEl.textContent || '').replace(/\n+$/g, '');
return `\`\`\`${lang}\n${code}\n\`\`\`\n\n`;
}
case 'blockquote': {
const inner = cleanWhitespace(serializeInlineChildren(el));
if (!inner) return '';
return inner.split('\n').map(line => `> ${line}`).join('\n') + '\n\n';
}
case 'a': {
const href = el.getAttribute('href') || '';
const text = cleanWhitespace(serializeInlineChildren(el)) || href;
if (!href) return text;
return `[${text}](${href})`;
}
case 'img': {
const alt = el.getAttribute('alt') || 'image';
const src = el.getAttribute('src') || '';
return src ? `` : `![${alt}]()`;
}
case 'ul':
return serializeList(el, 0, false) + '\n\n';
case 'ol':
return serializeList(el, 0, true) + '\n\n';
case 'table':
return serializeTable(el) + '\n\n';
case 'details':
case 'summary': {
const text = cleanWhitespace(serializeInlineChildren(el));
return text ? `${text}\n\n` : '';
}
case 'div':
case 'section':
case 'article':
case 'span':
case 'main':
default:
return Array.from(el.childNodes).map(serializeNode).join('');
}
}
function normalizeMarkdown(md) {
return String(md || '')
.replace(/\r/g, '')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.replace(/[ \t]{2,}/g, ' ')
.trim();
}
function findContentRoot(container) {
const selectors = [
'[data-message-content]',
'.markdown',
'[class*="markdown"]',
'.prose',
'[class*="prose"]',
'[class*="whitespace-pre-wrap"]'
];
for (const sel of selectors) {
const found = container.querySelector(sel);
if (found && found.textContent && found.textContent.trim()) {
return found;
}
}
return container;
}
// ---------- 优先使用网页复制按钮提取消息 ----------
function roleLabel(role) {
return role === 'user' ? '我' : 'ChatGPT';
}
function getTurnType(turnEl) {
const dataTurn = turnEl.getAttribute('data-turn');
if (dataTurn === 'user') return 'user';
if (turnEl.querySelector('[data-message-author-role="user"]')) return 'user';
if (turnEl.querySelector('button[aria-label="复制回复"]')) return 'assistant';
if (turnEl.querySelector('button[aria-label="Copy response"]')) return 'assistant';
if (turnEl.querySelector('button[aria-label="复制消息"]')) return 'user';
if (turnEl.querySelector('button[aria-label="Copy prompt"]')) return 'user';
return '';
}
function findCopyButton(turnEl, type) {
if (type === 'user') {
return (
turnEl.querySelector('button[data-testid="copy-turn-action-button"][aria-label="复制消息"]') ||
turnEl.querySelector('button[aria-label="复制消息"]') ||
turnEl.querySelector('button[data-testid="copy-turn-action-button"][aria-label="Copy prompt"]') ||
turnEl.querySelector('button[aria-label="Copy prompt"]')
);
}
if (type === 'assistant') {
return (
turnEl.querySelector('button[data-testid="copy-turn-action-button"][aria-label="复制回复"]') ||
turnEl.querySelector('button[aria-label="复制回复"]') ||
turnEl.querySelector('button[data-testid="copy-turn-action-button"][aria-label="Copy response"]') ||
turnEl.querySelector('button[aria-label="Copy response"]')
);
}
return null;
}
function extractFallbackFromTurn(turnEl, role) {
if (role === 'user') {
const userBlock = turnEl.querySelector('[data-message-author-role="user"]');
if (userBlock && userBlock.innerText && userBlock.innerText.trim()) {
return userBlock.innerText.trim();
}
}
const root = findContentRoot(turnEl);
const md = normalizeMarkdown(serializeNode(root));
if (md) return md;
return (turnEl.innerText || '').trim();
}
async function clickAndReadClipboard(copyBtn, fallbackText = '') {
const before = await readClipboardSafe();
try {
copyBtn.scrollIntoView({ behavior: 'instant', block: 'center' });
} catch (_) {
copyBtn.scrollIntoView({ block: 'center' });
}
const hoverTarget = copyBtn.closest('[data-testid^="conversation-turn-"], section, article, div');
if (hoverTarget) {
hoverTarget.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
hoverTarget.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
hoverTarget.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }));
}
await sleep(80);
copyBtn.click();
await sleep(300);
for (let i = 0; i < 8; i++) {
const after = await readClipboardSafe();
if (after && after !== before) return after;
await sleep(160);
}
return fallbackText || '';
}
async function extractMessagesUsingCopyButtons(progressCb) {
const turnNodes = Array.from(document.querySelectorAll('section[data-testid^="conversation-turn-"]'));
if (!turnNodes.length) return [];
const messages = [];
for (let i = 0; i < turnNodes.length; i++) {
const turnEl = turnNodes[i];
const role = getTurnType(turnEl);
if (!role) continue;
progressCb && progressCb(i + 1, turnNodes.length, role);
const copyBtn = findCopyButton(turnEl, role);
const fallbackText = extractFallbackFromTurn(turnEl, role);
let content = '';
if (copyBtn) {
content = await clickAndReadClipboard(copyBtn, fallbackText);
} else {
content = fallbackText;
}
content = normalizeMarkdown(content);
if (!content) continue;
messages.push({ role, content });
}
return messages;
}
function extractMessagesFallback() {
const explicitNodes = Array.from(document.querySelectorAll('[data-message-author-role]'))
.filter(el => !el.parentElement || !el.parentElement.closest('[data-message-author-role]'));
if (explicitNodes.length) {
const msgs = explicitNodes.map(el => {
const rawRole = (el.getAttribute('data-message-author-role') || '').toLowerCase();
const role = rawRole.includes('user') ? 'user' : 'assistant';
const root = findContentRoot(el);
const content = normalizeMarkdown(serializeNode(root));
return { role, content };
}).filter(m => m.content);
if (msgs.length) return msgs;
}
const articles = Array.from(document.querySelectorAll('main article'));
if (!articles.length) return [];
let expected = 'user';
const result = [];
for (const article of articles) {
let role = expected;
const copyBtn = article.querySelector('button[aria-label*="Copy"], button[aria-label*="复制"], [data-testid*="copy"]');
const editBtn = article.querySelector('button[aria-label*="Edit"], button[aria-label*="编辑"]');
if (copyBtn) role = 'assistant';
else if (editBtn) role = 'user';
expected = role === 'user' ? 'assistant' : 'user';
const root = findContentRoot(article);
const content = normalizeMarkdown(serializeNode(root));
if (!content) continue;
result.push({ role, content });
}
return result;
}
async function extractMessages(progressCb) {
const byCopy = await extractMessagesUsingCopyButtons(progressCb);
if (byCopy.length) return byCopy;
return extractMessagesFallback();
}
// ---------- Markdown 构建 ----------
function buildMarkdown(title, url, conversationId, messages) {
const exportedAt = formatLocalDateTime(new Date());
const frontmatter = [
'---',
`title: ${yamlString(title)}`,
`source: ${yamlString(url)}`,
`conversation_id: ${yamlString(conversationId || 'unknown')}`,
`exported_at: ${yamlString(exportedAt)}`,
'tags:',
' - chatgpt',
' - export',
'---',
''
].join('\n');
const header = [
`# ${title}`,
'',
`> 导出自 ChatGPT 网页`,
`> 原始链接:${url}`,
`> 导出时间:${exportedAt}`,
''
].join('\n');
const body = messages.map((msg, idx) => {
return `## ${idx + 1}. ${roleLabel(msg.role)}\n\n${msg.content || '_(空)_'}\n`;
}).join('\n');
return `${frontmatter}${header}${body}`.trim() + '\n';
}
async function collectCurrentChatMarkdown(progressCb) {
const messages = await extractMessages(progressCb);
if (!messages.length) {
throw new Error('没提取到聊天内容,请先打开一个具体会话页面。');
}
const title = getConversationTitle();
const conversationId = getConversationId();
const markdown = buildMarkdown(title, location.href, conversationId, messages);
return { title, conversationId, messages, markdown };
}
// ---------- 导出动作 ----------
async function exportToObsidian() {
const btn = document.getElementById(BTN_OBSIDIAN_ID);
try {
setButtonBusy(btn, true, '导出中…', '导出到 Obsidian');
const { vault, folder } = await configure(false);
const { title, conversationId, markdown } = await collectCurrentChatMarkdown((i, total, role) => {
toast(`正在提取第 ${i}/${total} 条${role === 'user' ? '消息' : '回复'}…`, 'info', 900);
});
const safeTitle = sanitizeSegment(title);
const safeFolder = sanitizeFolderPath(folder);
const stableName = `${safeTitle}.md`;
const filePath = safeFolder ? `${safeFolder}/${stableName}` : stableName;
const copied = await copyText(markdown);
if (copied) {
openObsidian(vault, filePath, true);
toast(`已导出到 Obsidian:${filePath}`, 'success', 3600);
notify(`已导出到 Obsidian:${filePath}`);
return;
}
if (markdown.length < 1800) {
openObsidian(vault, filePath, false, markdown);
toast(`剪贴板不可用,已改用内容直传:${filePath}`, 'success', 3600);
notify(`已导出到 Obsidian:${filePath}`);
return;
}
downloadMarkdown(stableName, markdown);
toast('导出到 Obsidian 失败:剪贴板不可用,已下载 Markdown 作为兜底。', 'warn', 4200);
} catch (err) {
console.error('[ChatGPT -> Obsidian]', err);
toast(`导出失败:${err.message || err}`, 'error', 3600);
} finally {
setButtonBusy(btn, false, '导出中…', '导出到 Obsidian');
}
}
async function downloadCurrentChatMarkdown() {
const btn = document.getElementById(BTN_MD_ID);
try {
setButtonBusy(btn, true, '下载中…', '下载 Markdown');
const { title, conversationId, markdown } = await collectCurrentChatMarkdown((i, total, role) => {
toast(`正在整理第 ${i}/${total} 条${role === 'user' ? '消息' : '回复'}…`, 'info', 900);
});
const safeTitle = sanitizeSegment(title);
const stableName = `${safeTitle}.md`;
downloadMarkdown(stableName, markdown);
toast(`已下载 Markdown:${stableName}`, 'success', 3200);
notify(`已下载 Markdown:${stableName}`);
} catch (err) {
console.error('[ChatGPT -> Markdown]', err);
toast(`下载失败:${err.message || err}`, 'error', 3600);
} finally {
setButtonBusy(btn, false, '下载中…', '下载 Markdown');
}
}
// ---------- 菜单 ----------
function registerMenu() {
if (typeof GM_registerMenuCommand !== 'function') return;
GM_registerMenuCommand('设置 Obsidian Vault / 文件夹', async () => {
try {
await configure(true);
toast('配置已保存。', 'success');
} catch (err) {
toast(`配置未完成:${err.message || err}`, 'warn');
}
});
GM_registerMenuCommand('导出当前会话到 Obsidian', exportToObsidian);
GM_registerMenuCommand('下载当前会话 Markdown', downloadCurrentChatMarkdown);
}
// ---------- 启动 ----------
function init() {
ensureButtons();
registerMenu();
setInterval(() => {
ensureButtons();
}, 1500);
}
init();
})();