acmer-coach 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.md +258 -0
- package/assets/extension/luogu/README.md +104 -0
- package/assets/extension/luogu/content.js +137 -0
- package/assets/extension/luogu/manifest.json +22 -0
- package/assets/knowledge/DEPENDS.yaml +391 -0
- package/assets/knowledge/LICENSE +41 -0
- package/assets/knowledge/MAP.yaml +2540 -0
- package/assets/knowledge/NODE_ENTRY.yaml +465 -0
- package/assets/knowledge/NODE_META.yaml +457 -0
- package/assets/knowledge/skilltree.html +1702 -0
- package/assets/oiwiki/LICENSE +49 -0
- package/assets/oiwiki/chunks.jsonl +2177 -0
- package/assets/oiwiki/node-pages.json +1208 -0
- package/assets/pool/LICENSE +83 -0
- package/assets/pool/problems.jsonl.gz +0 -0
- package/assets/rules/coach-rules.md +425 -0
- package/build-node-meta.mjs +98 -0
- package/coach/analyze_map.py +138 -0
- package/coach/build_map.py +230 -0
- package/coach/build_node_pages.py +105 -0
- package/coach/check_page_syntax.mjs +48 -0
- package/coach/coach-metrics.py +313 -0
- package/coach/export_oiwiki_chunks.py +100 -0
- package/coach/export_problem_pool.py +64 -0
- package/coach/extract_oiwiki.py +110 -0
- package/coach/oiwiki_tree.json +2987 -0
- package/coach/render_map.py +226 -0
- package/coach/render_skilltree.py +197 -0
- package/coach/serve_map.py +181 -0
- package/coach/skilltree_template.html +1702 -0
- package/coach/validate_map.py +184 -0
- package/coach/verify_drag.mjs +51 -0
- package/coach/verify_interact.mjs +82 -0
- package/coach/verify_serve.py +138 -0
- package/cordis.patch.yml +10 -0
- package/dev/parity-check.mjs +169 -0
- package/dev/run-all.sh +70 -0
- package/dev/setup-check.mjs +191 -0
- package/dev/w1-check.mjs +170 -0
- package/dev/wiki-check.mjs +100 -0
- package/dev.sh +142 -0
- package/docs//350/256/276/350/256/241/346/226/271/351/222/210.md +325 -0
- package/index.js +5820 -0
- package/lib/client.js +209 -0
- package/lib/setup.js +455 -0
- package/package.json +33 -0
- package/progress-cli.mjs +67 -0
- package/verify-shape.mjs +315 -0
- package/verify.mjs +3535 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
analyze_map.py — 看地图的拓扑形状:环 / 深度 / 最长链 / 未铺进度
|
|
4
|
+
|
|
5
|
+
跟 validate_map.py 的分工:
|
|
6
|
+
validate_map.py 管「结构合不合法」(环、孤儿边、重复 id、引用)
|
|
7
|
+
analyze_map.py 管「形状对不对」(深度分布、最长链、铺了多少)
|
|
8
|
+
|
|
9
|
+
用法: python analyze_map.py
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from collections import Counter, defaultdict
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
for _s in (sys.stdout, sys.stderr):
|
|
18
|
+
if _s and hasattr(_s, "reconfigure"):
|
|
19
|
+
_s.reconfigure(encoding="utf-8", errors="replace")
|
|
20
|
+
|
|
21
|
+
MAP = os.path.join(os.path.expanduser("~"), ".dsh", "knowledge", "MAP.yaml")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main():
|
|
25
|
+
doc = yaml.safe_load(open(MAP, encoding="utf-8"))
|
|
26
|
+
nodes = doc["nodes"]
|
|
27
|
+
name_of = {n["id"]: n["name"] for n in nodes}
|
|
28
|
+
dom = {n["id"]: n["domain"] for n in nodes}
|
|
29
|
+
|
|
30
|
+
# 只认已铺的边:depends 为 None = 还没铺
|
|
31
|
+
dep = {}
|
|
32
|
+
for n in nodes:
|
|
33
|
+
if n.get("depends") is None:
|
|
34
|
+
dep[n["id"]] = None # 未铺
|
|
35
|
+
else:
|
|
36
|
+
dep[n["id"]] = [d for d in n["depends"] if d in name_of]
|
|
37
|
+
|
|
38
|
+
covered = [k for k, v in dep.items() if v is not None]
|
|
39
|
+
pending = [k for k, v in dep.items() if v is None]
|
|
40
|
+
|
|
41
|
+
print("=" * 62)
|
|
42
|
+
print(f"节点 {len(nodes)} 已铺前置 {len(covered)} 未铺 {len(pending)}")
|
|
43
|
+
print("=" * 62)
|
|
44
|
+
|
|
45
|
+
# ── 环检测(未铺的节点不参与)──
|
|
46
|
+
WHITE, GREY, BLACK = 0, 1, 2
|
|
47
|
+
color = defaultdict(int)
|
|
48
|
+
cycles = []
|
|
49
|
+
|
|
50
|
+
def dfs(u, path):
|
|
51
|
+
color[u] = GREY
|
|
52
|
+
for v in dep.get(u) or []:
|
|
53
|
+
if dep.get(v) is None:
|
|
54
|
+
continue
|
|
55
|
+
if color[v] == GREY:
|
|
56
|
+
i = path.index(v)
|
|
57
|
+
cycles.append(path[i:] + [v])
|
|
58
|
+
elif color[v] == WHITE:
|
|
59
|
+
dfs(v, path + [v])
|
|
60
|
+
color[u] = BLACK
|
|
61
|
+
|
|
62
|
+
for u in covered:
|
|
63
|
+
if color[u] == WHITE and dep.get(u) is not None:
|
|
64
|
+
dfs(u, [u])
|
|
65
|
+
|
|
66
|
+
if cycles:
|
|
67
|
+
print(f"❌ 发现 {len(cycles)} 个环:")
|
|
68
|
+
for c in cycles[:5]:
|
|
69
|
+
print(" " + " → ".join(name_of.get(x, x) for x in c))
|
|
70
|
+
return 1
|
|
71
|
+
print("✅ 无环\n")
|
|
72
|
+
|
|
73
|
+
# ── 最长路分层 ──
|
|
74
|
+
# 关键:祖先链上只要还有一个「未铺」的节点,这个节点的深度就是**未知**,
|
|
75
|
+
# 不是 0。把"不知道"当成"根"会让整张图看起来比实际浅得多 —— 前面吃过这个亏。
|
|
76
|
+
memo = {}
|
|
77
|
+
_stack = set()
|
|
78
|
+
|
|
79
|
+
def depth(u):
|
|
80
|
+
"""层深;祖先链上有未铺节点 → None(未知)。"""
|
|
81
|
+
if u in memo:
|
|
82
|
+
return memo[u]
|
|
83
|
+
if u in _stack: # 防御:理论上无环,已在上游校验
|
|
84
|
+
return 0
|
|
85
|
+
ps = dep.get(u) or []
|
|
86
|
+
if any(dep.get(p) is None for p in ps):
|
|
87
|
+
memo[u] = None
|
|
88
|
+
return None
|
|
89
|
+
_stack.add(u)
|
|
90
|
+
ds = [depth(p) for p in ps]
|
|
91
|
+
_stack.discard(u)
|
|
92
|
+
if any(d is None for d in ds):
|
|
93
|
+
memo[u] = None
|
|
94
|
+
else:
|
|
95
|
+
memo[u] = 0 if not ds else max(ds) + 1
|
|
96
|
+
return memo[u]
|
|
97
|
+
|
|
98
|
+
for u in covered:
|
|
99
|
+
depth(u)
|
|
100
|
+
|
|
101
|
+
known = {u: depth(u) for u in covered if depth(u) is not None}
|
|
102
|
+
unknown = sorted(set(covered) - set(known))
|
|
103
|
+
lv = Counter(known.values())
|
|
104
|
+
print("=== 拓扑深度(只统计祖先链全部已铺的节点)===")
|
|
105
|
+
mx = max(lv) if lv else 0
|
|
106
|
+
for k in range(mx + 1):
|
|
107
|
+
n = lv.get(k, 0)
|
|
108
|
+
print(f" L{k:<2} {n:>3} 个 {'█' * min(n, 55)}")
|
|
109
|
+
print(f"\n 最大深度 {mx} 层(目标 8-12)")
|
|
110
|
+
print(f" 深度可算的 {len(known)} 个;深度**未知**(祖先链上有未铺){len(unknown)} 个")
|
|
111
|
+
|
|
112
|
+
# ── 最深的三条链 ──
|
|
113
|
+
def chain(u):
|
|
114
|
+
ps = dep.get(u) or []
|
|
115
|
+
if not ps:
|
|
116
|
+
return [u]
|
|
117
|
+
return chain(max(ps, key=lambda p: known.get(p, -1))) + [u]
|
|
118
|
+
|
|
119
|
+
print("\n=== 最深的三条链 ===")
|
|
120
|
+
for u in sorted(known, key=known.get, reverse=True)[:3]:
|
|
121
|
+
ch = chain(u)
|
|
122
|
+
print(f" ({len(ch) - 1} 层) " + " → ".join(name_of.get(x, x) for x in ch))
|
|
123
|
+
|
|
124
|
+
# ── 未铺进度 ──
|
|
125
|
+
if pending:
|
|
126
|
+
print(f"\n=== 未铺前置的 {len(pending)} 个(按学科)===")
|
|
127
|
+
for d, c in Counter(dom[k] for k in pending).most_common():
|
|
128
|
+
print(f" {d:<8} {c:>3}")
|
|
129
|
+
|
|
130
|
+
# ── 根节点(确认不需要前置的)──
|
|
131
|
+
roots = [k for k in covered if not dep[k]]
|
|
132
|
+
print(f"\n=== 确认的根节点 {len(roots)} 个 ===")
|
|
133
|
+
print(" " + " / ".join(sorted(name_of.get(r, r) for r in roots)))
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
sys.exit(main())
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
build_map.py — 用 OI-wiki 全量条目 + DEPENDS.yaml,生成 MAP.yaml
|
|
4
|
+
|
|
5
|
+
设计(第三版):
|
|
6
|
+
前两版都错在同一处:**把 OI-wiki 的 parent 当成了前置。**
|
|
7
|
+
|
|
8
|
+
parent 是「分类归属」(树链剖分和树分治放在"树上问题"下面一起讲),
|
|
9
|
+
requires 是「前置」(不会 DFS 就学不了树链剖分)。这两个关系在 OI-wiki
|
|
10
|
+
的目录里长得一模一样,但完全不是一回事:
|
|
11
|
+
- 「树上问题」是抽屉,不是知识点 —— 你不可能"掌握树上问题"
|
|
12
|
+
- 「李超线段树」和「线段树」在目录里同级,但后者是前者的前置
|
|
13
|
+
|
|
14
|
+
第二版因为「同域内的树形关系不写」,把同域内的真实前置全丢了
|
|
15
|
+
(素数→筛法→莫比乌斯反演 这些边从来没被记过),
|
|
16
|
+
351 个点里 230 个没有前置,拓扑深度只有 3 层。
|
|
17
|
+
|
|
18
|
+
这一版:
|
|
19
|
+
- 前置关系全部来自 knowledge/DEPENDS.yaml(数据,不是代码)
|
|
20
|
+
- **每个节点显式写全部直接前置**,不靠 parent 顶替
|
|
21
|
+
- parent 降级为纯分组字段,只用于显示,不参与任何依赖计算
|
|
22
|
+
|
|
23
|
+
每个节点留 `tier` 字段(core/normal/rare/skip)—— 本版**先留空**,
|
|
24
|
+
等第 2 遍叠权重/难度时一起标。先求全,再求挑。
|
|
25
|
+
|
|
26
|
+
用法: python build_map.py → 覆写 ~/.dsh/knowledge/MAP.yaml
|
|
27
|
+
"""
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
from collections import Counter
|
|
32
|
+
|
|
33
|
+
import yaml
|
|
34
|
+
|
|
35
|
+
for _s in (sys.stdout, sys.stderr):
|
|
36
|
+
if _s and hasattr(_s, "reconfigure"):
|
|
37
|
+
_s.reconfigure(encoding="utf-8", errors="replace")
|
|
38
|
+
|
|
39
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
40
|
+
SRC = os.path.join(HERE, "oiwiki_tree.json")
|
|
41
|
+
DEPS = os.path.join(os.path.expanduser("~"), ".dsh", "knowledge", "DEPENDS.yaml")
|
|
42
|
+
META = os.path.join(os.path.expanduser("~"), ".dsh", "knowledge", "NODE_META.yaml")
|
|
43
|
+
ENTRY = os.path.join(os.path.expanduser("~"), ".dsh", "knowledge", "NODE_ENTRY.yaml")
|
|
44
|
+
OUT = os.path.join(os.path.expanduser("~"), ".dsh", "knowledge", "MAP.yaml")
|
|
45
|
+
|
|
46
|
+
# 平行分支(不是依赖,是「选一条走即可」)
|
|
47
|
+
PARALLEL_HINTS = [
|
|
48
|
+
{"a": "树状数组", "b": "线段树", "note": "区间维护的两条独立路线,走通一条即可"},
|
|
49
|
+
{"a": "块状数据结构", "b": "线段树", "note": "分块是线段树的朴素退路,学不动时可先走分块"},
|
|
50
|
+
{"a": "Treap", "b": "Splay 树", "note": "平衡树任选一种写熟即可,另一种按需补"},
|
|
51
|
+
{"a": "后缀数组 (SA)", "b": "后缀自动机 (SAM)", "note": "字符串难题的两条路线,先走通一条"},
|
|
52
|
+
{"a": "单调队列/单调栈优化", "b": "线段树", "note": "都是优化 DP 转移的手段"},
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def slug(name):
|
|
57
|
+
"""id 直接用中文全名 —— 自解释、可读、不必维护映射表。"""
|
|
58
|
+
return name
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_requires():
|
|
62
|
+
"""读 DEPENDS.yaml → ({节点名: [直接前置]}, 文档头)。
|
|
63
|
+
|
|
64
|
+
返回的字典里**没有**的节点 = 前置还没铺(不是"根节点")。
|
|
65
|
+
这个区别很重要:没铺是"不知道",空列表是"确认不需要前置"。
|
|
66
|
+
"""
|
|
67
|
+
if not os.path.exists(DEPS):
|
|
68
|
+
print(f"❌ 找不到 {DEPS}")
|
|
69
|
+
return None, None
|
|
70
|
+
doc = yaml.safe_load(open(DEPS, encoding="utf-8")) or {}
|
|
71
|
+
return (doc.get("requires") or {}), (doc.get("meta") or {})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_meta():
|
|
75
|
+
"""读两份**标注**(都不是地图结构,是"这知识点重不重要 / 什么水平学得起")。
|
|
76
|
+
|
|
77
|
+
NODE_META.yaml 人维护 —— tier(core/normal/rare/skip)+ 偏离默认时的依据
|
|
78
|
+
NODE_ENTRY.yaml 机器算 —— entry(题池的 p25)+ pool/median,见 build-node-meta.mjs
|
|
79
|
+
|
|
80
|
+
两份分开是有意的:机器那份可以**全权重写**,人那份**谁都不许动**。
|
|
81
|
+
混在一个文件里,"脚本重跑"和"人改标注"就会互相冲掉 ——
|
|
82
|
+
跟 PROGRESS.yaml 的 yaml 库吃注释是同一个坑。
|
|
83
|
+
"""
|
|
84
|
+
tiers, entries = {}, {}
|
|
85
|
+
if os.path.exists(META):
|
|
86
|
+
doc = yaml.safe_load(open(META, encoding="utf-8")) or {}
|
|
87
|
+
tiers = doc.get("nodes") or {}
|
|
88
|
+
if os.path.exists(ENTRY):
|
|
89
|
+
doc = yaml.safe_load(open(ENTRY, encoding="utf-8")) or {}
|
|
90
|
+
entries = doc.get("nodes") or {}
|
|
91
|
+
return tiers, entries
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main():
|
|
95
|
+
items = json.load(open(SRC, encoding="utf-8"))
|
|
96
|
+
req, dep_meta = load_requires()
|
|
97
|
+
if req is None:
|
|
98
|
+
return 1
|
|
99
|
+
tiers, entries = load_meta()
|
|
100
|
+
|
|
101
|
+
# 同一 domain 内重名 → 用 path 前缀消歧
|
|
102
|
+
seen = Counter((i["domain"], i["name"]) for i in items)
|
|
103
|
+
dupes = {k for k, v in seen.items() if v > 1}
|
|
104
|
+
|
|
105
|
+
nodes = []
|
|
106
|
+
skipped = []
|
|
107
|
+
for it in items:
|
|
108
|
+
name = it["name"]
|
|
109
|
+
path = it["path"]
|
|
110
|
+
# OI-wiki 里有些页面既是分组名、又是分组里的条目(并查集 / 二叉搜索树 & 平衡树),
|
|
111
|
+
# 抽出来会变成 X 和 X/X 两个节点、指向同一个知识点 —— 丢掉自嵌套的那个,
|
|
112
|
+
# 否则图上会出现两个一模一样的点。
|
|
113
|
+
if len(path) == 2 and path[0] == path[1] == name:
|
|
114
|
+
skipped.append(name)
|
|
115
|
+
continue
|
|
116
|
+
parent = path[0] if len(path) > 1 else None
|
|
117
|
+
nid = name
|
|
118
|
+
if (it["domain"], name) in dupes:
|
|
119
|
+
nid = "/".join(path)
|
|
120
|
+
nodes.append({
|
|
121
|
+
"id": nid,
|
|
122
|
+
"name": name,
|
|
123
|
+
"domain": it["domain"],
|
|
124
|
+
"parent": parent, # 仅用于分组显示
|
|
125
|
+
"depends": req.get(name) if name in req else None, # None = 前置未铺
|
|
126
|
+
"tier": (tiers.get(name) or {}).get("tier"), # NODE_META.yaml
|
|
127
|
+
"entry": (entries.get(name) or {}).get("entry"), # NODE_ENTRY.yaml
|
|
128
|
+
"pool": (entries.get(name) or {}).get("pool"),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
ids = {n["id"] for n in nodes}
|
|
132
|
+
names = {n["name"] for n in nodes}
|
|
133
|
+
|
|
134
|
+
# 校验:DEPENDS.yaml 里引用的前置必须真实存在
|
|
135
|
+
bad = []
|
|
136
|
+
for n in nodes:
|
|
137
|
+
for d in n["depends"] or []:
|
|
138
|
+
if d not in names and d not in ids:
|
|
139
|
+
bad.append(f"{n['name']} → {d}(不存在)")
|
|
140
|
+
|
|
141
|
+
covered = sum(1 for n in nodes if n["depends"] is not None)
|
|
142
|
+
edge_count = sum(len(n["depends"] or []) for n in nodes)
|
|
143
|
+
|
|
144
|
+
# ── 输出 YAML(手写序列化,保证可读 + 稳定顺序)──
|
|
145
|
+
L = []
|
|
146
|
+
L.append("# ══════════════════════════════════════════════════════════════")
|
|
147
|
+
L.append("# MAP.yaml — 教练地图 v3(前置 = depends,分类 = parent)")
|
|
148
|
+
L.append("#")
|
|
149
|
+
L.append("# 生成方式:coach/build_map.py(OI-wiki 导航 351 条 + knowledge/DEPENDS.yaml)")
|
|
150
|
+
L.append("# ⚠️ 不要手改本文件 —— 改 DEPENDS.yaml 再重跑 build_map.py。")
|
|
151
|
+
L.append("#")
|
|
152
|
+
L.append("# 结构约定:")
|
|
153
|
+
L.append("# depends 前置。学这个之前必须先掌握的直接前置。空列表 = 确认的根节点,")
|
|
154
|
+
L.append("# null = 还没铺(**不是根,是不知道**,别把它排到最前面)。")
|
|
155
|
+
L.append("# parent 分类归属,**只用于分组显示,不是前置**。")
|
|
156
|
+
L.append("# 「树上问题」是抽屉不是知识点 —— 它不参与任何依赖计算。")
|
|
157
|
+
L.append("# tier 这个知识点重不重要:core/normal/rare/skip。")
|
|
158
|
+
L.append("# 来自 knowledge/NODE_META.yaml(人手维护),本文件只做合并。")
|
|
159
|
+
L.append("# entry 题池的 p25,≈「什么水平开始学得起」。**只有有题池的节点才有**。")
|
|
160
|
+
L.append("# 来自 knowledge/NODE_ENTRY.yaml(机器算,跑 build-node-meta.mjs)。")
|
|
161
|
+
L.append("# ══════════════════════════════════════════════════════════════")
|
|
162
|
+
L.append("")
|
|
163
|
+
L.append("meta:")
|
|
164
|
+
L.append(" version: 3")
|
|
165
|
+
L.append(" source: OI-wiki 导航(mkdocs.yml)")
|
|
166
|
+
L.append(f" node_count: {len(nodes)}")
|
|
167
|
+
L.append(f" edge_count: {edge_count}")
|
|
168
|
+
L.append(f" depends_covered: {covered}/{len(nodes)}")
|
|
169
|
+
L.append("")
|
|
170
|
+
L.append("nodes:")
|
|
171
|
+
|
|
172
|
+
cur_domain = None
|
|
173
|
+
for n in sorted(nodes, key=lambda x: (x["domain"], x["parent"] or "", x["name"])):
|
|
174
|
+
if n["domain"] != cur_domain:
|
|
175
|
+
cur_domain = n["domain"]
|
|
176
|
+
L.append(f" # ── {cur_domain} ──")
|
|
177
|
+
L.append(f" - id: {n['id']}")
|
|
178
|
+
L.append(f" name: {n['name']}")
|
|
179
|
+
L.append(f" domain: {n['domain']}")
|
|
180
|
+
if n["parent"]:
|
|
181
|
+
L.append(f" parent: {n['parent']}")
|
|
182
|
+
if n["depends"] is not None:
|
|
183
|
+
L.append(f" depends: [{', '.join(n['depends'])}]")
|
|
184
|
+
# tier / entry 是**标注**,不是地图结构 —— 来自 NODE_META.yaml 和
|
|
185
|
+
# NODE_ENTRY.yaml,本文件只负责合并。缺了就不写这一行(缺 = 没标 / 没数据)。
|
|
186
|
+
if n["tier"]:
|
|
187
|
+
L.append(f" tier: {n['tier']}")
|
|
188
|
+
if n["entry"]:
|
|
189
|
+
L.append(f" entry: {n['entry']} # 题池 p25(≈入门段位),机器算")
|
|
190
|
+
if n["pool"]:
|
|
191
|
+
L.append(f" pool: {n['pool']} # entry 是拿这么多道题算出来的")
|
|
192
|
+
L.append("")
|
|
193
|
+
|
|
194
|
+
L.append("parallel_branches:")
|
|
195
|
+
for p in PARALLEL_HINTS:
|
|
196
|
+
L.append(f" - a: {p['a']}")
|
|
197
|
+
L.append(f" b: {p['b']}")
|
|
198
|
+
L.append(f" note: {p['note']}")
|
|
199
|
+
L.append("")
|
|
200
|
+
|
|
201
|
+
with open(OUT, "w", encoding="utf-8") as f:
|
|
202
|
+
f.write("\n".join(L))
|
|
203
|
+
|
|
204
|
+
by_domain = Counter(n["domain"] for n in nodes)
|
|
205
|
+
print(f"生成完成 → {OUT}")
|
|
206
|
+
print(f" 节点 {len(nodes)} 前置边 {edge_count} 已铺前置 {covered}/{len(nodes)}")
|
|
207
|
+
# 标注覆盖率要报出来。静默漏标 = 教练拿到一个没有 tier 的节点、
|
|
208
|
+
# 悄悄按"没标注"处理,而人以为标全了 —— 又一种"盘上少了一块没人知道"。
|
|
209
|
+
no_tier = [n["name"] for n in nodes if not n["tier"]]
|
|
210
|
+
no_entry = [n["name"] for n in nodes if not n["entry"]]
|
|
211
|
+
print(f" 已标 tier {len(nodes) - len(no_tier)}/{len(nodes)}"
|
|
212
|
+
f" 有入门段位 {len(nodes) - len(no_entry)}/{len(nodes)}")
|
|
213
|
+
if no_tier:
|
|
214
|
+
head = " / ".join(no_tier[:8])
|
|
215
|
+
print(f" ⚠️ 没标 tier 的 {len(no_tier)} 个:{head}{' …' if len(no_tier) > 8 else ''}")
|
|
216
|
+
if skipped:
|
|
217
|
+
print(f" 丢弃自嵌套重复节点 {len(skipped)} 个:{' / '.join(skipped)}")
|
|
218
|
+
for k, v in by_domain.most_common():
|
|
219
|
+
print(f" {k:8s} {v:4d}")
|
|
220
|
+
if bad:
|
|
221
|
+
print(f"\n❌ {len(bad)} 个引用问题:")
|
|
222
|
+
for b in bad[:15]:
|
|
223
|
+
print(f" - {b}")
|
|
224
|
+
return 1
|
|
225
|
+
print("\n✅ 引用完整(DEPENDS.yaml 里的前置都能对上)")
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
if __name__ == "__main__":
|
|
230
|
+
sys.exit(main())
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""把「地图节点 → OI Wiki 页面」这张表建出来。
|
|
3
|
+
|
|
4
|
+
**为什么要它**:地图节点的名字就是从 OI Wiki 导航标题来的,所以
|
|
5
|
+
`节点名 → 文件路径` 是一张**精确表**,不是猜的。有了它,查资料就不用靠
|
|
6
|
+
模糊搜索去撞 —— 搜"线段树"可能撞出一堆相关页,查表只有一页,而且是对的。
|
|
7
|
+
|
|
8
|
+
**分类节点怎么办**:导航里有一层是纯分类(「网络流」底下挂着最大流 / 费用流…),
|
|
9
|
+
它自己没有页面。这类节点的内容就散在整棵子树里,所以映射到**后代的全部文件**。
|
|
10
|
+
|
|
11
|
+
⚠️ 两个踩过的坑,决定了这里必须**递归遍历原始树**,不能复用 flatten 的结果:
|
|
12
|
+
① 只走三层会漏 —— 「莫队算法」的 8 个子页面在**第四层**,一层都收不到;
|
|
13
|
+
② flatten 会按 SKIP_SUFFIX 滤掉「XX 简介」—— 那个过滤是给**地图节点**用的
|
|
14
|
+
(不该把"线段树简介"当成一个知识点),但**章节页面**不能滤:
|
|
15
|
+
「后缀数组 (SA)」的两个页面里有一个就叫「后缀数组简介」,滤掉就少收一页。
|
|
16
|
+
|
|
17
|
+
用法:
|
|
18
|
+
python build_node_pages.py <map.yaml> <mkdocs.yml> <输出 node-pages.json>
|
|
19
|
+
|
|
20
|
+
⚠️ 第二个参数要**原始的 mkdocs.yml**,不是 extract_oiwiki.py 拍平后的
|
|
21
|
+
`oiwiki_tree.json` —— 拍平那份没有 kids,拿它建子树并集会是空的。
|
|
22
|
+
解析器直接复用 extract_oiwiki.parse_nav,一份实现。
|
|
23
|
+
|
|
24
|
+
产物: {"节点名": ["相对路径.md", ...]} —— 路径相对 OI Wiki 的 docs/
|
|
25
|
+
"""
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
import yaml
|
|
31
|
+
|
|
32
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
33
|
+
from extract_oiwiki import parse_nav # noqa: E402 (同目录的解析器,别抄第二份)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def descendants(node, acc):
|
|
37
|
+
"""收集一棵子树的全部文件(任意深度,不过滤)。"""
|
|
38
|
+
for k in node.get("kids", []):
|
|
39
|
+
if k.get("file"):
|
|
40
|
+
acc.append(k["file"])
|
|
41
|
+
descendants(k, acc)
|
|
42
|
+
return acc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def walk(tree):
|
|
46
|
+
"""遍历原始导航树 → {name: [files]}。"""
|
|
47
|
+
by_name, by_section = {}, {}
|
|
48
|
+
|
|
49
|
+
def visit(node):
|
|
50
|
+
name = node.get("name")
|
|
51
|
+
if node.get("file"):
|
|
52
|
+
by_name.setdefault(name, node["file"])
|
|
53
|
+
sub = descendants(node, [])
|
|
54
|
+
if sub:
|
|
55
|
+
by_section.setdefault(name, sub)
|
|
56
|
+
for k in node.get("kids", []):
|
|
57
|
+
visit(k)
|
|
58
|
+
|
|
59
|
+
for d in tree:
|
|
60
|
+
visit(d)
|
|
61
|
+
return by_name, by_section
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def dedup(xs):
|
|
65
|
+
seen, acc = set(), []
|
|
66
|
+
for x in xs:
|
|
67
|
+
if x not in seen:
|
|
68
|
+
seen.add(x)
|
|
69
|
+
acc.append(x)
|
|
70
|
+
return acc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main():
|
|
74
|
+
if len(sys.argv) < 4:
|
|
75
|
+
print(__doc__)
|
|
76
|
+
return 1
|
|
77
|
+
map_path, nav_path, out_path = sys.argv[1:4]
|
|
78
|
+
|
|
79
|
+
tree = parse_nav(nav_path)
|
|
80
|
+
nodes = [n["id"] for n in yaml.safe_load(open(map_path, encoding="utf-8"))["nodes"]]
|
|
81
|
+
|
|
82
|
+
by_name, by_section = walk(tree)
|
|
83
|
+
|
|
84
|
+
out, missing = {}, []
|
|
85
|
+
for n in nodes:
|
|
86
|
+
if n in by_name:
|
|
87
|
+
out[n] = [by_name[n]] # 有自己页面的,精确到那一页
|
|
88
|
+
elif n in by_section:
|
|
89
|
+
out[n] = dedup(by_section[n]) # 分类节点,落到整棵子树的页面
|
|
90
|
+
else:
|
|
91
|
+
missing.append(n)
|
|
92
|
+
|
|
93
|
+
json.dump(out, open(out_path, "w", encoding="utf-8"),
|
|
94
|
+
ensure_ascii=False, indent=1)
|
|
95
|
+
|
|
96
|
+
multi = sum(1 for v in out.values() if len(v) > 1)
|
|
97
|
+
print(f"· 映射 {len(out)}/{len(nodes)} 个节点({multi} 个走子树并集)")
|
|
98
|
+
if missing:
|
|
99
|
+
print(f" ⚠️ 没有页面的 {len(missing)} 个: {missing[:12]}")
|
|
100
|
+
print(f" → {out_path}")
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
sys.exit(main())
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// 语法级体检:把 skilltree.html 里的内联 script 抽出来过一遍 V8 解析。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要它:改模板(CYCLE 数组、键盘映射、图例文字)时最容易的坏法是
|
|
4
|
+
// 把某处语句写坏 —— 那样的页面**照样 HTTP 200**,打开来是一片空白,
|
|
5
|
+
// 而 curl 看不出来。这里只做**解析**,不执行(不碰 DOM)。
|
|
6
|
+
//
|
|
7
|
+
// 用法: node check_page_syntax.mjs [要检查的 html]
|
|
8
|
+
// 不给参数就检查**模板本身**(coach/skilltree_template.html)。
|
|
9
|
+
// 要检查渲染产物就传路径,或用 COACH_DATA_DIR 指到数据目录。
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { join, dirname } from 'node:path'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
import vm from 'node:vm'
|
|
15
|
+
|
|
16
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
17
|
+
const FILE = process.argv[2]
|
|
18
|
+
|| join(process.env.COACH_DATA_DIR || join(homedir(), '.dsh', 'knowledge'), 'skilltree.html')
|
|
19
|
+
const html = readFileSync(FILE, 'utf8')
|
|
20
|
+
const blocks = [...html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)]
|
|
21
|
+
.map((m) => m[1]).filter((c) => c.trim())
|
|
22
|
+
|
|
23
|
+
console.log(`skilltree.html:${html.length} 字符,内联 script 块 ${blocks.length} 个`)
|
|
24
|
+
let bad = 0
|
|
25
|
+
blocks.forEach((code, i) => {
|
|
26
|
+
try {
|
|
27
|
+
new vm.Script(code, { filename: `inline-${i}.js` })
|
|
28
|
+
console.log(` 块 ${i}: 语法 OK(${code.length} 字符)`)
|
|
29
|
+
} catch (e) {
|
|
30
|
+
bad++
|
|
31
|
+
console.log(` 块 ${i}: ✗ ${e.message}`)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// 顺带把这次改动关心的三件事也钉一下(不执行页面,只查文本)
|
|
36
|
+
const asserts = [
|
|
37
|
+
['CYCLE 只有三档', /const CYCLE = \["none", "studying", "learned"\]/.test(html)],
|
|
38
|
+
['键盘映射是 123', /"123"\.indexOf\(e\.key\)/.test(html)],
|
|
39
|
+
['没有残留的 "1234"', !html.includes('"1234"')],
|
|
40
|
+
['图例不再说「→ 已验证」循环', !html.includes('未学 → 在学 → 学过 → 已验证')],
|
|
41
|
+
['verified 仍留在显示用的 LABEL / COLOR 里', html.includes('verified: "已验证"')],
|
|
42
|
+
]
|
|
43
|
+
console.log('')
|
|
44
|
+
for (const [label, ok] of asserts) {
|
|
45
|
+
console.log(` ${ok ? 'OK ' : 'FAIL'} ${label}`)
|
|
46
|
+
if (!ok) bad++
|
|
47
|
+
}
|
|
48
|
+
process.exit(bad ? 1 : 0)
|