diff --git a/.dump/_relation_browser_check_v13.py b/.dump/_relation_browser_check_v13.py
new file mode 100644
index 0000000..52733bc
--- /dev/null
+++ b/.dump/_relation_browser_check_v13.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+"""V13 关系索引真实 Chrome 冒烟:关系页只加载 relation core,不预取 metadata。"""
+from __future__ import annotations
+import os,re,shutil,subprocess,sys,tempfile
+from pathlib import Path
+
+TARGET=(Path(sys.argv[1]) if len(sys.argv)>1 else Path('dist/full/index.html')).resolve()
+
+
+def find_chrome():
+ env=os.environ.get('CHROME_BIN')
+ if env and Path(env).exists():return env
+ for name in ('google-chrome','chrome','chromium','chromium-browser'):
+ found=shutil.which(name)
+ if found:return found
+ for cand in ('/usr/bin/google-chrome','/usr/bin/chromium'):
+ if Path(cand).exists():return cand
+ return None
+
+
+def chrome_dump(hash_:str,budget:int=10000)->str:
+ chrome=find_chrome()
+ if not chrome:raise SystemExit('未找到 Chrome / Chromium')
+ with tempfile.TemporaryDirectory() as prof:
+ cmd=[chrome,'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check','--disable-extensions','--allow-file-access-from-files','--user-data-dir='+prof,'--virtual-time-budget=%d'%budget,'--dump-dom',TARGET.as_uri()+hash_]
+ if hasattr(os,'geteuid') and os.geteuid()==0:cmd.insert(1,'--no-sandbox')
+ r=subprocess.run(cmd,capture_output=True,text=True,encoding='utf-8',errors='replace')
+ if not r.stdout:raise AssertionError('Chrome 未返回 DOM:%s'%((r.stderr or '').strip()[-500:]))
+ return r.stdout
+
+
+def check_relation_core_only():
+ dom=chrome_dump('#view=relations')
+ for pattern in (r'data-ming-chunks="relations"',r'data-ming-data-chunk="relations"',r'id="relations" class="view active"',r'关系索引',r'
'):
+ if not re.search(pattern,dom,re.S):raise AssertionError('缺少关系页渲染结果:%s'%pattern)
+ if 'data-ming-data-chunk="relation-meta"' in dom:
+ raise AssertionError('关系索引不应预取 relation-meta')
+ print('ok V13 关系索引仅 relation core,不预取 metadata')
+
+
+if __name__=='__main__':
+ if not TARGET.exists():raise SystemExit('目标文件不存在:%s'%TARGET)
+ check_relation_core_only()
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ced6689..e3c1a4f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,7 +11,7 @@ permissions:
jobs:
check:
- name: 语法 · 不变量 · 构建 · V12 浏览器
+ name: 语法 · 不变量 · 构建 · V13 浏览器
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -35,7 +35,7 @@ jobs:
echo "未发现原书全文文件 ✓"
- name: Python 语法检查
- run: python -m compileall -q src tests .dump/_web_browser_check.py
+ run: python -m compileall -q src tests .dump/_web_browser_check.py .dump/_relation_browser_check_v13.py
- name: 前端动态统计口径规范化
run: |
@@ -69,7 +69,7 @@ jobs:
python src/sync_theme.py
git diff --check -- web/template/index.html
- - name: V3 / V12 产品体验镜像同步冒烟
+ - name: V3 / V13 产品体验镜像同步冒烟
run: |
python src/sync_experience.py
python src/sync_experience.py --check
@@ -83,7 +83,8 @@ jobs:
python src/sync_readme.py
python src/sync_readme_v11.py
python src/sync_readme_v12.py
- python src/sync_readme_v12.py --check
+ python src/sync_readme_v13.py
+ python src/sync_readme_v13.py --check
git diff --check -- README.md
- name: 构建冒烟(standalone,不落盘)
@@ -101,13 +102,15 @@ jobs:
- name: 离线版体积预算
run: python src/check_budget.py .ci/standalone.html
- - name: 构建在线 V12 分层产物
+ - name: 构建在线 V13 分层产物
run: |
python src/build.py --scope full --target web --out .ci/site --quiet
python src/split_time_asset_v11.py .ci/site
python src/split_time_asset_v11.py .ci/site --check
python src/split_event_asset_v12.py .ci/site
python src/split_event_asset_v12.py .ci/site --check
+ python src/split_relation_asset_v13.py .ci/site
+ python src/split_relation_asset_v13.py .ci/site --check
- name: 在线实体/空间基础结构与体积预算
run: python src/check_delivery.py .ci/site
@@ -118,6 +121,9 @@ jobs:
- name: V12 事件摘要/详情分层与组合入口预算
run: python src/check_event_layers_v12.py .ci/site
+ - name: V13 关系 core / metadata 分层预算
+ run: python src/check_relation_layers_v13.py .ci/site
+
- name: 单文件无头浏览器自检
run: python .dump/_browser_check.py .ci/standalone.html
@@ -126,3 +132,6 @@ jobs:
- name: V12 在线分层浏览器自检
run: python .dump/_web_browser_check.py .ci/site/index.html
+
+ - name: V13 关系索引在线浏览器自检
+ run: python .dump/_relation_browser_check_v13.py .ci/site/index.html
diff --git a/.github/workflows/refresh-index.yml b/.github/workflows/refresh-index.yml
index 7d3691b..b6abffc 100644
--- a/.github/workflows/refresh-index.yml
+++ b/.github/workflows/refresh-index.yml
@@ -21,7 +21,7 @@ concurrency:
jobs:
refresh:
if: github.actor != 'github-actions[bot]'
- name: 重建在线 V12 分层版、离线版与 README 统计
+ name: 重建在线 V13 分层版、离线版与 README 统计
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -48,17 +48,18 @@ jobs:
- name: 同步视觉主题镜像
run: python src/sync_theme.py
- - name: 同步 V3 / V12 产品体验镜像
+ - name: 同步 V3 / V13 产品体验镜像
run: python src/sync_experience.py
- - name: 同步 README 最终模型与 V12 交付说明
+ - name: 同步 README 最终模型与 V13 交付说明
run: |
python src/sync_readme.py
python src/sync_readme_v11.py
python src/sync_readme_v12.py
- python src/sync_readme_v12.py --check
+ python src/sync_readme_v13.py
+ python src/sync_readme_v13.py --check
- - name: 构建 V12 双交付产物
+ - name: 构建 V13 双交付产物
shell: bash
run: |
rm -rf .ci/publish
@@ -67,6 +68,8 @@ jobs:
python src/split_time_asset_v11.py .ci/publish --check
python src/split_event_asset_v12.py .ci/publish
python src/split_event_asset_v12.py .ci/publish --check
+ python src/split_relation_asset_v13.py .ci/publish
+ python src/split_relation_asset_v13.py .ci/publish --check
python src/build.py --scope full --target standalone --out standalone.html --quiet
rm -rf assets
cp .ci/publish/index.html index.html
@@ -75,6 +78,7 @@ jobs:
python src/check_delivery.py .ci/publish
python src/check_time_layers_v11.py .ci/publish
python src/check_event_layers_v12.py .ci/publish
+ python src/check_relation_layers_v13.py .ci/publish
python src/check_budget.py standalone.html
- name: 仅在公开产物变化时回写 main
@@ -86,11 +90,11 @@ jobs:
exit 0
fi
if git diff --quiet -- web/js/app.js web/js/experience.js web/css/app.css web/template/index.html README.md index.html standalone.html assets; then
- echo "源码镜像、README、在线 V12 分层版与离线版均已一致,无需提交。"
+ echo "源码镜像、README、在线 V13 分层版与离线版均已一致,无需提交。"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add web/js/app.js web/js/experience.js web/css/app.css web/template/index.html README.md index.html standalone.html assets
- git commit -m "chore: publish layered event, time and space web data"
+ git commit -m "chore: publish layered relation, event, time and space web data"
git push origin HEAD:main
diff --git a/src/check_relation_layers_v13.py b/src/check_relation_layers_v13.py
new file mode 100644
index 0000000..464c16f
--- /dev/null
+++ b/src/check_relation_layers_v13.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+"""V13 关系 core / metadata 分层交付门禁。"""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import split_relation_asset_v13 as V13
+
+KIB = 1024
+CORE_WARN = 340 * KIB
+CORE_HARD = 448 * KIB
+META_HARD = 640 * KIB
+
+
+def human(n: int) -> str:
+ return "%.1f KiB" % (n / KIB)
+
+
+def main(argv=None) -> int:
+ argv = list(sys.argv[1:] if argv is None else argv)
+ root = Path(argv[0] if argv else ".ci/site")
+ try:
+ stats = V13.check_site(root)
+ except (RuntimeError, ValueError) as exc:
+ print("[FAIL] %s" % exc)
+ return 2
+
+ loader_path = root / "assets" / "data-loader.js"
+ if not loader_path.exists():
+ print("[FAIL] 缺少 data-loader.js")
+ return 2
+ loader = loader_path.read_text(encoding="utf-8")
+ errors = []
+ for token in (
+ "'relations','relation-meta','time'",
+ "relations:['relations']",
+ "__MING_APPLY_RELATION_META__",
+ ):
+ if token not in loader:
+ errors.append("loader 缺少 V13 关系分层契约:%s" % token)
+ if "relations:['relations','relation-meta']" in loader:
+ errors.append("关系索引仍预取 relation-meta")
+
+ core = stats["relations_bytes"]
+ meta = stats["meta_bytes"]
+ print("V13 relation core:%s · %d relations" % (human(core), stats["relation_count"]))
+ print("V13 deferred relation metadata:%s" % human(meta))
+ print("V13 关系索引零缓存数据:%s" % human(core))
+
+ if core > CORE_HARD:
+ errors.append("data-relations.js 超过 hard %s" % human(CORE_HARD))
+ elif core > CORE_WARN:
+ print("[WARN] relation core %s > warn %s" % (human(core), human(CORE_WARN)))
+ if meta > META_HARD:
+ errors.append("data-relation-meta.js 超过 hard %s" % human(META_HARD))
+
+ if errors:
+ for error in errors:
+ print("[FAIL] %s" % error)
+ return 1
+ print("V13 关系 core / metadata 分层预算通过。")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/split_relation_asset_v13.py b/src/split_relation_asset_v13.py
new file mode 100644
index 0000000..8de443f
--- /dev/null
+++ b/src/split_relation_asset_v13.py
@@ -0,0 +1,179 @@
+# -*- coding: utf-8 -*-
+"""V13 在线关系物理分层:可见关系 core + deferred metadata。
+
+最终 DATA、generate_report 与 standalone 完全不变。该脚本只处理已构建的 web 产物:
+- data-relations.js 仅保留关系索引真正渲染/筛选所需字段;
+- id/sourceId/targetId/sourceType/targetType/source/endpointKind 等完整元数据
+ 按原数组位置进入 data-relation-meta.js;
+- core + metadata 可逐值重建原始 relations,且重复执行只做结构校验。
+"""
+from __future__ import annotations
+
+import argparse
+import copy
+import json
+import re
+from pathlib import Path
+
+RELATION_CORE_KEYS = ("from", "to", "rel", "category", "sourceTitle")
+_ASSIGN_RE = re.compile(r"^Object\.assign\(DATA,(.*)\);$", re.MULTILINE)
+_META_MARKER = "window.__MING_RELATION_META__="
+
+
+def _compact(value) -> str:
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":")).replace("", "<\\/")
+
+
+def read_chunk_payload(path: Path) -> dict:
+ text = path.read_text(encoding="utf-8")
+ match = _ASSIGN_RE.search(text)
+ if not match:
+ raise RuntimeError("找不到 Object.assign(DATA,...):%s" % path)
+ value = json.loads(match.group(1))
+ if not isinstance(value, dict):
+ raise RuntimeError("DATA chunk 不是对象:%s" % path)
+ return value
+
+
+def _replace_payload(text: str, payload: dict) -> str:
+ updated, count = _ASSIGN_RE.subn("Object.assign(DATA,%s);" % _compact(payload), text, count=1)
+ if count != 1:
+ raise RuntimeError("替换 relations DATA chunk 失败")
+ return updated
+
+
+def split_relation_transport(relations: list[dict]) -> tuple[list[dict], list[dict]]:
+ core = []
+ metadata = []
+ for row in relations:
+ visible = {key: copy.deepcopy(row[key]) for key in RELATION_CORE_KEYS if key in row}
+ patch = {key: copy.deepcopy(value) for key, value in row.items() if key not in visible}
+ core.append(visible)
+ metadata.append(patch)
+ return core, metadata
+
+
+def reconstruct_relations(core: list[dict], metadata: list[dict]) -> list[dict]:
+ if len(core) != len(metadata):
+ raise RuntimeError("关系 core / metadata 长度不一致:%d != %d" % (len(core), len(metadata)))
+ result = []
+ for visible, patch in zip(core, metadata):
+ item = copy.deepcopy(visible)
+ item.update(copy.deepcopy(patch))
+ result.append(item)
+ return result
+
+
+def _meta_script(metadata: list[dict]) -> str:
+ return (
+ "window.__MING_RELATION_META__=%s;\n"
+ "if(window.__MING_APPLY_RELATION_META__)window.__MING_APPLY_RELATION_META__();\n"
+ "window.__MING_DATA_CHUNKS__=window.__MING_DATA_CHUNKS__||{};\n"
+ "window.__MING_DATA_CHUNKS__['relation-meta']=true;\n"
+ "document.dispatchEvent(new CustomEvent('ming:data-chunk',{detail:{name:'relation-meta'}}));\n"
+ % _compact(metadata)
+ )
+
+
+def read_relation_meta(path: Path) -> list[dict]:
+ text = path.read_text(encoding="utf-8")
+ start = text.find(_META_MARKER)
+ if start < 0:
+ raise RuntimeError("relation-meta 格式异常:缺少 registry")
+ start += len(_META_MARKER)
+ end = text.find(";\n", start)
+ if end < 0:
+ raise RuntimeError("relation-meta JSON 结束位置异常")
+ value = json.loads(text[start:end])
+ if not isinstance(value, list):
+ raise RuntimeError("relation-meta 必须是数组")
+ return value
+
+
+def check_site(root: Path) -> dict:
+ assets = root / "assets"
+ relation_path = assets / "data-relations.js"
+ meta_path = assets / "data-relation-meta.js"
+ if not relation_path.exists():
+ raise RuntimeError("缺少 %s" % relation_path)
+ if not meta_path.exists():
+ raise RuntimeError("缺少 %s" % meta_path)
+ payload = read_chunk_payload(relation_path)
+ relations = payload.get("relations")
+ if not isinstance(relations, list):
+ raise RuntimeError("data-relations.js 缺少 relations 数组")
+ allowed = set(RELATION_CORE_KEYS)
+ for i, row in enumerate(relations):
+ extra = set(row) - allowed
+ if extra:
+ raise RuntimeError("关系 core 仍含 deferred 字段 #%d:%s" % (i, sorted(extra)))
+ for key in ("from", "to", "rel", "category", "sourceTitle"):
+ if key not in row:
+ raise RuntimeError("关系 core 缺少可见字段 #%d:%s" % (i, key))
+ metadata = read_relation_meta(meta_path)
+ if len(metadata) != len(relations):
+ raise RuntimeError("relation-meta 行数与 core 不一致")
+ if relations and not any(metadata):
+ raise RuntimeError("relation-meta 为空,未保存被拆出的元数据")
+ return {
+ "relations_bytes": relation_path.stat().st_size,
+ "meta_bytes": meta_path.stat().st_size,
+ "relation_count": len(relations),
+ }
+
+
+def split_site(root: Path) -> dict:
+ assets = root / "assets"
+ relation_path = assets / "data-relations.js"
+ meta_path = assets / "data-relation-meta.js"
+ if not relation_path.exists():
+ raise RuntimeError("缺少 %s" % relation_path)
+ payload = read_chunk_payload(relation_path)
+ relations = payload.get("relations")
+ if not isinstance(relations, list):
+ raise RuntimeError("data-relations.js 缺少 relations 数组")
+
+ # 已拆分时只验证,不重复生成。
+ if meta_path.exists() and all(set(row) <= set(RELATION_CORE_KEYS) for row in relations):
+ return check_site(root)
+
+ original = copy.deepcopy(relations)
+ core, metadata = split_relation_transport(relations)
+ if reconstruct_relations(core, metadata) != original:
+ raise RuntimeError("V13 relations core + metadata 不可逆")
+
+ rewritten = dict(payload)
+ rewritten["relations"] = core
+ relation_path.write_text(
+ _replace_payload(relation_path.read_text(encoding="utf-8"), rewritten),
+ encoding="utf-8",
+ )
+ meta_path.write_text(_meta_script(metadata), encoding="utf-8")
+
+ stats = check_site(root)
+ final_core = read_chunk_payload(relation_path)["relations"]
+ final_meta = read_relation_meta(meta_path)
+ if reconstruct_relations(final_core, final_meta) != original:
+ raise RuntimeError("V13 物理产物无法恢复原始 relations")
+ return stats
+
+
+def main(argv=None) -> int:
+ parser = argparse.ArgumentParser(description="V13:拆分 web 关系可见 core 与 deferred metadata")
+ parser.add_argument("root", type=Path, nargs="?", default=Path("dist/full"))
+ parser.add_argument("--check", action="store_true", help="只检查已拆分产物")
+ args = parser.parse_args(argv)
+ try:
+ stats = check_site(args.root) if args.check else split_site(args.root)
+ except (RuntimeError, ValueError, json.JSONDecodeError) as exc:
+ print("[FAIL] %s" % exc)
+ return 1
+ print(
+ "V13 关系分层通过:core %.1f KiB · metadata %.1f KiB · %d relations"
+ % (stats["relations_bytes"] / 1024, stats["meta_bytes"] / 1024, stats["relation_count"])
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/sync_readme_v13.py b/src/sync_readme_v13.py
new file mode 100644
index 0000000..0f49246
--- /dev/null
+++ b/src/sync_readme_v13.py
@@ -0,0 +1,58 @@
+# -*- coding: utf-8 -*-
+"""在基础/V11/V12 README 同步后补充 V13 关系 core + metadata 交付说明。"""
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+BASE = Path(__file__).resolve().parents[1]
+README = BASE / "README.md"
+
+INTRO = "基于《明朝那些事儿》七部 156 章全文抽取整理的静态知识库。在线版采用 V13 分层按需交付:首页只加载 `boot-data.js`,全局搜索加载轻量 `search-index.js`;人物为 `data-characters.js` + 16 个人物详情 shard,地点为 `data-locations.js` + 8 个地点详情 shard + 独立章节/航线块,年谱使用独立 `data-lifespans.js`;事件为轻量 `data-events.js` + 8 个事件详情 shard;关系索引进一步只加载可见字段组成的 `data-relations.js` core,完整关系 id / 端点类型 / 来源键等 metadata 只在 full DATA 时由 `data-relation-meta.js` 补回。`standalone.html` 仍是完整单文件离线版。"
+PAGES = "GitHub Pages:`https://cochranek.github.io/ming/`(根 `index.html` 为 V13 分层按需版;`standalone.html` 为可下载 / 双击打开的完整离线版)"
+STATS = "> 上表的章节 / 地点 / 人物 / 事件 / 关系统计由 `src/sync_readme.py` 从最终模型自动刷新;发布同步工作流会同时维护在线 V13 `index.html + assets/` 与离线 `standalone.html`。"
+TARGET = "| `--target standalone\\|web` | 默认 `standalone`=CSS/JS/DATA 全内联到 `standalone.html`;`web`=输出到 `dist//`。生产发布在基础 web 构建后依次执行 V11 时间分层、V12 事件分层与 V13 关系分层:人物/地点/事件详情使用确定性 shard,时间另有 `data-lifespans.js`;关系为轻量 `data-relations.js` core + 仅 full DATA 才加载的 `data-relation-meta.js`;图谱 / 洞察 / 元数据继续独立按需加载 |"
+DELIVERY = "`standalone` 构建继续内联完整最终 DATA;在线生产链路对同一 payload 做可逆传输分区:`src/split_time_asset_v11.py` 拆出 `lifespans`,`src/split_event_asset_v12.py` 把 events 变换成摘要核心 + 8 个详情 shard,`src/split_relation_asset_v13.py` 再把 relations 变换成可见 core + deferred metadata。加载全部物理块后可逐值恢复原始最终模型;不维护 `data-full.js`、`data-character-details.js`、`data-location-details.js`、`data-event-details.js` 或旧 `data-space.js` 单体包。"
+
+
+def _replace_prefix(lines: list[str], prefix: str, replacement: str, label: str) -> None:
+ matches = [i for i, line in enumerate(lines) if line.startswith(prefix)]
+ if len(matches) != 1:
+ raise RuntimeError("README V13 锚点异常(%s):%d" % (label, len(matches)))
+ lines[matches[0]] = replacement
+
+
+def render_readme(source: str) -> str:
+ lines = source.splitlines()
+ _replace_prefix(lines, "基于《明朝那些事儿》七部 156 章全文抽取整理的静态知识库。", INTRO, "intro")
+ _replace_prefix(lines, "GitHub Pages:", PAGES, "pages")
+ _replace_prefix(lines, "> 上表的章节 / 地点 / 人物 / 事件 / 关系统计由", STATS, "stats")
+ _replace_prefix(lines, "| `--target standalone\\|web` |", TARGET, "target")
+ _replace_prefix(lines, "`standalone` 构建继续", DELIVERY, "delivery")
+ return "\n".join(lines) + ("\n" if source.endswith("\n") else "")
+
+
+def main(argv=None) -> int:
+ parser = argparse.ArgumentParser(description="同步 README 的 V13 关系分层说明")
+ parser.add_argument("--check", action="store_true")
+ args = parser.parse_args(argv)
+ source = README.read_text(encoding="utf-8")
+ try:
+ updated = render_readme(source)
+ except RuntimeError as exc:
+ print("[FAIL] %s" % exc, file=sys.stderr)
+ return 2
+ if updated == source:
+ print("README V13 关系分层说明已同步")
+ return 0
+ if args.check:
+ print("README 尚未同步 V13 关系分层说明", file=sys.stderr)
+ return 1
+ README.write_text(updated, encoding="utf-8")
+ print("README V13 关系分层说明已写入")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/sw.js b/sw.js
index 0fdbe04..2f69f2e 100644
--- a/sw.js
+++ b/sw.js
@@ -7,8 +7,9 @@
// v19:V10 空间分层:locations + 8 个 location-detail-* + place-chapters + voyages。
// v20:V11 时间分层:lifespans 从 timeline 独立;年谱只缓存 characters + lifespans。
// v21:V12 事件分层:events 仅摘要,单事件详情进入 8 个 event-detail-*;时间轴/帝王只缓存 time。
+// v22:V13 关系分层:relations 仅可见 core;relation-meta 只在完整 DATA 路径按需缓存。
const CACHE_PREFIX = 'ming-report-';
-const CACHE = CACHE_PREFIX + 'v21';
+const CACHE = CACHE_PREFIX + 'v22';
self.addEventListener('install', () => { self.skipWaiting(); });
self.addEventListener('activate', (event) => {
@@ -25,8 +26,8 @@ self.addEventListener('fetch', (event) => {
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
- // V12 仍只缓存用户真实走过的路径:时间轴/帝王只取 time;事件索引只取 events core;
- // 点击单事件再缓存一个 event-detail-*,与 character-detail-* / location-detail-* 同一策略。
+ // V13 仍只缓存用户真实走过的路径:关系索引只取 relations core;full DATA 才取 relation-meta;
+ // 事件、人物、地点详情继续只缓存被真正访问的对应 shard。
event.respondWith((async () => {
const cache = await caches.open(CACHE);
const cached = await cache.match(req);
diff --git a/tests/test_relation_layers_v13.py b/tests/test_relation_layers_v13.py
new file mode 100644
index 0000000..bfa2c8d
--- /dev/null
+++ b/tests/test_relation_layers_v13.py
@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import tempfile
+from pathlib import Path
+
+import _support
+import build
+import split_relation_asset_v13 as V13
+
+ROOT = Path(__file__).resolve().parents[1]
+CORE_KEYS = {"from", "to", "rel", "category", "sourceTitle"}
+
+
+def test_v13_relation_postprocess_is_lossless_and_idempotent():
+ payload = _support.payload("full")
+ with tempfile.TemporaryDirectory() as td:
+ out = Path(td)
+ build.render_web(payload, out)
+ original = V13.read_chunk_payload(out / "assets" / "data-relations.js")["relations"]
+ stats = V13.split_site(out)
+ core = V13.read_chunk_payload(out / "assets" / "data-relations.js")["relations"]
+ meta = V13.read_relation_meta(out / "assets" / "data-relation-meta.js")
+ assert V13.reconstruct_relations(core, meta) == original
+ assert stats["relation_count"] == len(payload["relations"])
+ assert len(core) == len(meta) == len(original)
+ assert all(set(row) == CORE_KEYS for row in core)
+ assert any("id" in patch for patch in meta)
+ assert V13.split_site(out) == V13.check_site(out)
+
+
+def test_v13_relation_core_is_smaller_than_original_transport():
+ payload = _support.payload("full")
+ with tempfile.TemporaryDirectory() as td:
+ out = Path(td)
+ build.render_web(payload, out)
+ before = (out / "assets" / "data-relations.js").stat().st_size
+ stats = V13.split_site(out)
+ assert stats["relations_bytes"] < before
+ assert stats["relations_bytes"] < 448 * 1024
+ assert stats["meta_bytes"] < 640 * 1024
+
+
+def test_v13_loader_defers_relation_metadata_from_relation_view():
+ loader = (ROOT / "web" / "js" / "data-loader.js").read_text(encoding="utf-8")
+ assert "'relations','relation-meta','time'" in loader
+ assert "relations:['relations']" in loader
+ assert "relations:['relations','relation-meta']" not in loader
+ assert "__MING_RELATION_META__" in loader
+ assert "__MING_APPLY_RELATION_META__" in loader
+ assert "name==='relations'||name==='relation-meta'" in loader
diff --git a/tests/test_visual_polish.py b/tests/test_visual_polish.py
index 3dff72b..05a1d8b 100644
--- a/tests/test_visual_polish.py
+++ b/tests/test_visual_polish.py
@@ -71,8 +71,9 @@ def test_web_target_copies_service_worker_and_reports_all_assets():
def test_service_worker_cache_bumped_for_frontend_change():
sw = B.SW_PATH.read_text(encoding="utf-8")
- assert "CACHE_PREFIX + 'v21'" in sw
- assert "V12" in sw
+ assert "CACHE_PREFIX + 'v22'" in sw
+ assert "V13" in sw
+ assert "relation-meta" in sw
assert "event-detail" in sw
assert "lifespans" in sw
assert "location-detail" in sw
diff --git a/web/js/data-loader.js b/web/js/data-loader.js
index daefe3e..ef9577c 100644
--- a/web/js/data-loader.js
+++ b/web/js/data-loader.js
@@ -1,5 +1,5 @@
-/* ===== V12 在线数据加载器:人物/地点/事件详情分片 + 时间域分层 =====
- standalone.html 不加载本文件。事件索引只取轻量 core;单事件详情再取对应 shard。 */
+/* ===== V13 在线数据加载器:实体详情分片 + 时间域 + 关系 metadata 分层 =====
+ standalone.html 不加载本文件。关系索引只取可见 core;完整关系元数据仅 full DATA 再补。 */
(function(){
'use strict';
if(typeof DATA==='undefined')return;
@@ -11,7 +11,7 @@ const EVENT_DETAIL_SHARD_COUNT=8;
const CHARACTER_DETAIL_CHUNKS=Array.from({length:CHARACTER_DETAIL_SHARD_COUNT},(_,i)=>'character-detail-'+String(i).padStart(2,'0'));
const LOCATION_DETAIL_CHUNKS=Array.from({length:LOCATION_DETAIL_SHARD_COUNT},(_,i)=>'location-detail-'+String(i).padStart(2,'0'));
const EVENT_DETAIL_CHUNKS=Array.from({length:EVENT_DETAIL_SHARD_COUNT},(_,i)=>'event-detail-'+String(i).padStart(2,'0'));
-const ALL_CHUNKS=['characters',...CHARACTER_DETAIL_CHUNKS,'locations',...LOCATION_DETAIL_CHUNKS,'place-chapters','voyages','events',...EVENT_DETAIL_CHUNKS,'relations','time','lifespans','graphs','insight','meta'];
+const ALL_CHUNKS=['characters',...CHARACTER_DETAIL_CHUNKS,'locations',...LOCATION_DETAIL_CHUNKS,'place-chapters','voyages','events',...EVENT_DETAIL_CHUNKS,'relations','relation-meta','time','lifespans','graphs','insight','meta'];
const VIEW_CHUNKS={
overview:[],distribution:[],
visuals:['graphs'],
@@ -31,6 +31,7 @@ window.__MING_DATA_CHUNKS__=window.__MING_DATA_CHUNKS__||{};
window.__MING_CHARACTER_DETAILS__=window.__MING_CHARACTER_DETAILS__||{};
window.__MING_LOCATION_DETAILS__=window.__MING_LOCATION_DETAILS__||{};
window.__MING_EVENT_DETAILS__=window.__MING_EVENT_DETAILS__||{};
+window.__MING_RELATION_META__=window.__MING_RELATION_META__||[];
function stableShardIndex(value,count){
let h=5381>>>0;
@@ -89,16 +90,23 @@ window.__MING_APPLY_EVENT_DETAILS__=function(){
(DATA.events||[]).forEach(x=>{const extra=details[x&&x.id];if(extra){Object.assign(x,extra);patched++;}});
root.dataset.mingEventDetails=String(patched);return patched;
};
+window.__MING_APPLY_RELATION_META__=function(){
+ const meta=window.__MING_RELATION_META__||[],rows=DATA.relations||[];
+ if(!meta.length||meta.length!==rows.length)return 0;
+ rows.forEach((x,i)=>Object.assign(x,meta[i]||{}));
+ root.dataset.mingRelationMeta=String(rows.length);return rows.length;
+};
window.__MING_SEARCH_INDEX_READY=window.__MING_SEARCH_INDEX_READY===true;
syncDataState();markSearch(window.__MING_SEARCH_INDEX_READY?'ready':'idle');
-window.__MING_APPLY_CHARACTER_DETAILS__();window.__MING_APPLY_LOCATION_DETAILS__();window.__MING_APPLY_EVENT_DETAILS__();
+window.__MING_APPLY_CHARACTER_DETAILS__();window.__MING_APPLY_LOCATION_DETAILS__();window.__MING_APPLY_EVENT_DETAILS__();window.__MING_APPLY_RELATION_META__();
document.addEventListener('ming:data-chunk',event=>{
const name=event&&event.detail&&event.detail.name;
if(name==='characters'||String(name||'').startsWith('character-detail-'))window.__MING_APPLY_CHARACTER_DETAILS__();
if(name==='locations'||String(name||'').startsWith('location-detail-'))window.__MING_APPLY_LOCATION_DETAILS__();
if(name==='events'||String(name||'').startsWith('event-detail-'))window.__MING_APPLY_EVENT_DETAILS__();
+ if(name==='relations'||name==='relation-meta')window.__MING_APPLY_RELATION_META__();
syncDataState(name?'chunk:'+name:'chunk');
});
@@ -113,6 +121,7 @@ function loadChunk(name,reason){
if(name==='characters'||name.startsWith('character-detail-'))window.__MING_APPLY_CHARACTER_DETAILS__();
if(name==='locations'||name.startsWith('location-detail-'))window.__MING_APPLY_LOCATION_DETAILS__();
if(name==='events'||name.startsWith('event-detail-'))window.__MING_APPLY_EVENT_DETAILS__();
+ if(name==='relations'||name==='relation-meta')window.__MING_APPLY_RELATION_META__();
syncDataState(reason||('chunk:'+name));resolve(name);
};
script.onerror=()=>reject(new Error('领域数据加载失败:'+name));
@@ -126,7 +135,7 @@ window.__MING_ENSURE_DATA_CHUNKS=function(names,reason){
if(!wanted.length){syncDataState(reason);return Promise.resolve(DATA);}
root.dataset.mingData='loading';root.dataset.mingDataReason=reason||'interaction';
return Promise.all(wanted.map(name=>loadChunk(name,reason))).then(()=>{
- window.__MING_APPLY_CHARACTER_DETAILS__();window.__MING_APPLY_LOCATION_DETAILS__();window.__MING_APPLY_EVENT_DETAILS__();
+ window.__MING_APPLY_CHARACTER_DETAILS__();window.__MING_APPLY_LOCATION_DETAILS__();window.__MING_APPLY_EVENT_DETAILS__();window.__MING_APPLY_RELATION_META__();
syncDataState(reason);
try{if(typeof window.__MING_AFTER_DATA_CHUNKS==='function')window.__MING_AFTER_DATA_CHUNKS(wanted);}catch(_){}
return DATA;
@@ -164,7 +173,7 @@ window.__MING_ENSURE_FULL_DATA=function(reason){
if(window.__MING_FULL_DATA_READY)return Promise.resolve(DATA);
if(fullPromise)return fullPromise;
fullPromise=window.__MING_ENSURE_DATA_CHUNKS(ALL_CHUNKS,reason||'full').then(data=>{
- window.__MING_APPLY_CHARACTER_DETAILS__();window.__MING_APPLY_LOCATION_DETAILS__();window.__MING_APPLY_EVENT_DETAILS__();
+ window.__MING_APPLY_CHARACTER_DETAILS__();window.__MING_APPLY_LOCATION_DETAILS__();window.__MING_APPLY_EVENT_DETAILS__();window.__MING_APPLY_RELATION_META__();
try{if(typeof window.__MING_AFTER_FULL_DATA==='function')window.__MING_AFTER_FULL_DATA();}catch(_){}return data;
}).catch(err=>{fullPromise=null;throw err;});
return fullPromise;