Perplexity to Notion Exporter

Perplexity 导出至 Notion:智能图片归位 (支持 PicList/PicGo)+隐私开关+单个对话导出+多代码块列表修复

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Perplexity to Notion Exporter
// @namespace    http://tampermonkey.net/
// @version      2.9
// @license      MIT
// @description  Perplexity 导出至 Notion:智能图片归位 (支持 PicList/PicGo)+隐私开关+单个对话导出+多代码块列表修复
// @author       Wyih
// @match        https://www.perplexity.ai/*
// @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;

    // 全局图片去重集合
    let processedImageUrls = new Set();

    // ------------------- 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 样式(Sticky + 灰显标识) -------------------
    GM_addStyle(`
    #perp-saver-btn {
      position: fixed; bottom: 20px; right: 20px; z-index: 9999;
      background-color: #20808D; 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;
    }
    #perp-saver-btn:hover { background-color: #176570; transform: translateY(-2px); }
    #perp-saver-btn.loading { background-color: #666; cursor: wait; }

    .perp-tool-group-sticky {
      z-index: 9500;
      display: inline-flex;
      gap: 8px;
      opacity: 0;
      transition: opacity 0.2s ease-in-out;
      background: #fff;
      padding: 4px 8px;
      border-radius: 999px;
      box-shadow: 0 2px 5px rgba(0,0,0,0.12);
      border: 1px solid #e5e7eb;
    }

    .perp-icon-btn {
      cursor: pointer;
      font-size: 16px;
      width: 28px;
      height: 28px;
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      color: #4b5563;
      transition: all 0.2s;
      user-select: none;
    }
    .perp-icon-btn:hover { background: rgba(0,0,0,0.06); color: #000; }
    .perp-privacy-toggle[data-skip="true"] { color: #dc2626; background: #fee2e2; }

    .perp-icon-btn.processing span { display: block; animation: spin 1s linear infinite; }
    .perp-icon-btn.success { color: #16a34a !important; background: #dcfce7; }
    .perp-icon-btn.error { color: #dc2626 !important; background: #fee2e2; }
    @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }

    /* hover 时显示工具条;标记隐藏时工具条常显 */
    .perp-query-bubble:hover .perp-tool-group-sticky,
    .perp-prose-wrap:hover .perp-tool-group-sticky,
    [data-skip-export="true"] .perp-tool-group-sticky { opacity: 1 !important; }

    /* sticky:问与答都采用同样定位 */
    .perp-query-bubble .perp-tool-group-sticky,
    .perp-prose-wrap .perp-tool-group-sticky {
      position: sticky;
      top: 14px;
      float: right;
      margin-left: 10px;
      margin-bottom: 10px;
    }

    /* 隐藏状态灰显 */
    [data-skip-export="true"] {
      opacity: 0.55;
      filter: grayscale(0.2);
    }
  `);

    // ------------------- 3. 资源处理(PicList 上传) -------------------
    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()
                    });
                });
                return;
            }
            GM_xmlhttpRequest({
                method: "GET",
                url,
                responseType: 'arraybuffer',
                onload: r => {
                    if (r.status === 200) {
                        const m = r.responseHeaders.match(/content-type:\s*(.*)/i);
                        resolve({ buffer: r.response, type: m ? m[1] : undefined });
                    } else reject();
                },
                onerror: () => 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 = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp' };
                if (mimeMap[mime]) finalFilename += mimeMap[mime];
            }
            const boundary = "----PerpSaverBoundary" + Math.random().toString(36).substring(2);
            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 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;
            }
            if (urlObj) {
                const [_, name, realUrl] = urlObj.url.split('::');
                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} images...`);
            const res = await Promise.all(tasks);
            res.forEach(r => {
                const blk = map.get(r.i);
                if (r.ok) {
                    blk.image.external.url = r.url;
                } else {
                    blk.type = "paragraph";
                    blk.paragraph = {
                        rich_text: [{
                            type: "text",
                            text: { content: `⚠️ Image Upload Failed: ${r.name}` },
                            annotations: { color: "red" }
                        }]
                    };
                    delete blk.image;
                }
            });
        }
        return blocks;
    }

    // ------------------- 4. DOM → Notion Blocks 解析(原逻辑保留) -------------------
    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 splitCodeSafe(code) {
        const chunks = [];
        let remaining = code;
        while (remaining.length > 0) {
            if (remaining.length <= MAX_TEXT_LENGTH) {
                chunks.push(remaining);
                break;
            }
            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) { // Text
                const fullText = n.textContent;
                if (!fullText) return;

                if (/^[\s\uFEFF\xA0]+$/.test(fullText)) return;
                if (/^\[\d+\]$/.test(fullText.trim())) 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) { // Element
                if (n.classList.contains('katex-mathml') || n.tagName === 'MJX-CONTAINER') return;
                if (n.classList.contains('katex-html')) {
                    n.childNodes.forEach(c => tr(c, s));
                    return;
                }

                const latex = n.getAttribute('data-latex-source') || n.querySelector('annotation[encoding="application/x-tex"]')?.textContent;
                if (latex) {
                    rt.push({ type: "equation", equation: { expression: latex.trim() } });
                    return;
                }

                const ns = { ...s };
                if (['B', 'STRONG'].includes(n.tagName) || n.style.fontWeight > 500) ns.bold = true;
                if (['I', 'EM'].includes(n.tagName)) ns.italic = true;
                if (n.tagName === 'CODE') ns.code = true;
                if (n.tagName === 'A' && n.href) ns.link = { url: n.href };
                n.childNodes.forEach(c => tr(c, ns));
            }
        }
        nodes.forEach(n => tr(n));
        return rt;
    }

    function isEmptyRichText(rt) {
        if (!rt || rt.length === 0) return true;
        const allText = rt.map(t => t.text?.content || '').join('');
        return allText.replace(/[\s\uFEFF\xA0]+/g, '').length === 0;
    }

    function processNodesToBlocks(nodes) {
        const blocks = [], buf = [];
        const flush = () => {
            if (buf.length) {
                const rt = parseInlineNodes(buf);
                if (rt.length && !isEmptyRichText(rt)) {
                    blocks.push({
                        object: "block",
                        type: "paragraph",
                        paragraph: { rich_text: rt }
                    });
                }
                buf.length = 0;
            }
        };

        Array.from(nodes).forEach(n => {
            if (['SCRIPT', 'STYLE', 'SVG', 'NOSCRIPT'].includes(n.nodeName)) return;

            // 忽略 Sources / Related 区域
            if (n.nodeType === 1 && (
                (n.textContent || '').startsWith('Sources') ||
                (n.textContent || '').startsWith('Related') ||
                n.classList.contains('grid-cols-2')
            )) return;

            const isElement = n.nodeType === 1;

            // 块级公式
            if (isElement && (n.classList.contains('katex-display') || n.classList.contains('math-display'))) {
                const tex = n.querySelector('annotation[encoding="application/x-tex"]');
                if (tex) {
                    flush();
                    blocks.push({
                        object: "block",
                        type: "equation",
                        equation: { expression: tex.textContent.trim() }
                    });
                    return;
                }
            }

            // 行内内容缓冲
            if (n.nodeType === 3 || ['B', 'I', 'CODE', 'SPAN', 'A', 'STRONG', 'EM'].includes(n.nodeName)) {
                buf.push(n);
                return;
            }

            if (isElement) {
                flush();
                const t = n.tagName;

                // 空占位过滤
                if ((t === 'DIV' || t === 'P') && (n.innerText || '').trim().length === 0 && !n.querySelector('img')) {
                    return;
                }

                if (t === 'P' || t === 'DIV') {
                    if (n.querySelector('pre')) {
                        blocks.push(...processNodesToBlocks(n.childNodes));
                    } else {
                        const hasBlockChild = Array.from(n.children).some(c =>
                            ['P', 'DIV', 'UL', 'OL', 'H1', 'H2', 'H3', 'PRE', 'TABLE'].includes(c.tagName)
                        );
                        if (hasBlockChild) {
                            blocks.push(...processNodesToBlocks(n.childNodes));
                        } else {
                            const rt = parseInlineNodes(n.childNodes);
                            if (rt.length && !isEmptyRichText(rt)) {
                                blocks.push({
                                    object: "block",
                                    type: "paragraph",
                                    paragraph: { rich_text: rt }
                                });
                            }
                        }
                    }
                } else if (t === 'IMG') {
                    if (n.src && !n.src.includes('data:image/svg')) {
                        if (!processedImageUrls.has(n.src)) {
                            processedImageUrls.add(n.src);
                            blocks.push({
                                object: "block",
                                type: "image",
                                image: {
                                    type: "external",
                                    external: { url: `${ASSET_PLACEHOLDER_PREFIX}image.png::${n.src}` }
                                }
                            });
                        }
                    }
                } else if (t === 'PRE') {
                    const codeEl = n.querySelector('code');
                    const langMatch = (codeEl?.className || '').match(/language-([a-zA-Z0-9]+)/);
                    const language = mapLanguageToNotion(langMatch ? langMatch[1] : 'plain text');
                    const fullCode = n.textContent;
                    const rawChunks = splitCodeSafe(fullCode);
                    const codeRichText = rawChunks.map(c => ({ type: "text", text: { content: c } }));
                    blocks.push({
                        object: "block",
                        type: "code",
                        code: { rich_text: codeRichText, language }
                    });
                } else if (/^H[1-6]$/.test(t)) {
                    const level = t[1] < 4 ? t[1] : 3;
                    const hrt = parseInlineNodes(n.childNodes);
                    if (!isEmptyRichText(hrt)) {
                        blocks.push({
                            object: "block",
                            type: `heading_${level}`,
                            [`heading_${level}`]: { rich_text: hrt }
                        });
                    }
                } 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') return;
                        const liRT = parseInlineNodes(li.childNodes);
                        if (liRT.length && !isEmptyRichText(liRT)) {
                            blocks.push({
                                object: "block",
                                type: tp,
                                [tp]: { rich_text: liRT }
                            });
                        }
                    });
                } 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 => parseInlineNodes(c.childNodes)) }
                            });
                        });
                        tb.table.table_width = max || 1;
                        blocks.push(tb);
                    }
                } else {
                    blocks.push(...processNodesToBlocks(n.childNodes));
                }
            }
        });
        flush();
        return blocks;
    }

    // ------------------- 5. 关键修复:以 prose 为锚点配对问答 -------------------

    function getThreadRootFromAny(el = null) {
        const candidates = [
            el?.closest?.('div.isolate'),
            el?.closest?.('div.max-w-threadContentWidth'),
            document.querySelector('div.isolate'),
            document.querySelector('div.max-w-threadContentWidth'),
            document.body
        ].filter(Boolean);

        for (const c of candidates) {
            try {
                if (c.querySelector('.group\\/query') && c.querySelector('.prose')) return c;
            } catch (_) { }
        }
        return document.body;
    }

    function getAllQueryAnchors(root) {
        return Array.from(root.querySelectorAll('.group\\/query'))
            .filter(el => (el.innerText || el.textContent || '').trim().length > 0);
    }

    function getQueryTextFromAnchor(queryAnchor) {
        if (!queryAnchor) return '';
        const t = queryAnchor.querySelector('.select-text');
        const raw = (t?.innerText || queryAnchor.innerText || queryAnchor.textContent || '').trim();
        return raw.replace(/Ask a follow up.*/i, '').trim();
    }

    function getAllProse(root) {
        return Array.from(root.querySelectorAll('.prose'));
    }

    function findNearestQueryBeforeProse(root, proseEl) {
        const queries = getAllQueryAnchors(root);
        let best = null;
        for (const q of queries) {
            const pos = q.compareDocumentPosition(proseEl);
            if (pos & Node.DOCUMENT_POSITION_FOLLOWING) best = q;
        }
        return best;
    }

    function findFirstProseAfterQuery(root, queryAnchor) {
        const proseList = getAllProse(root);
        for (const p of proseList) {
            const pos = queryAnchor.compareDocumentPosition(p);
            if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return p;
        }
        return null;
    }

    function notionCalloutUserHidden() {
        return {
            object: "block",
            type: "callout",
            callout: {
                rich_text: [{ type: "text", text: { content: "🔒 User 已隐藏(未导出)" } }],
                icon: { emoji: "🔒" },
                color: "gray_background"
            }
        };
    }

    function blocksFromUserText(text) {
        const blocks = [];
        if (!text) return blocks;
        blocks.push({
            object: "block",
            type: "heading_3",
            heading_3: {
                rich_text: [{ type: "text", text: { content: "User" } }],
                color: "default"
            }
        });
        blocks.push({
            object: "block",
            type: "paragraph",
            paragraph: { rich_text: [{ type: "text", text: { content: text } }] }
        });
        return blocks;
    }

    function blocksFromProse(proseEl) {
        const blocks = [];
        blocks.push({
            object: "block",
            type: "heading_3",
            heading_3: {
                rich_text: [{ type: "text", text: { content: "Perplexity" } }],
                color: "blue_background"
            }
        });
        const clone = proseEl.cloneNode(true);
        clone.querySelectorAll('.perp-tool-group-sticky, button, .grid.gap-2, .mt-4.grid').forEach(s => s.remove());
        blocks.push(...processNodesToBlocks(clone.childNodes));
        return blocks;
    }

    // 全量导出:遍历 prose;答隐藏直接跳过;问隐藏写 callout
    function getChatBlocksFull() {
        processedImageUrls = new Set();
        const blocks = [];
        const root = getThreadRootFromAny(null);

        const proseList = getAllProse(root);
        proseList.forEach(prose => {
            // 答案隐藏:全量跳过
            if (prose.getAttribute('data-skip-export') === 'true') return;

            const q = findNearestQueryBeforeProse(root, prose);
            const userSkipped = !!(q && q.getAttribute('data-skip-export') === 'true');
            const userText = (!userSkipped && q) ? getQueryTextFromAnchor(q) : '';

            // User 区块:未隐藏输出正文;隐藏输出 callout
            blocks.push({
                object: "block",
                type: "heading_3",
                heading_3: {
                    rich_text: [{ type: "text", text: { content: "User" } }],
                    color: "default"
                }
            });
            if (userSkipped) blocks.push(notionCalloutUserHidden());
            else if (userText) blocks.push(...blocksFromUserText(userText).slice(1)); // 复用 paragraph,仅跳过 heading

            // Perplexity 区块
            blocks.push(...blocksFromProse(prose));
            blocks.push({ object: "block", type: "divider", divider: {} });
        });

        return blocks;
    }

    // 单条导出:点答(prose)→ 前序问 + 当前答;问隐藏写 callout;答隐藏不导出
    function getChatBlocksSingleFromProse(proseEl) {
        processedImageUrls = new Set();
        const blocks = [];
        if (!proseEl) return blocks;
        if (proseEl.getAttribute('data-skip-export') === 'true') return blocks;

        const root = getThreadRootFromAny(proseEl);
        const q = findNearestQueryBeforeProse(root, proseEl);
        const userSkipped = !!(q && q.getAttribute('data-skip-export') === 'true');
        const userText = (!userSkipped && q) ? getQueryTextFromAnchor(q) : '';

        blocks.push({
            object: "block",
            type: "heading_3",
            heading_3: { rich_text: [{ type: "text", text: { content: "User" } }], color: "default" }
        });
        if (userSkipped) blocks.push(notionCalloutUserHidden());
        else if (userText) blocks.push({
            object: "block",
            type: "paragraph",
            paragraph: { rich_text: [{ type: "text", text: { content: userText } }] }
        });

        blocks.push(...blocksFromProse(proseEl));
        blocks.push({ object: "block", type: "divider", divider: {} });
        return blocks;
    }

    // 单条导出:点问(query)→ 当前问 + 后续第一个答;若答隐藏则只导出问
    function getChatBlocksSingleFromQueryAnchor(queryAnchor) {
        processedImageUrls = new Set();
        const blocks = [];
        if (!queryAnchor) return blocks;

        const root = getThreadRootFromAny(queryAnchor);
        const userText = getQueryTextFromAnchor(queryAnchor);
        const p = findFirstProseAfterQuery(root, queryAnchor);

        blocks.push(...blocksFromUserText(userText));

        if (p && p.getAttribute('data-skip-export') !== 'true') {
            blocks.push(...blocksFromProse(p));
        }

        blocks.push({ object: "block", type: "divider", divider: {} });
        return blocks;
    }

    // ------------------- 6. Sticky 工具条(问/答独立开关 + 单条导出触发) -------------------
    function makeToolGroup({ onTogglePrivacy, onSingleExport }) {
        const group = document.createElement('div');
        group.className = 'perp-tool-group-sticky';

        const privacyBtn = document.createElement('div');
        privacyBtn.className = 'perp-icon-btn perp-privacy-toggle';
        privacyBtn.title = "切换:是否导出此条内容";
        privacyBtn.setAttribute('data-skip', 'false');
        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 = '👁️';
                onTogglePrivacy(false);
            } else {
                privacyBtn.setAttribute('data-skip', 'true');
                privacyIcon.textContent = '🚫';
                onTogglePrivacy(true);
            }
        };

        const singleExportBtn = document.createElement('div');
        singleExportBtn.className = 'perp-icon-btn';
        singleExportBtn.title = "单条导出";
        const exportIcon = document.createElement('span');
        exportIcon.textContent = '📤';
        singleExportBtn.appendChild(exportIcon);

        singleExportBtn.onclick = (e) => {
            e.stopPropagation();
            onSingleExport(singleExportBtn, exportIcon);
        };

        group.appendChild(privacyBtn);
        group.appendChild(singleExportBtn);
        return group;
    }

    function injectForAnswers() {
        const proseList = document.querySelectorAll('.prose');
        proseList.forEach((prose) => {
            const existingWrap = prose.closest('.perp-prose-wrap');
            if (existingWrap && existingWrap.querySelector('.perp-tool-group-sticky')) return;

            const wrap = document.createElement('div');
            wrap.className = 'perp-prose-wrap';
            wrap.style.position = 'relative';

            const parent = prose.parentNode;
            if (!parent) return;
            parent.insertBefore(wrap, prose);
            wrap.appendChild(prose);

            const group = makeToolGroup({
                onTogglePrivacy: (skip) => {
                    prose.setAttribute('data-skip-export', skip ? 'true' : 'false');
                    wrap.setAttribute('data-skip-export', skip ? 'true' : 'false');
                },
                onSingleExport: (iconBtn, iconElem) => {
                    handleSingleExportFromProse(prose, iconBtn, iconElem);
                }
            });

            wrap.prepend(group);
        });
    }

    function injectForQueries() {
        const queryAnchors = document.querySelectorAll('.group\\/query');
        queryAnchors.forEach((qa) => {
            const bubble = qa.querySelector('div.rounded-2xl') || qa;
            if (!bubble) return;

            if (bubble.querySelector('.perp-tool-group-sticky')) return;

            bubble.classList.add('perp-query-bubble');
            if (getComputedStyle(bubble).position === 'static') bubble.style.position = 'relative';

            const group = makeToolGroup({
                onTogglePrivacy: (skip) => {
                    qa.setAttribute('data-skip-export', skip ? 'true' : 'false');
                    bubble.setAttribute('data-skip-export', skip ? 'true' : 'false');
                },
                onSingleExport: (iconBtn, iconElem) => {
                    handleSingleExportFromQueryAnchor(qa, iconBtn, iconElem);
                }
            });

            bubble.appendChild(group);
        });
    }

    function injectPageControls() {
        injectForAnswers();
        injectForQueries();
    }

    // ------------------- 7. Notion 上传 -------------------
    function getPageTitle() {
        return document.title.replace(' - Perplexity', '') || "Perplexity 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) => {
                if (res.status === 200) {
                    appendBlocksBatch(pageId, blocks.slice(90), token, statusCallback);
                } else {
                    console.error(res.responseText);
                    statusCallback('❌ Fail');
                }
            }
        });
    }

    function createPageAndUpload(title, blocks, token, dbId, statusCallback) {
        const props = {
            "Name": { title: [{ text: { content: title } }] },
            "Date": { date: { start: new Date().toISOString() } },
            "URL": { url: location.href }
        };

        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: props,
                children: blocks.slice(0, 90)
            }),
            onload: (res) => {
                if (res.status === 200) {
                    const pageId = JSON.parse(res.responseText).id;
                    appendBlocksBatch(pageId, blocks.slice(90), token, statusCallback);
                } else {
                    statusCallback('❌ Fail');
                    alert(`Notion Error: ${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?.contains('perp-icon-btn') && iconElem) {
                if (msg?.includes('Saved')) {
                    btnOrLabelUpdater.classList.remove('processing');
                    btnOrLabelUpdater.classList.add('success');
                    iconElem.textContent = '✅';
                    setTimeout(() => {
                        btnOrLabelUpdater.classList.remove('success');
                        iconElem.textContent = '📤';
                    }, 2500);
                } else if (msg?.includes('Fail')) {
                    btnOrLabelUpdater.classList.remove('processing');
                    btnOrLabelUpdater.classList.add('error');
                    iconElem.textContent = '❌';
                } else if (msg) {
                    btnOrLabelUpdater.classList.add('processing');
                    iconElem.textContent = '⏳';
                }
            } else if (btnOrLabelUpdater?.id === 'perp-saver-btn') {
                btnOrLabelUpdater.textContent = msg === null ? '📥 Save to Notion' : msg;
            }
        };

        if (btnOrLabelUpdater?.id === 'perp-saver-btn') {
            btnOrLabelUpdater.classList.add('loading');
            btnOrLabelUpdater.textContent = '🕵️ Processing...';
        } else {
            updateStatus('Processing...');
        }

        try {
            blocks = await processAssets(blocks, updateStatus);
            if (btnOrLabelUpdater?.id === 'perp-saver-btn') btnOrLabelUpdater.textContent = '💾 Saving...';
            createPageAndUpload(title, blocks, token, dbId, updateStatus);
        } catch (e) {
            console.error(e);
            updateStatus('❌ Fail');
            alert(e.message);
        } finally {
            if (btnOrLabelUpdater?.id === 'perp-saver-btn') {
                btnOrLabelUpdater.classList.remove('loading');
            }
        }
    }

    function handleFullExport() {
        const btn = document.getElementById('perp-saver-btn');
        const blocks = getChatBlocksFull();

        let title = getPageTitle();
        try {
            const root = getThreadRootFromAny(null);
            const qs = getAllQueryAnchors(root);
            const first = qs.length ? getQueryTextFromAnchor(qs[0]) : '';
            if (first) title = first.slice(0, 50).replace(/\n/g, ' ') + "...";
        } catch (_) { }

        executeExport(blocks, title, btn);
    }

    function handleSingleExportFromProse(proseEl, iconBtn, iconElem) {
        if (!proseEl) return;
        if (proseEl.getAttribute('data-skip-export') === 'true') {
            alert('该回答已标记为不导出。');
            return;
        }
        const blocks = getChatBlocksSingleFromProse(proseEl);

        let title = getPageTitle();
        try {
            const root = getThreadRootFromAny(proseEl);
            const q = findNearestQueryBeforeProse(root, proseEl);
            const t = q ? getQueryTextFromAnchor(q) : '';
            if (t) title = t.slice(0, 50).replace(/\n/g, ' ') + "...";
        } catch (_) { }

        executeExport(blocks, title, iconBtn, iconElem);
    }

    function handleSingleExportFromQueryAnchor(queryAnchor, iconBtn, iconElem) {
        if (!queryAnchor) return;
        const blocks = getChatBlocksSingleFromQueryAnchor(queryAnchor);
        const t = getQueryTextFromAnchor(queryAnchor);
        const title = (t || getPageTitle()).slice(0, 50).replace(/\n/g, ' ') + "...";
        executeExport(blocks, title, iconBtn, iconElem);
    }

    // ------------------- 9. 初始化(定时注入) -------------------
    function tryInit() {
        if (!document.getElementById('perp-saver-btn')) {
            const btn = document.createElement('button');
            btn.id = 'perp-saver-btn';
            btn.textContent = '📥 Save to Notion';
            btn.onclick = handleFullExport;
            document.body.appendChild(btn);
        }
        injectPageControls();
    }

    setInterval(tryInit, 1200);

})();