portal_build.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  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. import time
  39. ROOT = pathlib.Path(__file__).resolve().parents[1]
  40. sys.path.insert(0, str(ROOT))
  41. from src import paths as P # noqa: E402
  42. PORTAL = P.PORTAL
  43. SRC = P.RELEASE / 'portal_src'
  44. TPL_DIR = SRC / 'templates'
  45. SHELL = SRC / 'shell.html'
  46. GOV = SRC / 'governance_sources.json'
  47. MANIFEST = SRC / 'manifest.json'
  48. RE_TPL = re.compile(r'<template id="([^"]+)">(.*?)</template>', re.S)
  49. RE_TPL_MARK = re.compile(r'<!--@TEMPLATE:([^>]+?)-->')
  50. RE_GOV = re.compile(r'(<script type="application/json" id="governance-sources">)(.*?)(</script>)', re.S)
  51. RE_GOV_MARK = re.compile(r'<!--@GOVERNANCE_SOURCES-->')
  52. RE_CLAIMS = re.compile(r'<section id="contract-claims"[^>]*>.*?</section><!--/contract-claims-->', re.S)
  53. CRLF = b'\r\n'
  54. def rd(p: pathlib.Path) -> str:
  55. """字节级读: 不做换行转换 (门户行尾约定 = LF; 文本模式在 Windows 上会把 \\n 变 \\r\\n)。"""
  56. return p.read_bytes().decode('utf-8')
  57. def wr(p: pathlib.Path, s: str) -> None:
  58. """字节级写: 同上, 保证装配结果与拆之前逐字节可比。"""
  59. p.write_bytes(s.encode('utf-8'))
  60. def sha(b) -> str:
  61. return hashlib.sha256(b if isinstance(b, bytes) else b.encode('utf-8')).hexdigest()
  62. # ────────────────────────────────────────────────────────────── 拆 (一次性)
  63. def do_extract() -> int:
  64. if not PORTAL.is_file():
  65. print(f'[X] 门户不存在: {P.rel(PORTAL)}')
  66. return 1
  67. raw = rd(PORTAL)
  68. orig_sha = sha(raw)
  69. TPL_DIR.mkdir(parents=True, exist_ok=True)
  70. items = {}
  71. for m in RE_TPL.finditer(raw):
  72. tid, body = m.group(1), m.group(2)
  73. wr(TPL_DIR / f'{tid}.html', body)
  74. items[tid] = dict(file=f'templates/{tid}.html', bytes=len(body.encode('utf-8')), sha256=sha(body))
  75. shell = RE_TPL.sub(lambda m: f'<!--@TEMPLATE:{m.group(1)}-->', raw)
  76. g = RE_GOV.search(shell)
  77. if not g:
  78. print('[X] 找不到 governance-sources 脚本块')
  79. return 1
  80. gov_body = g.group(2)
  81. json.loads(gov_body) # 自检: 必须是合法 JSON
  82. wr(GOV, gov_body)
  83. shell = shell[:g.start(2)] + '<!--@GOVERNANCE_SOURCES-->' + shell[g.end(2):]
  84. n_claims = len(RE_CLAIMS.findall(shell))
  85. shell = RE_CLAIMS.sub('', shell) # claims 是产物: 不进外壳
  86. wr(SHELL, shell)
  87. man = dict(
  88. built_from=dict(portal=P.rel(PORTAL), portal_sha256=orig_sha),
  89. shell=dict(file='shell.html', bytes=len(shell.encode('utf-8')), sha256=sha(shell)),
  90. governance_sources=dict(file='governance_sources.json',
  91. bytes=len(gov_body.encode('utf-8')), sha256=sha(gov_body)),
  92. templates=dict(count=len(items), items=items),
  93. claims_section_stripped=n_claims,
  94. expected_portal_sha256=orig_sha,
  95. line_ending='LF',
  96. notes=[
  97. 'shell.html = 门户外壳 (CSS/JS/导航/面板结构), 含 <!--@TEMPLATE:id--> 与 <!--@GOVERNANCE_SOURCES--> 标记;',
  98. 'templates/ 与 governance_sources.json 是**产物/交付件内容** (按"产物不进 git"的约定不入库); '
  99. 'manifest 里留 sha256 以便漂移检测 (--check);',
  100. 'contract-claims 段由 scripts/guanlan_portal_inject_claims.py 在装配最后注入 (内容来自 outputs/<场>/guanlan/…);',
  101. '锚点修复 scripts/guanlan_portal_fix_anchors.py 的输出是**代码**, 已固化在 shell.html 里;',
  102. '行尾必须是 LF: 三个脚本 (本器 + 两个注入器) 都已用字节级/newline="" 写文件, 装配后有 CRLF 自检。',
  103. ])
  104. wr(MANIFEST, json.dumps(man, ensure_ascii=False, indent=1))
  105. print(f'拆出: shell.html {man["shell"]["bytes"]/1e3:.1f} KB · templates/ {len(items)} 个 '
  106. f'({sum(v["bytes"] for v in items.values())/1e6:.2f} MB) · governance_sources.json '
  107. f'{man["governance_sources"]["bytes"]/1e3:.1f} KB · 剥离 claims 段 {n_claims} 处')
  108. print(f'原门户 sha256 {orig_sha[:16]} → 记为期望值; 下一步跑 --verify 验逐字节一致')
  109. return 0
  110. # ────────────────────────────────────────────────────────────── 装
  111. def assemble(out: pathlib.Path, claims: bool = True) -> tuple[str, dict]:
  112. if not SHELL.is_file():
  113. raise SystemExit(f'缺外壳 {P.rel(SHELL)}; 先跑 --extract')
  114. if not TPL_DIR.is_dir():
  115. raise SystemExit(f'缺内嵌件目录 {P.rel(TPL_DIR)}')
  116. s = rd(SHELL)
  117. rep = dict(templates=0, sources=0)
  118. def sub_tpl(m):
  119. tid = m.group(1)
  120. f = TPL_DIR / f'{tid}.html'
  121. if not f.is_file():
  122. raise SystemExit(f'缺模板 {P.rel(f)}')
  123. rep['templates'] += 1
  124. return f'<template id="{tid}">' + rd(f) + '</template>'
  125. s = RE_TPL_MARK.sub(sub_tpl, s)
  126. if RE_TPL_MARK.search(s):
  127. raise SystemExit('仍有模板标记未替换 (shell 与 templates/ 不一致)')
  128. if RE_GOV_MARK.search(s):
  129. if not GOV.is_file():
  130. raise SystemExit(f'缺 {P.rel(GOV)}')
  131. rep['sources'] = 1
  132. s = RE_GOV_MARK.sub(lambda m: rd(GOV), s, count=1)
  133. wr(out, s)
  134. if claims:
  135. inj = ROOT / 'scripts' / 'guanlan_portal_inject_claims.py'
  136. if inj.is_file():
  137. import importlib.util
  138. spec = importlib.util.spec_from_file_location('_portal_inject', inj)
  139. mod = importlib.util.module_from_spec(spec)
  140. spec.loader.exec_module(mod)
  141. try:
  142. rep['claims_sha'] = mod.inject(out, out)[:16]
  143. except FileNotFoundError as e:
  144. # 产物被挪走时 (scripts/products_state.py --off) 契约派生物不在 —— 这不是程序坏了:
  145. # 契约段的来源就是产物, 产物不在则内容不在。给一句人话, 别丢裸 traceback。
  146. raise SystemExit(
  147. f'契约结论段生成不了: 缺产物 {getattr(e, "filename", "?")}\n'
  148. f' 原因: 门户正文的结论段由事实契约派生物渲染, 产物被挪走时它自然不在 '
  149. f'(空状态属预期)。\n'
  150. f' 三选一: ① scripts/products_state.py --on 还原产物后重跑;\n'
  151. f' ② 加 --no-claims 只装外壳与内嵌件 (装出的门户会缺这一节);\n'
  152. f' ③ 先 scripts/rebuild_from_raw.py 重新产出。')
  153. else:
  154. rep['claims_sha'] = '(无注入器, 跳过)'
  155. b = out.read_bytes()
  156. if b.count(CRLF):
  157. raise SystemExit(f'装配结果里出现 {b.count(CRLF):,} 处 CRLF —— 门户行尾必须是 LF; '
  158. f'多半是某个注入器又用了文本模式写文件 (查 scripts/guanlan_portal_*.py 的 write_text)')
  159. return sha(b), rep
  160. def do_build(claims=True) -> int:
  161. # 先装到临时文件, 成功后再原子替换 —— 否则一旦中途失败 (产物不在 → 契约段生成不了),
  162. # 就地写坏的就是正在被网关读的那个 20 MB 门户, 而且它不在 git 里, 没法 checkout 回来
  163. # (2026-09-11 实测踩到: --no-claims 直接把 release/portal.html 换成了缺契约段的版本)。
  164. tmp = PORTAL.with_name(PORTAL.name + '.tmp')
  165. try:
  166. h, rep = assemble(tmp, claims)
  167. tmp.replace(PORTAL)
  168. finally:
  169. if tmp.exists():
  170. tmp.unlink()
  171. print(f'装配完成 → {P.rel(PORTAL)} sha256 {h[:16]} '
  172. f'(内嵌件 {rep["templates"]} 个, 资料索引 {rep["sources"]} 处, claims {rep.get("claims_sha")})')
  173. return 0
  174. def do_verify() -> int:
  175. if not PORTAL.is_file():
  176. print(f'[X] 现有门户不存在: {P.rel(PORTAL)}')
  177. return 1
  178. cur = PORTAL.read_bytes()
  179. with tempfile.TemporaryDirectory() as td:
  180. tmp = pathlib.Path(td) / 'portal.html'
  181. _, rep = assemble(tmp, claims=True)
  182. built = tmp.read_bytes()
  183. print(f'现有 {P.rel(PORTAL)} {len(cur)/1e6:.2f} MB sha256 {sha(cur)[:16]}')
  184. print(f'装配结果 {len(built)/1e6:.2f} MB sha256 {sha(built)[:16]}')
  185. print(f' 内嵌件 {rep["templates"]} 个 · 资料索引 {rep["sources"]} 处 · claims {rep.get("claims_sha")}')
  186. if built == cur:
  187. print(' 逐字节一致 ✔ (拆→装回到同一个文件: 装配链可信, 产物可重建)')
  188. return 0
  189. i = next((k for k in range(min(len(cur), len(built))) if cur[k] != built[k]), min(len(cur), len(built)))
  190. print(f' ✘ 不一致: 首个差异字节 @{i:,} (现有 {len(cur):,} / 装配 {len(built):,})')
  191. print(f' 现有: …{cur[max(0,i-70):i+70].decode("utf-8","replace")!r}…')
  192. print(f' 装配: …{built[max(0,i-70):i+70].decode("utf-8","replace")!r}…')
  193. return 3
  194. def do_rebaseline() -> int:
  195. """把当前 shell.html / portal.html 记为新的基线 (manifest 里的 sha256)。
  196. 为什么需要它: manifest 的 sha256 是"漂移检测"的基准。**有意**改了门户外壳 (例如 2026-09-16
  197. 按用户令在菜单里加「数据重算」) 之后, `--check` 会如实报漂移 —— 这是对的, 但不能让人只有
  198. "手改 JSON" 或 "跑 --extract 重新拆包" 两条路: 后者会用 portal.html 反过来覆盖 shell.html
  199. (而装配过程会注入契约结论段/内嵌模板), 一不小心就把手工维护的外壳冲掉。
  200. 本命令只改 manifest 里的两个基准值, 并记下是谁、什么时候、为什么重基线。
  201. """
  202. if not MANIFEST.is_file():
  203. print(f'[X] 缺 {P.rel(MANIFEST)}; 先跑 --extract')
  204. return 1
  205. if not (SHELL.is_file() and PORTAL.is_file()):
  206. print('[X] shell.html / portal.html 不全, 不能重基线')
  207. return 1
  208. man = json.loads(rd(MANIFEST))
  209. old_shell = man.get('shell', {}).get('sha256', '?')[:16]
  210. old_portal = (man.get('expected_portal_sha256') or '?')[:16]
  211. shell_b = SHELL.read_bytes()
  212. portal_b = PORTAL.read_bytes()
  213. man['shell'] = dict(file='shell.html', bytes=len(shell_b), sha256=sha(shell_b))
  214. man['expected_portal_sha256'] = sha(portal_b)
  215. man['notes'] = list(man.get('notes') or []) + [
  216. f'重基线 {time.strftime("%Y-%m-%d %H:%M")}: shell {old_shell}→{sha(shell_b)[:16]}, '
  217. f'portal {old_portal}→{sha(portal_b)[:16]} (有意改动门户外壳后重设漂移基准)']
  218. wr(MANIFEST, json.dumps(man, ensure_ascii=False, indent=1))
  219. print(f'已重基线 {P.rel(MANIFEST)}')
  220. print(f' shell.html {old_shell} → {sha(shell_b)[:16]} ({len(shell_b):,} B)')
  221. print(f' portal.html {old_portal} → {sha(portal_b)[:16]} ({len(portal_b):,} B)')
  222. print(' 下一步: --check 应显示"全部与 manifest 一致 ✔"')
  223. return 0
  224. def do_check() -> int:
  225. if not MANIFEST.is_file():
  226. print(f'[X] 缺 {P.rel(MANIFEST)}; 先跑 --extract')
  227. return 1
  228. man = json.loads(rd(MANIFEST))
  229. bad = 0
  230. for label, f, h in (('shell', SHELL, man['shell']['sha256']),
  231. ('governance_sources', GOV, man['governance_sources']['sha256'])):
  232. got = sha(f.read_bytes()) if f.is_file() else '(缺)'
  233. if got != h:
  234. bad += 1
  235. print(f' [{"OK" if got == h else "漂移"}] {label:20s} {P.rel(f)}')
  236. miss = 0
  237. for tid, it in man['templates']['items'].items():
  238. f = SRC / it['file']
  239. got = sha(f.read_bytes()) if f.is_file() else '(缺)'
  240. if got != it['sha256']:
  241. bad += 1
  242. miss += 1
  243. if miss <= 3:
  244. print(f' [漂移] template {tid:34s} {got[:16]} != manifest {it["sha256"][:16]}')
  245. if miss > 3:
  246. print(f' … 另有 {miss-3} 个模板漂移')
  247. print(f'结论: {"全部与 manifest 一致 ✔" if not bad else f"{bad} 处漂移"}')
  248. if PORTAL.is_file():
  249. cur = sha(PORTAL.read_bytes())
  250. print(f' 现有门户 sha256 {cur[:16]} (manifest 期望 {man["expected_portal_sha256"][:16]})'
  251. f'{" ← 一致" if cur == man["expected_portal_sha256"] else " ← 已变 (重算过/注入过)"}')
  252. return 0 if not bad else 1
  253. if __name__ == '__main__':
  254. # 控制台可能是 GBK (Windows 中文默认 936): 正文里的 ✔/✘ 编不出来会直接抛 UnicodeEncodeError
  255. # (校验结论明明通过了却因为 print 崩掉, 退出码变成 1)。降级为 '?' 而不是崩。见 src/console.py。
  256. from src import console
  257. console.soft()
  258. ap = argparse.ArgumentParser()
  259. g = ap.add_mutually_exclusive_group()
  260. g.add_argument('--extract', action='store_true', help='从现有 portal.html 拆出 portal_src/ (一次性)')
  261. g.add_argument('--verify', action='store_true', help='装配到临时文件并与现有门户逐字节比对')
  262. g.add_argument('--check', action='store_true', help='源件与 manifest 的 sha256 漂移检查')
  263. g.add_argument('--rebaseline', action='store_true',
  264. help='**有意**改了 shell.html/portal.html 后, 把当前状态记为新的漂移基准 (只改 manifest 的两个 sha, 不碰文件)')
  265. ap.add_argument('--no-claims', action='store_true', help='装配时不注入契约结论段')
  266. a = ap.parse_args()
  267. sys.exit(do_extract() if a.extract else do_verify() if a.verify else
  268. do_rebaseline() if a.rebaseline else
  269. do_check() if a.check else do_build(claims=not a.no_claims))