搞爬虫的人都知道,《三国演义》这种古典名著站点简直是新手练习的绝佳素材——页面结构规律、章节导航清晰、正文格式统一,而且公版作品不用担心版权。用 requests 抓页面 + BeautifulSoup 解析,基本能在一顿饭的功夫里撸完整个项目。这篇文章就把我实际爬取《三国演义》全本章节标题和正文的完整过程拆开讲清楚,包括页面分析、bs4 选择器怎么写得稳、乱码怎么处理、正文里的广告垃圾怎么清洗,最后还有我踩过的几个坑和对应的排查思路。
1. 项目设计与思路拆解
1.1 为什么拿《三国演义》练手最合适
爬虫入门最怕的不是写代码,而是找不到一个合适的练手目标。电商页面反爬太凶、社交平台数据是动态加载、视频网站结构复杂,新手一上来就撞这些硬墙,心态很容易崩。
《三国演义》在线阅读站点就友好得多:第一,整本书有一百二十回,章节列表都在同一页上,抓一次就能拿到全部链接;第二,正文页结构高度统一,每一回都是一个独立页面,正文基本集中在同一个标签容器里;第三,这类古典文学站点大多不怎么更新反爬策略,有些甚至连 User-Agent 都不校验,堪称爬虫练习的“新手村”。
我选的是某在线阅读平台的三国演义频道,页面是典型的服务端渲染 HTML,数据直接嵌在网页源代码里,不需要分析接口、不需要处理 JavaScript 动态加载,用 requests 拿到的 HTML 里就能看到完整的章节标题和正文内容。这就意味着整个项目只需要两个核心库:requests 负责下载页面,BeautifulSoup 负责解析提取。
1.2 技术选型:为什么是 bs4 而不是正则或 XPath
很多新手会在 bs4、正则表达式、XPath 之间犹豫。我的实际经验是:这个项目用 bs4 是最稳妥的选择,但不是因为它性能最好,而是因为它写起来最不容易出错。
正则表达式确实是万金油,对付结构简单的文本提取很高效,但 HTML 是一种嵌套结构,标签可以套标签,属性顺序也可能变化,用正则去匹配 HTML 本质上是在用“平面文本”的思路处理“树状结构”,一旦遇到标签嵌套层级变化,匹配规则就可能失效。比如正文里有<p>话说天下大势</p>和<p>玄德<strong>遂</strong>与关、张来见卢植</p>,用正则提取纯文本就得考虑去掉内层标签的问题,写着写着就变成在维护一坨模式串。
XPath 效率高,配合 lxml 解析速度比 bs4 快不少,但 XPath 的语法对新手的可读性略差,写复杂条件时也没有 bs4 那种 find/find_all 的语义直观。
bs4 的核心优势在于它把 HTML 解析成了可遍历的文档树,你可以用“找某个 class 的 div,再在这个 div 里找所有 a 标签”这种自然语言式的思路去写选择逻辑。代码读起来就像是在描述“我要什么”,而不是在描述“怎么匹配字符串”。对于小说爬虫这种结构清晰、层级不深、语义明确的页面,bs4 的易用性完胜正则,开发效率也比 XPath 高出一截。
1.3 整体流程一句话
整个项目的流程可以压缩成三步:第一步,请求章节列表页,用 bs4 解析出每一回的标题和详情页链接;第二步,逐个请求详情页,解析出正文文本;第三步,清洗数据(去掉广告、空行、特殊字符),按章节顺序写入本地文本文件。
这个流程是几乎所有“列表页 + 详情页”型爬虫的标准范式。学会这一套,你就同时掌握了解析列表、批量请求、数据清洗三条核心技能,以后再爬其他小说、新闻、博客,换汤不换药。
2. 环境准备与工具选型
2.1 基础依赖
这个项目对环境要求极低,一台能跑 Python 的电脑就行。我用的是 Python 3.10,但 3.8 以上版本都完全没问题。
需要安装的库就两个:
pip install requests beautifulsoup4如果希望解析速度快一点,可以顺手装一个 lxml,bs4 会优先使用它作为解析引擎:
pip install lxml这里提醒一句,requests 和 urllib 的区别在于 requests 封装了会话、请求头、编码处理等一堆琐碎细节,写起来代码更短更直观;urllib 是标准库,不需要安装,但处理请求头、处理 gzip 压缩、处理编码都得自己写逻辑,没必要。
我在项目里还顺手用了标准库的 time 模块,目的是在请求间隙 sleep 一下,避免请求频率太快给目标站点造成压力。爬虫虽然技术上是中性的,但使用上一定要克制,这是从业者的底线。
2.2 请求头伪装
虽然很多小说站点不校验 User-Agent,但我强烈建议从一开始就保留完整的请求头设置。原因很简单:这是一个好习惯,而且某些站点的反爬策略是分等级的,不校验 UA 不代表以后不校验。
实际请求头我通常这样配置:
headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", "Connection": "keep-alive" }不需要模拟 Cookie,也不需要设置 Referer,这种古典文学站点通常不会做登录墙。但注意,UA 里面那个 Chrome 版本号要填一个真实存在的版本,填一个不存在的 UA 同样会被某些 WAF 识别为爬虫特征。
2.3 解析器的选择细节
bs4 支持多种解析器,最常用的是 html.parser(标准库自带)和 lxml。我实测下来,lxml 的解析速度大约是 html.parser 的三到五倍,在爬全本一百二十回这种场景下差别不大,但如果你以后去爬几万个列表项的大型站点,这就是肉眼可见的差距了。
不过 lxml 有个小坑:如果安装的 lxml 版本与系统环境有兼容问题,导入时可能会报错。真遇到这种情况,直接退回到 html.parser 也完全可行,功能上没有任何损失,别为了性能在环境问题上卡太久。
3. 目标页面分析与结构拆解
3.1 列表页的分析方法
拿到一个页面,第一步不是写代码,而是先在浏览器里打开,按 F12 打开开发者工具,找到 Elements 面板。
我分析的章回列表页结构大致是这样的:页面主体区有一个<div>包裹着所有章节链接,每个链接是<a href="/nansha/120/1.html">第一回 宴桃园豪杰三结义 斩黄巾英雄首立功</a>这种格式。所有章节链接没有放在<ul>列表里,而是直接平铺在 div 下面,中间用换行或分隔符隔开。
这里有一个关键点:不要只盯着标签名看,要注意链接的 href 规律。我观察到的规律是/{route}/{book_id}/{chapter_id}.html,chapter_id 从 1 到 120 递增。这种规律意味着就算列表页上的链接被截断或者有遗漏,你也可以直接按 id 规律构造 URL 来兜底。
确定解析策略之前的准备工作是:在浏览器里右键点击某个章节链接,选择“检查”,看它被哪些父级标签包裹。我看了半天,最终确定可以用“先定位正文容器 div,再在容器内找所有 a 标签”的策略。这比直接find_all("a")更稳,因为页面上可能还有其他无关链接(比如导航、广告、版权信息等),直接全找会把垃圾数据也捞进来。
3.2 正文页的分析方法
点进任意一章,比如第一回,继续看正文页结构。我看到的典型结构是:<div id="content">或<div class="content">包裹着全部正文文字,每一段是一个<p>标签。
注意,我遇到的情况是正文容器标签的 id 或 class 在不同站点之间差异很大,但同一个站点内部是统一的。分析时要把整个 div 的“身份证”记全——标签名、id、class——然后在代码里用这些信息去定位。
正文里还可能混着一些广告文字。我爬的这个站点,正文段落里偶尔会出现“手机用户请访问某某网址”或者“请记住本站域名”之类的推广文案,有的混在段落中间,有的直接插在<p>里。这些垃圾数据如果不清理,会严重影响最终文本质量。
3.3 编码问题:最隐蔽的坑
中文站点老玩家都知道,网页编码是最容易翻车的点之一。我遇到的这个站点,列表页和正文页都是 GBK 编码,而不是 UTF-8。如果直接用 requests 的response.text,它会按照响应头里的 charset 去猜编码,但这个站点响应头里没声明 charset,requests 只能默认用 UTF-8 去解码,结果就是一屏幕乱码。
正确的做法是在拿到 response 对象后,手动处理编码:
response = requests.get(url, headers=headers) response.encoding = "gbk"或者更稳健的方式是,先取原始字节,再用response.apparent_encoding让 requests 从字节内容里推断编码。但apparent_encoding基于字符集统计,偶尔会猜错(比如把 GBK 误判成 ISO-8859-1),所以我的习惯是:确认了站点是 GBK 之后,直接硬编码response.encoding = "gbk",不猜。
还有个细节:有些 GBK 页面里会夹杂少量 UTF-8 的内容,硬解码可能报UnicodeDecodeError,这时候可以给response.content传errors="ignore"或errors="replace"参数,先保证程序不崩,再考虑内容完整性:
html_bytes = requests.get(url, headers=headers).content html_text = html_bytes.decode("gbk", errors="ignore")当然这是兜底方案,正常情况下直接在 response 对象上设置 encoding 就够了。
4. 核心代码实现与解析逻辑
4.1 第一步:请求章节列表页并提取全部章节信息
我先把代码骨架搭好,定义好基础变量和函数,然后写列表页的请求与解析。
import requests import re import time from bs4 import BeautifulSoup BASE_URL = "https://example.com/nansha/120/" HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } def get_html(url, encoding="gbk"): resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = encoding return resp.text列表页解析的核心逻辑就几行:
list_html = get_html(BASE_URL + "index.html") soup = BeautifulSoup(list_html, "lxml") chapters = [] # 定位章节列表容器 container = soup.find("div", class_="listmain") if container: for a_tag in container.find_all("a"): href = a_tag.get("href") title = a_tag.get_text(strip=True) if href and title and href.endswith(".html"): chapters.append((title, href))注意两个细节。第一,get_text(strip=True)会去掉标题首尾的空白字符,比直接a_tag.text干净得多。第二,href.endswith(".html")这个条件是为了过滤掉容器内可能混入的“返回目录”“上一章”“下一章”这类功能性链接,它们也是 a 标签,也占着位置,但它们的 href 可能指向别的页面,不能当作正文章节处理。
打印一下chapters列表,确认长度是 120,每个章节标题和链接都正确,这步才算过。这一步非常关键,因为后面所有正文页 URL 都来源于此,如果列表解析有问题,后面全部白写。
4.2 第二步:请求正文页并提取章节正文
遍历章节列表,逐个请求正文页,这是整个流程的第二步:
def parse_chapter(title, url): full_url = url if url.startswith("http") else BASE_URL + url html = get_html(full_url) soup = BeautifulSoup(html, "lxml") # 定位正文容器 content_div = soup.find("div", id="content") or soup.find("div", class_="content") if not content_div: return title, "" # 提取所有段落的文本 paragraphs = content_div.find_all("p") if paragraphs: content = "\n".join(p.get_text(strip=True) for p in paragraphs) else: # 有的页面正文没有 p 标签,直接取 div 的文本 content = content_div.get_text("\n", strip=True) return title, content这里我留了两条路:优先按<p>标签一块一块地取,但如果页面结构变了(比如某些章节编辑器把文本直接塞在了 div 下面,没有包 p 标签),就走get_text("\n", strip=True)这个分支,一次性把 div 下所有子孙节点的纯文本拼出来,用换行符分隔。
这种“双保险”写法是我强烈推荐的。写爬虫的人都知道,同一个站点的页面结构也会有细微变化,可能是编辑后台不同、可能是某些章节被手工修正过,只写一种解析方式,通常爬到四五十章就会在某一个页面上报No paragraphs found之类的错。
4.3 第三步:清洗数据并写入文件
拿到正文文本之后,必须做一个清洗步骤。这个步骤容易被新手忽略,但它直接决定了最终文本能不能直接阅读。
我写的清洗逻辑包括这几项:删除特定广告文案(比如“请记住本站”这类)、把连续多个空行压缩成两个空行、去掉每行首尾的空白、把中文全角空格替换成普通空格。这些都做完之后,按“标题 + 正文”的格式写入一个纯文本文件。
import os def clean_text(text): # 删除广告行 ad_patterns = [ r"请记住本站.*?", r"手机用户.*?", r"最快更新.*?", r"笔趣阁.*?", ] for pat in ad_patterns: text = re.sub(pat, "", text) # 压缩连续空行 text = re.sub(r"\n\s*\n+", "\n\n", text) return text.strip() def save_to_file(chapters_with_content, filename="sanguo.txt"): with open(filename, "w", encoding="utf-8") as f: for title, content in chapters_with_content: f.write(title + "\n\n") f.write(clean_text(content) + "\n\n")clean_text里面的广告关键词列表是我从实际页面里总结出来的,具体到你自己爬的站点,可以先爬两章,把正文里出现的垃圾文案原文复制出来,再写进正则里精准删除。比一上来就写一个通用正则删所有“请记住”这种模糊匹配要安全得多。
保存文件时我统一用 UTF-8 编码。如果你打算用 Windows 自带的记事本打开,也可以把编码参数改成encoding="utf-8-sig",这样会带一个 BOM 头,记事本不容易乱码。
5. 主流程编排与完整代码
把上面几个函数串起来,主流程就这几行:
def main(): chapters_with_content = [] # 第一步:解析列表 list_html = get_html(BASE_URL + "index.html") soup = BeautifulSoup(list_html, "lxml") container = soup.find("div", class_="listmain") for a_tag in container.find_all("a"): href = a_tag.get("href") title = a_tag.get_text(strip=True) if not href or not href.endswith(".html"): continue # 第二步:解析正文 t, content = parse_chapter(title, href) if content: chapters_with_content.append((t, content)) print(f"已抓取:{t}") else: print(f"抓取失败:{t}") # 控制频率 time.sleep(1) # 第三步:保存 save_to_file(chapters_with_content) print(f"完成,共 {len(chapters_with_content)} 章") if __name__ == "__main__": main()time.sleep(1)这个设置是我根据自己的经验定的:一次只请求一个页面,每秒最多一个请求,对大部分中小型站点来说都是很温和的节奏。有人会把它调成 0.1 秒来加速,我个人不推荐这么做,120 章的书,就算每章多花半秒,总共也就多一分钟,没必要为了这一分钟担风险、给站点添负担。
如果怕中途断网或者程序崩溃导致前功尽弃,可以加一个“断点续爬”逻辑:每次成功抓取一章,就把这一章的标题追加写入一个progress.txt,下次启动时先读这个文件,跳过已经抓过的章节。代码也不复杂:
def get_done_set(): done = set() if os.path.exists("progress.txt"): with open("progress.txt", "r", encoding="utf-8") as f: for line in f: done.add(line.strip()) return done def mark_done(title): with open("progress.txt", "a", encoding="utf-8") as f: f.write(title + "\n")然后在主循环里先判断if title not in done_set,抓完再调用mark_done(title)。这属于“稍微改改就能用”的增强功能,想偷懒也可以不加,但加了之后体验完全不同,尤其是爬到第八十几章突然网络断了,重跑一遍从第一章开始的那种挫败感,能少则少。
6. 常见问题与排查技巧实录
6.1 乱码问题的三层排查思路
乱码是中文爬虫的老大难。我这次也踩了,列表页第一次打印出来全是“��”之类的内容。排查顺序应该是这样的。
第一层:检查是不是没有设置编码就用了response.text。这种情况最典型,解决方案就是手动设置response.encoding = "gbk"。
第二层:如果设置完还是乱码,可能是页面其实是 UTF-8 但站点偏偏不声明。这时候改用response.apparent_encoding来自动推断,或者干脆同时试两种编码来打印对比:
html_bytes = response.content print(html_bytes.decode("gbk", errors="ignore")[:200]) print(html_bytes.decode("utf-8", errors="ignore")[:200])哪个能正常打印出汉字,就用哪个编码。
第三层:如果字节解码没问题但仍有零星乱码(比如某些特殊字符是别的编码),就可以在解码时传errors="ignore"忽略无法解码的字节,把损失控制在单字级别。
我这三个排查动作做完,最终定位是 GBK 编码,硬编码设置之后整个页面就干净了。
6.2 解析结果为空:用打印检查选择器
新手最容易遇到的第二个问题:find_all("a")返回空列表。这时候不要反复改代码,而是先打印:
container = soup.find("div", class_="listmain") print(container)看看 container 本身是不是 None。如果 container 是 None,说明 class 名写错了。这时候回浏览器看真实的 class 名是什么,有时是class_="listmain"有时是class_="chapter-list",只能以实际页面为准。
如果 container 有值但find_all("a")结果为空,说明容器里不是直接放 a 标签,可能还隔了一层结构。这时打印container.prettify()看它的完整子结构,再调整选择器。我试过很多次,这个办法每次都能解决问题——打印出来看真实结构,永远比对着网络上的教程盲猜靠谱。
6.3 正文不完整:注意段落标签的嵌套
第三个高频问题:正文只抓到一部分,或者整段正文被<script>标签里的内容截断。我在分析页面时发现,正文容器 div 底部混入了一段类似阅读记录脚本的<script>代码块。
解决方案依然是通过打印观察。正文的content_div.get_text会把 script 里面的内容也捞出来拼到末尾。所以更稳的写法是在提取文本之前,先把容器内的script和style标签暴力清除:
for tag in content_div.find_all(["script", "style"]): tag.decompose()这样就能保证get_text只拿到真正的正文。类似的,页面里如果混入了 iframe 广告,也可以直接decompose()掉。这个操作在实战中极其常用,建议养成习惯。
6.4 请求失败的容错与日志
网络请求不可能百分百成功。我在实际抓取过程中,第一百一十回左右出现过一次ConnectionError。如果不做容错,整个程序就崩了,前面抓的全白费。
我的做法是给请求函数包一层重试逻辑:
def get_html_retry(url, encoding="gbk", retries=3): for i in range(retries): try: return get_html(url, encoding) except Exception as e: print(f"第 {i+1} 次请求失败:{e}") time.sleep(2) raise RuntimeError(f"请求多次失败:{url}")超时时间也建议设一下,timeout=10表示 10 秒内没有响应就放弃。不设 timeout 的 requests 在某些网络环境里可能卡几分钟不返回,那体验非常折磨。
6.5 最终质量检查
抓完 120 章之后,不要直接拿去做下一步,先做一次全本质量检查。我的检查方法是:统计文件总行数、搜索是否还有广告关键词残留、随机抽读几章开头和结尾、用字数统计确认全文大约有多少字。《三国演义》全本大约六十多万字,如果你的结果差很多,就要回头看是不是有章节抓漏了或者正文截断了。
这次实测下来,最终文件里“请记住本站”这类广告词完全清零,120 个章节标题全部匹配,正文总字数在正常范围内。质量检查通过,这个项目才算真正完成。
7. 经验总结与后续扩展思路
实际把《三国演义》全本爬下来之后,我的感受是这个项目最大的价值不在于“拿到了一本书”,而在于跑通了爬虫完整的思考链路:分析页面 -> 编写解析逻辑 -> 容错处理 -> 数据清洗 -> 质量验证。这五个环节任何一个缺失,最终交付的结果都达不到“可读”的标准。
如果后续想在这个项目基础上继续扩展,我建议往三个方向考虑。第一个方向是“结构化数据”:不只是保存纯文本,而是把章节名、回数、人物名、地点都抽出来做成结构化数据,这需要用到命名实体识别或者简单的规则匹配。第二个方向是“断点续爬”:把当前版本加一个增量抓取功能,以后同一个站点如果新增了内容,可以只抓新增部分。第三个方向是“多站点适配”:把解析配置抽出来,做一个类似“目标站点配置项”的东西,换一个站点只需要改配置不用改代码,这是商业爬虫的常见架构思路。
根据我个人的实操体会,这个项目最适合放在“第一个有完整产出”的爬虫练习来定位。它比单纯的“抓个标题”多一点难度(因为涉及列表页、详情页两级跳转),但又不至于让你陷入反爬对抗的泥潭。耐心跑完一遍,你对 requests 的请求流程、bs4 的文档树操作、文本清洗的常用套路都会有一个实操级的理解,这种理解是看多少教程都换不来的。