portal_build.py 17 KB

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