ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Python爬虫实战:用requests+XPath抓取小说全章节并合并TXT

2026/10/8 4:07:33 拓冰建站 浏览量
Python爬虫实战:用requests+XPath抓取小说全章节并合并TXT 朋友前几天找我说想把《斗罗大陆》整本抓到本地慢慢看问我能不能写个爬虫脚本。这种需求太典型了从目录页拿全书的章节链接逐个请求正文页把正文文本清洗干净最后按顺序合并成一个TXT。你不需要上多重的框架Python 的 requests 加 XPath 就够跑通整条链路真正的难点全藏在细节里——编码乱不乱、请求频次、断点续爬、章节排序、反爬识别哪一环没处理好都会翻车。这篇博文就以这部小说为例子把“章节自动抓取与合并”的完整流程掰开揉碎讲一遍适合刚入门 Python 爬虫、想拿真实站点练手的朋友参考。我会把请求伪装、解析清洗、断点管理、文件合并这几个核心环节的代码全部贴出来附带我在实抓过程中踩过的坑和排查思路。1. 项目整体设计与思路拆解1.1 需求拆解这不是一个“写循环”那么简单先别急着写代码把需求说清楚。所谓“章节自动抓取与合并”本质上是四个连续动作的串联采集目录访问小说的目录页解析出所有章节的链接和标题这一步是后续所有请求的数据来源。采集正文遍历目录里的链接逐个请求章节详情页从 HTML 中提取正文文本去掉广告、导航、脚本等无关内容。持久化存储把每一章的标题和正文保存成结构化数据方便后续合并。合并输出把所有章节按阅读顺序拼成一个完整的 TXT 或 EPUB 文件。看似简单但每个环节都有潜藏问题。目录页可能分页章节标题里可能带非法字符正文页可能出现“该章节不存在”的假 404网络抖动会导致请求超时抓取到一半程序挂了又要从头跑……所以设计这套脚本的核心思路不是“跑一次成功”而是“断了能续、错了能查、结果能验证”。我在动手前给自己定了三条要求请求要轻、解析要稳、进度要持久化。这决定了后续所有技术选型。1.2 技术选型为什么是 requests lxml而不是 Scrapy 或 Playwright先给结论这个项目用 requests lxml 就足够了。我见过不少人一上来就上 Scrapy 分布式结果光调试中间件就耗了一天。对于单机、单书、几千个章节的需求Scrapy 的异步并发反而是负担——你并发太快站方马上给你封 IP你还得去配 AutoThrottle、代理池纯属杀鸡用牛刀。Scrapy 更适合大规模、长时间、带调度编排的爬虫工程不适合这种“一次性跑完收工”的小任务。那 Playwright 这种浏览器自动化工具呢只在目标页面是动态渲染时才需要。大多数小说站点为了 SEO 和兼容性章节正文还是服务端渲染的 HTML直接 requests 就能拿到完整内容。先抓包看一眼接口Discovery 阶段永远比盲目上重型工具更高效。解析层我选 XPath 而不是正则原因很简单XPath 是沿着 DOM 树结构定位节点只要页面的 HTML 结构不变表达式就永远有效正则是基于文本模式的匹配一个空格的差异或者标签嵌套调整就能让整个表达式失效。章节列表和正文块在 HTML 里通常都有明确的容器节点比如classlist、idchapter-content这种XPath 一写一个准。1.3 整体流程图解一条主线四个分支整条链路的编排逻辑是启动时先加载本地进度文件如果有历史进度就跳过已抓完的章节没有则从目录页开始解析然后把章节信息写入内存队列接着逐个请求详情页提取正文并清洗每成功保存一章就把进度写回 JSON 文件全部完成后统一合并。框架非常简单没有消息队列没有数据库一个脚本加两个 JSON 文件就够。我建议你也保持这个克制能写文件的不要上数据库能用循环的不要上并发。工具越多排查链路越长。项目跑通之后再考虑优化也不迟。2. 核心细节解析与实操要点2.1 请求层User-Agent、Session 与请求频次的平衡很多新手写爬虫只写一个requests.get(url)然后被 403 打懵。现代 Web 服务端对请求的识别首先看的就是User-Agent和Referer头。如果你不带 UA收到的默认字符串基本等同于告诉对方“我是爬虫”。我实际用的 Headers 是这样组织的HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36, Referer: https://www.example-novel.com/, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.5, }Referer头很关键。小说站的详情页通常只允许站内跳转访问直接裸 URL 请求有时会被追问来源。我观察过有站点的反爬逻辑就是同时校验 Referer 和 Cookie 里的某个会话标记Referer 不对直接返回 404。请求会话建议用requests.Session()而不是每次新建。Session 会自动保存服务端下发的 Cookie对后续请求的“指纹一致性”有帮助。另一个容易被忽略的点是单次请求频率。我要求自己每抓完一章至少间隔 1.5 到 3 秒用time.sleep(random.uniform(1.5, 3))制造随机间隔而不是固定 sleep(2)。固定间隔在服务端的访问日志里显得非常机械随机间隔更接近真实阅读节奏。关于超时我统一设置timeout10并配合重试机制。网络抖动是常态重试三次、指数退避是基本操作def fetch_html(url, session, retries3): for attempt in range(retries): try: resp session.get(url, headersHEADERS, timeout10) resp.raise_for_status() return resp.content except (requests.RequestException, Exception) as e: print(f第 {attempt 1} 次尝试失败: {e}) time.sleep(2 * (attempt 1)) return None这里返回的是resp.content而不是resp.text。resp.text是 requests 根据响应头猜测编码解码后的字符串一旦猜测错误后面全是乱码resp.content是原始字节可以自己指定解码方式可控性强很多。2.2 编码处理乱码问题的根源与解法爬小说最烦的就是中文乱码。乱码的本质是字节流用错了字符集去解码。常见小说的历史站点有些是 UTF-8有些是 GBK/GB2312甚至同一站点不同页面用的编码都不一样。我的处理顺序是这样的先从响应头拿Content-Type里的 charset比如charsetutf-8拿不到就看 HTML 源码里meta charset...标签这两个都没有再用chardet库检测。我实际写了一个小工具函数import chardet def decode_html(content: bytes) - str: if not content: return # 方式一先从 meta 标签里找 charset head content[:2048].decode(utf-8, errorsignore) import re m re.search(rcharset?([\w-])?, head, re.I) if m: enc m.group(1) try: return content.decode(enc, errorsreplace) except LookupError: pass # 方式二chardet 检测 det chardet.detect(content) enc det.get(encoding, utf-8) return content.decode(enc, errorsreplace)有个重要经验chardet对短页面的检测准确率会下降尤其当页面里只有少量中文字符时它可能误判成 ISO-8859-1 或 Windows-1252。所以我优先用 meta 标签它为兜底方案。2.3 XPath 解析text()、string() 与 strip 的组合使用XPath 是这个项目的解析核心有三个细节必须注意。第一个是取属性的写法。目录页的章节链接挂在a标签的href属性上用item.xpath(./href)取出来是一个列表取第一个元素即可。第二个是取文本的差异。//div[idcontent]/p/text()只会取到直接子节点里的文本如果p里还嵌套了span、a这些嵌套标签内部的文字就会丢。我的处理方案是用string()函数text node.xpath(string(.))string(.)会把当前节点下所有文本拼接在一起不会漏掉嵌套内容。但要注意如果节点里含有script或者样式节点string()也会把它们带进去这时候需要先手动移除script和style节点再取值。第三个是清洗。正文页返回来往往混着站内公告、上一章下一章的导航、甚至是广告位文本。我在提取正文的容器节点后会先剔除子节点里的div、a、script、style等非正文元素只保留段落节点for tag in content_node.xpath(.//script | .//style | .//ins | .//div): tag.getparent().remove(tag) text content_node.xpath(string(.)).strip()这样拿到的文本干净很多。清洗顺序很重要必须先移除再取 string因为string()的结果是一次性快照你在快照上做不了删除操作。2.4 文件名与排序两个容易被忽视的坑爬下来的章节要合并合并前你得能保证顺序是对的。很多人喜欢直接按章节标题存文件名然后按文件名排序合并。这里有两个坑。第一个是 Windows 文件名非法字符。章节标题里如果出现\ / : * ? |存文件直接报错。解决办法是正则替换import re def safe_filename(title: str) - str: return re.sub(r[\\/:*?|], _, title)第二个是自然排序问题。字符串排序时“第 10 章”会排在“第 2 章”前面因为按字典序比较字符串1 2。所以合并时要优先从 URL 或者标题里提取章节数字用数字排序而不是用文件名排序def chapter_sort_key(item): m re.search(r第\s*(\d)\s*章, item[title]) return int(m.group(1)) if m else float(inf)这一招直接决定了最终 Txt 文件的章节顺序是否正确别小看它。2.5 理解反爬先读懂对方的防护逻辑在做爬虫实测时免不了碰到站点本身的防护策略。传统后端比如 Java 的 Controller 层一般会做几类防护校验 User-Agent 是否来自真实浏览器、校验 Referer 是否站内跳转、对访问频率做限制、对高风险 IP 做验证码或临时封禁、关键接口做签名参数。前端也会有操作比如禁止右键查看源码、混淆页面结构、动态加载正文让静态源码里看不到内容。理解了这些防护手段你的应对思路也就清晰了伪装 UA 和 Referer、控制访问频率、分析网络面板找到真实的正文接口。这里必须强调一点任何爬虫实战都应遵循目标网站的 robots 协议和服务条款只用于个人学习、研究和备份控制合理频率不要把数据用于商业分发。技术的价值在于理解和改进而不是滥用。3. 实操过程与核心环节实现3.1 目录页采集拿到全部章节入口我以一类典型的小说站页面结构为例。目录页一般长这样div classlist a href/book/1001/1.html第1章 引子/a a href/book/1001/2.html第2章 唐三/a a href/book/1001/3.html第3章 测验/a ... /div采集代码import requests import json import re import time import random from lxml import etree BASE_URL https://www.example-novel.com/book/1001/ HEADERS { ... } session requests.Session() def parse_catalog(html_text: str): tree etree.HTML(html_text) links tree.xpath(//div[classlist]//a) catalog [] for link in links: href link.xpath(./href) title link.xpath(string(.)).strip() if not href or not title: continue href href[0] if not href.startswith(http): href BASE_URL href.lstrip(/) catalog.append({title: title, url: href}) return catalog拿到目录之后我会立刻打印前三条和后三条人工确认一下解析结果。这一步的价值是尽早发现 XPath 写错的问题免得后面几千个请求跑完才发现目录是空的。代码里我特意处理了相对 URL 拼接的问题因为有的站点目录页链接是相对路径需要补全域名。还有一个隐藏问题有些小说站目录页有分页比如每页 100 章、全书 3000 章就有 30 页。处理方式是先解析第一页看有没有“下一页”的链接有就循环跟进直到没有下一页为止。我在代码里留一个循环把路径为list.html、list_2.html这种规律利用起来。3.2 正文页采集与正文清洗拿到章节列表后逐个请求正文页。我在实操里会把每章存成一个独立的 JSON 文件而不是直接攒内存里。这样即使程序崩了已经抓完的章节还在磁盘上。正文采集的核心代码def parse_chapter(chapter_url: str): content_bytes fetch_html(chapter_url, session) if not content_bytes: return None html_text decode_html(content_bytes) tree etree.HTML(html_text) # 找正文容器 content_node tree.xpath(//div[idchapter-content]) if not content_node: # 站点结构不同时备选方案 content_node tree.xpath(//div[classcontent]) if not content_node: return None content_node content_node[0] # 清洗 for tag in content_node.xpath(.//script | .//style | .//ins | .//div[classads]): tag.getparent().remove(tag) paragraphs content_node.xpath(.//p) if paragraphs: paragraphs_text [p.xpath(string(.)).strip() for p in paragraphs] text \n.join([t for t in paragraphs_text if t]) else: text content_node.xpath(string(.)).strip() return text这里有个细节//div[idchapter-content]和//div[classcontent]都是备选条件。实际上我在多个不同的小说站都走过不同站点的容器 class 名五花八门有content、article-content、read-content等。我建议你写一个候选容器列表逐个尝试谁先匹配就用谁。这也算是我踩坑后的经验补足。文本块的筛选上我优先提取所有p段落再拼接。这样做的好处是保留了段间距合并后看 TXT 不会整篇糊在一起。如果正文容器里没有p就整体string()一下兜底。3.3 断点续爬与进度管理这个设计我认为是整个项目里最实用的。爬小说最怕跑到一半出问题网络断开、程序异常、电脑关机。如果没有断点续爬前面抓的东西全部作废。我的做法是抓一章记一章。用一个progress.json记录已完成章节的 URLPROGRESS_FILE progress.json def load_progress(): if os.path.exists(PROGRESS_FILE): with open(PROGRESS_FILE, r, encodingutf-8) as f: return json.load(f) return {done: []} def save_progress(progress): with open(PROGRESS_FILE, w, encodingutf-8) as f: json.dump(progress, f, ensure_asciiFalse, indent2)主循环里每章正文抓取成功并校验长度大于 500 字后才把 URL 追加到进度里。下次重启时通过if url not in done_set判断是否跳过def main(): catalog load_catalog_from_json(catalog.json) progress load_progress() done_set set(progress[done]) for item in catalog: url item[url] if url in done_set: print(f跳过已完成章节: {item[title]}) continue text parse_chapter(url) if not text or len(text) 500: print(f章节内容异常跳过并记录: {item[title]}) continue save_chapter_json(item[title], text) progress[done].append(url) save_progress(progress) time.sleep(random.uniform(1.5, 3))正文校验字数的逻辑很多人会忽略。我见过有章节页点进去显示“本章内容整理中”正文容器里只有一句提示语如果不去校验字数就会把一个只有几十个字的假章节当成正常章节存下来。设置 500 字的阈值能过滤掉大部分异常页。如果某章真的内容极少比如只有标题的公告页这个阈值误伤的可接受范围内毕竟后续可以手动补抓。3.4 合并输出生成完整 TXT 文件全部章节抓完之后合并就简单了。注意合并前要先做数字排序这里用上一节写的chapter_sort_keydef merge_to_txt(chapter_files: list, output_name斗罗大陆.txt): # 读取所有章节 JSON chapters [] for path in chapter_files: with open(path, r, encodingutf-8) as f: data json.load(f) chapters.append(data) # 数字排序 chapters.sort(keychapter_sort_key) with open(output_name, w, encodingutf-8) as f: for ch in chapters: f.write(ch[title] \n\n) f.write(ch[content] \n\n\n) print(f合并完成共 {len(chapters)} 章)输出编码统一用 UTF-8。有很多人喜欢用encodingutf-8存但 Windows 上打开 UTF-8 的 TXT 没问题可是一些老阅读器只认 GBK。如果你要照顾老设备可以用encodinggbk但前提是文本里没有 GBK 无法编码的特殊字符比如生僻的韩文日文有的话会直接抛 UnicodeEncodeError。我的建议是默认 UTF-8它现在是绝对主流。其实我还喜欢在合并时加一个简单目录索引for ch in chapters: f.write(f{ch[title]}\n) f.write(\n * 30 \n) for ch in chapters: ...这样生成的 TXT 自带“卷首目录”用手机阅读器打开时可以直接跳转目录体验好很多。3.5 完整流程跑通后的验证抓完别急着收工。我会打开生成的 TXT抽查三处第一章、中间某章、最后一章。重点看三件事顺序是否正确第 1 章后面是不是第 2 章而不是第 10 章。内容是否完整有没有出现缺失段、重复段。编码是否正常打开无乱码引号、省略号是否正常渲染。同时统计一下目录解析出的章节总数、实际成功抓取的章节数、跳过数。如果两者差值过大就说明某些章节页解析有问题需要回查。我在实抓时会把统计信息打印在运行日志末尾方便一眼定位问题print(f目录章节总数: {len(catalog)}) print(f已抓取章节数: {len(chapters)}) print(f缺失章节数: {len(catalog) - len(chapters)})4. 常见问题与排查技巧实录4.1 问题速查表下面这张表是我在不同站点实操中沉淀下来的高频问题与解法几乎每一条都真实踩过。现象可能原因排查与解决思路请求返回 403缺少 UA、Referer 不对、IP 频率过高检查 Headers确认 Referer 是否站内地址降低抓取频率重启网络换 IP 再试等待而不是硬碰中文乱码响应头 charset 缺失或误判优先看 HTML 的 meta charset用 chardet 检测不要直接用resp.textXPath 取到空列表页面结构变了、内容在 iframe 里重新打开页面看实际 DOM用浏览器开发者工具复制 XPath检查是否被反爬返回了验证页章节顺序错乱按字符串排序导致“第10章”排在“第2章”前用re.search(r第\s*(\d)\s*章)提取章节号并按数字排序正文缺失、只有提示语章节未写完、反爬返回空页校验正文长度过滤 500 字的异常页人工补充抓取抓几百章后出现大量超时IP 被临时限速立即停止等待 10~30 分钟把耗时的 sleep 调大后续用更慢的节奏执行写入 TXT 报编码错误文本里含有目标编码不支持的字符统一用 UTF-8 输出不要用 GBK 硬转目录页有分页只抓了第一页没有翻页处理寻找“下一页”链接规律写循环跟进4.2 服务端视角为什么你的爬虫容易被识别我自己写过 Java 后端接口也做过 Controller 层的防爬设计所以很清楚对方是怎么识别爬虫的。从服务端看最直观的信号就是频率一个 IP 在极短时间内请求几十上百次明显不是人的行为。其次就是指纹请求头缺失 UA、UA 值非常老旧、Accept 头不完整、跳站没有 Referer这些都容易被规则引擎命中。这个项目里我给每个章节请求之间加了随机延迟就是出于这种考虑。另外我把Session对象用于全部请求而不是每次requests.get新建也是为了让会话特征更连续。说到这里必须多提醒一句不要尝试绕过验证码、破解签名或者突破封禁。这类对抗既违法也不道德。爬虫实战的核心是掌握 HTTP 请求、HTML 解析、数据清洗这些通用技能而不是去和某个具体站点死磕。遇到严格防护的站点换一个公开文档 API 或者遵守对方的规则做合规数据获取才是聪明做法。4.3 动态加载页面的应对思路有一次我遇到一个正文页直接用 requests 拿到的 HTML 里根本没有正文数据只有一个奇怪的 JS 入口。后来打开浏览器开发者工具看 Network 面板才发现正文是通过一个 XHR 接口按chapter_id动态加载的。这种情况下的 XML 解析思路就完全不同了先找到那个接口的 URL 规则再在代码里直接请求数据接口解析 JSON 而不是解析 HTML。这种场景才是 Playwright 和 Selenium 的用武之地如果一个接口本身加密了或者需要执行复杂 JS 才能拿到 token渲染浏览器是唯一手段。但即便到了这一步我也建议优先抓接口实在走不通再上浏览器。4.4 爬虫工具箱与大项目扩展方向跑通这个小项目后如果你想往更深一层走可以关注三类工具。第一类是解析与调试工具Chrome 的 XPath Helper 插件、XPath 在线测试工具、JSON 在线格式化工具都能在解析阶段显著提速。第二类是框架升级方向如果目标从“单本小说”变成“整站小说”从“每天几十章”变成“每天几万章”那就要考虑 Scrapy 的并发调度、增量去重、代理 IP 池、任务队列Redis Scrapy这套标准流水线了。我自己后来做一个爬虫平台时就是把这个项目的代码重构成 Scrapy 项目再套上 RabbitMQ 做的分布化改造。第三类是数据可视化与校验抓完数据之后用 Pandas 做章节数量统计、字数分布分析或者用简单的 Flask 页面展示抓取状态都是不错的练手方向。结尾说个我实际抓完《斗罗大陆》之后的体会整本书三千多章requests 单线程抓严格控制频率的情况下大概需要两个小时出头。中途我还故意关闭了一次程序来测试断点续爬重启后确实能做到无缝续跑那一刻才感觉这个项目的设计是对的。后来我又拿这个方法抓过几本别的书只要站点的 HTML 结构能摸清这套模板基本上换一下 XPath 就能复用。最后再分享一个小技巧每个章节的正文文件我建议你在保存时就顺带把标题里的空格、特殊字符清洗干净并且在 JSON 里存一份“章节序号”字段。这个字段看着不起眼到合并排序那天你就知道它的价值了——千万别用文件名去排序。