Gemini to Notion Exporter

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

Устаревшая версия за 25.11.2025. Перейдите к последней версии.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

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

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         Gemini to Notion Exporter
// @namespace    http://tampermonkey.net/
// @version      12.0
// @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 NOTION_BLOCK_LIMIT = 90;
    const NOTION_RICH_TEXT_LIMIT = 90;

    // ------------------- 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 样式 -------------------
    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;
        }
        #gemini-saver-btn:hover { background-color: #0052a3; transform: translateY(-2px); }
        #gemini-saver-btn.loading { background-color: #666; cursor: wait; }
        
        .gemini-privacy-toggle {
            position: absolute; z-index: 900;
            cursor: pointer; opacity: 0.1; transition: opacity 0.2s, transform 0.2s;
            font-size: 16px; user-select: none; filter: grayscale(1);
        }
        user-query:hover .gemini-privacy-toggle, model-response:hover .gemini-privacy-toggle { opacity: 0.6; }
        .gemini-privacy-toggle:hover { opacity: 1 !important; transform: scale(1.2); filter: grayscale(0); }
        .gemini-privacy-toggle[data-skip="true"] { opacity: 1; filter: none; }
        user-query .gemini-privacy-toggle { bottom: 8px; right: 8px; }
        model-response .gemini-privacy-toggle { bottom: 15px; right: 0px; }
    `);

    // ------------------- 3. 核心:隐私标记功能 -------------------
    function injectPrivacyToggles() {
        const bubbles = document.querySelectorAll('user-query, model-response');
        bubbles.forEach(bubble => {
            if (bubble.querySelector('.gemini-privacy-toggle')) return;
            const btn = document.createElement('div');
            btn.className = 'gemini-privacy-toggle';
            btn.title = "点击切换:是否导出此条内容";
            btn.innerText = '👁️'; 
            btn.setAttribute('data-skip', 'false');
            btn.onclick = (e) => {
                e.stopPropagation();
                const isSkipping = btn.getAttribute('data-skip') === 'true';
                if (isSkipping) {
                    btn.setAttribute('data-skip', 'false'); btn.innerText = '👁️'; bubble.setAttribute('data-privacy-skip', 'false');
                } else {
                    btn.setAttribute('data-skip', 'true'); btn.innerText = '🚫'; bubble.setAttribute('data-privacy-skip', 'true');
                }
            };
            if (getComputedStyle(bubble).position === 'static') bubble.style.position = 'relative';
            bubble.appendChild(btn);
        });
    }

    // ------------------- 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 = {
                    'text/x-r-source': '.R', 'text/x-r': '.R', 'text/x-r-markdown': '.Rmd', 'text/quarto': '.qmd',
                    'application/pdf': '.pdf', 'application/msword': '.doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
                    'application/vnd.ms-excel': '.xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
                    'application/vnd.ms-powerpoint': '.ppt', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
                    'text/html': '.html', 'application/json': '.json', 'text/xml': '.xml', 'text/javascript': '.js', 'text/css': '.css', 'text/x-python': '.py',
                    'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/svg+xml': '.svg',
                    'text/plain': '.txt', 'text/markdown': '.md', 'application/zip': '.zip', 'application/gzip': '.tar.gz'
                };
                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 postData = `\r\n--${boundary}--\r\n`;
            const combinedBlob = new Blob([preData, arrayBufferObj.buffer, postData]);

            console.log(`[Gemini Saver] 上传: ${finalFilename}`);
            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, btn) {
        const tasks = []; const map = new Map();
        blocks.forEach((b, i) => {
            let urlObj = null, isImg = false;
            if (b.type === 'image' && b.image?.external?.url?.startsWith(ASSET_PLACEHOLDER_PREFIX)) { urlObj = b.image.external; isImg = true; }
            else if (b.type === 'file' && b.file?.external?.url?.startsWith(ASSET_PLACEHOLDER_PREFIX)) { urlObj = b.file.external; isImg = false; }

            if (urlObj) {
                const [_, name, realUrl] = urlObj.url.split('::');
                if (realUrl.startsWith('blob:') && !isImg) {
                    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) {
            btn.textContent = `⏳ 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 parseInlineNodes(nodes) {
        const rt=[]; function tr(n,s={}){
            if(n.nodeType===3){ if(n.textContent) rt.push({type:"text",text:{content:n.textContent.slice(0,1900),link:s.link},annotations:{bold:!!s.bold,italic:!!s.italic,code:!!s.code,color:"default"}}); }
            else if(n.nodeType===1){ const ns={...s}; if(['B','STRONG'].includes(n.tagName))ns.bold=true; if(['I','EM'].includes(n.tagName))ns.italic=true; if(n.tagName==='CODE')ns.code=true; if(n.tagName==='A')ns.link={url:n.href}; n.childNodes.forEach(c=>tr(c,ns)); }
        } nodes.forEach(n=>tr(n)); return rt;
    }
    
    // 【关键修复】增强文件识别正则,添加 html/r/qmd
    function processNodesToBlocks(nodes) {
        const blocks=[], buf=[]; const flush=()=>{ if(buf.length){ const rt=parseInlineNodes(buf); for(let i=0;i<rt.length;i+=90) blocks.push({object:"block",type:"paragraph",paragraph:{rich_text:rt.slice(i,i+90)}}); buf.length=0; } };
        
        // 更新:支持 html, R, Rmd, qmd, doc, ppt 等后缀
        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;
            if(n.nodeType===3||['B','I','CODE','SPAN','A'].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); return;
            }
            if(n.nodeType===1) {
                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') blocks.push({object:"block",type:"code",code:{rich_text:[{type:"text",text:{content:n.textContent.slice(0,1999)}}],language:detectLanguageRecursive(n)}});
                else if(/^H[1-6]$/.test(t)) blocks.push({object:"block",type:`heading_${t[1]<4?t[1]:3}`,[`heading_${t[1]<4?t[1]:3}`]:{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=>[{type:"text",text:{content:c.innerText.trim().slice(0,1000)}}])}}); });
                        tb.table.table_width=max; blocks.push(tb);
                    }
                } else blocks.push(...processNodesToBlocks(n.childNodes));
            }
        }); flush(); return blocks;
    }

    // ------------------- 6. 抓取入口 (User & Gemini 隐私支持) -------------------
    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 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';
                const role = isUser ? "User" : "Gemini";

                if (bubble.getAttribute('data-privacy-skip') === 'true') {
                    console.log(`[Gemini Saver] 跳过隐私内容 (${role})`);
                    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-privacy-toggle', '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: {} });
            });
        } else {
            children.push(...processNodesToBlocks(document.body.childNodes));
        }
        return children;
    }

    // ------------------- 7. Notion 上传 (保持一致) -------------------
    function getChatTitle() { const q = document.querySelector('user-query'); return q ? q.innerText.replace(/\n/g, ' ').slice(0,60) : "Gemini Chat"; }
    function appendBlocksBatch(pageId, blocks, token, btn) {
        if(!blocks.length) { btn.textContent='✅ Saved!'; btn.className='success'; setTimeout(()=>{btn.textContent='📥 Save to Notion';btn.className='';},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, btn) : console.error(res.responseText)
        });
    }
    function createPageAndUpload(title, blocks, token, dbId, btn) {
        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, btn) : (btn.textContent='❌ Fail', alert(res.responseText)); },
            onerror: () => btn.textContent='❌ Net Error'
        });
    }

    // ------------------- 8. 主程序 -------------------
    async function main() {
        const { token, dbId } = getConfig(); if (!token) return promptConfig();
        const btn = document.getElementById('gemini-saver-btn'); btn.textContent = '🕵️ Processing...'; btn.className = 'loading';
        try {
            let blocks = getInitialChatBlocks();
            blocks = await processAssets(blocks, btn);
            btn.textContent = '💾 Saving...';
            createPageAndUpload(getChatTitle(), blocks, token, dbId, btn);
        } catch (e) { console.error(e); btn.textContent = '❌ Error'; alert(e.message); }
    }

    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 = main;
            document.body.appendChild(btn);
        }
        injectPrivacyToggles();
    }
    setInterval(tryInit, 1500);

})();