"""HTML 模板「JSON 填充」纯引擎 + 模板注册表。 定时任务的「模板数据转换器」内置 agent 把用户传入的任意 JSON 智能映射成某个 HTML 模板需要的 ``DATA`` 结构后,本模块负责把数据**填充**进模板并产出一份完整、 可预览的 HTML 文档。 设计要点 -------- * **模板自身即 schema 来源**:每个模板的 HTML 里都内嵌一行 ``const DATA={...};`` (台湾滨海防卫展示系统 no.1 还内嵌 ``const MANIFEST={...};``)。我们直接解析这行 拿到「参考数据」,既当**目标格式的范例**(喂给大模型),又当**结构校验**的基准 (顶层键 + 类型)。注册表因此不需要手写一份庞大的 schema 字面量,改模板即改契约。 * **行级安全替换**:模板把每个常量压在**单独一行**(``const DATA=...;`` 以分号结尾、 行内无换行),所以用 ``^const VAR=.*;$`` (MULTILINE) 精确命中该行整体替换,绝不 误伤模板里别处出现的同名子串。替换值用函数回调写入,避开 ``re.sub`` 把 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``。 """ 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" @dataclass(frozen=True) class TemplateSpec: """一个 HTML 模板的注册信息。 ``file`` 相对 :data:`_TEMPLATES_ROOT`;``data_var``/``manifest_var`` 是模板里 待替换的 JS 常量名(``manifest_var`` 为空表示该模板没有 MANIFEST 行)。 ``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 # 已注册模板。新增 no.2/no.3 时在此追加一条即可——前端下拉、调度器填充全自动生效。 TEMPLATES: tuple[TemplateSpec, ...] = ( TemplateSpec( id="no1", name="no.1", description="", file="no1/taiwan_littoral_ui_redwhite_v2.html", data_var="DATA", manifest_var="MANIFEST", ), 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", # 数据存于