
如果你既喜欢 DC 动画又是一名喜欢动手写脚本的开发者那么在看到《我与超人的冒险》第3季第4集的标题“别叫我超级小子捣蛋鬼夏日特辑”时第一反应可能不只是“这集好可爱”还会有另一个问题我的本地剧集文件到底有没有整理好第3季第4集在文件命名里应该怎么写我能不能用一个小工具把整个追番进度管理起来这篇文章不打算展开做影评而是把这一集当成一个非常欢乐的示例数据带着大家用 Python 标准库从零实现一个“本地追番清单小工具”。工具的核心功能包括解析S03E04这一类剧集编号、记录某集是否已经看过、按关键词查询剧集信息最后统计整个动画的观看进度。这个项目不依赖任何第三方库也不需要联网数据全部用 JSON 文件保存适合 Python 初学者练习也适合想系统了解命令行工具结构、正则表达式、文件读写的开发者。1. 《我与超人的冒险》与“捣蛋鬼夏日特辑”是什么1.1 这集被讨论的动画背景《我与超人的冒险》是 DC 旗下以年轻超人为主角的动画剧集。它和传统超人故事那种严肃大场面风格不同整体画风明亮角色关系更轻松克拉克、露易丝和吉米三个人像年轻人一样一边工作一边成长。正因如此很多观众会用“轻松可爱”来形容这部动画。项目标题里提到的“捣蛋鬼夏日特辑”指的应该是第3季第4集这样一个相对独立、气氛欢乐的集数。在超人相关故事中捣蛋鬼先生Mr. Mxyzptlk是来自第五维度的经典角色特点是台词密集、行为像小孩一样任性经常用夸张的方式给超人制造麻烦。以他为主角的剧集通常不会走沉重路线反而更像一部“超英主题的夏日小甜点”。这类集数天然适合作为示例数据它有明确的季数、集数、有特色标题还有可爱角色标签简直是为“剧集信息管理工具”准备的测试样本。1.2 从观众视角到开发者视角作为普通观众遇到这种特别篇时往往只需要打开播放器点开就行。但如果你的本地文件越来越多尤其是同时追好几部动画、剧集文件命名又不统一的时候就会遇到几个很现实的问题有的文件叫My Adventures with Superman S03E04.mkv。有的文件叫我与超人的冒险第3季第4集.mp4。还有的文件只写03x04 - title.mkv。收藏夹里可能还混着《超人王朝》《毁灭之日》等其他超人主题资源。这时候靠肉眼去一个个确认“看到第几集”就很累。更靠谱的方法是写一个脚本用统一的规则识别文件名里的季数和集数把进度记录在一个 JSON 文件里再通过命令随时查询。这篇文章要做的工具就是解决这些问题的。2. 需求分析与项目设计2.1 工具功能清单在动手写代码之前先把需求拆清楚。要做一个实用的本地追番清单工具至少要包含以下能力功能说明对应命令剧集文件名解析从任意文件名中识别出S03E04或“第3季第4集”parse剧集信息查询按动画名称或集数查询剧集标题query观看进度标记把某一集标记为已看mark观看进度统计输出已看集数、未看集数、完成比例stats数据本地化所有数据以 JSON 文件保存不依赖数据库自动为了控制项目复杂度这个工具不做登录、不做网络请求、不做资源扫描只负责管理你已经拥有的剧集信息。2.2 项目目录结构项目目录可以设计成下面这样dc_watchlist/ ├── data/ │ ├── library.json # 剧集库登记动画与集数信息 │ └── progress.json # 进度文件记录哪些集数已经看过 ├── series_tracker.py # 主程序所有命令入口 └── README.md # 可选说明文档其中data/目录保存数据series_tracker.py是唯一一个 Python 文件。这样设计的好处是数据和代码分离以后你想换一部动画只需要修改 JSON 数据完全不用动代码。2.3 为什么选择 JSON 保存数据很多人会想到用 SQLite 做数据存储但对于一个简单的个人追番工具来说JSON 有以下优势结构直观打开文件就能看到每一集的标题、ID、标签。修改方便新增一集只需要加一个对象。无需安装驱动Python 标准库json直接读写。容易备份文件可以直接复制到别的地方。当然如果以后数据量变大或者需要多人协作再迁移到 SQLite 也不难。这就是“先简单后演进”的开发思路。3. 环境准备与基础概念3.1 运行环境本文示例代码基于 Python 3建议使用 3.10 或更高版本。由于只用标准库不需要pip install任何包。在命令行验证 Python 版本python3 --versionWindows 环境下可能是python --version能输出版本号即可。如果你还没有创建虚拟环境可以执行mkdir dc_watchlist cd dc_watchlist python3 -m venv venv然后激活虚拟环境# macOS / Linux source venv/bin/activate # Windows PowerShell venv\Scripts\Activate.ps1 # Windows CMD venv\Scripts\activate.bat本文的工具不依赖第三方包所以即使不建虚拟环境也能运行但养成用虚拟环境隔离项目的习惯对以后做更大项目非常重要。3.2 剧集编号规则 SxxExx 与“第几季第几集”《我与超人的冒险》第3季第4集在标准命名里通常写成My Adventures with Superman S03E04其中S03表示 Season 3也就是第3季。E04表示 Episode 4也就是第4集。S03E04合在一起就是这个剧集在欧美剧集文件规范里的“身份证”。这套规则来自欧美剧集命名习惯字母不区分大小写常见的变体还有S3E4季数和集数不补零。03x04老式用法用x分隔。第3季第4集中文命名习惯。写代码时这几种格式都要能识别。如果只匹配一种格式用户文件稍微变一下就失效工具就不够健壮。3.3 准备工作要做多久这个项目从零开始写大概需要 20 到 30 分钟。如果你只是想复制代码运行5 分钟就能看到效果。作为学习项目建议跟着代码思路自己敲一遍遇到问题再去对照命令行输出。4. 从零实现本地追番清单工具4.1 准备剧集数据 data/library.json先在dc_watchlist/data/下创建library.json。这个文件是“剧集库”以数组形式保存多部动画。每一部动画包含id、title、origin_title、aliases和episodes。aliases是别名列表作用是让你输入中文名、英文名或缩写都能找到同一部动画。例如你想看《我与超人的冒险》可以直接输入maws也可以输入“我与超人的冒险”或My Adventures with Superman。示例数据如下{ shows: [ { id: maws, title: 我与超人的冒险, origin_title: My Adventures with Superman, type: animated_series, aliases: [ maws, my adventures with superman, 我与超人的冒险 ], episodes: { S01E01: { title: 示例剧集开启冒险, keywords: [超人, 露易丝, 吉米] }, S03E04: { title: 别叫我超级小子捣蛋鬼夏日特辑, keywords: [捣蛋鬼, Mr. Mxyzptlk, 夏日特辑, 轻松] } } } ] }这里只写了两集作为示例重点展示结构。实际使用中你可以把所有集数都登记进去每一集对应一个 episode code 字符串。为什么会把S03E04放在第一层因为这是我们查询和标记进度的主键。用 episode code 做 key在代码中判断“某一集是否已看”时非常方便不需要在 JSON 里做嵌套遍历。4.2 准备进度文件 data/progress.json进度文件表示“哪些剧集已经看过”。初始状态为空对象{}当你运行mark命令标记某集已看后脚本会在文件里写入类似下面的内容{ maws: { S03E04: 2025-08-16 } }这里的第一层 key 是动画id第二层 key 是剧集编号codevalue 是观看日期。日期由代码自动生成不需要自己填。4.3 编写主程序系列追踪脚本在项目根目录创建series_tracker.py下面分模块拆解代码。首先是导入依赖和定义路径#!/usr/bin/env python3 # -*- coding: utf-8 -*- series_tracker.py - 本地追番清单工具 使用 Python 标准库实现不依赖第三方包。 import argparse import json import re import sys from datetime import date from pathlib import Path BASE_DIR Path(__file__).resolve().parent DATA_DIR BASE_DIR / data LIBRARY_FILE DATA_DIR / library.json PROGRESS_FILE DATA_DIR / progress.json说明Path(__file__).resolve().parent表示当前 Python 文件所在目录。通过BASE_DIR拼接数据文件路径保证无论从哪个目录执行脚本都能找到data下的文件。这一点很重要。如果不这样做在项目外部运行脚本时脚本会找不到 JSON 文件。接着定义 JSON 读写函数def load_json(path: Path): if not path.exists(): if progress in path.name: return {} return {shows: []} with path.open(r, encodingutf-8) as f: return json.load(f) def save_json(path: Path, data): path.parent.mkdir(parentsTrue, exist_okTrue) with path.open(w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2)这里有几个细节读取 JSON 时显式指定encodingutf-8避免 Windows 默认编码导致中文乱码。写入时使用ensure_asciiFalse保证 JSON 文件里保存的是中文而不是\uXXXX转义序列。indent2让 JSON 文件有缩进方便手工打开查看。接下来是文本归一化函数用来处理别名匹配def normalize_text(text: str) - str: 去掉空格、下划线和常见分隔符统一为小写字符串。 text text.lower() text re.sub(r[\s._\-!?,、], , text) return text.strip()举个例子My Adventures with Superman会被转成myadventureswithsupermanmy-adventures-with-superman也会被转成同样的字符串。这样比较别名时就非常放松用户输入带不带横线、空格都能匹配。4.4 剧集文件名解析器剧集文件名解析是整个工具最核心的部分。定义一个规则列表EPISODE_PATTERNS [ re.compile(r[Ss](?Pseason\d{1,2})[Ee](?Pepisode\d{1,2})), re.compile(r(?Pseason\d{1,2})[xX](?Pepisode\d{1,2})), re.compile(r第(?Pseason\d{1,2})季第(?Pepisode\d{1,2})[集话]), ]对应的解析函数如下def parse_filename(filename: str): 从文件名中解析季数和集数。 text filename.strip() for pattern in EPISODE_PATTERNS: match pattern.search(text) if match: season int(match.group(season)) episode int(match.group(episode)) prefix text[:match.start()].strip() return { episode_code: fS{season:02d}E{episode:02d}, season: season, episode: episode, prefix_hint: prefix, } return None返回结果是一个字典其中episode_code统一转成类似S03E04的格式。season和episode为整数方便排序。prefix_hint是文件名中出现在季数前面的部分通常是剧名例如My Adventures with Superman。这段代码的逻辑很简单按顺序尝试三种规则只要有一种匹配成功就返回结构化结果。4.5 按 ID 查找动画在命令功能中我们经常需要根据用户输入的剧名找到对应动画。例如输入maws要能找到《我与超人的冒险》。封装一个查询函数def find_show(alias: str): 根据用户输入的 ID 或名称别名查找动画。 library load_json(LIBRARY_FILE) target normalize_text(alias) for show in library.get(shows, []): id_matched show[id].lower() target if id_matched: return show for alias_item in show.get(aliases, []): if normalize_text(alias_item) target: return show return None先比较id再比较别名列表。注意这里把所有文本都经过normalize_text所以用户输入My Adventures with Superman和my adventures with superman都能匹配到。4.6 实现 mark 标记已看功能现在实现第一个核心命令把某一集标记为已看。def mark_episode(show_id: str, episode_code: str): 将某部动画的某一集标记为已看。 show find_show(show_id) if not show: print(f未找到动画{show_id}) return episode_code normalize_episode_code(episode_code) if episode_code not in show[episodes]: print(f动画《{show[title]}》中没有登记剧集 {episode_code}) return progress load_json(PROGRESS_FILE) if show[id] not in progress: progress[show[id]] {} progress[show[id]][episode_code] date.today().isoformat() save_json(PROGRESS_FILE, progress) print(f已标记《{show[title]}》{episode_code} 为已看。)这里需要把用户输入统一成规范的剧集编号。例如用户输入s03e04、S3E4、03x04都要能转成S03E04。增加一个辅助函数def normalize_episode_code(code: str) - str: 把用户输入的剧集编号统一成 SxxExx 格式。 code code.strip().lower() match re.search(rs(\d{1,2})e(\d{1,2}), code) if match: season int(match.group(1)) episode int(match.group(2)) return fS{season:02d}E{episode:02d} match re.search(r(\d{1,2})x(\d{1,2}), code) if match: season int(match.group(1)) episode int(match.group(2)) return fS{season:02d}E{episode:02d} return code.upper()如果用户传入的是标准代码会自动规范如果传入的是无法识别的字符串则原样返回并交给上层检查。4.7 实现 query 查询功能查询功能要支持两种场景查询某部动画已登记的全部剧集。查询某一集的详细信息。代码如下def query_episodes(show_id: str, episode_code: str None): 查询动画的剧集信息。 show find_show(show_id) if not show: print(f未找到动画{show_id}) return episodes show[episodes] if not episodes: print(f《{show[title]}》尚未登记任何剧集。) return if episode_code: code normalize_episode_code(episode_code) ep episodes.get(code) if not ep: print(f未找到剧集 {code}) return print(f动画{show[title]}) print(f剧集{code}{ep.get(title, 无标题)}) keywords ep.get(keywords, []) if keywords: print(标签 、.join(keywords)) else: print(f《{show[title]}》已登记剧集) for code in sorted(episodes.keys()): ep episodes[code] print(f- {code} {ep.get(title, )})这里用sorted对剧集编号排序。因为S01E01、S03E04这类字符串在字典 key 中是不排序的手动排序后输出会更整齐。4.8 实现 stats 进度统计进度统计功能需要读取两个文件剧集库里的总集数和进度文件里的已看集数。def show_stats(show_id: str): 统计某部动画的本地观看进度。 show find_show(show_id) if not show: print(f未找到动画{show_id}) return episodes show[episodes] total len(episodes) if total 0: print(f《{show[title]}》尚未登记任何剧集。) return progress load_json(PROGRESS_FILE) watched_map progress.get(show[id], {}) watched [code for code in episodes if code in watched_map] watched_count len(watched) percent watched_count / total * 100 print(f动画{show[title]}) print(f登记剧集总数{total}) print(f已看集数{watched_count}) print(f未看集数{total - watched_count}) print(f完成比例{percent:.1f}%) if watched: print(已看列表) for code in sorted(watched): ep episodes[code] print(f- {code} {ep.get(title, )})从进度文件中取到的是一个字典key 是已经看过的剧集编号。这里用列表推导式[code for code in episodes if code in watched_map]求出“在剧集库登记过且已在进度文件中的剧集”。4.9 把功能串成命令行为了让脚本像真正的命令行工具一样用argparse来解析子命令。先定义主解析器def build_parser(): parser argparse.ArgumentParser( description本地追番清单工具支持解析、查询、标记和统计。 ) subparsers parser.add_subparsers(destcommand) # parse 子命令 parse_parser subparsers.add_parser(parse, help解析剧集文件名) parse_parser.add_argument(filename, help剧集文件名例如 S03E04) # query 子命令 query_parser subparsers.add_parser(query, help查询剧集信息) query_parser.add_argument(show, help动画 ID 或别名例如 maws) query_parser.add_argument(--episode, defaultNone, help可选剧集编号例如 S03E04) # mark 子命令 mark_parser subparsers.add_parser(mark, help标记某集已看) mark_parser.add_argument(show, help动画 ID 或别名) mark_parser.add_argument(episode, help剧集编号) # stats 子命令 stats_parser subparsers.add_parser(stats, help统计观看进度) stats_parser.add_argument(show, help动画 ID 或别名) return parser然后写主函数def main(): parser build_parser() args parser.parse_args() if args.command parse: result parse_filename(args.filename) if not result: print(未能识别剧集编号请确认文件名包含 S03E04 / 03x04 / 第3季第4集 等格式。) sys.exit(1) print(json.dumps(result, ensure_asciiFalse, indent2)) elif args.command query: query_episodes(args.show, args.episode) elif args.command mark: mark_episode(args.show, args.episode) elif args.command stats: show_stats(args.show) else: parser.print_help() if __name__ __main__: main()到这一步整个脚本已经完整。你可以在项目目录下执行python3 series_tracker.py --help如果一切正常会输出命令帮助信息。4.10 完整代码参考如果你希望只复制一份完整代码请将以下内容保存为series_tracker.py#!/usr/bin/env python3 # -*- coding: utf-8 -*- series_tracker.py - 本地追番清单工具 使用 Python 标准库实现不依赖第三方包。 import argparse import json import re import sys from datetime import date from pathlib import Path BASE_DIR Path(__file__).resolve().parent DATA_DIR BASE_DIR / data LIBRARY_FILE DATA_DIR / library.json PROGRESS_FILE DATA_DIR / progress.json EPISODE_PATTERNS [ re.compile(r[Ss](?Pseason\d{1,2})[Ee](?Pepisode\d{1,2})), re.compile(r(?Pseason\d{1,2})[xX](?Pepisode\d{1,2})), re.compile(r第(?Pseason\d{1,2})季第(?Pepisode\d{1,2})[集话]), ] def load_json(path: Path): if not path.exists(): if progress in path.name: return {} return {shows: []} with path.open(r, encodingutf-8) as f: return json.load(f) def save_json(path: Path, data): path.parent.mkdir(parentsTrue, exist_okTrue) with path.open(w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) def normalize_text(text: str) - str: text text.lower() text re.sub(r[\s._\-!?,、], , text) return text.strip() def normalize_episode_code(code: str) - str: code code.strip().lower() match re.search(rs(\d{1,2})e(\d{1,2}), code) if match: season int(match.group(1)) episode int(match.group(2)) return fS{season:02d}E{episode:02d} match re.search(r(\d{1,2})x(\d{1,2}), code) if match: season int(match.group(1)) episode int(match.group(2)) return fS{season:02d}E{episode:02d} return code.upper() def parse_filename(filename: str): text filename.strip() for pattern in EPISODE_PATTERNS: match pattern.search(text) if match: season int(match.group(season)) episode int(match.group(episode)) prefix text[: match.start()].strip() return { episode_code: fS{season:02d}E{episode:02d}, season: season, episode: episode, prefix_hint: prefix, } return None def find_show(alias: str): library load_json(LIBRARY_FILE) target normalize_text(alias) for show in library.get(shows, []): if normalize_text(show[id]) target: return show for alias_item in show.get(aliases, []): if normalize_text(alias_item) target: return show return None def mark_episode(show_id: str, episode_code: str): show find_show(show_id) if not show: print(f未找到动画{show_id}) return episode_code normalize_episode_code(episode_code) if episode_code not in show[episodes]: print(f动画《{show[title]}》中没有登记剧集 {episode_code}) return progress load_json(PROGRESS_FILE) if show[id] not in progress: progress[show[id]] {} progress[show[id]][episode_code] date.today().isoformat() save_json(PROGRESS_FILE, progress) print(f已标记《{show[title]}》{episode_code} 为已看。) def query_episodes(show_id: str, episode_code: str None): show find_show(show_id) if not show: print(f未找到动画{show_id}) return episodes show[episodes] if not episodes: print(f《{show[title]}》尚未登记任何剧集。) return if episode_code: code normalize_episode_code(episode_code) ep episodes.get(code) if not ep: print(f未找到剧集 {code}) return print(f动画{show[title]}) print(f剧集{code}{ep.get(title, 无标题)}) keywords ep.get(keywords, []) if keywords: print(标签 、.join(keywords)) else: print(f《{show[title]}》已登记剧集) for code in sorted(episodes.keys()): ep episodes[code] print(f- {code} {ep.get(title, )}) def show_stats(show_id: str): show find_show(show_id) if not show: print(f未找到动画{show_id}) return episodes show[episodes] total len(episodes) if total 0: print(f《{show[title]}》尚未登记任何剧集。) return progress load_json(PROGRESS_FILE) watched_map progress.get(show[id], {}) watched [code for code in episodes if code in watched_map] watched_count len(watched) percent watched_count / total * 100 print(f动画{show[title]}) print(f登记剧集总数{total}) print(f已看集数{watched_count}) print(f未看集数{total - watched_count}) print(f完成比例{percent:.1f}%) if watched: print(已看列表) for code in sorted(watched): ep episodes[code] print(f- {code} {ep.get(title, )}) def build_parser(): parser argparse.ArgumentParser( description本地追番清单工具支持解析、查询、标记和统计。 ) subparsers parser.add_subparsers(destcommand) parse_parser subparsers.add_parser(parse, help解析剧集文件名) parse_parser.add_argument(filename, help剧集文件名例如 S03E04) query_parser subparsers.add_parser(query, help查询剧集信息) query_parser.add_argument(show, help动画 ID 或别名例如 maws) query_parser.add_argument(--episode, defaultNone, help可选剧集编号例如 S03E04) mark_parser subparsers.add_parser(mark, help标记某集已看) mark_parser.add_argument(show, help动画 ID 或别名) mark_parser.add_argument(episode, help剧集编号) stats_parser subparsers.add_parser(stats, help统计观看进度) stats_parser.add_argument(show, help动画 ID 或别名) return parser def main(): parser build_parser() args parser.parse_args() if args.command parse: result parse_filename(args.filename) if not result: print(未能识别剧集编号请确认文件名包含 S03E04 / 03x04 / 第3季第4集 等格式。) sys.exit(1) print(json.dumps(result, ensure_asciiFalse, indent2)) elif args.command query: query_episodes(args.show, args.episode) elif args.command mark: mark_episode(args.show, args.episode) elif args.command stats: show_stats(args.show) else: parser.print_help() if __name__ __main__: main()注意这段代码要求目录下存在data/library.json和data/progress.json。如果没有可以手动创建也可以让 Python 在第一次运行时自动读取默认空数据。5. 运行与验证5.1 解析剧集文件名在项目根目录执行python3 series_tracker.py parse My Adventures with Superman S03E04.mkv预期输出{ episode_code: S03E04, season: 3, episode: 4, prefix_hint: My Adventures with Superman }可以看到脚本成功把文件名中的剧集信息提取了出来。如果传入中文格式文件名python3 series_tracker.py parse 我与超人的冒险第3季第4集.mp4也能正常输出S03E04。这就是前面定义多种正则模式的好处。5.2 查询剧集信息查询这部动画登记的所有剧集python3 series_tracker.py query maws预期输出《我与超人的冒险》已登记剧集 - S01E01 示例剧集开启冒险 - S03E04 别叫我超级小子捣蛋鬼夏日特辑单独查询第3季第4集python3 series_tracker.py query maws --episode S03E04预期输出动画我与超人的冒险 剧集S03E04别叫我超级小子捣蛋鬼夏日特辑 标签捣蛋鬼、Mr. Mxyzptlk、夏日特辑、轻松这里的标签是示例数据你完全可以按自己的理解补充更多关键词。5.3 标记观看进度执行标记python3 series_tracker.py mark maws S03E04预期输出已标记《我与超人的冒险》S03E04 为已看。此时再打开data/progress.json会看到类似内容{ maws: { S03E04: 2025-08-16 } }日期是你运行命令当天的日期由date.today().isoformat()自动生成。5.4 统计观看进度继续执行python3 series_tracker.py stats maws预期输出动画我与超人的冒险 登记剧集总数2 已看集数1 未看集数1 完成比例50.0% 已看列表 - S03E04 别叫我超级小子捣蛋鬼夏日特辑这是因为library.json中只登记了两集示例数据所以 1 除以 2 得到 50%。如果你把整季的每一集都登记进去这里的统计就会变成真实的整季进度。6. 常见问题与排查思路问题现象常见原因解决思路执行命令后提示找不到 JSON 文件脚本工作目录不在项目根目录检查data/library.json是否和脚本同级建议使用完整路径运行脚本中文显示为\uXXXX转义写入 JSON 时没有设置ensure_asciiFalse检查save_json中的ensure_asciiFalse文件名里的S03E04识别不出来文件名中没有完整季数集数检查文件名是否包含S03E04、03x04或“第3季第4集”格式输入maws查询不到library.json里没有对应的id或aliases检查 JSON 中是否写了id: maws并确认别名匹配mark时提示“没有登记剧集”某一集没有出现在episodes中先在library.json中补充该集数据再执行标记Windows 下运行python3提示找不到命令Windows 中 Python 命令通常是python改用python series_tracker.py ...执行遇到问题不要急着改代码先加几行print看输入输出或者打开 JSON 文件检查数据结构。命令行工具的调试思路通常就是“输入—处理—输出”三段式定位。7. 后续扩展把追番工具升级成定时提醒目前的工具已经可以手动管理追番进度但如果希望每部动画更新后自动提醒可以在此基础上继续扩展。最简单的做法是写一个新的命令或脚本定期读取一个“待更新时间表”。这个时间表可以放在 data/schedule