← Files Dr.SHI-GYOARCHIVED FILE

skills/source-script/scripts/build_script_html.py

15.5 KB · Nov 3, 2026 · 19:11 UTC

↓ Download file

#!/usr/bin/env python3
"""Render a complete, offline script from validated JSON. Standard library only."""

import argparse
import datetime
import html
import json
import re
from pathlib import Path
from urllib.parse import urlsplit


ZEROICHI_HEADINGS = [
    "動画タイトル", "サムネイルで使用するテキスト", "【ダイジェスト】",
    "【キャッチフレーズ+自己紹介 例)〇〇を専門に診療している〇〇科医の〇〇です】",
    "【視聴者の悩み共感パート】", "【動画を最後まで見るべき理由】", "【動画本編】",
    "【Call to Action 例)チャンネル登録、グッドボタン、コメントをぜひお願いいたします。】",
    "引用論文", "引用論文まとめ",
]
ZEROICHI_ROLES = ["metadata", "metadata", "digest", "spoken", "spoken", "spoken",
                  "spoken", "spoken", "references", "references"]
CITATION = re.compile(r"(?:\(\*(?P<legacy>[1-9][0-9]*)\)|(引用文献挿入:(?P<number>[1-9][0-9]*)))")
CSS = """
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:#f4f3f6;
color:#24202b;font-family:system-ui,-apple-system,'Hiragino Sans','Yu Gothic',sans-serif}
main{max-width:1000px;margin:24px auto;padding:clamp(20px,5vw,56px);background:#fff}
h1{font-size:clamp(26px,5vw,38px);line-height:1.45}h2{font-size:26px;margin:2.3em 0 .8em}
h3{font-size:22px;margin:1.8em 0 .7em}p,li{font-size:19px;line-height:1.9}
p{margin:0 0 1.1em;white-space:pre-wrap;overflow-wrap:anywhere}a{color:#563299;overflow-wrap:anywhere}
.citation-marker{font-size:.85em}.meta,.label{font-size:15px;color:#645d6e}.label{margin-bottom:.7em}
nav{border-block:1px solid #ddd5e7;padding:12px 0}nav ul{padding-left:1.3em}
nav li{font-size:16px;line-height:1.8}section,aside,footer{scroll-margin-top:20px}
.digest,.notes{background:#f7f4fb;border-left:4px solid #8b70b0;padding:16px 20px;margin:28px 0}
.digest h2,.notes h2{margin-top:0}.digest-source{color:#b00020;font-weight:800}
.references li{margin-bottom:1.4em}.references p{margin:.3em 0;font-size:17px}
.references ol{padding-left:2em}.references a{font-size:17px}
@media(max-width:600px){main{margin:0;padding:20px}h2{font-size:23px}h3{font-size:21px}}
@media print{body{background:#fff}main{margin:0;max-width:none;padding:0}nav,.screen-only{display:none}
h2,h3{break-after:avoid}p{orphans:3;widows:3}a{color:inherit}.digest-source{color:#b00020}}
"""


def require(condition, message):
    if not condition:
        raise ValueError(message)


def string(value, label, allow_empty=False):
    require(isinstance(value, str) and (allow_empty or value.strip()), f"{label}: text required")
    return value


def sequence(value, label):
    require(isinstance(value, list), f"{label}: array required")
    return value


def mapping(value, label):
    require(isinstance(value, dict), f"{label}: object required")
    return value


def allowed_keys(value, keys, label):
    require(not (set(value) - set(keys)), f"{label}: unknown fields {sorted(set(value) - set(keys))}")


def safe_url(value):
    string(value, "reference URL")
    require(not any(c.isspace() or ord(c) < 32 for c in value), "reference URL: whitespace/control character")
    try:
        parts = urlsplit(value)
        port = parts.port
        require(parts.scheme in {"https", "http"} and bool(parts.hostname)
                and not parts.username and not parts.password, "reference URL: HTTP(S) URL required")
        require(not any(c in parts.netloc for c in '\\<>\"'), "reference URL: invalid host")
    except ValueError as exc:
        raise ValueError(f"reference URL: {exc}") from exc
    return value


def citation_id(match):
    return match["number"] or match["legacy"]


def inline(text):
    # Normalize only at rendering time: source/digest offsets remain unchanged.
    def render(match):
        rid = citation_id(match)
        return (f'<span class="citation-marker" data-role="editorial-citation">'
                f'<a href="#ref-{rid}">(引用文献挿入:{rid})</a></span>')
    return CITATION.sub(render, html.escape(text))


def marked(text, spans):
    merged = []
    for start, end in sorted(spans):
        if merged and start <= merged[-1][1]:
            merged[-1][1] = max(merged[-1][1], end)
        else:
            merged.append([start, end])
    result, previous = [], 0
    for start, end in merged:
        result.extend([inline(text[previous:start]), '<strong class="digest-source">',
                       inline(text[start:end]), '</strong>'])
        previous = end
    result.append(inline(text[previous:]))
    return ''.join(result)


def render_script(data):
    mapping(data, "document")
    allowed_keys(data, {"title", "version", "date", "author", "layout", "sections", "references"}, "document")
    for field in ("title", "version", "date"):
        string(data.get(field), field)
    require(re.fullmatch(r"\d{4}-\d{2}-\d{2}", data["date"]), "date: YYYY-MM-DD required")
    datetime.date.fromisoformat(data["date"])
    if "author" in data:
        string(data["author"], "author")
    require(data.get("layout", "standard") in {"standard", "zeroichi"}, "unknown layout")
    sections = sequence(data.get("sections"), "sections")
    require(sections, "at least one section required")
    refs, used_refs, ids, paragraphs, highlights = {}, set(), set(), {}, {}

    def identifier(value):
        string(value, "ID")
        require(re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]*", value), f"invalid ID: {value}")
        require(value not in ids and value not in {"script-document", "page-title", "toc"}
                and not value.startswith(("ref-", "digest-item-")), f"duplicate/reserved ID: {value}")
        ids.add(value)

    for ref in sequence(data.get("references", []), "references"):
        mapping(ref, "reference")
        allowed_keys(ref, {"id", "authors", "title", "year", "url", "kind", "scope", "bibliography", "summary", "supports", "short"}, "reference")
        rid = ref.get("id")
        require(type(rid) is int and rid > 0 and rid not in refs, "reference ID: unique positive integer required")
        for field in ("authors", "title", "year", "kind", "scope"):
            string(ref.get(field), f"reference {rid}.{field}")
        for field in ("bibliography", "summary", "supports", "short"):
            if field in ref:
                string(ref[field], f"reference {rid}.{field}")
        safe_url(ref.get("url"))
        refs[rid] = ref

    reference_sections, digest_count, spoken_count = [], 0, 0
    for section in sections:
        mapping(section, "section")
        role = section.get("role")
        require(role in {"metadata", "spoken", "digest", "references", "notes"}, "unknown section role")
        common_keys = {"id", "heading", "role"}
        allowed_keys(section, common_keys | ({"items"} if role == "digest" else {"style"} if role == "references" else {"blocks"}), "section")
        identifier(section.get("id"))
        string(section.get("heading"), "section heading")
        if role == "references":
            require(section.get("style") in {"short", "full"}, "reference section style: short/full required")
            reference_sections.append(section["style"])
        elif role == "digest":
            sequence(section.get("items"), "digest items")
            digest_count += 1
        else:
            for block in sequence(section.get("blocks"), "blocks"):
                mapping(block, "block")
                allowed_keys(block, {"id", "text", "heading"}, "block")
                identifier(block.get("id"))
                require(("text" in block) != ("heading" in block), "block needs exactly one text or heading")
                text = string(block.get("text", block.get("heading")), "block content")
                if "text" in block and role == "spoken":
                    paragraphs[block["id"]] = text
                    spoken_count += 1
                for token in re.finditer(r"\(\*[^)]*\)|(引用文献挿入[^)]*)", text):
                    require(CITATION.fullmatch(token[0]), f"malformed citation: {token[0]}")
                remaining = CITATION.sub("", text)
                require("(*" not in remaining and "引用文献挿入" not in remaining,
                        "malformed or incomplete citation marker")
                for match in CITATION.finditer(text):
                    rid = int(citation_id(match))
                    require(rid in refs, f"missing reference: {rid}")
                    require(role == "spoken" and "text" in block, "citations belong in spoken paragraphs")
                    used_refs.add(rid)
    require(spoken_count, "full spoken script required")
    require(digest_count <= 1, "only one digest section allowed")
    require(len(reference_sections) == len(set(reference_sections)), "duplicate reference section style")
    require(not refs or reference_sections.count("full") == 1, "full references section required")
    require(used_refs == set(refs), f"unused references: {sorted(set(refs) - used_refs)}")
    if data.get("layout") == "zeroichi":
        require([s["heading"] for s in sections[:10]] == ZEROICHI_HEADINGS, "zeroichi: preserve all 10 fixed headings in order")
        require([s["role"] for s in sections[:10]] == ZEROICHI_ROLES, "zeroichi: incorrect fixed section roles")
        require(sections[8].get("style") == "short" and sections[9].get("style") == "full", "zeroichi: short then full references required")
        require(all(s["role"] == "notes" for s in sections[10:]), "zeroichi: only separate notes may follow the 10 sections")
        for ref in refs.values():
            for field in ("bibliography", "short", "summary", "supports"):
                string(ref.get(field), f"zeroichi reference {ref['id']}.{field}")

    digest_html = []
    for section in sections:
        if section["role"] != "digest":
            continue
        for index, item in enumerate(section["items"], 1):
            mapping(item, "digest item")
            allowed_keys(item, {"source", "quote", "occurrence", "masks"}, "digest item")
            source, quote = item.get("source"), string(item.get("quote"), "digest quote")
            require(isinstance(source, str) and source in paragraphs, "digest source: spoken paragraph ID required")
            text = paragraphs[source]
            starts = [m.start() for m in re.finditer('(?=' + re.escape(quote) + ')', text)]
            require(starts, f"digest quote is absent from {source}")
            occurrence = item.get("occurrence", 1)
            require(type(occurrence) is int and 1 <= occurrence <= len(starts), "digest occurrence out of range")
            require(len(starts) == 1 or "occurrence" in item, "ambiguous digest quote: specify occurrence")
            start, end = starts[occurrence - 1], starts[occurrence - 1] + len(quote)
            for citation in CITATION.finditer(text):
                require(not (citation.start() < start < citation.end() or citation.start() < end < citation.end()), "digest cannot split a citation marker")
            highlights.setdefault(source, []).append((start, end))
            shown, previous = [], 0
            for mask in sequence(item.get("masks", []), "digest masks"):
                mapping(mask, "mask")
                allowed_keys(mask, {"start", "end"}, "mask")
                a, b = mask.get("start"), mask.get("end")
                require(type(a) is int and type(b) is int and previous <= a < b <= len(quote), "mask ranges must be ordered, disjoint, within quote")
                require(a != 0 or b != len(quote), "cannot mask the whole quote")
                require(not any(a < c.end() and b > c.start() for c in CITATION.finditer(quote)), "cannot mask citation marker")
                shown.extend([quote[previous:a], "??"])
                previous = b
            shown.append(quote[previous:])
            require(sum(m["end"] - m["start"] for m in item.get("masks", [])) < len(quote), "cannot mask the whole quote")
            digest_html.append(f'<p id="digest-item-{index}">{inline("".join(shown))}<br>'
                               f'<a class="label" href="#{source}">本文の該当箇所へ</a></p>')

    chunks = []
    for section in sections:
        role, sid = section["role"], section["id"]
        tag = "aside" if role == "digest" else "footer" if role == "notes" else "section"
        chunks.append(f'<{tag} id="{sid}" class="{role}" data-role="{role}"><h2>{html.escape(section["heading"])}</h2>')
        if role in {"digest", "notes"}:
            chunks.append('<p class="label">編集・制作用(読み上げ本文外)</p>')
        if role == "digest":
            chunks.extend(digest_html)
        elif role == "references":
            chunks.append('<ol>')
            for rid, ref in sorted(refs.items()):
                description = ref.get("bibliography", f'{ref["authors"]}. {ref["title"]}. {ref["year"]}.')
                if section["style"] == "short":
                    chunks.append(f'<li value="{rid}"><a href="#ref-{rid}">{html.escape(ref.get("short", description))}</a></li>')
                else:
                    chunks.append(f'<li id="ref-{rid}" value="{rid}"><p>{html.escape(description)}</p>'
                                  f'<p><a href="{html.escape(ref["url"], quote=True)}">{html.escape(ref["url"])}</a></p>'
                                  f'<p>種類:{html.escape(ref["kind"])}/確認範囲:{html.escape(ref["scope"])}</p>')
                    for field in ("summary", "supports"):
                        if field in ref:
                            chunks.append(f'<p>{html.escape(ref[field])}</p>')
                    chunks.append('</li>')
            chunks.append('</ol>')
        else:
            for block in section["blocks"]:
                bid = block["id"]
                if "heading" in block:
                    chunks.append(f'<h3 id="{bid}">{html.escape(block["heading"])}</h3>')
                else:
                    chunks.append(f'<p id="{bid}">{marked(block["text"], highlights.get(bid, []))}</p>')
        chunks.append(f'</{tag}>')
    metadata = " / ".join([data["version"], data["date"]] + ([data["author"]] if data.get("author") else []))
    toc = ''.join(f'<li><a href="#{s["id"]}">{html.escape(s["heading"])}</a></li>' for s in sections)
    return (f'<!doctype html>\n<html lang="ja"><head><meta charset="utf-8">'
            f'<meta name="viewport" content="width=device-width, initial-scale=1">'
            f'<title>{html.escape(data["title"])}</title><style>{CSS}</style></head><body>'
            f'<main id="script-document"><header><h1 id="page-title">{html.escape(data["title"])}</h1>'
            f'<p class="meta">{html.escape(metadata)}</p><p class="label screen-only">本文は文字を選択してコピーできます。</p></header>'
            f'<nav id="toc" aria-label="目次"><ul>{toc}</ul></nav>{"".join(chunks)}</main></body></html>\n')


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("input", type=Path, help="script JSON")
    parser.add_argument("output", type=Path, help="actual HTML output file")
    args = parser.parse_args()
    try:
        require(args.input.resolve() != args.output.resolve(), "input and output must differ")
        result = render_script(json.loads(args.input.read_text(encoding="utf-8")))
        args.output.parent.mkdir(parents=True, exist_ok=True)
        args.output.write_text(result, encoding="utf-8")
        require(args.output.read_text(encoding="utf-8") == result, "saved file readback mismatch")
    except (ValueError, OSError, TypeError) as exc:
        parser.exit(2, f"error: {exc}\n")
    print(json.dumps({"output": str(args.output.resolve()), "bytes": args.output.stat().st_size}, ensure_ascii=False))


if __name__ == "__main__":
    main()

SHA-256: d00267eea25ad36fdf68c3e1e540d50f9b6346bff3f66d35e99872c0e0434554