pages_audit.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""页面归口审计 —— 把"这一页该不该随输入数据变 / 算不算产物"变成机器每天能查的事 (2026-09-17, 用户令 1)。
  4. ## 背景(用户的问题)
  5. http://127.0.0.1:28084/#findings #sim #documents 及它们包含的子页,
  6. "应否随着输入数据的变化而变化"、"是否也属于产物"、"如是产物应纳入产物管理"。
  7. 答案不是一句话能给的: 三页里既有**受管静态叙述**(方法论/架构), 也有**冻结交付件**(治理清单/报告),
  8. 还有**真·数据派生快照**(把 outputs 里的数据烘进页面) —— 后者才是"属于产物、必须纳入产物管理"的那批。
  9. 本模块把这份判断落成 `configs/portal_pages.yaml` 登记表 + 五条机器可查的规则, 于是:
  10. · static —— 正文不得引用产物(setting 变了就该改分类);
  11. · live —— iframe/链接指向的端口必须是 `configs/serve.json` 里的已知服务;
  12. · data-citing —— 引用的产物路径**必须存在**(否则是悬空引用, 挂羊头卖狗肉);
  13. · data-derived —— **必须有 source + (source_sha256 或 generated_at)**; 有 sha 的还要跟当前产物
  14. 逐字节比对 ⇒ **陈旧检测**(数据换了、页面没换 = 报出来);
  15. · frozen-delivery —— 文件名或正文里必须能读到版本号与日期(客户拿到手才知道是哪一版)。
  16. ## 为什么要有"陈旧检测"
  17. 页面把产物烘进去, 就**脱离**了"产物即时进页面"的机制(§5.1 的指纹热重载只对实时读产物的服务生效)。
  18. 数据重算后, 这种页面**不会自己变**; 没有检测, 它就一直挂着旧数字 —— 这正是用户担心的情形。
  19. ## 退出码 (给 rebuild_all / check 用)
  20. 0 全部一致 · 5 登记缺项/文件缺失/未登记页 · 6 溯源缺失 · 7 页面陈旧(数据已变, 页面没变) · 8 交付件缺版本号 · 9 分类错误
  21. ## 用法
  22. python scripts/pages_audit.py # 表 + 检查(默认)
  23. python scripts/pages_audit.py --list # 只打表
  24. python scripts/pages_audit.py --check # 只检查(安静模式, 只打结论)
  25. python scripts/pages_audit.py --write-doc # 把表写进 docs/系统设计说明.md (标记区间内)
  26. python scripts/pages_audit.py --json # 机器可读结果
  27. """
  28. from __future__ import annotations
  29. import argparse
  30. import fnmatch
  31. import glob as globmod
  32. import hashlib
  33. import html as htmllib
  34. import json
  35. import pathlib
  36. import re
  37. import sys
  38. ROOT = pathlib.Path(__file__).resolve().parents[1]
  39. sys.path.insert(0, str(ROOT))
  40. from src import paths as P # noqa: E402
  41. REG = P.CONFIGS / 'portal_pages.yaml'
  42. DOC = P.ROOT / 'docs' / '系统设计说明.md'
  43. def dt_from(ts: float | None) -> str:
  44. """mtime → 'YYYY-mm-dd HH:MM' (报告里给人看的时间)。"""
  45. if not ts:
  46. return '?'
  47. import datetime as _dt
  48. return _dt.datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
  49. DOC_BEGIN, DOC_END = '<!-- PAGES:BEGIN -->', '<!-- PAGES:END -->'
  50. RC_OK, RC_MISS, RC_PROV, RC_STALE, RC_VER, RC_KIND = 0, 5, 6, 7, 8, 9
  51. KINDS = ('static', 'live', 'data-citing', 'data-derived', 'frozen-delivery')
  52. RE_OUT = re.compile(r'outputs/[A-Za-z0-9_\u4e00-\u9fff\-./]+')
  53. # 版本号只认"像版本"的: v1.0 / V2.1 / 版本1.2 / 客户版|外发版|正式版|评审版。
  54. # ★别写成 \d{8} 之类 —— 那会把页面里的机组编号(00019164)、时间戳当成版本号(2026-09-17 实测踩到)。
  55. RE_VER = re.compile(r'[vV]\d+(?:\.\d+)+|版本\s*[vV]?\d+(?:\.\d+)*|客户版|外发版|正式版|评审版')
  56. RE_DATE = re.compile(r'20\d\d-\d\d-\d\d|20\d{6}')
  57. RE_GEN = re.compile(r'生成[^0-9]{0,8}(20\d\d-\d\d-\d\d[ T]?\d?\d?:?\d?\d?)')
  58. def load(path=None):
  59. import yaml
  60. reg_p = pathlib.Path(path) if path else REG
  61. if not reg_p.is_file():
  62. raise SystemExit(f'缺登记表 {P.rel(reg_p)}')
  63. reg = yaml.safe_load(reg_p.read_text(encoding='utf-8'))
  64. reg['_file'] = str(reg_p)
  65. return reg
  66. def sha256(p: pathlib.Path) -> str | None:
  67. try:
  68. return hashlib.sha256(p.read_bytes()).hexdigest()
  69. except OSError:
  70. return None
  71. def expand(entry) -> list[pathlib.Path]:
  72. """一个登记项的 `file` 字段 → 实际文件列表 (支持 * 通配)。"""
  73. pat = P.ROOT / entry['file']
  74. s = str(pat)
  75. if any(c in s for c in '*?['):
  76. return sorted(pathlib.Path(x) for x in globmod.glob(s, recursive=True))
  77. return [pat] if pat.is_file() else []
  78. def texts(files):
  79. for f in files:
  80. try:
  81. yield f, f.read_text(encoding='utf-8', errors='replace')
  82. except OSError:
  83. continue
  84. def read_embedded_json(path: pathlib.Path, var: str):
  85. """抓页面里内嵌的 window.<var> = {…} —— 模板里是 HTML 转义过的, 解析前先反转义。"""
  86. try:
  87. t = path.read_text(encoding='utf-8', errors='replace')
  88. except OSError:
  89. return None
  90. m = re.search(r'window\.' + re.escape(var) + r'\s*=\s*(\{)', t)
  91. if not m:
  92. return None
  93. raw, depth = '', 0
  94. for ch in t[m.start(1):]:
  95. raw += ch
  96. if ch == '{':
  97. depth += 1
  98. elif ch == '}':
  99. depth -= 1
  100. if depth == 0:
  101. break
  102. for cand in (raw, htmllib.unescape(raw)):
  103. try:
  104. return json.loads(cand)
  105. except Exception:
  106. continue
  107. return None
  108. def check_entry(e, results):
  109. """按 kind 查一个登记项 → 往 results 里加 (level, 项, 说明, 退出码)。
  110. level: OK 通过 / X 失败 / ! 要处理 / ? 只能人工看 / i 已知缺口(登记了 known_gap 并写了理由)。
  111. ★ known_gap 的存在是**如实**的产物: 交付包里确实没有那件产物(例如振动线六层链的 cleaned/gearbox_life),
  112. 页面里却引用了它。我们不改交付件正文(客户手里那一版是冻结的), 但必须把"引用悬空"这件事记在明处,
  113. 所以它报 `i` 而不是装作通过, 也不当成新问题反复报警。
  114. """
  115. eid = e.get('id') or e.get('name') or '?'
  116. kind = e.get('kind')
  117. gap = e.get('known_gap') or {}
  118. if kind not in KINDS:
  119. results.append(('X', eid, f'kind 非法: {kind} (允许 {"/".join(KINDS)})', RC_KIND))
  120. return
  121. files = expand(e)
  122. if not files and not e.get('members_in_zip'):
  123. results.append(('X', eid, f'登记的 file 不存在或没匹配到: {e.get("file")}', RC_MISS))
  124. return
  125. # ① 引用的产物必须在位 (data-citing / data-derived; 登记了 cites 的也查)
  126. for f, t in texts(files):
  127. cited = set(e.get('cites') or [])
  128. if kind in ('data-derived', 'data-citing'):
  129. cited |= set(RE_OUT.findall(t))
  130. for c in sorted(cited):
  131. if (P.ROOT / c).exists():
  132. continue
  133. if gap and c in (gap.get('missing') or []):
  134. results.append(('i', eid, f'引用悬空(已知): {c} —— {gap.get("why", "见登记表")}', RC_OK))
  135. else:
  136. results.append(('X', eid, f'{P.rel(f)} 引用的产物不在位: {c} (悬空引用)', RC_MISS))
  137. # ② 分类规则
  138. if kind == 'static':
  139. for f, t in texts(files):
  140. if RE_OUT.search(t):
  141. results.append(('!', eid, f'{P.rel(f)} 正文里出现产物路径 —— 可能该改成 data-citing/data-derived',
  142. RC_KIND))
  143. elif kind == 'live':
  144. svc = {}
  145. try:
  146. svc = json.loads(P.SERVE_JSON.read_text(encoding='utf-8-sig'))
  147. except Exception:
  148. pass
  149. ports = {int(v) for k, v in svc.items() if isinstance(v, int)}
  150. tgt = e.get('live_target') or ''
  151. m = re.search(r':(\d{2,5})', tgt)
  152. if m and ports and int(m.group(1)) not in ports:
  153. results.append(('X', eid, f'live_target 端口 {m.group(1)} 不在 configs/serve.json 的服务端口里', RC_MISS))
  154. elif kind == 'data-derived':
  155. src = e.get('source')
  156. var = e.get('embedded_json_var')
  157. skey, shkey = e.get('source_key'), e.get('source_sha_key')
  158. found_sha, found_src, found_gen = None, src, None
  159. if var and skey:
  160. for f in files:
  161. obj = read_embedded_json(f, var)
  162. if isinstance(obj, dict):
  163. found_src = obj.get(skey) or found_src
  164. found_sha = obj.get(shkey) if shkey else None
  165. break
  166. for f, t in texts(files):
  167. if found_gen is None:
  168. g = RE_GEN.search(t)
  169. if g:
  170. found_gen = g.group(1)
  171. if not found_src:
  172. if gap:
  173. results.append(('i', eid, f'溯源缺失(已知): {gap.get("why", "")}', RC_OK))
  174. else:
  175. results.append(('!', eid, 'data-derived 但没登记 source, 页面里也没有内嵌来源 ⇒ 溯源缺失', RC_PROV))
  176. if not (found_sha or found_gen or e.get('generated_at_key')):
  177. if e.get('generated_at_source') == 'mtime':
  178. # 页面件自己就是生成物: 用"它自己的 mtime"当生成时间, 与 source 的 mtime 比 ⇒ 可做陈旧代理判据
  179. src = e.get('source')
  180. mt = None
  181. for f in files:
  182. try:
  183. mt = f.stat().st_mtime
  184. break
  185. except OSError:
  186. continue
  187. sp = (P.ROOT / src) if isinstance(src, str) else None
  188. if mt and sp is not None and sp.is_file() and (sp.stat().st_mtime - mt) > 86400:
  189. results.append(('X', eid, f'**页面件疑似陈旧**: {P.rel(files[0])} 写于 '
  190. f'{dt_from(mt)}, 而它的源 {src} 更新于 {dt_from(sp.stat().st_mtime)} '
  191. f'⇒ 源换了、这份页面件没重生成', RC_STALE))
  192. else:
  193. results.append(('OK', eid, f'按 mtime 判: 页面件与源 {src or "(未登记)"} 的新旧关系正常 '
  194. f'(页面件 {dt_from(mt) if mt else "?"})', RC_OK))
  195. if not found_src:
  196. pass
  197. elif not gap:
  198. results.append(('!', eid, 'data-derived 但既无 source_sha256 也无生成时间 ⇒ 无法做陈旧检测', RC_PROV))
  199. if found_src:
  200. sp = P.ROOT / found_src
  201. if not sp.exists():
  202. if gap and found_src in (gap.get('missing') or []):
  203. results.append(('i', eid, f'内嵌来源不在位(已知): {found_src} —— {gap.get("why", "")}', RC_OK))
  204. else:
  205. results.append(('!', eid, f'内嵌来源 {found_src} 不在位 (页面里烘的是别处/历史数据)', RC_MISS))
  206. elif found_sha:
  207. cur = sha256(sp)
  208. if cur and cur.lower() != str(found_sha).lower():
  209. results.append(('X', eid,
  210. f'**页面陈旧**: 内嵌快照 source_sha256={str(found_sha)[:16]} 与当前 '
  211. f'{found_src} 的 sha256={cur[:16]} 不同 ⇒ 数据变了, 这份页面没跟着重生成',
  212. RC_STALE))
  213. else:
  214. results.append(('OK', eid, f'快照与当前产物一致 ({found_src} sha256 {str(found_sha)[:16]})', RC_OK))
  215. elif e.get('generated_at_source') != 'mtime':
  216. results.append(('?', eid, f'有来源 {found_src} 但无指纹, 只能按生成时间判断 '
  217. f'(记录: {found_gen or "无"})', RC_PROV))
  218. elif kind == 'frozen-delivery':
  219. ok_ver = ok_date = False
  220. for f in files:
  221. name = f.name
  222. ok_ver |= bool(RE_VER.search(name))
  223. ok_date |= bool(RE_DATE.search(name))
  224. if not (ok_ver and ok_date):
  225. t = f.read_text(encoding='utf-8', errors='replace') # 版本号/日期可能在正文深处
  226. ok_ver |= bool(RE_VER.search(t))
  227. ok_date |= bool(RE_DATE.search(t))
  228. if e.get('version'):
  229. ok_ver = True
  230. if not ok_ver:
  231. results.append(('!', eid, '冻结交付件但没有版本号 (文件名/正文都没有 v*/日期) ⇒ 客户拿到手分不清是哪一版',
  232. RC_VER))
  233. if not ok_date and not e.get('version'):
  234. results.append(('?', eid, '冻结交付件没有日期标记', RC_VER))
  235. # ③ 子项递归
  236. for c in e.get('children') or []:
  237. check_entry(c, results)
  238. def declared_patterns(reg) -> list[str]:
  239. """登记表里所有 file 字段 (含通配) —— 覆盖检查用。"""
  240. pats = []
  241. def walk(e):
  242. f = e.get('file')
  243. if f:
  244. pats.append(str(f))
  245. for c in e.get('children') or []:
  246. walk(c)
  247. for e in reg.get('pages') or []:
  248. walk(e)
  249. return pats
  250. def coverage_gaps(reg) -> list[str]:
  251. """登记表里没写、但门户里真实存在的内嵌页 —— 漏登记就等于没管。"""
  252. pats = declared_patterns(reg)
  253. gaps = []
  254. tdir = P.RELEASE / 'portal_src' / 'templates'
  255. for f in sorted(tdir.glob('*.html')):
  256. rel = f.relative_to(P.ROOT).as_posix()
  257. if not any(fnmatch.fnmatch(rel, p) or rel == p for p in pats):
  258. gaps.append(rel)
  259. return gaps
  260. def render(reg, results) -> str:
  261. lines = ['| 页面/子页 | kind | 随输入数据变? | 依据(为什么这么判) |', '|---|---|---|---|']
  262. def walk(e, depth=0):
  263. eid = e.get('id') or e.get('name') or '?'
  264. title = e.get('title') or ''
  265. pre = '&nbsp;&nbsp;└ ' * depth
  266. lines.append(f"| {pre}`{eid}`{' ' + title if title and e.get('id') else ''} | `{e.get('kind')}` | "
  267. f"{'**是**' if e.get('changes_with_data') else '否'} | {e.get('evidence', '')} |")
  268. for c in e.get('children') or []:
  269. walk(c, depth + 1)
  270. for e in reg.get('pages') or []:
  271. walk(e)
  272. return '\n'.join(lines)
  273. def audit(registry=None):
  274. """→ (rc, results, unregistered)。
  275. 供 `guanlan.py check` / `scripts/rebuild_all.py` 直接调用 —— 走函数而不是子进程:
  276. 子进程的 stdout 是中文, Windows 控制台默认 cp936 会把 UTF-8 输出解成乱码, 解析结论就不可靠了。
  277. """
  278. reg = load(registry)
  279. results: list[tuple[str, str, str, int]] = []
  280. for e in reg.get('pages') or []:
  281. check_entry(e, results)
  282. results += ledger_coverage(reg)
  283. rc_map = {r[3] for r in results if r[0] not in ('OK', 'i', '?') and r[3]}
  284. return (max(rc_map) if rc_map else RC_OK), results, coverage_gaps(reg)
  285. def ledger_coverage(reg) -> list:
  286. """页面侧产物的**台账覆盖**检查 (用户令 2026-09-17: 是产物就要纳入产物管理)。
  287. 凡登记表里指向 `outputs/<场>/…` 的路径 (`file` / `source`), 都必须出现在该场的
  288. `_provenance.json::files` 里 —— 否则它虽然被页面用了, 却没进产物台账 (来源/类型无从查)。
  289. """
  290. out: list[tuple[str, str, str, int]] = []
  291. prov_p = P.out_root() / '_provenance.json'
  292. if not prov_p.is_file():
  293. return out
  294. try:
  295. prov = set((json.loads(prov_p.read_text(encoding='utf-8')).get('files') or {}).keys())
  296. except Exception:
  297. return out
  298. prefix = P.out_root().relative_to(P.ROOT).as_posix() + '/' # outputs/<场>/
  299. def walk(e):
  300. eid = e.get('id') or e.get('name') or '?'
  301. gap = e.get('known_gap') or {}
  302. gap_missing = set(gap.get('missing') or [])
  303. for key in ('file', 'source'):
  304. v = e.get(key)
  305. if not isinstance(v, str) or not v.startswith(prefix) or any(c in v for c in '*?['):
  306. continue
  307. if v in gap_missing or any(v.endswith(g.split('/')[-1]) for g in gap_missing):
  308. out.append(('i', eid, f'页面侧产物 {v} 不在本包(已知缺口): {gap.get("why", "")[:60]}', RC_OK))
  309. continue
  310. rel = v[len(prefix):]
  311. if rel not in prov:
  312. out.append(('X', eid, f'页面侧产物 {v} 未进产物台账 (_provenance.json 里没有 {rel}) '
  313. f'⇒ 来源/类型无从查, 不算"纳入产物管理"', RC_PROV))
  314. for c in e.get('children') or []:
  315. walk(c)
  316. for e in reg.get('pages') or []:
  317. walk(e)
  318. return out
  319. def main() -> int:
  320. ap = argparse.ArgumentParser()
  321. ap.add_argument('--list', action='store_true', help='只打表')
  322. ap.add_argument('--check', action='store_true', help='只检查')
  323. ap.add_argument('--write-doc', action='store_true', help='把表写进 docs/系统设计说明.md')
  324. ap.add_argument('--json', action='store_true', help='机器可读输出')
  325. ap.add_argument('--registry', default=None, help='登记表路径 (默认 configs/portal_pages.yaml; 测试用)')
  326. a = ap.parse_args()
  327. reg = load(a.registry)
  328. rc, results, gaps = audit(a.registry)
  329. if a.json:
  330. print(json.dumps(dict(rc=rc,
  331. results=[dict(level=r[0], item=r[1], note=r[2], rc=r[3]) for r in results],
  332. unregistered=gaps), ensure_ascii=False, indent=1))
  333. return rc
  334. if a.list or not a.check:
  335. print('== 门户页面归口 (configs/portal_pages.yaml) ==')
  336. for e in reg.get('pages') or []:
  337. def walk(x, d=0):
  338. eid = x.get('id') or x.get('name')
  339. print(f' {" " * d}{eid:34s} {x.get("kind"):16s} '
  340. f'{"随数据变" if x.get("changes_with_data") else "不随数据变"}')
  341. for c in x.get('children') or []:
  342. walk(c, d + 1)
  343. walk(e)
  344. if not a.check or True:
  345. lvl = {}
  346. for level, item, note, r in results:
  347. lvl[level] = lvl.get(level, 0) + 1
  348. print(f'\n== 检查: {lvl.get("OK", 0)} 项一致, {lvl.get("i", 0)} 项已知缺口, '
  349. f'{sum(v for k, v in lvl.items() if k not in ("OK", "i"))} 项要处理 ==')
  350. for level, item, note, r in results:
  351. if level not in ('OK', 'i'):
  352. print(f' [{level}] {item}: {note}')
  353. if lvl.get('i'):
  354. print(f' [i] 已知缺口 {lvl["i"]} 条 (引用悬空/溯源缺失, 已在登记表里写明理由, 不重复刷屏):')
  355. seen = set()
  356. for level, item, note, r in results:
  357. if level == 'i' and item not in seen:
  358. seen.add(item)
  359. print(f' · {item}')
  360. if gaps:
  361. print(f' [!] 门户里有 {len(gaps)} 个内嵌页/子页**未登记**(漏登记=没管):')
  362. for g in gaps[:8]:
  363. print(f' · {g}')
  364. print(f'结论: {"全部一致" if rc == RC_OK else "见上"}; 退出码 {rc}')
  365. if a.write_doc:
  366. write_doc(reg)
  367. return rc
  368. return rc
  369. def write_doc(reg) -> None:
  370. if not DOC.is_file():
  371. print(f' (缺 {P.rel(DOC)}, 跳过写文档)')
  372. return
  373. t = DOC.read_text(encoding='utf-8')
  374. block = (f'{DOC_BEGIN}\n### 10.1 门户页面归口(自动生成,勿手改)\n\n'
  375. f'判据与说明见 `configs/portal_pages.yaml` 头部;检查器 `scripts/pages_audit.py`。\n\n'
  376. f'{render(reg, None)}\n\n{DOC_END}')
  377. if DOC_BEGIN in t and DOC_END in t:
  378. t = re.sub(re.escape(DOC_BEGIN) + r'.*?' + re.escape(DOC_END), lambda m: block, t, flags=re.S)
  379. else:
  380. t = t.rstrip() + '\n\n---\n\n## 10. 页面归口(哪些页面是产物、该不该随数据变)\n\n' + block + '\n'
  381. DOC.write_text(t, encoding='utf-8')
  382. print(f' 已写入 {P.rel(DOC)} (§10 页面归口)')
  383. if __name__ == '__main__':
  384. from src import console
  385. console.soft()
  386. sys.exit(main())