Gemini to Notion Exporter

Gemini 导出:修复 CSP/PDF 报错,恢复精准语言识别与多轮图片回填,保留简化版表格处理

As of 24. 11. 2025. See the latest version.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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         Gemini to Notion Exporter
// @namespace    http://tampermonkey.net/
// @version      11.9
// @license      MIT
// @description  Gemini 导出:修复 CSP/PDF 报错,恢复精准语言识别与多轮图片回填,保留简化版表格处理
// @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 NOTION_BLOCK_LIMIT = 90;
    const NOTION_RICH_TEXT_LIMIT = 90;

    // ------------------- 0. 环境自检 -------------------
    function checkPicListConnection() {
        console.log("正在检查 PicList 连接...");
        GM_xmlhttpRequest({
            method: "GET",
            url: "http://127.0.0.1:36677/heartbeat",
            timeout: 2000,
            onload: function(res) {
                if (res.status === 200) console.log("✅ PicList 心跳正常!");
                else console.warn("⚠️ PicList 连接异常:", res.status);
            },
            onerror: function(err) {
                console.error("❌ 无法连接到 PicList (127.0.0.1:36677)。");
            }
        });
    }
    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 (ntn_...):', 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('✅ 配置已保存!');
            }
        }
    }
    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. 核心:资源获取与上传 (新版修复逻辑) -------------------

    // 辅助:通过 Canvas 从页面元素提取图片数据 (绕过 CSP 禁止 fetch blob 的限制)
    function convertBlobImageToBuffer(blobUrl) {
        return new Promise((resolve, reject) => {
            const img = document.querySelector(`img[src="${blobUrl}"]`);
            if (!img) return reject("找不到对应的 DOM 图片元素");
            if (!img.complete || img.naturalWidth === 0) return reject("图片未加载完成");

            try {
                const canvas = document.createElement('canvas');
                canvas.width = img.naturalWidth;
                canvas.height = img.naturalHeight;
                const ctx = canvas.getContext('2d');
                ctx.drawImage(img, 0, 0);
                canvas.toBlob((blob) => {
                    if (!blob) return reject("Canvas 导出失败");
                    blob.arrayBuffer()
                        .then(buffer => resolve({ buffer, type: blob.type || 'image/png' }))
                        .catch(e => reject("ArrayBuffer 转换失败"));
                }, 'image/png');
            } catch (e) {
                reject("Canvas 绘图错误: " + e.message);
            }
        });
    }

    // 核心:获取资源数据 (混合模式)
    function fetchAssetAsArrayBuffer(url) {
        return new Promise((resolve, reject) => {
            // 情况 A: Blob URL (CSP 限制区)
            if (url.startsWith('blob:')) {
                // 优先尝试 Canvas 提取(仅限图片)
                convertBlobImageToBuffer(url)
                    .then(resolve)
                    .catch(canvasErr => {
                        console.warn("[Gemini Saver] Canvas 提取失败,尝试 XHR:", canvasErr);
                        // 如果 Canvas 失败 (如 PDF Blob),尝试 XHR,虽然大概率被 CSP 拦截,但没别的办法了
                        GM_xmlhttpRequest({
                            method: "GET",
                            url: url,
                            responseType: 'arraybuffer',
                            onload: (res) => {
                                if (res.status === 200) resolve({ buffer: res.response, type: 'application/octet-stream' });
                                else reject(`Blob XHR Failed: ${res.status}`);
                            },
                            onerror: () => reject("Blob Network Error (CSP blocked?)")
                        });
                    });
            }
            // 情况 B: 普通 HTTP/HTTPS URL
            else {
                // 必须用 GM_xmlhttpRequest,它不走页面网络栈,能绕过 CSP 且带 Cookies (下载 PDF 必需)
                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")
                });
            }
        });
    }

    // 核心:上传到 PicList (使用 FormData 修复 PDF 损坏问题)
    function uploadToPicList(arrayBufferObj, filename) {
        return new Promise((resolve, reject) => {
            const formData = new FormData();
            const blob = new Blob([arrayBufferObj.buffer], { type: arrayBufferObj.type || 'application/octet-stream' });
            formData.append('file', blob, filename);

            console.log(`[PicList] Uploading: ${filename} (${blob.size} bytes)`);

            GM_xmlhttpRequest({
                method: "POST",
                url: PICLIST_URL,
                data: formData,
                onload: (res) => {
                    try {
                        const r = JSON.parse(res.responseText);
                        if (r.success && r.result && r.result.length > 0) {
                            resolve(r.result[0]);
                        } else {
                            reject("PicList Refused: " + (r.message || res.responseText));
                        }
                    } catch (e) {
                        reject("PicList Response Error: " + e.message);
                    }
                },
                onerror: (err) => reject("PicList Network Error")
            });
        });
    }

    // 处理资源列表
    async function processAssets(blocks, btn) {
        const tasks = [];
        const map = new Map();

        blocks.forEach((b, i) => {
            let urlObj = null;
            let isImageBlock = false;

            if (b.type === 'image' && b.image?.external?.url?.startsWith(ASSET_PLACEHOLDER_PREFIX)) {
                urlObj = b.image.external;
                isImageBlock = true;
            } else if (b.type === 'file' && b.file?.external?.url?.startsWith(ASSET_PLACEHOLDER_PREFIX)) {
                urlObj = b.file.external;
                isImageBlock = false;
            }

            if (urlObj) {
                const parts = urlObj.url.split('::');
                const filename = parts[1];
                const realUrl = parts.slice(2).join('::');

                // 针对 Blob 类型的非图片文件 (如 PDF),由于 CSP 限制无法 fetch,只能跳过
                // 如果是 HTTP 链接的 PDF (URL),可以走 GM_xhr 下载,不需要跳过
                if (realUrl.startsWith('blob:') && !isImageBlock) {
                    console.warn(`[Gemini Saver] ⚠️ 跳过 Blob 文件: ${filename} (CSP 限制)`);
                    b.type = "paragraph";
                    b.paragraph = {
                        rich_text: [{ type: "text", text: { content: `📄 [本地文件未上传] ${filename}` }, annotations: { color: "gray", italic: true } },
                                    { type: "text", text: { content: " (CSP 限制无法提取)" }, annotations: { color: "gray", code: true } }]
                    };
                    delete b.file;
                    return;
                }

                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}` }, annotations: { color: "red" } }]
                    };
                    delete block.file;
                    delete block.image;
                }
            });

            if (failCount > 0) alert(`⚠️ ${failCount} 个文件上传失败!\n原因: ${failMsg}`);
        }
        return blocks;
    }

    // ------------------- 4. 语言检测 (恢复原版 robust 逻辑) -------------------
    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;
        // 向上查找 3 层,寻找 Header (如 "Python code")
        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;
        }
        // 查找 <code> 标签
        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";
    }

    // ------------------- 5. DOM 解析 (恢复原版图片处理 + 新版表格) -------------------
    function parseInlineNodes(nodes) {
        const richText = [];
        function pushTextChunks(content, styles = {}) {
            if (!content) return;
            const maxLen = 1900;
            for (let offset = 0; offset < content.length; offset += maxLen) {
                richText.push({
                    type: "text",
                    text: { content: content.slice(offset, offset + maxLen), 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) {
                if (node.textContent && node.textContent.trim() !== "") pushTextChunks(node.textContent, styles);
            } else if (node.nodeType === Node.ELEMENT_NODE) {
                const newStyles = { ...styles };
                if (['B','STRONG'].includes(node.tagName)) newStyles.bold = true;
                if (['I','EM'].includes(node.tagName)) newStyles.italic = true;
                if (['U'].includes(node.tagName)) newStyles.underline = true;
                if (['S','DEL'].includes(node.tagName)) newStyles.strikethrough = true;
                if (node.tagName === 'CODE') newStyles.code = true;
                if (node.tagName === 'A') newStyles.link = { url: node.href };
                Array.from(node.childNodes).forEach(c => traverse(c, newStyles));
            }
        }
        Array.from(nodes).forEach(n => traverse(n));
        return richText;
    }

    function filenameFromUrl(url) {
        try { return decodeURIComponent(new URL(url).pathname.split("/").pop()) || "file"; } catch(e) { return null; }
    }

    function processNodesToBlocks(nodes) {
        const blocks = [];
        let inlineBuffer = [];
        const flushBuffer = () => {
            if (inlineBuffer.length > 0) {
                const rt = parseInlineNodes(inlineBuffer);
                // 简单的分段处理,防止超出 Notion 限制
                for (let i = 0; i < rt.length; i += NOTION_RICH_TEXT_LIMIT) {
                     const slice = rt.slice(i, i + NOTION_RICH_TEXT_LIMIT);
                     if(slice.length) blocks.push({ object: "block", type: "paragraph", paragraph: { rich_text: slice } });
                }
                inlineBuffer = [];
            }
        };

        Array.from(nodes).forEach(node => {
            if (['SCRIPT','STYLE','SVG','PATH'].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 = node;
                    // 图片链接
                    if (el.childNodes.length === 1 && el.firstElementChild?.tagName === "IMG") {
                        flushBuffer();
                        const img = el.firstElementChild;
                        const filename = (img.alt || filenameFromUrl(el.href) || "image.png").trim();
                        blocks.push({
                            object: "block", type: "image",
                            image: { type: "external", external: { url: `${ASSET_PLACEHOLDER_PREFIX}${filename}::${img.src || el.href}` } }
                        });
                        return;
                    }
                    // 文件链接
                    if (el.href && (el.hasAttribute("download") || /\.(pdf|zip|docx?|xlsx?)/i.test(el.href) || el.href.includes("blob:"))) {
                        flushBuffer();
                        let filename = (el.innerText || filenameFromUrl(el.href) || "attachment").trim();
                        if (filename.length > 80) filename = filename.slice(0,60) + "...";
                        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) {
                flushBuffer();
                const tag = node.tagName;
                if (tag === 'P') blocks.push(...processNodesToBlocks(node.childNodes));
                else if (tag === 'UL' || tag === 'OL') {
                    const type = tag === 'UL' ? 'bulleted_list_item' : 'numbered_list_item';
                    Array.from(node.children).forEach(li => {
                        if (li.tagName === 'LI') {
                            blocks.push({ object: "block", type, [type]: { rich_text: parseInlineNodes(li.childNodes) } });
                            const sub = li.querySelector('ul, ol');
                            if (sub) blocks.push(...processNodesToBlocks([sub]));
                        }
                    });
                }
                else if (tag === 'PRE') {
                    // 使用原版强大的语言检测
                    blocks.push({
                        object: "block", type: "code",
                        code: {
                            rich_text: [{ type: "text", text: { content: node.textContent.substring(0, 1999) } }],
                            language: detectLanguageRecursive(node)
                        }
                    });
                }
                else if (/^H[1-6]$/.test(tag)) {
                    const type = `heading_${tag === 'H1' ? 1 : tag === 'H2' ? 2 : 3}`;
                    blocks.push({ object: "block", type, [type]: { rich_text: parseInlineNodes(node.childNodes) } });
                }
                else if (tag === 'BLOCKQUOTE') {
                    blocks.push({ object: "block", type: "quote", quote: { rich_text: parseInlineNodes(node.childNodes) } });
                }
                else if (tag === 'IMG') {
                    if (!node.className.includes('avatar') && !node.className.includes('user-icon')) {
                        blocks.push({
                            object: "block", type: "image",
                            image: { type: "external", external: { url: `${ASSET_PLACEHOLDER_PREFIX}image.png::${node.src}` } }
                        });
                    }
                }
                else if (tag === 'TABLE') {
                     // 保留新版简化的表格逻辑
                    const rows = Array.from(node.querySelectorAll('tr'));
                    if(rows.length) {
                        const tableBlock = { object: "block", type: "table", table: { table_width: 1, children: [] } };
                        let maxCols = 0;
                        rows.forEach(r => {
                            const cells = Array.from(r.querySelectorAll('td, th'));
                            maxCols = Math.max(maxCols, cells.length);
                            tableBlock.table.children.push({
                                object: "block", type: "table_row",
                                table_row: { cells: cells.map(c => [{ type: "text", text: { content: c.innerText.trim().slice(0, 1000) } }]) }
                            });
                        });
                        tableBlock.table.table_width = maxCols;
                        blocks.push(tableBlock);
                    }
                }
                else {
                    blocks.push(...processNodesToBlocks(node.childNodes));
                }
            }
        });
        flushBuffer();
        return blocks;
    }

    // ------------------- 6. 图片回填 (恢复原版精确逻辑) -------------------
    function buildUploadedImageMap() {
        const uploadedImgs = Array.from(document.querySelectorAll('img[data-test-id="uploaded-img"], img.preview-image'));
        const userBubbles = Array.from(document.querySelectorAll('user-query'));
        const map = new Map();

        // 策略:从 img 往上找最近的 user-query 容器;找不到则找最近的前置 sibling
        function findOwnerByAncestor(img) {
            let node = img.parentElement;
            while (node && node !== document.body) {
                if (node.tagName === 'USER-QUERY') return node; // 有些版本直接在里面
                const uq = node.querySelector('user-query');
                if (uq) return uq;
                node = node.parentElement;
            }
            return null;
        }

        uploadedImgs.forEach(img => {
            let owner = findOwnerByAncestor(img);
            if (!owner && userBubbles.length) {
                // 如果没有直接父子关系,寻找位置最近的 user-query
                let closest = null;
                userBubbles.forEach(u => {
                    const rel = img.compareDocumentPosition(u);
                    // u 在 img 后面 (DOCUMENT_POSITION_FOLLOWING = 4) 表示 u 是 img 之后的元素
                    // 我们要找 img *之前* 的那个 query,或者 img *之后* 紧挨着的
                    // 这里简化逻辑:找文档流中在 img 之前且最近的一个
                    if (u.compareDocumentPosition(img) & Node.DOCUMENT_POSITION_FOLLOWING) {
                        closest = u;
                    }
                });
                owner = closest || userBubbles[userBubbles.length - 1]; // 兜底给最后一个
            }
            if (owner) {
                if (!map.has(owner)) map.set(owner, []);
                map.get(owner).push(img);
            }
        });
        return map;
    }

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

        // 恢复:先构建上传图片映射
        const uploadMap = buildUploadedImageMap();

        if (bubbles.length > 0) {
            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()));

                // 恢复:把归属该 query 的图片克隆进去
                if (isUser && uploadMap.has(bubble)) {
                    const holder = document.createElement("div");
                    uploadMap.get(bubble).forEach(img => holder.appendChild(img.cloneNode(true)));
                    clone.appendChild(holder);
                }

                children.push(...processNodesToBlocks(clone.childNodes));
                children.push({ object: "block", type: "divider", divider: {} });
            });
        } else {
            children.push(...processNodesToBlocks(document.body.childNodes));
        }
        return children;
    }

    // ------------------- 8. Notion 上传 (保持不变) -------------------
    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; }
    }

    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);

        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: getChatUrl() }
                },
                children: firstBatch
            }),
            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';
                    alert("Notion API Error:\n" + res.responseText);
                }
            },
            onerror: () => { btn.textContent = '❌ Net Error'; btn.className = 'error'; }
        });
    }

    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] blocks found:", blocks.length);
            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("Error: " + 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);
    }
    setInterval(tryInit, 2000);
})();