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
279 lines
8.5 KiB
Python
279 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
import sys
|
|
import io
|
|
import re
|
|
import datetime
|
|
from pathlib import Path
|
|
from typing import Dict, List, Set, Tuple
|
|
from collections import defaultdict
|
|
|
|
if sys.platform == "win32":
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
|
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
|
|
|
|
|
|
class WikiLinkAnalyzer:
|
|
def __init__(self, wiki_dir: Path):
|
|
self.wiki_dir = wiki_dir
|
|
self.outgoing_links: Dict[str, Set[str]] = defaultdict(set)
|
|
self.incoming_links: Dict[str, Set[str]] = defaultdict(set)
|
|
self.all_files: Set[str] = set()
|
|
self.files_with_content: Dict[str, int] = {}
|
|
|
|
def extract_wikilinks(self, content: str) -> List[str]:
|
|
pattern = r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]"
|
|
return re.findall(pattern, content)
|
|
|
|
def normalize_link(self, link: str) -> str:
|
|
link = link.strip()
|
|
link = link.replace(" ", "-")
|
|
return link
|
|
|
|
def analyze_file(self, file_path: Path) -> Tuple[Set[str], int]:
|
|
try:
|
|
content = file_path.read_text(encoding="utf-8")
|
|
links = self.extract_wikilinks(content)
|
|
normalized_links = {self.normalize_link(l) for l in links}
|
|
return normalized_links, len(content)
|
|
except Exception as e:
|
|
return set(), 0
|
|
|
|
def analyze_all(self):
|
|
md_files = list(self.wiki_dir.glob("*.md"))
|
|
print(f"分析 {len(md_files)} 个Wiki文件...")
|
|
|
|
for md_file in md_files:
|
|
filename = md_file.stem
|
|
self.all_files.add(filename)
|
|
|
|
outgoing, content_length = self.analyze_file(md_file)
|
|
self.outgoing_links[filename] = outgoing
|
|
self.files_with_content[filename] = content_length
|
|
|
|
for link in outgoing:
|
|
self.incoming_links[link].add(filename)
|
|
|
|
def find_orphaned_files(self) -> List[str]:
|
|
orphaned = []
|
|
for filename in self.all_files:
|
|
if (
|
|
filename not in self.incoming_links
|
|
or len(self.incoming_links[filename]) == 0
|
|
):
|
|
orphaned.append(filename)
|
|
return orphaned
|
|
|
|
def find_dangling_links(self) -> Dict[str, List[str]]:
|
|
dangling = {}
|
|
for filename, links in self.outgoing_links.items():
|
|
broken = []
|
|
for link in links:
|
|
if link not in self.all_files:
|
|
broken.append(link)
|
|
if broken:
|
|
dangling[filename] = broken
|
|
return dangling
|
|
|
|
def calculate_link_stats(self) -> Dict:
|
|
stats = {
|
|
"total_files": len(self.all_files),
|
|
"files_with_outgoing": 0,
|
|
"files_with_incoming": 0,
|
|
"total_outgoing_links": 0,
|
|
"total_incoming_links": 0,
|
|
"avg_outgoing_per_file": 0,
|
|
"avg_incoming_per_file": 0,
|
|
}
|
|
|
|
for links in self.outgoing_links.values():
|
|
stats["total_outgoing_links"] += len(links)
|
|
for links in self.incoming_links.values():
|
|
stats["total_incoming_links"] += len(links)
|
|
|
|
stats["files_with_outgoing"] = sum(1 for l in self.outgoing_links.values() if l)
|
|
stats["files_with_incoming"] = sum(1 for l in self.incoming_links.values() if l)
|
|
|
|
if stats["total_files"] > 0:
|
|
stats["avg_outgoing_per_file"] = (
|
|
stats["total_outgoing_links"] / stats["total_files"]
|
|
)
|
|
stats["avg_incoming_per_file"] = (
|
|
stats["total_incoming_links"] / stats["total_files"]
|
|
)
|
|
|
|
return stats
|
|
|
|
def get_top_linked_files(self, limit: int = 20) -> List[Tuple[str, int]]:
|
|
files = [(f, len(links)) for f, links in self.incoming_links.items()]
|
|
files.sort(key=lambda x: x[1], reverse=True)
|
|
return files[:limit]
|
|
|
|
def generate_report(self) -> str:
|
|
stats = self.calculate_link_stats()
|
|
orphaned = self.find_orphaned_files()
|
|
dangling = self.find_dangling_links()
|
|
top_linked = self.get_top_linked_files()
|
|
|
|
report = f"""# Wiki双链分析报告
|
|
|
|
## 总体统计
|
|
|
|
| 指标 | 值 |
|
|
|------|-----|
|
|
| 总文件数 | {stats["total_files"]} |
|
|
| 有外链的文件 | {stats["files_with_outgoing"]} ({stats["files_with_outgoing"] / stats["total_files"] * 100:.1f}%) |
|
|
| 有入链的文件 | {stats["files_with_incoming"]} ({stats["files_with_incoming"] / stats["total_files"] * 100:.1f}%) |
|
|
| 外链总数 | {stats["total_outgoing_links"]} |
|
|
| 入链总数 | {stats["total_incoming_links"]} |
|
|
| 平均每文件外链 | {stats["avg_outgoing_per_file"]:.1f} |
|
|
| 平均每文件入链 | {stats["avg_incoming_per_file"]:.1f} |
|
|
|
|
## 孤立文件(无入链)
|
|
|
|
共 {len(orphaned)} 个文件没有任何页面引用:
|
|
|
|
"""
|
|
|
|
for f in sorted(orphaned):
|
|
content_size = self.files_with_content.get(f, 0)
|
|
outgoing = len(self.outgoing_links.get(f, []))
|
|
report += f"- [[{f}]] ({content_size}字, {outgoing}个外链)\n"
|
|
|
|
report += f"""
|
|
|
|
## 悬空链接(指向不存在的页面)
|
|
|
|
"""
|
|
|
|
if not dangling:
|
|
report += "无悬空链接 ✅\n"
|
|
else:
|
|
for filename, links in sorted(dangling.items()):
|
|
report += f"**{filename}**: {', '.join(links)}\n"
|
|
|
|
report += f"""
|
|
|
|
## 高链接页面(入链最多)
|
|
|
|
| 页面 | 入链数 |
|
|
|------|--------|
|
|
"""
|
|
|
|
for filename, count in top_linked:
|
|
report += f"| [[{filename}]] | {count} |\n"
|
|
|
|
report += f"""
|
|
|
|
## 双向链接分析
|
|
|
|
"""
|
|
|
|
bidirectional = 0
|
|
one_way = 0
|
|
no_links = 0
|
|
|
|
for filename in self.all_files:
|
|
outgoing = self.outgoing_links.get(filename, set())
|
|
incoming = self.incoming_links.get(filename, set())
|
|
|
|
common = outgoing & incoming
|
|
if common:
|
|
bidirectional += 1
|
|
elif outgoing or incoming:
|
|
one_way += 1
|
|
else:
|
|
no_links += 1
|
|
|
|
report += f"""| 类型 | 数量 | 占比 |
|
|
|------|------|------|
|
|
| 双向链接 | {bidirectional} | {bidirectional / stats["total_files"] * 100:.1f}% |
|
|
| 单向链接 | {one_way} | {one_way / stats["total_files"] * 100:.1f}% |
|
|
| 无链接 | {no_links} | {no_links / stats["total_files"] * 100:.1f}% |
|
|
|
|
## 改进建议
|
|
|
|
### 1. 消除孤立文件
|
|
- 为 {len(orphaned)} 个孤立文件添加相关引用
|
|
- 优先处理内容丰富的孤立文件
|
|
|
|
### 2. 修复悬空链接
|
|
"""
|
|
|
|
if dangling:
|
|
total_dangling = sum(len(links) for links in dangling.values())
|
|
report += f"- 修复 {total_dangling} 个悬空链接\n"
|
|
else:
|
|
report += "- 无需修复 ✅\n"
|
|
|
|
report += """
|
|
### 3. 建立双向链接
|
|
- 增加页面间的相互引用
|
|
- 建议每个概念页面至少有2-3个双向链接
|
|
|
|
### 4. 优化链接结构
|
|
- 入链最多的页面应该是核心概念页面
|
|
- 确保知识网络连通
|
|
|
|
---
|
|
|
|
*报告生成时间: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*
|
|
"""
|
|
|
|
return report
|
|
|
|
def save_report(self, output_path: Path):
|
|
report = self.generate_report()
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
f.write(report)
|
|
print(f"✅ 分析报告已保存: {output_path}")
|
|
|
|
|
|
def main():
|
|
print("=" * 60)
|
|
print("Wiki双链深度分析工具")
|
|
print("=" * 60)
|
|
|
|
script_dir = Path(__file__).parent
|
|
project_root = script_dir.parent.parent.parent
|
|
wiki_dir = project_root / "wiki"
|
|
|
|
print(f"项目根目录: {project_root}")
|
|
print(f"Wiki目录: {wiki_dir}")
|
|
|
|
if not wiki_dir.exists():
|
|
print(f"❌ Wiki目录不存在: {wiki_dir}")
|
|
return
|
|
|
|
output_path = (
|
|
project_root
|
|
/ "raw"
|
|
/ "教育AI研究"
|
|
/ "outputs"
|
|
/ f"wiki_link_analysis_{datetime.date.today()}.md"
|
|
)
|
|
|
|
analyzer = WikiLinkAnalyzer(wiki_dir)
|
|
analyzer.analyze_all()
|
|
analyzer.save_report(output_path)
|
|
|
|
stats = analyzer.calculate_link_stats()
|
|
orphaned = len(analyzer.find_orphaned_files())
|
|
dangling = len(analyzer.find_dangling_links())
|
|
|
|
print()
|
|
print("=" * 60)
|
|
print("分析完成")
|
|
print("=" * 60)
|
|
print(f"总文件数: {stats['total_files']}")
|
|
print(f"平均每文件外链: {stats['avg_outgoing_per_file']:.1f}")
|
|
print(f"平均每文件入链: {stats['avg_incoming_per_file']:.1f}")
|
|
print(f"孤立文件数: {orphaned}")
|
|
print(f"悬空链接数: {dangling}")
|
|
print("=" * 60)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|