🎉 欢迎访问GreasyFork.Org 镜像站!本镜像站由公众号【爱吃馍】搭建,用于分享脚本。联系邮箱📮

Greasy fork 爱吃馍镜像

Greasy Fork is available in English.

ChatGPT to Notion Exporter

ChatGPT 导出到 Notion:智能图片归位 (支持 PicList/PicGo)+隐私开关+单个对话导出

ของเมื่อวันที่ 02-12-2025 ดู เวอร์ชันล่าสุด

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey to install this script.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

// ==UserScript==
// @name         ChatGPT to Notion Exporter
// @namespace    http://tampermonkey.net/
// @version      2.6
// @license      MIT
// @description  ChatGPT 导出到 Notion:智能图片归位 (支持 PicList/PicGo)+隐私开关+单个对话导出
// @author       Wyih 
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @connect      api.notion.com
// @connect      127.0.0.1
// @connect      *
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @run-at       document-end
// ==/UserScript==

(function () {
    'use strict';

    console.log('[ChatGPT→Notion v2.6] script loaded');

    // --- 基础配置 ---
    const PICLIST_URL = "http://127.0.0.1:36677/upload";
    const ASSET_PLACEHOLDER_PREFIX = "PICLIST_WAITING::";
    const MAX_TEXT_LENGTH = 2000;

    // ------------------- 0. PicList 环境自检 -------------------
    function checkPicListConnection() {
        GM_xmlhttpRequest({
            method: "GET",
            url: "http://127.0.0.1:36677/heartbeat",
            timeout: 2000,
            onload: (res) => { if (res.status === 200) console.log("✅ PicList 连接正常"); },
            onerror: () => console.warn("⚠️ PicList 未连接 (图片可能无法导出)")
        });
    }
    setTimeout(checkPicListConnection, 3000);

    // ------------------- 1. Notion 配置管理 -------------------
    function getConfig() {
        return { token: GM_getValue('notion_token', ''), dbId: GM_getValue('notion_db_id', '') };
    }

    function promptConfig() {
        const token = prompt('请输入 Notion Integration Secret:', GM_getValue('notion_token', ''));
        if (token) {
            const dbId = prompt('请输入 Notion Database ID:', GM_getValue('notion_db_id', ''));
            if (dbId) { GM_setValue('notion_token', token); GM_setValue('notion_db_id', dbId); alert('配置已保存'); }
        }
    }
    GM_registerMenuCommand('⚙️ 设置 Notion Token', promptConfig);

    // ------------------- 2. UI 样式 (Sticky) -------------------
    GM_addStyle(`
        /* 主按钮 */
        #chatgpt-saver-btn {
            position: fixed; bottom: 20px; right: 20px; z-index: 9999;
            background-color: #10A37F; color: white; border: none; border-radius: 6px;
            padding: 10px 16px; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
            font-family: system-ui, sans-serif; font-weight: 600; font-size: 14px; transition: all 0.2s;
        }
        #chatgpt-saver-btn:hover { background-color: #0d8465; transform: translateY(-2px); }
        #chatgpt-saver-btn.loading { background-color: #666; cursor: wait; }

        /* 气泡基础 */
        .cgpt-turn { position: relative; transition: background 0.2s; }
        .cgpt-turn:hover {
            box-shadow: 0 0 0 2px rgba(16, 163, 127, 0.2);
            border-radius: 8px;
            background-color: rgba(16, 163, 127, 0.02);
        }

        /* 工具条 */
        .cgpt-tool-group {
            z-index: 9500; display: flex; gap: 6px;
            opacity: 0; transition: opacity 0.2s ease-in-out;
            background: white; padding: 4px 6px; border-radius: 20px;
            box-shadow: 0 2px 5px rgba(0,0,0,0.15); border: 1px solid #e0e0e0;
        }
        .cgpt-turn:hover .cgpt-tool-group { opacity: 1; }
        .cgpt-tool-group:has(.cgpt-privacy-toggle[data-skip="true"]) { opacity: 1 !important; border-color: #fce8e6; background: #fff8f8; }

        /* Assistant: 右浮动 + Sticky */
        .cgpt-turn[data-role="assistant"] { display: block !important; }
        .cgpt-turn[data-role="assistant"] .cgpt-tool-group {
            position: sticky; top: 10px; float: right; margin-left: 10px; margin-bottom: 10px; z-index: 100;
        }

        /* User: Flex Column + Sticky */
        .cgpt-turn[data-role="user"] { display: flex !important; flex-direction: column !important; }
        .cgpt-turn[data-role="user"] .cgpt-tool-group {
            position: sticky; top: 10px; align-self: flex-end; margin-bottom: -34px; z-index: 100; order: -1;
        }

        /* 按钮图标 */
        .cgpt-icon-btn {
            cursor: pointer; font-size: 16px; line-height: 24px; user-select: none;
            width: 26px; height: 26px; text-align: center; border-radius: 50%;
            transition: background 0.2s; display: flex; align-items: center; justify-content: center; color: #555;
        }
        .cgpt-icon-btn:hover { background: rgba(0,0,0,0.08); color: #000; }
        .cgpt-privacy-toggle[data-skip="true"] { color: #d93025; background: #fce8e6; }

        @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
        .cgpt-icon-btn.processing { cursor: wait; color: #1a73e8; background: #e8f0fe; }
        .cgpt-icon-btn.processing span { display: block; animation: spin 1s linear infinite; }
        .cgpt-icon-btn.success { color: #188038 !important; background: #e6f4ea; }
        .cgpt-icon-btn.error { color: #d93025 !important; background: #fce8e6; }
    `);

    // ------------------- 3. 气泡定位与 UI 注入 -------------------
    function getTurnWrappers() {
        const nodes = new Set();
        document.querySelectorAll('div[data-testid="conversation-turn"]').forEach(el => nodes.add(el));
        document.querySelectorAll('[data-message-author-role="user"]').forEach(el => nodes.add(el));
        document.querySelectorAll('[data-message-author-role="assistant"]').forEach(el => nodes.add(el));
        document.querySelectorAll('.agent-turn').forEach(el => nodes.add(el));
        return Array.from(nodes);
    }

    function getRoleFromWrapper(wrapper) {
        let role = wrapper.getAttribute('data-message-author-role');
        if (role) return role;
        const inner = wrapper.querySelector('[data-message-author-role]');
        if (inner) return inner.getAttribute('data-message-author-role');
        if (wrapper.classList.contains('agent-turn')) return 'assistant';
        if (wrapper.querySelector('div[class*="user"]')) return 'user';
        return 'assistant';
    }

    function injectPerTurnControls() {
        const turns = getTurnWrappers();
        turns.forEach(turn => {
            if (turn.querySelector('.cgpt-tool-group')) return;

            const role = getRoleFromWrapper(turn);
            turn.classList.add('cgpt-turn');
            turn.setAttribute('data-role', role);

            const group = document.createElement('div');
            group.className = 'cgpt-tool-group';

            // 👁️
            const privacyBtn = document.createElement('div');
            privacyBtn.className = 'cgpt-icon-btn cgpt-privacy-toggle';
            privacyBtn.title = '切换隐私(不导出)';
            privacyBtn.setAttribute('data-skip', 'false');
            const privacyIcon = document.createElement('span');
            privacyIcon.textContent = '👁️';
            privacyBtn.appendChild(privacyIcon);
            privacyBtn.onclick = (e) => {
                e.stopPropagation();
                const isSkipping = privacyBtn.getAttribute('data-skip') === 'true';
                if (isSkipping) {
                    privacyBtn.setAttribute('data-skip', 'false'); privacyIcon.textContent = '👁️'; turn.setAttribute('data-privacy-skip', 'false');
                } else {
                    privacyBtn.setAttribute('data-skip', 'true'); privacyIcon.textContent = '🚫'; turn.setAttribute('data-privacy-skip', 'true');
                }
            };

            // 📤
            const singleBtn = document.createElement('div');
            singleBtn.className = 'cgpt-icon-btn';
            singleBtn.title = '单条导出';
            const exportIcon = document.createElement('span');
            exportIcon.textContent = '📤';
            singleBtn.appendChild(exportIcon);
            singleBtn.onclick = (e) => {
                e.stopPropagation();
                handleSingleExport(turn, singleBtn, exportIcon);
            };

            group.appendChild(privacyBtn);
            group.appendChild(singleBtn);

            if (turn.firstChild) {
                turn.insertBefore(group, turn.firstChild);
            } else {
                turn.appendChild(group);
            }
        });
    }

    // ------------------- 4. 资源处理 (Native Fetch + PicList Upload) -------------------
    
    // 🌟 使用原生 fetch 获取 ArrayBuffer
    // 优点:自动携带 chatgpt.com 的 cookie,解决 backend-api 鉴权问题;同时也支持 blob:
    async function fetchUrlAsArrayBuffer(url) {
        try {
            const response = await fetch(url);
            if (!response.ok) throw new Error(`HTTP ${response.status}`);
            const blob = await response.blob();
            const buffer = await blob.arrayBuffer();
            return { buffer, type: blob.type };
        } catch (e) {
            console.error("Fetch failed for:", url, e);
            throw e;
        }
    }

    function uploadToPicList(arrayBufferObj, filename) {
        return new Promise((resolve, reject) => {
            if (!arrayBufferObj.buffer) return reject("空数据");
            let finalFilename = filename.split('?')[0];
            const mime = (arrayBufferObj.type || '').split(';')[0].trim().toLowerCase();
            const mimeMap = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp' };
            if (!finalFilename.includes('.')) {
                if (mimeMap[mime]) finalFilename += mimeMap[mime]; else finalFilename += '.png';
            }
            const boundary = "----ChatGPTBoundary" + Math.random().toString(36).substring(2);
            const preData = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${finalFilename.replace(/"/g, '')}"\r\nContent-Type: ${mime || 'application/octet-stream'}\r\n\r\n`;
            const combinedBlob = new Blob([preData, arrayBufferObj.buffer, `\r\n--${boundary}--\r\n`]);
            
            // 🌟 上传 PicList 必须用 GM_xmlhttpRequest (跨域)
            GM_xmlhttpRequest({
                method: "POST", url: PICLIST_URL, headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` }, data: combinedBlob,
                onload: (res) => { try { const r = JSON.parse(res.responseText); if (r.success && r.result) resolve(r.result[0]); else reject(r.message || "上传失败"); } catch (e) { reject(e.message); } },
                onerror: () => reject("PicList 连接失败")
            });
        });
    }

    async function processAssets(blocks, statusCallback) {
        const tasks = [];
        const map = new Map();

        blocks.forEach((b, i) => {
            let urlObj = null;
            if (b.type === 'image' && b.image?.external?.url?.startsWith(ASSET_PLACEHOLDER_PREFIX)) urlObj = b.image.external;
            else if (b.type === 'file' && b.file?.external?.url?.startsWith(ASSET_PLACEHOLDER_PREFIX)) urlObj = b.file.external;

            if (urlObj) {
                const [_, name, realUrl] = urlObj.url.split('::');
                // 普通文件 blob 无法导出,图片可以
                if (realUrl.startsWith('blob:') && b.type === 'file') {
                    b.type = "paragraph";
                    b.paragraph = { rich_text: [{ type: "text", text: { content: `📄 [本地文件] ${name}` }, annotations: { color: "gray", italic: true } }] };
                    delete b.file; return;
                }
                const task = fetchUrlAsArrayBuffer(realUrl)
                    .then(buf => uploadToPicList(buf, name))
                    .then(u => ({ i, url: u, name, ok: true }))
                    .catch(e => ({ i, err: e, name, ok: false }));
                tasks.push(task);
                map.set(i, b);
            }
        });

        if (tasks.length) {
            statusCallback(`⏳ Uploading ${tasks.length} assets...`);
            const res = await Promise.all(tasks);
            res.forEach(r => {
                const blk = map.get(r.i);
                if (!blk) return;
                if (r.ok) {
                    if (blk.type === 'image') blk.image.external.url = r.url;
                    else if (blk.type === 'file') { blk.file.external.url = r.url; blk.file.name = r.name; }
                } else {
                    blk.type = "paragraph";
                    blk.paragraph = { rich_text: [{ type: "text", text: { content: `⚠️ 图片导出失败: ${r.name}` }, annotations: { color: "red" } }] };
                    delete blk.file; delete blk.image;
                }
            });
        }
        return blocks;
    }

    // ------------------- 5. DOM 转 Blocks (v1.2 Logic + Fix: ALLOW BUTTON) -------------------
    const NOTION_LANGUAGES = new Set(["bash", "c", "c++", "css", "go", "html", "java", "javascript", "json", "python", "markdown", "sql", "typescript", "yaml", "plain text"]);
    function detectLanguageRecursive(preNode) {
        let c = preNode;
        for (let i = 0; i < 3; i++) {
            if (!c) break;
            const h = c.previousElementSibling;
            if (h && NOTION_LANGUAGES.has(h.innerText.toLowerCase())) return h.innerText.toLowerCase();
            c = c.parentElement;
        }
        const code = preNode.querySelector('code');
        return (code && code.className.match(/language-([\w-]+)/)) ? code.className.match(/language-([\w-]+)/)[1] : "plain text";
    }

    function splitCodeSafe(code) {
        const chunks = [];
        let remaining = code;
        while (remaining.length > 0) {
            if (remaining.length <= MAX_TEXT_LENGTH) { chunks.push(remaining); break; }
            let splitIndex = remaining.lastIndexOf('\n', MAX_TEXT_LENGTH - 1);
            if (splitIndex === -1) splitIndex = MAX_TEXT_LENGTH; else splitIndex += 1;
            chunks.push(remaining.slice(0, splitIndex));
            remaining = remaining.slice(splitIndex);
        }
        return chunks;
    }

    function parseInlineNodes(nodes) {
        const rt = [];
        function tr(n, s = {}) {
            if (n.nodeType === 3) {
                const fullText = n.textContent;
                // v1.2 严格空行过滤
                if (!fullText || !fullText.trim()) return;
                for (let i = 0; i < fullText.length; i += MAX_TEXT_LENGTH) {
                    rt.push({
                        type: "text", text: { content: fullText.slice(i, i + MAX_TEXT_LENGTH), link: s.link },
                        annotations: { bold: !!s.bold, italic: !!s.italic, code: !!s.code, color: "default" }
                    });
                }
            } else if (n.nodeType === 1) {
                const ns = { ...s };
                if (['B', 'STRONG'].includes(n.tagName)) ns.bold = true;
                if (['I', 'EM'].includes(n.tagName)) ns.italic = true;
                if (n.tagName === 'CODE') ns.code = true;
                if (n.tagName === 'A') ns.link = { url: n.href };
                n.childNodes.forEach(c => tr(c, ns));
            }
        }
        nodes.forEach(n => tr(n));
        return rt;
    }

    // seenImages 用于 AI 图片去重
    function processNodesToBlocks(nodes, seenImages = new Set()) {
        const blocks = [];
        const buf = [];

        const flush = () => {
            if (!buf.length) return;
            const rt = parseInlineNodes(buf);
            if (!rt.length) { buf.length = 0; return; }
            for (let i = 0; i < rt.length; i += 90) {
                blocks.push({ object: "block", type: "paragraph", paragraph: { rich_text: rt.slice(i, i + 90) } });
            }
            buf.length = 0;
        };

        Array.from(nodes).forEach(n => {
            // 🌟 核心修复:从屏蔽列表中移除了 'BUTTON',允许脚本进入 <button> 内部查找 <img>
            if (['SCRIPT', 'STYLE', 'SVG'].includes(n.nodeName)) return;

            if (n.nodeType === 3 || ['B', 'I', 'CODE', 'SPAN', 'A', 'STRONG', 'EM'].includes(n.nodeName)) {
                if (n.nodeName === 'A' && (n.hasAttribute('download') || n.href.includes('blob:'))) {
                    flush();
                    const fn = (n.innerText || 'file').trim();
                    blocks.push({ object: "block", type: "file", file: { type: "external", name: fn.slice(0, 60), external: { url: `${ASSET_PLACEHOLDER_PREFIX}${fn}::${n.href}` } } });
                    return;
                }
                buf.push(n);
                return;
            }

            if (n.nodeType === 1) {
                const t = n.tagName;

                if (t === 'P' || t === 'DIV' || t === 'BUTTON') { // 🌟 显式允许 BUTTON
                    flush();
                    blocks.push(...processNodesToBlocks(n.childNodes, seenImages));
                } else if (t === 'IMG') {
                    flush();
                    if (!n.className.includes('avatar') && n.src) {
                        // 🌟 图片去重
                        if (n.src.startsWith('http')) {
                            // 网络图片去重
                            if (!seenImages.has(n.src)) {
                                seenImages.add(n.src);
                                blocks.push({ object: "block", type: "image", image: { type: "external", external: { url: `${ASSET_PLACEHOLDER_PREFIX}image.png::${n.src}` } } });
                            }
                        } else {
                            // Blob 图片直接添加 (URL 每次不同,去重意义不大,且通常不重叠)
                            blocks.push({ object: "block", type: "image", image: { type: "external", external: { url: `${ASSET_PLACEHOLDER_PREFIX}image.png::${n.src}` } } });
                        }
                    }
                } else if (t === 'PRE') {
                    flush();
                    const fullCode = n.textContent || "";
                    if (!fullCode.trim()) return;
                    const lang = detectLanguageRecursive(n);
                    const chunks = splitCodeSafe(fullCode);
                    const rt = chunks.map(c => ({ type: "text", text: { content: c } }));
                    blocks.push({ object: "block", type: "code", code: { rich_text: rt, language: lang } });
                } else if (/^H[1-6]$/.test(t)) {
                    flush();
                    const rich = parseInlineNodes(n.childNodes);
                    if (!rich.length) return;
                    const hLevel = t[1] < 4 ? t[1] : 3;
                    const hType = `heading_${hLevel}`;
                    blocks.push({ object: "block", type: hType, [hType]: { rich_text: rich } });
                } else if (t === 'BLOCKQUOTE') {
                    flush();
                    const rich = parseInlineNodes(n.childNodes);
                    if (!rich.length) return;
                    blocks.push({ object: "block", type: "quote", quote: { rich_text: rich } });
                } else if (t === 'HR') {
                    flush();
                    blocks.push({ object: "block", type: "divider", divider: {} });
                } else if (t === 'UL' || t === 'OL') {
                    flush();
                    const tp = t === 'UL' ? 'bulleted_list_item' : 'numbered_list_item';
                    Array.from(n.children).forEach(li => {
                        if (li.tagName === 'LI') {
                            const rich = parseInlineNodes(li.childNodes);
                            if (!rich.length) return;
                            blocks.push({ object: "block", type: tp, [tp]: { rich_text: rich } });
                        }
                    });
                } else if (t === 'TABLE') {
                    flush();
                    const rows = Array.from(n.querySelectorAll('tr'));
                    if (rows.length) {
                        const tb = { object: "block", type: "table", table: { table_width: 1, children: [] } };
                        let max = 0;
                        rows.forEach(r => {
                            const cs = Array.from(r.querySelectorAll('td,th'));
                            max = Math.max(max, cs.length);
                            tb.table.children.push({
                                object: "block", type: "table_row",
                                table_row: { cells: cs.map(c => [{ type: "text", text: { content: c.innerText.trim().slice(0, 1000) } }]) }
                            });
                        });
                        tb.table.table_width = max;
                        blocks.push(tb);
                    }
                } else {
                    blocks.push(...processNodesToBlocks(n.childNodes, seenImages));
                }
            }
        });
        flush();
        return blocks;
    }

    // ------------------- 6. 导出 -------------------
    function getChatBlocks(targetTurns = null) {
        const allTurns = getTurnWrappers();
        const turnsToProcess = targetTurns || allTurns;
        const children = [];

        turnsToProcess.forEach(turn => {
            const role = getRoleFromWrapper(turn);
            const isUser = role === 'user';
            const label = isUser ? 'User' : 'ChatGPT';

            if (turn.getAttribute('data-privacy-skip') === 'true') {
                children.push({
                    object: "block", type: "callout",
                    callout: {
                        rich_text: [{ type: "text", text: { content: `🚫 此 ${label} 内容已标记为隐私,未导出。` }, annotations: { color: "gray", italic: true } }],
                        icon: { emoji: "🔒" }, color: "gray_background"
                    }
                });
                return;
            }

            children.push({
                object: "block", type: "heading_3",
                heading_3: { rich_text: [{ type: "text", text: { content: label } }], color: isUser ? "default" : "blue_background" }
            });

            const clone = turn.cloneNode(true);
            clone.querySelectorAll('.cgpt-tool-group').forEach(el => el.remove());

            children.push(...processNodesToBlocks(clone.childNodes, new Set()));
            children.push({ object: "block", type: "divider", divider: {} });
        });
        return children;
    }

    function getChatTitle(specificTurn = null) {
        const all = getTurnWrappers();
        const el = specificTurn || (all.find(t => getRoleFromWrapper(t) === 'user') || all[0]);
        return el ? el.innerText.replace(/\n/g, ' ').trim().slice(0, 60) : 'ChatGPT Chat';
    }

    function appendBlocksBatch(pageId, blocks, token, statusCallback) {
        if (!blocks.length) {
            statusCallback('✅ Saved!');
            setTimeout(() => statusCallback(null), 3000);
            return;
        }
        GM_xmlhttpRequest({
            method: 'PATCH',
            url: `https://api.notion.com/v1/blocks/${pageId}/children`,
            headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Notion-Version': '2022-06-28' },
            data: JSON.stringify({ children: blocks.slice(0, 90) }),
            onload: (res) => {
                if (res.status === 200) appendBlocksBatch(pageId, blocks.slice(90), token, statusCallback);
                else { console.error(res.responseText); statusCallback('❌ Fail'); }
            },
            onerror: () => statusCallback('❌ Net Error')
        });
    }

    function createPageAndUpload(title, blocks, token, dbId, statusCallback) {
        GM_xmlhttpRequest({
            method: 'POST',
            url: 'https://api.notion.com/v1/pages',
            headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Notion-Version': '2022-06-28' },
            data: JSON.stringify({
                parent: { database_id: dbId },
                properties: {
                    'Name': { title: [{ text: { content: title } }] },
                    'Date': { date: { start: new Date().toISOString() } },
                    'URL': { url: location.href }
                },
                children: blocks.slice(0, 90)
            }),
            onload: (res) => {
                if (res.status === 200) {
                    const page = JSON.parse(res.responseText);
                    appendBlocksBatch(page.id, blocks.slice(90), token, statusCallback);
                } else {
                    statusCallback('❌ Fail'); alert(res.responseText);
                }
            },
            onerror: () => statusCallback('❌ Net Error')
        });
    }

    async function executeExport(blocks, title, btnOrLabel, iconElem) {
        const { token, dbId } = getConfig();
        if (!token || !dbId) { promptConfig(); return; }

        const updateStatus = (msg) => {
            if (btnOrLabel.classList && btnOrLabel.classList.contains('cgpt-icon-btn') && iconElem) {
                if (msg && msg.includes('Saved')) {
                    btnOrLabel.classList.remove('processing'); btnOrLabel.classList.add('success'); iconElem.textContent = '✅';
                    setTimeout(() => { btnOrLabel.classList.remove('success'); iconElem.textContent = '📤'; }, 2500);
                } else if (msg && (msg.includes('Fail') || msg.includes('Error'))) {
                    btnOrLabel.classList.remove('processing'); btnOrLabel.classList.add('error'); iconElem.textContent = '❌';
                } else if (msg) {
                    btnOrLabel.classList.add('processing'); btnOrLabel.classList.remove('success', 'error'); iconElem.textContent = '⏳';
                }
            } else if (btnOrLabel.id === 'chatgpt-saver-btn') {
                btnOrLabel.textContent = msg || '📥 Save to Notion';
            }
        };

        if (btnOrLabel.id === 'chatgpt-saver-btn') {
            btnOrLabel.classList.add('loading'); btnOrLabel.textContent = '🕵️ Processing...';
        } else updateStatus('Processing...');

        try {
            blocks = await processAssets(blocks, updateStatus);
            if (btnOrLabel.id === 'chatgpt-saver-btn') btnOrLabel.textContent = '💾 Saving...';
            createPageAndUpload(title, blocks, token, dbId, updateStatus);
        } catch (e) {
            console.error(e);
            if (btnOrLabel.id === 'chatgpt-saver-btn') btnOrLabel.textContent = '❌ Error';
            updateStatus('❌ Fail'); alert(e.message);
        } finally {
            if (btnOrLabel.id === 'chatgpt-saver-btn') btnOrLabel.classList.remove('loading');
        }
    }

    function handleFullExport() {
        const btn = document.getElementById('chatgpt-saver-btn');
        const blocks = getChatBlocks(null);
        if (!blocks.length) return alert('空对话');
        executeExport(blocks, getChatTitle(), btn);
    }

    function handleSingleExport(turnWrapper, iconBtn, iconElem) {
        const all = getTurnWrappers();
        const idx = all.indexOf(turnWrapper);
        if (idx === -1) return alert('未找到气泡');

        const targets = [turnWrapper];
        const role = getRoleFromWrapper(turnWrapper);

        if (role === 'user') {
            for (let i = idx + 1; i < all.length; i++) {
                const r = getRoleFromWrapper(all[i]);
                if (r === 'assistant') {
                    if (all[i].getAttribute('data-privacy-skip') !== 'true') targets.push(all[i]);
                    break;
                }
                if (r === 'user') break;
            }
        }
        const blocks = getChatBlocks(targets);
        if (!blocks.length) return alert('空内容');
        const title = getChatTitle(turnWrapper);
        executeExport(blocks, title, iconBtn, iconElem);
    }

    function tryInit() {
        if (!document.body) return;
        if (!document.getElementById('chatgpt-saver-btn')) {
            const btn = document.createElement('button');
            btn.id = 'chatgpt-saver-btn'; btn.textContent = '📥 Save to Notion'; btn.onclick = handleFullExport;
            document.body.appendChild(btn);
        }
        injectPerTurnControls();
    }
    setInterval(tryInit, 1500);
})();