// ==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}`;
}).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();
});
}
})();