PAYATHON 2026

如何用 Python 解析含 ü 实体的 audio.cdindex?

排障赵师傅

xml.etree.ElementTree 默认不加载外部 DTD。即使把地址换成本地的 my_private.dtd,其中的实体声明也不会生效。对于这份文件,可以将 uuml 声明放进 DOCTYPE 的内部子集,或把已知的 ü 替换为数值字符引用 ü。

为什么本地 DTD 没有生效?

这条声明引用的是外部 DTD:

<!DOCTYPE CDInfo SYSTEM "my_private.dtd">

ElementTree 不会自动读取它,无论远程 DTD 能否访问、本地文件是否存在。之前其他 CD 能正常解析,是因为它们没有用到需要在外部 DTD 中定义的实体。

XML 预定义的命名实体只有 &amp;、&lt;、&gt;、&apos; 和 &quot;。&uuml; 是常见的 HTML 实体,在 XML 中使用时仍需声明。

可以将声明改为内部子集:

<!DOCTYPE CDInfo [
    <!ENTITY uuml "&#252;">
]>

这样,实体定义就包含在 XML 文档中,ElementTree 可以直接读取。&#252; 表示 Unicode 字符 U+00FC,也就是 ü,因此 f&uuml;r 会解析为 für。取消内联声明那一行的注释后能解析成功,也是这个原因。

方案一:将外部 DTD 引用替换为内部声明

题目中的 DOCTYPE 只有 SYSTEM 外部引用,没有内部子集,可以在解析前替换这条声明。

建议以二进制方式读取文件,让 XML 解析器根据 encoding="ISO-8859-1" 解码,避免 open(dest) 使用系统默认编码。

from pathlib import Path
import re
import xml.etree.ElementTree as ET


EXTERNAL_DOCTYPE = re.compile(
    rb"""<!DOCTYPE\s+CDInfo\s+SYSTEM\s+(?:"[^"]*"|'[^']*')\s*>"""
)

INTERNAL_DOCTYPE = (
    b'<!DOCTYPE CDInfo [<!ENTITY uuml "&#252;">]>'
)


def clean(data):
    data, count = EXTERNAL_DOCTYPE.subn(
        INTERNAL_DOCTYPE, data, count=1
    )
    if count != 1:
        raise ValueError("未找到预期的 CDInfo SYSTEM 声明")
    return data


def readAudioindex(dest):
    data = Path(dest).read_bytes()
    root = ET.fromstring(clean(data))

    album = root.findtext("Title") or ""
    artist = root.findtext(".//Artist") or ""
    tracks = []

    for track in root.iter("Track"):
        title = track.findtext("Name") or ""

        # 保留原程序按第一个 "/" 拆分艺术家和曲名的约定。
        if "/" in title:
            track_artist, title = title.split("/", 1)
            track_artist = track_artist.strip()
            title = title.strip()
        else:
            track_artist = artist

        tracks.append({
            "num": int(track.attrib["Num"]),
            "tt": title,
            "ta": track_artist,
        })

    return artist, album, tracks

这个匹配规则既适用于原来的 MusicBrainz URL,也适用于示例中的 "my_private.dtd"。它只处理题目展示的外部声明格式,不能用于任意复杂的 DTD。如果文档已有内部子集,应保留原有声明,再补充缺失的实体定义。

原代码中的匹配规则也需要注意:

dtype = re.compile(r"<!DOCTYPE.*musicbrainz.*>")

它只匹配包含 musicbrainz 的单行声明。示例文件改用 "my_private.dtd" 后,clean() 就不会替换这条声明,因此即使启用了内联声明那一行,解析也可能仍然失败。

方案二:只替换已知实体

如果只需处理这一处 &uuml;,可以直接替换为数值字符引用:

from pathlib import Path
import xml.etree.ElementTree as ET

data = Path("audio.cdindex").read_bytes()
data = data.replace(b"&uuml;", b"&#252;")
root = ET.fromstring(data)

数值字符引用不依赖 DTD,因此可以保留原来的外部 DOCTYPE。ElementTree 仍会忽略外部 DTD,但曲目中的 &#252; 能正常解析为 ü。

这种做法适用于已确认 &uuml; 出现在普通文本或属性值中的文件。全局替换也会改动注释和 CDATA 中的同名字节串。如果需要完整保留这些内容,使用内部实体声明更合适。

编码和后续处理要注意什么?

不要用默认编码读取声明为 ISO-8859-1 的文件。上述字节读取方式要求文件的实际编码与 XML 声明一致。如果按文本读取,应明确指定编码:

with open(dest, encoding="iso-8859-1") as fd:
    text = fd.read()

也不要对整个 XML 调用 html.unescape()。它会同时转换 &amp;、&lt; 等合法的 XML 转义,可能导致正文中的字符被误当成 XML 语法。

添加 uuml 的定义只能解决这一种实体缺失问题。后续如果遇到其他未定义实体,还需要补充对应的声明,或为其指定替换映射。

备注:内容仅供参考。