整理《为了N》的完结纪念内容时,会看到类似“他是我黑暗世界里唯一可以守护的光”这样的标题;标题里同时还带着“安希”CP、“豆瓣8.6”、“12年”和“完结纪念”等标签。对普通观众,这是一句观后感;对开发者,它却是一个值得抽成字段的小型文本问题。我们把这段文案作为样本数据,设计一个可以从中文标题中提取剧名、评分、年份关键词、CP标签和语录,并生成可浏览静态页面的小工具。整个过程不依赖重型框架,却能覆盖正则解析、中文清洗、JSON 结构化、静态页面渲染和本地调试等常见知识点。
下面先把需求拆清楚,再逐步搭建脚本,最后补充常见坑和可复用清单。文章的目标是让读者拿到一整套思路后,不只是看懂这段《为了N》示例,而是能应用到自己的追剧、书影音或内容管理系统里。
1. 需求拆解:为什么要把“观后纪念文案”转成结构化数据
1.1 先把标题当成一条样本数据看
我们手头有一个很典型的用户标题:
“他是我黑暗世界里唯一可以守护的光”⚡️12年了...依然磕安希啊啊啊《为了N》豆瓣8.6|完结纪念这一行里包含了很多信息。自然语言阅读时,人能快速区分哪些是语录、哪些是剧名、哪些是个人观感。但机器做不到,尤其是中文标题里的书名号、评分、年份标签和 CP 标签经常混在一起。
如果只做一次性人工整理,完全不需要写程序。可一旦数据量变成几十条、几百条,比如一个社区要整理近期完结剧集的“纪念帖”、一个内容运营要统计“哪些 CP 讨论度高”、或者一个个人收藏夹想把每一条动态都做成卡片,就必须先把文案解析成统一字段。
这个场景的关键点不是“看懂标题在说什么”,而是“从声音混杂的自然语言里取出可统计、可筛选、可展示的字段”。
1.2 目标字段要贴合标题里的真实信息
对上面这一行,《为了N》不是代码技术栈,但不妨碍我们把它当作领域数据来处理。解析后至少需要出现这些字段:
| 字段 | 示例值 | 用途 |
|---|---|---|
| 剧名 | 为了N | 按作品聚合,区分本项目属于哪部剧 |
| 评分字段 | 8.6 | 作为口碑标签展示,不修改原数值 |
| 年份标签 | 12年 | 保留“12年”这样的纪念性说法,不强行推测具体日期 |
| 语录 | 他是我黑暗世界里唯一可以守护的光 | 卡片主标题、文案引言 |
| CP 标签 | 安希 | 话题筛选、同类内容聚合 |
| 动态类型 | 完结纪念 | 判断这条内容属于复盘、推荐还是纪念 |
字段命名时要注意,不应该把“12年”直接解释成“播出12年”,因为粉丝写下“12年了”的基准点可能各自不同。程序只负责识别标题里出现了年份表述,并把原文保留下来。真正的时间统计适合放到另一个步骤里,由人工或权威资料确认。
1.3 学习环境与生产环境的差异
在学习环境里,直接用一个 Python 文件解析某个标题就够了,不需要考虑并发和权限。但真实生产环境里,这条文案可能来自用户发布的动态,可能是 HTML 转义后的文本,可能包含错别字,也可能在“安希”前面出现空格或零宽字符。
生产环境至少要多考虑几类问题:
- 输入不可信,不能把标题内容未转义就写入 HTML。
- 解析规则要版本化,不能因为某条新标题改变了匹配逻辑,导致历史数据回退。
- 原始字段要保留,新增字段只做增量解析,不要覆盖源数据。
- 解析结果要有日志和告警,尤其是“剧名没识别出来”“CP 标签为空”等情况。
下面用最小可复现的方式把学习环境先跑通,再逐步讨论可以升级的方向。
2. 环境准备与项目结构:不引入重型框架也能跑通
2.1 运行环境要求
解析脚本建议使用 Python 3.10 或更高版本,主要原因是类型注解、Path操作和字符串处理在较新版本里更顺手。其实 Python 3.8 以上也能运行,只是项目里可以顺手把类型注解写规范,便于维护。
依赖管理使用标准库venv,流程可以固定下来:
| 项目 | 说明 |
|---|---|
| 操作系统 | Windows / macOS / Linux 均可 |
| Python | 3.10+,建议用 3.11 或 3.12 |
| 第三方库 | emoji,仅用于清理文本中的 Emoji |
| 浏览器 | 用于预览生成的 HTML 卡片 |
| 命令行 | 系统自带终端或 PowerShell 均可 |
emoji不是必须的,理论上可以自己写正则跳过一部分 Emoji,但 Emoji 的编码范围非常杂,有的字符由多个码点组合而成。使用emoji.replace_emoji()比自己维护正则更稳妥。
2.2 目录结构先规划好
项目不要做成单一脚本堆在桌面,至少要区分输入、输出和代码。
推荐结构如下:
n_project/ ├── requirements.txt ├── input/ │ └── titles.txt ├── parse_entry.py ├── generate_html.py └── output/ ├── entries.json └── index.html目录职责:
input/titles.txt:放原始文案,一行一条。parse_entry.py:负责读取输入文件,解析字段,生成 JSON。generate_html.py:读取 JSON,渲染成静态 HTML。output/:保存每次运行生成的结果,避免手改源数据。
把“解析”和“渲染”拆成两个文件,是因为两个环节的改动频率不同。解析规则频繁变化,而页面模板往往相对稳定。拆开后,解析出错时可以单独调整parse_entry.py,不会影响已经生成好的 HTML 文件。
2.3 创建虚拟环境并安装依赖
在终端里执行以下命令:
mkdir -p n_project/{input,output} cd n_project python3 -m venv .venv source .venv/bin/activate如果当前系统是 Windows,激活命令要换成:
.venv\Scripts\activate接着写requirements.txt:
emoji>=2.8.0安装依赖:
pip install -r requirements.txt安装完成后,可以用一行命令确认环境已生效:
python -c "import emoji; print(emoji.__version__)"能打印出版本号,说明后续脚本里的emoji.replace_emoji()可以正常调用。
2.4 放一条最小输入样例
把下面这行内容写入input/titles.txt:
“他是我黑暗世界里唯一可以守护的光”⚡️12年了...依然磕安希啊啊啊《为了N》豆瓣8.6|完结纪念这里把用户输入当作原始数据。后续所有解析逻辑都以这一行作为验证样本。
需要注意的是,输入文件必须保存为 UTF-8 编码。如果用 Windows 记事本保存成 GBK,脚本读取中文时很容易出现乱码或UnicodeDecodeError。代码读取文件时也固定指定encoding="utf-8",不要依赖操作系统默认编码。
3. 核心解析实现:从原始标题提取字段
3.1 先清洗 Emoji 和不可见字符
原始标题里有一个雷雨闪电样式的字符,即:
⚡️这串字符通常是两个 Unicode 码点组合而成。如果不对它做清洗,后续正则匹配很容易受影响。例如某些正则会因为中间出现 Emoji,把本来连续的“文案片段”切断。
先定义清洗函数:
import emoji def clean_text(raw: str) -> str: return emoji.replace_emoji(raw, replace_string='').strip()清洗后,标题会变成:
“他是我黑暗世界里唯一可以守护的光”12年了...依然磕安希啊啊啊《为了N》豆瓣8.6|完结纪念这里只移除 Emoji,不删除中文引号、书名号、数字和小数点,因为这些字符本身承载有效字段。
3.2 用正则提取剧名、豆瓣评分和年份关键词
中文文本里,书名号是很好的边界标识。提取剧名时,直接找《...》结构即可:
import re SHOW_PATTERN = re.compile(r'《(?P<show>[^》]+)》') RATING_PATTERN = re.compile(r'豆瓣\s*(?P<rating>\d+(?:\.\d+)?)') YEAR_PATTERN = re.compile(r'(?P<year>\d{1,3})\s*年') def find_first(pattern, text): m = pattern.search(text) return m.group(1).strip() if m else ''三个正则说明如下:
《(?P<show>[^》]+)》:取书名号之间的内容,剧名可以是中文、英文、数字或符号,不限定语言。豆瓣\s*(?P<rating>\d+(?:\.\d+)?):先匹配“豆瓣”两个字,再匹配整数或小数评分。标题里“豆瓣8.6”会被解析成8.6。(?P<year>\d{1,3})\s*年:匹配“12年”这样的文本,保留成原格式。
从样本里解析出的中间结果是:
show = 为了N rating = 8.6 year = 12年这里刻意把“12年”当作字符串保存。因为标题表达的是“已经12年了”的感叹,并不等于严谨的“电视剧首播于12年前”。解析程序不应该在信息不足时替用户补全语义。
3.3 提取语录和 CP 标签时要注意中文语气词
语录通常是引号内的内容。可以同时兼容中文双引号和英文双引号:
QUOTE_PATTERN = re.compile(r'[“"](?P<quote>[^”"]+)[”"]')对样本来说,提取结果是:
他是我黑暗世界里唯一可以守护的光CP 标签相对麻烦。标题里的写法是:
依然磕安希啊啊啊常规想法是提取“磕”后面的两个字,得到“安希”。但中文标题往往伴随“啊啊啊”“呜呜呜”“哈哈哈”这类语气词。如果直接用磕([\u4e00-\u9fff]{2}),只匹配两个字符,那么“安希”刚好命中;但如果文本是“依然磕安希啊”,还需要去掉末尾语气词。
更稳妥的做法是先扩大匹配范围,再对结果做一次尾部清理:
CP_PATTERNS = [ re.compile(r'磕(?P<cp>[\u4e00-\u9fffA-Za-z0-9]{1,8})'), re.compile(r'(?P<cp>[\u4e00-\u9fffA-Za-z0-9]{1,8})\s*CP'), ] def normalize_cp(cp_value: str) -> str: if not cp_value: return '' return re.sub(r'[啊吧呀哦哈]+$', '', cp_value)第一条正则匹配“磕”后面的中文、英文或数字,允许 1 到 8 个字符。对样本来说,匹配到的是“安希啊啊啊”,经过normalize_cp()去掉尾部语气词后得到“安希”。
第二条正则用于匹配“安希CP”这类写法。实际项目里两条规则可以同时保留。
3.4 把解析结果统一组装成字典
把前面所有方法组合成一个parse_entry()函数:
def parse_entry(raw: str) -> dict: cleaned = clean_text(raw) show = '' rating = '' year = '' quote = '' cp = '' if SHOW_PATTERN.search(cleaned): show = SHOW_PATTERN.search(cleaned).group(1).strip() if RATING_PATTERN.search(cleaned): rating = RATING_PATTERN.search(cleaned).group(1).strip() if YEAR_PATTERN.search(cleaned): year = YEAR_PATTERN.search(cleaned).group(1).strip() if QUOTE_PATTERN.search(cleaned): quote = QUOTE_PATTERN.search(cleaned).group(1).strip() for cp_pattern in CP_PATTERNS: m = cp_pattern.search(cleaned) if m: cp = normalize_cp(m.group(1).strip()) break memo = '完结纪念' if '完结纪念' in cleaned else '' return { 'original': raw, 'show': show, 'rating': rating, 'year_label': year, 'quote': quote, 'cp': cp, 'memo': memo, }这个函数虽然基于样本设计,但已经考虑了可能的空值情况。当某条标题里没有语录时,quote会保持空字符串,不直接导致程序崩溃。
3.5 批量读取输入并生成 JSON
解析函数只处理单条文本,外层需要一个批量入口:
import json import sys from pathlib import Path def main(): input_path = sys.argv[1] if len(sys.argv) > 1 else 'input/titles.txt' records = [] with open(input_path, 'r', encoding='utf-8') as f: for line_no, line in enumerate(f, 1): line = line.strip() if not line: continue record = parse_entry(line) records.append({ 'line_no': line_no, **record, }) output_path = Path('output/entries.json') output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, 'w', encoding='utf-8') as f: json.dump(records, f, ensure_ascii=False, indent=2) print(f'parsed {len(records)} records') print(f'written to {output_path}') if __name__ == '__main__': main()保存为parse_entry.py后执行:
python parse_entry.py得到output/entries.json:
[ { "line_no": 1, "original": "“他是我黑暗世界里唯一可以守护的光”⚡️12年了...依然磕安希啊啊啊《为了N》豆瓣8.6|完结纪念", "show": "为了N", "rating": "8.6", "year_label": "12年", "quote": "他是我黑暗世界里唯一可以守护的光", "cp": "安希", "memo": "完结纪念" } ]JSON 输出有两个细节需要注意。第一,ensure_ascii=False会保留中文,避免输出成\u4e3a\u4e86N,提升可读性。第二,original字段保留完整原始文案,即使未来清洗规则变化,历史数据也不会丢原始信息。
4. 渲染成静态卡片:让数据变为页面
4.1 页面为什么要做成静态 HTML
解析完成后,数据已经变成 JSON。如果只是在终端里打印字段,很难观察效果,也不方便给非技术同事看。更好的做法是把 JSON 渲染成一个简单页面。
选择静态 HTML 而不是后端模板系统,有三个原因:
- 个人项目中数据量不大,不需要数据库和后台服务。
- 静态文件可以直接用
python -m http.server预览,也能上传到对象存储或 GitHub Pages。 - 渲染脚本只执行一次,不依赖在线服务,离线环境也能看结果。
4.2 写一个 HTML 渲染脚本
先准备一个最小generate_html.py,作用是读取entries.json,把每一条记录渲染成卡片并输出到index.html。
代码如下:
import html import json from pathlib import Path HTML_TEMPLATE = """<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>剧集纪念条目展示</title> <style> body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: #f7f5f0; margin: 0; padding: 32px; } .container { max-width: 960px; margin: 0 auto; } .card { background: #fff; border-radius: 16px; padding: 24px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06); margin-bottom: 24px; border-top: 6px solid #7c3aed; } .badge { display: inline-block; background: #2d2a32; color: #fff; font-size: 12px; padding: 2px 10px; border-radius: 999px; margin-bottom: 12px; } .card h2 { margin: 0 0 8px; color: #18181b; } .quote { font-size: 20px; line-height: 1.8; color: #27272a; border-left: 4px solid #c4b5fd; padding-left: 16px; margin: 16px 0; } .meta { display: flex; flex-wrap: wrap; gap: 8px; color: #52525b; font-size: 14px; } .meta span { background: #f4f4f5; padding: 4px 10px; border-radius: 8px; } </style> </head> <body> <div class="container"> <!-- CARDS --> </div> </body> </html> """ def build_card(record: dict) -> str: memo = html.escape((record.get('memo') or '条目')) show = html.escape((record.get('show') or '未知剧名')) quote = html.escape((record.get('quote') or '暂无语录')) rating = html.escape((record.get('rating') or '暂无')) return f""" <article class="card"> <span class="badge">{memo}</span> <h2>{show}</h2> <p class="quote">{quote}</p> <div class="meta"> <span>豆瓣 {rating}</span> <span>{html.escape(record.get('year_label') or '')}</span> <span>CP: {html.escape(record.get('cp') or '未设置')}</span> </div> </article> """ def main(): entries_path = Path('output/entries.json') records = json.loads(entries_path.read_text(encoding='utf-8')) cards = '\n'.join(build_card(record) for record in records) page = HTML_TEMPLATE.replace('<!-- CARDS -->', cards) output_path = Path('output/index.html') output_path.write_text(page, encoding='utf-8') print(f'written to {output_path}') if __name__ == '__main__': main()这里有一个容易被新手忽略的点:模板内插值前必须做 HTML 转义。如果直接把用户输入的quote拼进 HTML,一旦文本里包含<script>,就会形成脚本注入。用html.escape()把类似&、<、>转成安全形式,是渲染用户内容时不可省略的步骤。
4.3 启动本地 HTTP 服务查看效果
执行渲染脚本:
python generate_html.py然后启动本地静态服务:
python3 -m http.server 8000 -d output浏览器访问:
http://localhost:8000正常情况下会看到一张卡片。卡片顶部有“完结纪念”的徽标,下面显示《为了N》的剧名、语录、豆瓣 8.6、12年和CP: 安希。
如果只是临时看效果,也可以直接用浏览器打开output/index.html。不过推荐使用静态服务,因为后续如果页面里加入了fetch('entries.json'),直接双击 HTML 文件触发浏览器跨域限制,可能拿不到数据。
4.4 通过响应结果验证脚本是否正常
本地服务启动后,可以在另一个终端执行:
curl -s http://localhost:8000/entries.json如果终端能打印出刚刚生成的 JSON,说明文件路径和服务端口正确。再把返回内容里的show字段是否等于“为了N”作为验证点,避免出现页面已打开但数据是旧版本的情况。
5. 本方案可能遇到的坑和排查路径
5.1 常见问题与解决方案速查
下面这张表格总结了整个流程里最容易遇到的几类问题:
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
运行python parse_entry.py后报ModuleNotFoundError | 当前虚拟环境没激活或未安装 emoji | 执行pip list查看依赖 | 激活虚拟环境后重新pip install -r requirements.txt |
JSON 文件里中文变成了\u4e3a | 写文件时没有指定ensure_ascii=False | 用文本编辑器打开 JSON 查看 | 在json.dump中加入ensure_ascii=False |
| CP 标签解析成“安希啊啊啊” | 正则范围包含语气词 | 输出中间结果,查看cp原始值 | 用normalize_cp()去掉末尾语气词 |
| 剧名匹配为空 | 文本中的书名号不是中文全角符号 | 查看原始输入文件的字符编码 | 统一使用全角《》,或补充半角< >规则 |
| HTML 页面上语录出现乱码 | HTML 文件编码不是 UTF-8 | 到浏览器 view-source 查看 meta 标签 | 写文件时固定encoding='utf-8' |
| 重复执行后看到旧数据 | 脚本运行失败,文件没被覆盖 | 查看终端是否有报错输出 | 先保证entries.json生成成功,再执行渲染 |
5.2 从现象倒推原因:不要直接改正则
当解析结果不符合预期时,建议按以下顺序排查,不要上来就调整正则表达式。
第一,先看输入文本有没有被正确读取。在parse_entry()开头临时加一行print(raw),确认原始文本里没有乱码或隐藏字符。
第二,看清洗后的文本。Emoji 可能被移除,也可能被替换成空字符串后留下多余空格。先打印cleaned,确认后续正则面对的是干净文本。
第三,单独验证字段规则。例如只保留剧名正则,在命令行测试:
python -c "import re; print(re.findall(r'《([^》]+)》', '《为了N》'))"这种最小化验证可以快速定位是正则写错,还是整体流程传参有问题。
第四,查看 JSON 是否真的覆盖成功。如果文件时间戳没变化,说明脚本可能在读取阶段就抛了异常。生产环境建议用日志记录输入路径、读取行数和输出条数,避免静默失败。
5.3 中文文本边界的额外风险
中文文本处理最麻烦的是“边界不清晰”。英文单词有空格分隔,中文往往连续书写。例如“依然磕安希啊啊啊”里,“安希”和“啊啊啊”之间没有分隔符。如果只按正则提取,很可能拿到多余内容。
一种做法是建立受控词典。对于剧名、CP 名这类封闭集合,与其完全依赖正则,不如在解析后增加一层“候选人比对”,并把比对结果存入日志。比如已知候选里有“安希”,清洗后得到的标签如果包含“安希”,再截取出来。
这种方法更稳,但需要有人维护候选列表。它可以放到后续的配置文件中,而不是写死在代码里。
6. 最佳实践:从单人脚本进化为可维护的小工具
6.1 不要把所有解析逻辑堆在一个文件里
这个示例能跑通,但继续增加字段时