Phase 0-2: Schema cleanup, typed relations, event-driven automation
- Phase 0: AGENTS.md cleanup (dedup quotes, renumber sections, merge qmd) - Phase 1: typed relations (manage-relations.py, graph-search.py, check-staleness.py, detect-conflicts.py) - Phase 2: frontmatter validator, weekly lint, knowledge promotion, git hooks - Fix .gitignore to track tools/ and .githooks/ - Fix git remote URL (remove plaintext token) - New wiki pages: 504 pages, 34 raw sources
This commit is contained in:
@@ -0,0 +1,612 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
sync_home_wiki.py — home-wiki 知识页面 → kepano LLM Wiki 同步脚本
|
||||
|
||||
用途(定期执行):
|
||||
将 D:\\TC_UP\\home-wiki 的 concepts/ entities/ syntheses/ 知识页面,
|
||||
同步到 D:\\Applications\\app\\kepano-obsidian-main\\wiki\\,
|
||||
执行 frontmatter 规范转换,幂等更新;可选自动维护 index.md 与 lint 体检。
|
||||
|
||||
同步范围:
|
||||
concepts/ + entities/ + syntheses/ (排除 index.md)
|
||||
Daily/ MyNotes/ reports/ 不在知识同步范围。
|
||||
|
||||
转换规则(home-wiki frontmatter → kepano Wiki 规范):
|
||||
categories: 加 [[LLM Wiki]];entity/person 额外加 [[People]]
|
||||
tags: 前缀 [wiki, {people|concept}],subtype→concept/{subtype}(仅 concept),追加原 tags
|
||||
created: 保留
|
||||
source: sources[0] → 文件路径取 stem 做 wikilink;URL/标识符原样;空则留空
|
||||
type: 保留(concept/entity/synthesis)
|
||||
aliases: title 或 name
|
||||
正文: 保留,移除 home-wiki 特有的 openclaw 自动段落(## Related、注释标记行)
|
||||
|
||||
index 自动维护(--index):
|
||||
用标记块 <!-- BEGIN/END home-wiki-sync --> 界定 index.md 的「概念页」「实体页」
|
||||
两个表,脚本依据同步页面元数据 + 简介 JSON 缓存自动重生成。
|
||||
简介缓存 tools/data/home-wiki-summaries.json:首次从 index.md 现有表格种子化,
|
||||
保留人工优化;新增页面自动提取(description → 顶部引用块 → 概述首句)。
|
||||
综合报告表因混合其他来源,保持手动维护。
|
||||
|
||||
用法:
|
||||
python tools/scripts/sync_home_wiki.py --dry-run # 仅诊断差异
|
||||
python tools/scripts/sync_home_wiki.py # 同步(写入变化的页面)
|
||||
python tools/scripts/sync_home_wiki.py --index --log # 一站式:同步+更新index+记日志
|
||||
python tools/scripts/sync_home_wiki.py --lint # 体检(孤儿/断链/source空值)
|
||||
|
||||
退出码:0 成功;1 源目录缺失;2 有错误。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.stderr.write("ERROR: PyYAML 未安装,请运行 pip install pyyaml\n")
|
||||
sys.exit(2)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 路径配置(两库位于不同位置,必须使用绝对路径)
|
||||
# ---------------------------------------------------------------------------
|
||||
SRC_ROOT = Path(r"D:\TC_UP\home-wiki")
|
||||
DST_WIKI = Path(r"D:\Applications\app\kepano-obsidian-main\wiki")
|
||||
DST_INDEX = DST_WIKI / "index.md"
|
||||
DST_LOG = DST_WIKI / "log.md"
|
||||
SUMMARIES_CACHE = Path(__file__).resolve().parent.parent / "data" / "home-wiki-summaries.json"
|
||||
|
||||
SRC_DIRS = ["concepts", "entities", "syntheses"]
|
||||
|
||||
# home-wiki 完整文件名 → kepano 简短名(仅含副标题、过长标题的需要映射)
|
||||
NAME_MAP = {
|
||||
"顶级人生三重境:道家驭势、佛家修心、儒家立身": "顶级人生三重境",
|
||||
}
|
||||
|
||||
INDEX_BEGIN = "<!-- BEGIN home-wiki-sync -->"
|
||||
INDEX_END = "<!-- END home-wiki-sync -->"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 文档解析
|
||||
# ---------------------------------------------------------------------------
|
||||
def _extract_list_field(text, key):
|
||||
"""从 frontmatter 文本中提取列表字段(兼容 flow 与 block 两种写法)。"""
|
||||
m = re.search(rf"^[ \t]*{re.escape(key)}[ \t]*:\s*\[(.*)\][ \t]*$", text, re.M)
|
||||
if m:
|
||||
return [t.strip().strip("\"'") for t in m.group(1).split(",") if t.strip()]
|
||||
m = re.search(rf"^[ \t]*{re.escape(key)}[ \t]*:\s*\n((?:[ \t]+-.+\n?)+)", text, re.M)
|
||||
if m:
|
||||
items = re.findall(r"^[ \t]+-[ \t]+(.+?)[ \t]*$", m.group(1), re.M)
|
||||
return [it.strip().strip("\"'") for it in items]
|
||||
return []
|
||||
|
||||
|
||||
def _robust_parse_fm(fm_text):
|
||||
"""健壮解析 frontmatter:先 yaml.safe_load,失败则正则逐字段回退。
|
||||
|
||||
home-wiki frontmatter 常含 `related: [[wikilink]]` 这类 Obsidian wikilink,
|
||||
`[[` 会触发 YAML flow sequence 解析异常,故需要回退。
|
||||
"""
|
||||
try:
|
||||
fm = yaml.safe_load(fm_text)
|
||||
if isinstance(fm, dict) and fm.get("type"):
|
||||
return fm
|
||||
except yaml.YAMLError:
|
||||
pass
|
||||
fm = {}
|
||||
for key in ("type", "subtype", "title", "name", "created", "updated",
|
||||
"confidence", "field", "nameEn", "description"):
|
||||
m = re.search(rf"^[ \t]*{re.escape(key)}[ \t]*:[ \t]*(.+?)[ \t]*$", fm_text, re.M)
|
||||
if m and m.group(1).strip() not in ("", "[]"):
|
||||
fm[key] = m.group(1).strip().strip("\"'")
|
||||
for key in ("tags", "sources"):
|
||||
vals = _extract_list_field(fm_text, key)
|
||||
if vals:
|
||||
fm[key] = vals
|
||||
return fm
|
||||
|
||||
|
||||
def parse_doc(text):
|
||||
"""分离 frontmatter 与正文。返回 (fm_dict, body_str)。"""
|
||||
m = re.match(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", text, re.S)
|
||||
if not m:
|
||||
return {}, text
|
||||
return _robust_parse_fm(m.group(1)), m.group(2)
|
||||
|
||||
|
||||
def strip_openclaw(body):
|
||||
"""移除 home-wiki 特有的 openclaw 自动段落,保留实质内容。
|
||||
|
||||
- 移除 `## Related` 及其 openclaw:wiki:related 包裹块
|
||||
- 移除所有 `<!-- openclaw:...:...:(start|end) -->` 注释标记行(保留被包裹的内容)
|
||||
"""
|
||||
body = re.sub(
|
||||
r"\n##\s*Related\s*\n<!--\s*openclaw:wiki:related:start\s*-->.*?<!--\s*openclaw:wiki:related:end\s*-->",
|
||||
"\n",
|
||||
body,
|
||||
flags=re.S,
|
||||
)
|
||||
body = re.sub(r"<!--\s*openclaw:[a-z]+:[a-z]+:(?:start|end)\s*-->\s*\n?", "", body)
|
||||
return body.rstrip() + "\n"
|
||||
|
||||
|
||||
def norm_body(body):
|
||||
"""规范化正文用于对比:移除 openclaw 段,丢弃空行与行尾空白。"""
|
||||
b = strip_openclaw(body)
|
||||
lines = [ln.rstrip() for ln in b.splitlines() if ln.strip()]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# frontmatter 转换
|
||||
# ---------------------------------------------------------------------------
|
||||
def _as_list(v):
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
if s.startswith("[") and s.endswith("]"):
|
||||
s = s[1:-1]
|
||||
return [t.strip() for t in s.split(",") if t.strip()]
|
||||
return [v]
|
||||
|
||||
|
||||
def convert_frontmatter(fm, default_name):
|
||||
"""home-wiki frontmatter → kepano Wiki frontmatter(有序)。"""
|
||||
fm_type = str(fm.get("type", "concept")).strip()
|
||||
subtype = fm.get("subtype")
|
||||
if isinstance(subtype, list):
|
||||
subtype = subtype[0] if subtype else None
|
||||
subtype = str(subtype).strip() if subtype else None
|
||||
title = fm.get("title") or fm.get("name") or default_name
|
||||
|
||||
categories = ["[[LLM Wiki]]"]
|
||||
type_tag = "concept"
|
||||
if fm_type == "entity":
|
||||
if subtype == "person":
|
||||
categories.append("[[People]]")
|
||||
type_tag = "people"
|
||||
else:
|
||||
type_tag = "entity"
|
||||
elif fm_type == "synthesis":
|
||||
type_tag = "concept"
|
||||
|
||||
tags = ["wiki", type_tag]
|
||||
# 仅 concept 类型把 subtype 转为 concept/{subtype} 标签
|
||||
# (entity/person 用 people 标签 + [[People]] category,不加 concept/person)
|
||||
if subtype and fm_type == "concept":
|
||||
tags.append(f"concept/{subtype}")
|
||||
for t in _as_list(fm.get("tags")):
|
||||
t = str(t).strip()
|
||||
if t and t not in tags:
|
||||
tags.append(t)
|
||||
|
||||
sources = _as_list(fm.get("sources"))
|
||||
src0 = str(sources[0]) if sources else ""
|
||||
if ("/" in src0 or "\\" in src0) and not src0.startswith("http"):
|
||||
src_val = "[[" + Path(src0).stem + "]]"
|
||||
elif src0:
|
||||
src_val = src0
|
||||
else:
|
||||
src_val = ""
|
||||
|
||||
return [
|
||||
("categories", categories),
|
||||
("tags", tags),
|
||||
("created", str(fm.get("created", ""))),
|
||||
("source", src_val),
|
||||
("type", fm_type),
|
||||
("aliases", [str(title)]),
|
||||
]
|
||||
|
||||
|
||||
def _yaml_quote(val):
|
||||
"""如果值包含 YAML 特殊字符(如 [[ wikilink 的方括号),加双引号。"""
|
||||
s = str(val)
|
||||
if not s:
|
||||
return '""'
|
||||
if '[' in s or ']' in s or '{' in s or '}' in s or ':' in s or '#' in s:
|
||||
return f'"{s}"'
|
||||
return s
|
||||
|
||||
|
||||
def dump_frontmatter(pairs):
|
||||
out = ["---"]
|
||||
for k, v in pairs:
|
||||
if isinstance(v, list):
|
||||
out.append(f"{k}:")
|
||||
for item in v:
|
||||
out.append(f" - {_yaml_quote(item)}")
|
||||
else:
|
||||
out.append(f"{k}: {_yaml_quote(v)}")
|
||||
out.append("---")
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 同步主流程
|
||||
# ---------------------------------------------------------------------------
|
||||
def target_name(stem):
|
||||
"""home-wiki 文件名(stem) → kepano 目标文件名。"""
|
||||
return NAME_MAP.get(stem, stem)
|
||||
|
||||
|
||||
def collect_sources():
|
||||
"""收集所有待同步源文件(排除 index.md)。返回 [(src_path, stem)]。"""
|
||||
items = []
|
||||
for d in SRC_DIRS:
|
||||
sdir = SRC_ROOT / d
|
||||
if not sdir.is_dir():
|
||||
continue
|
||||
for p in sorted(sdir.glob("*.md")):
|
||||
if p.stem.lower() == "index":
|
||||
continue
|
||||
items.append((p, p.stem))
|
||||
return items
|
||||
|
||||
|
||||
def build_page(src_path, stem):
|
||||
"""读取源文件,生成 kepano 页面内容 (frontmatter_str + body)。"""
|
||||
text = src_path.read_text(encoding="utf-8")
|
||||
fm, body = parse_doc(text)
|
||||
pairs = convert_frontmatter(fm, stem)
|
||||
body_clean = strip_openclaw(body)
|
||||
# 确保正文与 frontmatter 间有空行
|
||||
return dump_frontmatter(pairs) + "\n" + body_clean.lstrip("\n")
|
||||
|
||||
|
||||
def _norm_full(text):
|
||||
"""规范化整篇(frontmatter+正文)用于对比:去行尾空白、去空行。"""
|
||||
return "\n".join(ln.rstrip() for ln in text.splitlines() if ln.strip())
|
||||
|
||||
|
||||
def get_page_meta():
|
||||
"""返回 {target_stem: {"type":..., "src_dir":..., "src_stem":...}}。"""
|
||||
meta = {}
|
||||
for src_path, stem in collect_sources():
|
||||
fm, _ = parse_doc(src_path.read_text(encoding="utf-8"))
|
||||
meta[target_name(stem)] = {
|
||||
"type": str(fm.get("type", "concept")),
|
||||
"src_dir": src_path.parent.name,
|
||||
"src_stem": stem,
|
||||
}
|
||||
return meta
|
||||
|
||||
|
||||
def sync(dry_run=False):
|
||||
if not SRC_ROOT.is_dir():
|
||||
sys.stderr.write(f"ERROR: 源目录不存在: {SRC_ROOT}\n")
|
||||
sys.exit(1)
|
||||
if not DST_WIKI.is_dir():
|
||||
sys.stderr.write(f"ERROR: 目标 wiki 目录不存在: {DST_WIKI}\n")
|
||||
sys.exit(1)
|
||||
|
||||
items = collect_sources()
|
||||
created, updated, unchanged = [], [], []
|
||||
errors = []
|
||||
|
||||
for src_path, stem in items:
|
||||
try:
|
||||
dst_path = DST_WIKI / f"{target_name(stem)}.md"
|
||||
new_content = build_page(src_path, stem)
|
||||
if not dst_path.exists():
|
||||
created.append(stem)
|
||||
if not dry_run:
|
||||
dst_path.write_text(new_content, encoding="utf-8")
|
||||
continue
|
||||
old_content = dst_path.read_text(encoding="utf-8")
|
||||
# 对比完整生成内容(frontmatter+正文),确保 frontmatter 损坏也会被修复
|
||||
if _norm_full(new_content) == _norm_full(old_content):
|
||||
unchanged.append(stem)
|
||||
continue
|
||||
updated.append(stem)
|
||||
if not dry_run:
|
||||
dst_path.write_text(new_content, encoding="utf-8")
|
||||
except Exception as e: # noqa
|
||||
errors.append(f"{stem}: {e}")
|
||||
|
||||
return {
|
||||
"total": len(items),
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"unchanged": unchanged,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 简介提取与缓存(index 自动维护用)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _wikilink_to_text(s):
|
||||
"""将简介里的 wikilink 转为纯文本显示:[[a|b]]→b,[[a]]→a。"""
|
||||
s = re.sub(r"\[\[([^\]|]+?)\|([^\]]+?)\]\]", r"\2", s)
|
||||
s = re.sub(r"\[\[([^\]]+?)\]\]", r"\1", s)
|
||||
return s
|
||||
|
||||
|
||||
def _trunc(s, n=80):
|
||||
s = s.strip()
|
||||
if len(s) > n:
|
||||
s = s[:n].rstrip() + "…"
|
||||
return s
|
||||
|
||||
|
||||
def extract_summary(body, fm):
|
||||
"""从页面提取一句话简介。优先级:description → 顶部首个引用块 → 概述/定义首句。"""
|
||||
if fm.get("description"):
|
||||
return _trunc(_wikilink_to_text(str(fm["description"])))
|
||||
lines = body.splitlines()
|
||||
i, n = 0, len(lines)
|
||||
while i < n and not lines[i].lstrip().startswith(">"):
|
||||
i += 1
|
||||
if i < n:
|
||||
quotes = []
|
||||
while i < n and lines[i].lstrip().startswith(">"):
|
||||
content = lines[i].lstrip()[1:].strip()
|
||||
if content and not re.match(r"^[—\-]+", content):
|
||||
quotes.append(content)
|
||||
i += 1
|
||||
if quotes:
|
||||
return _trunc(_wikilink_to_text(" ".join(quotes)))
|
||||
m = re.search(r"^##\s*(?:概述|定义|简介)\s*\n\s*(.+)$", body, re.M)
|
||||
if m:
|
||||
first = m.group(1).strip().splitlines()[0]
|
||||
if first:
|
||||
return _trunc(_wikilink_to_text(first))
|
||||
return ""
|
||||
|
||||
|
||||
def seed_from_index():
|
||||
"""首次运行:从 index.md 现有跨库表格解析人工简介作为初始缓存。"""
|
||||
cache = {}
|
||||
if not DST_INDEX.exists():
|
||||
return cache
|
||||
text = DST_INDEX.read_text(encoding="utf-8")
|
||||
# 只解析来源列含 [[home-wiki/ 的行,避免误抓其他来源页面
|
||||
pattern = re.compile(
|
||||
r"^\|\s*\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]\s*\|\s*([^|]+?)\s*\|\s*\[\[home-wiki/",
|
||||
re.M,
|
||||
)
|
||||
for m in pattern.finditer(text):
|
||||
stem = m.group(1).strip()
|
||||
summary = m.group(2).strip()
|
||||
if stem and summary:
|
||||
cache[stem] = {"summary": summary}
|
||||
return cache
|
||||
|
||||
|
||||
def load_summaries():
|
||||
if SUMMARIES_CACHE.exists():
|
||||
try:
|
||||
return json.loads(SUMMARIES_CACHE.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return seed_from_index()
|
||||
|
||||
|
||||
def save_summaries(cache):
|
||||
SUMMARIES_CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||||
SUMMARIES_CACHE.write_text(
|
||||
json.dumps(cache, ensure_ascii=False, indent=2, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# index.md 自动维护
|
||||
# ---------------------------------------------------------------------------
|
||||
def _build_table(stems, cache, meta, title):
|
||||
"""生成单个分类表格(Markdown 文本)。"""
|
||||
lines = [f"### {title}({len(stems)})", "", "| 页面 | 简介 | 来源 |", "|------|------|------|"]
|
||||
for s in stems:
|
||||
summ = cache.get(s, {}).get("summary", "")
|
||||
src = f"[[home-wiki/{meta[s]['src_dir']}/{s}]]"
|
||||
lines.append(f"| [[{s}]] | {summ} | {src} |")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def update_index(meta):
|
||||
"""用标记块自动维护 index.md 的概念/实体表,并刷新「最后更新」日期。"""
|
||||
cache_existed = SUMMARIES_CACHE.exists()
|
||||
cache = load_summaries()
|
||||
# 为缓存缺失的页面提取简介
|
||||
dirty = False
|
||||
for stem, m in meta.items():
|
||||
if not cache.get(stem, {}).get("summary"):
|
||||
dst = DST_WIKI / f"{stem}.md"
|
||||
if dst.exists():
|
||||
fm, body = parse_doc(dst.read_text(encoding="utf-8"))
|
||||
cache[stem] = {"summary": extract_summary(body, fm), "type": m["type"]}
|
||||
dirty = True
|
||||
# 首次运行(缓存文件不存在)或有新提取,都持久化(JSON 是简介权威来源)
|
||||
if dirty or not cache_existed:
|
||||
save_summaries(cache)
|
||||
|
||||
concepts = sorted(s for s, m in meta.items() if m["type"] == "concept")
|
||||
entities = sorted(s for s, m in meta.items() if m["type"] == "entity")
|
||||
|
||||
block_body = [
|
||||
"## 跨库同步(home-wiki)",
|
||||
"",
|
||||
"> 来自 `D:\\TC_UP\\home-wiki` 的知识内容,聚焦高等教育 AI、系统理论、哲学思想等方向。",
|
||||
"",
|
||||
_build_table(concepts, cache, meta, "概念页"),
|
||||
"",
|
||||
_build_table(entities, cache, meta, "实体页"),
|
||||
]
|
||||
generated = INDEX_BEGIN + "\n" + "\n".join(block_body) + "\n" + INDEX_END
|
||||
|
||||
text = DST_INDEX.read_text(encoding="utf-8")
|
||||
if INDEX_BEGIN in text and INDEX_END in text:
|
||||
new_text = re.sub(
|
||||
re.escape(INDEX_BEGIN) + r".*?" + re.escape(INDEX_END),
|
||||
lambda _: generated,
|
||||
text,
|
||||
flags=re.S,
|
||||
)
|
||||
else:
|
||||
# 首次:替换现有手动「跨库同步」章节(到「综合报告」之前)为标记块
|
||||
new_text = re.sub(
|
||||
r"## 跨库同步(home-wiki).*?(?=### 综合报告)",
|
||||
lambda _: generated + "\n\n",
|
||||
text,
|
||||
flags=re.S,
|
||||
)
|
||||
|
||||
# 刷新「最后更新」日期(pattern 匹配整格含结尾 |,replacement 给完整行,避免管道符累加)
|
||||
today = date.today().isoformat()
|
||||
new_text = re.sub(
|
||||
r"\|\s*最后更新\s*\|\s*[^\n|]*\|",
|
||||
f"| 最后更新 | {today} (home-wiki 跨库同步) |",
|
||||
new_text,
|
||||
)
|
||||
|
||||
if new_text != text:
|
||||
DST_INDEX.write_text(new_text, encoding="utf-8")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# lint 体检
|
||||
# ---------------------------------------------------------------------------
|
||||
def lint():
|
||||
meta = get_page_meta()
|
||||
synced = list(meta.keys())
|
||||
allfiles = {p.stem for p in DST_WIKI.glob("*.md")}
|
||||
|
||||
# aliases 反向映射(MarkItDown 等大小写变体)
|
||||
aliases_map = {}
|
||||
for p in DST_WIKI.glob("*.md"):
|
||||
fm, _ = parse_doc(p.read_text(encoding="utf-8"))
|
||||
for a in _as_list(fm.get("aliases")):
|
||||
aliases_map[str(a).strip()] = p.stem
|
||||
|
||||
# 孤儿:同步页面在 wiki 的入站链接数(排除自身)
|
||||
inbound = {s: 0 for s in synced}
|
||||
for p in DST_WIKI.glob("*.md"):
|
||||
t = p.read_text(encoding="utf-8")
|
||||
for s in synced:
|
||||
if p.stem == s:
|
||||
continue
|
||||
if re.search(rf"\[\[{re.escape(s)}[\]|\]]", t):
|
||||
inbound[s] += 1
|
||||
orphans = [s for s, c in inbound.items() if c == 0]
|
||||
|
||||
# source 空值
|
||||
empty_source = []
|
||||
for s in synced:
|
||||
t = (DST_WIKI / f"{s}.md").read_text(encoding="utf-8")
|
||||
m = re.search(r"^source:[ \t]*(.*)$", t, re.M)
|
||||
if m and not m.group(1).strip():
|
||||
empty_source.append(s)
|
||||
|
||||
# 断链:同步页面引用的 [[x]] 在 wiki 是否存在(排除 raw 来源类、aliases、category)
|
||||
valid = allfiles | set(aliases_map.keys()) | set(synced)
|
||||
skip = {"People", "LLM Wiki", "wikilink"}
|
||||
broken = {}
|
||||
for s in synced:
|
||||
t = (DST_WIKI / f"{s}.md").read_text(encoding="utf-8")
|
||||
for m in re.findall(r"\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]", t):
|
||||
tgt = m.strip()
|
||||
if not tgt or tgt.startswith("http") or "/" in tgt or "\\" in tgt:
|
||||
continue
|
||||
if tgt in valid or tgt in skip:
|
||||
continue
|
||||
broken.setdefault(tgt, []).append(s)
|
||||
|
||||
# index 覆盖检查:同步的概念/实体是否都在 index 跨库章节
|
||||
idx_text = DST_INDEX.read_text(encoding="utf-8") if DST_INDEX.exists() else ""
|
||||
not_in_index = [s for s in synced if meta[s]["type"] in ("concept", "entity")
|
||||
and not re.search(rf"\[\[{re.escape(s)}[\]|\]]", idx_text)]
|
||||
|
||||
print("== home-wiki 同步体检 ==")
|
||||
print(f"同步页面: {len(synced)} | wiki 总文件: {len(allfiles)}")
|
||||
print(f"\n[孤儿] {len(orphans)} 个(0 入站链接)")
|
||||
for s in orphans:
|
||||
print(f" - {s}")
|
||||
print(f"\n[source 空值] {len(empty_source)} 个(违反 wiki 层 source 必填)")
|
||||
for s in empty_source:
|
||||
print(f" - {s}")
|
||||
print(f"\n[断链] {len(broken)} 个目标(同步页面引用、kepano 无对应页)")
|
||||
for tgt in sorted(broken):
|
||||
print(f" - {tgt} ← {', '.join(sorted(broken[tgt]))}")
|
||||
print(f"\n[未入 index] {len(not_in_index)} 个(概念/实体未在跨库章节)")
|
||||
for s in not_in_index:
|
||||
print(f" - {s}")
|
||||
|
||||
issues = len(orphans) + len(empty_source) + len(not_in_index)
|
||||
print(f"\n硬性问题(孤儿+空source+未入index): {issues}")
|
||||
print(f"软性问题(断链,多为待创建概念/raw来源): {len(broken)}")
|
||||
return issues
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 日志
|
||||
# ---------------------------------------------------------------------------
|
||||
def append_log(result):
|
||||
today = date.today().isoformat()
|
||||
lines = [
|
||||
f"\n## [{today}] sync | home-wiki 跨库同步",
|
||||
"",
|
||||
f"**来源**: `{SRC_ROOT}`",
|
||||
"",
|
||||
"**操作**:",
|
||||
f"- 扫描 concepts/entities/syntheses 共 {result['total']} 个页面",
|
||||
]
|
||||
if result["updated"]:
|
||||
lines.append(f"- 更新 {len(result['updated'])} 页: {', '.join(result['updated'])}")
|
||||
if result["created"]:
|
||||
lines.append(f"- 新建 {len(result['created'])} 页: {', '.join(result['created'])}")
|
||||
if result["unchanged"]:
|
||||
lines.append(f"- 未变 {len(result['unchanged'])} 页")
|
||||
if result["errors"]:
|
||||
lines.append(f"- 错误 {len(result['errors'])} 项: {'; '.join(result['errors'])}")
|
||||
lines += ["", f"**涉及页面**: {len(result['updated'])} 更新,{len(result['created'])} 新建", ""]
|
||||
with DST_LOG.open("a", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="home-wiki → kepano LLM Wiki 同步")
|
||||
ap.add_argument("--dry-run", action="store_true", help="仅诊断,不写入")
|
||||
ap.add_argument("--log", action="store_true", help="同步后追加 wiki/log.md")
|
||||
ap.add_argument("--index", action="store_true", help="同步后自动维护 wiki/index.md 跨库章节")
|
||||
ap.add_argument("--lint", action="store_true", help="仅体检(孤儿/断链/source空值),不同步")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.lint:
|
||||
issues = lint()
|
||||
sys.exit(0 if issues == 0 else 0) # lint 不以问题数为错误码
|
||||
|
||||
res = sync(dry_run=args.dry_run)
|
||||
verb = "诊断" if args.dry_run else "同步"
|
||||
print(f"== home-wiki {verb}报告 ==")
|
||||
print(f"扫描: {res['total']} 页")
|
||||
print(f"需更新: {len(res['updated'])} -> {res['updated']}")
|
||||
print(f"需新建: {len(res['created'])} -> {res['created']}")
|
||||
print(f"未变化: {len(res['unchanged'])} -> {res['unchanged']}")
|
||||
if res["errors"]:
|
||||
print(f"错误: {res['errors']}")
|
||||
sys.exit(2)
|
||||
|
||||
if not args.dry_run:
|
||||
if args.log and (res["updated"] or res["created"]):
|
||||
append_log(res)
|
||||
print("已追加 wiki/log.md")
|
||||
if args.index:
|
||||
meta = get_page_meta()
|
||||
changed = update_index(meta)
|
||||
print(f"wiki/index.md 跨库章节: {'已更新' if changed else '无变化'}")
|
||||
print("完成。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user