|
@@ -0,0 +1,257 @@
|
|
|
|
|
+#!/usr/bin/env python3
|
|
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
|
|
+r"""门户装配器 —— 把 `release\portal.html` 拆成"受管的源 + 不入库的产物", 并可逐字节重建 (2026-09-11)。
|
|
|
|
|
+
|
|
|
|
|
+## 为什么
|
|
|
|
|
+
|
|
|
|
|
+`release\portal.html` 20.23 MB 里 **99.3% 是内嵌的交付文档**(28 个 `<template>`, ~20 MB) ——
|
|
|
|
|
+那是产物/交付件内容, 却因为"门户外壳也在同一个文件里"而整体进了版本管理。
|
|
|
|
|
+本器把两者拆开: **外壳受管、内嵌件与合成结果不入库**, 且装配结果与拆之前**逐字节相同**(`--verify` 证明)。
|
|
|
|
|
+
|
|
|
|
|
+## 目录约定
|
|
|
|
|
+
|
|
|
|
|
+ release/portal.html ← 产物: 装配结果 (不入 git; 服务/网关直接读它)
|
|
|
|
|
+ release/portal_src/shell.html ← **受管**: 门户外壳 (样式/脚本/导航/面板), 内嵌件处留标记
|
|
|
|
|
+ release/portal_src/templates/<id>.html ← 产物: 28 个内嵌交付文档正文 (治理清单分册/报告/仿真台面板)
|
|
|
|
|
+ release/portal_src/governance_sources.json ← 产物: 脱敏资料索引 + source_sha256
|
|
|
|
|
+ release/portal_src/manifest.json ← **受管**: 各件 sha256 + 期望的 portal.html sha256 + 说明
|
|
|
|
|
+ release/portal_src/README.md ← **受管**: 人读的重建说明
|
|
|
|
|
+
|
|
|
|
|
+## 标记 (shell.html 里)
|
|
|
|
|
+
|
|
|
|
|
+ <!--@TEMPLATE:<id>--> → <template id="<id>">…templates/<id>.html…</template>
|
|
|
|
|
+ <!--@GOVERNANCE_SOURCES--> → governance_sources.json 内容 (位于 <script type="application/json"> 内)
|
|
|
|
|
+ contract-claims 段**不在** shell 里: 它是产物, 装配最后交 guanlan_portal_inject_claims.py 注入。
|
|
|
|
|
+
|
|
|
|
|
+## 行尾约定 (踩过)
|
|
|
|
|
+
|
|
|
|
|
+门户行尾是 **LF**。Python 文本模式在 Windows 上写文件会把 `\n` 变成 `\r\n` —— 20 MB 文件整体被改写,
|
|
|
|
|
+`/api/version` 里的 portal_sha256 指纹随之变化。所以本器全程**字节级读写**(`rd`/`wr`), 并在装配后自检"无 CRLF";
|
|
|
|
|
+另外两个就地注入器 (`guanlan_portal_fix_anchors.py` / `guanlan_portal_inject_claims.py`) 也一并加了 `newline=""`。
|
|
|
|
|
+
|
|
|
|
|
+## 用法
|
|
|
|
|
+
|
|
|
|
|
+ python scripts/portal_build.py --extract # 一次性: 从现有 portal.html 拆出 portal_src/
|
|
|
|
|
+ python scripts/portal_build.py # 装配 → release/portal.html (含 claims)
|
|
|
|
|
+ python scripts/portal_build.py --no-claims # 只装配外壳与内嵌件
|
|
|
|
|
+ python scripts/portal_build.py --verify # 装配到临时文件, 与现有 portal.html **逐字节**比对
|
|
|
|
|
+ python scripts/portal_build.py --check # 各源件与 manifest 的 sha256 是否漂移
|
|
|
|
|
+"""
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import argparse
|
|
|
|
|
+import hashlib
|
|
|
|
|
+import json
|
|
|
|
|
+import pathlib
|
|
|
|
|
+import re
|
|
|
|
|
+import sys
|
|
|
|
|
+import tempfile
|
|
|
|
|
+
|
|
|
|
|
+ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
|
|
|
+sys.path.insert(0, str(ROOT))
|
|
|
|
|
+from src import paths as P # noqa: E402
|
|
|
|
|
+
|
|
|
|
|
+PORTAL = P.PORTAL
|
|
|
|
|
+SRC = P.RELEASE / 'portal_src'
|
|
|
|
|
+TPL_DIR = SRC / 'templates'
|
|
|
|
|
+SHELL = SRC / 'shell.html'
|
|
|
|
|
+GOV = SRC / 'governance_sources.json'
|
|
|
|
|
+MANIFEST = SRC / 'manifest.json'
|
|
|
|
|
+
|
|
|
|
|
+RE_TPL = re.compile(r'<template id="([^"]+)">(.*?)</template>', re.S)
|
|
|
|
|
+RE_TPL_MARK = re.compile(r'<!--@TEMPLATE:([^>]+?)-->')
|
|
|
|
|
+RE_GOV = re.compile(r'(<script type="application/json" id="governance-sources">)(.*?)(</script>)', re.S)
|
|
|
|
|
+RE_GOV_MARK = re.compile(r'<!--@GOVERNANCE_SOURCES-->')
|
|
|
|
|
+RE_CLAIMS = re.compile(r'<section id="contract-claims"[^>]*>.*?</section><!--/contract-claims-->', re.S)
|
|
|
|
|
+CRLF = b'\r\n'
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def rd(p: pathlib.Path) -> str:
|
|
|
|
|
+ """字节级读: 不做换行转换 (门户行尾约定 = LF; 文本模式在 Windows 上会把 \\n 变 \\r\\n)。"""
|
|
|
|
|
+ return p.read_bytes().decode('utf-8')
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def wr(p: pathlib.Path, s: str) -> None:
|
|
|
|
|
+ """字节级写: 同上, 保证装配结果与拆之前逐字节可比。"""
|
|
|
|
|
+ p.write_bytes(s.encode('utf-8'))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def sha(b) -> str:
|
|
|
|
|
+ return hashlib.sha256(b if isinstance(b, bytes) else b.encode('utf-8')).hexdigest()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ────────────────────────────────────────────────────────────── 拆 (一次性)
|
|
|
|
|
+def do_extract() -> int:
|
|
|
|
|
+ if not PORTAL.is_file():
|
|
|
|
|
+ print(f'[X] 门户不存在: {P.rel(PORTAL)}')
|
|
|
|
|
+ return 1
|
|
|
|
|
+ raw = rd(PORTAL)
|
|
|
|
|
+ orig_sha = sha(raw)
|
|
|
|
|
+ TPL_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+
|
|
|
|
|
+ items = {}
|
|
|
|
|
+ for m in RE_TPL.finditer(raw):
|
|
|
|
|
+ tid, body = m.group(1), m.group(2)
|
|
|
|
|
+ wr(TPL_DIR / f'{tid}.html', body)
|
|
|
|
|
+ items[tid] = dict(file=f'templates/{tid}.html', bytes=len(body.encode('utf-8')), sha256=sha(body))
|
|
|
|
|
+ shell = RE_TPL.sub(lambda m: f'<!--@TEMPLATE:{m.group(1)}-->', raw)
|
|
|
|
|
+
|
|
|
|
|
+ g = RE_GOV.search(shell)
|
|
|
|
|
+ if not g:
|
|
|
|
|
+ print('[X] 找不到 governance-sources 脚本块')
|
|
|
|
|
+ return 1
|
|
|
|
|
+ gov_body = g.group(2)
|
|
|
|
|
+ json.loads(gov_body) # 自检: 必须是合法 JSON
|
|
|
|
|
+ wr(GOV, gov_body)
|
|
|
|
|
+ shell = shell[:g.start(2)] + '<!--@GOVERNANCE_SOURCES-->' + shell[g.end(2):]
|
|
|
|
|
+
|
|
|
|
|
+ n_claims = len(RE_CLAIMS.findall(shell))
|
|
|
|
|
+ shell = RE_CLAIMS.sub('', shell) # claims 是产物: 不进外壳
|
|
|
|
|
+ wr(SHELL, shell)
|
|
|
|
|
+
|
|
|
|
|
+ man = dict(
|
|
|
|
|
+ built_from=dict(portal=P.rel(PORTAL), portal_sha256=orig_sha),
|
|
|
|
|
+ shell=dict(file='shell.html', bytes=len(shell.encode('utf-8')), sha256=sha(shell)),
|
|
|
|
|
+ governance_sources=dict(file='governance_sources.json',
|
|
|
|
|
+ bytes=len(gov_body.encode('utf-8')), sha256=sha(gov_body)),
|
|
|
|
|
+ templates=dict(count=len(items), items=items),
|
|
|
|
|
+ claims_section_stripped=n_claims,
|
|
|
|
|
+ expected_portal_sha256=orig_sha,
|
|
|
|
|
+ line_ending='LF',
|
|
|
|
|
+ notes=[
|
|
|
|
|
+ 'shell.html = 门户外壳 (CSS/JS/导航/面板结构), 含 <!--@TEMPLATE:id--> 与 <!--@GOVERNANCE_SOURCES--> 标记;',
|
|
|
|
|
+ 'templates/ 与 governance_sources.json 是**产物/交付件内容** (按"产物不进 git"的约定不入库); '
|
|
|
|
|
+ 'manifest 里留 sha256 以便漂移检测 (--check);',
|
|
|
|
|
+ 'contract-claims 段由 scripts/guanlan_portal_inject_claims.py 在装配最后注入 (内容来自 outputs/<场>/guanlan/…);',
|
|
|
|
|
+ '锚点修复 scripts/guanlan_portal_fix_anchors.py 的输出是**代码**, 已固化在 shell.html 里;',
|
|
|
|
|
+ '行尾必须是 LF: 三个脚本 (本器 + 两个注入器) 都已用字节级/newline="" 写文件, 装配后有 CRLF 自检。',
|
|
|
|
|
+ ])
|
|
|
|
|
+ wr(MANIFEST, json.dumps(man, ensure_ascii=False, indent=1))
|
|
|
|
|
+ print(f'拆出: shell.html {man["shell"]["bytes"]/1e3:.1f} KB · templates/ {len(items)} 个 '
|
|
|
|
|
+ f'({sum(v["bytes"] for v in items.values())/1e6:.2f} MB) · governance_sources.json '
|
|
|
|
|
+ f'{man["governance_sources"]["bytes"]/1e3:.1f} KB · 剥离 claims 段 {n_claims} 处')
|
|
|
|
|
+ print(f'原门户 sha256 {orig_sha[:16]} → 记为期望值; 下一步跑 --verify 验逐字节一致')
|
|
|
|
|
+ return 0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ────────────────────────────────────────────────────────────── 装
|
|
|
|
|
+def assemble(out: pathlib.Path, claims: bool = True) -> tuple[str, dict]:
|
|
|
|
|
+ if not SHELL.is_file():
|
|
|
|
|
+ raise SystemExit(f'缺外壳 {P.rel(SHELL)}; 先跑 --extract')
|
|
|
|
|
+ if not TPL_DIR.is_dir():
|
|
|
|
|
+ raise SystemExit(f'缺内嵌件目录 {P.rel(TPL_DIR)}')
|
|
|
|
|
+ s = rd(SHELL)
|
|
|
|
|
+ rep = dict(templates=0, sources=0)
|
|
|
|
|
+
|
|
|
|
|
+ def sub_tpl(m):
|
|
|
|
|
+ tid = m.group(1)
|
|
|
|
|
+ f = TPL_DIR / f'{tid}.html'
|
|
|
|
|
+ if not f.is_file():
|
|
|
|
|
+ raise SystemExit(f'缺模板 {P.rel(f)}')
|
|
|
|
|
+ rep['templates'] += 1
|
|
|
|
|
+ return f'<template id="{tid}">' + rd(f) + '</template>'
|
|
|
|
|
+
|
|
|
|
|
+ s = RE_TPL_MARK.sub(sub_tpl, s)
|
|
|
|
|
+ if RE_TPL_MARK.search(s):
|
|
|
|
|
+ raise SystemExit('仍有模板标记未替换 (shell 与 templates/ 不一致)')
|
|
|
|
|
+ if RE_GOV_MARK.search(s):
|
|
|
|
|
+ if not GOV.is_file():
|
|
|
|
|
+ raise SystemExit(f'缺 {P.rel(GOV)}')
|
|
|
|
|
+ rep['sources'] = 1
|
|
|
|
|
+ s = RE_GOV_MARK.sub(lambda m: rd(GOV), s, count=1)
|
|
|
|
|
+
|
|
|
|
|
+ wr(out, s)
|
|
|
|
|
+ if claims:
|
|
|
|
|
+ inj = ROOT / 'scripts' / 'guanlan_portal_inject_claims.py'
|
|
|
|
|
+ if inj.is_file():
|
|
|
|
|
+ import importlib.util
|
|
|
|
|
+ spec = importlib.util.spec_from_file_location('_portal_inject', inj)
|
|
|
|
|
+ mod = importlib.util.module_from_spec(spec)
|
|
|
|
|
+ spec.loader.exec_module(mod)
|
|
|
|
|
+ rep['claims_sha'] = mod.inject(out, out)[:16]
|
|
|
|
|
+ else:
|
|
|
|
|
+ rep['claims_sha'] = '(无注入器, 跳过)'
|
|
|
|
|
+ b = out.read_bytes()
|
|
|
|
|
+ if b.count(CRLF):
|
|
|
|
|
+ raise SystemExit(f'装配结果里出现 {b.count(CRLF):,} 处 CRLF —— 门户行尾必须是 LF; '
|
|
|
|
|
+ f'多半是某个注入器又用了文本模式写文件 (查 scripts/guanlan_portal_*.py 的 write_text)')
|
|
|
|
|
+ return sha(b), rep
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def do_build(claims=True) -> int:
|
|
|
|
|
+ h, rep = assemble(PORTAL, claims)
|
|
|
|
|
+ print(f'装配完成 → {P.rel(PORTAL)} sha256 {h[:16]} '
|
|
|
|
|
+ f'(内嵌件 {rep["templates"]} 个, 资料索引 {rep["sources"]} 处, claims {rep.get("claims_sha")})')
|
|
|
|
|
+ return 0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def do_verify() -> int:
|
|
|
|
|
+ if not PORTAL.is_file():
|
|
|
|
|
+ print(f'[X] 现有门户不存在: {P.rel(PORTAL)}')
|
|
|
|
|
+ return 1
|
|
|
|
|
+ cur = PORTAL.read_bytes()
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as td:
|
|
|
|
|
+ tmp = pathlib.Path(td) / 'portal.html'
|
|
|
|
|
+ _, rep = assemble(tmp, claims=True)
|
|
|
|
|
+ built = tmp.read_bytes()
|
|
|
|
|
+ print(f'现有 {P.rel(PORTAL)} {len(cur)/1e6:.2f} MB sha256 {sha(cur)[:16]}')
|
|
|
|
|
+ print(f'装配结果 {len(built)/1e6:.2f} MB sha256 {sha(built)[:16]}')
|
|
|
|
|
+ print(f' 内嵌件 {rep["templates"]} 个 · 资料索引 {rep["sources"]} 处 · claims {rep.get("claims_sha")}')
|
|
|
|
|
+ if built == cur:
|
|
|
|
|
+ print(' 逐字节一致 ✔ (拆→装回到同一个文件: 装配链可信, 产物可重建)')
|
|
|
|
|
+ return 0
|
|
|
|
|
+ i = next((k for k in range(min(len(cur), len(built))) if cur[k] != built[k]), min(len(cur), len(built)))
|
|
|
|
|
+ print(f' ✘ 不一致: 首个差异字节 @{i:,} (现有 {len(cur):,} / 装配 {len(built):,})')
|
|
|
|
|
+ print(f' 现有: …{cur[max(0,i-70):i+70].decode("utf-8","replace")!r}…')
|
|
|
|
|
+ print(f' 装配: …{built[max(0,i-70):i+70].decode("utf-8","replace")!r}…')
|
|
|
|
|
+ return 3
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def do_check() -> int:
|
|
|
|
|
+ if not MANIFEST.is_file():
|
|
|
|
|
+ print(f'[X] 缺 {P.rel(MANIFEST)}; 先跑 --extract')
|
|
|
|
|
+ return 1
|
|
|
|
|
+ man = json.loads(rd(MANIFEST))
|
|
|
|
|
+ bad = 0
|
|
|
|
|
+ for label, f, h in (('shell', SHELL, man['shell']['sha256']),
|
|
|
|
|
+ ('governance_sources', GOV, man['governance_sources']['sha256'])):
|
|
|
|
|
+ got = sha(f.read_bytes()) if f.is_file() else '(缺)'
|
|
|
|
|
+ if got != h:
|
|
|
|
|
+ bad += 1
|
|
|
|
|
+ print(f' [{"OK" if got == h else "漂移"}] {label:20s} {P.rel(f)}')
|
|
|
|
|
+ miss = 0
|
|
|
|
|
+ for tid, it in man['templates']['items'].items():
|
|
|
|
|
+ f = SRC / it['file']
|
|
|
|
|
+ got = sha(f.read_bytes()) if f.is_file() else '(缺)'
|
|
|
|
|
+ if got != it['sha256']:
|
|
|
|
|
+ bad += 1
|
|
|
|
|
+ miss += 1
|
|
|
|
|
+ if miss <= 3:
|
|
|
|
|
+ print(f' [漂移] template {tid:34s} {got[:16]} != manifest {it["sha256"][:16]}')
|
|
|
|
|
+ if miss > 3:
|
|
|
|
|
+ print(f' … 另有 {miss-3} 个模板漂移')
|
|
|
|
|
+ print(f'结论: {"全部与 manifest 一致 ✔" if not bad else f"{bad} 处漂移"}')
|
|
|
|
|
+ if PORTAL.is_file():
|
|
|
|
|
+ cur = sha(PORTAL.read_bytes())
|
|
|
|
|
+ print(f' 现有门户 sha256 {cur[:16]} (manifest 期望 {man["expected_portal_sha256"][:16]})'
|
|
|
|
|
+ f'{" ← 一致" if cur == man["expected_portal_sha256"] else " ← 已变 (重算过/注入过)"}')
|
|
|
|
|
+ return 0 if not bad else 1
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == '__main__':
|
|
|
|
|
+ # 控制台可能是 GBK (Windows 中文默认 936): 正文里的 ✔/✘ 等符号编不出来会直接抛 UnicodeEncodeError
|
|
|
|
|
+ # (校验结论明明通过了却因为 print 崩掉, 退出码变成 1)。降级为 '?' 而不是崩。
|
|
|
|
|
+ for _s in (sys.stdout, sys.stderr):
|
|
|
|
|
+ try:
|
|
|
|
|
+ _s.reconfigure(errors='replace')
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ pass
|
|
|
|
|
+ ap = argparse.ArgumentParser()
|
|
|
|
|
+ g = ap.add_mutually_exclusive_group()
|
|
|
|
|
+ g.add_argument('--extract', action='store_true', help='从现有 portal.html 拆出 portal_src/ (一次性)')
|
|
|
|
|
+ g.add_argument('--verify', action='store_true', help='装配到临时文件并与现有门户逐字节比对')
|
|
|
|
|
+ g.add_argument('--check', action='store_true', help='源件与 manifest 的 sha256 漂移检查')
|
|
|
|
|
+ ap.add_argument('--no-claims', action='store_true', help='装配时不注入契约结论段')
|
|
|
|
|
+ a = ap.parse_args()
|
|
|
|
|
+ sys.exit(do_extract() if a.extract else do_verify() if a.verify else
|
|
|
|
|
+ do_check() if a.check else do_build(claims=not a.no_claims))
|