1. 为什么值得花时间啃透 .nfo 这种"老古董"格式
第一次接触 .nfo 文件的人,多半是在整理本地影音库或者游戏资源包的时候,突然发现某个文件夹里躺着一个同名的小文本,双击打开要么是一堆看不懂的标签,要么是一段排版整齐的 ASCII 字符画。很多人第一反应是"这玩意儿有啥用",直接删掉。但我可以很负责任地说,.nfo 是本地媒体库管理里性价比极高的一个环节——它决定了你的播放器、媒体中心能不能正确识别片名、年份、演员、简介、海报,甚至决定了你几千部片子能不能被自动归类。
.nfo 本质上就是一个纯文本文件,早期是给一些信息文件做说明用的,后来在媒体收藏圈子里逐渐演化成了一套约定俗成的元数据描述格式。它有两种主流形态:一种是Kodi/Emby/Jellyfin 这类媒体中心使用的 XML 结构,根节点通常是<movie>、<tvshow>、<episode>、<album>之类;另一种是纯文本的 ASCII 艺术排版,常见于早期的破解资源包,用等宽字符拼出标题、发行组、发布日期等信息。这两种形态的处理思路完全不同,前者要解析 XML,后者要处理字符编码和排版。
用 Python 来处理 .nfo 的价值在于:当你手上有几百上千个媒体文件、每个都配着一个 .nfo,靠手工改是绝对不现实的。你需要批量读取、批量修正字段、批量生成缺失的元数据、批量校验格式是否合规。Python 的xml.etree.ElementTree、lxml、chardet这些库组合起来,能把这件事做得又快又稳。这篇内容适合三类人看:一是刚搭好媒体服务器、被一堆乱七八糟 .nfo 搞得头大的新手;二是想写批量整理脚本、但不确定 XML 命名空间和编码怎么处理的中级玩家;三是需要把 .nfo 数据抽取出来做二次分析(比如统计收藏、生成报表)的开发者。下面我会从格式拆解、库选型、实操脚本、踩坑排查几个层面,把这件事讲透。
2. .nfo 格式的整体拆解与处理思路设计
2.1 两种形态的识别与分流策略
动手写代码之前,第一件事是判断你面对的 .nfo 到底属于哪一类。我的做法是先读文件头几百个字节,看是否以<?xml开头或者包含<movie、<tvshow这类根标签。如果是,走 XML 解析路线;如果不是,再判断是不是纯文本 ASCII 排版,这类文件通常含有大量█、▓、─、│之类的制表字符,或者用+---+拼边框。
这里有个容易被忽略的点:有些 .nfo 文件虽然内容是 XML,但开头带了 BOM(字节顺序标记),或者前面混进了几行发行组自己加的注释文字,导致直接丢给 XML 解析器会报 "junk after document element"。所以分流逻辑不能只看开头,得做一次"容错探测"——先尝试用宽松模式解析,失败再回退到文本模式。我一般会写一个detect_nfo_type(path)函数,返回'xml'、'text'或'unknown'三种结果,后续所有处理都基于这个判断走不同分支。
为什么强调分流?因为 XML 和纯文本的处理成本差异巨大。XML 有结构、有字段名,可以精准定位;纯文本只能靠正则去猜,鲁棒性差很多。把两类混在一起处理,代码会变得又臭又长,而且一旦遇到畸形文件就容易整批崩掉。分流之后,每一类各自有独立的异常处理,互不干扰,这是批量脚本能稳定跑完几千个文件的前提。
2.2 为什么优先选 ElementTree 而不是一上来就上 lxml
库选型这块,很多人一搜就搜到 lxml,觉得功能强、性能好,直接上。我的建议是:先摸清你的 .nfo 有多"脏"。如果文件都是媒体中心自动刮削生成的,结构规整、命名空间统一,那标准库的xml.etree.ElementTree完全够用,零依赖、部署简单,脚本丢到任何一台机器上都能跑。lxml 的优势在于容错解析(recover=True)、XPath 完整支持和更快的 C 实现,但它是第三方依赖,装的时候在某些环境下还得编译,对只想跑个整理脚本的人来说是额外负担。
我的实际策略是分层:默认用 ElementTree,遇到解析失败的文件再降级到 lxml 的容错模式。这样 90% 的正常文件走轻量路线,剩下 10% 的畸形文件也能救回来。具体判断逻辑是捕获ET.ParseError,一旦触发就用lxml.etree.parse(path, parser=etree.XMLParser(recover=True))再试一次。这个组合我在多个批量项目里用过,实测下来很稳,既避免了全量依赖 lxml,又不会因为个别脏文件中断整个流程。
还有一个细节是命名空间。Kodi 系的 .nfo 有时会带xmlns声明,直接用find('title')会找不到节点,得用带命名空间的写法或者iter()遍历时忽略命名空间。我通常写一个strip_ns(tag)的小工具,把{http://...}title这种形式统一裁成title,后续处理就清爽了。
2.3 编码问题的整体预案
.nfo 的编码是另一个大坑。老资源包里 GBK、GB2312、Big5 混着来,新一点的媒体中心基本用 UTF-8。如果你用默认的open(path)去读,在非 UTF-8 环境下大概率抛UnicodeDecodeError。我的预案是三步走:先按 UTF-8 严格读,失败则用chardet探测编码,探测结果置信度低于阈值时回退到gb18030(它兼容 GBK/GB2312 且覆盖更全)。写回时统一转成 UTF-8,并在文件头显式声明<?xml version="1.0" encoding="utf-8"?>,避免下游播放器再猜一遍。
注意:
chardet对短文本的探测准确率有限,几百字节以内的文件经常误判。所以探测前最好先读够 4KB 以上再喂给它,短文件直接按gb18030兜底更省事。
3. 核心字段解析与实操要点
3.1 XML 型 .nfo 的字段结构长什么样
一个典型的电影 .nfo 大致长这样(字段是约定俗成的,不同刮削器略有差异):
<?xml version="1.0" encoding="utf-8"?> <movie> <title>示例片名</title> <originaltitle>Original Title</originaltitle> <year>2021</year> <rating>7.8</rating> <plot>剧情简介文本</plot> <genre>动作</genre> <genre>科幻</genre> <actor><name>某演员</name><role>某角色</role></actor> <uniqueid type="imdb">tt0000000</uniqueid> </movie>剧集类则是<tvshow>包<season>包<episode>的层级,或者每个单集一个<episodedetails>。理解这个层级关系很重要,因为批量修改时你得知道自己在改哪一层。比如给所有剧集补season编号,就得遍历<episodedetails>节点而不是根节点。
解析时我习惯先把整棵树转成一个扁平字典,键用"路径式"命名,比如movie.title、movie.actor[0].name,这样后续做字段比对、生成报表都方便。转换函数大概二十行,核心是递归遍历elem,遇到有子节点就往下钻,遇到叶子节点就记录tag: text。多值字段(比如多个<genre>)要特殊处理成列表,否则会被后面的值覆盖。
3.2 纯文本 ASCII 型 .nfo 的提取技巧
这类文件没有结构,全靠排版。常见的模式是标题: xxx、发行日期: xxx这种"键冒号值"的行,或者用制表字符拼出的表格。提取思路是用正则逐行匹配^\s*([^::]+)[::]\s*(.+)$,把键值对捞出来。难点在于有些行是纯装饰(比如─────分隔线),有些行的值里本身带冒号(比如时间12:30),所以正则要写得克制一点,键名限制在合理长度内,避免把整行都吞进去。
对于 ASCII 字符画部分,我的建议是直接跳过不解析。字符画只是视觉装饰,没有结构化信息,硬去解析既费力又没收益。判断方法很简单:如果一行里制表字符(█▓▒░─│┌┐└┘├┤等)占比超过一半,就判定为装饰行,跳过。这个阈值我试过 0.5 和 0.6,0.5 更稳妥,误杀正常文本的概率很低。
3.3 字段校验:哪些字段值得较真
不是所有字段都值得花力气校验。我的经验是重点盯这几个:title不能为空、year必须是四位数字且在合理区间(比如 1888 到当前年份加 2)、rating必须在 0 到 10 之间、uniqueid的type属性要合法。其他字段比如plot、genre缺失很常见,不必强求。
校验的价值在于提前发现问题文件,避免它们污染媒体库。我一般会生成一份校验报告,列出每个文件的"问题清单",格式是文件名 -> [字段: 问题描述],然后人工过一遍决定是自动修还是手动处理。自动修的部分主要是补默认值(比如year缺失就留空而不是瞎填)、修正明显越界的数值(rating大于 10 就截断到 10)。这里要克制,不要自作主张填假数据,宁可留空让刮削器重新抓,也别塞进去一个错误值,否则后面排查起来更麻烦。
4. 完整实操流程:从批量读取到安全写回
4.1 环境准备与依赖安装
先把环境搭起来。Python 3.8 以上都行,我习惯用 3.10 因为match语法写分支判断很顺手。依赖就三个:
pip install lxml chardetxml.etree.ElementTree是标准库不用装。如果你只想跑最基础的解析,其实连 lxml 都可以先不装,等遇到畸形文件再补。我建议先装 chardet,编码探测这块它是刚需。
目录结构上,我习惯把待处理文件放input/,处理结果放output/,日志放logs/,脚本本身放根目录。这样跑批量的时候不会把原文件搞乱,出问题也能对照着回滚。永远不要在原目录上直接改,这是我踩过最痛的坑之一——有次脚本写回时编码判断错了,几百个文件的标题全变成乱码,幸好当时有备份。
4.2 批量扫描与类型分流的代码骨架
先写扫描和分流的主循环:
import os from pathlib import Path def scan_nfo_files(root_dir): return list(Path(root_dir).rglob('*.nfo')) def detect_nfo_type(path): try: with open(path, 'rb') as f: head = f.read(4096) except OSError: return 'unknown' if b'<?xml' in head or b'<movie' in head or b'<tvshow' in head: return 'xml' text_chars = sum(1 for b in head if b in b'\xb2\xb1\xb0\xdb\xdf\xd9\xc4\xc0') if text_chars > len(head) * 0.1: return 'text' return 'unknown'rglob会递归扫所有子目录,比os.walk写起来简洁。detect_nfo_type里那个字节判断是粗筛,\xb2这些是 GBK 编码下制表字符的字节值,命中率高但会有误判,所以后面解析时还要再兜一层异常。分流结果我一般先打印统计,看看这批文件里 XML 和文本各占多少,心里有个数再往下走。
4.3 XML 解析与字段抽取的完整实现
核心解析函数这样写:
import xml.etree.ElementTree as ET from lxml import etree def strip_ns(tag): return tag.split('}')[-1] if '}' in tag else tag def parse_xml_nfo(path): try: tree = ET.parse(path) root = tree.getroot() except ET.ParseError: parser = etree.XMLParser(recover=True, encoding='utf-8') tree = etree.parse(str(path), parser) root = tree.getroot() result = {} def walk(elem, prefix=''): tag = strip_ns(elem.tag) key = f'{prefix}.{tag}' if prefix else tag children = list(elem) if not children: text = (elem.text or '').strip() if key in result: if not isinstance(result[key], list): result[key] = [result[key]] result[key].append(text) else: result[key] = text else: for child in children: walk(child, key) walk(root) return result这段代码的关键点有三个。第一,strip_ns处理命名空间,避免find找不到节点。第二,解析失败时降级到 lxml 的recover=True,这个模式会尽量把能读的部分读出来,而不是直接抛异常。第三,walk递归时对重复键做列表化处理,这样多个<genre>不会互相覆盖。实测下来,这套逻辑能覆盖 95% 以上的真实 .nfo 文件。
4.4 安全写回:先备份再落盘
写回是最容易出事的一步。我的原则是三步走:备份、写临时文件、原子替换。备份就是把原文件复制一份到backup/目录,文件名加时间戳后缀。写临时文件是先写到xxx.nfo.tmp,确认内容完整后再os.replace覆盖原文件。os.replace在大多数文件系统上是原子操作,能避免写到一半断电导致文件损坏。
import shutil, os, time def safe_write(path, content, backup_dir): backup_dir = Path(backup_dir) backup_dir.mkdir(parents=True, exist_ok=True) ts = time.strftime('%Y%m%d%H%M%S') shutil.copy2(path, backup_dir / f'{Path(path).name}.{ts}.bak') tmp = Path(str(path) + '.tmp') with open(tmp, 'w', encoding='utf-8') as f: f.write(content) os.replace(tmp, path)写回内容时记得在开头补上 XML 声明,并且把编码统一成 UTF-8。如果原文件是 GBK,写回成 UTF-8 后下游播放器一般都能正确识别,因为声明里写清楚了。但有个例外:某些老版本播放器只认本地编码,这种情况就得保留原编码写回,判断依据是看原文件声明里的encoding属性。
4.5 纯文本型 .nfo 的提取与转换
文本型文件的处理相对简单,核心是键值对提取:
import re KV_PATTERN = re.compile(r'^\s*([\u4e00-\u9fa5A-Za-z][\u4e00-\u9fa5A-Za-z0-9 _-]{0,20})[::]\s*(.+)$') def parse_text_nfo(path): with open(path, 'r', encoding='utf-8', errors='replace') as f: lines = f.readlines() result = {} for line in lines: m = KV_PATTERN.match(line) if m: key, value = m.group(1).strip(), m.group(2).strip() result[key] = value return result正则里键名限制在 20 个字符以内,且必须以中文或字母开头,这样能过滤掉大部分装饰行。errors='replace'保证遇到坏字节不会崩,坏字节会被替换成占位符,后续可以统计有多少文件出现了替换符来判断编码质量。提取出来的键值对可以进一步映射到标准字段名,比如"片名"映射到title、"发行日期"映射到year,映射表按你的实际文件情况维护。
5. 常见问题与排查技巧实录
5.1 解析报错速查表
批量跑的时候报错是常态,我把高频问题整理成表,方便对照排查:
| 报错信息 | 典型原因 | 处理办法 |
|---|---|---|
ParseError: junk after document element | XML 后面混了注释或多余内容 | 用 lxmlrecover=True降级解析 |
UnicodeDecodeError | 编码不是 UTF-8 | chardet 探测后按探测结果读 |
KeyError/ 字段为空 | 命名空间导致 find 失败 | 用strip_ns裁掉命名空间 |
| 写回后播放器不识别 | 编码声明与实际编码不符 | 检查声明并统一编码 |
| 多值字段只剩一个 | 字典键被覆盖 | 重复键做列表化处理 |
这张表是我从实际项目里攒出来的,基本覆盖了八成以上的报错场景。遇到表里没有的,先看报错行号定位到具体文件,再单独拿那个文件跑一遍最小复现,通常很快就能找到原因。
5.2 三个容易踩的实操坑
坑一:BOM 头导致解析失败。有些 Windows 下生成的 .nfo 开头带\xef\xbb\xbf,ElementTree 读到会报错。解决办法是读文件时用encoding='utf-8-sig',它会自动吃掉 BOM。这个细节不注意的话,会有一批文件莫名其妙解析失败,排查半天才发现是三个字节的问题。
坑二:iter()遍历时把嵌套节点也捞出来。比如你想找所有<title>,用root.iter('title')会把<movie><title>和<episode><title>全捞出来,如果文件里同时有电影和剧集信息就会串。正确做法是先定位到目标父节点,再在父节点范围内findall。这个坑我在处理混合类型文件时踩过,导致剧集标题被电影标题覆盖。
坑三:批量写回时路径拼接出错。用Path拼接路径时,如果原文件名里带特殊字符(比如#、&),某些场景下会出问题。稳妥做法是用Path(path).with_suffix('.nfo.tmp')这种基于原路径派生的方式,而不是手工拼字符串。另外写回前一定要确认目标目录有写权限,否则os.replace会抛PermissionError,而且是在处理到一半的时候才抛,很尴尬。
5.3 性能与规模化的经验
处理几百个文件,上面的脚本几秒钟就跑完了。但如果规模上到几万个,就得考虑优化。我的经验是:I/O 是瓶颈,不是解析。所以优化重点放在减少文件打开次数和并行化上。用concurrent.futures.ThreadPoolExecutor开 8 到 16 个线程做文件级并行,实测能提速 3 到 5 倍,因为解析本身是 CPU 轻负载,线程切换开销可以接受。注意别开太多线程,机械硬盘上开 32 个线程反而会因为寻道竞争变慢,8 到 16 是比较稳的区间。
另外日志要写全。每个文件的处理结果(成功、跳过、失败原因)都记一行到日志文件,跑完之后统计一下成功率。我一般要求成功率在 98% 以上才算这批文件处理合格,剩下的 2% 单独拎出来人工看。日志格式用时间 | 文件路径 | 状态 | 备注,方便后续用grep过滤。
6. 字段映射与数据二次利用的扩展思路
6.1 建立自己的字段映射表
不同来源的 .nfo 字段命名五花八门,有的用originaltitle,有的用original_title,有的干脆用中文"原名"。想批量统一,就得维护一张映射表。我的做法是用一个 YAML 或 JSON 文件存映射关系,格式是标准字段名: [别名1, 别名2, ...],脚本启动时加载,解析时按别名逐个匹配。这样以后遇到新别名,改配置文件就行,不用动代码。
映射表还有个好处是可以做"字段归一化"。比如rating有的写7.8,有的写7.8/10,有的写78%,归一化函数统一转成 0 到 10 的浮点数。归一化规则要写清楚并记日志,方便回溯。我一般会把归一化前后的值都记下来,出问题时能快速定位是哪条规则误伤了。
6.2 把 .nfo 数据抽出来做统计
字段抽干净之后,二次利用的空间很大。比如统计收藏里各年份的影片数量、各类型的占比、评分分布,用pandas几行代码就能出图。我做过一个收藏分析的小工具,把几千个 .nfo 解析成 DataFrame,然后按年份、类型、评分做透视,输出一份 HTML 报告。这个过程里 .nfo 就是数据源,Python 负责 ETL,最后可视化。对喜欢折腾媒体库的人来说,这种"数据反哺"的玩法挺有意思,也能帮你发现收藏里的盲区,比如某几年片子特别少、某类型几乎没收。
做统计时要注意数据清洗。year字段可能有空值、有异常值(比如 0 或 9999),rating可能有空字符串。清洗规则我一般写成独立的函数,和解析逻辑分开,这样统计脚本可以复用,也方便单独测试。清洗后的数据存成 CSV 或 Parquet,后续分析直接读,不用每次重新解析 .nfo。
6.3 生成缺失 .nfo 的补全脚本
反向操作也很有用:手上有一堆媒体文件但没有 .nfo,可以根据文件名规则(比如片名 (年份).mkv)生成基础 .nfo 模板,字段先留空,等刮削器去填。生成模板的关键是文件名解析,正则大概长这样:^(.+?)[\s._]*[\(\[]?(\d{4})[\)\]]?,能覆盖大部分片名 (2020)、片名.2020这类命名。解析出来的片名和年份填进模板,其他字段留空节点,这样媒体中心扫描时至少能识别出条目,不会一片空白。
生成模板时有个细节:<uniqueid>节点最好留空但保留节点本身,因为有些刮削器看到节点存在才会去补 ID,节点都没有就直接跳过了。这个行为不同刮削器不一样,我一般两种都生成一份对比测试,看哪个识别率高就用哪个。这种"喂数据"的思路,本质上是把 .nfo 当成媒体库和刮削器之间的契约文件,理解了这个定位,很多操作就顺理成章了。
7. 我在实际项目里攒下的几点体会
处理 .nfo 这件事,技术难度其实不高,难的是对"脏数据"的耐心和对"不破坏原文件"的敬畏。我最早写这类脚本时图省事,直接在原文件上改,结果一次编码判断失误把一批文件的标题写成了乱码,虽然最后靠备份救回来了,但那次教训让我养成了"备份、临时文件、原子替换"三件套的习惯,之后再没出过不可逆的事故。
另一个体会是不要过度自动化。字段校验发现问题时,能自动修的修,修不了的就老老实实列出来人工处理,别为了追求"全自动"硬塞默认值。媒体库这种东西,一个错误的年份或评分,可能让你在几千部片子里找半天才发现问题源头。宁可慢一点、留点人工环节,也别让脚本替你做它不该做的决定。
最后分享一个小技巧:处理前先拿 20 个文件做样本跑一遍,人工核对解析结果和写回内容,确认无误再全量跑。这个"小样本验证"的习惯,帮我省下了无数次全量重跑的时间。样本要覆盖不同类型的文件——XML 的、文本的、带 BOM 的、GBK 编码的,每种都挑几个,跑通了再放大规模,心里踏实。