Gemini 导出:智能图片归位 (支持 PicList/PicGo)+隐私开关+单个对话导出
目前為
// ==UserScript==
// @name Gemini to Notion Exporter
// @namespace http://tampermonkey.net/
// @version 13.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 MAX_TEXT_LENGTH = 2000;
// ------------------- 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; transition: all 0.2s;
}
#gemini-saver-btn:hover { background-color: #0052a3; transform: translateY(-2px); }
#gemini-saver-btn.loading { background-color: #666; cursor: wait; }
/* 局部工具栏 - 强力可见性 */
.gemini-tool-group {
position: absolute; z-index: 9500;
display: flex; gap: 6px;
opacity: 0.4; /* 默认半透明 */
transition: all 0.2s;
filter: grayscale(1);
background: rgba(255,255,255,0.95);
padding: 4px 8px; border-radius: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
border: 1px solid #ccc;
}
user-query:hover .gemini-tool-group, model-response:hover .gemini-tool-group, .gemini-tool-group:hover {
opacity: 1 !important; filter: grayscale(0); transform: scale(1.05); background: white; border-color: #bbb;
}
user-query .gemini-tool-group { bottom: 8px; right: 8px; }
model-response .gemini-tool-group { bottom: 15px; right: 0px; }
/* 图标按钮 */
.gemini-icon-btn {
cursor: pointer; font-size: 18px; line-height: 24px; user-select: none;
width: 28px; height: 28px; text-align: center;
border-radius: 50%; transition: background 0.2s;
display: flex; align-items: center; justify-content: center;
}
.gemini-icon-btn:hover { background: rgba(0,0,0,0.08); }
/* 隐私激活 */
.gemini-privacy-toggle[data-skip="true"] { color: #d93025; background: #fce8e6; opacity: 1; filter: none; }
.gemini-tool-group:has(.gemini-privacy-toggle[data-skip="true"]) { opacity: 1; filter: none; border-color: #fce8e6; }
/* --- 动画 --- */
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.gemini-icon-btn.processing { cursor: wait; color: #1a73e8; background: #e8f0fe; }
.gemini-icon-btn.processing span { display: block; animation: spin 1s linear infinite; }
.gemini-icon-btn.success { color: #188038 !important; background: #e6f4ea; }
.gemini-icon-btn.error { color: #d93025 !important; background: #fce8e6; }
`);
// ------------------- 3. UI 注入 (DOM 原生创建,拒绝 innerHTML) -------------------
function injectPageControls() {
const bubbles = document.querySelectorAll('user-query, model-response');
bubbles.forEach(bubble => {
if (bubble.querySelector('.gemini-tool-group')) return;
// 强制定位
if (getComputedStyle(bubble).position === 'static') bubble.style.position = 'relative';
const group = document.createElement('div');
group.className = 'gemini-tool-group';
// --- 隐私按钮 ---
const privacyBtn = document.createElement('div');
privacyBtn.className = 'gemini-icon-btn gemini-privacy-toggle';
privacyBtn.title = "点击切换:是否导出此条内容";
privacyBtn.setAttribute('data-skip', 'false');
// 使用 span 包裹以便旋转
const privacyIcon = document.createElement('span');
privacyIcon.textContent = '👁️';
privacyBtn.appendChild(privacyIcon);
privacyBtn.onclick = (e) => {
e.stopPropagation();
const isSkipping = privacyBtn.getAttribute('data-skip') === 'true';
if (isSkipping) {
privacyBtn.setAttribute('data-skip', 'false'); privacyIcon.textContent = '👁️'; bubble.setAttribute('data-privacy-skip', 'false');
} else {
privacyBtn.setAttribute('data-skip', 'true'); privacyIcon.textContent = '🚫'; bubble.setAttribute('data-privacy-skip', 'true');
}
};
// --- 单条导出按钮 ---
const singleExportBtn = document.createElement('div');
singleExportBtn.className = 'gemini-icon-btn';
singleExportBtn.title = "仅导出此条对话";
const exportIcon = document.createElement('span');
exportIcon.textContent = '📤';
singleExportBtn.appendChild(exportIcon);
singleExportBtn.onclick = (e) => {
e.stopPropagation();
handleSingleExport(bubble, singleExportBtn, exportIcon); // 传icon进去以便控制文字
};
group.appendChild(privacyBtn);
group.appendChild(singleExportBtn);
bubble.appendChild(group);
});
}
// ------------------- 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 = { 'application/pdf':'.pdf', 'application/msword':'.doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':'.docx', 'image/png':'.png', 'image/jpeg':'.jpg', 'image/webp':'.webp' };
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 combinedBlob = new Blob([preData, arrayBufferObj.buffer, `\r\n--${boundary}--\r\n`]);
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, statusCallback) {
const tasks = []; const map = new Map();
blocks.forEach((b, i) => {
let urlObj = null;
if (b.type === 'image' && b.image?.external?.url?.startsWith(ASSET_PLACEHOLDER_PREFIX)) { urlObj = b.image.external; }
else if (b.type === 'file' && b.file?.external?.url?.startsWith(ASSET_PLACEHOLDER_PREFIX)) { urlObj = b.file.external; }
if (urlObj) {
const [_, name, realUrl] = urlObj.url.split('::');
if (realUrl.startsWith('blob:') && b.type === 'file') {
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) {
statusCallback(`⏳ 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 splitCodeSafe(code) {
const chunks = [];
let remaining = code;
while (remaining.length > 0) {
// 如果剩余长度小于等于 MAX,直接结束
if (remaining.length <= MAX_TEXT_LENGTH) {
chunks.push(remaining);
break;
}
// 搜索换行符。关键修正:使用 MAX_TEXT_LENGTH - 1 作为搜索边界。
// 这样找到的 index 最大为 1999。
// 下一步 splitIndex += 1 后,最大为 2000,完美避开 Notion 的 2000 限制。
let splitIndex = remaining.lastIndexOf('\n', MAX_TEXT_LENGTH - 1);
if (splitIndex === -1) {
// 没找到换行符,强制切分
splitIndex = MAX_TEXT_LENGTH;
} else {
// 找到了,包含换行符
splitIndex += 1;
}
chunks.push(remaining.slice(0, splitIndex));
remaining = remaining.slice(splitIndex);
}
return chunks;
}
function parseInlineNodes(nodes) {
const rt=[];
function tr(n,s={}){
if(n.nodeType===3){
const fullText = n.textContent;
if (!fullText) return;
for (let i = 0; i < fullText.length; i += MAX_TEXT_LENGTH) {
rt.push({ type: "text", text: { content: fullText.slice(i, i + MAX_TEXT_LENGTH), 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;
}
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; } };
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') {
const fullCode = n.textContent;
const lang = detectLanguageRecursive(n);
const rawChunks = splitCodeSafe(fullCode);
// 核心修复:代码块只保留纯文本,去除 annotations,确保高亮生效
const codeRichText = rawChunks.map(chunk => ({ type: "text", text: { content: chunk } }));
blocks.push({ object:"block", type:"code", code:{ rich_text: codeRichText, language: lang } });
}
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. 抓取逻辑 -------------------
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 getChatBlocks(targetBubbles = null) {
const allBubbles = document.querySelectorAll('user-query, model-response');
const bubblesToProcess = targetBubbles || Array.from(allBubbles);
const children = [];
const uploadMap = buildUploadedImageMap();
if (bubblesToProcess.length > 0) {
bubblesToProcess.forEach(bubble => {
const isUser = bubble.tagName.toLowerCase() === 'user-query';
const role = isUser ? "User" : "Gemini";
if (bubble.getAttribute('data-privacy-skip') === 'true') {
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-tool-group', '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: {} });
});
}
return children;
}
// ------------------- 7. Notion 上传 -------------------
function getChatTitle(specificBubble = null) {
if (specificBubble) return specificBubble.innerText.replace(/\n/g, ' ').slice(0, 50) + "...";
const q = document.querySelector('user-query'); return q ? q.innerText.replace(/\n/g, ' ').slice(0,60) : "Gemini Chat";
}
function appendBlocksBatch(pageId, blocks, token, statusCallback) {
if(!blocks.length) { statusCallback('✅ Saved!'); setTimeout(()=>statusCallback(null), 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, statusCallback) : console.error(res.responseText)
});
}
function createPageAndUpload(title, blocks, token, dbId, statusCallback) {
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, statusCallback) : (statusCallback('❌ Fail'), alert(res.responseText)); },
onerror: () => statusCallback('❌ Net Error')
});
}
// ------------------- 8. 主逻辑 & 状态控制 -------------------
async function executeExport(blocks, title, btnOrLabelUpdater, iconElem) {
const { token, dbId } = getConfig(); if (!token) return promptConfig();
const updateStatus = (msg) => {
// 单条导出按钮处理
if (btnOrLabelUpdater.classList && btnOrLabelUpdater.classList.contains('gemini-icon-btn') && iconElem) {
if (msg && msg.includes('Saved')) {
btnOrLabelUpdater.classList.remove('processing');
btnOrLabelUpdater.classList.add('success');
iconElem.textContent = '✅';
setTimeout(() => {
btnOrLabelUpdater.classList.remove('success');
iconElem.textContent = '📤';
}, 2500);
} else if (msg && (msg.includes('Fail') || msg.includes('Error'))) {
btnOrLabelUpdater.classList.remove('processing');
btnOrLabelUpdater.classList.add('error');
iconElem.textContent = '❌';
} else if (msg) {
btnOrLabelUpdater.classList.add('processing');
btnOrLabelUpdater.classList.remove('success', 'error');
iconElem.textContent = '⏳';
}
}
// 全局按钮处理
else if (btnOrLabelUpdater.id === 'gemini-saver-btn') {
if (msg === null) btnOrLabelUpdater.textContent = '📥 Save to Notion';
else btnOrLabelUpdater.textContent = msg;
}
};
if (btnOrLabelUpdater.id === 'gemini-saver-btn') {
btnOrLabelUpdater.classList.add('loading');
btnOrLabelUpdater.textContent = '🕵️ Processing...';
} else {
updateStatus('Processing...');
}
try {
blocks = await processAssets(blocks, updateStatus);
if (btnOrLabelUpdater.id === 'gemini-saver-btn') btnOrLabelUpdater.textContent = '💾 Saving...';
createPageAndUpload(title, blocks, token, dbId, updateStatus);
} catch (e) {
console.error(e);
if (btnOrLabelUpdater.id === 'gemini-saver-btn') btnOrLabelUpdater.textContent = '❌ Error';
updateStatus('❌ Fail');
alert(e.message);
} finally {
if (btnOrLabelUpdater.id === 'gemini-saver-btn') btnOrLabelUpdater.classList.remove('loading');
}
}
function handleFullExport() {
const btn = document.getElementById('gemini-saver-btn');
const blocks = getChatBlocks(null);
executeExport(blocks, getChatTitle(), btn);
}
function handleSingleExport(bubble, iconBtn, iconElem) {
const targets = [bubble];
if (bubble.tagName.toLowerCase() === 'user-query') {
const next = bubble.nextElementSibling;
if (next && next.tagName.toLowerCase() === 'model-response' && next.getAttribute('data-privacy-skip') !== 'true') {
targets.push(next);
}
}
const blocks = getChatBlocks(targets);
const title = getChatTitle(bubble);
executeExport(blocks, title, iconBtn, iconElem);
}
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 = handleFullExport;
document.body.appendChild(btn);
}
injectPageControls();
}
setInterval(tryInit, 1500);
})();