Gemini to Notion Exporter

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

2025-11-25 기준 버전입니다. 최신 버전을 확인하세요.

이 스크립트를 설치하려면 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);

})();