第 1 卷第 1 章:1–9 年

This commit is contained in:
Chen Yi
2026-10-05 17:30:11 +08:00
parent c56394afdd
commit 3468433c9a
34 changed files with 2564 additions and 4 deletions
+347
View File
@@ -0,0 +1,347 @@
"""解析矮人要塞的 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)