--- tags: - tagfeed-live cssclasses: - tagfeed words: 2026-08-23: 2728 2026-08-25: 2807 2026-08-26: 2804 --- # 🏷 #knomo(实时聚合页) > 本页由 DataviewJS 实时计算,笔记有变动时自动刷新,无需任何后台服务。 ```dataviewjs // ============================================================ // tagfeed-live:Logseq 风格标签实时聚合(标准 DataviewJS 脚本) // // 用法: // 1. 新建一个笔记(例如 00index/tagfeed-md/标签名.md) // 2. 在笔记里插入一个 dataviewjs 代码块(三个反引号 + dataviewjs), // 把本脚本全部粘贴进去 // 3. 只需要改下面「配置区」的两行: // - targetTag 换成你要聚合的标签(不带 #,中英文都可以;多个标签写成数组 ["a", "b"]) // - excludedPaths 换成你想排除的文件夹 // 4. 完成。以后任何笔记里出现/删除这个标签,本页自动刷新, // 不需要跑任何命令(Dataview 会在文件变动后自动重新渲染)。 // ============================================================ // ---------------- 配置区(通常只改这里) ---------------- const targetTag = "knomo"; // 要聚合的标签(不带 #);多个标签用数组:["lyric", "歌词"] const excludedPaths = ["X2.Archived", "00index", "X1.Knomo", "02DS/02copilot", ".agents", ".claude", ".copilot", ".opencode", ".smart-env", ".workbuddy", ".trash"]; // 要排除的文件夹/路径前缀(vault 相对路径) // 例如:["00index", "模板", "X1.Knomo"] // 补充说明: // - 所有隐藏文件/文件夹(. 开头:.agents/.claude/.copilot/.opencode/.smart-env/.workbuddy/.trash 等)与 X.Attachment 始终跳过 // - tagfeed 系统页会自动跳过:静态聚合页(文件头含 /g, ""); return s; } const MACHINE_LINE_RE = [ /Switch to EXCALIDRAW VIEW/i, /MORE OPTIONS menu of this document/i, /Decompress current Excalidraw file/i, /plugin settings under ['"]Saving['"]/i, /^\s*#{1,6}\s*Drawing\s*$/i, /^\s*```(?:compressed-json|json)?\s*$/i, /^\s*[\{\[]\s*"type"\s*:\s*"excalidraw"/i, /^\s*"elements"\s*:/i, ]; const HTML_COMMENT_RE = //g; const TEMPLATE_RE = /<%[\s\S]*?%>/g; const HTML_TAG_RE = /<\/?[A-Za-z][^>\n]*>/g; const PASTED_IMAGE_MD_RE = /!?\[[^\]\n]*Pasted Image[^\]\n]*\]\([^)]+?\.(?:png|jpe?g|gif|webp)\)/gi; const PASTED_IMAGE_WIKI_RE = /!?\[\[[^\]\n]*Pasted Image[^\]\n]*\.(?:png|jpe?g|gif|webp)(?:\|[^\]\n]*)?\]\]/gi; const PASTED_IMAGE_TEXT_RE = /\bPasted Image[^\n]*?\.(?:png|jpe?g|gif|webp)\b/gi; const SENSITIVE_TOKEN_LINE_RE = /\b(?:github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,})\b/i; const SENSITIVE_TOKEN_RE = /\b(?:github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,})\b/g; const LONG_HEX_RE = /\b[0-9a-fA-F]{32,}\b/g; const EXCALIDRAW_DATA_HEADING_RE = /^\s*#{1,6}\s+Excalidraw Data\s*$/i; const isExcalidrawDataHeading = ln => EXCALIDRAW_DATA_HEADING_RE.test(String(ln || "").trim()); const EXCALIDRAW_BLOCK_REF_RE = /\s+\^[A-Za-z0-9_-]+\s*$/; function extractFrontmatterPrefix(text) { const m = String(text || "").match(/^---\s*\n[\s\S]*?\n(?:---|\.\.\.)\s*\n/); return m ? m[0].trimEnd() : ""; } function extractExcalidrawTextElements(text) { const lines = String(text || "").split("\n"); const start = lines.findIndex(ln => /^\s*##\s+Text Elements\s*$/i.test(ln)); if (start < 0) return ""; const chunks = []; let current = []; const flush = () => { const joined = current.map(x => x.trim()).filter(Boolean).join(" ").replace(/[ \t]{2,}/g, " ").trim(); if (joined) chunks.push(joined); current = []; }; for (let i = start + 1; i < lines.length; i++) { const raw = lines[i]; const stripped = raw.trim(); if (stripped === "%%" || /^\s*##\s+(?:Embedded Files|Drawing)\s*$/i.test(raw)) break; if (!stripped) { if (current.length) current.push(""); continue; } const hadRef = EXCALIDRAW_BLOCK_REF_RE.test(raw); const cleaned = raw.replace(EXCALIDRAW_BLOCK_REF_RE, "").trim(); if (cleaned) current.push(cleaned); if (hadRef) flush(); } flush(); return chunks.join("\n\n"); } function readableExcalidrawMarkdown(text) { const frontmatter = extractFrontmatterPrefix(text); const body = extractExcalidrawTextElements(text); return [frontmatter, body].filter(Boolean).join("\n\n"); } function sanitizeHumanLine(ln) { let s = String(ln || ""); if (!s.trim()) return null; if (MACHINE_LINE_RE.some(re => re.test(s))) return null; if (SENSITIVE_TOKEN_LINE_RE.test(s)) return null; s = s.replace(HTML_COMMENT_RE, ""); s = s.replace(TEMPLATE_RE, ""); s = s.replace(PASTED_IMAGE_MD_RE, ""); s = s.replace(PASTED_IMAGE_WIKI_RE, ""); s = s.replace(PASTED_IMAGE_TEXT_RE, ""); s = s.replace(SENSITIVE_TOKEN_RE, "[已隐藏敏感内容]"); s = s.replace(LONG_HEX_RE, "[已隐藏机器ID]"); s = s.replace(HTML_TAG_RE, ""); s = s.replace(/[ \t]{2,}/g, " ").trim(); const residue = s.replace(/\[已隐藏机器ID\]|\[已隐藏敏感内容\]/g, "").replace(/[^A-Za-z0-9\u4e00-\u9fff]+/g, ""); if (!s || !residue || /^[-*+>]*$/.test(s)) return null; return s; } function humanizeBlock(block) { const out = []; for (const row of block) { const lvl = Array.isArray(row) ? row[0] : 0; const txt = sanitizeHumanLine(Array.isArray(row) ? row[1] : row); if (txt) out.push([lvl, txt]); } if (!out.length) return []; const minLevel = Math.min(...out.map(x => x[0])); return out.map(([lvl, txt]) => [Math.max(0, lvl - minLevel), txt]); } function vdate(y, m, d) { const dt = new Date(y, m - 1, d); return (dt.getFullYear() === y && dt.getMonth() === m - 1 && dt.getDate() === d) ? dt : null; } function dateFromName(name) { // 文件名含 YYYY-MM-DD,或日记命名 26.0805 -> 2026-08-05 const m1 = name.match(/(\d{4})-(\d{2})-(\d{2})/); if (m1) { const d1 = vdate(+m1[1], +m1[2], +m1[3]); if (d1) return d1; } const m = name.match(/^(\d{2})\.(\d{2})(\d{2})(?:\D|$)/); return m ? vdate(2000 + +m[1], +m[2], +m[3]) : null; } function parseFrontmatter(text) { const lines = text.split("\n"); if (!lines.length || lines[0].trim() !== "---") return { tags: new Set(), date: null, bodyStart: 0 }; const n = Math.min(lines.length, 200); for (let i = 1; i < n; i++) { const t = lines[i].trim(); if (t === "---" || t === "...") { const block = lines.slice(1, i).join("\n"); const tags = new Set(); const im = block.match(/^tags:\s*\[(.*?)\]/m); if (im) { for (const x0 of im[1].split(",")) { const x = x0.trim().replace(/^["']|["']$/g, "").replace(/^#/, "").trim(); if (x) tags.add(x); } } else { const bm = block.match(/^tags:\s*\n((?:[ \t]+-[ \t]+.*\n?)+)/m); if (bm) for (const ln of bm[1].split("\n")) { const m2 = ln.match(/^\s*-\s*["']?([\w\u4e00-\u9fff/\-]+)["']?\s*$/); if (m2) tags.add(m2[1]); } } let fdate = null; for (const key of ["date", "created", "published"]) { const dm = block.match(new RegExp("^" + key + ":\\s*[\"']?(\\d{4})-(\\d{2})-(\\d{2})", "m")); if (dm) { fdate = vdate(+dm[1], +dm[2], +dm[3]); break; } } return { tags, date: fdate, bodyStart: i + 1 }; } } return { tags: new Set(), date: null, bodyStart: 0 }; } const stripTag = s => String(s).replace(TAG_RE, "").replace(/[ \t]{2,}/g, " ").trim(); function scanText(text, note, relpath, fileDate, mtimeDate) { const fm = parseFrontmatter(text); if (hasFmTag(fm.tags, "tagfeed") || hasFmTag(fm.tags, "tagfeed-live")) return []; // 跳过 tagfeed 系统页(静态聚合页/实时页) const lines = text.split("\n"); const entries = [], seen = new Set(); let order = 0, inFence = false, inPct = false, inHtml = false; let curHead = null, curHeadRaw = null, curDateHead = null; const entryDate = () => curDateHead || fileDate || fm.date || mtimeDate; const anchorOf = () => (curDateHead ? null : curHeadRaw); const breadcrumb = () => { if (curHead && !DATEH_RE.test(curHead)) { const t = curHead.replace(ANY_TAG, "").replace(/\s+/g, " ").trim(); if (t) return t; } return null; }; function addBlock(keyIdx, block) { block = humanizeBlock(block); const key = relpath + "|" + keyIdx; if (seen.has(key) || !block.length) return; seen.add(key); const d = entryDate(); if (!d) return; let t = null; const tm = block[0][1].slice(0, 30).match(TIME_RE); if (tm) t = [+tm[1], +tm[2], +(tm[3] || 0)]; order++; entries.push({ date: d, time: t, order, relpath, note, breadcrumb: breadcrumb(), lines: block, pageLevel: false, notes: [note], anchor: anchorOf() }); } function collectChildren(i, parentIndent) { const kids = [], raw = []; let j = i + 1; while (j < lines.length) { const ln = lines[j]; if (ln.trim() === "") { let k = j + 1; while (k < lines.length && lines[k].trim() === "") k++; if (k < lines.length && LIST_RE.test(lines[k]) && indentOf(lines[k]) > parentIndent) { j = k; continue; } break; } const mi = ln.match(LIST_RE); if (mi && indentOf(ln) > parentIndent) { raw.push([indentOf(ln), (mi[3] || "").replace(/\s+$/, "")]); j++; } else break; } for (const [ind, txt] of raw) kids.push([Math.floor((ind - parentIndent) / 2), txt]); return kids; } // A tag on an indented continuation line belongs to its nearest list item. function collectParentListBlock(i) { let parentIdx = null; let parentIndent = 0; for (let k = i - 1; k >= fm.bodyStart; k--) { const prev = lines[k]; if (!prev.trim() || HEAD_RE.test(prev) || FENCE_RE.test(prev)) break; if (prev.match(LIST_RE)) { parentIdx = k; parentIndent = indentOf(prev); break; } } if (parentIdx === null || indentOf(lines[i]) <= parentIndent) return null; const block = []; for (let j = parentIdx; j <= i; j++) { const current = lines[j]; if (!current.trim()) continue; const cm = current.match(LIST_RE); if (cm) { const currentIndent = indentOf(current); if (j !== parentIdx && currentIndent <= parentIndent) return null; const text = stripTag(cleanMatch(cm[3] || "")) || (cm[3] || "").trim(); block.push([j === parentIdx ? 0 : Math.max(1, Math.floor((currentIndent - parentIndent) / 2)), text]); } else { const text = stripTag(cleanMatch(current)); if (text) block.push([Math.max(1, Math.floor((indentOf(current) - parentIndent) / 2)), text]); } } return block.length ? { keyIdx: parentIdx, block } : null; } for (let i = fm.bodyStart; i < lines.length; i++) { const ln = lines[i]; if (FENCE_RE.test(ln)) { inFence = !inFence; continue; } if (inFence) continue; if (isExcalidrawDataHeading(ln)) break; if (inPct) { if (ln.includes("%%")) inPct = false; continue; } if (inHtml) { if (ln.includes("-->")) inHtml = false; continue; } const stripped = ln.trim(); if (stripped.startsWith("%%") && !stripped.slice(2).includes("%%")) { inPct = true; continue; } if (stripped.startsWith("")) { inHtml = true; continue; } const hm = ln.match(HEAD_RE); if (hm) { const htext = hm[2]; const dh = htext.match(DATEH_RE); if (dh) { curDateHead = vdate(+dh[1], +dh[2], +dh[3]); curHead = null; curHeadRaw = null; } else { curHead = htext; curHeadRaw = htext.trim(); } if (TAG_RE.test(cleanMatch(htext))) { const sec = [[0, "**" + stripTag(htext) + "**"]]; let j = i + 1; const level = hm[1].length; let count = 0; while (j < lines.length && count < 300) { const sl = lines[j]; if (isExcalidrawDataHeading(sl)) break; const shm = sl.match(HEAD_RE); if (shm && shm[1].length <= level) break; if (FENCE_RE.test(sl)) break; if (sl.trim()) { const smi = sl.match(LIST_RE); if (smi) sec.push([1 + Math.floor(indentOf(sl) / 2), (smi[3] || "").replace(/\s+$/, "")]); else sec.push([1, sl.trim()]); count++; } j++; } addBlock(i, sec); } continue; } const clean = cleanMatch(ln); if (!TAG_RE.test(clean)) continue; const mi = ln.match(LIST_RE); if (mi) { const ind = indentOf(ln); const itemText = mi[3] || ""; const cleaned = stripTag(itemText); const kids = collectChildren(i, ind); addBlock(i, cleaned ? [[0, cleaned], ...kids] : [[0, itemText.trim()], ...kids]); continue; } // 独立标签行:向下吸收 list items,或向上归属到紧邻的块 if (ln.replace(ANY_TAG, "").trim() === "" || ln.replace(ANY_TAG, "").trim() === "%%") { let sec = []; let j = i + 1; while (j < lines.length) { const mj = lines[j].match(LIST_RE); if (mj) { sec.push([indentOf(lines[j]), (mj[3] || "").replace(/\s+$/, "")]); j++; } else break; } let keyIdx = i; if (!sec.length) { const up = []; let k = i - 1, bulletIdx = null; while (k >= fm.bodyStart) { const bk = lines[k]; if (bk.trim() === "" || HEAD_RE.test(bk) || FENCE_RE.test(bk)) break; up.push(k); if (LIST_RE.test(bk)) { bulletIdx = k; break; } k--; } if (bulletIdx !== null) { const inds = []; for (let bl = bulletIdx; bl < i; bl++) { const mb = lines[bl].match(LIST_RE); if (mb) { inds.push(indentOf(lines[bl])); sec.push([indentOf(lines[bl]), (mb[3] || "").replace(/\s+$/, "")]); } else sec.push([(inds.length ? inds[inds.length - 1] + 2 : 0), lines[bl].trim()]); } keyIdx = bulletIdx; } else if (up.length) { sec = up.slice().reverse().map(k2 => [0, lines[k2].trim()]); } else { // 兜底:标签行紧挨在某个标题下方(中间只有空行)→ 捕获该标题下的整个小节 let h = i - 1; while (h >= fm.bodyStart && lines[h].trim() === "") h--; const hmk = h >= fm.bodyStart ? lines[h].match(HEAD_RE) : null; if (hmk) { const level = hmk[1].length; const title = hmk[2].replace(ANY_TAG, "").replace(/\s+/g, " ").trim(); const sec2 = title ? [[0, "**" + title + "**"]] : []; let j2 = i + 1, count2 = 0; while (j2 < lines.length && count2 < 300) { const sl = lines[j2]; if (isExcalidrawDataHeading(sl)) break; const shm = sl.match(HEAD_RE); if (shm && shm[1].length <= level) break; if (FENCE_RE.test(sl)) break; if (sl.trim()) { const smi = sl.match(LIST_RE); if (smi) sec2.push([1 + Math.floor(indentOf(sl) / 2), (smi[3] || "").replace(/\s+$/, "")]); else sec2.push([1, sl.trim().replace(/^>\s*/, "")]); count2++; } j2++; } if (sec2.length) sec = sec2; } } } if (sec.length) { const mi0 = Math.min(...sec.map(x => x[0])); addBlock(keyIdx, sec.map(([ind, t]) => [Math.floor((ind - mi0) / 2), t])); } continue; } // 命中在列表项的缩进续行:摘要应回到所属列表项,而不是只截取续行尾部。 const parentBlock = collectParentListBlock(i); if (parentBlock) { addBlock(parentBlock.keyIdx, parentBlock.block); continue; } // 普通段落带标签:吸收到空行为止 const para = [stripTag(clean)]; let j = i + 1; while (j < lines.length) { const nl = lines[j]; if (isExcalidrawDataHeading(nl) || nl.trim() === "" || HEAD_RE.test(nl) || LIST_RE.test(nl) || FENCE_RE.test(nl)) break; para.push(nl.trim()); j++; } addBlock(i, [[0, para.filter(p => p).join(" ")]]); } // frontmatter 标签 → 页面级条目(附正文开头预览) if (hasAnyTargetFmTag(fm.tags)) { const d = fm.date || fileDate || mtimeDate; if (d) { order++; // 收集正文文本至 PREV_CHARS:超过上限的长文在渲染时截断并附「查看全文」链接 const prev = []; let prevTotal = 0, prevCount = 0; for (let k = fm.bodyStart; k < lines.length && prevCount < 200; k++) { const s = lines[k].trim(); if (isExcalidrawDataHeading(s)) break; if (!s || s.startsWith("#") || s === "---") continue; const clean = sanitizeHumanLine(s.replace(ANY_TAG, "")); if (!clean) continue; prev.push(clean); prevTotal += clean.length; prevCount++; if (prevTotal > PREV_CHARS) break; // 超过上限:已足够判断,停止收集 } const preview = prev.join(" "); entries.push({ date: d, time: null, order, relpath, note, breadcrumb: null, lines: preview ? [[0, preview]] : [], pageLevel: true, notes: [note], anchor: null }); } } return entries; } // ---------------- 扫描整个 vault ---------------- const currentPath = (dv.current() && dv.current().file) ? dv.current().file.path : null; let all = []; for (const f of app.vault.getMarkdownFiles()) { if (f.path === currentPath) continue; if (SKIP_DIRS.some(d => f.path.startsWith(d + "/"))) continue; if (f.path.split("/").some(seg => seg.startsWith("."))) continue; // 排除所有隐藏文件/文件夹(.开头) if (excludedPaths.some(p => f.path === p || f.path.startsWith(p.endsWith("/") ? p : p + "/"))) continue; let text; try { text = await app.vault.cachedRead(f); } catch (e) { continue; } if (f.path.endsWith(".excalidraw.md")) text = readableExcalidrawMarkdown(text); if (text.slice(0, 1000).includes(MARKER)) continue; // 跳过 tagfeed 生成的静态页 all = all.concat(scanText(text, f.basename, f.path, dateFromName(f.basename), new Date(f.stat.mtime))); } // ---------------- 去重:同日期+同内容合并,来源并排 ---------------- const timeCmp = (a, b) => (a[0] - b[0]) || (a[1] - b[1]) || (a[2] - b[2]); const mergedMap = new Map(); for (const e of all) { const fp = e.date.getTime() + "|" + e.lines.map(x => x[0] + ":" + x[1]).join("\n"); const m = mergedMap.get(fp); if (m) { if (!m.notes.includes(e.note)) m.notes.push(e.note); if (e.time && (!m.time || timeCmp(e.time, m.time) < 0)) m.time = e.time; // 若先扫到的来源没有面包屑/锚点,而后面的有,则采用后者(与扫描顺序无关,结果确定) if (!m.breadcrumb && e.breadcrumb) { m.breadcrumb = e.breadcrumb; m.anchor = e.anchor; m.relpath = e.relpath; m.note = e.note; } } else mergedMap.set(fp, e); } const entries = [...mergedMap.values()]; // ---------------- 渲染:按日期倒序 ---------------- const byDate = new Map(); for (const e of entries) { const k = e.date.getTime(); if (!byDate.has(k)) byDate.set(k, []); byDate.get(k).push(e); } const dates = [...byDate.keys()].sort((a, b) => b - a); const noteSet = new Set(); for (const e of entries) for (const n of e.notes) noteSet.add(n); const out = []; out.push("> **" + entries.length + "** 个内容块 | **" + noteSet.size + "** 篇笔记 | 按日期倒序 | 随笔记变动自动刷新"); const pad2 = x => String(x).padStart(2, "0"); const fmtDate = d => d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()); const invTime = t => t.map(x => pad2(99 - x)).join(""); for (const dk of dates) { const d = new Date(dk); out.push("## 📅 " + fmtDate(d) + " · " + WEEKDAYS[d.getDay() === 0 ? 6 : d.getDay() - 1]); out.push(""); const group = byDate.get(dk); const sub = new Map(); // (笔记+面包屑) -> entries for (const e of group) { const key = e.relpath + "|" + (e.breadcrumb || ""); if (!sub.has(key)) sub.set(key, []); sub.get(key).push(e); } const subKey = ([key, es]) => { const times = es.filter(e => e.time).map(e => e.time); const best = times.length ? times.reduce((a, b) => (timeCmp(a, b) > 0 ? a : b)) : null; return (best ? "0" : "1") + (best ? invTime(best) : "000000") + key; }; for (const [key, es] of [...sub.entries()].sort((a, b) => (subKey(a) < subKey(b) ? -1 : subKey(a) > subKey(b) ? 1 : 0))) { const noteList = []; for (const e of es) for (const n of e.notes) if (!noteList.includes(n)) noteList.push(n); noteList.sort(); const crumb = es.map(e => e.breadcrumb).find(b => b) || null; out.push("- 🔗 " + noteList.map(n => "[[" + n + "]]").join(" · ") + (crumb ? " · " + crumb : "")); const sorted = es.slice().sort((a, b) => { const ka = (a.time ? "0" : "1") + (a.time ? invTime(a.time) : "") + String(a.order).padStart(6, "0"); const kb = (b.time ? "0" : "1") + (b.time ? invTime(b.time) : "") + String(b.order).padStart(6, "0"); return ka < kb ? -1 : ka > kb ? 1 : 0; }); for (const e of sorted) { let bl = e.lines, truncated = false; const totalChars = bl.reduce((s, x) => s + x[1].length, 0); if (totalChars > PREV_CHARS) { // 只有超过上限才截断;上限内全文展示 truncated = true; const shown = []; let chars = 0; for (const [lvl, txt] of bl) { if (chars + txt.length > PREV_CHARS) { if (PREV_CHARS - chars > 0) shown.push([lvl, txt.slice(0, PREV_CHARS - chars) + "…"]); break; } shown.push([lvl, txt]); chars += txt.length; } bl = shown; } for (const [lvl, txt] of bl) out.push("\t".repeat(lvl + 1) + "- " + txt); if (truncated) { const target = e.note + (e.anchor ? "#" + e.anchor : ""); const label = e.pageLevel ? "页面级标签 · 查看全文" : "查看全文"; out.push("\t- [[" + target + "|↗ " + label + "]]"); } } out.push(""); } out.push(""); } dv.paragraph(out.join("\n")); } catch (err) { dv.paragraph("> ❌ tagfeed-live 执行失败:" + err.message); console.error("tagfeed-live:", err); } ```