搞爬虫的人都知道《三国演义》这种古典名著站点简直是新手练习的绝佳素材——页面结构规律、章节导航清晰、正文格式统一而且公版作品不用担心版权。用 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如果希望解析速度快一点可以顺手装一个 lxmlbs4 会优先使用它作为解析引擎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/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.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}.htmlchapter_id 从 1 到 120 递增。这种规律意味着就算列表页上的链接被截断或者有遗漏你也可以直接按 id 规律构造 URL 来兜底。确定解析策略之前的准备工作是在浏览器里右键点击某个章节链接选择“检查”看它被哪些父级标签包裹。我看了半天最终确定可以用“先定位正文容器 div再在容器内找所有 a 标签”的策略。这比直接find_all(a)更稳因为页面上可能还有其他无关链接比如导航、广告、版权信息等直接全找会把垃圾数据也捞进来。3.2 正文页的分析方法点进任意一章比如第一回继续看正文页结构。我看到的典型结构是div idcontent或div classcontent包裹着全部正文文字每一段是一个p标签。注意我遇到的情况是正文容器标签的 id 或 class 在不同站点之间差异很大但同一个站点内部是统一的。分析时要把整个 div 的“身份证”记全——标签名、id、class——然后在代码里用这些信息去定位。正文里还可能混着一些广告文字。我爬的这个站点正文段落里偶尔会出现“手机用户请访问某某网址”或者“请记住本站域名”之类的推广文案有的混在段落中间有的直接插在p里。这些垃圾数据如果不清理会严重影响最终文本质量。3.3 编码问题最隐蔽的坑中文站点老玩家都知道网页编码是最容易翻车的点之一。我遇到的这个站点列表页和正文页都是 GBK 编码而不是 UTF-8。如果直接用 requests 的response.text它会按照响应头里的 charset 去猜编码但这个站点响应头里没声明 charsetrequests 只能默认用 UTF-8 去解码结果就是一屏幕乱码。正确的做法是在拿到 response 对象后手动处理编码response requests.get(url, headersheaders) response.encoding gbk或者更稳健的方式是先取原始字节再用response.apparent_encoding让 requests 从字节内容里推断编码。但apparent_encoding基于字符集统计偶尔会猜错比如把 GBK 误判成 ISO-8859-1所以我的习惯是确认了站点是 GBK 之后直接硬编码response.encoding gbk不猜。还有个细节有些 GBK 页面里会夹杂少量 UTF-8 的内容硬解码可能报UnicodeDecodeError这时候可以给response.content传errorsignore或errorsreplace参数先保证程序不崩再考虑内容完整性html_bytes requests.get(url, headersheaders).content html_text html_bytes.decode(gbk, errorsignore)当然这是兜底方案正常情况下直接在 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, encodinggbk): resp requests.get(url, headersHEADERS, timeout10) 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(stripTrue) if href and title and href.endswith(.html): chapters.append((title, href))注意两个细节。第一get_text(stripTrue)会去掉标题首尾的空白字符比直接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, idcontent) 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(stripTrue) for p in paragraphs) else: # 有的页面正文没有 p 标签直接取 div 的文本 content content_div.get_text(\n, stripTrue) return title, content这里我留了两条路优先按p标签一块一块地取但如果页面结构变了比如某些章节编辑器把文本直接塞在了 div 下面没有包 p 标签就走get_text(\n, stripTrue)这个分支一次性把 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, filenamesanguo.txt): with open(filename, w, encodingutf-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 自带的记事本打开也可以把编码参数改成encodingutf-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(stripTrue) 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, encodingutf-8) as f: for line in f: done.add(line.strip()) return done def mark_done(title): with open(progress.txt, a, encodingutf-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, errorsignore)[:200]) print(html_bytes.decode(utf-8, errorsignore)[:200])哪个能正常打印出汉字就用哪个编码。第三层如果字节解码没问题但仍有零星乱码比如某些特殊字符是别的编码就可以在解码时传errorsignore忽略无法解码的字节把损失控制在单字级别。我这三个排查动作做完最终定位是 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, encodinggbk, retries3): for i in range(retries): try: return get_html(url, encoding) except Exception as e: print(f第 {i1} 次请求失败{e}) time.sleep(2) raise RuntimeError(f请求多次失败{url})超时时间也建议设一下timeout10表示 10 秒内没有响应就放弃。不设 timeout 的 requests 在某些网络环境里可能卡几分钟不返回那体验非常折磨。6.5 最终质量检查抓完 120 章之后不要直接拿去做下一步先做一次全本质量检查。我的检查方法是统计文件总行数、搜索是否还有广告关键词残留、随机抽读几章开头和结尾、用字数统计确认全文大约有多少字。《三国演义》全本大约六十多万字如果你的结果差很多就要回头看是不是有章节抓漏了或者正文截断了。这次实测下来最终文件里“请记住本站”这类广告词完全清零120 个章节标题全部匹配正文总字数在正常范围内。质量检查通过这个项目才算真正完成。7. 经验总结与后续扩展思路实际把《三国演义》全本爬下来之后我的感受是这个项目最大的价值不在于“拿到了一本书”而在于跑通了爬虫完整的思考链路分析页面 - 编写解析逻辑 - 容错处理 - 数据清洗 - 质量验证。这五个环节任何一个缺失最终交付的结果都达不到“可读”的标准。如果后续想在这个项目基础上继续扩展我建议往三个方向考虑。第一个方向是“结构化数据”不只是保存纯文本而是把章节名、回数、人物名、地点都抽出来做成结构化数据这需要用到命名实体识别或者简单的规则匹配。第二个方向是“断点续爬”把当前版本加一个增量抓取功能以后同一个站点如果新增了内容可以只抓新增部分。第三个方向是“多站点适配”把解析配置抽出来做一个类似“目标站点配置项”的东西换一个站点只需要改配置不用改代码这是商业爬虫的常见架构思路。根据我个人的实操体会这个项目最适合放在“第一个有完整产出”的爬虫练习来定位。它比单纯的“抓个标题”多一点难度因为涉及列表页、详情页两级跳转但又不至于让你陷入反爬对抗的泥潭。耐心跑完一遍你对 requests 的请求流程、bs4 的文档树操作、文本清洗的常用套路都会有一个实操级的理解这种理解是看多少教程都换不来的。
阅读完成 · 觉得有帮助?