#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ tagfeed-md 扫描器 ============== 只负责一件事:**扫描 vault 里新出现的 #tag,在 00index/tagfeed-md/ 下为每个标签生成 tagfeed 页面**。 它与画布索引解耦: - 不再生成画布索引。 - 不读取旧画布引用作为排除集; 这里只判断「该 tag 是否已有同名 tagfeed 页」。 - tagfeed-md 是全集存储目录,不再区分整理状态目录。 收录规则(满足则**不**生成页面) -------------------------------- 1. 系统/噪音标签:纯数字、代码型(#ifdef #endif #all #setDefaults)、颜色值(#FF0000)等。 2. 用户指定三类自动排除:part0 开头 / index_ 开头 / 含 ThreadPoolF。 扫描范围(用户指定) -------------------- - 顶层黑名单 EXCLUDE_TOP 整棵子树不扫:X2.Archived / 00index / X1.Knomo / X.Attachment / P3.bobo - 嵌套黑名单 EXCLUDE_PATHS 整棵子树不扫:02DS/02copilot(Copilot 对话日志)、 02DS/01dril-book(读书笔记库);两者均「永不从其中取内容生成 tagfeed」。 - 仅扫描后缀 SCAN_EXT:.md .markdown .excalidraw .canvas;其它后缀一律跳过 - markdown 只抽标准 `tags:` frontmatter 与正文 #tag;topic/category/keywords/type 等 元数据字段绝不当作标签(避免 copilot 对话、读书笔记的元数据被误建页) 幂等 & 安全 ---------- 只读取 vault、只写入 00index/tagfeed-md/ 下的 .md;已存在的文件跳过不覆盖。 纯标准库(frontmatter 优先 PyYAML,无则内置轻量解析兜底)。 用法 ---- python3 sync_0inbox.py python3 sync_0inbox.py /path/to/vault """ import hashlib import json import os import re import sys try: import yaml # type: ignore _HAS_YAML = True except Exception: yaml = None _HAS_YAML = False # --------------------------------------------------------------------------- # frontmatter 解析 # --------------------------------------------------------------------------- def _simple_fm_parse(text: str): data = {} cur_key = None cur_list = None for line in text.splitlines(): if not line.strip() or line.strip().startswith("#"): continue m = re.match(r"^([A-Za-z0-9_\-]+):\s*(.*)$", line) if m: key = m.group(1) val = m.group(2).strip() if val.startswith("[") and val.endswith("]"): items = [x.strip().lstrip("#") for x in val[1:-1].split(",")] data[key] = items cur_key, cur_list = None, None elif val == "": cur_key, cur_list = key, [] data[key] = cur_list else: data[key] = val.lstrip("#") cur_key, cur_list = None, None elif re.match(r"^\s*-\s*(.*)$", line) and cur_list is not None: cur_list.append(re.match(r"^\s*-\s*(.*)$", line).group(1).strip().lstrip("#")) return data def parse_frontmatter(fm_text: str): if _HAS_YAML: try: return yaml.safe_load(fm_text) or {} except Exception: pass return _simple_fm_parse(fm_text) # --------------------------------------------------------------------------- # 过滤规则 # --------------------------------------------------------------------------- NOISE_EXACT = { "ifdef", "endif", "if", "else", "elif", "ifndef", "define", "undef", "include", "pragma", "all", "todo", "tag", "tags", "rrggbb", "ffffff", "000000", "ff0000", "00ff00", "0000ff", } NOISE_PREFIX = ("setdefaults", "set", "get", "is", "en", "ff", "rr") def tag_to_relpath(tag: str) -> str: return tag.replace("/", "_") def is_noise(tag: str) -> bool: low = tag.lower() if low in NOISE_EXACT: return True if low.startswith(NOISE_PREFIX): return True # 用户指定三类自动排除:part0 开头 / index_ 开头 / 含 ThreadPoolF if low.startswith("part0") or low.startswith("index_") or "threadpoolf" in low: return True if re.fullmatch(r"\d+([._]\d+)?", tag): return True if re.fullmatch(r"[0-9a-fA-F]{3}([0-9a-fA-F]{3})?", tag): return True return False # --------------------------------------------------------------------------- # 小工具 # --------------------------------------------------------------------------- def hid(s: str) -> str: return hashlib.md5(s.encode("utf-8")).hexdigest()[:16] def resolve_vault(explicit=None) -> str: if explicit: return os.path.normpath(explicit) here = os.path.dirname(os.path.abspath(__file__)) return os.path.normpath(os.path.join(here, "..", "..")) TAG_RE = re.compile(r"(? list: if v is None: return [] if isinstance(v, list): out = [] for item in v: out.extend(split_fm_value(item)) return out if isinstance(v, dict): return split_fm_value(" ".join(str(x) for x in v.values())) s = str(v).strip() if not s: return [] s = s.strip().strip('"').strip("'") parts = re.split(r"[,,;;]", s) out = [] for p in parts: for tok in p.split(): tok = tok.strip().strip('"').strip("'").lstrip("#") if tok: out.append(tok) return out def collect_tags_in_file(path: str) -> set: ext = os.path.splitext(path)[1].lower() tags = set() try: with open(path, encoding="utf-8", errors="ignore") as f: raw = f.read() except OSError: return tags if ext in (".md", ".markdown"): m = re.match(r"^---\s*\n(.*?)\n---\s*\n", raw, re.DOTALL) fm_text = "" body = raw if m: fm_text = m.group(1) body = raw[m.end():] try: fm = parse_frontmatter(fm_text) or {} except Exception: fm = {} if isinstance(fm, dict): for key in FM_KEYWORD_KEYS: if key in fm: for tok in split_fm_value(fm[key]): tok = tok.strip().lstrip("#") if tok: tags.add(tok) # 注意:不再遍历所有 frontmatter 值去抽取 #tag。 # 否则 topic/category/keywords 等元数据字段里出现的 # 文本 # 会被误判为标签,违背「这类字段不生成 tagfeed 页」的约定。 in_fence = False for line in body.splitlines(): if line.lstrip().startswith("```"): in_fence = not in_fence continue if in_fence: continue for mm in TAG_RE.finditer(line): tags.add(mm.group(1)) else: for mm in TAG_RE.finditer(raw): tags.add(mm.group(1)) return tags # --------------------------------------------------------------------------- # tagfeed 页面模板抽取(从 vault 现有 tagfeed-live 页面) # --------------------------------------------------------------------------- def extract_tagfeed_template(vault: str) -> str: agg_dir = os.path.join(vault, "00index", "tagfeed-md") seed = None if os.path.isdir(agg_dir): for root, dirs, files in os.walk(agg_dir): dirs[:] = [d for d in dirs if not d.startswith(".")] for fn in files: if fn.startswith(".") or not fn.endswith(".md"): continue fp = os.path.join(root, fn) try: txt = open(fp, encoding="utf-8", errors="ignore").read() except OSError: continue if "tagfeed-live" in txt and "targetTag" in txt: seed = txt break if seed: break if not seed: return ( "---\n" "tags:\n - tagfeed-live\n" "cssclasses:\n - tagfeed\n" "---\n\n" "# 🏷 #__TARGET_TAG__(实时聚合页)\n\n" "> 本页由 DataviewJS 实时计算,笔记有变动时自动刷新。\n\n" "```dataviewjs\n" "const targetTag = \"__TARGET_TAG__\";\n" "const excludedPaths = [\"X2.Archived\", \"X0.Clippings\", \"X1.Knomo\", \"00index/tagfeed-md\", \"02DS/02copilot\", \"02DS/01dril-book\", \".agents\", \".claude\", \".copilot\", \".opencode\", \".smart-env\", \".workbuddy\", \".trash\"];\n" "dv.paragraph(\"#\" + targetTag + \" 聚合占位\");\n" "```\n" ) fmt = re.match(r"^---\s*\n(.*?)\n---\s*\n", seed, re.DOTALL) fm = fmt.group(0) if fmt else ( "---\n" "tags:\n - tagfeed-live\n" "cssclasses:\n - tagfeed\n" "---\n\n" ) dm = re.search(r"```dataviewjs\n(.*?)\n```", seed, re.DOTALL) dv = dm.group(1) if dm else "dv.paragraph(\"#\" + targetTag);" dv = re.sub(r'const\s+targetTag\s*=\s*(?:"[^"]*"|\[[^\]]*\])', 'const targetTag = "__TARGET_TAG__"', dv, count=1) title = "# 🏷 #__TARGET_TAG__(实时聚合页)\n\n" note = "> 本页由 DataviewJS 实时计算,笔记有变动时自动刷新,无需任何后台服务。\n\n" return fm + title + note + "```dataviewjs\n" + dv + "\n```\n" def write_tagfeed_pages(vault: str, tags: list, template: str) -> tuple: """为每个待生成 tag 在 00index/tagfeed-md/ 下生成 tagfeed 页面。 返回 (created, skipped);已存在同名文件则跳过不覆盖。""" inbox = os.path.join(vault, "00index", "tagfeed-md") os.makedirs(inbox, exist_ok=True) created = skipped = 0 for tag in tags: rel = tag_to_relpath(tag) fp = os.path.join(inbox, rel + ".md") if os.path.exists(fp): skipped += 1 continue content = template.replace("__TARGET_TAG__", tag) with open(fp, "w", encoding="utf-8") as f: f.write(content) created += 1 return created, skipped # --------------------------------------------------------------------------- # 主流程 # --------------------------------------------------------------------------- def main() -> None: vault = resolve_vault(sys.argv[1] if len(sys.argv) > 1 else None) # 全 vault 扫描 #tag all_tags = {} for root, dirs, files in os.walk(vault): dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS and not d.startswith(".")] rel = os.path.relpath(root, vault) top = rel.split(os.sep)[0] if top in EXCLUDE_TOP: dirs[:] = [] continue # 嵌套路径排除(如 02DS/02copilot) if any(rel == p or rel.startswith(p + os.sep) for p in EXCLUDE_PATHS): dirs[:] = [] continue for fn in files: if fn.startswith("."): continue ext = os.path.splitext(fn)[1].lower() if ext not in SCAN_EXT: continue full = os.path.join(root, fn) for tg in collect_tags_in_file(full): if is_noise(tg): continue all_tags[tg] = all_tags.get(tg, 0) + 1 # 待生成 = 有使用、但还未在 tagfeed-md 中落盘的 tag pending = list(all_tags.keys()) pending.sort(key=lambda t: (-all_tags[t], t.lower())) # 生成 tagfeed-md 页面 template = extract_tagfeed_template(vault) created, skipped = write_tagfeed_pages(vault, pending, template) print("已同步 tagfeed-md 页面: %s" % os.path.join(vault, "00index", "tagfeed-md")) print("vault 实际 #tag(去噪)=%d 待生成(tagfeed-md)=%d" % (len(all_tags), len(pending))) print("tagfeed-md 页面: 新建=%d 已存在跳过=%d" % (created, skipped)) if __name__ == "__main__": main()