348 lines
11 KiB
Python
348 lines
11 KiB
Python
"""解析矮人要塞的 legends 导出。
|
||
|
||
数据来源有两份,按 id 合并:
|
||
1. ``legends.xml`` —— DF 原生 "Export XML" 产物:名字、年份、事件主体
|
||
2. ``legends_plus.xml`` —— DFHack ``exportlegends`` 产物:补充字段(sex/race 等)
|
||
|
||
因为大世界的导出可达数十 MB,这里用 iterparse 流式解析。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from defusedxml.ElementTree import iterparse
|
||
|
||
# 事件里指向其他实体的字段 → 指向哪类对象
|
||
ID_FIELDS: dict[str, str] = {
|
||
"hfid": "figure",
|
||
"hist_figure_id": "figure",
|
||
"slayer_hfid": "figure",
|
||
"slayer_item_id": "artifact",
|
||
"target_hfid": "figure",
|
||
"source_hfid": "figure",
|
||
"site_id": "site",
|
||
"site_civ_id": "entity",
|
||
"entity_id": "entity",
|
||
"attacker_civ_id": "entity",
|
||
"defender_civ_id": "entity",
|
||
"artifact_id": "artifact",
|
||
"region_id": "region",
|
||
"feature_layer_id": "region",
|
||
"deity": "figure",
|
||
"worshipper_hfid": "figure",
|
||
"creature_id": "creature",
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class Figure:
|
||
id: int
|
||
name: str = ""
|
||
race: str = ""
|
||
sex: int | None = None
|
||
birth_year: int | None = None
|
||
death_year: int | None = None
|
||
profession: str = ""
|
||
entities: list[int] = field(default_factory=list)
|
||
sites: list[int] = field(default_factory=list)
|
||
|
||
@property
|
||
def alive_span(self) -> str:
|
||
# DF 用负数占位表示“无此项”(未记录/未死),不要当成真实年份显示
|
||
birth = self.birth_year if (self.birth_year or 0) >= 0 else None
|
||
death = self.death_year if (self.death_year or 0) >= 0 else None
|
||
if birth is None and death is None:
|
||
return "生卒不详"
|
||
b = "?" if birth is None else str(birth)
|
||
d = "在世/不详" if death is None else str(death)
|
||
return f"{b}–{d}"
|
||
|
||
|
||
@dataclass
|
||
class Event:
|
||
id: int
|
||
year: int
|
||
seconds72: int = 0
|
||
type: str = ""
|
||
fields: dict[str, list[str]] = field(default_factory=dict)
|
||
|
||
def ids(self, tag: str) -> list[int]:
|
||
out: list[int] = []
|
||
for raw in self.fields.get(tag, []):
|
||
try:
|
||
out.append(int(raw))
|
||
except ValueError:
|
||
continue
|
||
return out
|
||
|
||
def text(self, tag: str) -> str:
|
||
vals = self.fields.get(tag)
|
||
return vals[0] if vals else ""
|
||
|
||
|
||
@dataclass
|
||
class Entity:
|
||
id: int
|
||
name: str = ""
|
||
type: str = ""
|
||
race: str = ""
|
||
|
||
|
||
@dataclass
|
||
class Site:
|
||
id: int
|
||
name: str = ""
|
||
type: str = ""
|
||
|
||
|
||
# ------------------------------------------------------------------ 展示用工具
|
||
def pretty(name: str) -> str:
|
||
"""展示用:DF 的程序化名字全小写,正文里按首字母大写会更像专名。
|
||
|
||
只影响渲染,不改变史料本身。
|
||
"""
|
||
if not name:
|
||
return name
|
||
return " ".join(part[:1].upper() + part[1:] for part in name.split())
|
||
|
||
|
||
@dataclass
|
||
class World:
|
||
name: str = ""
|
||
altname: str = ""
|
||
figures: dict[int, Figure] = field(default_factory=dict)
|
||
events: list[Event] = field(default_factory=list)
|
||
entities: dict[int, Entity] = field(default_factory=dict)
|
||
sites: dict[int, Site] = field(default_factory=dict)
|
||
sources: list[str] = field(default_factory=list)
|
||
|
||
# ---------------------------------------------------------- 查询
|
||
# 约定:id 为负表示“无此项”(DF 用 -1 占位),返回空串让调用方跳过。
|
||
def figure_name(self, fid: int) -> str:
|
||
if fid < 0:
|
||
return ""
|
||
fig = self.figures.get(fid)
|
||
return pretty(fig.name) if fig and fig.name else f"HF#{fid}"
|
||
|
||
def entity_name(self, eid: int) -> str:
|
||
if eid < 0:
|
||
return ""
|
||
ent = self.entities.get(eid)
|
||
return pretty(ent.name) if ent and ent.name else f"Entity#{eid}"
|
||
|
||
def site_name(self, sid: int) -> str:
|
||
if sid < 0:
|
||
return ""
|
||
st = self.sites.get(sid)
|
||
return pretty(st.name) if st and st.name else f"Site#{sid}"
|
||
|
||
def event_years(self) -> tuple[int, int]:
|
||
years = [e.year for e in self.events if e.year]
|
||
return (min(years), max(years)) if years else (0, 0)
|
||
|
||
|
||
# ------------------------------------------------------------------ 解析工具
|
||
def _int_or_none(text: str | None) -> int | None:
|
||
if text is None:
|
||
return None
|
||
try:
|
||
return int(text)
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _children(elem: Any) -> dict[str, list[str]]:
|
||
"""把元素的子标签收成 {tag: [文本, ...]}。"""
|
||
out: dict[str, list[str]] = {}
|
||
for child in elem:
|
||
val = (child.text or "").strip()
|
||
out.setdefault(child.tag, []).append(val)
|
||
return out
|
||
|
||
|
||
def _first(d: dict[str, list[str]], *keys: str) -> str:
|
||
for k in keys:
|
||
if d.get(k):
|
||
return d[k][0]
|
||
return ""
|
||
|
||
|
||
# XML 1.0 不允许的控制字符(保留 \t \n \r)
|
||
ILLEGAL_BYTES = bytes(range(0x09)) + b"\x0b\x0c" + bytes(range(0x0e, 0x20))
|
||
CLEAN_CACHE = Path("data/exports/clean")
|
||
|
||
|
||
def sanitize(src: Path, dst: Path, chunk_size: int = 8 << 20) -> Path:
|
||
"""把 DF 导出的 legends XML 转成合规的 UTF-8 文件。
|
||
|
||
DF 会在名字里嵌入 \\x10/\\x11 等 CP437 控制字节做专名标记,
|
||
XML 1.0 禁止这些字节,严格解析器会直接报错。
|
||
这里做两件事:剔除非法控制字节;把 CP437 声明与实际字节统一成 UTF-8。
|
||
cp437 是单字节编码,因此可以按块转换而不会切断字符。
|
||
"""
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
with src.open("rb") as fin, dst.open("wb") as fout:
|
||
first = True
|
||
while True:
|
||
buf = fin.read(chunk_size)
|
||
if not buf:
|
||
break
|
||
buf = buf.translate(None, ILLEGAL_BYTES)
|
||
if first:
|
||
buf = buf.replace(b"encoding='CP437'", b"encoding='UTF-8'", 1)
|
||
buf = buf.replace(b'encoding="CP437"', b'encoding="UTF-8"', 1)
|
||
first = False
|
||
fout.write(buf.decode("cp437").encode("utf-8"))
|
||
return dst
|
||
|
||
|
||
def clean_copy(src: Path) -> Path:
|
||
"""返回可解析的干净副本,带缓存(源文件更新则重建)。
|
||
|
||
缓存写到 data/exports/clean/ 子目录,避开 find_exports 的 *.xml 扫描,
|
||
否则干净副本会被当作第二份史料,导致事件数翻倍。
|
||
"""
|
||
dst = CLEAN_CACHE / src.name
|
||
if dst.is_file() and dst.stat().st_mtime >= src.stat().st_mtime and dst.stat().st_size > 0:
|
||
return dst
|
||
return sanitize(src, dst)
|
||
|
||
|
||
def _reject_unsafe_xml(path: Path) -> None:
|
||
"""拒绝带 DTD / 实体声明的文件。
|
||
|
||
DOCTYPE 必须出现在根元素之前,因此只检查文件头部即可拦住
|
||
XXE 与 billion-laughs 这两类实体展开攻击。
|
||
"""
|
||
with path.open("rb") as fh:
|
||
head = fh.read(65536)
|
||
for marker in (b"<!DOCTYPE", b"<!ENTITY"):
|
||
if marker in head:
|
||
raise ValueError(f"拒绝解析含 DTD/实体声明的文件({marker.decode()}): {path}")
|
||
|
||
|
||
WRAPPERS = ("historical_figures", "historical_events", "entities", "sites")
|
||
|
||
|
||
def parse_file(path: Path, world: World) -> None:
|
||
"""流式解析一个 legends XML,合并进 world。
|
||
|
||
用元素深度而不是包裹标签的进出状态来控制语义:
|
||
只有 depth == 2(即 <df_world> 的直接子元素)才会被当作世界级字段,
|
||
这样历史人物、地区里的 <name> 永远不会污染世界名。
|
||
"""
|
||
_reject_unsafe_xml(path)
|
||
world.sources.append(str(path))
|
||
depth = 0
|
||
parser = iterparse(str(path), events=("start", "end"))
|
||
for event, elem in parser:
|
||
if event == "start":
|
||
depth += 1
|
||
continue
|
||
|
||
tag = elem.tag
|
||
if depth == 2:
|
||
if tag == "name":
|
||
world.name = world.name or (elem.text or "").strip()
|
||
elif tag == "altname":
|
||
world.altname = world.altname or (elem.text or "").strip()
|
||
elif tag == "historical_figure":
|
||
_add_figure(world, elem)
|
||
elem.clear()
|
||
elif tag == "historical_event":
|
||
_add_event(world, elem)
|
||
elem.clear()
|
||
elif tag == "entity":
|
||
_add_entity(world, elem)
|
||
elem.clear()
|
||
elif tag == "site":
|
||
_add_site(world, elem)
|
||
elem.clear()
|
||
|
||
depth -= 1
|
||
|
||
|
||
def _add_figure(world: World, elem) -> None:
|
||
d = _children(elem)
|
||
fid = _int_or_none(_first(d, "id"))
|
||
if fid is None:
|
||
return
|
||
fig = world.figures.setdefault(fid, Figure(id=fid))
|
||
fig.name = fig.name or _first(d, "name")
|
||
fig.race = fig.race or _first(d, "race")
|
||
fig.profession = fig.profession or _first(d, "profession")
|
||
if fig.sex is None:
|
||
fig.sex = _int_or_none(_first(d, "sex"))
|
||
if fig.birth_year is None:
|
||
fig.birth_year = _int_or_none(_first(d, "birth_year"))
|
||
if fig.death_year is None:
|
||
fig.death_year = _int_or_none(_first(d, "death_year"))
|
||
|
||
|
||
def _add_event(world: World, elem) -> None:
|
||
d = _children(elem)
|
||
eid = _int_or_none(_first(d, "id"))
|
||
year = _int_or_none(_first(d, "year"))
|
||
if eid is None or year is None:
|
||
return
|
||
world.events.append(
|
||
Event(
|
||
id=eid,
|
||
year=year,
|
||
seconds72=_int_or_none(_first(d, "seconds72")) or 0,
|
||
type=_first(d, "type"),
|
||
fields=d,
|
||
)
|
||
)
|
||
|
||
|
||
def _add_entity(world: World, elem) -> None:
|
||
d = _children(elem)
|
||
eid = _int_or_none(_first(d, "id"))
|
||
if eid is None:
|
||
return
|
||
ent = world.entities.setdefault(eid, Entity(id=eid))
|
||
ent.name = ent.name or _first(d, "name")
|
||
ent.type = ent.type or _first(d, "type")
|
||
ent.race = ent.race or _first(d, "race")
|
||
|
||
|
||
def _add_site(world: World, elem) -> None:
|
||
d = _children(elem)
|
||
sid = _int_or_none(_first(d, "id"))
|
||
if sid is None:
|
||
return
|
||
st = world.sites.setdefault(sid, Site(id=sid))
|
||
st.name = st.name or _first(d, "name")
|
||
st.type = st.type or _first(d, "type")
|
||
|
||
|
||
def load(paths: list[Path]) -> World:
|
||
world = World()
|
||
for p in paths:
|
||
if not p.is_file():
|
||
raise FileNotFoundError(f"缺少 legends 导出文件: {p}")
|
||
if p.stat().st_size == 0:
|
||
raise ValueError(f"legends 导出文件为空: {p}")
|
||
parse_file(clean_copy(p), world)
|
||
world.events.sort(key=lambda e: (e.year, e.seconds72, e.id))
|
||
return world
|
||
|
||
|
||
def describe(world: World, top: int = 8) -> str:
|
||
lo, hi = world.event_years()
|
||
by_type: dict[str, int] = {}
|
||
for e in world.events:
|
||
by_type[e.type] = by_type.get(e.type, 0) + 1
|
||
hot = sorted(by_type.items(), key=lambda kv: -kv[1])[:top]
|
||
lines = [
|
||
f"世界: {world.name}" + (f"({world.altname})" if world.altname else ""),
|
||
f"历史跨度: {lo}–{hi}({hi - lo} 年)| 事件 {len(world.events)} 条",
|
||
f"历史人物 {len(world.figures)} | 文明/组织 {len(world.entities)} | 地点 {len(world.sites)}",
|
||
"高频事件类型: " + ", ".join(f"{t}×{n}" for t, n in hot),
|
||
f"来源: {', '.join(Path(s).name for s in world.sources)}",
|
||
]
|
||
return "\n".join(lines)
|