☰
Python爬虫实战:requests+XPath抓取小说并合并为TXT
2026/10/8 4:07:30 网站建设 项目流程

写这个项目的时候,我刚把《斗罗大陆》刷到第五遍,突然冒出个念头:能不能用 Python 把这套书的所有章节自动抓下来,合并成一个干净的本地 txt?说干就干。这个项目虽然针对的是“小说爬虫”,但背后涉及的核心技术——requests 请求、XPath 解析、文本清洗、文件合并——几乎涵盖了大多数网页数据采集场景的通用打法。今天把这套完整的过程拆开揉碎讲一遍,不是为了让大家拿去盗版传播,而是想展示一台普通笔记本如何通过几十行代码,把分散的网页内容组织成规整的本地资源。学习爬虫技术本身没问题,但务必只用于公开数据、自有数据或已获授权的场景,尊重版权,别碰付费章节和反盗版机制。

整个项目里,我认为最有价值的部分不是“能抓到什么”,而是“怎么把脏乱差的网页内容洗干净”。比如 XPath 里text()函数的坑、章节排序的字符串陷阱、合并文件时的编码处理,每一个都是实战中会卡住半天的细节。这篇博文会把原理、代码、避坑经验一次性聊透。

1. 项目整体设计与技术选型

1.1 为什么选择 requests + XPath,而不是直接上 Scrapy

很多新手一上来就问我:爬个小说是不是得上 Scrapy?我的答案是不需要。Scrapy 是分布式爬虫框架,适合大规模、高并发、需调度管理的场景。但小说站的数据量撑死几千章,用 requests 同步请求完全够用,省掉框架的学习成本,代码还能控制在 150 行以内。

requests 负责 HTTP 通信,XPath 负责从 HTML 里提取数据。这套组合的好处是:调试直观。requests 拿到的 response 直接用 lxml 解析,每一步都能在 IDE 里打印检查,不像 Scrapy 还要熟悉 item、pipeline、middleware 这些抽象概念。另外,小说站的结构通常比较简单:一个列表页放所有章节链接,每个链接指向正文页。这种“扁平化”结构根本不需要分布式,单线程加个time.sleep()就能温和地跑完。

选 XPath 还有一层原因:它处理“文本提取”比正则表达式稳定得多。正则写.*?很容易被页面换行、空格绕晕,而 XPath 直接定位节点,配合text()函数能精准拿到标签内的字符串。虽然有些网站会有“懒加载”或者“字体反爬”,但普通小说站很少上这些手段,XPath 足够应付 90% 的场景。

1.2 明确目标:章节序号、标题、正文三项缺一不可

动手之前,先理清要抓什么、存什么。我定义的数据模型不需要数据库,不需要 Redis,只需要三个变量:chapter_order(章节序号)、chapter_title(章节标题)、chapter_content(正文内容)。听起来简单,但有一个魔鬼细节——章节排序问题。

小说站的列表页通常按时间倒序或正序混排,有些甚至不标序号。如果用列表页的自然顺序来排序,抓下来的章节会出现“第一百二十三章”排在“第九十九章”前面的尴尬情况。所以我的方案是:不管列表页顺序,先抓所有章节链接,然后在解析正文时提取正文里的“序号”字段(比如正文首行的“第一百二十三章”),转成整数存进文件名或字典 key,最后统一排序。这比信任列表页顺序可靠得多。

另一个关键设计是断点续抓。小说动辄几百章,不可能一口气跑完。中途网络抖动断掉,难道要从头再来?所以我把抓取结果实时写入文本文件,每抓完一章就 append 一次,文件名为001_第一百二十三章.txt这种格式。下次运行时通过检测已存在的文件名来跳过已抓取的章节。这个设计虽然简单,但实际救了我两次,后面在常见问题环节会细说。

2. 核心实现:请求、解析与文本提取

2.1 请求头的伪装与会话保持

小说站的防爬虽然不严格,但裸用默认 requests 请求头很容易触发 403。原因很简单:正常的浏览器请求头里会有User-Agent、Referer、Accept-Language这些字段,而 requests 默认的 User-Agent 是python-requests/2.x,服务端一眼就能识别。

我的做法是构造一个标准的请求头字典,关键是模拟真实浏览器的 User-Agent。这里用的是一个比较通用的 Chrome 版本字符串:

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,en;q=0.8', 'Referer': 'https://example.com/', # 根据实际站点填写 }

如果你要抓多个章节,强烈建议用一个requests.Session()而不是每次新建 requests.get。Session 会自动保存 cookies,某些站点的“首次访问设置城市”或“阅读模式”需要 cookie 支撑,用 Session 能减少很多意外。另外 Session 对象可以统一设置 headers,后续请求就不用重复带参数了:

session = requests.Session() session.headers.update(headers)

还有一点小经验:请求前先打印目标 URL 看看有没有跳转。有些小说站喜欢在链接里加跳转参数,直接请求会返回 302 到了首页。用allow_redirects=True(默认)跟一下,再观察最终 URL 是否还是正文页。如果发现跳转到别的域名,大概率是防盗链策略,需要在 headers 里补上正确的Referer。

2.2 XPath 解析:列表页与正文页的分工

先说列表页。列表页一般是整部小说的所有章节在同一个页面上,也有分页的。我遇到的站点是单页完整列表,定位章节链接的 XPath 通常长这样:

from lxml import etree html = etree.HTML(response.text) # 找到所有章节链接,注意 href 和 text 都要取 links = html.xpath('//div[@class="list"]/a/@href') titles = html.xpath('//div[@class="list"]/a/text()')

这里有个新手很容易踩的坑://a[@href]会把页面上所有链接都抓下来,包括广告、首页、分类页。所以一定要用“容器节点”来缩小范围。先看目标站点哪个标签包裹着章节列表,比如<div class="list">、<ul id="chapterlist">,然后在那个节点下找 a 标签。如果页面结构不熟悉,最好先把response.text存成 HTML 文件,在浏览器里打开,用开发者工具的“选择器”功能定位,别盲猜。

接下来是正文页。正文页里除了章节标题,就是大段的正文内容。正文内容往往存在<div id="content">这样的节点里。但直接取string()会带出一堆空白符和脚本标签里的内容。我常用的方法是:先定位到正文容器节点,然后把里面所有 p 标签(或直接取容器内部文本)做一次拼接,再用正则清洗。

关键点来了——text()函数的正确用法。很多教程会说//div[@id='content']/text()提取文本,但在实际页面中,正文的每一段都在单独的<p>标签里,直接取 div 的 text() 只能拿到第一个文本节点,后面的段落全丢失。正确写法是取所有后代文本节点:

content_nodes = html.xpath('//div[@id="content"]//text()') content = ''.join(content_nodes)

这样会把所有后代文本取出来,包括子标签里的。之后再统一清洗。

还有一个场景:正文里夹杂着<br/>标签。某些站点用<br/>分行,此时//text()会把<br/>前后的文本拆成不同节点,join 后没有换行。所以需要根据情况决定是否在text()前加换行符。我的处理是先把所有text()遍历一遍,遇到节点类型为“br 相邻”的场景补一个换行符。代码示例:

paragraphs = html.xpath('//div[@id="content"]/p/text()')

如果明确知道正文只放在<p>里,直接取 p 下的 text 最干净。先观察再写代码,不要死记硬背模板。

2.3 编码大坑:从 GBK 到 Unicode 的转换

编码问题可以说是爬虫生涯里最经典的坑之一。很多老牌小说站用的是 GBK/GB2312 编码,而 requests 默认会根据 HTTP header 里的 charset 解码,有时候 header 没写或者写错,就会出现乱码。最稳妥的办法不是依赖自动检测,而是手动指定编码。

拿到 response 后先检查response.encoding和response.apparent_encoding。前一个是 header 里的推测编码,后一个是基于内容统计的猜测编码。我一般这样处理:

if response.encoding and response.encoding.lower() not in ('utf-8', 'utf8'): response.encoding = response.apparent_encoding html = response.text

更粗暴一点的方法是直接把网页源码以二进制存下来,然后用chardet或charset-normalizer库检测编码。不过对于绝大多数中文站点,apparent_encoding已经够用了。但要注意,apparent_encoding在字节数少的时候可能猜错,如果你发现正文里出现“锟斤拷”这种经典乱码,优先怀疑编码识别错了,而不是 XPath 写错。

我的最终方案其实是:请求时直接指定response.encoding = 'gbk',然后打印前几个字符验证。因为这个站实际用的就是 GBK,一旦确认就固定住,不要每次动态猜。动态猜编码适合浏览器,不适合爬虫脚本。

3. 章节合并与文本清洗实战

3.1 文本清洗三步走:空白、特殊符号、段落间距

抓下来的正文里往往充满 \u3000(全角空格)、\xa0(不间断空格)、\r、\n 各种混杂物。以及某些小说站在正文开头会插入“本章未完,点击下一页继续阅读”这类广告。为了合并成一本干净的 txt,我总结了清洗三步法。

第一步是去除杂散的空白字符。用正则把多个空白符替换成统一格式:

import re def clean_text(text): # 将全角空格、不间断空格替换为普通空格 text = re.sub(r'[\u3000\xa0]', ' ', text) # 多个空白符(含换行)压缩为一个换行 text = re.sub(r'\n\s*\n', '\n', text) # 去掉行尾空格 text = re.sub(r'[ \t]+$', '', text, flags=re.M) return text.strip()

注意不要上来就把所有\n删掉,小说是需要换行的。我的做法是先把段落标记保留下来,比如原始文本里可能用<br/>或\n\n表示段落,清洗时保证每个自然段之间有一个空行。

第二步是删除广告和站点干扰。常见广告有“请收藏本站”“手机用户请访问”“最新章节全文阅读”等。这个不能一概而论,最好先观察目标页面的正文里到底有没有,没有就不用管。如果确定有,可以用字符串替换把它删掉,但要小心别把正常正文里的词组误杀。例如“伍佰”这种词容易和“五百”搞混,所以广告去除我只针对整句匹配,不用模糊规则。

第三步是统一标点。中文小说常见半角逗号、句号混用,有些站点把英文标点转成中文时出错。我的经验是:只处理显眼的空格断行问题,标点风格保留原样,因为读者能看懂,没必要过度清洗导致误改。

清洗后,每一章的正文就可以安全地合并到总文件里了。

3.2 合并文件:编码、排序、追加写入

合并成单一 txt 时最怕两个问题:中文乱码和章节顺序打乱。前面已经确认了解析用的编码是 GBK,那写文件就需要统一转成 UTF-8,否则在某些软件里打开会变乱码。所有章节内容在内存里都存成 Unicode 字符串,写入时指定encoding='utf-8'就行。

文件名的排序我用的是“三位零填充数字 + 章节标题”,比如:

001_第一回 唐三觉醒 002_第二回 王圣挑战 ...

如果不用零填充而是直接用1_第一回、10_第十回,字符串排序的时候10会排在2前面,导致顺序全乱。所以要么填充到三位、四位,要么用整数 key 排序后再写。

合并的核心代码很简单:

import glob chapter_files = glob.glob('chapters/*.txt') # 提取文件名的数字部分,转 int 排序 def sort_key(path): return int(re.search(r'(\d+)', path).group(1)) chapter_files.sort(key=sort_key) with open('斗罗大陆_合集.txt', 'w', encoding='utf-8') as out: for file in chapter_files: with open(file, 'r', encoding='utf-8') as f: content = f.read() out.write(content) out.write('\n\n') # 章节之间空两行

这里有一个小技巧:不要在循环外把全部内容读进内存再一次性写。如果整个小说有几万章,内存可能撑不住。用逐文件读取追加写入的方式,内存占用始终只有一章的大小,稳得很。

3.3 实现断点续抓:文件检测与跳过

前面提到过断点续抓,这里给出完整实现。每次抓某章前,先判断该文件是否存在,存在就跳过。因为文件名包含了章节序号,所以只要文件生成成功且内容非空,就视为已抓取。

import os def fetch_chapter(session, url, chapter_order, title): file_path = f'chapters/{chapter_order:03d}_{title}.txt' if os.path.exists(file_path) and os.path.getsize(file_path) > 0: print(f'跳过已存在的章节:{title}') return # 实际抓取逻辑...

这个方案有个前提:章节文件写入时必须原子写入,否则中途断掉留下半截文件,下一步会误判已抓取。我的做法是先写临时文件,再重命名:

tmp_file = file_path + '.tmp' with open(tmp_file, 'w', encoding='utf-8') as f: f.write(clean_content) os.replace(tmp_file, file_path)

os.replace是原子操作,不会出现写一半的问题。这个小细节值得记下,不只在小说爬虫里有用,任何断点续传任务都能套用。

4. 常见问题与反爬见解实录

4.1 请求超时与重试机制

小说爬虫不是高并发系统,但网络波动依然存在。我最常见的问题是请求某个章节时requests.get()抛出Timeout,然后整个程序崩溃退出。因此必须给每个请求设置timeout参数,并配合重试。

我的实现是加一个指数退避的重试函数:

import time import requests def get_with_retry(session, url, retries=3, timeout=10): for i in range(retries): try: resp = session.get(url, timeout=timeout) if resp.status_code == 200: return resp except requests.exceptions.RequestException: print(f'请求失败,重试 {i+1}/{retries}') time.sleep(2 ** i) return None

注意不要一边重试一边把爬虫跑成风暴。每次失败后间隔时间变长,既给了服务端喘息,也避免自己被抓。单线程跑 1000 章,每章间隔 0.5 秒,总耗时不过 8 分钟多,没必要贪快。

4.2 字体反爬、动态加载与前端混淆的应对策略

小说站的反爬级别一般不高,但有些网站会引入字体文件反爬,让页面显示的数字和 HTML 里的字符并不对应。如果你遇到了,有两种思路:一是下载页面里的自定义字体文件,解析cmap映射,把字符集替换成真实数字;二是干脆不抓数字,直接抓汉字标题,因为标题里的汉字不受字体映射影响。遇到这种站点,我的第一个建议是:换一个源站。为了一本小说去逆向字体文件,投入产出比太低。

动态加载方面,如果章节列表是通过 AJAX 异步渲染的,直接请求 HTML 拿不到链接。这时候需要看浏览器里的 Network 面板,找出真实数据接口(通常是包含章节 JSON 的 URL),直接请求那个 API。requests 照样能搞定,只是多了一步接口分析。这属于“自动化工具”的范畴,如果有必要,可以研究一下浏览器自动化框架,但徒增复杂度,我通常跳过。

关于“前端防止爬虫”和“防止查看页面源码”这类热搜词,我想说明一下:前端做的防护都是纸老虎。页面源码永远可以被浏览器查看,脚本永远可以被断点调试,所谓“防止打开”更多是用户体验层面的限制。爬虫真正要面对的是后端检测,如请求频率、指纹识别、行为验证。所以写爬虫的人应该把精力放在遵守 robots 协议、控制频率和伪装成真实用户上,而不是去攻击那些反调试代码,那样容易触线。

4.3 常见错误速查表

我把这次实战中遇到的高频问题整理成了一张表,方便对照排查:

现象可能原因解决方案
抓下来的正文为空XPath 取错了节点,或text()和//text()没分清先打印容器节点及其内层 HTML 结构,确认是 div 还是 p
章节标题中文乱码响应编码误判手动绑定编码为 GBK 或 UTF-8,检查apparent_encoding
排序错乱(第十章排在第九章前)字符串排序而非整数排序文件名补零或使用 int 排序 key
抓了几百章后 IP 被封请求频率过高增大time.sleep()间隔,加上随机延迟 0.3~1 秒
文件内容包含广告正文节点范围过大缩小 XPath 范围,或做整句广告字符串删除
中途中止后无法续抓没有断点检测机制基于文件名是否存在实现跳过逻辑

记得我最开始跑的时候,因为忘了加response.encoding = 'gbk',前 20 章全是“��”乱码,删掉重来。后来我把编码检测放在循环外做一次验证,后面全流程就顺了。这就是经验的代价,也是写这篇文章的意义——希望你一次就能跑通。

5. 还能怎么扩展:从单机到分布式与可视化

5.1 分布式爬虫与 scrapy-redis

如果你不只是抓一本小说,而是想抓整个站点的所有作品,单线程就不够看了。这时候可以引入 Scrapy + scrapy-redis,把去重队列放到 Redis 里,部署多台机器或多进程抓取。但注意,分布式的复杂度指数级上升,光队列调度、去重、异常恢复就要花很多时间。我的观点是:除非你有严肃的数据需求,否则别为了炫技上分布式。单机 + 线程池 + 随机延迟,已经能应付绝大多数中小型站点的爬取任务了。

5.2 给爬虫加个可视化界面

Python 爬虫可以配上可视化界面,但“可视化”不是爬虫的核心,而是锦上添花。如果你想做成工具,可以用 Flask 搭一个简单的 Web UI,把已经抓取好的文本文件在网页上展示,甚至做成在线阅读器。我后来就把抓下来的《斗罗大陆》合集导入到了一个自建的阅读器页面,客制化了字体和背景色。这样整个体验就很舒服,不管在电脑还是手机浏览器上都能看。

不过这里必须提醒一句:自己爬来自己读是一回事,做成公共服务是另一回事。未经授权把小说网站的内容放到自己的网站上供人阅读,属于明确的侵权行为。所以我的可视化界面只做在本地 localhost,不开放外网访问。爬虫技术是无罪的,但用在哪里要守规矩。

5.3 数据清洗的通用化封装

这个项目里最值得复用的不是抓取代码,而是清洗模块。我把clean_text函数单独提出来,原始文本进去,规整文本出来,不依赖任何网站结构。后来我在做招聘信息爬虫、新闻资讯聚合时,直接把它拿过来改改用,效果都很好。如果你打算长期写爬虫,一定要把“抓取”和“清洗”解耦成独立函数,这样换站点时只要改 XPath,清洗逻辑不用大动。

还有个小技巧:把章节抓取路径存成 JSON 配置文件,里面放列表页地址、正文容器 XPath、标题容器 XPath、是否删除广告等字段。这样换个小说站,不用改代码,只改配置就能跑。我在这篇博文里没有展开,因为篇幅有限,但值得你自己尝试。

最后再说点实在的

坦白讲,爬虫这个技能点并不神秘,它本质上是“HTTP 请求 + 字符串处理”的组合拳。写《斗罗大陆》抓取脚本,我这个过程模拟了真实从业者的工作流:分析页面、构造请求、解析数据、清洗存储、防御异常。每个环节都藏着一些小技巧,比如临时文件防止半截覆盖、整数排序避免字符串排序陷阱、编码先验证再固定。如果你能自己独立把这个项目跑通,再遇到其他结构相似的信息网站,心里就有底了。

我个人的强烈建议是:抓取任何数据前,先看看网站的robots.txt,尊重服务端声明;抓取频率控制在“人类手速”以内;抓取下来的数据只用于个人学习和研究。爬虫本身是工具,工具的好坏取决于拿它做什么。把这套技能用在正向的地方,比如分析公开数据、做个人阅读归档、监控自有网站状态,你会觉得特别值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询