Gemini 导出:支持 PicList,图片 / 文本 / 代码;Gemini 生成图按对话位置插入;用户上传图按对应 user-query 归位
目前為
// ==UserScript==
// @name Gemini to Notion Exporter (支持 PicList 上传 & 图片回填)
// @namespace http://tampermonkey.net/
// @version 11.6
// @license MIT
// @description Gemini 导出:支持 PicList,图片 / 文本 / 代码;Gemini 生成图按对话位置插入;用户上传图按对应 user-query 归位
// @author Wyih with Gemini Thought Partner (patched by ChatGPT)
// @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; // 单个 paragraph.rich_text 最多 90 项
// ------------------- 0. 环境自检 (启动时运行) -------------------
function checkPicListConnection() {
console.log("正在检查 PicList 连接...");
GM_xmlhttpRequest({
method: "GET",
url: "http://127.0.0.1:36677/heartbeat",
timeout: 2000,
onload: function(res) {
console.log("PicList 心跳检测响应:", res.status, res.responseText);
if (res.status === 200) {
try {
const data = JSON.parse(res.responseText);
if (data.success) {
console.log("✅ PicList 心跳正常!");
} else {
console.warn("⚠️ PicList 心跳返回非 success:", res.responseText);
}
} catch (e) {
console.warn("⚠️ PicList 心跳 JSON 解析失败:", res.responseText);
}
} else {
console.warn("⚠️ PicList 心跳 HTTP 非 200:", res.status);
}
},
onerror: function(err) {
console.error("❌ 无法连接到 PicList (127.0.0.1:36677)。", err);
alert("⚠️ 警告:脚本无法连接到 PicList (127.0.0.1:36677)\n\n请检查:\n1. PicList 是否开启 Server\n2. 端口是否为 36677");
},
ontimeout: function() {
alert("⚠️ 警告:连接 PicList /heartbeat 超时。请检查防火墙或 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 (secret_...):', 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('✅ 配置已保存!');
return true;
}
}
return false;
}
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. PicList 上传逻辑 -------------------
function fetchAssetAsArrayBuffer(url) {
return new Promise((resolve, reject) => {
if (url.startsWith('blob:')) {
fetch(url)
.then(r => r.arrayBuffer())
.then(buffer => resolve({ buffer: buffer, type: 'application/octet-stream' }))
.catch(e => reject(`Blob Fetch Error: ${e.message}`));
} else {
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")
});
}
});
}
function uploadToPicList(arrayBufferObj, filename) {
return new Promise((resolve, reject) => {
const mime = arrayBufferObj.type || "application/octet-stream";
const boundary = "----PicListBoundary" + Math.random().toString(16).slice(2);
const CRLF = "\r\n";
function arrayBufferToBinaryString(buffer) {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return binary;
}
const head =
"--" + boundary + CRLF +
'Content-Disposition: form-data; name="file"; filename="' +
filename.replace(/"/g, '\\"') + '"' + CRLF +
"Content-Type: " + mime + CRLF + CRLF;
const fileBinary = arrayBufferToBinaryString(arrayBufferObj.buffer);
const tail = CRLF + "--" + boundary + "--" + CRLF;
const body = head + fileBinary + tail;
console.log("[PicList] start upload via multipart:", filename, "mime:", mime);
GM_xmlhttpRequest({
method: "POST",
url: PICLIST_URL,
headers: {
"Content-Type": "multipart/form-data; boundary=" + boundary
},
data: body,
binary: true,
onload: (res) => {
console.log("[PicList] response status:", res.status);
console.log("[PicList] raw response:", res.responseText);
try {
const r = JSON.parse(res.responseText);
if (r.success && r.result && r.result.length > 0) {
console.log("[PicList] upload OK:", r.result[0]);
resolve(r.result[0]);
} else {
reject("PicList Refused: " + (r.message || res.responseText));
}
} catch (e) {
reject("PicList Response Parse Error: " + e.message +
" | Raw: " + res.responseText.substring(0, 200));
}
},
onerror: (err) => {
console.error("[PicList] GM_xmlhttpRequest error:", err);
reject("Connect Fail (GM_xmlhttpRequest onerror)");
}
});
});
}
async function processAssets(blocks, btn) {
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 parts = urlObj.url.split('::');
const filename = parts[1];
const realUrl = parts.slice(2).join('::');
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} (Err: ${r.err})` },
annotations: {
bold: false,
italic: false,
strikethrough: false,
underline: false,
code: false,
color: "red"
}
}]
};
delete block.file;
delete block.image;
}
});
if (failCount > 0) {
alert(`⚠️ ${failCount} 个文件上传失败!\n\n最后一个错误原因:\n${failMsg}\n\n请按 F12 查看控制台获取详细日志。`);
}
} else {
console.log("[PicList] no assets to upload (no PICLIST_WAITING blocks)");
}
return blocks;
}
// ------------------- 4. DOM 解析 -------------------
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;
}
}
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;
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;
}
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";
}
function parseInlineNodes(nodes) {
const richText = [];
function pushTextChunks(content, styles = {}) {
if (!content) return;
const maxLen = 1900; // 留一点余量
for (let offset = 0; offset < content.length; offset += maxLen) {
const chunk = content.slice(offset, offset + maxLen);
richText.push({
type: "text",
text: {
content: chunk,
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) {
const content = node.textContent;
if (content && content.trim() !== "") {
pushTextChunks(content, styles);
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
const tag = node.tagName;
const newStyles = { ...styles };
if (tag === 'B' || tag === 'STRONG') newStyles.bold = true;
if (tag === 'I' || tag === 'EM') newStyles.italic = true;
if (tag === 'U') newStyles.underline = true;
if (tag === 'S' || tag === 'DEL') newStyles.strikethrough = true;
if (tag === 'CODE') newStyles.code = true;
if (tag === 'A' && node.href) newStyles.link = { url: node.href };
Array.from(node.childNodes).forEach(child => traverse(child, newStyles));
}
}
Array.from(nodes).forEach(node => traverse(node));
return richText;
}
function pushParagraphWithLimit(blocks, richText) {
if (!richText || richText.length === 0) return;
for (let i = 0; i < richText.length; i += NOTION_RICH_TEXT_LIMIT) {
const slice = richText.slice(i, i + NOTION_RICH_TEXT_LIMIT);
if (!slice.some(t => (t.text?.content || "").trim() !== "")) continue;
blocks.push({
object: "block",
type: "paragraph",
paragraph: { rich_text: slice }
});
}
}
function isFileLikeLink(a) {
if (!a || a.nodeName !== "A" || !a.href) return false;
const href = a.href;
if (a.hasAttribute("download")) return true;
if (href.startsWith("blob:")) return true;
if (/\.(pdf|zip|rar|7z|docx?|xlsx?|pptx?|csv|json|txt|md|apk|dmg|iso|exe|tar|gz)(\?|#|$)/i.test(href)) {
return true;
}
if (href.includes("export=download") || href.includes("uc?export=download")) {
return true;
}
return false;
}
function filenameFromUrl(url) {
try {
const u = new URL(url);
const pathname = u.pathname.split("/").filter(Boolean).pop() || "";
return decodeURIComponent(pathname) || null;
} catch (e) {
return null;
}
}
function processNodesToBlocks(nodes) {
const blocks = [];
let inlineBuffer = [];
const flushBuffer = () => {
if (inlineBuffer.length > 0) {
const richText = parseInlineNodes(inlineBuffer);
pushParagraphWithLimit(blocks, richText);
inlineBuffer = [];
}
};
Array.from(nodes).forEach(node => {
if (['SCRIPT', 'STYLE', 'SVG', 'PATH', 'MAT-ICON'].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 = /** @type {HTMLAnchorElement} */ (node);
// ① <a> 里面只有一张 <img>,且没有其它文字 -> 当成 image block
const onlyImgChild =
el.childNodes.length === 1 &&
el.firstElementChild &&
el.firstElementChild.tagName === "IMG";
if (onlyImgChild) {
flushBuffer();
const img = /** @type {HTMLImageElement} */ (el.firstElementChild);
const src = img.src || el.href;
if (src) {
let filename =
(img.alt && img.alt.trim()) ||
filenameFromUrl(el.href) ||
"image.png";
blocks.push({
object: "block",
type: "image",
image: {
type: "external",
external: {
url: `${ASSET_PLACEHOLDER_PREFIX}${filename}::${src}`
}
}
});
}
return;
}
// ② 普通“文件链接” -> file block
if (isFileLikeLink(el)) {
flushBuffer();
let filename =
el.getAttribute("download")?.trim() ||
el.innerText.trim() ||
filenameFromUrl(el.href) ||
"attachment";
if (filename.length > 80) {
filename = filename.slice(0, 60) + "..." + filename.slice(-10);
}
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) {
const el = /** @type {HTMLElement} */ (node);
flushBuffer();
const tag = el.tagName;
// <p> 不整体打包,递归子节点,保证图文顺序
if (tag === 'P') {
const innerBlocks = processNodesToBlocks(el.childNodes);
blocks.push(...innerBlocks);
}
else if (tag === 'UL' || tag === 'OL') {
const listType = tag === 'UL' ? 'bulleted_list_item' : 'numbered_list_item';
Array.from(el.children).forEach(li => {
if (li.tagName === 'LI') {
const liRichText = parseInlineNodes(li.childNodes);
for (let i = 0; i < liRichText.length; i += NOTION_RICH_TEXT_LIMIT) {
const slice = liRichText.slice(i, i + NOTION_RICH_TEXT_LIMIT);
blocks.push({
object: "block",
type: listType,
[listType]: { rich_text: slice }
});
}
const subList = li.querySelector('ul, ol');
if (subList) blocks.push(...processNodesToBlocks([subList]));
}
});
}
else if (tag === 'PRE') {
const codeContent = el.textContent;
const finalLang = detectLanguageRecursive(el);
blocks.push({
object: "block",
type: "code",
code: {
rich_text: [{
type: "text",
text: { content: codeContent.substring(0, 1999) }
}],
language: finalLang
}
});
}
else if (['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(tag)) {
let type = "heading_3";
if (tag === 'H1') type = "heading_1";
if (tag === 'H2') type = "heading_2";
blocks.push({
object: "block",
type,
[type]: { rich_text: parseInlineNodes(el.childNodes) }
});
}
else if (tag === 'BLOCKQUOTE') {
blocks.push({
object: "block",
type: "quote",
quote: { rich_text: parseInlineNodes(el.childNodes) }
});
}
else if (tag === 'IMG') {
const cls = el.className || '';
const alt = el.alt || '';
const isAvatar =
/avatar|user-icon/i.test(cls) ||
/profile/i.test(alt);
if (el.src && !isAvatar) {
console.log("[Gemini Saver] capture IMG:", el.src);
blocks.push({
object: "block",
type: "image",
image: {
type: "external",
external: {
url: `${ASSET_PLACEHOLDER_PREFIX}image.png::${el.src}`
}
}
});
}
}
else if (tag === 'TABLE') {
const rows = Array.from(el.querySelectorAll('tr'));
if (rows.length === 0) return;
// 计算最大列数
let colCount = 0;
rows.forEach(r => {
const cells = Array.from(r.querySelectorAll('td, th'));
if (cells.length > colCount) colCount = cells.length;
});
if (colCount === 0) return;
const maxLen = 1500; // 单个 text.content 安全长度
const tableBlock = {
object: "block",
type: "table",
table: {
table_width: colCount,
has_column_header: false,
has_row_header: false,
children: [] // ✅ children 放在 table 里面
}
};
rows.forEach(r => {
const cells = Array.from(r.querySelectorAll('td, th'));
const rowCells = [];
for (let col = 0; col < colCount; col++) {
const cell = cells[col];
const rawText = cell ? cell.innerText.trim().replace(/\s+/g, ' ') : "";
const richTexts = [];
if (rawText) {
for (let i = 0; i < rawText.length; i += maxLen) {
const chunk = rawText.slice(i, i + maxLen);
richTexts.push({
type: "text",
text: { content: chunk }
});
}
} else {
richTexts.push({
type: "text",
text: { content: "" }
});
}
rowCells.push(richTexts);
}
tableBlock.table.children.push({ // ✅ 写到 table.children 里
object: "block",
type: "table_row",
table_row: {
cells: rowCells
}
});
});
blocks.push(tableBlock);
}
else {
// 默认:递归处理子节点,保证嵌套结构里的图片也能按顺序出来
const innerBlocks = processNodesToBlocks(el.childNodes);
blocks.push(...innerBlocks);
}
}
});
flushBuffer();
return blocks;
}
// ------------------- 4.5 把上传图片按 DOM 顺序映射到 user-query -------------------
function buildUploadedImageMap() {
// 1. 找到所有用户上传的预览图
const uploadedImgs = Array.from(
document.querySelectorAll(
'img[data-test-id="uploaded-img"], img.preview-image[alt="Uploaded image preview"]'
)
);
const userBubbles = Array.from(document.querySelectorAll('user-query'));
console.log("[Gemini Saver] uploaded preview imgs:", uploadedImgs.length);
console.log("[Gemini Saver] user-query bubbles:", userBubbles.length);
const map = new Map(); // key: <user-query> element, value: img[]
// 优先策略:从 img 往上找最近的“turn 容器”,里面含有 user-query 就归给它
function findOwnerByAncestor(img) {
let node = img.parentElement;
while (node && node !== document.body && node !== document.documentElement) {
const uq = node.querySelector('user-query');
if (uq) {
return uq;
}
node = node.parentElement;
}
return null;
}
uploadedImgs.forEach(img => {
let owner = findOwnerByAncestor(img);
// 如果祖先找不到,就退回到“按文档顺序最近的后继 user-query”策略
if (!owner && userBubbles.length) {
let candidate = null;
userBubbles.forEach(u => {
const rel = img.compareDocumentPosition(u);
// u 在 img 后面
if (rel & Node.DOCUMENT_POSITION_FOLLOWING) {
if (!candidate) {
candidate = u;
} else {
// 选离 img 最近的那个 user-query
const rel2 = candidate.compareDocumentPosition(u);
// candidate 在 u 前面 => u 更靠近 img
if (rel2 & Node.DOCUMENT_POSITION_FOLLOWING) {
candidate = u;
}
}
}
});
owner = candidate || owner;
}
// 如果还没有(极少数异常情况),兜底给最后一个 user-query
if (!owner && userBubbles.length) {
owner = userBubbles[userBubbles.length - 1];
}
if (!owner) {
console.warn("[Gemini Saver] uploaded img has no owner user-query:", img.src);
return;
}
const arr = map.get(owner) || [];
arr.push(img);
map.set(owner, arr);
});
console.log("[Gemini Saver] uploaded image map size:", map.size);
return map;
}
// ------------------- 5. 抓取入口 -------------------
function getInitialChatBlocks() {
const bubbles = document.querySelectorAll('user-query, model-response');
const children = [];
// 先构建「上传图片 → 最近 user-query」映射
const uploadMap = buildUploadedImageMap();
if (bubbles.length > 0) {
console.log("[Gemini Saver] found bubbles:", bubbles.length);
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());
});
// ⭐ 对于 user-query:把映射给它的「上传图片 preview」克隆进来
if (isUser && uploadMap.has(bubble)) {
const imgs = uploadMap.get(bubble);
const holder = document.createElement("div");
holder.setAttribute("data-gemini-saver-uploaded-holder", "true");
imgs.forEach(img => {
const copy = img.cloneNode(true);
holder.appendChild(copy);
});
// 简单起见:放在 bubble 内容末尾(通常对应 UI 中图片在文字下方的情况)
clone.appendChild(holder);
}
const contentBlocks = processNodesToBlocks(clone.childNodes);
children.push(...contentBlocks);
children.push({ object: "block", type: "divider", divider: {} });
});
return children;
}
console.warn("[Gemini Saver] no user-query/model-response found, fallback to document.body");
const contentBlocks = processNodesToBlocks(document.body.childNodes);
children.push(...contentBlocks);
return children;
}
// ------------------- 6. Notion 上传 -------------------
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);
const payload = {
parent: { database_id: dbId },
properties: {
"Name": { title: [{ text: { content: title } }] },
"Date": { date: { start: new Date().toISOString() } },
"URL": { url: getChatUrl() }
},
children: firstBatch
};
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(payload),
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';
if (res.responseText.includes("Date") && res.responseText.includes("property")) {
alert("❌ Notion Database 缺少 'Date' 列!请添加或修改脚本。");
} else {
alert("Create Page Error:\n" + res.responseText);
}
}
},
onerror: () => {
btn.textContent = '❌ Net Error'; btn.className = 'error';
}
});
}
// ------------------- 7. 主入口 -------------------
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] total blocks:", blocks.length);
if (!blocks || blocks.length === 0) throw new Error("未找到对话内容");
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("执行出错: " + 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);
}
let retries = 0;
const loadTimer = setInterval(() => {
tryInit();
retries++;
if (document.getElementById('gemini-saver-btn') || retries > 10) clearInterval(loadTimer);
}, 1000);
let lastUrl = location.href;
new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(tryInit, 2000);
}
}).observe(document.body, {subtree: true, childList: true});
})();