feat(vault): Phase 3 tools + batch citation/relation enrichment
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
enrich-citations.py — Phase 3 P0: 批量为零引用页面补溯源链接
|
||||
|
||||
策略:
|
||||
1. **来源节补全**:无 `## 来源` 节的页面,自动添加,包含指向 raw source 的 wikilink
|
||||
2. **首段引用注入**:若 raw source 文件存在,提取页面标题关键词,
|
||||
在 raw 文件中定位匹配行,注入首条 `[raw:source:行号]` 作为溯源种子
|
||||
3. **存根页标记**:body < 50 字的页面添加 `> [!warning] 存根页面` callout
|
||||
|
||||
安全措施:
|
||||
- 幂等:已有 `## 来源` 节或 `[raw:` 引用则跳过
|
||||
- --dry-run / --apply
|
||||
- 仅处理 wiki/*.md(排除 index/log/archive)
|
||||
|
||||
用法:
|
||||
python tools/scripts/enrich-citations.py --dry-run
|
||||
python tools/scripts/enrich-citations.py --apply
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None
|
||||
|
||||
VAULT = Path(r"D:\Applications\app\kepano-obsidian-main")
|
||||
WIKI = VAULT / "wiki"
|
||||
RAW = VAULT / "raw"
|
||||
EXCLUDE = {"index.md", "log.md"}
|
||||
|
||||
|
||||
def read_frontmatter(fp: Path) -> tuple[dict, str, str]:
|
||||
content = fp.read_text(encoding="utf-8")
|
||||
m = re.match(r"^---\n(.*?)\n---\n?(.*)", content, re.DOTALL)
|
||||
if not m:
|
||||
return {}, content, ""
|
||||
fm_text = m.group(1)
|
||||
body = m.group(2)
|
||||
if yaml:
|
||||
try:
|
||||
front = yaml.safe_load(fm_text) or {}
|
||||
except Exception:
|
||||
front = {}
|
||||
else:
|
||||
front = {}
|
||||
return front, body, fm_text
|
||||
|
||||
|
||||
def get_source_filename(front: dict) -> str:
|
||||
"""Extract raw filename from source field"""
|
||||
source = front.get("source", "")
|
||||
if not source:
|
||||
return ""
|
||||
s = str(source).strip().strip('"').strip("'")
|
||||
s = re.sub(r"\[\[(?:raw/)?(.+?)(?:\|.*)?\]\]", r"\1", s)
|
||||
s = re.sub(r"\.md$", "", s)
|
||||
return s
|
||||
|
||||
|
||||
def find_raw_file(source_name: str) -> Path | None:
|
||||
"""Try to locate the raw file"""
|
||||
candidates = [
|
||||
RAW / f"{source_name}.md",
|
||||
RAW / source_name,
|
||||
]
|
||||
# Also search subdirectories
|
||||
for pattern in [f"**/{source_name}.md", f"**/{source_name}/index.md"]:
|
||||
matches = list(RAW.glob(pattern))
|
||||
if matches:
|
||||
candidates.append(matches[0])
|
||||
for c in candidates:
|
||||
if c.exists() and c.is_file():
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def find_keyword_line(raw_path: Path, page_name: str) -> str:
|
||||
"""Find a line in raw file matching the page name or key keyword"""
|
||||
try:
|
||||
raw_text = raw_path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return ""
|
||||
lines = raw_text.splitlines()
|
||||
|
||||
# Strategy: search for page name, then aliases
|
||||
keywords = [page_name]
|
||||
# Also try shorter keywords
|
||||
if len(page_name) > 4:
|
||||
keywords.append(page_name[:4])
|
||||
|
||||
for kw in keywords:
|
||||
for i, line in enumerate(lines, 1):
|
||||
if kw in line and len(line.strip()) > 5:
|
||||
return f"{i}"
|
||||
return ""
|
||||
|
||||
|
||||
def has_source_section(body: str) -> bool:
|
||||
return bool(re.search(r"^##\s*来源", body, re.MULTILINE))
|
||||
|
||||
|
||||
def has_raw_citations(body: str) -> bool:
|
||||
return "[raw:" in body
|
||||
|
||||
|
||||
def is_stub(body: str) -> bool:
|
||||
clean = re.sub(r"!\[\[.*?\]\]", "", body)
|
||||
clean = re.sub(r"```.*?```", "", clean, flags=re.DOTALL)
|
||||
clean = re.sub(r"\|.*?\|", "", clean)
|
||||
clean = re.sub(r"\s+", "", clean)
|
||||
return len(clean) < 150
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Enrich wiki pages with source citations")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview without writing")
|
||||
parser.add_argument("--apply", action="store_true", help="Write changes to files")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.dry_run and not args.apply:
|
||||
args.dry_run = True
|
||||
|
||||
stats = {"source_section": 0, "citation_seed": 0, "stub_flag": 0, "skip_has_citation": 0, "skip_no_source": 0}
|
||||
|
||||
for fp in sorted(WIKI.glob("*.md")):
|
||||
if fp.name in EXCLUDE:
|
||||
continue
|
||||
front, body, fm_text = read_frontmatter(fp)
|
||||
page_name = fp.stem
|
||||
|
||||
if has_raw_citations(body):
|
||||
stats["skip_has_citation"] += 1
|
||||
continue
|
||||
|
||||
source_name = get_source_filename(front)
|
||||
raw_path = find_raw_file(source_name) if source_name else None
|
||||
|
||||
modified = False
|
||||
additions = []
|
||||
|
||||
# 1. Add citation seed if raw file exists
|
||||
if raw_path:
|
||||
line_num = find_keyword_line(raw_path, page_name)
|
||||
if line_num:
|
||||
# Find first paragraph in body and append citation
|
||||
first_para_match = re.search(r"^(.+?)(?:\n\n|\n#)", body, re.DOTALL)
|
||||
if first_para_match:
|
||||
insert_point = first_para_match.end()
|
||||
cite = f"[raw:{source_name}:{line_num}]"
|
||||
if cite not in body:
|
||||
body = body[:insert_point] + f" {cite}" + body[insert_point:]
|
||||
modified = True
|
||||
stats["citation_seed"] += 1
|
||||
additions.append(f"citation seed [raw:{source_name}:{line_num}]")
|
||||
|
||||
# 2. Add/fix source section
|
||||
if not has_source_section(body):
|
||||
source_link = front.get("source", "")
|
||||
if source_link:
|
||||
source_section = f"\n\n## 来源\n\n> **溯源规则**:所有数字/百分比/具体结论必须标注 `[raw:{{文件名}}:{{行号}}]` 格式。\n\n- {source_link}"
|
||||
body = body.rstrip() + source_section
|
||||
modified = True
|
||||
stats["source_section"] += 1
|
||||
additions.append("source section")
|
||||
|
||||
# 3. Flag stub pages
|
||||
if is_stub(body) and not args.dry_run:
|
||||
if "> [!warning] 存根页面" not in body:
|
||||
stub_callout = "\n\n> [!warning] 存根页面\n> 本页内容不足,待扩充。参考同类黄金标准页面(如 [[DSpark]]、[[何长工]])补充来源引用和正文内容。\n"
|
||||
body = body.rstrip() + stub_callout
|
||||
modified = True
|
||||
stats["stub_flag"] += 1
|
||||
additions.append("stub callout")
|
||||
|
||||
if not modified:
|
||||
if not source_name:
|
||||
stats["skip_no_source"] += 1
|
||||
continue
|
||||
|
||||
if args.dry_run:
|
||||
print(f" [[{page_name}]]: {', '.join(additions)}")
|
||||
continue
|
||||
|
||||
if args.apply:
|
||||
# Reconstruct file
|
||||
if yaml:
|
||||
new_yaml = yaml.dump(front, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
||||
else:
|
||||
new_yaml = fm_text
|
||||
fp.write_text(f"---\n{new_yaml}---\n{body}", encoding="utf-8")
|
||||
|
||||
mode = "DRY RUN" if args.dry_run else "APPLIED"
|
||||
print(f"\n=== {mode} ===")
|
||||
print(f" Citation seeds added: {stats['citation_seed']}")
|
||||
print(f" Source sections added: {stats['source_section']}")
|
||||
print(f" Stub flags added: {stats['stub_flag']}")
|
||||
print(f" Skipped (has citation): {stats['skip_has_citation']}")
|
||||
print(f" Skipped (no source): {stats['skip_no_source']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user