// ==UserScript== // @name Perplexity to Local/Notion // @namespace http://tampermonkey.net/ // @version 1.0 // @description 修复批量下载Chrome拦截问题、Notion block/title缺type字段导致400失败的问题 // @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 // @downloadURL none // ==/UserScript== (function () { 'use strict'; let isProcessing = false; 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-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, '&').replace(/"/g, '"'); } 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 = `
🌸 P2L/N 🌸
▸ Notion 字段名设置(默认通常不用改)
秒后启动

`; 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('宝宝!设置已经牢牢记住辣!'); }; 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(); } function injectSingleSaveBtn() { if (!window.location.href.includes('/search/')) 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 (location.href.includes('/search/') && 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 (!window.location.href.includes('/library')) return; const root = document.querySelector('main') || document.body; root.querySelectorAll('a[href^="/search/"]').forEach(a => { if (!a.parentElement) return; if (!a.parentElement.querySelector('.hiyori-checkbox')) { const cb = document.createElement('input'); cb.type = 'checkbox'; cb.className = 'hiyori-checkbox'; a.parentElement.insertBefore(cb, a); } }); } 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'; } function startBatch(useSelected) { if (GM_getValue('hiyori_running', false)) { if (!confirm('检测到已有批量任务运行中,是否强制覆盖?')) return; } const root = document.querySelector('main') || document.body; const items = []; const seen = new Set(); if (useSelected) { const allLinks = Array.from(root.querySelectorAll('a[href^="/search/"]')); root.querySelectorAll('.hiyori-checkbox:checked').forEach(cb => { const a = cb.parentElement.querySelector('a[href^="/search/"]'); if (!a || !a.href || seen.has(a.href)) return; seen.add(a.href); const realIdx = allLinks.indexOf(a); items.push({ url: a.href, originalIndex: realIdx >= 0 ? realIdx : 999 }); }); } else { Array.from(root.querySelectorAll('a[href^="/search/"]')) .slice(0, CONFIG.count * 2) .forEach((a, idx) => { if (!a.href || seen.has(a.href) || items.length >= CONFIG.count) return; seen.add(a.href); items.push({ url: a.href, originalIndex: idx }); }); } if (items.length === 0) { alert('宝宝,没有找到文章呀,请确认是否勾选或者页面加载完毕哦!'); 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(''); } // - 挂载 到 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); // Library 页面自动运行检测 if (window.location.href.includes('/library') && CONFIG.autoRun && !GM_getValue('hiyori_running', false)) { const delay = CONFIG.autoRunDelay; console.log(`[妃爱] 已开启自动抓取,${delay} 秒后启动...`); let countdown = delay; const countdownInterval = setInterval(() => { countdown--; console.log(`[妃爱] 自动抓取倒计时: ${countdown} 秒`); if (countdown <= 0) clearInterval(countdownInterval); }, 1000); setTimeout(() => { if (GM_getValue('hiyori_running', false)) return; // 防止重复触发 console.log('[妃爱] 自动抓取已启动!'); startBatch(false); }, delay * 1000); } if (window.location.href.includes('/search/') && GM_getValue('hiyori_running', false)) { processCurrentPage().catch(err => { console.error('[妃爱] 自动处理入口出错:', err); if (GM_getValue('hiyori_running', false)) processNextInQueue(); }); } })();