// ==UserScript== // @name 思源第二大脑 // @namespace siyuan-second-brain // @version 2.3.4 // @description 高亮笔记概念+别名,悬浮透视原文,点击跳转思源。 // @author You // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @connect 127.0.0.1 // @run-at document-idle // ==/UserScript== (function() { 'use strict'; const CONFIG = { SIYUAN_API: 'http://127.0.0.1:6806', API_TOKEN: '', NOTEBOOK_ID: '', MAX_CONCEPTS: 9999, // 🔑 提升上限,避免被截断 CACHE_MINUTES: 30, HIGHLIGHT_CLASS: 'siyuan-brain-hit', TOOLTIP_MAX_LEN: 120, }; const CACHE_KEY = 'siyuan_brain_cache_v5'; // 🔑 强制升级,彻底抛弃旧缓存 const LOG = (s, d, x) => console.log(`[🧠思源] [${s}] ${d}`, x || ''); const ERR = (s, d, x) => console.error(`[🧠思源] [${s}] ❌ ${d}`, x || ''); /* ================= 注入全局 CSS ================= */ const styleId = 'siyuan-brain-style'; if (!document.getElementById(styleId)) { const css = document.createElement('style'); css.id = styleId; css.textContent = ` @keyframes siyuanPulse { 0% { box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.7) !important; } 70% { box-shadow: 0 0 0 6px rgba(255, 152, 0, 0) !important; } 100% { box-shadow: 0 0 0 0 rgba(255, 152, 0, 0) !important; } } .${CONFIG.HIGHLIGHT_CLASS} { background-color: #ffeb3b !important; color: #000 !important; border-radius: 3px !important; padding: 0 2px !important; margin: 0 1px !important; cursor: help !important; position: relative !important; display: inline !important; font-weight: 600 !important; text-decoration: none !important; border-bottom: 2px solid #ff9800 !important; animation: siyuanPulse 2s infinite !important; z-index: 9999 !important; } #siyuan-tooltip { position: fixed !important; z-index: 2147483647 !important; display: none; max-width: 340px !important; background: #fff !important; border: 1px solid #e0e0e0 !important; border-radius: 8px !important; box-shadow: 0 8px 24px rgba(0,0,0,0.15) !important; font-family: system-ui, -apple-system, sans-serif !important; font-size: 13px !important; line-height: 1.5 !important; color: #333 !important; pointer-events: auto !important; opacity: 0; transition: opacity 0.15s; } #siyuan-tooltip.visible { opacity: 1; } #siyuan-tooltip .tt-header { padding: 8px 12px !important; background: #fff8e1 !important; border-bottom: 1px solid #ffe0b2 !important; border-radius: 8px 8px 0 0 !important; font-weight: 600 !important; color: #e65100 !important; font-size: 12px !important; } #siyuan-tooltip .tt-body { padding: 10px 12px !important; max-height: 240px !important; overflow-y: auto !important; } #siyuan-tooltip .tt-item { margin-bottom: 8px !important; padding-bottom: 8px !important; border-bottom: 1px dashed #eee !important; cursor: pointer !important; transition: background 0.15s !important; border-radius: 4px !important; padding: 6px !important; } #siyuan-tooltip .tt-item:hover { background: #fff3e0 !important; } #siyuan-tooltip .tt-item:last-child { margin-bottom: 0 !important; border-bottom: none !important; } #siyuan-tooltip .tt-title { font-weight: 600 !important; color: #1565c0 !important; font-size: 12px !important; margin-bottom: 2px !important; } #siyuan-tooltip .tt-snippet { color: #555 !important; font-size: 12px !important; line-height: 1.4 !important; } #siyuan-tooltip .tt-meta { color: #999 !important; font-size: 11px !important; margin-top: 4px !important; } #siyuan-tooltip .tt-arrow { position: absolute !important; bottom: -6px !important; left: 50% !important; transform: translateX(-50%) !important; width: 0 !important; height: 0 !important; border-left: 6px solid transparent !important; border-right: 6px solid transparent !important; border-top: 6px solid #fff !important; } #siyuan-toast { position: fixed !important; bottom: 24px !important; right: 24px !important; z-index: 2147483647 !important; background: #1a1a2e !important; color: #fff !important; padding: 10px 16px !important; border-radius: 8px !important; font-size: 13px !important; font-family: system-ui, sans-serif !important; box-shadow: 0 4px 16px rgba(0,0,0,0.25) !important; opacity: 0; transition: opacity 0.3s, transform 0.3s !important; transform: translateY(10px) !important; pointer-events: none !important; } #siyuan-toast.show { opacity: 1 !important; transform: translateY(0) !important; } `; document.head.appendChild(css); LOG('CSS', '样式表已注入'); } function getHeaders() { const h = {'Content-Type': 'application/json'}; if (CONFIG.API_TOKEN) h.Authorization = `Token ${CONFIG.API_TOKEN}`; return h; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /* ================= SQL 查询工具 ================= */ function queryBlocksByKeyword(keyword, limit) { return new Promise((resolve) => { const safeKeyword = keyword.replace(/'/g, "''"); const sql = `SELECT id, content, hPath, type, updated, box FROM blocks WHERE content LIKE '%${safeKeyword}%' AND type IN ('d', 'h', 'p') ORDER BY updated DESC LIMIT ${limit}`; GM_xmlhttpRequest({ method: 'POST', url: `${CONFIG.SIYUAN_API}/api/query/sql`, headers: getHeaders(), data: JSON.stringify({stmt: sql}), onload: (res) => { try { const json = JSON.parse(res.responseText); if (json.code !== 0) { resolve([]); return; } resolve(json.data || []); } catch (e) { resolve([]); } }, onerror: () => resolve([]) }); }); } /* ================= 阶段一:高亮引擎(标题 + 别名) ================= */ async function loadConcepts() { const cache = GM_getValue(CACHE_KEY); if (cache && (Date.now() - cache.ts < CONFIG.CACHE_MINUTES * 60000)) { LOG('缓存', `命中 ${cache.data.length} 个概念`); return cache.data; } // 标题查询 const titleSql = `SELECT DISTINCT content FROM blocks WHERE (type = 'd' OR type = 'h') AND length(content) >= 2 AND length(content) <= 25 ${CONFIG.NOTEBOOK_ID ? `AND box = '${CONFIG.NOTEBOOK_ID}'` : ''} ORDER BY updated DESC LIMIT ${CONFIG.MAX_CONCEPTS}`; // 🔑 关键修复:去掉单条 alias 长度限制,提升 LIMIT,增加 ORDER BY const aliasSql = `SELECT alias FROM blocks WHERE alias != '' AND alias IS NOT NULL ORDER BY updated DESC LIMIT ${CONFIG.MAX_CONCEPTS}`; LOG('网络', '并行拉取:标题 + 别名...'); const [titles, aliases] = await Promise.all([ new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: `${CONFIG.SIYUAN_API}/api/query/sql`, headers: getHeaders(), data: JSON.stringify({stmt: titleSql}), onload: (res) => { try { const json = JSON.parse(res.responseText); if (json.code !== 0) { resolve([]); return; } const list = json.data.map(d => d.content.replace(/[#*`]/g, '').trim()) .filter(c => c && !/^\d+\./.test(c)); LOG('数据', `标题拉取 ${list.length} 个`, list.slice(0, 5)); resolve(list); } catch (e) { resolve([]); } }, onerror: () => resolve([]) }); }), new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: `${CONFIG.SIYUAN_API}/api/query/sql`, headers: getHeaders(), data: JSON.stringify({stmt: aliasSql}), onload: (res) => { try { const json = JSON.parse(res.responseText); if (json.code !== 0) { LOG('数据', `别名查询返回 code=${json.code}`, json.msg || ''); resolve([]); return; } if (!json.data?.length) { LOG('数据', 'blocks.alias 字段无数据'); resolve([]); return; } // 🔑 调试:打印原始返回条数和前 10 条原始 alias LOG('数据', `别名原始返回 ${json.data.length} 条`, json.data.slice(0, 10).map(d => d.alias)); const raw = json.data.map(d => d.alias); const split = []; raw.forEach(v => { v.split(/[,,]/).forEach(p => { const trimmed = p.trim(); if (trimmed && trimmed.length >= 2 && trimmed.length <= 25) split.push(trimmed); }); }); const list = [...new Set(split)]; LOG('数据', `别名拆分后 ${list.length} 个`, list.slice(0, 10)); resolve(list); } catch (e) { resolve([]); } }, onerror: () => resolve([]) }); }) ]); const merged = [...new Set([...titles, ...aliases])].sort((a, b) => b.length - a.length); if (!merged.length) { LOG('数据', '笔记库无标题也无别名'); return []; } GM_setValue(CACHE_KEY, {ts: Date.now(), data: merged}); LOG('数据', `合并后共 ${merged.length} 个概念(标题${titles.length} + 别名${aliases.length})`, merged.slice(0, 10)); // 🔑 调试:确认 SLAM 这个别名是否存在 // const slamLike = merged.filter(c => c.toLowerCase().includes('slam')); // LOG('调试', `最终概念库中包含 SLAM 的: ${slamLike.length} 个`, slamLike); return merged; } function walkAndHighlight(node, patterns) { if (node.nodeType === 3) { const text = node.textContent; const lowerText = text.toLowerCase(); for (const p of patterns) { const lowerPattern = p.text.toLowerCase(); const idx = lowerText.indexOf(lowerPattern); if (idx === -1) continue; const span = document.createElement('span'); span.className = CONFIG.HIGHLIGHT_CLASS; span.title = `思源概念:${p.text}`; span.dataset.concept = p.text; const before = text.substring(0, idx); const match = text.substring(idx, idx + p.text.length); const after = text.substring(idx + p.text.length); const parent = node.parentNode; if (!parent) return; if (before) parent.insertBefore(document.createTextNode(before), node); span.textContent = match; parent.insertBefore(span, node); if (after) parent.insertBefore(document.createTextNode(after), node); parent.removeChild(node); bindTooltip(span, p.text); if (after) walkAndHighlight(span.nextSibling, patterns); return; } } else if (node.nodeType === 1) { const tag = node.tagName.toLowerCase(); if (/script|style|code|pre|textarea|input/.test(tag)) return; for (let i = node.childNodes.length - 1; i >= 0; i--) { walkAndHighlight(node.childNodes[i], patterns); } } } /* ================= 阶段二:悬浮透视 + 点击跳转 ================= */ const tooltip = document.createElement('div'); tooltip.id = 'siyuan-tooltip'; tooltip.innerHTML = '
'; document.body.appendChild(tooltip); const toast = document.createElement('div'); toast.id = 'siyuan-toast'; document.body.appendChild(toast); function showToast(msg) { toast.textContent = msg; toast.classList.add('show'); setTimeout(() => toast.classList.remove('show'), 2500); } let tooltipTimer = null; let currentTooltipTarget = null; const tooltipCache = new Map(); tooltip.addEventListener('mouseenter', () => clearTimeout(tooltipTimer)); tooltip.addEventListener('mouseleave', () => { tooltipTimer = setTimeout(hideTooltip, 200); }); function hideTooltip() { tooltip.classList.remove('visible'); currentTooltipTarget = null; setTimeout(() => { if (!tooltip.classList.contains('visible')) tooltip.style.display = 'none'; }, 150); } function positionTooltip(target) { const rect = target.getBoundingClientRect(); const ttRect = tooltip.getBoundingClientRect(); let top = rect.top - ttRect.height - 8; let left = rect.left + (rect.width / 2) - (ttRect.width / 2); if (top < 10) top = rect.bottom + 8; if (left < 10) left = 10; if (left + ttRect.width > window.innerWidth - 10) left = window.innerWidth - ttRect.width - 10; tooltip.style.top = top + 'px'; tooltip.style.left = left + 'px'; } function bindTooltip(el, concept) { let enterTimer = null; el.addEventListener('mouseenter', async () => { clearTimeout(tooltipTimer); if (currentTooltipTarget === el) return; currentTooltipTarget = el; tooltip.querySelector('.tt-header').textContent = `📝 ${concept}`; const body = tooltip.querySelector('.tt-body'); body.innerHTML = '
正在从思源读取...
'; tooltip.style.display = 'block'; tooltip.classList.add('visible'); positionTooltip(el); const cacheKey = `tt_${concept}`; const cached = tooltipCache.get(cacheKey); if (cached && (Date.now() - cached.ts < 3 * 60000)) { renderTooltipBlocks(cached.data, body); positionTooltip(el); return; } clearTimeout(enterTimer); enterTimer = setTimeout(async () => { const blocks = await queryBlocksByKeyword(concept, 3); tooltipCache.set(cacheKey, {ts: Date.now(), data: blocks}); renderTooltipBlocks(blocks, body); positionTooltip(el); }, 300); }); el.addEventListener('mouseleave', () => { clearTimeout(enterTimer); tooltipTimer = setTimeout(() => { if (currentTooltipTarget === el) hideTooltip(); }, 250); }); } function renderTooltipBlocks(blocks, container) { if (!blocks.length) { container.innerHTML = `
未在笔记中找到相关记录
该词可能仅出现在标题/别名中,正文尚未包含此关键词。
`; return; } container.innerHTML = blocks.map(b => { const snippet = (b.content || '').substring(0, CONFIG.TOOLTIP_MAX_LEN); const path = b.hPath || '未知路径'; const box = b.box ? `[${b.box}] ` : ''; return `
${escapeHtml((b.content || '').substring(0, 40) || '无标题')}
${escapeHtml(snippet)}${(b.content || '').length > CONFIG.TOOLTIP_MAX_LEN ? '...' : ''}
📂 ${box}${escapeHtml(path)} · ${b.type || '?'}
`; }).join(''); container.querySelectorAll('.tt-item').forEach(item => { item.addEventListener('click', (e) => { e.stopPropagation(); const blockId = item.dataset.blockId; const siyuanUrl = `siyuan://blocks/${blockId}`; try { window.open(siyuanUrl, '_blank'); } catch (err) {} navigator.clipboard.writeText(siyuanUrl).then(() => { showToast(`🔗 已复制 "${item.querySelector('.tt-title').textContent}"`); }); }); }); } /* ================= 主流程 ================= */ async function main() { if (location.host === '127.0.0.1' && location.port === '6806') return; LOG('启动', `页面: ${location.href}`); const concepts = await loadConcepts(); if (concepts.length) { const patterns = concepts.map(c => ({text: c})); const t0 = performance.now(); walkAndHighlight(document.body, patterns); const hits = document.querySelectorAll('.' + CONFIG.HIGHLIGHT_CLASS); LOG('高亮', `命中 ${hits.length} 处,概念库 ${concepts.length} 个,耗时 ${(performance.now()-t0).toFixed(1)}ms`); } else { LOG('高亮', '概念库为空,跳过'); } } GM_registerMenuCommand('🧠 刷新概念缓存', () => { GM_setValue(CACHE_KEY, null); tooltipCache.clear(); location.reload(); }); if (document.readyState === 'complete' || document.readyState === 'interactive') { setTimeout(main, 600); } else { window.addEventListener('DOMContentLoaded', () => setTimeout(main, 600)); } })();