Greasy Fork

Greasy Fork is available in English.

Perplexity to Local/Notion

兼容新版 Perplexity Library/History 列表,修复批量抓取找不到文章;保留本地保存与 Notion 写入修复

当前为 2026-05-03 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Perplexity to Local/Notion
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  兼容新版 Perplexity Library/History 列表,修复批量抓取找不到文章;保留本地保存与 Notion 写入修复
// @author       sandleft
// @match        https://www.perplexity.ai/*
// @run-at       document-end
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_download
// @connect      api.notion.com
// ==/UserScript==

(function () {
    'use strict';


    let isProcessing = false;
    let autoRunTimer = null;


    const CONFIG = {
        get token()     { return (GM_getValue('notion_token', '') || '').trim(); },
        get dbId()      { return (GM_getValue('db_id',        '') || '').trim(); },
        get count()     {
            const v = parseInt(GM_getValue('save_count', '12'));
            return (isNaN(v) || v <= 0) ? 12 : v;
        },
        get fileType()  {
            const v = GM_getValue('file_type', 'md');
            return (v === 'md' || v === 'txt') ? v : 'md';
        },
        get propTitle() { return (GM_getValue('prop_title', 'Title') || 'Title').trim(); },
        get propUrl()   { return (GM_getValue('prop_url',   'URL')   || 'URL'  ).trim(); },
        get propTags()  { return (GM_getValue('prop_tags',  'Tags')  || 'Tags' ).trim(); },
        get propTime()  { return (GM_getValue('prop_time',  'Time')  || 'Time' ).trim(); },
        get autoRun()   { return GM_getValue('auto_run', false); },
        get autoRunDelay() {
            const v = parseInt(GM_getValue('auto_run_delay', '5'));
            return (isNaN(v) || v < 0) ? 5 : v;
        },
    };


    GM_addStyle(`
        #hiyori-panel {
            position: fixed; top: 5%; right: 20px; width: 290px;
            max-height: 88vh; overflow-y: auto;
            background: #fff0f5; border: 2px solid #ffb6c1;
            border-radius: 15px; z-index: 10000; padding: 15px;
            font-family: 'Microsoft YaHei', sans-serif;
            box-shadow: 0 4px 15px rgba(255,182,193,0.4);
            scrollbar-width: thin; scrollbar-color: #ffb6c1 #fff0f5;
        }
        .hiyori-title { color: #ff69b4; font-weight: bold; text-align: center; margin-bottom: 10px; font-size: 16px; }
        .hiyori-label { font-size: 12px; color: #ff69b4; font-weight: bold; margin-top: 5px; display: block; }
        .hiyori-input, .hiyori-select {
            width: 100%; padding: 6px; margin: 3px 0 8px 0;
            border: 1px solid #ffb6c1; border-radius: 5px;
            font-size: 12px; box-sizing: border-box;
        }
        .hiyori-btn {
            width: 100%; padding: 8px; margin-top: 5px;
            background: #ffb6c1; color: white; border: none;
            border-radius: 20px; cursor: pointer; font-weight: bold; transition: 0.3s;
        }
        .hiyori-btn:hover { background: #ff69b4; transform: scale(1.02); }
        .hiyori-float-btn {
            position: fixed; bottom: 30px; right: 30px;
            width: 60px; height: 60px; background: #ffb6c1; color: white;
            border-radius: 50%; border: 4px solid #fff; cursor: pointer;
            z-index: 9999; font-size: 30px;
            display: flex; align-items: center; justify-content: center;
            box-shadow: 0 4px 10px rgba(0,0,0,0.2);
        }
        .hiyori-checkbox { margin-right: 10px; transform: scale(1.3); cursor: pointer; accent-color: #ff69b4; }
        .hiyori-library-item {
            position: relative !important;
            padding-left: 30px !important;
        }
        .hiyori-library-item > .hiyori-checkbox {
            position: absolute; left: 6px; top: 50%;
            transform: translateY(-50%) scale(1.15);
            z-index: 10001; margin: 0;
        }
        #hiyori-single-btn {
            position: fixed; bottom: 100px; right: 30px; width: 120px;
            z-index: 9998; box-shadow: 0 4px 6px rgba(0,0,0,0.1);
        }
        #hiyori-panel details > summary {
            color: #ff69b4; font-size: 12px; cursor: pointer; font-weight: bold;
        }
    `);


    function escHtml(str) {
        return (str || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
    }


    function isLibraryPage() {
        return /^\/library(?:\/|$)/.test(window.location.pathname);
    }


    function isSearchPage() {
        return /^\/search(?:\/|$)/.test(window.location.pathname);
    }


    function normalizeSearchUrl(raw) {
        if (!raw) return '';

        const value = String(raw).trim().replace(/&amp;/g, '&');
        const direct = normalizeSearchUrlCandidate(value);
        if (direct) return direct;

        const embedded = value.match(
            /https?:\/\/www\.perplexity\.ai\/search(?:\/[^\s"'<>]*)?(?:\?[^\s"'<>]*)?|(?:^|[\s"'=])((?:\/search(?:\/[^\s"'<>]*)?(?:\?[^\s"'<>]*)?))/
        );
        if (!embedded) return '';

        return normalizeSearchUrlCandidate((embedded[1] || embedded[0]).trim());
    }


    function normalizeSearchUrlCandidate(value) {
        if (!value) return '';

        try {
            const url = new URL(value, window.location.origin);
            if (url.origin !== window.location.origin) return '';
            if (!/^\/search(?:\/|$)/.test(url.pathname)) return '';
            url.hash = '';
            return url.href;
        } catch (e) {
            return '';
        }
    }


    function getSearchUrlFromElement(el) {
        if (!el) return '';

        const directAttrs = ['href', 'data-href', 'data-url', 'data-link', 'to'];
        for (const attr of directAttrs) {
            const url = normalizeSearchUrl(el.getAttribute && el.getAttribute(attr));
            if (url) return url;
        }

        const linked = el.querySelector && el.querySelector(
            'a[href*="/search/"], [href*="/search/"], [data-href*="/search/"], [data-url*="/search/"], [data-link*="/search/"]'
        );
        if (linked) {
            for (const attr of directAttrs) {
                const url = normalizeSearchUrl(linked.getAttribute && linked.getAttribute(attr));
                if (url) return url;
            }
        }

        if (el.outerHTML && el.outerHTML.length < 6000) {
            const url = normalizeSearchUrl(el.outerHTML);
            if (url) return url;
        }

        return '';
    }


    function getLibraryItemElement(sourceEl) {
        const root = document.querySelector('main') || document.body;
        let el = sourceEl;

        while (el && el !== root && el !== document.body) {
            const tag = (el.tagName || '').toLowerCase();
            const role = el.getAttribute && el.getAttribute('role');
            const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
            const text = (el.innerText || '').trim();
            const looksLikeRow =
                tag === 'li' ||
                tag === 'article' ||
                role === 'listitem' ||
                role === 'link' ||
                role === 'button' ||
                (rect && rect.width >= 260 && rect.height >= 24 && rect.height <= 180 && text.length > 0);

            if (looksLikeRow && getSearchUrlFromElement(el)) return el;
            el = el.parentElement;
        }

        return sourceEl.parentElement || sourceEl;
    }


    function getDirectCheckbox(item) {
        return Array.from(item.children || []).find(child => child.classList && child.classList.contains('hiyori-checkbox'));
    }


    function collectLibraryEntries() {
        const root = document.querySelector('main') || document.body;
        const candidates = Array.from(root.querySelectorAll(
            [
                'a[href]',
                '[role="link"]',
                '[role="button"]',
                '[data-testid*="thread"]',
                '[data-testid*="search"]',
                '[data-testid*="library"]',
                '[href*="/search/"]',
                '[data-href*="/search/"]',
                '[data-url*="/search/"]',
                '[data-link*="/search/"]'
            ].join(', ')
        ));
        const entries = [];
        const seen = new Set();

        candidates.forEach(candidate => {
            const url = getSearchUrlFromElement(candidate);
            if (!url || seen.has(url)) return;

            const item = getLibraryItemElement(candidate);
            if (!item || item === document.body || item === root) return;

            seen.add(url);
            entries.push({
                url,
                originalIndex: entries.length,
                item,
            });
        });

        return entries;
    }


    async function waitForLibraryEntries(timeout = 15000) {
        const deadline = Date.now() + timeout;
        let entries = collectLibraryEntries();

        while (entries.length === 0 && Date.now() < deadline) {
            await sleep(350);
            entries = collectLibraryEntries();
        }

        return entries;
    }


    function scheduleLibraryAutoRun() {
        if (!isLibraryPage() || !CONFIG.autoRun || GM_getValue('hiyori_running', false)) return;
        if (autoRunTimer) return;

        const delay = CONFIG.autoRunDelay;
        console.log(`[妃爱] 已开启自动抓取,${delay} 秒后启动...`);

        autoRunTimer = setTimeout(() => {
            autoRunTimer = null;
            if (!isLibraryPage() || GM_getValue('hiyori_running', false)) return;
            console.log('[妃爱] 自动抓取已启动!');
            startBatch(false);
        }, delay * 1000);
    }


    function clearLibraryAutoRun() {
        if (!autoRunTimer) return;
        clearTimeout(autoRunTimer);
        autoRunTimer = null;
    }


    function initUI() {
        if (document.getElementById('hiyori-panel')) return;

        const panel = document.createElement('div');
        panel.id = 'hiyori-panel';
        panel.style.display = GM_getValue('panel_show', 'none');
        panel.innerHTML = `
            <div class="hiyori-title">🌸 P2L/N 🌸</div>

            <label class="hiyori-label">Notion 密钥 (Token)</label>
            <input id="h-token" class="hiyori-input" type="password"
                   placeholder="secret_..." value="${escHtml(CONFIG.token)}">

            <label class="hiyori-label">数据库 ID</label>
            <input id="h-dbid" class="hiyori-input"
                   placeholder="输入数据库ID..." value="${escHtml(CONFIG.dbId)}">

            <details style="margin: 6px 0;">
                <summary>▸ Notion 字段名设置(默认通常不用改)</summary>
                <label class="hiyori-label">标题字段名(Notion 默认库为 Name)</label>
                <input id="h-prop-title" class="hiyori-input"
                       placeholder="Title" value="${escHtml(CONFIG.propTitle)}">
                <label class="hiyori-label">链接字段名</label>
                <input id="h-prop-url" class="hiyori-input"
                       placeholder="URL" value="${escHtml(CONFIG.propUrl)}">
                <label class="hiyori-label">标签字段名</label>
                <input id="h-prop-tags" class="hiyori-input"
                       placeholder="Tags" value="${escHtml(CONFIG.propTags)}">
                <label class="hiyori-label">日期字段名</label>
                <input id="h-prop-time" class="hiyori-input"
                       placeholder="Time" value="${escHtml(CONFIG.propTime)}">
            </details>

            <label class="hiyori-label">自动保存数量</label>
            <input id="h-count" class="hiyori-input" type="number"
                   min="1" value="${CONFIG.count}">

            <label class="hiyori-label">本地保存格式</label>
            <select id="h-filetype" class="hiyori-select">
                <option value="md"  ${CONFIG.fileType === 'md'  ? 'selected' : ''}>Markdown (.md)</option>
                <option value="txt" ${CONFIG.fileType === 'txt' ? 'selected' : ''}>纯文本</option>
            </select>

            <label class="hiyori-label">
                <input type="checkbox" id="h-auto-run" ${CONFIG.autoRun ? 'checked' : ''}
                       style="margin-right: 5px; vertical-align: middle; accent-color: #ff69b4;">
                进入 Library 自动开始抓取
            </label>
            <div style="display: flex; align-items: center; gap: 8px; margin-top: 5px;">
                <label class="hiyori-label" style="margin: 0;">延时</label>
                <input id="h-auto-delay" class="hiyori-input" type="number" min="0" max="300"
                       value="${CONFIG.autoRunDelay}" style="width: 70px;">
                <span style="font-size: 12px; color: #ff69b4;">秒后启动</span>
            </div>

            <button id="h-save-config"    class="hiyori-btn">保存设置 ✨</button>
            <hr style="border: 0.5px solid #ffb6c1; margin: 10px 0;">
            <button id="h-batch-auto"     class="hiyori-btn">自动倒序抓取最新文章 🚀</button>
            <button id="h-batch-selected" class="hiyori-btn">保存已勾选的文章 🎯</button>
            <button id="h-stop" class="hiyori-btn"
                    style="background:#ff9999; display:none;">紧急停止 🛑</button>
        `;
        document.body.appendChild(panel);


        const floatBtn = document.createElement('div');
        floatBtn.className = 'hiyori-float-btn';
        floatBtn.innerHTML = '🌸';
        floatBtn.onclick = () => {
            const show = panel.style.display === 'none' ? 'block' : 'none';
            panel.style.display = show;
            GM_setValue('panel_show', show);
        };
        document.body.appendChild(floatBtn);


        document.getElementById('h-save-config').onclick = () => {
            const cnt = parseInt(document.getElementById('h-count').value);
            const delay = parseInt(document.getElementById('h-auto-delay').value);
            GM_setValue('notion_token', document.getElementById('h-token').value.trim());
            GM_setValue('db_id',        document.getElementById('h-dbid').value.trim());
            GM_setValue('prop_title',   document.getElementById('h-prop-title').value.trim() || 'Title');
            GM_setValue('prop_url',     document.getElementById('h-prop-url').value.trim()   || 'URL');
            GM_setValue('prop_tags',    document.getElementById('h-prop-tags').value.trim()  || 'Tags');
            GM_setValue('prop_time',    document.getElementById('h-prop-time').value.trim()  || 'Time');
            GM_setValue('save_count',   String(isNaN(cnt) || cnt <= 0 ? 12 : cnt));
            GM_setValue('file_type',    document.getElementById('h-filetype').value);
            GM_setValue('auto_run',     document.getElementById('h-auto-run').checked);
            GM_setValue('auto_run_delay', String(isNaN(delay) || delay < 0 ? 5 : delay));
            alert('宝宝!设置已经牢牢记住辣!');
            clearLibraryAutoRun();
            scheduleLibraryAutoRun();
        };

        document.getElementById('h-batch-auto').onclick     = () => startBatch(false);
        document.getElementById('h-batch-selected').onclick = () => startBatch(true);
        document.getElementById('h-stop').onclick           = () => stopBatch(true);

        if (GM_getValue('hiyori_running', false)) {
            document.getElementById('h-stop').style.display = 'block';
        }

        injectSingleSaveBtn();
        injectCheckboxes();
        scheduleLibraryAutoRun();
    }


    function injectSingleSaveBtn() {
        if (!isSearchPage()) return;
        if (document.getElementById('hiyori-single-btn'))  return;
        const btn     = document.createElement('button');
        btn.id        = 'hiyori-single-btn';
        btn.className = 'hiyori-btn';
        btn.innerHTML = '🌸 保存当前页';
        btn.onclick   = () => processCurrentPage(true)
            .catch(e => console.error('[妃爱] 单篇保存出错:', e));
        document.body.appendChild(btn);
    }


    let lastHref = location.href;
    new MutationObserver(() => {
        if (location.href === lastHref) return;
        lastHref = location.href;
        const old = document.getElementById('hiyori-single-btn');
        if (old) old.remove();
        injectSingleSaveBtn();

        if (isLibraryPage()) {
            injectCheckboxes();
            scheduleLibraryAutoRun();
        } else {
            clearLibraryAutoRun();
        }

        if (isSearchPage() && GM_getValue('hiyori_running', false)) {
            processCurrentPage().catch(err => {
                console.error('[妃爱] 自动处理入口出错:', err);
                if (GM_getValue('hiyori_running', false)) processNextInQueue();
            });
        }
    }).observe(document.body, { childList: true, subtree: true });


    function injectCheckboxes() {
        if (!isLibraryPage()) return;

        collectLibraryEntries().forEach(entry => {
            const item = entry.item;
            if (!item || getDirectCheckbox(item)) return;

            const cb     = document.createElement('input');
            cb.type      = 'checkbox';
            cb.className = 'hiyori-checkbox';
            cb.dataset.hiyoriUrl = entry.url;

            ['pointerdown', 'mousedown', 'mouseup'].forEach(type => {
                cb.addEventListener(type, e => e.stopPropagation());
            });
            cb.addEventListener('click', e => {
                const checked = cb.checked;
                e.preventDefault();
                e.stopPropagation();
                setTimeout(() => { cb.checked = checked; }, 0);
            });

            item.classList.add('hiyori-library-item');
            item.insertBefore(cb, item.firstChild);
        });
    }


    function stopBatch(showAlert = false) {
        GM_setValue('hiyori_running', false);
        GM_setValue('hiyori_queue',   []);

        if (showAlert) {
            const failures = GM_getValue('hiyori_failures', []);
            if (Array.isArray(failures) && failures.length > 0) {
                alert(`🛑 已停止!以下文章写入 Notion 失败(本地文件已保存):\n${failures.join('\n')}`);
            } else {
                alert('🛑 已经乖乖停下啦!');
            }
        }

        GM_setValue('hiyori_failures', []);

        const btn = document.getElementById('h-stop');
        if (btn) btn.style.display = 'none';
    }


    async function startBatch(useSelected) {
        if (!isLibraryPage()) {
            alert('宝宝,请先打开 Perplexity 的 Library/历史页面再批量抓取哦!');
            return;
        }

        if (GM_getValue('hiyori_running', false)) {
            if (!confirm('检测到已有批量任务运行中,是否强制覆盖?')) return;
        }

        const entries = await waitForLibraryEntries();
        injectCheckboxes();

        const items = [];
        const seen  = new Set();

        if (useSelected) {
            collectLibraryEntries().forEach(entry => {
                const cb = getDirectCheckbox(entry.item);
                const url = cb && cb.dataset.hiyoriUrl ? cb.dataset.hiyoriUrl : entry.url;
                if (!cb || !cb.checked || !url || seen.has(url)) return;
                seen.add(url);
                items.push({ url, originalIndex: entry.originalIndex });
            });
        } else {
            entries.slice(0, CONFIG.count).forEach(entry => {
                if (!entry.url || seen.has(entry.url)) return;
                seen.add(entry.url);
                items.push({ url: entry.url, originalIndex: entry.originalIndex });
            });
        }

        if (items.length === 0) {
            alert('宝宝,没有找到文章呀,请确认 Library/历史页面已经加载完毕,或者已勾选需要保存的文章哦!');
            return;
        }

        items.reverse();
        GM_setValue('hiyori_queue',    items);
        GM_setValue('hiyori_running',  true);
        GM_setValue('hiyori_failures', []);

        const stopBtn = document.getElementById('h-stop');
        if (stopBtn) stopBtn.style.display = 'block';

        processNextInQueue();
    }


    function processNextInQueue() {
        if (!GM_getValue('hiyori_running', false)) return;

        const queue = GM_getValue('hiyori_queue', []);

        if (!Array.isArray(queue)) {
            console.error('[妃爱] 队列数据损坏,已静默重置');
            stopBatch(false);
            alert('⚠️ 队列数据异常,已自动重置,请重新开始。');
            return;
        }

        if (queue.length > 0) {
            const next = queue.shift();
            GM_setValue('hiyori_current_tag_index', next.originalIndex);
            GM_setValue('hiyori_queue',             queue);

            if (next.url === window.location.href) {
                window.location.reload();
            } else {
                window.location.href = next.url;
            }
        } else {
            GM_setValue('hiyori_running', false);
            const failures = GM_getValue('hiyori_failures', []);
            GM_setValue('hiyori_failures', []);

            if (Array.isArray(failures) && failures.length > 0) {
                alert(`🌸 批量完成!但以下文章写入 Notion 失败(本地文件已保存):\n${failures.join('\n')}`);
            } else {
                alert('🌸 宝宝!所有的素材都已经完美归档啦!');
            }
            window.location.href = 'https://www.perplexity.ai/library';
        }
    }


    async function waitForStableContent(timeout = 15000, stableWindow = 1500) {
        const deadline  = Date.now() + timeout;
        let lastLen     = -1;
        let stableSince = 0;

        while (Date.now() < deadline) {
            const main = document.querySelector('main') || document.body;
            const len  = (main.innerText || '').length;
            if (len > 200) {
                if (len === lastLen) {
                    if (stableSince === 0) stableSince = Date.now();
                    if (Date.now() - stableSince >= stableWindow) return main;
                } else {
                    stableSince = 0;
                    lastLen     = len;
                }
            }
            await sleep(400);
        }
        return document.querySelector('main') || document.body;
    }


    async function waitForTitle(timeout = 8000) {
        const generic = new Set([
            '', 'Perplexity', 'Perplexity AI',
            'New Thread', 'Ask anything', 'Untitled',
            '新建对话', '新线程',
            '新しいスレッド', '新しい質問',
            '새 스레드',
        ]);
        const deadline = Date.now() + timeout;
        while (Date.now() < deadline) {
            const t = document.title.replace(/ - Perplexity/g, '').trim();
            if (t && !generic.has(t)) return t;
            await sleep(300);
        }
        return document.title.replace(/ - Perplexity/g, '').trim() || '未命名对话';
    }

    function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }


    function chunkText(text, maxLen) {
        const chars  = Array.from(text);
        const chunks = [];
        for (let i = 0; i < chars.length; i += maxLen) {
            chunks.push(chars.slice(i, i + maxLen).join(''));
        }
        return chunks;
    }


    function safeSubstring(str, maxCodePoints) {
        return Array.from(str).slice(0, maxCodePoints).join('');
    }


    // - 挂载 <a> 到 DOM 后点击(修复 Firefox 不触发未挂载元素下载问题)

    // - safeSubstring 截断(防服务对代理对)
    function downloadLocal(title, content, ext) {
        const safe    = safeSubstring(
            title.replace(/[/\\:*?"<>|]/g, '_'), 40
        );
        const mime    = ext === 'md' ? 'text/markdown' : 'text/plain';
        const blobUrl = URL.createObjectURL(
            new Blob([content], { type: mime + ';charset=utf-8' })
        );
        const a       = document.createElement('a');
        a.href        = blobUrl;
        a.download    = `${safe}.${ext}`;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
    }


    function recordFailure(title) {
        const f = GM_getValue('hiyori_failures', []);
        if (!Array.isArray(f)) return;
        f.push(title);
        GM_setValue('hiyori_failures', f);
    }


    async function processCurrentPage(isSingle = false) {
        if (isProcessing) {
            console.warn('[妃爱] 已在处理中,跳过重复调用');
            return;
        }
        isProcessing = true;

        try {
            const main = await waitForStableContent();

            if (!isSingle && !GM_getValue('hiyori_running', false)) return;

            const title = await waitForTitle();

            if (!isSingle && !GM_getValue('hiyori_running', false)) return;

            const url = window.location.href;

            const uniqueSources = new Set();
            main.querySelectorAll('a[href^="http"]').forEach(a => {
                if (a.href && !a.href.includes('perplexity.ai')) {
                    uniqueSources.add(a.href);
                }
            });

            const images = Array.from(main.querySelectorAll('img')).filter(img => {
                const src = img.currentSrc || img.dataset.src || img.src || '';
                return src.startsWith('http') &&
                       (img.naturalWidth > 50 || !!img.dataset.src);
            });

            const bodyText = (main.innerText || '').trim();
            if (!bodyText) {
                console.warn('[妃爱] 页面内容为空,跳过:', title);
                if (isSingle) alert('宝宝,页面内容还没加载出来哦,请稍后再试!');
                if (!isSingle) processNextInQueue();
                return;
            }

            const sourcesSection = uniqueSources.size > 0
                ? '\n\n---\n### 🌸 引用来源 (Sources)\n' +
                  Array.from(uniqueSources).map((l, i) => `[${i + 1}] ${l}`).join('\n')
                : '';

            const imagesSection = images.length > 0
                ? '\n\n---\n### 🌸 提取的附图资源\n' +
                  images.map(img => {
                      const src = img.currentSrc || img.dataset.src || img.src;
                      return CONFIG.fileType === 'md'
                          ? `![图片](<${src}>)`
                          : `[图片链接]: ${src}`;
                  }).join('\n\n')
                : '';

            const fullContent = bodyText + sourcesSection + imagesSection;

            let tag = '综合';
            if (!isSingle) {
                const idx = GM_getValue('hiyori_current_tag_index', 0);
                tag = idx < 4 ? '经济' : idx < 10 ? '政治军事' : '综合';
            }

            downloadLocal(title, fullContent, CONFIG.fileType);

            if (CONFIG.token && CONFIG.dbId) {
                await saveToNotion(title, url, fullContent, tag, isSingle);
            } else {
                if (isSingle) {
                    alert('宝宝,只帮您保存了本地文件哦!Notion 配置未填写。');
                } else {
                    processNextInQueue();
                }
            }

        } catch (err) {
            console.error('[妃爱] processCurrentPage 出错:', err);
            if (!isSingle && GM_getValue('hiyori_running', false)) {
                processNextInQueue();
            }
        } finally {
            isProcessing = false;
        }
    }


    function saveToNotion(title, url, content, tag, isSingle, retry = 0) {
        return new Promise(resolve => {
            const allChars    = Array.from(content);
            const isTruncated = allChars.length > 99 * 1900;


            //   block 顶层需要 type: 'paragraph'

            const blocks = chunkText(content, 1900).slice(0, 99).map(chunk => ({
                object:    'block',
                type:      'paragraph',
                paragraph: { rich_text: [{ type: 'text', text: { content: chunk } }] }
            }));

            const now       = new Date();
            const localDate = [
                now.getFullYear(),
                String(now.getMonth() + 1).padStart(2, '0'),
                String(now.getDate()).padStart(2, '0')
            ].join('-');

            const props = {};

            props[CONFIG.propTitle] = { title:        [{ type: 'text', text: { content: safeSubstring(title, 100) } }] };
            props[CONFIG.propUrl]   = { url };
            props[CONFIG.propTags]  = { multi_select: [{ name: tag }] };
            props[CONFIG.propTime]  = { date:         { start: localDate } };

            GM_xmlhttpRequest({
                method:  'POST',
                url:     'https://api.notion.com/v1/pages',
                timeout: 20000,
                headers: {
                    'Authorization':  `Bearer ${CONFIG.token}`,
                    'Content-Type':   'application/json',
                    'Notion-Version': '2022-06-28'
                },
                data: JSON.stringify({
                    parent:     { database_id: CONFIG.dbId },
                    properties: props,
                    children:   blocks
                }),

                onload(res) {
                    if (res.status === 429 && retry < 3) {
                        const wait = (retry + 1) * 8000;
                        console.warn(`[妃爱] Notion 限流,${wait / 1000}s 后重试(第 ${retry + 1} 次)`);
                        setTimeout(
                            () => saveToNotion(title, url, content, tag, isSingle, retry + 1)
                                    .then(resolve),
                            wait
                        );
                        return;
                    }

                    if (res.status < 200 || res.status >= 300) {
                        console.error('[妃爱] Notion 写入失败:', res.status, res.responseText);
                        recordFailure(title);
                    } else if (isTruncated) {
                        console.warn('[妃爱] 内容超限,已截断至约 18.6 万字符:', title);
                    }

                    resolve();
                    if (!isSingle && GM_getValue('hiyori_running', false)) {
                        processNextInQueue();
                    }
                },

                onerror(err) {
                    console.error('[妃爱] Notion 网络错误:', err);
                    recordFailure(title);
                    resolve();
                    if (!isSingle && GM_getValue('hiyori_running', false)) {
                        processNextInQueue();
                    }
                },

                ontimeout() {
                    console.error('[妃爱] Notion 请求超时,已跳过:', title);
                    recordFailure(title);
                    resolve();
                    if (!isSingle && GM_getValue('hiyori_running', false)) {
                        processNextInQueue();
                    }
                }
            });
        });
    }


    if (document.readyState === 'complete') {
        initUI();
    } else {
        window.addEventListener('load', initUI);
    }

    setInterval(injectCheckboxes, 2000);

    if (isLibraryPage()) {
        scheduleLibraryAutoRun();
    }

    if (isSearchPage() &&
        GM_getValue('hiyori_running', false)) {
        processCurrentPage().catch(err => {
            console.error('[妃爱] 自动处理入口出错:', err);
            if (GM_getValue('hiyori_running', false)) processNextInQueue();
        });
    }

})();