Gemini 导出:修复 CSP/PDF 报错,恢复精准语言识别与多轮图片回填,保留简化版表格处理
当前为
// ==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);
})();