Gemini to Notion Exporter (支持 PicList 上传 & 图片回填)

Gemini 导出:支持 PicList,图片 / 文本 / 代码;Gemini 生成图按对话位置插入;用户上传图按对应 user-query 归位

目前為 2025-11-23 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Gemini to Notion Exporter (支持 PicList 上传 & 图片回填)
// @namespace    http://tampermonkey.net/
// @version      11.6
// @license      MIT
// @description  Gemini 导出:支持 PicList,图片 / 文本 / 代码;Gemini 生成图按对话位置插入;用户上传图按对应 user-query 归位
// @author       Wyih with Gemini Thought Partner (patched by ChatGPT)
// @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 NOTION_BLOCK_LIMIT = 90;
    const NOTION_RICH_TEXT_LIMIT = 90;  // 单个 paragraph.rich_text 最多 90 项

    // ------------------- 0. 环境自检 (启动时运行) -------------------
    function checkPicListConnection() {
        console.log("正在检查 PicList 连接...");
        GM_xmlhttpRequest({
            method: "GET",
            url: "http://127.0.0.1:36677/heartbeat",
            timeout: 2000,
            onload: function(res) {
                console.log("PicList 心跳检测响应:", res.status, res.responseText);
                if (res.status === 200) {
                    try {
                        const data = JSON.parse(res.responseText);
                        if (data.success) {
                            console.log("✅ PicList 心跳正常!");
                        } else {
                            console.warn("⚠️ PicList 心跳返回非 success:", res.responseText);
                        }
                    } catch (e) {
                        console.warn("⚠️ PicList 心跳 JSON 解析失败:", res.responseText);
                    }
                } else {
                    console.warn("⚠️ PicList 心跳 HTTP 非 200:", res.status);
                }
            },
            onerror: function(err) {
                console.error("❌ 无法连接到 PicList (127.0.0.1:36677)。", err);
                alert("⚠️ 警告:脚本无法连接到 PicList (127.0.0.1:36677)\n\n请检查:\n1. PicList 是否开启 Server\n2. 端口是否为 36677");
            },
            ontimeout: function() {
                alert("⚠️ 警告:连接 PicList /heartbeat 超时。请检查防火墙或 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 (secret_...):', GM_getValue('notion_token', ''));
        if (token) {
            const dbId = prompt('请输入 Notion Database ID (32位字符):', GM_getValue('notion_db_id', ''));
            if (dbId) {
                GM_setValue('notion_token', token);
                GM_setValue('notion_db_id', dbId);
                alert('✅ 配置已保存!');
                return true;
            }
        }
        return false;
    }
    GM_registerMenuCommand("⚙️ 设置 Notion Token 和 ID", promptConfig);

    // ------------------- 2. UI 样式 -------------------
    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;
            display: flex; align-items: center; gap: 8px; transition: all 0.2s;
        }
        #gemini-saver-btn:hover { background-color: #0052a3; transform: translateY(-2px); }
        #gemini-saver-btn.loading { background-color: #666; cursor: wait; }
        #gemini-saver-btn.success { background-color: #2ea043; }
        #gemini-saver-btn.error { background-color: #d00; }
    `);

    // ------------------- 3. PicList 上传逻辑 -------------------
    function fetchAssetAsArrayBuffer(url) {
        return new Promise((resolve, reject) => {
            if (url.startsWith('blob:')) {
                fetch(url)
                    .then(r => r.arrayBuffer())
                    .then(buffer => resolve({ buffer: buffer, type: 'application/octet-stream' }))
                    .catch(e => reject(`Blob Fetch Error: ${e.message}`));
            } else {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: url,
                    responseType: 'arraybuffer',
                    onload: (res) => {
                        if (res.status === 200) {
                            const contentType = res.responseHeaders.match(/content-type:\s*(.*)/i)?.[1] || 'application/octet-stream';
                            resolve({ buffer: res.response, type: contentType });
                        } else {
                            reject(`Download Error ${res.status}`);
                        }
                    },
                    onerror: (e) => reject("Network Error")
                });
            }
        });
    }

    function uploadToPicList(arrayBufferObj, filename) {
        return new Promise((resolve, reject) => {
            const mime = arrayBufferObj.type || "application/octet-stream";
            const boundary = "----PicListBoundary" + Math.random().toString(16).slice(2);
            const CRLF = "\r\n";

            function arrayBufferToBinaryString(buffer) {
                const bytes = new Uint8Array(buffer);
                let binary = "";
                for (let i = 0; i < bytes.length; i++) {
                    binary += String.fromCharCode(bytes[i]);
                }
                return binary;
            }

            const head =
                "--" + boundary + CRLF +
                'Content-Disposition: form-data; name="file"; filename="' +
                filename.replace(/"/g, '\\"') + '"' + CRLF +
                "Content-Type: " + mime + CRLF + CRLF;

            const fileBinary = arrayBufferToBinaryString(arrayBufferObj.buffer);
            const tail = CRLF + "--" + boundary + "--" + CRLF;
            const body = head + fileBinary + tail;

            console.log("[PicList] start upload via multipart:", filename, "mime:", mime);

            GM_xmlhttpRequest({
                method: "POST",
                url: PICLIST_URL,
                headers: {
                    "Content-Type": "multipart/form-data; boundary=" + boundary
                },
                data: body,
                binary: true,
                onload: (res) => {
                    console.log("[PicList] response status:", res.status);
                    console.log("[PicList] raw response:", res.responseText);

                    try {
                        const r = JSON.parse(res.responseText);
                        if (r.success && r.result && r.result.length > 0) {
                            console.log("[PicList] upload OK:", r.result[0]);
                            resolve(r.result[0]);
                        } else {
                            reject("PicList Refused: " + (r.message || res.responseText));
                        }
                    } catch (e) {
                        reject("PicList Response Parse Error: " + e.message +
                               " | Raw: " + res.responseText.substring(0, 200));
                    }
                },
                onerror: (err) => {
                    console.error("[PicList] GM_xmlhttpRequest error:", err);
                    reject("Connect Fail (GM_xmlhttpRequest onerror)");
                }
            });
        });
    }

    async function processAssets(blocks, btn) {
        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 parts = urlObj.url.split('::');
                const filename = parts[1];
                const realUrl = parts.slice(2).join('::');

                const task = fetchAssetAsArrayBuffer(realUrl)
                    .then(bufferObj => uploadToPicList(bufferObj, filename))
                    .then(uploadedUrl => ({ i, url: uploadedUrl, filename: filename, ok: true }))
                    .catch(e => ({ i, err: e, filename: filename, ok: false }));

                tasks.push(task);
                map.set(i, b);
            }
        });

        if (tasks.length) {
            btn.textContent = `⏳ Uploading ${tasks.length} files...`;
            const results = await Promise.all(tasks);

            let failCount = 0;
            let failMsg = "";

            results.forEach(r => {
                const block = map.get(r.i);
                if (r.ok) {
                    if (block.type === 'image') {
                        block.image.external.url = r.url;
                    } else if (block.type === 'file') {
                        block.file.external.url = r.url;
                        block.file.name = r.filename || "File";
                    }
                } else {
                    failCount++;
                    failMsg = r.err;
                    console.error(`❌ 文件上传失败 [${r.filename}]:`, r.err);

                    block.type = "paragraph";
                    block.paragraph = {
                        rich_text: [{
                            type: "text",
                            text: { content: `⚠️ Upload Failed: ${r.filename} (Err: ${r.err})` },
                            annotations: {
                                bold: false,
                                italic: false,
                                strikethrough: false,
                                underline: false,
                                code: false,
                                color: "red"
                            }
                        }]
                    };
                    delete block.file;
                    delete block.image;
                }
            });

            if (failCount > 0) {
                alert(`⚠️ ${failCount} 个文件上传失败!\n\n最后一个错误原因:\n${failMsg}\n\n请按 F12 查看控制台获取详细日志。`);
            }
        } else {
            console.log("[PicList] no assets to upload (no PICLIST_WAITING blocks)");
        }
        return blocks;
    }

    // ------------------- 4. DOM 解析 -------------------
    function getChatTitle() {
        const q = document.querySelector('user-query');
        return q ? q.innerText.replace(/\n/g, ' ').trim().substring(0, 60) : "Gemini Chat Export";
    }

    function getChatUrl() {
        try {
            const url = new URL(location.href);
            url.search = "";
            url.hash = "";
            return url.toString();
        } catch (e) {
            return location.href;
        }
    }

    const NOTION_LANGUAGES = new Set([
        "abap","arduino","bash","basic","c","clojure","coffeescript","c++","c#","css","dart","diff","docker",
        "elixir","elm","erlang","flow","fortran","f#","gherkin","glsl","go","graphql","groovy","haskell",
        "html","java","javascript","json","julia","kotlin","latex","less","lisp","livescript","lua","makefile",
        "markdown","markup","matlab","mermaid","nix","objective-c","ocaml","pascal","perl","php","plain text",
        "powershell","prolog","protobuf","python","r","reason","ruby","rust","sass","scala","scheme","scss",
        "shell","sql","swift","typescript","vb.net","verilog","vhdl","visual basic","webassembly","xml",
        "yaml","java/c","c/c++"
    ]);

    function mapLanguageToNotion(lang) {
        if (!lang) return "plain text";
        lang = lang.toLowerCase().trim().replace(/copy|code/g, '').trim();
        const mapping = {
            "js": "javascript","node":"javascript","jsx":"javascript","ts":"typescript","tsx":"typescript",
            "py":"python","python3":"python","cpp":"c++","cc":"c++","cs":"c#","csharp":"c#",
            "sh":"bash","shell":"bash","zsh":"bash","md":"markdown","yml":"yaml",
            "golang":"go","rs":"rust","rb":"ruby","txt":"plain text","text":"plain text"
        };
        if (mapping[lang]) return mapping[lang];
        if (NOTION_LANGUAGES.has(lang)) return lang;
        return "plain text";
    }

    function detectLanguageRecursive(preNode) {
        let currentNode = preNode;
        for (let i = 0; i < 3; i++) {
            if (!currentNode) break;
            let header = currentNode.previousElementSibling;
            if (header) {
                let text = (header.innerText || "").replace(/\n/g, ' ').trim().toLowerCase();
                let words = text.split(' ');
                for (let w of words.slice(0, 3)) {
                    if (NOTION_LANGUAGES.has(w) || w === 'js' || w === 'py' || w === 'cpp') {
                        return mapLanguageToNotion(w);
                    }
                }
            }
            currentNode = currentNode.parentElement;
        }
        const codeEl = preNode.querySelector('code');
        if (codeEl) {
            const cls = codeEl.className || "";
            const match = cls.match(/language-([\w\-\+\#]+)/) || cls.match(/^([\w\-\+\#]+)$/);
            if (match) return mapLanguageToNotion(match[1]);
        }
        return "plain text";
    }

    function parseInlineNodes(nodes) {
        const richText = [];

        function pushTextChunks(content, styles = {}) {
            if (!content) return;
            const maxLen = 1900; // 留一点余量
            for (let offset = 0; offset < content.length; offset += maxLen) {
                const chunk = content.slice(offset, offset + maxLen);
                richText.push({
                    type: "text",
                    text: {
                        content: chunk,
                        link: styles.link || null
                    },
                    annotations: {
                        bold: !!styles.bold,
                        italic: !!styles.italic,
                        strikethrough: !!styles.strikethrough,
                        underline: !!styles.underline,
                        code: !!styles.code,
                        color: "default"
                    }
                });
            }
        }

        function traverse(node, styles = {}) {
            if (node.nodeType === Node.TEXT_NODE) {
                const content = node.textContent;
                if (content && content.trim() !== "") {
                    pushTextChunks(content, styles);
                }
            } else if (node.nodeType === Node.ELEMENT_NODE) {
                const tag = node.tagName;
                const newStyles = { ...styles };
                if (tag === 'B' || tag === 'STRONG') newStyles.bold = true;
                if (tag === 'I' || tag === 'EM') newStyles.italic = true;
                if (tag === 'U') newStyles.underline = true;
                if (tag === 'S' || tag === 'DEL') newStyles.strikethrough = true;
                if (tag === 'CODE') newStyles.code = true;
                if (tag === 'A' && node.href) newStyles.link = { url: node.href };

                Array.from(node.childNodes).forEach(child => traverse(child, newStyles));
            }
        }

        Array.from(nodes).forEach(node => traverse(node));
        return richText;
    }

    function pushParagraphWithLimit(blocks, richText) {
        if (!richText || richText.length === 0) return;
        for (let i = 0; i < richText.length; i += NOTION_RICH_TEXT_LIMIT) {
            const slice = richText.slice(i, i + NOTION_RICH_TEXT_LIMIT);
            if (!slice.some(t => (t.text?.content || "").trim() !== "")) continue;
            blocks.push({
                object: "block",
                type: "paragraph",
                paragraph: { rich_text: slice }
            });
        }
    }

    function isFileLikeLink(a) {
        if (!a || a.nodeName !== "A" || !a.href) return false;
        const href = a.href;
        if (a.hasAttribute("download")) return true;
        if (href.startsWith("blob:")) return true;
        if (/\.(pdf|zip|rar|7z|docx?|xlsx?|pptx?|csv|json|txt|md|apk|dmg|iso|exe|tar|gz)(\?|#|$)/i.test(href)) {
            return true;
        }
        if (href.includes("export=download") || href.includes("uc?export=download")) {
            return true;
        }
        return false;
    }

    function filenameFromUrl(url) {
        try {
            const u = new URL(url);
            const pathname = u.pathname.split("/").filter(Boolean).pop() || "";
            return decodeURIComponent(pathname) || null;
        } catch (e) {
            return null;
        }
    }

    function processNodesToBlocks(nodes) {
        const blocks = [];
        let inlineBuffer = [];

        const flushBuffer = () => {
            if (inlineBuffer.length > 0) {
                const richText = parseInlineNodes(inlineBuffer);
                pushParagraphWithLimit(blocks, richText);
                inlineBuffer = [];
            }
        };

        Array.from(nodes).forEach(node => {
            if (['SCRIPT', 'STYLE', 'SVG', 'PATH', 'MAT-ICON'].includes(node.nodeName)) return;

            // -------- 1. 文本 / 行内标签 --------
            if (
                node.nodeType === Node.TEXT_NODE ||
                ['B', 'STRONG', 'I', 'EM', 'CODE', 'SPAN', 'A'].includes(node.nodeName)
            ) {
                if (node.nodeName === "A") {
                    const el = /** @type {HTMLAnchorElement} */ (node);

                    // ① <a> 里面只有一张 <img>,且没有其它文字 -> 当成 image block
                    const onlyImgChild =
                        el.childNodes.length === 1 &&
                        el.firstElementChild &&
                        el.firstElementChild.tagName === "IMG";

                    if (onlyImgChild) {
                        flushBuffer();

                        const img = /** @type {HTMLImageElement} */ (el.firstElementChild);
                        const src = img.src || el.href;
                        if (src) {
                            let filename =
                                (img.alt && img.alt.trim()) ||
                                filenameFromUrl(el.href) ||
                                "image.png";

                            blocks.push({
                                object: "block",
                                type: "image",
                                image: {
                                    type: "external",
                                    external: {
                                        url: `${ASSET_PLACEHOLDER_PREFIX}${filename}::${src}`
                                    }
                                }
                            });
                        }
                        return;
                    }

                    // ② 普通“文件链接” -> file block
                    if (isFileLikeLink(el)) {
                        flushBuffer();

                        let filename =
                            el.getAttribute("download")?.trim() ||
                            el.innerText.trim() ||
                            filenameFromUrl(el.href) ||
                            "attachment";

                        if (filename.length > 80) {
                            filename = filename.slice(0, 60) + "..." + filename.slice(-10);
                        }

                        blocks.push({
                            object: "block",
                            type: "file",
                            file: {
                                type: "external",
                                name: filename,
                                external: {
                                    url: `${ASSET_PLACEHOLDER_PREFIX}${filename}::${el.href}`
                                }
                            }
                        });
                        return;
                    }
                }

                inlineBuffer.push(node);
                return;
            }

            // -------- 2. 元素节点 --------
            if (node.nodeType === Node.ELEMENT_NODE) {
                const el = /** @type {HTMLElement} */ (node);
                flushBuffer();

                const tag = el.tagName;

                // <p> 不整体打包,递归子节点,保证图文顺序
                if (tag === 'P') {
                    const innerBlocks = processNodesToBlocks(el.childNodes);
                    blocks.push(...innerBlocks);
                }
                else if (tag === 'UL' || tag === 'OL') {
                    const listType = tag === 'UL' ? 'bulleted_list_item' : 'numbered_list_item';
                    Array.from(el.children).forEach(li => {
                        if (li.tagName === 'LI') {
                            const liRichText = parseInlineNodes(li.childNodes);
                            for (let i = 0; i < liRichText.length; i += NOTION_RICH_TEXT_LIMIT) {
                                const slice = liRichText.slice(i, i + NOTION_RICH_TEXT_LIMIT);
                                blocks.push({
                                    object: "block",
                                    type: listType,
                                    [listType]: { rich_text: slice }
                                });
                            }
                            const subList = li.querySelector('ul, ol');
                            if (subList) blocks.push(...processNodesToBlocks([subList]));
                        }
                    });
                }
                else if (tag === 'PRE') {
                    const codeContent = el.textContent;
                    const finalLang = detectLanguageRecursive(el);
                    blocks.push({
                        object: "block",
                        type: "code",
                        code: {
                            rich_text: [{
                                type: "text",
                                text: { content: codeContent.substring(0, 1999) }
                            }],
                            language: finalLang
                        }
                    });
                }
                else if (['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(tag)) {
                    let type = "heading_3";
                    if (tag === 'H1') type = "heading_1";
                    if (tag === 'H2') type = "heading_2";
                    blocks.push({
                        object: "block",
                        type,
                        [type]: { rich_text: parseInlineNodes(el.childNodes) }
                    });
                }
                else if (tag === 'BLOCKQUOTE') {
                    blocks.push({
                        object: "block",
                        type: "quote",
                        quote: { rich_text: parseInlineNodes(el.childNodes) }
                    });
                }
                else if (tag === 'IMG') {
                    const cls = el.className || '';
                    const alt = el.alt || '';

                    const isAvatar =
                        /avatar|user-icon/i.test(cls) ||
                        /profile/i.test(alt);

                    if (el.src && !isAvatar) {
                        console.log("[Gemini Saver] capture IMG:", el.src);
                        blocks.push({
                            object: "block",
                            type: "image",
                            image: {
                                type: "external",
                                external: {
                                    url: `${ASSET_PLACEHOLDER_PREFIX}image.png::${el.src}`
                                }
                            }
                        });
                    }
                }
                else if (tag === 'TABLE') {
                    const rows = Array.from(el.querySelectorAll('tr'));
                    if (rows.length === 0) return;

                    // 计算最大列数
                    let colCount = 0;
                    rows.forEach(r => {
                        const cells = Array.from(r.querySelectorAll('td, th'));
                        if (cells.length > colCount) colCount = cells.length;
                    });
                    if (colCount === 0) return;

                    const maxLen = 1500; // 单个 text.content 安全长度

                    const tableBlock = {
                        object: "block",
                        type: "table",
                        table: {
                            table_width: colCount,
                            has_column_header: false,
                            has_row_header: false,
                            children: []   // ✅ children 放在 table 里面
                        }
                    };

                    rows.forEach(r => {
                        const cells = Array.from(r.querySelectorAll('td, th'));
                        const rowCells = [];

                        for (let col = 0; col < colCount; col++) {
                            const cell = cells[col];
                            const rawText = cell ? cell.innerText.trim().replace(/\s+/g, ' ') : "";

                            const richTexts = [];
                            if (rawText) {
                                for (let i = 0; i < rawText.length; i += maxLen) {
                                    const chunk = rawText.slice(i, i + maxLen);
                                    richTexts.push({
                                        type: "text",
                                        text: { content: chunk }
                                    });
                                }
                            } else {
                                richTexts.push({
                                    type: "text",
                                    text: { content: "" }
                                });
                            }

                            rowCells.push(richTexts);
                        }

                        tableBlock.table.children.push({   // ✅ 写到 table.children 里
                            object: "block",
                            type: "table_row",
                            table_row: {
                                cells: rowCells
                            }
                        });
                    });

                    blocks.push(tableBlock);
                }
                else {
                    // 默认:递归处理子节点,保证嵌套结构里的图片也能按顺序出来
                    const innerBlocks = processNodesToBlocks(el.childNodes);
                    blocks.push(...innerBlocks);
                }
            }
        });

        flushBuffer();
        return blocks;
    }

    // ------------------- 4.5 把上传图片按 DOM 顺序映射到 user-query -------------------
    function buildUploadedImageMap() {
        // 1. 找到所有用户上传的预览图
        const uploadedImgs = Array.from(
            document.querySelectorAll(
                'img[data-test-id="uploaded-img"], img.preview-image[alt="Uploaded image preview"]'
            )
        );
        const userBubbles = Array.from(document.querySelectorAll('user-query'));

        console.log("[Gemini Saver] uploaded preview imgs:", uploadedImgs.length);
        console.log("[Gemini Saver] user-query bubbles:", userBubbles.length);

        const map = new Map(); // key: <user-query> element, value: img[]

        // 优先策略:从 img 往上找最近的“turn 容器”,里面含有 user-query 就归给它
        function findOwnerByAncestor(img) {
            let node = img.parentElement;
            while (node && node !== document.body && node !== document.documentElement) {
                const uq = node.querySelector('user-query');
                if (uq) {
                    return uq;
                }
                node = node.parentElement;
            }
            return null;
        }

        uploadedImgs.forEach(img => {
            let owner = findOwnerByAncestor(img);

            // 如果祖先找不到,就退回到“按文档顺序最近的后继 user-query”策略
            if (!owner && userBubbles.length) {
                let candidate = null;
                userBubbles.forEach(u => {
                    const rel = img.compareDocumentPosition(u);
                    // u 在 img 后面
                    if (rel & Node.DOCUMENT_POSITION_FOLLOWING) {
                        if (!candidate) {
                            candidate = u;
                        } else {
                            // 选离 img 最近的那个 user-query
                            const rel2 = candidate.compareDocumentPosition(u);
                            // candidate 在 u 前面 => u 更靠近 img
                            if (rel2 & Node.DOCUMENT_POSITION_FOLLOWING) {
                                candidate = u;
                            }
                        }
                    }
                });
                owner = candidate || owner;
            }

            // 如果还没有(极少数异常情况),兜底给最后一个 user-query
            if (!owner && userBubbles.length) {
                owner = userBubbles[userBubbles.length - 1];
            }

            if (!owner) {
                console.warn("[Gemini Saver] uploaded img has no owner user-query:", img.src);
                return;
            }

            const arr = map.get(owner) || [];
            arr.push(img);
            map.set(owner, arr);
        });

        console.log("[Gemini Saver] uploaded image map size:", map.size);
        return map;
    }

    // ------------------- 5. 抓取入口 -------------------
    function getInitialChatBlocks() {
        const bubbles = document.querySelectorAll('user-query, model-response');
        const children = [];

        // 先构建「上传图片 → 最近 user-query」映射
        const uploadMap = buildUploadedImageMap();

        if (bubbles.length > 0) {
            console.log("[Gemini Saver] found bubbles:", bubbles.length);

            bubbles.forEach(bubble => {
                const isUser = bubble.tagName.toLowerCase() === 'user-query';
                children.push({
                    object: "block",
                    type: "heading_3",
                    heading_3: {
                        rich_text: [{
                            type: "text",
                            text: { content: isUser ? "User" : "Gemini" }
                        }],
                        color: isUser ? "default" : "blue_background"
                    }
                });

                const clone = bubble.cloneNode(true);
                ['mat-icon', '.response-footer', '.message-actions'].forEach(s => {
                    clone.querySelectorAll(s).forEach(e => e.remove());
                });

                // ⭐ 对于 user-query:把映射给它的「上传图片 preview」克隆进来
                if (isUser && uploadMap.has(bubble)) {
                    const imgs = uploadMap.get(bubble);
                    const holder = document.createElement("div");
                    holder.setAttribute("data-gemini-saver-uploaded-holder", "true");
                    imgs.forEach(img => {
                        const copy = img.cloneNode(true);
                        holder.appendChild(copy);
                    });
                    // 简单起见:放在 bubble 内容末尾(通常对应 UI 中图片在文字下方的情况)
                    clone.appendChild(holder);
                }

                const contentBlocks = processNodesToBlocks(clone.childNodes);
                children.push(...contentBlocks);
                children.push({ object: "block", type: "divider", divider: {} });
            });

            return children;
        }

        console.warn("[Gemini Saver] no user-query/model-response found, fallback to document.body");
        const contentBlocks = processNodesToBlocks(document.body.childNodes);
        children.push(...contentBlocks);
        return children;
    }

    // ------------------- 6. Notion 上传 -------------------
    function appendBlocksBatch(pageId, remainingBlocks, token, btn) {
        if (remainingBlocks.length === 0) {
            btn.textContent = '✅ Saved!'; btn.className = 'success';
            setTimeout(() => { btn.textContent = '📥 Save to Notion'; btn.className = ''; }, 3000);
            return;
        }
        const batch = remainingBlocks.slice(0, NOTION_BLOCK_LIMIT);
        const nextRemaining = remainingBlocks.slice(NOTION_BLOCK_LIMIT);
        btn.textContent = `⏳ Appending (${remainingBlocks.length})...`;

        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: batch }),
            onload: (res) => {
                if (res.status === 200) {
                    appendBlocksBatch(pageId, nextRemaining, token, btn);
                } else {
                    console.error(res.responseText);
                    btn.textContent = '❌ Append Fail';
                }
            }
        });
    }

    function createPageAndUpload(title, blocks, token, dbId, btn) {
        const firstBatch = blocks.slice(0, NOTION_BLOCK_LIMIT);
        const remaining = blocks.slice(NOTION_BLOCK_LIMIT);

        const payload = {
            parent: { database_id: dbId },
            properties: {
                "Name": { title: [{ text: { content: title } }] },
                "Date": { date: { start: new Date().toISOString() } },
                "URL": { url: getChatUrl() }
            },
            children: firstBatch
        };

        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(payload),
            onload: (res) => {
                if (res.status === 200) {
                    const pageId = JSON.parse(res.responseText).id;
                    if (remaining.length > 0) {
                        appendBlocksBatch(pageId, remaining, token, btn);
                    } else {
                        btn.textContent = '✅ Saved!'; btn.className = 'success';
                        setTimeout(() => {
                            btn.textContent = '📥 Save to Notion';
                            btn.className = '';
                        }, 3000);
                    }
                } else {
                    console.error(res.responseText);
                    btn.textContent = '❌ Create Fail'; btn.className = 'error';
                    if (res.responseText.includes("Date") && res.responseText.includes("property")) {
                        alert("❌ Notion Database 缺少 'Date' 列!请添加或修改脚本。");
                    } else {
                        alert("Create Page Error:\n" + res.responseText);
                    }
                }
            },
            onerror: () => {
                btn.textContent = '❌ Net Error'; btn.className = 'error';
            }
        });
    }

    // ------------------- 7. 主入口 -------------------
    async function main() {
        const { token, dbId } = getConfig();
        if (!token || !dbId) {
            promptConfig();
            return;
        }
        const btn = document.getElementById('gemini-saver-btn');
        btn.textContent = '🕵️ Analyzing...'; btn.className = 'loading';
        try {
            let blocks = getInitialChatBlocks();
            console.log("[Gemini Saver] total blocks:", blocks.length);

            if (!blocks || blocks.length === 0) throw new Error("未找到对话内容");
            blocks = await processAssets(blocks, btn);

            btn.textContent = '💾 Creating Page...';
            createPageAndUpload(getChatTitle(), blocks, token, dbId, btn);
        } catch (e) {
            console.error(e);
            btn.textContent = '❌ Error'; btn.className = 'error';
            alert("执行出错: " + e.message);
        }
    }

    function tryInit() {
        if (document.getElementById('gemini-saver-btn')) return;
        const btn = document.createElement('button');
        btn.id = 'gemini-saver-btn';
        btn.textContent = '📥 Save to Notion';
        btn.onclick = main;
        document.body.appendChild(btn);
    }

    let retries = 0;
    const loadTimer = setInterval(() => {
        tryInit();
        retries++;
        if (document.getElementById('gemini-saver-btn') || retries > 10) clearInterval(loadTimer);
    }, 1000);

    let lastUrl = location.href;
    new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            setTimeout(tryInit, 2000);
        }
    }).observe(document.body, {subtree: true, childList: true});

})();