| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257 |
- #!/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))
|