网络小说阅读这件事,很多人都有过“追更追到一半,想把自己喜欢的章节存下来慢慢看”的念头。我最早动这个心思,是因为追的一本书更新太慢,想先把已发布的章节抓下来做成离线文档,通勤路上没网也能翻。后来发现身边不少做内容整理、做语料收集的朋友也有类似需求——把公开可读的章节正文按顺序保存成结构化文本。这篇就围绕“用 Python 抓取小说章节与正文”这个具体场景,把整条链路拆开讲清楚:从页面结构怎么分析、请求怎么发、章节列表怎么拿、正文怎么提取,到反爬应对、编码处理、断点续爬和最终的文件组织。适合有一定 Python 基础、想动手做一个可复现小工具的人,也适合刚学完 requests 和解析库、想找个真实项目练手的朋友。我不会给你一个“万能脚本”,而是把每一步的判断依据和踩过的坑讲透,你照着思路改改就能跑自己的目标。
1. 先想清楚:这类抓取到底在抓什么
动手写代码之前,得先把“抓取”这件事的对象想明白。小说站点的一个章节页面,本质上就是一张 HTML 页面,里面混着导航栏、广告位、推荐位、评论区,真正有价值的正文只占其中一小块。我们要做的,是从这堆噪声里把“章节标题 + 正文段落”精准地抠出来,再按书籍维度组织成有序的文件。这个认知决定了后面所有技术选型:解析库选谁、请求怎么控频、数据怎么落盘。
1.1 目标数据的三个层次
我把要抓的东西分成三层,层次清楚了,代码结构自然就清晰了。
第一层是书籍元信息:书名、作者、简介、总章节数、状态(连载/完结)。这些通常在书籍详情页,抓一次就够,用来给最终文件命名和建目录。
第二层是章节目录:每一章的标题和对应的 URL。目录页往往是一个列表,可能是分页的,也可能是懒加载的。这一层是整条链路的枢纽——目录拿全了,后面才能按序抓正文。
第三层是章节正文:标题、正文段落、可能还有字数、更新时间。这是数据量最大、也最容易被反爬盯上的一层。
三层的数据获取频率完全不同:元信息一次、目录几次、正文几百上千次。频率差异直接决定了请求策略要区别对待,这一点后面会展开。
1.2 为什么不能“一个请求打天下”
新手最容易犯的错,是拿到书籍首页就开始循环请求所有章节。问题在于:目录页的 URL 结构和正文页往往不是一套规则,有的站点目录是/book/123/,正文是/chapter/123/456.html,中间还夹着分页参数。硬拼 URL 的结果就是大量 404,既浪费时间又容易触发风控。
正确的做法是先解析目录页,把真实的章节链接提取出来存成列表,再遍历这个列表去请求正文。这样即使站点改了 URL 规则,你只需要改目录解析那一小段,正文抓取逻辑完全不用动。这是我在多个类似项目里反复验证过的解耦思路。
1.3 法律与礼貌边界必须先划清
这一点必须放在最前面说。抓取公开可读的页面用于个人离线阅读、语言学研究、文本分析,和批量转载、商业分发是两码事。动手前请确认:目标内容是否公开可访问、站点服务条款是否允许、抓取频率是否会给对方服务器造成压力。我的原则是只抓自己有权阅读的内容、控制频率、不绕过任何付费墙。付费章节、登录后才能看的内容,一律不碰。这不是技术问题,是底线问题,越过了后面所有技巧都失去意义。
2. 页面结构分析:用浏览器开发者工具把“地图”画出来
写解析代码之前,我习惯先在浏览器里把目标页面的 DOM 结构摸清楚。这一步做扎实,后面写选择器就是抄作业;这一步偷懒,后面就是无穷无尽的调试。下面是我常用的分析流程。
2.1 定位正文容器的实操手法
打开一个章节页,按 F12 打开开发者工具,用元素选择箭头(左上角那个小图标)点一下正文区域。这时候 Elements 面板会高亮对应的 DOM 节点。重点看三件事:
- 正文外层容器的
id或class是否有明显语义,比如content、read-content、chapter-body这类; - 正文段落是用
<p>包裹,还是用<br>换行的一整块文本; - 容器内部有没有混入广告 div、推荐链接、“本章未完”之类的干扰节点。
我遇到过一个站点,正文容器里每隔几段就插一个<div class="ad">,如果直接取容器text,广告文案会混进正文。解决办法是在提取后按 class 过滤掉这些子节点,或者只取<p>标签的文本。这个细节不提前看,抓下来几百章全是脏数据,返工成本极高。
2.2 目录页的分页与懒加载识别
目录页有两种常见形态。一种是静态分页,URL 带?page=2这种参数,翻页就是换参数重新请求,处理起来最简单。另一种是懒加载或“展开全部”,初始 HTML 里只有前几十章,剩下的靠 JS 动态插入。
判断方法很直接:在开发者工具里看 Network 面板,翻页或点“展开”时有没有新的 XHR/Fetch 请求。如果有,看那个请求返回的是 JSON 还是 HTML 片段,直接请求那个接口往往比模拟点击省事得多。如果目录是纯前端渲染、接口又难找,那就退一步,用能执行 JS 的方案(后面工具选型会讲)。我个人的偏好是优先找数据接口,因为接口返回的结构化数据比解析 HTML 稳定得多。
2.3 把选择器先在手写验证一遍
选定容器后,别急着写进代码。在开发者工具的 Console 里直接用document.querySelectorAll('.content p')试一下,看返回的节点数量和内容对不对。这一步花两分钟,能省掉后面半小时的“为什么抓出来是空的”。确认无误再把选择器搬进 Python,这是我一直保持的习惯。
3. 请求层设计:让每次请求都“像正常访问”
请求层是整个工具的地基。地基不稳,解析写得再漂亮也白搭。这一节讲请求头、会话复用、频率控制和重试,都是实打实影响成功率的细节。
3.1 请求头不是随便填的
很多教程让你复制一整个浏览器请求头,其实没必要全抄,但有几个字段必须带:
| 字段 | 作用 | 建议值 |
|---|---|---|
| User-Agent | 标识客户端类型 | 用一个常见桌面浏览器的 UA |
| Referer | 标识来源页 | 设为该书的目录页地址 |
| Accept-Language | 语言偏好 | zh-CN,zh;q=0.9 |
| Accept | 可接受内容类型 | text/html,application/xhtml+xml |
Referer这个字段经常被忽略,但不少站点会校验它——从目录页跳到正文页是正常行为,直接裸请求正文页反而可疑。把 Referer 设成目录页,请求链路在服务端看来就顺理成章。UA 不要用 Python 默认的python-requests/x.x,那等于自报家门。
3.2 用 Session 复用连接
requests.Session()不只是省几行代码,它会复用底层 TCP 连接、自动保持 Cookie。对于要连续请求几百个页面的场景,连接复用的收益很明显,而且 Cookie 保持能让某些站点的会话校验通过。写法很简单:
import requests session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0 Safari/537.36", "Accept-Language": "zh-CN,zh;q=0.9", })后面所有请求都走这个session.get(),不要每次新建连接。
3.3 频率控制:慢就是快
这是我最想强调的一条经验。很多人追求“抓得快”,结果请求发到一半被封 IP,前面抓的全白费。稳定的慢,远胜于崩溃的快。我的做法是每次请求之间随机 sleep 一个区间,比如 1 到 3 秒:
import time, random def polite_sleep(): time.sleep(random.uniform(1.0, 3.0))随机区间比固定间隔更自然,固定 1 秒的节奏在服务端日志里一眼就能看出是脚本。几百章的书,按这个节奏也就多花十几分钟,换来的是整本书顺利抓完,这笔账怎么算都划算。
3.4 超时与重试的合理配置
网络抖动是常态,必须给请求设超时,否则一个卡住的连接会让整个脚本挂死。超时我一般设(5, 15),即连接 5 秒、读取 15 秒。重试用urllib3的Retry配合HTTPAdapter最省心:
from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry retry = Retry(total=3, backoff_factor=1.5, status_forcelist=[429, 500, 502, 503, 504]) adapter = HTTPAdapter(max_retries=retry) session.mount("https://", adapter) session.mount("http://", adapter)backoff_factor=1.5意味着重试间隔会按 1.5 的倍数递增,避免连续猛冲。status_forcelist里放 429(请求过多)和 5xx,这些是值得重试的;404 这种就别重试了,重试也没用,直接记录跳过。
4. 解析层:从 HTML 里精准抠出标题和正文
请求拿到 HTML 之后,解析就是纯技术活了。工具选型、编码处理、正文清洗,每一步都有讲究。
4.1 解析库怎么选
常见的选择有这么几个,我按适用场景列一下:
| 库 | 优势 | 适用场景 |
|---|---|---|
| BeautifulSoup | 容错强、API 友好 | 结构不太规整的 HTML |
| lxml | 速度快、支持 XPath | 结构清晰、量大 |
| parsel | Scrapy 同款、支持 CSS/XPath | 想兼顾两者 |
| 正则 | 极致灵活 | 结构极简或特殊格式 |
我个人的默认组合是requests + BeautifulSoup(lxml 解析器)。BeautifulSoup 的容错能力在真实站点上太重要了——很多页面标签没闭合、属性乱写,lxml 单独用会直接报错,而 BeautifulSoup 能兜住。如果追求速度且页面规整,可以换纯 lxml + XPath。正则我只在正文是纯文本、没有可靠标签结构时才用,因为正则解析 HTML 天生脆弱,页面一改就崩。
4.2 编码问题:乱码的根因与解法
中文站点最常见的坑就是乱码。根因是响应头里的charset和页面<meta>里声明的不一致,或者干脆没声明。requests默认按响应头猜编码,猜错就乱码。稳妥的做法是优先用页面自身声明的编码:
resp = session.get(url, timeout=(5, 15)) resp.encoding = resp.apparent_encoding # 用内容探测的编码 html = resp.textapparent_encoding基于内容探测,对中文页面通常比响应头可靠。如果发现个别页面还是乱,可以手动指定resp.encoding = 'gbk'或'utf-8'试。我遇到过一个站点,响应头写utf-8,实际是gb2312,就是靠apparent_encoding救回来的。
4.3 正文提取与清洗的完整流程
拿到 HTML 后,正文提取我按这个顺序做:
- 用选择器定位正文容器,比如
soup.select_one('#content'); - 移除容器内的干扰节点:
for tag in container.select('.ad, script, style'): tag.decompose(); - 按段落提取文本:优先取
<p>,没有<p>就按<br>切分; - 逐段
strip()去首尾空白,过滤空段; - 把段落用换行拼成最终正文。
这里有个细节:有些站点正文里用全角空格做首行缩进,strip()去不掉,需要额外replace('\u3000', '')。还有的用 ,BeautifulSoup 解析后会变成\xa0,也要处理。这些脏字符不清理,存下来的文本看着就难受。
4.4 章节标题的规范化
标题里常带“第X章”“正文卷”之类的前缀,还有的带站点名后缀。我一般保留原始标题,但做两件事:一是去掉首尾空白和不可见字符;二是把标题里不能做文件名的字符(/ \ : * ? " < > |)替换掉,因为后面要用标题命名文件。这个处理放在保存阶段做更合适,解析阶段保持原样,方便排查。
5. 反爬应对与稳健性:让脚本能跑完全程
真实站点多少都有反爬措施,这一节讲怎么在不越界的前提下提高成功率,以及脚本崩了怎么续。
5.1 常见反爬信号与应对思路
| 现象 | 可能原因 | 应对 |
|---|---|---|
| 返回 403 | UA/Referer 被识别 | 补全请求头 |
| 返回验证页 | 频率过高 | 降频、加随机间隔 |
| 返回空正文 | 内容 JS 渲染 | 换渲染方案或找接口 |
| 连接被重置 | 并发过高 | 改串行、加延迟 |
| 返回 429 | 触发限流 | 退避重试、大幅降频 |
核心思路就一条:降低请求的“机器感”。串行请求、随机间隔、完整请求头、合理 Referer,这四件事做到位,大部分公开站点的正常阅读量级是能顺利抓完的。我不建议去研究怎么“破解”验证码或绕过风控,那既越界又不稳定,投入产出比极低。
5.2 断点续爬:崩了不用从头再来
几百章的书,中途断掉是常事。如果每次都从头抓,心态会崩。解决办法是每抓完一章就把进度落盘。我的做法是维护一个已抓章节的集合,存成 JSON 或直接看输出文件是否存在:
import os, json def load_done(progress_file): if os.path.exists(progress_file): with open(progress_file, encoding='utf-8') as f: return set(json.load(f)) return set() def mark_done(progress_file, chapter_id): done = load_done(progress_file) done.add(chapter_id) with open(progress_file, 'w', encoding='utf-8') as f: json.dump(list(done), f, ensure_ascii=False)启动时先加载已完成集合,遍历章节时跳过已完成的。这样即使脚本在第 300 章崩了,重跑直接从 301 开始。这个机制我每个抓取项目都会加,属于“加了不亏”的基础设施。
5.3 异常分类处理而不是一把梭
不要用一个try/except Exception把所有错误吞掉。我按错误类型分开处理:网络超时和 5xx 走重试;404 记录到失败列表跳过;解析失败(选择器没匹配到)单独记日志,因为那往往意味着页面结构变了,需要人工介入。失败列表最后统一输出,方便补抓。这种分类处理让排查效率高一个量级。
6. 数据落盘:文件组织与格式选择
抓下来的数据怎么存,直接决定后续好不好用。这一节讲目录结构、文件命名和格式取舍。
6.1 目录结构设计
我习惯按“书名/章节序号_章节标题.txt”组织:
output/ └── 某书名/ ├── meta.json ├── 0001_第一章xxx.txt ├── 0002_第二章xxx.txt └── ...章节序号补零到四位,保证文件管理器里按名称排序就是阅读顺序。meta.json存书名、作者、简介、抓取时间、章节总数,方便日后回溯。这个结构简单、直观、跨平台,用任何文本编辑器或阅读器都能直接打开。
6.2 单章文件里放什么
每个章节文件我一般写三部分:第一行是章节标题,空一行,然后是正文段落。不额外加序号、不加站点信息,保持纯净。如果要做语料分析,纯净正文最省事;如果要保留元信息,放meta.json里就够了,不必污染正文文件。
6.3 合并成单文件还是保持分章
两种都有人要。分章文件便于定位和增量更新;合并成一个大文件便于整体阅读和导入阅读器。我的做法是先分章存,最后提供一个合并脚本,需要时一键合并。合并时按文件名排序读取,用分隔线隔开各章。这样两种需求都满足,不用二选一。
7. 几个我踩过的坑和对应解法
这一节全是实战里换来的教训,文档里不会写,但能帮你少走弯路。
7.1 目录顺序和实际阅读顺序不一致
有的站点目录是倒序的(最新章在最前),有的分卷导致顺序错乱。如果直接按页面顺序抓,存下来的文件顺序是乱的。解法是抓完目录后,检查章节标题里的序号(“第X章”),按解析出的数字重新排序。如果标题里没有可靠序号,就看目录页有没有>
鸢尾花数据集上的四大分类算法比较与实践解析
简介:一份面向机器学习初学者的期末作业参考实现,围绕鸢尾花数据集完成逻辑回归、随机森林、K近邻、决策树四种经典分类算法的横向比较,可直接用于课程设计或期末大作业。压缩包共七个文件,其中包含四个源代码脚本,分别…
JSP期末大作业社团管理系统源码解析:Servlet与Ajax审批流实战
简介:这套基于JSP的期末大作业社团管理系统源代码,适用于大学阶段网页设计课程的项目实战,适合需要快速搭建社团管理后台的学生参考。系统具备成员信息维护、请假申请审批、退团请求处理以及匿名信息反馈等模块,前端使用异步方式提…
WinForm上位机MQTT客户端实例:从通信基座到调试避坑
简介:C# WinForm MQTT客户端实例附完整工程源码,面向需要在.NET平台实现物联网通信的C#桌面开发者,适用于快速搭建支持发布订阅、遗嘱消息、TLS/SSL加密的MQTT客户端,也可作为测试与监控工具的基础。压缩包内共121个文件ÿ…
Unity连接MySQL显示表格:Simple TableUI实现与避坑指南
简介:面向 Unity 开发者的 MySQL 数据展示整合包,适用于在 Unity 2020.3.45f1 客户端直连数据库、读取表格数据,并借助 Simple TableUI 完成界面渲染的技术场景,尤其适合做后台管理面板、数据监控看板或本地工具类应用的中级开发者…
OpenClaw记忆插件memory-lancedb-pro解析与应用
1. OpenClaw记忆插件memory-lancedb-pro深度解析 在AI助手领域,长期记忆能力一直是提升用户体验的关键。OpenClaw作为新一代智能助手平台,其memory-lancedb-pro插件通过本地向量数据库实现了高效的记忆存储与检索。这个官方插件将记忆数据存储在LanceDB中…
Linux无线网络频宽调整与hostapd配置优化
1. Linux无线网络频宽调整的核心价值在Linux环境下调整WLAN频宽(信道宽度)是无线网络优化的关键操作。作为一名长期从事Linux网络管理的工程师,我见过太多因为频宽配置不当导致的性能问题——明明硬件支持802.11n/ac,实测速率却只…