Export Chat History to Obsidian

Export ChatGPT conversations to Obsidian.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Export Chat History to Obsidian
// @namespace    http://tampermonkey.net/
// @version      0.9.7
// @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 += `# 🤖 AI:\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 });
})();