Gemini 导出:修复 CSP/PDF 报错,恢复精准语言识别与多轮图片回填,保留简化版表格处理
当前为
// ==UserScript==
// @name Gemini to Notion Exporter
// @namespace http://tampermonkey.net/
// @version 11.10
// @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 (手动构建 Multipart + 全能后缀补全 + R语言支持)
function uploadToPicList(arrayBufferObj, filename) {
return new Promise((resolve, reject) => {
if (!arrayBufferObj.buffer || arrayBufferObj.buffer.byteLength === 0) {
return reject("文件大小为 0,下载失败");
}
// 1. 智能补全文件名后缀 (MIME 映射表)
let finalFilename = filename;
// 去除可能存在的查询参数
if (finalFilename.includes('?')) finalFilename = finalFilename.split('?')[0];
const mime = (arrayBufferObj.type || '').toLowerCase().split(';')[0].trim();
// 判断是否需要补后缀
if (!finalFilename.includes('.') || finalFilename.length - finalFilename.lastIndexOf('.') > 6) {
const mimeMap = {
// --- R / Data Science (新增) ---
'text/x-r-source': '.R',
'text/x-r': '.R',
'application/x-r-source': '.R',
'text/x-r-markdown': '.Rmd',
'application/x-r-markdown': '.Rmd',
'text/quarto': '.qmd',
'application/quarto': '.qmd',
// Quarto 很多时候会被识别为 markdown,如果文件名本身没后缀,.md 也是可接受的兜底
// --- 办公文档 ---
'application/pdf': '.pdf',
'application/msword': '.doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
'application/vnd.ms-powerpoint': '.ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
'application/vnd.ms-excel': '.xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
'text/csv': '.csv',
'application/rtf': '.rtf',
// --- 网页与代码 ---
'text/html': '.html',
'application/json': '.json',
'text/xml': '.xml',
'application/xml': '.xml',
'text/javascript': '.js',
'application/javascript': '.js',
'text/css': '.css',
'text/x-python': '.py',
'application/x-python-code': '.py',
'text/x-java-source': '.java',
'text/x-c': '.c',
'text/x-c++': '.cpp',
'text/x-shellscript': '.sh',
// --- 图片 ---
'image/png': '.png',
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/gif': '.gif',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'image/bmp': '.bmp',
// --- 文本与压缩包 ---
'text/plain': '.txt',
'text/markdown': '.md',
'application/zip': '.zip',
'application/x-zip-compressed': '.zip',
'application/x-7z-compressed': '.7z',
'application/x-rar-compressed': '.rar',
'application/gzip': '.tar.gz'
};
if (mimeMap[mime]) {
finalFilename += mimeMap[mime];
console.log(`[Gemini Saver] 补全后缀: ${filename} -> ${finalFilename} (MIME: ${mime})`);
} else {
console.warn(`[Gemini Saver] 未知 MIME 类型: ${mime},保持原文件名: ${finalFilename}`);
}
}
// 2. 生成 Boundary
const boundary = "----GeminiSaverBoundary" + Math.random().toString(36).substring(2);
// 3. 构造 Multipart 请求体
const preData = `--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${finalFilename.replace(/"/g, '')}"\r\n` +
`Content-Type: ${mime || 'application/octet-stream'}\r\n\r\n`;
const postData = `\r\n--${boundary}--\r\n`;
// 4. 合并 Blob
const combinedBlob = new Blob([preData, arrayBufferObj.buffer, postData]);
console.log(`[Gemini Saver] 上传中: ${finalFilename} (${combinedBlob.size} bytes)`);
GM_xmlhttpRequest({
method: "POST",
url: PICLIST_URL,
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`
},
data: combinedBlob,
onload: (res) => {
if (res.status !== 200) {
return reject(`PicList HTTP Error: ${res.status}`);
}
try {
const r = JSON.parse(res.responseText);
if (r.success && r.result && r.result.length > 0) {
console.log("[PicList] ✅ 上传成功:", r.result[0]);
resolve(r.result[0]);
} else {
reject("PicList Refused: " + (r.message || res.responseText));
}
} catch (e) {
reject("JSON Parse Error: " + e.message);
}
},
onerror: (err) => reject("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);
})();