简介:《The Love Hypothesis》是Ali Hazelwood于2021年出版的当代romance小说PDF电子书,面向喜爱轻松幽默爱情故事、关注女性主义与性别议题的英文原版阅读者。全书围绕女主人公Olive展开,讲述这位聪明独立的年轻女性在自卑与对爱情的恐惧中,如何逐步相信自己也相信他人,并巧妙融入grumpy meets sunshine、fake dating等经典桥段,兼具娱乐性与社会文化意义。资源包共1个PDF文件,大小约3.66MB,内容完整,含版权页、目录、献词及各章节正文,便于在电脑或移动设备上直接阅读。目前已有4761人学习下载,热度可观。读者可借此了解romance小说的定义与特点、经典trope的运用方式、女性主义与性别角色在类型小说中的体现,以及Ali Hazelwood的写作风格与叙事技巧,适合作为英文原版阅读与类型文学研究的参考素材。
1. 从一份 PDF 文件名说起:为什么“The Love Hypothesis”值得做一次文本分析实战
一份名为The Love Hypothesis by Ali Hazelwood (z-lib.org).pdf的文件,表面看只是一本小说的电子版,但如果你把它当成一个真实的数据对象,它其实是一个非常适合练手的非结构化文本处理样本。它包含完整的章节结构、对话段落、叙述性描写,还有 PDF 转换过程中常见的断行、连字符、页眉页脚残留。很多做 NLP 的同行拿到英文小说 PDF 后第一反应是直接丢给模型,结果被脏数据坑得怀疑人生。这个标题背后真正值得做的,是把“一本英文小说 PDF”变成“可检索、可分析、可复用的干净文本”,再基于它跑通章节切分、词频统计、对话抽取、人物共现这几条常见管线。适合谁?适合刚接触文本清洗的新手,也适合想找一个中等规模英文语料做 pipeline 验证的熟手。下面我按自己实际处理这类文件的顺序,把每一步拆开讲。
2. 把 PDF 变成可处理的文本:工具选型与第一轮清洗
2.1 为什么我不推荐直接上 OCR
拿到The Love Hypothesis by Ali Hazelwood (z-lib.org).pdf这类文件,第一步要判断它是“文字版 PDF”还是“扫描版 PDF”。文字版 PDF 里的字符是可选中的,用pdfplumber或PyMuPDF就能直接抽取;扫描版才需要 OCR。我一般先用一条命令快速判断:
python -c " import fitz doc = fitz.open('The Love Hypothesis by Ali Hazelwood (z-lib.org).pdf') page = doc[10] text = page.get_text() print(repr(text[:300])) print('字符数:', len(text)) "如果输出里能看到成句的英文单词,说明是文字版,直接走抽取路线。如果输出大量\x00或空字符串,才考虑 OCR。OCR 的坑在于:小说里大量对话引号、斜体、破折号会被识别成乱码,后期清洗成本远高于文字版直接抽取。我处理过的英文小说 PDF 里,九成以上是文字版,所以优先用PyMuPDF(导入名fitz)做批量抽取。
2.2 用 PyMuPDF 批量抽取并保留页码标记
直接逐页抽取会丢掉页码信息,后期排查脏数据时找不到源头。我的做法是在每页文本前插入一个不可见标记,方便回溯:
import fitz def extract_pdf_pages(pdf_path): doc = fitz.open(pdf_path) pages = [] for i, page in enumerate(doc): raw = page.get_text("text") # 插入页码标记,格式固定,方便后续正则清除 marked = f"<<<PAGE_{i+1}>>>\n{raw}" pages.append(marked) doc.close() return "\n".join(pages) full_text = extract_pdf_pages("The Love Hypothesis by Ali Hazelwood (z-lib.org).pdf") with open("raw_extract.txt", "w", encoding="utf-8") as f: f.write(full_text) print("总字符数:", len(full_text))这里get_text("text")用的是纯文本模式,比get_text("blocks")更接近阅读顺序。页码标记用<<<PAGE_N>>>这种不会自然出现的格式,避免和正文冲突。抽完后先别急着清洗,打开raw_extract.txt看前 2000 字符,重点检查三件事:页眉页脚是否混入正文、连字符断行是否严重、章节标题是否完整。
2.3 第一轮清洗:去页眉页脚、修连字符、合并断行
英文小说 PDF 最常见的脏数据是页眉页脚重复出现,以及行尾连字符断词。我一般写一个组合清洗函数:
import re def clean_text(text): # 1. 去掉页码标记行本身,但保留位置信息用于调试时可注释掉 text = re.sub(r"<<<PAGE_\d+>>>\n?", "", text) # 2. 去掉常见页眉页脚:纯数字行、书名重复行 text = re.sub(r"^\s*\d+\s*$", "", text, flags=re.MULTILINE) text = re.sub(r"^\s*The Love Hypothesis\s*$", "", text, flags=re.MULTILINE | re.IGNORECASE) # 3. 修复行尾连字符断词:word-\nword -> wordword text = re.sub(r"(\w+)-\n(\w+)", r"\1\2", text) # 4. 合并被硬换行拆开的句子:非空行且上一行不以句末标点结尾 text = re.sub(r"(?<![.!?\"'])\n(?=[a-z])", " ", text) # 5. 压缩连续空行 text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() cleaned = clean_text(full_text) with open("cleaned.txt", "w", encoding="utf-8") as f: f.write(cleaned) print("清洗后字符数:", len(cleaned))参数说明:第 3 步的(\w+)-\n(\w+)只处理行尾连字符,不会误伤正常连字符词如well-known,因为后者连字符后没有换行。第 4 步用负向后顾(?<![.!?\"'])确保只在句子未结束时合并,避免把两个独立句子粘在一起。清洗后字符数通常会比原始抽取少 5% 到 15%,如果减少超过 20%,说明正则误删了正文,需要回看raw_extract.txt对比。
3. 章节切分与结构还原:让文本可导航
3.1 识别章节标题的三种模式
The Love Hypothesis这类英文小说通常用Chapter 1、Chapter One或纯数字作为章节标题。清洗后的文本里,章节标题往往独占一行。我一般先用正则扫一遍候选:
import re chapter_pattern = re.compile( r"^\s*(Chapter\s+(\d+|[IVXLCDM]+|One|Two|Three|Four|Five|Six|Seven|Eight|Nine|Ten|Eleven|Twelve|Thirteen|Fourteen|Fifteen|Sixteen|Seventeen|Eighteen|Nineteen|Twenty))\s*$", re.IGNORECASE | re.MULTILINE ) matches = list(chapter_pattern.finditer(cleaned)) for m in matches[:10]: print(m.start(), repr(m.group(0))) print("候选章节数:", len(matches))如果候选数在 20 到 40 之间,基本可以确认是章节标题。如果候选数为 0,说明标题格式不同,需要手动看前几页找规律。我遇到过用1.或I单独成行的,也有用装饰性分隔符的,这时候就得针对具体文件调整正则,没有万能公式。
3.2 按章节切分并输出结构化 JSON
确认章节标题后,按匹配位置切分文本,每章存成独立记录:
import json def split_chapters(text, pattern): matches = list(pattern.finditer(text)) chapters = [] for i, m in enumerate(matches): start = m.end() end = matches[i+1].start() if i+1 < len(matches) else len(text) title = m.group(0).strip() body = text[start:end].strip() chapters.append({ "index": i + 1, "title": title, "body": body, "char_count": len(body) }) return chapters chapters = split_chapters(cleaned, chapter_pattern) with open("chapters.json", "w", encoding="utf-8") as f: json.dump(chapters, f, ensure_ascii=False, indent=2) for ch in chapters[:5]: print(ch["index"], ch["title"], ch["char_count"])输出 JSON 的好处是后续做词频、对话抽取、人物共现时可以直接按章遍历,不用反复解析大文本。char_count字段用来快速发现异常章节——如果某一章只有几十字符,大概率是切分点误判,需要回查。
3.3 验证切分质量:三个必查指标
切分完不能直接往下走,我一般查三个指标:章节数是否合理、每章字符数分布是否均匀、首章和末章是否完整。用一段简单统计就能看出来:
counts = [ch["char_count"] for ch in chapters] print("章节数:", len(chapters)) print("平均字符数:", sum(counts) // len(counts)) print("最短章:", min(counts), "最长章:", max(counts)) print("首章前100字:", chapters[0]["body"][:100]) print("末章后100字:", chapters[-1]["body"][-100:])如果最短章和最长章差距超过 10 倍,通常是有章节标题被漏识别或误识别。首章前 100 字应该是正文开头,如果出现目录或版权页内容,说明清洗时没去掉前置页。末章后 100 字应该是故事结尾,如果出现“About the Author”之类,说明后置页混入了。这些检查花不了两分钟,但能省掉后面大量返工。
4. 词频、对话与人物共现:三条可复用的分析管线
4.1 词频统计:去掉停用词后看什么
词频是最基础的分析,但直接统计会得到一堆the、and、to。我一般用nltk的停用词表加自定义过滤:
from collections import Counter import re STOPWORDS = set(""" a an the and or but if while of to in on at by for with from as is are was were be been being have has had do does did will would could should may might must this that these those it its he she they them his her their you your i me my """.split()) def word_freq(text, top_n=30): words = re.findall(r"[a-zA-Z']+", text.lower()) filtered = [w for w in words if w not in STOPWORDS and len(w) > 2] return Counter(filtered).most_common(top_n) for word, cnt in word_freq(cleaned): print(f"{word}: {cnt}")参数说明:len(w) > 2过滤掉is、an这类短词,但会保留not、but等有语义的词。停用词表可以根据具体文本调整,比如小说里said出现频率极高,如果做主题分析可以把它加进停用词。词频结果里如果出现大量人名,说明这本书对话密集,适合做人物共现分析。
4.2 对话抽取:用引号规则抓取角色发言
英文小说对话通常用双引号或单引号包裹。我一般用正则抓取引号内内容,再按章统计对话密度:
import re def extract_dialogues(text): # 匹配双引号内内容,排除跨行过长的误匹配 pattern = re.compile(r'"([^"]{5,300})"') return pattern.findall(text) dialogues = extract_dialogues(cleaned) print("对话总数:", len(dialogues)) for d in dialogues[:5]: print("-", d[:80])这里限制长度 5 到 300 字符,是为了排除空引号和异常长匹配。如果文本里单引号对话更多,把正则改成r"'([^']{5,300})'"即可。对话抽取后可以按章统计密度,密度突然降低的章节往往是叙述性段落,密度高的章节是冲突场景,这对理解小说节奏很有帮助。
4.3 人物共现:从对话上下文找关系
人物共现的思路是:在同一段或同一章里,如果两个名字频繁同时出现,说明他们互动多。先定义主要人物名单,再按章统计共现次数:
from itertools import combinations from collections import defaultdict CHARACTERS = ["olive", "adam", "holden", "malcolm", "jeremy", "annika"] def cooccurrence(text, characters, window=500): text_lower = text.lower() cooc = defaultdict(int) for i, ch in enumerate(characters): for j in range(i+1, len(characters)): ch2 = characters[j] count = 0 for m in re.finditer(re.escape(ch), text_lower): start = max(0, m.start() - window) end = min(len(text_lower), m.end() + window) if ch2 in text_lower[start:end]: count += 1 cooc[(ch, ch2)] = count return cooc cooc = cooccurrence(cleaned, CHARACTERS) for pair, cnt in sorted(cooc.items(), key=lambda x: -x[1])[:10]: print(pair, cnt)参数说明:window=500表示以每个名字出现位置为中心,前后各 500 字符内如果出现另一个名字就算一次共现。窗口太小会漏掉远距离互动,太大则引入噪声。我一般先用 500 试,再看结果调整。人物名单需要手动定义,可以从词频结果里挑高频专有名词,也可以先跑一遍命名实体识别再筛选。
5. 避坑与排查:处理英文小说 PDF 时最容易翻车的五件事
5.1 连字符修复过度,把正常词粘在一起
现象:清洗后出现wellknown、selfesteem这类错误合并。原因:正则(\w+)-\n(\w+)把行尾连字符和下一行开头直接拼接,但有些词本身带连字符且恰好断行。解决:修复前先判断连字符前后是否构成常见词,或者只对长度超过 6 的片段做合并,短片段保留连字符。更稳妥的做法是维护一个例外词表,把well-known、self-esteem这类词排除。
5.2 章节标题漏识别,导致整本书变成一章
现象:chapters.json里只有一条记录,char_count等于全书长度。原因:章节标题格式和正则不匹配,比如用了CHAPTER ONE全大写,或者标题前有装饰符号。解决:先打印清洗后文本的前 5000 字符,肉眼确认标题长什么样,再调整正则。不要迷信通用正则,每本书的排版都有差异。
5.3 页眉页脚残留,污染词频结果
现象:词频统计里出现书名、作者名、页码数字高频出现。原因:清洗时只去掉了纯数字行,没去掉重复的书名行。解决:统计每行出现次数,出现超过 10 次的短行大概率是页眉页脚,批量删除。也可以按页分析,如果某行在超过 80% 的页面同一位置出现,直接标记为页眉页脚。
5.4 对话抽取把叙述中的引号也抓进来
现象:抽取的“对话”里混入了引用、强调性引号。原因:正则只认引号,不区分对话和引用。解决:加一层过滤,对话通常以said、asked、replied等动词结尾或开头,可以用这些词做上下文验证。另外,对话引号通常成对出现且长度适中,异常短的引号内容可以丢弃。
5.5 人物共现窗口设得太大,关系失真
现象:所有人物之间共现次数都很高,看不出差异。原因:window设得太大,整章内所有人都算共现。解决:把窗口缩小到 200 到 300 字符,或者改成按段落统计而不是按字符窗口。段落边界是天然的关系单元,按段落统计更符合阅读直觉。如果段落太长,可以按句子统计。
6. 进阶技巧:把分析结果做成可交互的检索工具
走到这一步,你已经有了干净文本、章节结构、词频、对话和人物共现数据。最后一个值得做的技巧,是用一个轻量级本地检索脚本把这些串起来,方便随时查任意词在书中的分布。我一般用 Python 的re加json就能实现,不需要额外依赖:
import json import re with open("chapters.json", "r", encoding="utf-8") as f: chapters = json.load(f) def search_term(term, context=60): results = [] pattern = re.compile(re.escape(term), re.IGNORECASE) for ch in chapters: for m in pattern.finditer(ch["body"]): start = max(0, m.start() - context) end = min(len(ch["body"]), m.end() + context) snippet = ch["body"][start:end].replace("\n", " ") results.append({ "chapter": ch["index"], "title": ch["title"], "snippet": snippet }) return results hits = search_term("hypothesis") print("命中数:", len(hits)) for h in hits[:5]: print(f"[{h['title']}] ...{h['snippet']}...")这个脚本的价值在于:你可以查hypothesis在哪些章节出现,结合章节标题判断它是在对话里还是叙述里出现。如果命中集中在某几章,说明这个概念是故事中后期才展开的。参数context=60控制前后文长度,查关键词用 60 够用,查长句可以调到 120。输出里保留章节标题,方便定位。
再进一步,可以把人物共现矩阵导出成 CSV,用表格软件打开看热力图。我一般会加一列“共现密度”,即共现次数除以两人各自出现次数之和,这样能消除角色戏份多少的影响。密度高的组合才是真正关系紧密的,单纯次数高可能只是因为两人都是主角。
最后说一个我自己的习惯:每次处理完一本 PDF,我都会把清洗前后的字符数、章节数、对话数、Top 20 词频记在一个report.txt里。下次处理同类文件时,先看这份报告,如果章节数或对话数偏离太远,就知道清洗参数需要调。这个习惯帮我省了很多重复排查的时间。希望帮到你。
本文还有配套的精品资源,点击获取