Files
2026-08-21 16:00:13 +08:00

121 lines
4.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""酒馆角色卡解析器。
从 PNG(``tEXt`` chunk 的 ``chara``/``ccv3``)/ JSON / CHARX(zip 含 ``card.json``)
解析角色卡,归一化为统一的 ``CharacterCard``(V2/V3 的 ``data`` 字段拍平)。
纯字节 / 文本解析,无文件 IO——调用方(service 层)负责读文件后传入字节。
V1 扁平结构自动归一化;``character_book`` 原样保留,由映射层转世谱条目。
"""
from __future__ import annotations
import base64
import io
import json
import zipfile
from dataclasses import dataclass, field
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
_ZIP_SIGNATURE = b"PK\x03\x04"
@dataclass(frozen=True)
class CharacterCard:
"""归一化后的角色卡(V1/V2/V3 统一为拍平字段)。"""
spec_version: str # "1" / "2.0" / "3.0"
name: str = ""
description: str = ""
personality: str = ""
scenario: str = ""
first_mes: str = ""
mes_example: str = ""
creator_notes: str = ""
system_prompt: str = ""
post_history_instructions: str = ""
alternate_greetings: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
creator: str = ""
character_version: str = ""
character_book: dict | None = None # 原始 lorebook,由映射层转 LorebookBook
nickname: str | None = None # V3
extensions: dict = field(default_factory=dict) # 保留未知键(酒馆规范:不得删除)
def parse_character_card(raw: bytes) -> CharacterCard:
"""从原始字节解析角色卡(自动识别 PNG / JSON / CHARX)。"""
if raw[:8] == _PNG_SIGNATURE:
data = _parse_png(raw)
elif raw[:4] == _ZIP_SIGNATURE:
data = _parse_charx(raw)
else:
data = json.loads(raw.decode("utf-8-sig"))
return _normalize(data)
def _parse_png(raw: bytes) -> dict:
"""从 PNG tEXt chunk 提取 chara / ccv3(base64 → JSON)。优先 ccv3。"""
chunks: list[tuple[str, str]] = []
offset = len(_PNG_SIGNATURE)
while offset + 8 <= len(raw):
length = int.from_bytes(raw[offset : offset + 4], "big")
chunk_type = raw[offset + 4 : offset + 8]
data_start = offset + 8
data_end = data_start + length
if data_end > len(raw):
break
if chunk_type == b"tEXt":
null = raw[data_start:data_end].find(b"\x00")
if null >= 0:
keyword = raw[data_start : data_start + null].decode("latin-1")
text = raw[data_start + null + 1 : data_end].decode("latin-1")
chunks.append((keyword, text))
offset = data_end + 4 # 跳过 CRC
for want in ("ccv3", "chara"):
for keyword, text in chunks:
if keyword == want:
decoded = base64.b64decode(text)
return json.loads(decoded.decode("utf-8"))
raise ValueError("PNG 中未找到角色卡元数据(chara / ccv3 chunk)")
def _parse_charx(raw: bytes) -> dict:
"""从 CHARX(zip)提取 card.json。"""
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
card_name = next((n for n in zf.namelist() if n.endswith("card.json")), None)
if card_name is None:
raise ValueError("CHARX 中未找到 card.json")
with zf.open(card_name) as f:
return json.loads(f.read().decode("utf-8"))
def _normalize(data: dict) -> CharacterCard:
"""V1 扁平结构归一化为 V2/V3(data 包裹)。"""
if isinstance(data.get("data"), dict):
inner = data["data"]
spec_version = str(data.get("spec_version", "2.0"))
else:
inner = data
spec_version = "1"
return CharacterCard(
spec_version=spec_version,
name=inner.get("name", ""),
description=inner.get("description", ""),
personality=inner.get("personality", ""),
scenario=inner.get("scenario", ""),
first_mes=inner.get("first_mes", ""),
mes_example=inner.get("mes_example", ""),
creator_notes=inner.get("creator_notes", ""),
system_prompt=inner.get("system_prompt", ""),
post_history_instructions=inner.get("post_history_instructions", ""),
alternate_greetings=list(inner.get("alternate_greetings", []) or []),
tags=list(inner.get("tags", []) or []),
creator=inner.get("creator", ""),
character_version=inner.get("character_version", ""),
character_book=inner.get("character_book"),
nickname=inner.get("nickname"),
extensions=dict(inner.get("extensions", {}) or {}),
)