Export Chat History to Obsidian

Export ChatGPT conversations to Obsidian.

// ==UserScript==
// @name         Export Chat History to Obsidian
// @namespace    http://tampermonkey.net/
// @version      0.9.5
// @description  Export ChatGPT conversations to Obsidian.
// @author       You
// @match        https://chatgpt.com/*
// @match        https://yuanbao.tencent.com/*
// @match        https://chat.deepseek.com/*
// @grant        GM_setClipboard
// @grant        GM_openInTab
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @license MIT
// ==/UserScript==

(function () {
    'use strict';
    var source = "others"
    var cssArticle = "article"
    var cssUser = "div.whitespace-pre-wrap"
    var cssCopyButton = "div.touch\\:-me-2 > button:nth-child(1)"
    if (window.location.hostname == 'chatgpt.com') {
        source = "chatgpt";
    } else if (window.location.hostname == 'yuanbao.tencent.com') {
        source = "yuanbao";
        cssArticle = "div.agent-chat__list__item"
        cssUser = "div.hyc-content-text"
        cssCopyButton = "div.agent-chat__toolbar__item.agent-chat__toolbar__copy"
    } else if (window.location.hostname == 'chat.deepseek.com') {
        source = "deepseek"
    }
    async function addExportButton() {
        if (document.getElementById('export-to-obsidian-btn')) return;

        const btn = document.createElement('button');
        btn.id = 'export-to-obsidian-btn';
        btn.innerText = '💾 Export';

        // 默认位置右上角:top 50px, right 20px
        const savedTop = await GM_getValue(source + '_exportBtnTop', 50);
        const savedRight = await GM_getValue(source + '_exportBtnRight', 20);

        btn.style.position = 'fixed';
        btn.style.top = savedTop + 'px';
        btn.style.right = savedRight + 'px';
        btn.style.zIndex = '9999';
        btn.style.padding = '8px 12px';
        btn.style.fontSize = '14px';
        btn.style.backgroundColor = '#2670dd';
        btn.style.color = '#fff';
        btn.style.border = 'none';
        btn.style.borderRadius = '5px';
        btn.style.cursor = 'move';
        btn.style.boxShadow = '0 2px 6px rgba(0,0,0,0.2)';
        btn.title = "拖动以移动位置,点击导出";

        makeButtonDraggable(btn);

        document.body.appendChild(btn);
    }



    // 拖动功能
    function makeButtonDraggable(button) {
        let isDragging = false;
        let startX = 0;
        let startY = 0;

        button.addEventListener('mousedown', (e) => {
            isDragging = true;
            startX = e.clientX;
            startY = e.clientY;

            const rect = button.getBoundingClientRect();
            button._offsetX = startX - rect.right;
            button._offsetY = startY - rect.top;

            document.body.style.userSelect = 'none';
        });

        document.addEventListener('mousemove', async (e) => {
            if (!isDragging) return;

            const newTop = e.clientY - button._offsetY;
            const newRight = window.innerWidth - (e.clientX - button._offsetX);

            button.style.top = `${newTop}px`;
            button.style.right = `${newRight}px`;

            await GM_setValue(source + '_exportBtnTop', newTop);
            await GM_setValue(source + '_exportBtnRight', newRight);
        });

        document.addEventListener('mouseup', (e) => {
            if (!isDragging) return;
            isDragging = false;
            document.body.style.userSelect = '';

            const dx = e.clientX - startX;
            const dy = e.clientY - startY;
            const distance = Math.sqrt(dx * dx + dy * dy);

            // 若移动很小(小于3像素),才视为点击
            if (distance < 3) {
                exportToObsidian();
            }
        });
    }




    function findElementsByStyle(property, value) {
        const allElements = document.querySelectorAll('[style*="z-index"], div');
        const result = [];

        allElements.forEach(element => {
            // 获取元素的计算样式
            const computedStyle = window.getComputedStyle(element);
            // 获取目标属性的值(如 'zIndex')
            const propValue = computedStyle.getPropertyValue(property);

            // 检查属性值是否匹配(转换为字符串比较)
            if (propValue === String(value)) {
                result.push(element);
            }
        });

        return result;
    }

    async function getContents() {
        const articles = document.querySelectorAll(cssArticle);

        const contents = [];

        for (var idx = 0; idx < articles.length; idx++) {
            const article = articles[idx]
            if (idx % 2 === 0) {
                const user = article.querySelector(cssUser);
                if (!user) {
                    continue;
                }
                contents.push(user.textContent);
            } else {
                const copyButton = article.querySelector(cssCopyButton);
                if (!copyButton) continue;

                copyButton.click();
                await new Promise(resolve => setTimeout(resolve, 250));

                const text = await navigator.clipboard.readText();
                contents.push(text);
            }
        }
        return contents;
    }

    async function getDeepSeekContents() {
        const contents = [];
        var copyButtons = document.querySelectorAll("div.ds-flex > div.ds-icon-button:nth-child(1)")

        for (const copyButton of copyButtons) {
            copyButton.click();
            await new Promise(resolve => setTimeout(resolve, 250));

            const text = await navigator.clipboard.readText();
            contents.push(text);
        }
        return contents;
    }


    // 核心导出逻辑
    async function exportToObsidian() {
        var contents;
        if (source == "deepseek") {
            contents = await getDeepSeekContents();
        } else {
            contents = await getContents();
        }
        if (contents.length === 0) {
            alert("没有找到任何对话记录!");
            return;
        }

        // 构建正文内容
        let body = '';
        contents.forEach((text, idx) => {
            if (idx % 2 === 0) {
                body += `# ❓ User:\n> ${text.replace(/\n/g, '\n> ')}\n\n`;
            } else {
                body += `# 🤖 GPT:\n${text}\n\n`;
            }
        });

        var title = document.title.replace(/[/\\?%*:|"<>]/g, '-').trim();
        if (source == "yuanbao") {
            var titleElement = document.querySelector("span.agent-dialogue__content--common__header__name__title");
            if (titleElement) {
                title = titleElement.textContent;
            }
        } else if (source == "deepseek") {
            var elements = findElementsByStyle('z-index', 12);
            if (elements.length) {
                title = elements[0].textContent;
            }
        }

        const timestamp = new Date().toLocaleString();
        const url = document.URL

        const dateOnly = new Date().toLocaleDateString('sv-SE').replace(/-/g, '');

        // YAML Frontmatter
        const yaml = `---\ntitle: "${title}"\ndate: ${timestamp}\nsource: ${source}\nURL: ${url}\n---\n\n`;

        const fullContent = yaml + body;

        // 写入剪贴板
        GM_setClipboard(fullContent);

        const obsidianUrl = `obsidian://new?file=Chat/${source}/${dateOnly}_${encodeURIComponent(title)}&clipboard`;
        const link = document.createElement('a');
        link.href = obsidianUrl;
        link.click(); // 只在用户触发的事件中使用
    }

    // 注册菜单项
    GM_registerMenuCommand("📥 导出到 Obsidian", exportToObsidian);

    // 初始化:添加按钮 + 观察变化
    setTimeout(addExportButton, 1000);
    const observer = new MutationObserver(addExportButton);
    observer.observe(document.body, { childList: true, subtree: true });
})();