从PDF到结构化文本:英文小说文本分析全链路实战
简介《The Love Hypothesis》是Ali Hazelwood于2021年出版的当代言情小说面向喜爱romance题材、关注女性主义与STEM职场议题的英文原版阅读者。故事围绕年轻女科学家Olive展开她聪明独立却因自卑与对爱情的恐惧而屡屡受挫作者巧妙运用grumpy meets sunshine与fake dating等经典桥段在轻松幽默的笔调中探讨性别角色、恋爱关系与自我认同兼具娱乐性与社会文化意义。资源为单份PDF电子书压缩包约3.66MB仅含1个pdf文件便于在电脑、平板或手机上直接阅读无需额外解压工具。目前已有4761人学习下载热度可观。读者可借此完整阅读原著体会Ali Hazelwood的叙事结构与人物塑造技巧理解言情小说中经典trope的运用方式并从中获得关于女性主义表达与亲密关系描写的阅读参考。1. 从一份 PDF 文件名说起为什么“The Love Hypothesis”值得做一次文本分析一份名为The Love Hypothesis by Ali Hazelwood (z-lib.org).pdf的文件本质上是一个电子书文件。但如果你是一名做自然语言处理、文本挖掘或数字人文的工程师这个文件名背后其实藏着一个很典型的落地场景如何把一本长篇英文小说 PDF 变成可检索、可统计、可建模的结构化文本。这件事听起来简单实际做起来从 PDF 解析、章节切分、对话抽取到人物关系分析每一步都有坑。我见过太多人卡在第一步——用pdfplumber抽出来的文本全是断行和页眉页脚后面所有分析都成了空中楼阁。这篇笔记就围绕这个具体文件把从 PDF 到可分析文本的完整链路拆开讲适合想拿真实长文本练手 NLTK、spaCy 或做书籍推荐系统的从业者。2. 先搞清楚 PDF 里到底有什么解析前的侦察与工具选型2.1 为什么不能直接pdftotext了事很多人拿到 PDF 第一反应是pdftotext或者PyPDF2一把梭。对于这份文件直接跑pdftotext确实能出文本但你会立刻发现三个问题页眉页脚混在正文里、段落之间的换行位置随机、对话引号经常被拆到两行。原因是这本 PDF 大概率是从 EPUB 转换来的排版引擎在每行末尾插入了硬换行而pdftotext默认保留这些换行。如果你后面要做句子分割或对话识别这些噪声会让分词器直接崩溃。我一般会先用pdfplumber做一次“侦察”因为它能同时拿到文本和每个字符的坐标。坐标信息是判断“这一行是正文还是页眉”的关键——页眉的 y 坐标通常固定在页面顶部 50 到 80 像素之间正文的 y 坐标分布更广。import pdfplumber pdf_path The Love Hypothesis by Ali Hazelwood (z-lib.org).pdf with pdfplumber.open(pdf_path) as pdf: # 先看前 5 页的文本和坐标分布 for i, page in enumerate(pdf.pages[:5]): print(f--- Page {i1} ---) # 提取每个字符的 y 坐标看看页眉页脚的位置 chars page.chars if chars: y_top min(c[top] for c in chars) y_bottom max(c[bottom] for c in chars) print(fy range: {y_top:.1f} - {y_bottom:.1f}) # 打印前 200 个字符看看内容 text page.extract_text() or print(text[:200]) print()这段代码的逻辑是先不急着抽全文而是看每一页的字符纵向范围。如果某一页的 y 范围明显比别的页窄说明这页可能是章节标题页或者空白页。参数上pdf.pages[:5]只取前五页避免一次加载整本书导致内存暴涨。page.chars返回的是字符级对象列表每个对象有top、bottom、x0、x1等属性。如果你发现某本书的页眉 y 坐标稳定在 60 以下就可以在后续抽取时直接过滤掉top 60的字符。2.2 用pdfplumber做页面级过滤的实操参数侦察完之后正式抽取时要加两个过滤条件一是按 y 坐标裁掉页眉页脚二是按 x 坐标裁掉左右边距。对于这本小说我实测页眉大约在top 55的区域页脚在bottom 720的区域A5 或类似开本。左右边距各留 40 像素。def extract_clean_text(pdf_path, header_cut55, footer_cut720, margin40): full_text [] with pdfplumber.open(pdf_path) as pdf: for page in pdf.pages: # 只保留正文区域的字符 filtered_chars [ c for c in page.chars if c[top] header_cut and c[bottom] footer_cut and c[x0] margin and c[x1] page.width - margin ] if not filtered_chars: continue # 用过滤后的字符重建文本行 # 按 top 坐标分组同一行的字符 top 差值小于 3 lines {} for c in filtered_chars: line_key round(c[top] / 3) lines.setdefault(line_key, []).append(c) # 每行内按 x0 排序拼成字符串 page_lines [] for key in sorted(lines.keys()): line_chars sorted(lines[key], keylambda c: c[x0]) page_lines.append(.join(c[text] for c in line_chars)) full_text.append(\n.join(page_lines)) return \n.join(full_text) raw extract_clean_text(The Love Hypothesis by Ali Hazelwood (z-lib.org).pdf) print(raw[:500])这里的关键参数是header_cut和footer_cut它们不是固定的每本书都要先跑一遍侦察代码再定。round(c[top] / 3)这个除法是为了把同一行的字符归到一组因为 PDF 里同一行的字符 top 值可能有 0.5 到 1 像素的浮动除以 3 相当于给了 3 像素的容差。如果你发现输出的文本行还是断得厉害可以把除数改成 5 或 8但太大会把相邻两行合并。margin40是左右边距如果书里有侧边注释这个值要调大。2.3 章节切分用正则找 “Chapter” 还是用字体大小抽完文本后下一步是切章节。这本小说的章节标题格式比较统一通常是 “Chapter 1”、“Chapter 2” 这种。但直接re.split(rChapter \d)会有一个问题正文里也可能出现 “chapter” 这个词比如 “in this chapter” 之类。更稳的做法是结合字体大小——章节标题的字体通常比正文大。pdfplumber的chars对象里有size属性可以先统计全书字体大小的分布找到正文的基准字号然后把明显大于基准字号的文本行标记为标题。import re from collections import Counter # 先统计字体大小分布 with pdfplumber.open(The Love Hypothesis by Ali Hazelwood (z-lib.org).pdf) as pdf: sizes [] for page in pdf.pages[:30]: # 采样前 30 页 for c in page.chars: sizes.append(round(c[size], 1)) size_counter Counter(sizes) print(size_counter.most_common(10)) # 假设输出显示 11.0 出现最多那就是正文基准拿到基准字号后比如正文是 11.0章节标题可能是 14.0 或 16.0。然后遍历每一页把字号大于 13.0 且文本匹配Chapter\s\d的行作为章节起点。这样切出来的章节边界比纯正则准得多。如果这本书没有明显的字体差异那就退回到正则加位置约束章节标题通常单独占一行且前后有空行。3. 从原始文本到可分析语料清洗、分句与对话抽取3.1 处理硬换行和连字符断词上一章抽出来的文本虽然去掉了页眉页脚但行尾的硬换行还在。英文小说里最常见的两个问题是一行末尾的单词被硬换行拆开以及行尾有连字符。比如 “hypoth-\nesis” 要合并成 “hypothesis”而 “the\nlove” 要合并成 “the love”。规则是如果一行以连字符结尾且下一行开头是小写字母就去掉连字符直接拼接如果一行不以标点结尾且下一行开头是小写字母就用空格拼接。def fix_line_breaks(text): lines text.split(\n) merged [] for i, line in enumerate(lines): line line.strip() if not line: continue if merged and merged[-1].endswith(-) and line[0].islower(): # 连字符断词去掉连字符直接拼 merged[-1] merged[-1][:-1] line elif merged and not merged[-1].endswith((., !, ?, , )) and line[0].islower(): # 普通硬换行加空格拼接 merged[-1] merged[-1] line else: merged.append(line) return \n.join(merged) clean_text fix_line_breaks(raw)这段代码的逻辑是维护一个merged列表逐行判断是否要和上一行合并。注意line[0].islower()这个条件很关键——如果下一行开头是大写字母很可能是新段落或新句子不应该合并。但英文里也有专有名词开头的情况所以这个规则不是 100% 准确对于小说文本够用了。如果你要做更精细的段落重建可以结合缩进信息但 PDF 里缩进往往丢失了所以只能靠标点和大小写启发式。3.2 用 NLTK 做句子分割和对话标记清洗完硬换行后文本基本是段落形式了。接下来做句子分割。NLTK 的punkt分词器对英文小说效果不错但它会把对话引号里的句子也切开。如果你后面要做对话情感分析需要先把对话抽出来。一个简单办法是用正则匹配双引号内的内容但要注意跨行对话和单引号。import nltk nltk.download(punkt, quietTrue) from nltk.tokenize import sent_tokenize def extract_dialogues(text): # 匹配双引号内的内容允许跨行 pattern re.compile(r([^]{10,}), re.DOTALL) dialogues pattern.findall(text) return dialogues sentences sent_tokenize(clean_text) dialogues extract_dialogues(clean_text) print(f总句子数: {len(sentences)}) print(f对话片段数: {len(dialogues)}) print(示例对话:, dialogues[:3])参数上{10,}表示只匹配长度超过 10 个字符的引号内容避免把缩写或引号误匹配。re.DOTALL让.能匹配换行符这样跨行对话也能抓到。如果你发现对话抽取漏了很多可能是书里用了弯引号“和”而不是直引号需要把正则改成r[“”]([^“”]{10,})[“”]。这一步做完你就有了两个基础语料全部句子和全部对话。后面做词频、情感分析、人物关系网络都从这两个列表出发。3.3 用 spaCy 做命名实体识别找人物这本小说是言情类人物名字反复出现。用 spaCy 的en_core_web_sm模型跑一遍 NER把PERSON类型的实体抽出来按出现频率排序基本就能拿到主要人物列表。import spacy from collections import Counter nlp spacy.load(en_core_web_sm) # 只取前 5000 个句子做 NER避免太慢 sample_sentences sentences[:5000] person_counter Counter() for sent in sample_sentences: doc nlp(sent) for ent in doc.ents: if ent.label_ PERSON: person_counter[ent.text] 1 print(person_counter.most_common(20))这里sentences[:5000]是采样因为整本书几万个句子跑 spaCy 可能要十几分钟。如果你机器性能够可以全量跑。en_core_web_sm是小模型速度快但精度一般人物名可能会有误判比如把 “Mr.” 单独识别成人名。解决办法是加一个后处理过滤掉长度小于 3 的实体以及常见称谓词。另外同一个人的不同称呼比如 “Olive” 和 “Olive Smith”需要做别名合并可以用姓和名的包含关系来归并。4. 避坑与排查PDF 文本抽取里最容易翻车的 5 个地方4.1 现象抽出来的文本全是乱码或空白原因通常是 PDF 使用了非标准字体编码或者文件本身是扫描版图片。pdfplumber对文本型 PDF 有效对扫描版无能为力。判断方法很简单用pdfplumber打开后看page.chars是否为空。如果为空说明这页没有可提取的文本层需要走 OCR 路线。对于这本小说如果是正规电子书转换的 PDF一般有文本层但如果你拿到的版本是拍照扫描的那就得先上 OCR。4.2 现象章节切分后第一章内容特别长后面章节很短原因通常是章节标题的字体大小和正文一样导致正则只匹配到了部分章节。解决方法是先打印出所有匹配Chapter \d的行及其字体大小看看是不是有些章节标题的字号没达到阈值。如果是就降低阈值或者改用位置规则章节标题通常出现在页面顶部或单独一页。另一个可能是书里有 “Prologue” 和 “Epilogue”这些不在Chapter \d模式里需要单独加规则。4.3 现象对话抽取时引号内的内容被截断原因可能是书里用了弯引号或者对话跨了多个段落。弯引号的问题前面说了改正则就行。跨段落对话更麻烦因为一段对话可能以引号开头但中间没有闭合引号直到下一段才闭合。这种情况用正则很难完美处理我的做法是先用简单正则抽然后人工检查前 20 个结果如果发现截断严重就改用基于规则的状态机遇到开引号开始记录遇到闭引号结束中间不管换行。4.4 现象spaCy NER 把普通名词识别成人名原因是大模型对小写单词的上下文判断不准比如 “love” 在某些句子里可能被误判。解决办法是加一个白名单过滤只保留在文本中出现次数超过 5 次且首字母大写的实体。另外可以结合对话抽取的结果——如果某个实体经常出现在对话引号附近那它更可能是人物名。这个启发式规则对小说文本特别有效。4.5 现象处理整本书时内存爆掉原因是pdfplumber默认会把所有页面加载到内存一本几百页的小说可能占几个 GB。解决办法是逐页处理不要一次性pdf.pages全部展开。可以用for page in pdf.pages:迭代但pdfplumber的迭代器仍然会缓存页面对象。更彻底的做法是用pdfminer.six的低层 API 逐页解析或者把 PDF 拆成多个小文件分批处理。我一般会在抽取文本后立刻把pdf对象关闭并且只保留清洗后的文本不保留原始字符对象。5. 进阶用 TF-IDF 和情感曲线看这本书的叙事节奏5.1 按章节算 TF-IDF 找关键词有了章节切分和清洗文本后可以按章节做 TF-IDF看看每一章的关键词是什么。这能帮你快速定位剧情高潮和转折点。用sklearn的TfidfVectorizer把每个章节当成一个文档。from sklearn.feature_extraction.text import TfidfVectorizer # 假设 chapters 是一个列表每个元素是一章的文本 chapters split_into_chapters(clean_text) # 你需要自己实现这个函数 vectorizer TfidfVectorizer( stop_wordsenglish, max_features5000, ngram_range(1, 2), min_df2 ) tfidf_matrix vectorizer.fit_transform(chapters) feature_names vectorizer.get_feature_names_out() # 打印每章 top 10 关键词 for i, chapter in enumerate(chapters): if i 5: # 只看前 5 章示例 break row tfidf_matrix[i].toarray().flatten() top_indices row.argsort()[-10:][::-1] print(fChapter {i1}: {[feature_names[j] for j in top_indices]})参数上ngram_range(1, 2)表示同时看单个词和双词短语这样能抓到 “fake dating” 这种关键短语。min_df2过滤掉只出现一次的词减少噪声。max_features5000限制词汇表大小加快计算。如果你发现关键词里全是人名可以把人名加入停用词表再跑一遍。5.2 用 VADER 做逐章情感曲线VADER 是专门针对社交媒体文本的情感分析工具但对小说对话也出奇地好用因为它对感叹号和大小写敏感。把每一章的文本按句子跑 VADER取平均情感分就能画出一条叙事情感曲线。from nltk.sentiment import SentimentIntensityAnalyzer nltk.download(vader_lexicon, quietTrue) sia SentimentIntensityAnalyzer() chapter_sentiments [] for chapter in chapters: sentences_in_chapter sent_tokenize(chapter) scores [] for sent in sentences_in_chapter: score sia.polarity_scores(sent)[compound] scores.append(score) if scores: chapter_sentiments.append(sum(scores) / len(scores)) else: chapter_sentiments.append(0) # 打印每章平均情感分 for i, s in enumerate(chapter_sentiments): print(fChapter {i1}: {s:.3f})compound分数范围是 -1 到 1负值偏消极正值偏积极。言情小说通常在前几章有较多冲突负分中间升温正分结尾圆满高分。如果你发现曲线和预期不符可能是章节切分错了或者 VADER 对某些反讽句判断失误。这时候可以抽样看几段低分句子确认是不是真的消极内容。5.3 一个我常用的验证习惯每次做完一条分析链路我都会做一件事随机抽 10 个句子人工读一遍看看清洗和分句有没有把意思搞乱。这个习惯帮我省了很多后悔药——有一次我发现分句器把 “Mr. Smith” 切成了 “Mr.” 和 “Smith”导致后面 NER 全错。所以不管你的 pipeline 多自动最后一定要有人工抽检这一步。希望帮到你。本文还有配套的精品资源点击获取