Gemini to Notion Exporter

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

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

您需要先安裝使用者腳本管理器擴展,如 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
// @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);

})();