#!/usr/bin/env python3 # -*- coding: utf-8 -*- r"""页面归口审计 —— 把"这一页该不该随输入数据变 / 算不算产物"变成机器每天能查的事 (2026-09-17, 用户令 1)。 ## 背景(用户的问题) http://127.0.0.1:28084/#findings #sim #documents 及它们包含的子页, "应否随着输入数据的变化而变化"、"是否也属于产物"、"如是产物应纳入产物管理"。 答案不是一句话能给的: 三页里既有**受管静态叙述**(方法论/架构), 也有**冻结交付件**(治理清单/报告), 还有**真·数据派生快照**(把 outputs 里的数据烘进页面) —— 后者才是"属于产物、必须纳入产物管理"的那批。 本模块把这份判断落成 `configs/portal_pages.yaml` 登记表 + 五条机器可查的规则, 于是: · static —— 正文不得引用产物(setting 变了就该改分类); · live —— iframe/链接指向的端口必须是 `configs/serve.json` 里的已知服务; · data-citing —— 引用的产物路径**必须存在**(否则是悬空引用, 挂羊头卖狗肉); · data-derived —— **必须有 source + (source_sha256 或 generated_at)**; 有 sha 的还要跟当前产物 逐字节比对 ⇒ **陈旧检测**(数据换了、页面没换 = 报出来); · frozen-delivery —— 文件名或正文里必须能读到版本号与日期(客户拿到手才知道是哪一版)。 ## 为什么要有"陈旧检测" 页面把产物烘进去, 就**脱离**了"产物即时进页面"的机制(§5.1 的指纹热重载只对实时读产物的服务生效)。 数据重算后, 这种页面**不会自己变**; 没有检测, 它就一直挂着旧数字 —— 这正是用户担心的情形。 ## 退出码 (给 rebuild_all / check 用) 0 全部一致 · 5 登记缺项/文件缺失/未登记页 · 6 溯源缺失 · 7 页面陈旧(数据已变, 页面没变) · 8 交付件缺版本号 · 9 分类错误 ## 用法 python scripts/pages_audit.py # 表 + 检查(默认) python scripts/pages_audit.py --list # 只打表 python scripts/pages_audit.py --check # 只检查(安静模式, 只打结论) python scripts/pages_audit.py --write-doc # 把表写进 docs/系统设计说明.md (标记区间内) python scripts/pages_audit.py --json # 机器可读结果 """ from __future__ import annotations import argparse import fnmatch import glob as globmod import hashlib import html as htmllib import json import pathlib import re import sys ROOT = pathlib.Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from src import paths as P # noqa: E402 REG = P.CONFIGS / 'portal_pages.yaml' DOC = P.ROOT / 'docs' / '系统设计说明.md' DOC_BEGIN, DOC_END = '', '' RC_OK, RC_MISS, RC_PROV, RC_STALE, RC_VER, RC_KIND = 0, 5, 6, 7, 8, 9 KINDS = ('static', 'live', 'data-citing', 'data-derived', 'frozen-delivery') RE_OUT = re.compile(r'outputs/[A-Za-z0-9_\u4e00-\u9fff\-./]+') # 版本号只认"像版本"的: v1.0 / V2.1 / 版本1.2 / 客户版|外发版|正式版|评审版。 # ★别写成 \d{8} 之类 —— 那会把页面里的机组编号(00019164)、时间戳当成版本号(2026-09-17 实测踩到)。 RE_VER = re.compile(r'[vV]\d+(?:\.\d+)+|版本\s*[vV]?\d+(?:\.\d+)*|客户版|外发版|正式版|评审版') RE_DATE = re.compile(r'20\d\d-\d\d-\d\d|20\d{6}') RE_GEN = re.compile(r'生成[^0-9]{0,8}(20\d\d-\d\d-\d\d[ T]?\d?\d?:?\d?\d?)') def load(path=None): import yaml reg_p = pathlib.Path(path) if path else REG if not reg_p.is_file(): raise SystemExit(f'缺登记表 {P.rel(reg_p)}') reg = yaml.safe_load(reg_p.read_text(encoding='utf-8')) reg['_file'] = str(reg_p) return reg def sha256(p: pathlib.Path) -> str | None: try: return hashlib.sha256(p.read_bytes()).hexdigest() except OSError: return None def expand(entry) -> list[pathlib.Path]: """一个登记项的 `file` 字段 → 实际文件列表 (支持 * 通配)。""" pat = P.ROOT / entry['file'] s = str(pat) if any(c in s for c in '*?['): return sorted(pathlib.Path(x) for x in globmod.glob(s, recursive=True)) return [pat] if pat.is_file() else [] def texts(files): for f in files: try: yield f, f.read_text(encoding='utf-8', errors='replace') except OSError: continue def read_embedded_json(path: pathlib.Path, var: str): """抓页面里内嵌的 window. = {…} —— 模板里是 HTML 转义过的, 解析前先反转义。""" try: t = path.read_text(encoding='utf-8', errors='replace') except OSError: return None m = re.search(r'window\.' + re.escape(var) + r'\s*=\s*(\{)', t) if not m: return None raw, depth = '', 0 for ch in t[m.start(1):]: raw += ch if ch == '{': depth += 1 elif ch == '}': depth -= 1 if depth == 0: break for cand in (raw, htmllib.unescape(raw)): try: return json.loads(cand) except Exception: continue return None def check_entry(e, results): """按 kind 查一个登记项 → 往 results 里加 (level, 项, 说明, 退出码)。 level: OK 通过 / X 失败 / ! 要处理 / ? 只能人工看 / i 已知缺口(登记了 known_gap 并写了理由)。 ★ known_gap 的存在是**如实**的产物: 交付包里确实没有那件产物(例如振动线六层链的 cleaned/gearbox_life), 页面里却引用了它。我们不改交付件正文(客户手里那一版是冻结的), 但必须把"引用悬空"这件事记在明处, 所以它报 `i` 而不是装作通过, 也不当成新问题反复报警。 """ eid = e.get('id') or e.get('name') or '?' kind = e.get('kind') gap = e.get('known_gap') or {} if kind not in KINDS: results.append(('X', eid, f'kind 非法: {kind} (允许 {"/".join(KINDS)})', RC_KIND)) return files = expand(e) if not files and not e.get('members_in_zip'): results.append(('X', eid, f'登记的 file 不存在或没匹配到: {e.get("file")}', RC_MISS)) return # ① 引用的产物必须在位 (data-citing / data-derived; 登记了 cites 的也查) for f, t in texts(files): cited = set(e.get('cites') or []) if kind in ('data-derived', 'data-citing'): cited |= set(RE_OUT.findall(t)) for c in sorted(cited): if (P.ROOT / c).exists(): continue if gap and c in (gap.get('missing') or []): results.append(('i', eid, f'引用悬空(已知): {c} —— {gap.get("why", "见登记表")}', RC_OK)) else: results.append(('X', eid, f'{P.rel(f)} 引用的产物不在位: {c} (悬空引用)', RC_MISS)) # ② 分类规则 if kind == 'static': for f, t in texts(files): if RE_OUT.search(t): results.append(('!', eid, f'{P.rel(f)} 正文里出现产物路径 —— 可能该改成 data-citing/data-derived', RC_KIND)) elif kind == 'live': svc = {} try: svc = json.loads(P.SERVE_JSON.read_text(encoding='utf-8-sig')) except Exception: pass ports = {int(v) for k, v in svc.items() if isinstance(v, int)} tgt = e.get('live_target') or '' m = re.search(r':(\d{2,5})', tgt) if m and ports and int(m.group(1)) not in ports: results.append(('X', eid, f'live_target 端口 {m.group(1)} 不在 configs/serve.json 的服务端口里', RC_MISS)) elif kind == 'data-derived': src = e.get('source') var = e.get('embedded_json_var') skey, shkey = e.get('source_key'), e.get('source_sha_key') found_sha, found_src, found_gen = None, src, None if var and skey: for f in files: obj = read_embedded_json(f, var) if isinstance(obj, dict): found_src = obj.get(skey) or found_src found_sha = obj.get(shkey) if shkey else None break for f, t in texts(files): if found_gen is None: g = RE_GEN.search(t) if g: found_gen = g.group(1) if not found_src: if gap: results.append(('i', eid, f'溯源缺失(已知): {gap.get("why", "")}', RC_OK)) else: results.append(('!', eid, 'data-derived 但没登记 source, 页面里也没有内嵌来源 ⇒ 溯源缺失', RC_PROV)) if not (found_sha or found_gen or e.get('generated_at_key')): if not gap: results.append(('!', eid, 'data-derived 但既无 source_sha256 也无生成时间 ⇒ 无法做陈旧检测', RC_PROV)) if found_src: sp = P.ROOT / found_src if not sp.exists(): if gap and found_src in (gap.get('missing') or []): results.append(('i', eid, f'内嵌来源不在位(已知): {found_src} —— {gap.get("why", "")}', RC_OK)) else: results.append(('!', eid, f'内嵌来源 {found_src} 不在位 (页面里烘的是别处/历史数据)', RC_MISS)) elif found_sha: cur = sha256(sp) if cur and cur.lower() != str(found_sha).lower(): results.append(('X', eid, f'**页面陈旧**: 内嵌快照 source_sha256={str(found_sha)[:16]} 与当前 ' f'{found_src} 的 sha256={cur[:16]} 不同 ⇒ 数据变了, 这份页面没跟着重生成', RC_STALE)) else: results.append(('OK', eid, f'快照与当前产物一致 ({found_src} sha256 {str(found_sha)[:16]})', RC_OK)) else: results.append(('?', eid, f'有来源 {found_src} 但无指纹, 只能按生成时间判断 ' f'(记录: {found_gen or "无"})', RC_PROV)) elif kind == 'frozen-delivery': ok_ver = ok_date = False for f in files: name = f.name ok_ver |= bool(RE_VER.search(name)) ok_date |= bool(RE_DATE.search(name)) if not (ok_ver and ok_date): t = f.read_text(encoding='utf-8', errors='replace') # 版本号/日期可能在正文深处 ok_ver |= bool(RE_VER.search(t)) ok_date |= bool(RE_DATE.search(t)) if e.get('version'): ok_ver = True if not ok_ver: results.append(('!', eid, '冻结交付件但没有版本号 (文件名/正文都没有 v*/日期) ⇒ 客户拿到手分不清是哪一版', RC_VER)) if not ok_date and not e.get('version'): results.append(('?', eid, '冻结交付件没有日期标记', RC_VER)) # ③ 子项递归 for c in e.get('children') or []: check_entry(c, results) def declared_patterns(reg) -> list[str]: """登记表里所有 file 字段 (含通配) —— 覆盖检查用。""" pats = [] def walk(e): f = e.get('file') if f: pats.append(str(f)) for c in e.get('children') or []: walk(c) for e in reg.get('pages') or []: walk(e) return pats def coverage_gaps(reg) -> list[str]: """登记表里没写、但门户里真实存在的内嵌页 —— 漏登记就等于没管。""" pats = declared_patterns(reg) gaps = [] tdir = P.RELEASE / 'portal_src' / 'templates' for f in sorted(tdir.glob('*.html')): rel = f.relative_to(P.ROOT).as_posix() if not any(fnmatch.fnmatch(rel, p) or rel == p for p in pats): gaps.append(rel) return gaps def render(reg, results) -> str: lines = ['| 页面/子页 | kind | 随输入数据变? | 依据(为什么这么判) |', '|---|---|---|---|'] def walk(e, depth=0): eid = e.get('id') or e.get('name') or '?' title = e.get('title') or '' pre = '  └ ' * depth lines.append(f"| {pre}`{eid}`{' ' + title if title and e.get('id') else ''} | `{e.get('kind')}` | " f"{'**是**' if e.get('changes_with_data') else '否'} | {e.get('evidence', '')} |") for c in e.get('children') or []: walk(c, depth + 1) for e in reg.get('pages') or []: walk(e) return '\n'.join(lines) def audit(registry=None): """→ (rc, results, unregistered)。 供 `guanlan.py check` / `scripts/rebuild_all.py` 直接调用 —— 走函数而不是子进程: 子进程的 stdout 是中文, Windows 控制台默认 cp936 会把 UTF-8 输出解成乱码, 解析结论就不可靠了。 """ reg = load(registry) results: list[tuple[str, str, str, int]] = [] for e in reg.get('pages') or []: check_entry(e, results) rc_map = {r[3] for r in results if r[0] not in ('OK', 'i') and r[3]} return (max(rc_map) if rc_map else RC_OK), results, coverage_gaps(reg) def main() -> int: ap = argparse.ArgumentParser() ap.add_argument('--list', action='store_true', help='只打表') ap.add_argument('--check', action='store_true', help='只检查') ap.add_argument('--write-doc', action='store_true', help='把表写进 docs/系统设计说明.md') ap.add_argument('--json', action='store_true', help='机器可读输出') ap.add_argument('--registry', default=None, help='登记表路径 (默认 configs/portal_pages.yaml; 测试用)') a = ap.parse_args() reg = load(a.registry) rc, results, gaps = audit(a.registry) if a.json: print(json.dumps(dict(rc=rc, results=[dict(level=r[0], item=r[1], note=r[2], rc=r[3]) for r in results], unregistered=gaps), ensure_ascii=False, indent=1)) return rc if a.list or not a.check: print('== 门户页面归口 (configs/portal_pages.yaml) ==') for e in reg.get('pages') or []: def walk(x, d=0): eid = x.get('id') or x.get('name') print(f' {" " * d}{eid:34s} {x.get("kind"):16s} ' f'{"随数据变" if x.get("changes_with_data") else "不随数据变"}') for c in x.get('children') or []: walk(c, d + 1) walk(e) if not a.check or True: lvl = {} for level, item, note, r in results: lvl[level] = lvl.get(level, 0) + 1 print(f'\n== 检查: {lvl.get("OK", 0)} 项一致, {lvl.get("i", 0)} 项已知缺口, ' f'{sum(v for k, v in lvl.items() if k not in ("OK", "i"))} 项要处理 ==') for level, item, note, r in results: if level not in ('OK', 'i'): print(f' [{level}] {item}: {note}') if lvl.get('i'): print(f' [i] 已知缺口 {lvl["i"]} 条 (引用悬空/溯源缺失, 已在登记表里写明理由, 不重复刷屏):') seen = set() for level, item, note, r in results: if level == 'i' and item not in seen: seen.add(item) print(f' · {item}') if gaps: print(f' [!] 门户里有 {len(gaps)} 个内嵌页/子页**未登记**(漏登记=没管):') for g in gaps[:8]: print(f' · {g}') print(f'结论: {"全部一致" if rc == RC_OK else "见上"}; 退出码 {rc}') if a.write_doc: write_doc(reg) return rc return rc def write_doc(reg) -> None: if not DOC.is_file(): print(f' (缺 {P.rel(DOC)}, 跳过写文档)') return t = DOC.read_text(encoding='utf-8') block = (f'{DOC_BEGIN}\n### 10.1 门户页面归口(自动生成,勿手改)\n\n' f'判据与说明见 `configs/portal_pages.yaml` 头部;检查器 `scripts/pages_audit.py`。\n\n' f'{render(reg, None)}\n\n{DOC_END}') if DOC_BEGIN in t and DOC_END in t: t = re.sub(re.escape(DOC_BEGIN) + r'.*?' + re.escape(DOC_END), lambda m: block, t, flags=re.S) else: t = t.rstrip() + '\n\n---\n\n## 10. 页面归口(哪些页面是产物、该不该随数据变)\n\n' + block + '\n' DOC.write_text(t, encoding='utf-8') print(f' 已写入 {P.rel(DOC)} (§10 页面归口)') if __name__ == '__main__': from src import console console.soft() sys.exit(main())