"""HTML 模板「JSON 填充」纯引擎 + 模板注册表。 定时任务的「模板数据转换器」内置 agent 把用户传入的任意 JSON 智能映射成某个 HTML 模板需要的 ``DATA`` 结构后,本模块负责把数据**填充**进模板并产出一份完整、 可预览的 HTML 文档。 设计要点 -------- * **模板自身即 schema 来源**:每个模板的 HTML 里都内嵌一行 ``const DATA={...};`` (台湾滨海防卫展示系统 no.1 还内嵌 ``const MANIFEST={...};``)。我们直接解析这行 拿到「参考数据」,既当**目标格式的范例**(喂给大模型),又当**结构校验**的基准 (顶层键 + 类型)。注册表因此不需要手写一份庞大的 schema 字面量,改模板即改契约。 * **行级安全替换**:模板把每个常量压在**单独一行**(``const DATA=...;`` 以分号结尾、 行内无换行),所以用 ``^const VAR=.*;$`` (MULTILINE) 精确命中该行整体替换,绝不 误伤模板里别处出现的同名子串。替换值用函数回调写入,避开 ``re.sub`` 把 JSON 里的 反斜杠当反向引用的坑。 * **结构化模板(kind="structured")**: no.1 起引入 page-generator 范式——目录含 ``template.html``(骨架,带 :data:`STRUCTURED_MARKERS` 注入标记)/``runtime.js`` (渲染逻辑 + 页面内 JSON 编辑器)/``data.json``(默认数据,兼作转换契约)/可选 ``manifest.json``。填充走 :func:`compose_structured` 标记组合,产物仍是自包含 单文件 HTML;``reference_data`` 读 ``data.json``,与单文件模板的契约/校验/合并 语义完全一致。 * **harness 层、零 app.* 依赖**:可被调度器(harness)与 Gateway 路由(app)共同 import。 公开入口:``list_templates`` / ``get_template`` / ``reference_data`` / ``schema_skeleton_json`` / ``validate_data`` / ``field_coverage_gaps`` / ``shape_mismatches`` / ``fill_template`` / ``invalidate_cache``。 模板来源(读取顺序):运行时存储 :mod:`.template_store` (``{base_dir}/html-templates//``,模板管理页可上传/编辑/新增,内置模板由 :func:`template_store.ensure_seeded` 启动 seed,缺失才复制、绝不覆盖修改)→ 随包 注册表 :data:`TEMPLATES`(下方静态清单)兜底。写操作经 ``template_store`` 完成, 写入后调 :func:`invalidate_cache` 清掉本模块的 lru_cache。 """ from __future__ import annotations import json import re from dataclasses import dataclass from datetime import date from functools import lru_cache from pathlib import Path from typing import Any _FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL | re.IGNORECASE) def _scan_balanced(text: str, open_ch: str, close_ch: str) -> Any | None: """从 text 里扫第一个**平衡**的 ``open_ch…close_ch`` 块并 ``json.loads``。 字符串感知(跳过引号内的括号/转义),失败返回 None。供从「含 JSON 的文本」里 把 JSON 段抠出来用。 """ start = text.find(open_ch) if start == -1: return None depth = 0 in_string = False escape = False for i in range(start, len(text)): ch = text[i] if in_string: if escape: escape = False elif ch == "\\": escape = True elif ch == '"': in_string = False continue if ch == '"': in_string = True elif ch == open_ch: depth += 1 elif ch == close_ch: depth -= 1 if depth == 0: blob = text[start : i + 1] try: return json.loads(blob) except (ValueError, TypeError): return None return None def extract_embedded_json(text: str | None) -> Any | None: """尽力从一段文本里抽出内嵌的 JSON(对象或数组)。 覆盖三种用户输入:纯 JSON(直接解析)、含 JSON 的文本(抠出第一个平衡的 ``{…}`` 或 ``[…]``)、纯文本(返回 ``None``)。也识别 ```json fence。 """ if not text or not isinstance(text, str): return None s = text.strip() fence = _FENCE_RE.search(s) if fence: inner = fence.group(1).strip() try: return json.loads(inner) except (ValueError, TypeError): s = inner # fence 内不是完整 JSON,继续往下扫 try: return json.loads(s) except (ValueError, TypeError): pass # 取最靠前出现的那种括号块(对象优先于数组,谁先出现谁优先)。 brace = _scan_balanced(s, "{", "}") bracket = _scan_balanced(s, "[", "]") if brace is not None and bracket is not None: return brace if s.find("{") <= s.find("[") else bracket return brace if brace is not None else bracket # 模板资源根目录(随 git 发布,与本模块同级 ``templates/`` 子目录)。 _TEMPLATES_ROOT = Path(__file__).parent / "templates" # 结构化模板骨架里的三个注入标记(compose_structured 逐个替换)。 DATA_MARKER = "__PAGE_DATA__" MANIFEST_MARKER = "__PAGE_MANIFEST__" RUNTIME_MARKER = "__PAGE_RUNTIME__" STRUCTURED_MARKERS: tuple[str, ...] = (DATA_MARKER, MANIFEST_MARKER, RUNTIME_MARKER) # 结构化模板目录里的组成文件(manifest.json 可选,其余必备)。 STRUCTURED_PARTS: tuple[str, ...] = ("template.html", "runtime.js", "data.json", "manifest.json") @dataclass(frozen=True) class TemplateSpec: """一个 HTML 模板的注册信息。 ``file`` 相对 :data:`_TEMPLATES_ROOT`;``data_var``/``manifest_var`` 是模板里 待替换的 JS 常量名(``manifest_var`` 为空表示该模板没有 MANIFEST 行)。 ``kind`` 有两种: * ``"single"``(默认): 单文件模板,数据以 ``const DATA={…};`` 单行内嵌,填充 = 行级替换; * ``"structured"``: 结构化模板(page-generator 范式),目录含 ``template.html``(骨架,带 :data:`STRUCTURED_MARKERS` 三个注入标记)/ ``runtime.js``(渲染 + 页面内 JSON 编辑器)/``data.json``(默认数据,即转换 契约)/可选 ``manifest.json``,填充 = 标记组合,产物仍是自包含单文件 HTML。 ``legacy_file`` 指向单文件时代的随包正文,供 template_store 自动升级时做 「未被手改」的字节级比对。 ``convertible=False`` 表示该模板暂只「直接展示已有数据」——执行时原样输出模板 HTML,不做 JSON 转换/校验/填充(无 ``const DATA=`` 注入行的模板用它,后续接入 转换后再改回 True)。 """ id: str name: str description: str file: str data_var: str = "DATA" manifest_var: str = "" convertible: bool = True kind: str = "single" legacy_file: str = "" @property def structured(self) -> bool: return self.kind == "structured" # 已注册模板(随包 seed 源 + 存储缺失时的兜底注册表)。新增模板时在此追加一条 # 即可——前端下拉、调度器填充全自动生效;运行时增删改走 template_store。 TEMPLATES: tuple[TemplateSpec, ...] = ( TemplateSpec( id="no1", name="no.1", description="", file="no1/template.html", data_var="DATA", manifest_var="MANIFEST", # 结构化模板(page-generator 范式):骨架 + runtime + 默认数据 + MANIFEST, # 填充走 compose_structured 标记组合;legacy_file 是单文件时代的随包正文, # 供 template_store 把「未被手改」的旧存储副本自动升级到结构化版。 kind="structured", legacy_file="no1/taiwan_littoral_ui_redwhite_v2.html", ), TemplateSpec( id="no2", name="no.2", description="", file="no2/taiwan_air_force_v3_2.html", # 暂只展示已有数据,不做 JSON 转换/填充(后续接入转换后再开)。 convertible=False, ), TemplateSpec( id="no3", name="no.3", description="", file="no3/no3.html", # 无 const DATA= 注入点,暂只展示已有数据(后续接入转换后再开)。 convertible=False, ), TemplateSpec( id="no4", name="no.4", description="", file="no4/no4.html", # 数据存于