a6f05ab2d5
- 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
119 lines
3.5 KiB
Python
119 lines
3.5 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
check-staleness.py — 检测 wiki 页面的时效性
|
||
|
||
检查每页的 last_reviewed 字段是否超过 review_interval_days,
|
||
输出过时页面列表和从未审查的页面列表。
|
||
|
||
用法:
|
||
python tools/scripts/check-staleness.py # 标准输出
|
||
python tools/scripts/check-staleness.py --json # JSON 格式
|
||
python tools/scripts/check-staleness.py --overdue-only # 仅过时页面
|
||
"""
|
||
import argparse
|
||
import json
|
||
import re
|
||
import sys
|
||
from datetime import date
|
||
from pathlib import Path
|
||
|
||
try:
|
||
import yaml
|
||
except ImportError:
|
||
yaml = None
|
||
|
||
WIKI = Path(r"D:\Applications\app\kepano-obsidian-main\wiki")
|
||
TODAY = date.today()
|
||
DEFAULT_INTERVAL = 180
|
||
|
||
|
||
def check_page(fp: Path) -> dict:
|
||
"""检查单页,返回结果 dict"""
|
||
result = {"page": fp.stem, "stale": False, "overdue_days": 0, "reason": ""}
|
||
content = fp.read_text(encoding="utf-8")
|
||
m = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
||
if not m:
|
||
result["reason"] = "no_frontmatter"
|
||
return result
|
||
if not yaml:
|
||
result["reason"] = "no_pyyaml"
|
||
return result
|
||
try:
|
||
front = yaml.safe_load(m.group(1)) or {}
|
||
except:
|
||
result["reason"] = "yaml_parse_error"
|
||
return result
|
||
|
||
last_str = front.get("last_reviewed", "")
|
||
interval = front.get("review_interval_days", DEFAULT_INTERVAL)
|
||
|
||
if not last_str:
|
||
result["reason"] = "never_reviewed"
|
||
result["stale"] = True
|
||
return result
|
||
|
||
try:
|
||
last = date.fromisoformat(str(last_str))
|
||
diff = (TODAY - last).days
|
||
if diff > interval:
|
||
result["stale"] = True
|
||
result["overdue_days"] = diff - interval
|
||
result["reason"] = f"overdue_by_{diff - interval}_days"
|
||
except ValueError:
|
||
result["reason"] = f"invalid_date_{last_str}"
|
||
result["stale"] = True
|
||
|
||
return result
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Check wiki page staleness")
|
||
parser.add_argument("--json", action="store_true", help="Output JSON")
|
||
parser.add_argument("--overdue-only", action="store_true", help="Only stale pages")
|
||
args = parser.parse_args()
|
||
|
||
all_pages = sorted(WIKI.glob("*.md"))
|
||
results = []
|
||
for fp in all_pages:
|
||
if fp.name in ("index.md", "log.md"):
|
||
continue
|
||
results.append(check_page(fp))
|
||
|
||
stale = [r for r in results if r["stale"]]
|
||
never = [r for r in stale if r["reason"] == "never_reviewed"]
|
||
overdue = [r for r in stale if r["reason"].startswith("overdue")]
|
||
|
||
if args.json:
|
||
output = {
|
||
"total": len(results),
|
||
"stale": len(stale),
|
||
"never_reviewed": len(never),
|
||
"overdue": len(overdue),
|
||
"pages": stale if args.overdue_only else results
|
||
}
|
||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||
return
|
||
|
||
print(f"=== Staleness Check ({TODAY}) ===")
|
||
print(f" Total: {len(results)} pages")
|
||
print(f" Stale: {len(stale)}")
|
||
print(f" Never reviewed: {len(never)}")
|
||
print(f" Overdue: {len(overdue)}")
|
||
|
||
if overdue:
|
||
print(f"\n Overdue pages (top 20):")
|
||
for r in sorted(overdue, key=lambda x: -x["overdue_days"])[:20]:
|
||
print(f" [[{r['page']}]] — overdue by {r['overdue_days']} days")
|
||
|
||
if never:
|
||
print(f"\n Never reviewed (top 20):")
|
||
for r in never[:20]:
|
||
print(f" [[{r['page']}]]")
|
||
if len(never) > 20:
|
||
print(f" ... and {len(never)-20} more")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|