Gemini to Notion Exporter

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Gemini to Notion Exporter
// @namespace    http://tampermonkey.net/
// @version      13.2
// @license      MIT
// @description  Gemini 导出:智能图片归位 (支持 PicList/PicGo)+隐私开关+单个对话导出
// @author       Wyih with Gemini Thought Partner
// @match        https://gemini.google.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
// ==/UserScript==

(function () {
    'use strict';

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

    // ------------------- 0. 环境自检 -------------------
    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.error("❌ 无法连接到 PicList")
        });
    }
    setTimeout(checkPicListConnection, 3000);

    // ------------------- 1. 配置管理 -------------------
    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(`
        /* 全量导出按钮 */
        #gemini-saver-btn {
            position: fixed; bottom: 20px; right: 20px; z-index: 9999;
            background-color: #0066CC; 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: sans-serif; font-weight: 600; font-size: 14px; transition: all 0.2s;
        }
        #gemini-saver-btn:hover { background-color: #0052a3; transform: translateY(-2px); }
        #gemini-saver-btn.loading { background-color: #666; cursor: wait; }

        /* --- 视觉边界 --- */
        user-query:hover, model-response:hover {
            box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.3);
            border-radius: 8px;
            background-color: rgba(66, 133, 244, 0.02);
        }

        /* --- 工具栏基础样式 --- */
        .gemini-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;
        }
        user-query:hover .gemini-tool-group, model-response:hover .gemini-tool-group { opacity: 1; }
        .gemini-tool-group:has(.gemini-privacy-toggle[data-skip="true"]) { opacity: 1 !important; border-color: #fce8e6; background: #fff8f8; }

        /* =============================================
           🔥 全员 Sticky:双轨制解决方案
           ============================================= */

        /* 方案 A: AI 回复 (Model) - Block 布局 */
        /* 使用 Float 让按钮浮在文字流的右侧 */
        model-response .gemini-tool-group {
            position: sticky;
            top: 14px;         /* 吸顶 */
            float: right;      /* 靠右 */
            margin-left: 10px; 
            margin-bottom: 10px;
        }

        /* 方案 B: 用户提问 (User) - Flex 布局 */
        /* 使用 Flex 属性让按钮“挤”到最右侧 */
        user-query .gemini-tool-group {
            position: sticky;
            top: 14px;         /* 吸顶 */
            
            align-self: flex-start; /* 纵向:保持在顶部,防止被拉伸 */
            margin-left: auto;      /* 横向:自动挤到最右边 (Flex 里的神技) */
            margin-right: 10px;     /* 右边留点呼吸感 */
            order: 100;             /* 确保在视觉上排在最后 */
        }
        /* ============================================= */

        /* 图标样式 */
        .gemini-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;
        }
        .gemini-icon-btn:hover { background: rgba(0,0,0,0.08); color: #000; }
        .gemini-privacy-toggle[data-skip="true"] { color: #d93025; background: #fce8e6; }
        
        @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
        .gemini-icon-btn.processing { cursor: wait; color: #1a73e8; background: #e8f0fe; }
        .gemini-icon-btn.processing span { display: block; animation: spin 1s linear infinite; }
        .gemini-icon-btn.success { color: #188038 !important; background: #e6f4ea; }
        .gemini-icon-btn.error { color: #d93025 !important; background: #fce8e6; }
    `);

    // ------------------- 3. UI 注入 (DOM 原生创建,拒绝 innerHTML) -------------------
    function injectPageControls() {
        const bubbles = document.querySelectorAll('user-query, model-response');
        bubbles.forEach(bubble => {
            if (bubble.querySelector('.gemini-tool-group')) return;

            // 强制定位
            if (getComputedStyle(bubble).position === 'static') bubble.style.position = 'relative';

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

            // --- 隐私按钮 ---
            const privacyBtn = document.createElement('div');
            privacyBtn.className = 'gemini-icon-btn gemini-privacy-toggle';
            privacyBtn.title = "点击切换:是否导出此条内容";
            privacyBtn.setAttribute('data-skip', 'false');

            // 使用 span 包裹以便旋转
            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 = '👁️'; bubble.setAttribute('data-privacy-skip', 'false');
                } else {
                    privacyBtn.setAttribute('data-skip', 'true'); privacyIcon.textContent = '🚫'; bubble.setAttribute('data-privacy-skip', 'true');
                }
            };

            // --- 单条导出按钮 ---
            const singleExportBtn = document.createElement('div');
            singleExportBtn.className = 'gemini-icon-btn';
            singleExportBtn.title = "仅导出此条对话";

            const exportIcon = document.createElement('span');
            exportIcon.textContent = '📤';
            singleExportBtn.appendChild(exportIcon);

            singleExportBtn.onclick = (e) => {
                e.stopPropagation();
                handleSingleExport(bubble, singleExportBtn, exportIcon); // 传icon进去以便控制文字
            };
            group.appendChild(privacyBtn);
            group.appendChild(singleExportBtn);

            // ⚠️ 核心逻辑修改:分而治之
            if (bubble.tagName.toLowerCase() === 'user-query') {
                // 对于 Flex 布局的用户框,插在【最后面】
                // 配合 CSS 的 margin-left: auto 自动挤到最右边,不破坏左边的排版
                bubble.appendChild(group);
            } else {
                // 对于 Block 布局的 AI 框,插在【最前面】
                // 配合 CSS 的 float: right 实现文字环绕
                bubble.prepend(group);
            }
        });
    }

    // ------------------- 4. 资源处理 -------------------
    function convertBlobImageToBuffer(blobUrl) {
        return new Promise((resolve, reject) => {
            const img = document.querySelector(`img[src="${blobUrl}"]`);
            if (!img || !img.complete || img.naturalWidth === 0) return reject("图片加载失败");
            try {
                const canvas = document.createElement('canvas');
                canvas.width = img.naturalWidth; canvas.height = img.naturalHeight;
                canvas.getContext('2d').drawImage(img, 0, 0);
                canvas.toBlob(b => b ? b.arrayBuffer().then(buf => resolve({ buffer: buf, type: b.type })) : reject("Canvas失败"), 'image/png');
            } catch (e) { reject(e.message); }
        });
    }

    function fetchAssetAsArrayBuffer(url) {
        return new Promise((resolve, reject) => {
            if (url.startsWith('blob:')) {
                convertBlobImageToBuffer(url).then(resolve).catch(() => {
                    GM_xmlhttpRequest({ method: "GET", url, responseType: 'arraybuffer', onload: r => r.status === 200 ? resolve({ buffer: r.response, type: 'application/octet-stream' }) : reject() });
                });
            } else {
                GM_xmlhttpRequest({ method: "GET", url, responseType: 'arraybuffer', onload: r => r.status === 200 ? resolve({ buffer: r.response, type: r.responseHeaders.match(/content-type:\s*(.*)/i)?.[1] }) : reject() });
            }
        });
    }

    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();
            if (!finalFilename.includes('.') || finalFilename.length - finalFilename.lastIndexOf('.') > 6) {
                const mimeMap = { 'application/pdf': '.pdf', 'application/msword': '.doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx', 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp' };
                if (mimeMap[mime]) finalFilename += mimeMap[mime];
            }
            const boundary = "----GeminiSaverBoundary" + 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`]);

            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); r.success ? resolve(r.result[0]) : reject(r.message); } catch (e) { reject(e.message); } },
                onerror: () => reject("网络错误")
            });
        });
    }

    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('::');
                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 = fetchAssetAsArrayBuffer(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}...`);
            const res = await Promise.all(tasks);
            res.forEach(r => {
                const blk = map.get(r.i);
                if (r.ok) { blk.type === 'image' ? blk.image.external.url = r.url : (blk.file.external.url = r.url, blk.file.name = r.name || "File"); }
                else {
                    console.error(`Upload Fail: ${r.name}`, r.err);
                    blk.type = "paragraph"; blk.paragraph = { rich_text: [{ type: "text", text: { content: `⚠️ Upload Failed: ${r.name}` }, annotations: { color: "red" } }] };
                    delete blk.file; delete blk.image;
                }
            });
        }
        return blocks;
    }

    // ------------------- 5. DOM 解析 (代码高亮 + 切分) -------------------
    const NOTION_LANGUAGES = new Set(["bash", "c", "c++", "css", "go", "html", "java", "javascript", "json", "kotlin", "markdown", "php", "python", "ruby", "rust", "shell", "sql", "swift", "typescript", "yaml", "r", "plain text"]);
    function mapLanguageToNotion(lang) {
        if (!lang) return "plain text"; lang = lang.toLowerCase().trim();
        if (lang === "js") return "javascript"; if (lang === "py") return "python";
        if (NOTION_LANGUAGES.has(lang)) return lang; return "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 mapLanguageToNotion(h.innerText); c = c.parentElement; }
        const code = preNode.querySelector('code'); return code && code.className.match(/language-([\w-]+)/) ? mapLanguageToNotion(code.className.match(/language-([\w-]+)/)[1]) : "plain text";
    }

    // 🌟 核心修复版:代码切分逻辑
    function splitCodeSafe(code) {
        const chunks = [];
        let remaining = code;
        while (remaining.length > 0) {
            // 如果剩余长度小于等于 MAX,直接结束
            if (remaining.length <= MAX_TEXT_LENGTH) {
                chunks.push(remaining);
                break;
            }

            // 搜索换行符。关键修正:使用 MAX_TEXT_LENGTH - 1 作为搜索边界。
            // 这样找到的 index 最大为 1999。
            // 下一步 splitIndex += 1 后,最大为 2000,完美避开 Notion 的 2000 限制。
            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 = {}) {
            // --- 0. 文本节点处理 ---
            if (n.nodeType === 3) {
                const fullText = n.textContent;
                if (!fullText) 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" } });
                }
            }
            // --- 1. 元素节点处理 ---
            else if (n.nodeType === 1) {
                // 🎯 核心修复:检测 Gemini 特有的公式属性 (data-math 或 data-latex-source)
                // 你的 HTML 显示:<span class="math-inline" data-math="\pi" ...>
                const latex = n.getAttribute('data-latex-source') || n.getAttribute('data-math');
                if (latex) {
                    rt.push({
                        type: "equation",
                        equation: { expression: latex.trim() }
                    });
                    return; // ⚠️ 关键:取到公式后立即停止递归,防止抓取到内部的视觉乱码
                }

                // 常规样式处理
                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;
    }

    // ------------------- 2. 核心逻辑修正(修复版):精准区分块级公式与行内公式 -------------------
    function processNodesToBlocks(nodes) {
        const blocks = [], buf = [];

        const flush = () => {
            if (buf.length) {
                const rt = parseInlineNodes(buf);
                if (rt.length) blocks.push({ object: "block", type: "paragraph", paragraph: { rich_text: rt } });
                buf.length = 0;
            }
        };

        const fileExtRegex = /\.(pdf|zip|docx?|xlsx?|pptx?|csv|txt|md|html?|rar|7z|tar|gz|iso|exe|apk|dmg|json|xml|epub|R|Rmd|qmd)(\?|$)/i;

        Array.from(nodes).forEach(n => {
            if (['SCRIPT', 'STYLE', 'SVG'].includes(n.nodeName)) return;

            // 🛡️ 安全检查:先判断是否为元素节点 (nodeType === 1)
            // 只有元素节点才有 getAttribute 和 classList,文本节点访问会报错
            const isElement = n.nodeType === 1;

            if (isElement) {
                // 🎯 逻辑:同时满足 [有公式数据] AND [是块级布局]
                const isMathTag = n.hasAttribute('data-math') || n.hasAttribute('data-latex-source');
                const isBlockLayout = n.tagName === 'DIV' || n.classList.contains('math-block') || n.classList.contains('katex-display');

                if (isMathTag && isBlockLayout) {
                    const latex = n.getAttribute('data-latex-source') || n.getAttribute('data-math');
                    if (latex) {
                        flush(); // 保存之前的文本
                        blocks.push({
                            object: "block",
                            type: "equation",
                            equation: { expression: latex.trim() }
                        });
                        return; // 成功处理,跳过后续
                    }
                }
            }

            // --- 常规内容处理 ---

            // 文本节点(3)、加粗、斜体、代码、行内公式SPAN、链接等 -> 进入缓冲区
            if (n.nodeType === 3 || ['B', 'I', 'CODE', 'SPAN', 'A', 'STRONG', 'EM', 'MAT-ICON'].includes(n.nodeName)) {
                if (n.nodeName === 'A' && (n.hasAttribute('download') || n.href.includes('blob:') || fileExtRegex.test(n.href))) {
                    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); // 行内公式 span 也会进这里
                return;
            }

            if (isElement) {
                flush();
                const t = n.tagName;

                if (t === 'P') blocks.push(...processNodesToBlocks(n.childNodes));
                else if (t === 'IMG' && !n.className.includes('avatar')) {
                    blocks.push({ object: "block", type: "image", image: { type: "external", external: { url: `${ASSET_PLACEHOLDER_PREFIX}image.png::${n.src}` } } });
                }
                else if (t === 'PRE') {
                    const fullCode = n.textContent;
                    const lang = detectLanguageRecursive(n);
                    const rawChunks = splitCodeSafe(fullCode);
                    const codeRichText = rawChunks.map(chunk => ({ type: "text", text: { content: chunk } }));
                    blocks.push({ object: "block", type: "code", code: { rich_text: codeRichText, language: lang } });
                }
                else if (/^H[1-6]$/.test(t)) {
                    const level = t[1] < 4 ? t[1] : 3;
                    blocks.push({ object: "block", type: `heading_${level}`, [`heading_${level}`]: { rich_text: parseInlineNodes(n.childNodes) } });
                }
                else if (t === 'BLOCKQUOTE') {
                    blocks.push({ object: "block", type: "quote", quote: { rich_text: parseInlineNodes(n.childNodes) } });
                }
                else if (t === 'UL' || t === 'OL') {
                    const tp = t === 'UL' ? 'bulleted_list_item' : 'numbered_list_item';
                    Array.from(n.children).forEach(li => {
                        if (li.tagName === 'LI') blocks.push({ object: "block", type: tp, [tp]: { rich_text: parseInlineNodes(li.childNodes) } })
                    });
                }
                else if (t === 'TABLE') {
                    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 => parseInlineNodes(c.childNodes)) } });
                        });
                        tb.table.table_width = max;
                        blocks.push(tb);
                    }
                }
                else {
                    blocks.push(...processNodesToBlocks(n.childNodes));
                }
            }
        });
        flush();
        return blocks;
    }

    // ------------------- 6. 抓取逻辑 -------------------
    function buildUploadedImageMap() {
        const map = new Map();
        const imgs = document.querySelectorAll('img[data-test-id="uploaded-img"], img.preview-image');
        const bubbles = Array.from(document.querySelectorAll('user-query'));
        imgs.forEach(img => {
            let p = img.parentElement; while (p && p !== document.body) { if (p.tagName === 'USER-QUERY' || p.querySelector('user-query')) break; p = p.parentElement; }
            const owner = p && (p.tagName === 'USER-QUERY' ? p : p.querySelector('user-query')) || bubbles[bubbles.length - 1];
            if (owner) { if (!map.has(owner)) map.set(owner, []); map.get(owner).push(img); }
        });
        return map;
    }

    function getChatBlocks(targetBubbles = null) {
        const allBubbles = document.querySelectorAll('user-query, model-response');
        const bubblesToProcess = targetBubbles || Array.from(allBubbles);
        const children = [];
        const uploadMap = buildUploadedImageMap();

        if (bubblesToProcess.length > 0) {
            bubblesToProcess.forEach(bubble => {
                const isUser = bubble.tagName.toLowerCase() === 'user-query';
                const role = isUser ? "User" : "Gemini";
                if (bubble.getAttribute('data-privacy-skip') === 'true') {
                    children.push({ object: "block", type: "callout", callout: { rich_text: [{ type: "text", text: { content: `🚫 此 ${role} 内容已标记为隐私,未导出。` }, 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: role } }], color: isUser ? "default" : "blue_background" } });
                const clone = bubble.cloneNode(true);
                ['.gemini-tool-group', 'mat-icon', '.response-footer', '.message-actions'].forEach(s => clone.querySelectorAll(s).forEach(e => e.remove()));
                if (isUser && uploadMap.has(bubble)) {
                    const d = document.createElement("div");
                    uploadMap.get(bubble).forEach(img => d.appendChild(img.cloneNode(true)));
                    clone.appendChild(d);
                }
                children.push(...processNodesToBlocks(clone.childNodes));
                children.push({ object: "block", type: "divider", divider: {} });
            });
        }
        return children;
    }

    // ------------------- 7. Notion 上传 -------------------
    function getChatTitle(specificBubble = null) {
        if (specificBubble) return specificBubble.innerText.replace(/\n/g, ' ').slice(0, 50) + "...";
        const q = document.querySelector('user-query'); return q ? q.innerText.replace(/\n/g, ' ').slice(0, 60) : "Gemini 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) => res.status === 200 ? appendBlocksBatch(pageId, blocks.slice(90), token, statusCallback) : console.error(res.responseText)
        });
    }

    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) => { res.status === 200 ? appendBlocksBatch(JSON.parse(res.responseText).id, blocks.slice(90), token, statusCallback) : (statusCallback('❌ Fail'), alert(res.responseText)); },
            onerror: () => statusCallback('❌ Net Error')
        });
    }

    // ------------------- 8. 主逻辑 & 状态控制 -------------------
    async function executeExport(blocks, title, btnOrLabelUpdater, iconElem) {
        const { token, dbId } = getConfig(); if (!token) return promptConfig();

        const updateStatus = (msg) => {
            // 单条导出按钮处理
            if (btnOrLabelUpdater.classList && btnOrLabelUpdater.classList.contains('gemini-icon-btn') && iconElem) {
                if (msg && msg.includes('Saved')) {
                    btnOrLabelUpdater.classList.remove('processing');
                    btnOrLabelUpdater.classList.add('success');
                    iconElem.textContent = '✅';
                    setTimeout(() => {
                        btnOrLabelUpdater.classList.remove('success');
                        iconElem.textContent = '📤';
                    }, 2500);
                } else if (msg && (msg.includes('Fail') || msg.includes('Error'))) {
                    btnOrLabelUpdater.classList.remove('processing');
                    btnOrLabelUpdater.classList.add('error');
                    iconElem.textContent = '❌';
                } else if (msg) {
                    btnOrLabelUpdater.classList.add('processing');
                    btnOrLabelUpdater.classList.remove('success', 'error');
                    iconElem.textContent = '⏳';
                }
            }
            // 全局按钮处理
            else if (btnOrLabelUpdater.id === 'gemini-saver-btn') {
                if (msg === null) btnOrLabelUpdater.textContent = '📥 Save to Notion';
                else btnOrLabelUpdater.textContent = msg;
            }
        };

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

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

    function handleFullExport() {
        const btn = document.getElementById('gemini-saver-btn');
        const blocks = getChatBlocks(null);
        executeExport(blocks, getChatTitle(), btn);
    }

    function handleSingleExport(bubble, iconBtn, iconElem) {
        const targets = [bubble];
        if (bubble.tagName.toLowerCase() === 'user-query') {
            const next = bubble.nextElementSibling;
            if (next && next.tagName.toLowerCase() === 'model-response' && next.getAttribute('data-privacy-skip') !== 'true') {
                targets.push(next);
            }
        }
        const blocks = getChatBlocks(targets);
        const title = getChatTitle(bubble);
        executeExport(blocks, title, iconBtn, iconElem);
    }

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

})();