#!/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' def dt_from(ts: float | None) -> str: """mtime → 'YYYY-mm-dd HH:MM' (报告里给人看的时间)。""" if not ts: return '?' import datetime as _dt return _dt.datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M') 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 e.get('generated_at_source') == 'mtime': # 页面件自己就是生成物: 用"它自己的 mtime"当生成时间, 与 source 的 mtime 比 ⇒ 可做陈旧代理判据 src = e.get('source') mt = None for f in files: try: mt = f.stat().st_mtime break except OSError: continue sp = (P.ROOT / src) if isinstance(src, str) else None if mt and sp is not None and sp.is_file() and (sp.stat().st_mtime - mt) > 86400: results.append(('X', eid, f'**页面件疑似陈旧**: {P.rel(files[0])} 写于 ' f'{dt_from(mt)}, 而它的源 {src} 更新于 {dt_from(sp.stat().st_mtime)} ' f'⇒ 源换了、这份页面件没重生成', RC_STALE)) else: results.append(('OK', eid, f'按 mtime 判: 页面件与源 {src or "(未登记)"} 的新旧关系正常 ' f'(页面件 {dt_from(mt) if mt else "?"})', RC_OK)) if not found_src: pass elif 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)) elif e.get('generated_at_source') != 'mtime': 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 inventory() -> list[dict]: """**所有网页**总清点(用户令 2026-09-17 第 2 问):把系统会呈现的每一页/每个子页列出来, 并判定"是不是产物(= 由 data/raw 重算出来的输出数据)"。 来源四路,各有依据(不靠猜): ① 门户锚点 `release/portal_src/shell.html` 的 `
` ② 工作台标签 `src/windscada/ui/app.js` 的 `TABS` 常量(SPA tab,实时读产物) ③ 组件产物页 `outputs/<场>/**/*.html|*.md` —— **这些本身就是产物** ④ 交付/仿真/三维页 `release/如东/**/*.html`、仿真资料包 zip 内的页面、`release/viewer/*.html`、内嵌件 """ reg = load() entries = _all_entries(reg) reg_files = {str(e.get('file') or '') for e in entries} reg_ids = {str(e.get('id') or e.get('name') or '') for e in entries} rows: list[dict] = [] # ① 门户锚点 shell = P.RELEASE / 'portal_src' / 'shell.html' if shell.is_file(): t = shell.read_text(encoding='utf-8', errors='replace') for m in re.finditer(r'
]*id="([^"]+)"[^>]*data-title="([^"]*)"', t): rows.append(dict(source='门户锚点', name=f'#{m.group(1)}', title=m.group(2), kind='shell', product=False, registered=m.group(1) in reg_ids, evidence='外壳页(静态/iframe/链接);逐条判定见 §10 与 §12')) # ② 工作台 SPA 标签 appjs = P.SRC / 'windscada' / 'ui' / 'app.js' if appjs.is_file(): t = appjs.read_text(encoding='utf-8', errors='replace') m = re.search(r"const TABS = \[([^\]]+)\]", t) if m: for tab in re.findall(r"'([a-z_]+)'", m.group(1)): rows.append(dict(source='工作台标签', name=f'tab={tab}', title='', kind='live', product=False, registered=_tab_registered(tab, entries), evidence='SPA 一次装载、按 tab 现场 fetch 产物 ⇒ 页面本体不存快照')) # ③ 组件产物页(本身就是产物) out = P.out_root() ledger = _ledger_keys() REPORT_HINT = ('报告', '收敛', '诊断', '评估', '结论', 'report', 'summary', 'findings') if out.is_dir(): for f in sorted(list(out.rglob('*.html')) + list(out.rglob('*.md'))): rel = f.relative_to(out).as_posix() if rel.startswith('_'): continue full = f'{out.name}/{rel}' if f.suffix == '.html': src, prod, why = '组件页面件 (.html)', True, '位于 outputs/<场>/ ⇒ 本身就是产物,且是被服务直接当页面呈现的件' elif any(k in f.name.lower() for k in REPORT_HINT): src, prod, why = '组件结论件 (.md,页面会渲染)', True, '产物(结论文本);工作台 report tab / CMS report.html 会渲染它' else: src, prod, why = '产物里的过程留痕 (.md,非网页)', True, '产物,但没有任何页面呈现它(给下游线的交接/复跑记录)' rows.append(dict(source=src, name=rel, title='', kind='product-page', product=prod, registered=(rel in ledger), evidence=why + f';台账: {"在" if rel in ledger else "缺"}')) # ④ 交付/仿真/三维/内嵌 # 已登记判定: 精确名 或 **通配模式**(登记表里常按 wildcard 收一族文件, 如 tpl-report-*.html.html) _ = None for pat, src, kind, prod, why in ( ('如东/**/*.html', '交付页 (release/如东)', 'frozen-delivery', False, '客户交付件,按版本冻结'), ('viewer/*.html', '三维页 (release/viewer)', 'static', False, '图纸/模型派生的三维资产'), ('portal_src/templates/*.html', '门户内嵌件', 'embed', None, '内嵌交付件(§10 逐条判定;个别为数据派生)')): for f in sorted(P.RELEASE.glob(pat)): if f.name.startswith('.'): continue rel = f.relative_to(P.RELEASE).as_posix() rows.append(dict(source=src, name=rel, title='', kind=kind, product=prod, registered=_is_registered(rel, f'outputs/{rel}', entries), evidence=why)) # 仿真资料包里的页面 z = P.RELEASE / '如东SWT40_控制律仿真台_20260906.zip' if z.is_file(): import zipfile with zipfile.ZipFile(z) as zf: for n in zf.namelist(): if not n.endswith('.html'): continue try: nm = n.encode('cp437').decode('utf-8') except Exception: nm = n rows.append(dict(source='仿真资料包 (zip)', name=nm.split('/')[-1], title='', kind='frozen-delivery', product=False, registered=True, evidence='图纸派生的冻结资料包(sim_sys_server 从 zip 现读)')) return rows def _declared_patterns(entries: list[dict]) -> list[str]: """登记表里所有 `file:` 取值(含通配)—— 判定"这一页/这一件有没有被登记"用。""" return [str(e.get('file') or '') for e in entries if e.get('file')] def _is_registered(rel: str, full: str, entries: list[dict]) -> bool: """精确 或 通配 命中页面登记表 → 已登记(通配用全路径与 basename 两种都比一次)。""" import fnmatch names = {rel, full, pathlib.Path(rel).name} pats = _declared_patterns(entries) pats += [pathlib.Path(x).name for x in pats] for n in names: if n in pats: return True if any(fnmatch.fnmatch(n, x) for x in pats): return True return False def _tab_registered(tab: str, entries: list[dict]) -> bool: for e in entries: if e.get('id') == 'detail_tabs' and tab in (e.get('tabs') or []): return True return False def _ledger_keys() -> set: """产物台账里的键(相对 outputs/<场>/),判定"产物有没有被管起来"用。""" prov_p = P.out_root() / '_provenance.json' if not prov_p.is_file(): return set() try: return set((json.loads(prov_p.read_text(encoding='utf-8')).get('files') or {}).keys()) except Exception: return set() def _all_entries(reg) -> list[dict]: out = [] def walk(e): out.append(e) for c in e.get('children') or []: walk(c) for e in reg.get('pages') or []: walk(e) return out def render_inventory(rows: list[dict]) -> str: head = ['| 来源 | 页面/子页 | 是产物? | 依据 | 已登记? |', '|---|---|---|---|---|'] body = [] for r in rows: p = '**是**' if r['product'] else ('—(逐条判定)' if r['product'] is None else '不是') body.append(f"| {r['source']} | `{r['name']}`{' ' + r['title'] if r.get('title') else ''} | {p} | " f"{r['evidence']} | {'✔' if r['registered'] else '**✘ 未登记**'} |") return '\n'.join(head + body) 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) results += ledger_coverage(reg) 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 ledger_coverage(reg) -> list: """页面侧产物的**台账覆盖**检查 (用户令 2026-09-17: 是产物就要纳入产物管理)。 凡登记表里指向 `outputs/<场>/…` 的路径 (`file` / `source`), 都必须出现在该场的 `_provenance.json::files` 里 —— 否则它虽然被页面用了, 却没进产物台账 (来源/类型无从查)。 """ out: list[tuple[str, str, str, int]] = [] prov_p = P.out_root() / '_provenance.json' if not prov_p.is_file(): return out try: prov = set((json.loads(prov_p.read_text(encoding='utf-8')).get('files') or {}).keys()) except Exception: return out prefix = P.out_root().relative_to(P.ROOT).as_posix() + '/' # outputs/<场>/ def walk(e): eid = e.get('id') or e.get('name') or '?' gap = e.get('known_gap') or {} gap_missing = set(gap.get('missing') or []) for key in ('file', 'source'): v = e.get(key) if not isinstance(v, str) or not v.startswith(prefix) or any(c in v for c in '*?['): continue if v in gap_missing or any(v.endswith(g.split('/')[-1]) for g in gap_missing): out.append(('i', eid, f'页面侧产物 {v} 不在本包(已知缺口): {gap.get("why", "")[:60]}', RC_OK)) continue rel = v[len(prefix):] if rel not in prov: out.append(('X', eid, f'页面侧产物 {v} 未进产物台账 (_provenance.json 里没有 {rel}) ' f'⇒ 来源/类型无从查, 不算"纳入产物管理"', RC_PROV)) for c in e.get('children') or []: walk(c) for e in reg.get('pages') or []: walk(e) return out 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('--inventory', action='store_true', help='**所有网页**总清点: 门户锚点/工作台标签/组件产物页/交付页/三维页/内嵌件/仿真页, 逐条判定是否产物') 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.inventory: rows = inventory() prod = [r for r in rows if r['product'] is True] un = [r for r in rows if not r['registered']] print(f'== 所有网页总清点: {len(rows)} 项 ==') print(f' 判定为产物(= 输入数据重算出来的输出数据): {len(prod)} 项; 未被管起来: {len(un)} 项') cur = None for r in rows: if r['source'] != cur: cur = r['source'] print(f'\n ▸ {cur}') flag = '产物' if r['product'] is True else ('逐条判定' if r['product'] is None else '非产物') print(f' [{"✔" if r["registered"] else "✘"}] {r["name"][:74]:74s} {flag}') if un: print('\n 未被管起来的条目:') for r in un: print(f' ✘ {r["source"]} | {r["name"]}') print('\n 判定依据: 产物板(组件页面件/结论件/过程留痕) = 位置在 outputs/<场>/ 下; 页面侧 = 登记表 kind 与 §10/§12 的逐条依据') if a.write_doc: write_doc(load()) return RC_OK 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 INV_BEGIN, INV_END = '', '' def write_inventory_doc() -> None: """把"所有网页总清点"写进 §12.2(自动块, 放在 §13 之前)。""" rows = inventory() prod = sum(1 for r in rows if r['product'] is True) un = [r for r in rows if not r['registered']] head = (f'### 12.2 全部网页总清点(自动生成,勿手改)\n\n' f'共 **{len(rows)}** 项(门户锚点 / 工作台标签 / 组件页面件 / 组件结论件 / 产物里的过程留痕 / ' f'交付页 / 三维页 / 门户内嵌件 / 仿真资料包页面),其中判定为**产物**的 {prod} 项;' f'未被管起来的 **{len(un)}** 项。判据:产物树里的件看"在不在产物台账里",网页看"在不在页面登记表里"。\n') block = f'{INV_BEGIN}\n{head}\n{render_inventory(rows)}\n\n{INV_END}' if not DOC.is_file(): return t = DOC.read_text(encoding='utf-8') if INV_BEGIN in t and INV_END in t: t = re.sub(re.escape(INV_BEGIN) + r'.*?' + re.escape(INV_END), lambda m: block, t, flags=re.S) else: anchor = '\n---\n\n## 13.' t = t.replace(anchor, f'\n\n{block}\n{anchor}', 1) if anchor in t else t.rstrip() + '\n\n' + block + '\n' DOC.write_text(t, encoding='utf-8') print(' 已写入 §12.2 全部网页总清点 (自动块)') 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 页面归口)') write_inventory_doc() if __name__ == '__main__': from src import console console.soft() sys.exit(main())