Ver código fonte

门户拆包: 壳受管, 内嵌件与 20MB 合成结果移出版本管理

release/portal.html 20.23 MB, 其中 99.3% 是内嵌的交付文档正文 (28 个 <template>:
治理清单分册/单机与整机报告/仿真台面板), 只有 138 KB 是门户自己的壳 (样式/脚本/导航/面板)。
原先整份文件都在库里, 每轮重算产物都往 git 塞 20 MB。

新增 scripts/portal_build.py:
  --extract 从现有门户拆出 release/portal_src/ (壳留 <!--@TEMPLATE:id--> 与
            <!--@GOVERNANCE_SOURCES--> 标记; 契约结论段是产物, 一并剥离)
  (默认)    装配 → release/portal.html, 末尾交 guanlan_portal_inject_claims.py 注入结论段
  --verify  装配到临时文件, 与现有门户逐字节比对
  --check   源件与 manifest 的 sha256 漂移检查

实测: --verify 两行同为 sha256 9b6aabeb6ca18d15 (20,226,052 B), 逐字节一致; --check 全部一致。

行尾坑: 门户通体 LF。Python 文本模式在 Windows 上写文件会把 \n 变 \r\n —— 20MB 整体改写,
/api/version 的 portal_sha256 与页面指纹全变。两个就地注入器的 write_text 补 newline="",
portal_build.py 全程字节级读写 (rd/wr) 并在装配后做 CRLF 自检; 控制台 GBK 编不出 ✔ 时
降级为 '?' 而不是抛 UnicodeEncodeError (校验通过却因 print 崩掉、退出码变 1)。

入库: shell.html / manifest.json (含各件 sha256 与期望门户 sha256) / README.md / 装配器。
不入库: templates/ (20.04 MB 交付件正文)、governance_sources.json、release/portal.html
(配套 git rm --cached, 磁盘文件原样保留, 门户仍随包分发并被服务读取)。
zhouyang.xie 4 semanas atrás
pai
commit
54ab6a1a75

+ 10 - 0
.gitignore

@@ -39,3 +39,13 @@ runtime/
 
 # 人工检查空状态时的产物临时存放处 (scripts/products_state.py --off/--on)
 _products_off/
+
+# ── 门户: 壳受管, 内嵌件与合成结果不入库 (2026-09-11) ─────────────────────────
+# release/portal.html 20.23 MB, 其中 99.3% 是内嵌的交付文档正文 (28 个 <template>)。
+# 拆成 release/portal_src/ (shell.html 138 KB 受管 + manifest.json 记 sha256), 装配结果与
+# 内嵌件按"产物不进 git"处理。scripts/portal_build.py --verify 证明拆→装逐字节一致。
+# 注意: 对**已跟踪**的 release/portal.html 无效, 配套一次 `git rm --cached release/portal.html`
+# (只从索引移除, 磁盘文件原样保留; 门户仍随包分发、仍被服务读取)。
+release/portal.html
+release/portal_src/templates/
+release/portal_src/governance_sources.json

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 29
release/portal.html


+ 58 - 0
release/portal_src/README.md

@@ -0,0 +1,58 @@
+# release/portal_src —— 门户的"受管外壳 + 不入库内嵌件"(2026-09-11)
+
+## 这是什么
+
+`release/portal.html`(20.23 MB 单文件门户)里 **99.3% 是内嵌的交付文档正文** —— 28 个
+`<template>`(治理清单分册、单机/整机报告、仿真台面板…),只有约 138 KB 是门户自己的壳
+(样式、脚本、导航、面板结构)。原先整份文件都在版本库里,于是每次重算产物都往 git 里塞 20 MB。
+
+本目录把两者拆开:**壳受管、内嵌件与合成结果不入库**,而且拆→装能回到**逐字节相同**的文件。
+
+| 路径 | 入库 | 说明 |
+|---|---|---|
+| `shell.html` | ✅ | 门户外壳 138.5 KB。内嵌件处留标记 `<!--@TEMPLATE:<id>-->`;资料索引处留 `<!--@GOVERNANCE_SOURCES-->` |
+| `manifest.json` | ✅ | 各件 sha256、条数、期望的 `portal.html` sha256、行尾约定、说明 |
+| `README.md` | ✅ | 本文件 |
+| `templates/<id>.html` | ❌ 产物 | 28 个内嵌交付文档正文,共 20.04 MB(交付件内容) |
+| `governance_sources.json` | ❌ 产物 | 脱敏资料索引 + `source_sha256`,1.5 KB |
+| `../portal.html` | ❌ 产物 | 装配结果;服务/网关直接读它 |
+
+未入库的三项**不随包体缺失**:它们就在本目录/上一层,随包分发、参与运行;只是不进版本库。
+`manifest.json` 里留了它们的 sha256,用来做漂移检测。
+
+## 重建
+
+```bat
+.venv\Scripts\python.exe scripts\portal_build.py            :: 装配 → release\portal.html (含契约结论段)
+.venv\Scripts\python.exe scripts\portal_build.py --verify   :: 装配到临时文件, 与现有门户逐字节比对
+.venv\Scripts\python.exe scripts\portal_build.py --check    :: 各源件与 manifest 的 sha256 漂移检查
+.venv\Scripts\python.exe scripts\portal_build.py --extract  :: 从现有门户重新拆出本目录 (一次性/重置用)
+.venv\Scripts\python.exe scripts\portal_build.py --no-claims:: 只装外壳与内嵌件, 不注入契约结论段
+```
+
+**期望结果**:`--verify` 打印两行相同的 sha256 并给出 `逐字节一致`,退出码 0。
+
+当前基线(本包,出厂状态):
+
+```
+release/portal.html   20,226,052 B   sha256 9b6aabeb6ca18d15…
+```
+
+## 两个必须知道的坑
+
+1. **行尾必须是 LF**。门户通体 LF(0 处 CRLF)。Python 文本模式在 Windows 上写文件会把 `\n`
+   转成 `\r\n` —— 20 MB 文件整体改写,`/api/version` 里的 `portal_sha256` 与页面指纹全变。
+   所以 `portal_build.py` 全程字节级读写(`rd`/`wr`),两个就地注入器
+   (`guanlan_portal_fix_anchors.py`、`guanlan_portal_inject_claims.py`)写文件都带 `newline=""`,
+   装配末尾还有一道 CRLF 自检 —— 触发就直接报错,不留一个"看起来一样"的文件。
+2. **契约结论段是产物,不在壳里**。`<section id="contract-claims">` 由
+   `scripts/guanlan_portal_inject_claims.py` 在装配最后注入,内容来自
+   `outputs/<场站>/guanlan/derived/portal_claims.json`(事实契约派生物)。
+   因此 `--extract` 会把它从壳里剥掉,装配时重新生成;**没有产物时装配出的门户会缺这一段**,
+   这正是"产物不在则内容不在"的预期行为。
+
+## 相关
+
+- 契约与产物地图:`docs/数据目录结构与落位约定_v0.2.md` §6
+- 产物进出 git 的约定与那次误提交的复盘:仓根 `.gitignore` 注释 + `_修复记录_20260911/README.md`
+- 人工检查空状态:`scripts/products_state.py --off/--on/--status`

+ 171 - 0
release/portal_src/manifest.json

@@ -0,0 +1,171 @@
+{
+ "built_from": {
+  "portal": "release/portal.html",
+  "portal_sha256": "9b6aabeb6ca18d15c3c0cd874dbb3dc1f820db050bdef7944e661d93d4ead843"
+ },
+ "shell": {
+  "file": "shell.html",
+  "bytes": 138544,
+  "sha256": "0a42ef684034be2f78cb8d38b16039d18a903d116d44154800d6abbaef8c3a71"
+ },
+ "governance_sources": {
+  "file": "governance_sources.json",
+  "bytes": 1512,
+  "sha256": "47f3aab9c4f83c8ab762714a94c2519802ebe62a508866b535df0036ba3d8381"
+ },
+ "templates": {
+  "count": 28,
+  "items": {
+   "tpl-U6_变桨液压仿真台.html": {
+    "file": "templates/tpl-U6_变桨液压仿真台.html.html",
+    "bytes": 52131,
+    "sha256": "1fcfd0dd350cafb20c0abeec857a29ee9828cf568cd5b34c23f4adcf960ca4b8"
+   },
+   "tpl-standard_panel_zh.html": {
+    "file": "templates/tpl-standard_panel_zh.html.html",
+    "bytes": 29270,
+    "sha256": "9ea213506ab22c0245953a1e3cd3c03aaaf14bc32f4025151a4559a5783c62ce"
+   },
+   "tpl-coverage_ch0_zh.html": {
+    "file": "templates/tpl-coverage_ch0_zh.html.html",
+    "bytes": 70141,
+    "sha256": "9a0de3ed599bc4b93b46c32e548dff8250a715d4a9b39d48ad211769ccbd7b06"
+   },
+   "tpl-如东传动链实际运行诊断_单文件版.html": {
+    "file": "templates/tpl-如东传动链实际运行诊断_单文件版.html.html",
+    "bytes": 5759924,
+    "sha256": "9cab9fd3b6b9c4f9ea1c22d6e1fb98b71d94b292390dbaddb6621591efd1c787"
+   },
+   "tpl-U6_液压公共站健康报告_客户版.html": {
+    "file": "templates/tpl-U6_液压公共站健康报告_客户版.html.html",
+    "bytes": 1007840,
+    "sha256": "47d770f7ae4855130685f3dc0a938fd7d9b340bc7e811dbb1b5be4ef320ea42d"
+   },
+   "tpl-如东海上风电场_整机综合诊断与风险评估_主轴冲击深挖修订版V2.1.html": {
+    "file": "templates/tpl-如东海上风电场_整机综合诊断与风险评估_主轴冲击深挖修订版V2.1.html.html",
+    "bytes": 3856574,
+    "sha256": "3927a7ffb6fb7dc1c0f01bdb1e635aa228744427e8baff46b2ec3f9a52ef9302"
+   },
+   "tpl-swt40_控制律仪表台_外发版.html": {
+    "file": "templates/tpl-swt40_控制律仪表台_外发版.html.html",
+    "bytes": 68602,
+    "sha256": "f6547d1ba9fadd04d63e04ded03588fb43b99e66133b1bca5d23dcb7c99eb8ca"
+   },
+   "tpl-report-主轴承.html": {
+    "file": "templates/tpl-report-主轴承.html.html",
+    "bytes": 752159,
+    "sha256": "999ae02e5e792645195816581d39f9690bf28f92c673d40df995907075b24328"
+   },
+   "tpl-report-齿轮箱.html": {
+    "file": "templates/tpl-report-齿轮箱.html.html",
+    "bytes": 3520452,
+    "sha256": "a424f05b112516158f1c239a85089d8ebe865fe7cbdaaf700ed73906f421e732"
+   },
+   "tpl-report-发电机.html": {
+    "file": "templates/tpl-report-发电机.html.html",
+    "bytes": 3098337,
+    "sha256": "fdd0fe65e4e20fdc59fb3735c3ca77a45cf98c10a15abbe913ed1b80afca055b"
+   },
+   "tpl-report-变桨.html": {
+    "file": "templates/tpl-report-变桨.html.html",
+    "bytes": 850894,
+    "sha256": "82d5ccfd20b5f59fdd043fd373f6656a684f6174cd94563431be5e2aea443400"
+   },
+   "tpl-governance-0.html": {
+    "file": "templates/tpl-governance-0.html.html",
+    "bytes": 27682,
+    "sha256": "83a6cfcfe2d4aa063ae9732c6f1ee2e5972cf3c044989ad094b2ef527ab93092"
+   },
+   "tpl-governance-1.html": {
+    "file": "templates/tpl-governance-1.html.html",
+    "bytes": 136129,
+    "sha256": "e40be3fa3b6177f8764801e794980a8fb840482c77994fe028384e0a8f246135"
+   },
+   "tpl-governance-2.html": {
+    "file": "templates/tpl-governance-2.html.html",
+    "bytes": 49979,
+    "sha256": "dd9e882775c62184f2f4cf7889377a54938a42285f78de54174ec2bf3b776980"
+   },
+   "tpl-governance-3.html": {
+    "file": "templates/tpl-governance-3.html.html",
+    "bytes": 61139,
+    "sha256": "bef801ab1f7a593605d2839ad37e9296d511dbbd6ae91a85d7fdc6cb281a6b2f"
+   },
+   "tpl-governance-4.html": {
+    "file": "templates/tpl-governance-4.html.html",
+    "bytes": 53963,
+    "sha256": "d16e27c091f00cf6b7460799f923c8c5bfe85f473e50dab5a6eee995aaa36360"
+   },
+   "tpl-governance-5.html": {
+    "file": "templates/tpl-governance-5.html.html",
+    "bytes": 56872,
+    "sha256": "e1f7736aab2d24957ab3b589a942a37d3a9e393670c4d9f599e28c2630571f55"
+   },
+   "tpl-governance-6.html": {
+    "file": "templates/tpl-governance-6.html.html",
+    "bytes": 53507,
+    "sha256": "e7bd17f6cb8ea6343e77d27fe4641bd75762797c4fd27e70880a6a5417d9605e"
+   },
+   "tpl-governance-7.html": {
+    "file": "templates/tpl-governance-7.html.html",
+    "bytes": 59624,
+    "sha256": "c31c920f5a6ea6d99d21a755e51f27c7394f8df902a8051f86412bac0dd7a9aa"
+   },
+   "tpl-governance-8.html": {
+    "file": "templates/tpl-governance-8.html.html",
+    "bytes": 60150,
+    "sha256": "7c0b86d88cbabe30e14f93c58848a5c0d2b43773a63007bad22262093ea937e4"
+   },
+   "tpl-governance-9.html": {
+    "file": "templates/tpl-governance-9.html.html",
+    "bytes": 55297,
+    "sha256": "8473cc316a1204f41cf582215e2e60dfe19ca8fb9e927d315c77ff5d799358d7"
+   },
+   "tpl-governance-10.html": {
+    "file": "templates/tpl-governance-10.html.html",
+    "bytes": 50953,
+    "sha256": "c886b7a5c00ca25c2ef2896c8d359ce7a8aaf3fe195ec0eec24fc0719857ae8f"
+   },
+   "tpl-governance-11.html": {
+    "file": "templates/tpl-governance-11.html.html",
+    "bytes": 34536,
+    "sha256": "760e5fa6cb508f24833c6fe0e5d4e3552d5c1f50bfab8def764027fa5adf0d3f"
+   },
+   "tpl-sc1-local.html": {
+    "file": "templates/tpl-sc1-local.html.html",
+    "bytes": 412,
+    "sha256": "b0f02617d97937ab822bade84ecd889cab731f27da316216c3014908611e42c6"
+   },
+   "tpl-swt-subsystem-1.html": {
+    "file": "templates/tpl-swt-subsystem-1.html.html",
+    "bytes": 68586,
+    "sha256": "c4a04fa2325d036db268cb02420edb230ea66875094c3a637e3b41318b002aaa"
+   },
+   "tpl-swt-subsystem-2.html": {
+    "file": "templates/tpl-swt-subsystem-2.html.html",
+    "bytes": 68592,
+    "sha256": "cb608b9cc29777d0cb3c006965c18381097de0d4b96929d336374a4f7371fac3"
+   },
+   "tpl-swt-subsystem-3.html": {
+    "file": "templates/tpl-swt-subsystem-3.html.html",
+    "bytes": 68595,
+    "sha256": "bd1af4fe48d52967b9afdc370bfa614248b6ef7a408fed545be95918e2810a5d"
+   },
+   "tpl-swt-subsystem-4.html": {
+    "file": "templates/tpl-swt-subsystem-4.html.html",
+    "bytes": 68595,
+    "sha256": "e4d9796efcde2cd05f544070dd3ce6cbc63a8638a8385c82c9dac78fd07583fa"
+   }
+  }
+ },
+ "claims_section_stripped": 1,
+ "expected_portal_sha256": "9b6aabeb6ca18d15c3c0cd874dbb3dc1f820db050bdef7944e661d93d4ead843",
+ "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 自检。"
+ ]
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 29 - 0
release/portal_src/shell.html


+ 1 - 1
scripts/guanlan_portal_fix_anchors.py

@@ -55,7 +55,7 @@ def inject(p: Path) -> tuple[bool, str]:
             bak = p.with_suffix(p.suffix + ".bak_anchor_" + time.strftime("%Y%m%d%H%M%S")); shutil.copy2(p, bak)
             s2 = s.replace(a, a + PATCH, 1)
             assert MARK in s2 and len(s2) > len(s), "注入后自检失败"
-            p.write_text(s2, encoding="utf-8")
+            p.write_text(s2, encoding="utf-8", newline="")   # 保持 LF: Windows 上默认会把整份门户改成 CRLF
             return True, f"已注入 ({p.name}, 备份 {bak.name}, 剥旧版 {n_old} 块, sha16 {hashlib.sha256(s2.encode()).hexdigest()[:16]})"
     return False, "未找到挂载点 (门户结构变了: 找 ovf.srcdoc 赋值处) → 未改动"
 

+ 3 - 1
scripts/guanlan_portal_inject_claims.py

@@ -43,7 +43,9 @@ def inject(src: Path, out: Path):
         m = re.search(r'(<div class="pg" id="findings"[^>]*>)(.*?)(</div>\s*<div class="pg" id=")', t, flags=re.S)
         assert m, "门户里找不到 findings 页块"
         t2 = t[:m.end(2)] + sec + t[m.end(2):]
-    out.write_text(t2, encoding="utf-8"); return hashlib.sha256(t2.encode("utf-8")).hexdigest()
+    # newline= 必须给: Windows 上 write_text 会把整份门户的 \\n 变成 \\r\\n —— 20MB 文件整体改写,
+    # portal_sha256 指纹随之变化 (2026-09-11 修)。门户行尾约定 = LF。
+    out.write_text(t2, encoding="utf-8", newline=""); return hashlib.sha256(t2.encode("utf-8")).hexdigest()
 
 
 if __name__ == "__main__":

+ 257 - 0
scripts/portal_build.py

@@ -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))

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff