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(" 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;