"""酒馆角色卡解析器。 从 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 {}), )