portal_build.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""门户装配器 —— 把 `release\portal.html` 拆成"受管的源 + 不入库的产物", 并可逐字节重建 (2026-09-11)。
  4. ## 为什么
  5. `release\portal.html` 20.23 MB 里 **99.3% 是内嵌的交付文档**(28 个 `<template>`, ~20 MB) ——
  6. 那是产物/交付件内容, 却因为"门户外壳也在同一个文件里"而整体进了版本管理。
  7. 本器把两者拆开: **外壳受管、内嵌件与合成结果不入库**, 且装配结果与拆之前**逐字节相同**(`--verify` 证明)。
  8. ## 目录约定
  9. release/portal.html ← 产物: 装配结果 (不入 git; 服务/网关直接读它)
  10. release/portal_src/shell.html ← **受管**: 门户外壳 (样式/脚本/导航/面板), 内嵌件处留标记
  11. release/portal_src/templates/<id>.html ← 产物: 28 个内嵌交付文档正文 (治理清单分册/报告/仿真台面板)
  12. release/portal_src/governance_sources.json ← 产物: 脱敏资料索引 + source_sha256
  13. release/portal_src/manifest.json ← **受管**: 各件 sha256 + 期望的 portal.html sha256 + 说明
  14. release/portal_src/README.md ← **受管**: 人读的重建说明
  15. ## 标记 (shell.html 里)
  16. <!--@TEMPLATE:<id>--> → <template id="<id>">…templates/<id>.html…</template>
  17. <!--@GOVERNANCE_SOURCES--> → governance_sources.json 内容 (位于 <script type="application/json"> 内)
  18. contract-claims 段**不在** shell 里: 它是产物, 装配最后交 guanlan_portal_inject_claims.py 注入。
  19. ## 行尾约定 (踩过)
  20. 门户行尾是 **LF**。Python 文本模式在 Windows 上写文件会把 `\n` 变成 `\r\n` —— 20 MB 文件整体被改写,
  21. `/api/version` 里的 portal_sha256 指纹随之变化。所以本器全程**字节级读写**(`rd`/`wr`), 并在装配后自检"无 CRLF";
  22. 另外两个就地注入器 (`guanlan_portal_fix_anchors.py` / `guanlan_portal_inject_claims.py`) 也一并加了 `newline=""`。
  23. ## 用法
  24. python scripts/portal_build.py --extract # 一次性: 从现有 portal.html 拆出 portal_src/
  25. python scripts/portal_build.py # 装配 → release/portal.html (含 claims)
  26. python scripts/portal_build.py --no-claims # 只装配外壳与内嵌件
  27. python scripts/portal_build.py --verify # 装配到临时文件, 与现有 portal.html **逐字节**比对
  28. python scripts/portal_build.py --check # 各源件与 manifest 的 sha256 是否漂移
  29. """
  30. from __future__ import annotations
  31. import argparse
  32. import hashlib
  33. import json
  34. import pathlib
  35. import re
  36. import sys
  37. import tempfile
  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. PORTAL = P.PORTAL
  42. SRC = P.RELEASE / 'portal_src'
  43. TPL_DIR = SRC / 'templates'
  44. SHELL = SRC / 'shell.html'
  45. GOV = SRC / 'governance_sources.json'
  46. MANIFEST = SRC / 'manifest.json'
  47. RE_TPL = re.compile(r'<template id="([^"]+)">(.*?)</template>', re.S)
  48. RE_TPL_MARK = re.compile(r'<!--@TEMPLATE:([^>]+?)-->')
  49. RE_GOV = re.compile(r'(<script type="application/json" id="governance-sources">)(.*?)(</script>)', re.S)
  50. RE_GOV_MARK = re.compile(r'<!--@GOVERNANCE_SOURCES-->')
  51. RE_CLAIMS = re.compile(r'<section id="contract-claims"[^>]*>.*?</section><!--/contract-claims-->', re.S)
  52. CRLF = b'\r\n'
  53. def rd(p: pathlib.Path) -> str:
  54. """字节级读: 不做换行转换 (门户行尾约定 = LF; 文本模式在 Windows 上会把 \\n 变 \\r\\n)。"""
  55. return p.read_bytes().decode('utf-8')
  56. def wr(p: pathlib.Path, s: str) -> None:
  57. """字节级写: 同上, 保证装配结果与拆之前逐字节可比。"""
  58. p.write_bytes(s.encode('utf-8'))
  59. def sha(b) -> str:
  60. return hashlib.sha256(b if isinstance(b, bytes) else b.encode('utf-8')).hexdigest()
  61. # ────────────────────────────────────────────────────────────── 拆 (一次性)
  62. def do_extract() -> int:
  63. if not PORTAL.is_file():
  64. print(f'[X] 门户不存在: {P.rel(PORTAL)}')
  65. return 1
  66. raw = rd(PORTAL)
  67. orig_sha = sha(raw)
  68. TPL_DIR.mkdir(parents=True, exist_ok=True)
  69. items = {}
  70. for m in RE_TPL.finditer(raw):
  71. tid, body = m.group(1), m.group(2)
  72. wr(TPL_DIR / f'{tid}.html', body)
  73. items[tid] = dict(file=f'templates/{tid}.html', bytes=len(body.encode('utf-8')), sha256=sha(body))
  74. shell = RE_TPL.sub(lambda m: f'<!--@TEMPLATE:{m.group(1)}-->', raw)
  75. g = RE_GOV.search(shell)
  76. if not g:
  77. print('[X] 找不到 governance-sources 脚本块')
  78. return 1
  79. gov_body = g.group(2)
  80. json.loads(gov_body) # 自检: 必须是合法 JSON
  81. wr(GOV, gov_body)
  82. shell = shell[:g.start(2)] + '<!--@GOVERNANCE_SOURCES-->' + shell[g.end(2):]
  83. n_claims = len(RE_CLAIMS.findall(shell))
  84. shell = RE_CLAIMS.sub('', shell) # claims 是产物: 不进外壳
  85. wr(SHELL, shell)
  86. man = dict(
  87. built_from=dict(portal=P.rel(PORTAL), portal_sha256=orig_sha),
  88. shell=dict(file='shell.html', bytes=len(shell.encode('utf-8')), sha256=sha(shell)),
  89. governance_sources=dict(file='governance_sources.json',
  90. bytes=len(gov_body.encode('utf-8')), sha256=sha(gov_body)),
  91. templates=dict(count=len(items), items=items),
  92. claims_section_stripped=n_claims,
  93. expected_portal_sha256=orig_sha,
  94. line_ending='LF',
  95. notes=[
  96. 'shell.html = 门户外壳 (CSS/JS/导航/面板结构), 含 <!--@TEMPLATE:id--> 与 <!--@GOVERNANCE_SOURCES--> 标记;',
  97. 'templates/ 与 governance_sources.json 是**产物/交付件内容** (按"产物不进 git"的约定不入库); '
  98. 'manifest 里留 sha256 以便漂移检测 (--check);',
  99. 'contract-claims 段由 scripts/guanlan_portal_inject_claims.py 在装配最后注入 (内容来自 outputs/<场>/guanlan/…);',
  100. '锚点修复 scripts/guanlan_portal_fix_anchors.py 的输出是**代码**, 已固化在 shell.html 里;',
  101. '行尾必须是 LF: 三个脚本 (本器 + 两个注入器) 都已用字节级/newline="" 写文件, 装配后有 CRLF 自检。',
  102. ])
  103. wr(MANIFEST, json.dumps(man, ensure_ascii=False, indent=1))
  104. print(f'拆出: shell.html {man["shell"]["bytes"]/1e3:.1f} KB · templates/ {len(items)} 个 '
  105. f'({sum(v["bytes"] for v in items.values())/1e6:.2f} MB) · governance_sources.json '
  106. f'{man["governance_sources"]["bytes"]/1e3:.1f} KB · 剥离 claims 段 {n_claims} 处')
  107. print(f'原门户 sha256 {orig_sha[:16]} → 记为期望值; 下一步跑 --verify 验逐字节一致')
  108. return 0
  109. # ────────────────────────────────────────────────────────────── 装
  110. def assemble(out: pathlib.Path, claims: bool = True) -> tuple[str, dict]:
  111. if not SHELL.is_file():
  112. raise SystemExit(f'缺外壳 {P.rel(SHELL)}; 先跑 --extract')
  113. if not TPL_DIR.is_dir():
  114. raise SystemExit(f'缺内嵌件目录 {P.rel(TPL_DIR)}')
  115. s = rd(SHELL)
  116. rep = dict(templates=0, sources=0)
  117. def sub_tpl(m):
  118. tid = m.group(1)
  119. f = TPL_DIR / f'{tid}.html'
  120. if not f.is_file():
  121. raise SystemExit(f'缺模板 {P.rel(f)}')
  122. rep['templates'] += 1
  123. return f'<template id="{tid}">' + rd(f) + '</template>'
  124. s = RE_TPL_MARK.sub(sub_tpl, s)
  125. if RE_TPL_MARK.search(s):
  126. raise SystemExit('仍有模板标记未替换 (shell 与 templates/ 不一致)')
  127. if RE_GOV_MARK.search(s):
  128. if not GOV.is_file():
  129. raise SystemExit(f'缺 {P.rel(GOV)}')
  130. rep['sources'] = 1
  131. s = RE_GOV_MARK.sub(lambda m: rd(GOV), s, count=1)
  132. wr(out, s)
  133. if claims:
  134. inj = ROOT / 'scripts' / 'guanlan_portal_inject_claims.py'
  135. if inj.is_file():
  136. import importlib.util
  137. spec = importlib.util.spec_from_file_location('_portal_inject', inj)
  138. mod = importlib.util.module_from_spec(spec)
  139. spec.loader.exec_module(mod)
  140. rep['claims_sha'] = mod.inject(out, out)[:16]
  141. else:
  142. rep['claims_sha'] = '(无注入器, 跳过)'
  143. b = out.read_bytes()
  144. if b.count(CRLF):
  145. raise SystemExit(f'装配结果里出现 {b.count(CRLF):,} 处 CRLF —— 门户行尾必须是 LF; '
  146. f'多半是某个注入器又用了文本模式写文件 (查 scripts/guanlan_portal_*.py 的 write_text)')
  147. return sha(b), rep
  148. def do_build(claims=True) -> int:
  149. h, rep = assemble(PORTAL, claims)
  150. print(f'装配完成 → {P.rel(PORTAL)} sha256 {h[:16]} '
  151. f'(内嵌件 {rep["templates"]} 个, 资料索引 {rep["sources"]} 处, claims {rep.get("claims_sha")})')
  152. return 0
  153. def do_verify() -> int:
  154. if not PORTAL.is_file():
  155. print(f'[X] 现有门户不存在: {P.rel(PORTAL)}')
  156. return 1
  157. cur = PORTAL.read_bytes()
  158. with tempfile.TemporaryDirectory() as td:
  159. tmp = pathlib.Path(td) / 'portal.html'
  160. _, rep = assemble(tmp, claims=True)
  161. built = tmp.read_bytes()
  162. print(f'现有 {P.rel(PORTAL)} {len(cur)/1e6:.2f} MB sha256 {sha(cur)[:16]}')
  163. print(f'装配结果 {len(built)/1e6:.2f} MB sha256 {sha(built)[:16]}')
  164. print(f' 内嵌件 {rep["templates"]} 个 · 资料索引 {rep["sources"]} 处 · claims {rep.get("claims_sha")}')
  165. if built == cur:
  166. print(' 逐字节一致 ✔ (拆→装回到同一个文件: 装配链可信, 产物可重建)')
  167. return 0
  168. i = next((k for k in range(min(len(cur), len(built))) if cur[k] != built[k]), min(len(cur), len(built)))
  169. print(f' ✘ 不一致: 首个差异字节 @{i:,} (现有 {len(cur):,} / 装配 {len(built):,})')
  170. print(f' 现有: …{cur[max(0,i-70):i+70].decode("utf-8","replace")!r}…')
  171. print(f' 装配: …{built[max(0,i-70):i+70].decode("utf-8","replace")!r}…')
  172. return 3
  173. def do_check() -> int:
  174. if not MANIFEST.is_file():
  175. print(f'[X] 缺 {P.rel(MANIFEST)}; 先跑 --extract')
  176. return 1
  177. man = json.loads(rd(MANIFEST))
  178. bad = 0
  179. for label, f, h in (('shell', SHELL, man['shell']['sha256']),
  180. ('governance_sources', GOV, man['governance_sources']['sha256'])):
  181. got = sha(f.read_bytes()) if f.is_file() else '(缺)'
  182. if got != h:
  183. bad += 1
  184. print(f' [{"OK" if got == h else "漂移"}] {label:20s} {P.rel(f)}')
  185. miss = 0
  186. for tid, it in man['templates']['items'].items():
  187. f = SRC / it['file']
  188. got = sha(f.read_bytes()) if f.is_file() else '(缺)'
  189. if got != it['sha256']:
  190. bad += 1
  191. miss += 1
  192. if miss <= 3:
  193. print(f' [漂移] template {tid:34s} {got[:16]} != manifest {it["sha256"][:16]}')
  194. if miss > 3:
  195. print(f' … 另有 {miss-3} 个模板漂移')
  196. print(f'结论: {"全部与 manifest 一致 ✔" if not bad else f"{bad} 处漂移"}')
  197. if PORTAL.is_file():
  198. cur = sha(PORTAL.read_bytes())
  199. print(f' 现有门户 sha256 {cur[:16]} (manifest 期望 {man["expected_portal_sha256"][:16]})'
  200. f'{" ← 一致" if cur == man["expected_portal_sha256"] else " ← 已变 (重算过/注入过)"}')
  201. return 0 if not bad else 1
  202. if __name__ == '__main__':
  203. # 控制台可能是 GBK (Windows 中文默认 936): 正文里的 ✔/✘ 等符号编不出来会直接抛 UnicodeEncodeError
  204. # (校验结论明明通过了却因为 print 崩掉, 退出码变成 1)。降级为 '?' 而不是崩。
  205. for _s in (sys.stdout, sys.stderr):
  206. try:
  207. _s.reconfigure(errors='replace')
  208. except Exception:
  209. pass
  210. ap = argparse.ArgumentParser()
  211. g = ap.add_mutually_exclusive_group()
  212. g.add_argument('--extract', action='store_true', help='从现有 portal.html 拆出 portal_src/ (一次性)')
  213. g.add_argument('--verify', action='store_true', help='装配到临时文件并与现有门户逐字节比对')
  214. g.add_argument('--check', action='store_true', help='源件与 manifest 的 sha256 漂移检查')
  215. ap.add_argument('--no-claims', action='store_true', help='装配时不注入契约结论段')
  216. a = ap.parse_args()
  217. sys.exit(do_extract() if a.extract else do_verify() if a.verify else
  218. do_check() if a.check else do_build(claims=not a.no_claims))