1. 从零搭建一个整本小说抓取脚本,先想清楚这三件事
很多人第一次写小说爬虫,脑子里只有一个念头:把网址丢给requests,然后循环翻页把文字存下来。真跑起来才发现,目录页的链接抓不全、正文里混着广告、章节顺序乱成一锅粥、跑到一半被封了 IP。问题不在代码写得多复杂,而在于动手之前没想清楚三件事:目标站点的页面结构长什么样、整本小说的数据流是怎么走的、以及怎么在不给站点添麻烦的前提下把活干完。
这篇内容就是围绕“用 Python 爬取小说网站的整本小说”这个目标,把从分析页面到落地成可复用脚本的完整链路拆开讲。适合两类人看:一类是刚学完requests和BeautifulSoup、想找个真实项目练手的新手;另一类是写过零散爬虫、但一到“整本抓取”就翻车的朋友。我会把每一步为什么这么做讲透,包括编码坑、限速策略、断点续爬这些文档里不常写、但实际一定会遇到的东西。
先明确一个前提:本文所有示例都基于公开可访问、允许正常阅读的页面结构做技术演示,抓取行为请控制在小规模、低频次,仅用于个人学习。真正要长期跑,务必先看目标站点的 robots 协议和使用条款,这是从业者的基本素养,不是可选项。
整本小说抓取的本质,其实是一条清晰的数据流水线:目录页解析出章节列表 → 逐章请求正文页 → 提取正文文本 → 清洗并落盘。听起来简单,但每一环都有坑。下面按这条流水线的顺序,把每个环节的选型理由、代码实现和踩坑经验讲清楚。
2. 目录页解析:整本抓取的起点,也是最容易漏章节的地方
2.1 先判断目录是“单页全量”还是“分页加载”
打开一本小说的目录页,第一件事不是急着写选择器,而是滚动到底部看有没有“下一页”“第 2 页”这类分页控件。这一步决定了你的抓取逻辑是单层循环还是双层循环。
- 单页全量目录:所有章节链接都在同一个 HTML 里,直接一次解析拿全,最简单。
- 分页目录:目录本身分了好几页,需要先翻完目录页收集全部章节链接,再进入正文抓取。
- 动态加载目录:滚动时才通过接口拉取章节列表,HTML 源码里看不到完整链接,这种情况要么找接口,要么用能执行 JS 的方案。
判断方法很直接:在浏览器里按 F12 打开开发者工具,切到 Network 面板,刷新页面,看章节列表是不是跟着某个 XHR/Fetch 请求一起回来的。如果是,那目录数据大概率来自接口,直接请求接口比解析 HTML 更稳。
我一般会先用下面这段代码快速探一下页面结构,确认章节链接的分布规律:
import requests from bs4 import BeautifulSoup headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0 Safari/537.36" } url = "https://example-novel-site.com/book/12345/" resp = requests.get(url, headers=headers, timeout=10) resp.encoding = resp.apparent_encoding # 关键:先探测真实编码 soup = BeautifulSoup(resp.text, "html.parser") # 打印所有链接,观察章节链接的规律 for a in soup.find_all("a", href=True)[:30]: print(a.get_text(strip=True), "->", a["href"])跑一遍就能看出章节链接有没有统一的前缀规律,比如都带/chapter/或者都以数字结尾。这个规律是后面写选择器的依据。
2.2 用 CSS 选择器精准锁定章节列表
目录页往往还混着“最新更新”“推荐阅读”“分类导航”等一堆链接,如果直接find_all("a")会把无关链接也抓进来。正确做法是先用开发者工具定位到包裹章节列表的那个容器,再在容器内部取链接。
假设章节列表在一个id="list"的div里,每个章节是dd > a,代码就是这样:
chapter_list = [] container = soup.find("div", id="list") if container: for a in container.find_all("a", href=True): chapter_list.append({ "title": a.get_text(strip=True), "url": a["href"] }) print(f"共解析到 {len(chapter_list)} 章")这里有个经验点:相对链接一定要补全成绝对链接。目录页里的href经常是/chapter/123.html这种相对路径,直接拿去请求会失败。用urllib.parse.urljoin处理最稳妥:
from urllib.parse import urljoin base = "https://example-novel-site.com" for ch in chapter_list: ch["url"] = urljoin(base, ch["url"])2.3 章节去重与顺序校验,别让重复链接毁掉整本书
有些站点的目录里,同一章会出现两次(比如置顶的最新章节又出现在列表里),或者“上一页/下一页”的链接被误当成章节。抓之前做一次去重和顺序检查,能省掉后面大量麻烦。
seen = set() unique_chapters = [] for ch in chapter_list: if ch["url"] not in seen: seen.add(ch["url"]) unique_chapters.append(ch) print(f"去重后剩余 {len(unique_chapters)} 章")顺序方面,如果目录本身是按章节顺序排列的,直接沿用即可;如果发现顺序乱了,可以尝试从 URL 里的数字提取章节号排序。但要注意,不是所有站点 URL 都带连续数字,强行排序反而可能打乱,所以优先相信目录页的原始顺序,这是最可靠的。
提示:解析完目录后,先把章节数量和第一章、最后一章的标题打印出来人工核对一遍。我踩过的坑里,有一半是目录阶段就漏了章节,后面正文抓得再漂亮也是残缺的。
3. 正文页提取:编码、选择器与广告清洗的实战处理
3.1 编码问题:中文小说乱码的根源与解法
中文小说站最容易翻车的就是编码。requests默认会根据响应头猜编码,但很多站点响应头写的是ISO-8859-1或者干脆不写,导致resp.text出来全是乱码。
正确姿势是不要盲目相信resp.encoding,而是用apparent_encoding让chardet系列库去探测真实编码:
resp = requests.get(chapter_url, headers=headers, timeout=10) resp.encoding = resp.apparent_encoding html = resp.text如果发现探测结果还是不对,可以手动指定。国内小说站常见的是gbk、gb2312、utf-8三种。判断方法:用resp.content拿到原始字节,分别用这几种编码解码,看哪种出来的中文正常:
for enc in ["utf-8", "gbk", "gb2312"]: try: text = resp.content.decode(enc) if "第" in text and "章" in text: print(f"编码可能是 {enc}") break except UnicodeDecodeError: continue这个技巧在批量抓取时特别有用,因为同一个站不同页面偶尔会出现编码不一致的情况。
3.2 正文选择器:从容器到段落的逐层定位
正文提取的核心是找到那个包裹正文的容器,通常是id="content"、class="content"或id="chaptercontent"这类命名。定位到容器后,正文往往是一堆<br>分隔的文本,或者包在<p>标签里。
soup = BeautifulSoup(html, "html.parser") content_div = soup.find("div", id="content") or soup.find("div", class_="content") if content_div: # 把 <br> 替换成换行,方便后续按段落处理 for br in content_div.find_all("br"): br.replace_with("\n") raw_text = content_div.get_text()这里有个细节:get_text()默认不加分隔符,会把相邻标签的文字粘在一起。加上separator="\n"或者先处理<br>更保险。我习惯先把<br>换成换行符,再get_text(),这样段落结构保留得最完整。
3.3 广告与无关内容的清洗规则
正文里混广告是常态,常见的有:章节末尾的“本站域名”“请记住本站”“手机用户请访问”、正文中间的推广链接、以及一些乱码符号。清洗思路是用规则过滤,而不是逐条硬删。
我一般会维护一个关键词黑名单,逐行过滤:
import re ad_keywords = ["本站", "请记住", "手机用户", "最新网址", "免费阅读", "加入书签", "推荐票"] def clean_content(text): lines = text.split("\n") cleaned = [] for line in lines: line = line.strip() if not line: continue if any(kw in line for kw in ad_keywords): continue # 去掉常见的全角空格和特殊符号 line = re.sub(r"[\u3000\xa0]+", " ", line) cleaned.append(line) return "\n".join(cleaned)这套规则不是万能的,不同站点广告词不一样,需要针对目标站点微调。但思路是通用的:先观察几章正文,把反复出现的广告词收集起来,形成黑名单。跑个十几章之后,黑名单基本就稳定了。
注意:清洗时不要过度删除,比如把正常的对话内容也误伤。建议先跑几章,把清洗前后的文本对比一下,确认没有误删再批量执行。
4. 整本抓取的工程化:限速、重试与断点续爬
4.1 为什么必须限速,以及怎么限才合理
一次性几百个请求打过去,轻则被临时限制,重则 IP 被拉黑,整本抓取直接中断。限速不是可选项,是整本抓取能不能跑完的关键。
最朴素的做法是每请求一章time.sleep一下:
import time import random time.sleep(random.uniform(1.0, 2.5))用随机间隔比固定间隔更自然,避免形成明显的机器节奏。间隔设多少合适?我的经验是单线程 1 到 3 秒比较稳妥,具体看站点响应速度。如果站点本身很快,1 秒也够;如果响应慢,适当拉长。
如果章节特别多,单线程太慢,可以考虑小规模并发,但并发数一定要压得很低,比如 2 到 3 个线程,并且每个线程各自限速。并发越高,被识别的风险越大,得不偿失。
4.2 请求失败的重试机制
网络抖动、临时超时、偶发的 5xx 错误,在几百次请求里几乎必然出现。没有重试机制,跑到一半断了就得从头再来。
用requests配合简单的重试循环就能解决:
def fetch(url, headers, retries=3): for i in range(retries): try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: resp.encoding = resp.apparent_encoding return resp.text except requests.RequestException as e: print(f"第 {i+1} 次请求失败: {e}") time.sleep(2 * (i + 1)) # 退避等待 return None退避等待用2 * (i + 1)这种递增方式,给站点喘息时间,也提高后续请求的成功率。重试次数别设太多,3 次足够,再多说明站点可能已经在限制你了,继续硬刚只会更糟。
4.3 断点续爬:让中断不再意味着从头再来
整本小说动辄几百上千章,跑到一半因为网络或限制中断是常事。如果每次都从头开始,既浪费时间又增加请求量。断点续爬的核心是记录已完成的章节。
最简单的方案是用一个进度文件,每抓完一章就追加一行:
import os def load_done(progress_file): if not os.path.exists(progress_file): return set() with open(progress_file, "r", encoding="utf-8") as f: return set(line.strip() for line in f) def mark_done(progress_file, chapter_url): with open(progress_file, "a", encoding="utf-8") as f: f.write(chapter_url + "\n")主循环里判断一下,已完成的直接跳过:
done = load_done("progress.txt") for ch in unique_chapters: if ch["url"] in done: continue html = fetch(ch["url"], headers) if html is None: continue # ... 解析、清洗、写入 ... mark_done("progress.txt", ch["url"]) time.sleep(random.uniform(1.0, 2.5))这套机制跑起来之后,就算中途断了,重新运行脚本会自动从断点继续,非常省心。我强烈建议从第一版脚本就加上断点续爬,不要等踩了坑才补。
4.4 落盘策略:边抓边写,别攒在内存里
新手常犯的错是把所有章节内容先存到一个大列表里,最后一次性写文件。问题是:一旦中途崩溃,内存里的内容全丢;而且几百章文本堆在内存里也不优雅。
正确做法是边抓边写,每抓完一章就追加到文件:
with open("novel.txt", "a", encoding="utf-8") as f: f.write(f"\n\n{ch['title']}\n\n") f.write(cleaned_text)用追加模式"a",配合断点续爬,整个流程就非常健壮了。如果希望每章单独成文件,也可以按章节序号命名,方便后续整理。
5. 常见翻车场景与排查思路
5.1 抓到的正文是空的或者只有几个字
这种情况八成是选择器没匹配上。排查步骤:先把resp.text存成 HTML 文件,用浏览器打开,手动确认正文容器的id或class。有时候站点会给正文容器加随机后缀,比如content_1234,这时候用find("div", id=re.compile("content"))这种正则匹配更稳。
还有一种可能是正文通过 JS 动态渲染,HTML 源码里根本没有。判断方法:看resp.text里有没有正文关键词。如果没有,就得考虑接口方案或者能执行 JS 的工具。
5.2 章节顺序错乱或缺失
顺序错乱通常是目录页本身排序有问题,或者去重时打乱了顺序。解决方法是保留目录页的原始顺序,去重时用有序去重(比如遍历时判断是否见过,而不是用 set 直接转列表)。
章节缺失则要检查目录解析是否漏了分页。如果目录分了好几页,只解析了第一页,后面自然全丢。回到目录页确认分页控件,把每一页都解析一遍。
5.3 跑到一半请求全部失败
这基本就是被限速或临时限制了。处理方式:立即停止脚本,等待一段时间(比如十几分钟到半小时),然后降低请求频率再继续。断点续爬在这里就体现出价值了,不用从头再来。
如果降低频率后还是失败,可能需要更换请求头里的User-Agent,或者检查是不是触发了某种频率阈值。但根本上,降低请求量、拉长间隔才是长久之计。
5.4 编码在部分章节突然乱码
前面提过,同一站点不同页面编码可能不一致。解决办法是在fetch函数里做编码探测,而不是全局写死一种编码。用apparent_encoding加手动兜底判断,基本能覆盖绝大多数情况。
6. 把脚本整理成可复用的结构
6.1 模块划分建议
跑通之后,建议把脚本整理成几个清晰的模块,方便以后换站点复用:
- 配置区:目标 URL、请求头、限速间隔、文件路径,全部提到最上面。
- 目录解析函数:输入目录页 URL,返回章节列表。
- 正文抓取函数:输入章节 URL,返回清洗后的正文。
- 主流程:串联目录解析、循环抓取、断点续爬、落盘。
这样换一个站点时,只需要改配置和两个解析函数里的选择器,主流程完全不用动。
6.2 一个容易忽略的细节:请求头要像正常浏览器
只带User-Agent有时候不够,加上Referer和Accept-Language会更自然:
headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0 Safari/537.36", "Referer": "https://example-novel-site.com/", "Accept-Language": "zh-CN,zh;q=0.9", }Referer设成站点首页,很多站点会校验这个字段,缺失时直接拒绝。这是实测下来很有效的一个小改动。
6.3 关于合规与频率的最后提醒
技术本身是中性的,但抓取行为要有边界。我的原则是:只抓自己需要阅读的内容,控制频率,不并发轰炸,不用于任何形式的再分发。如果站点提供了官方阅读渠道,优先用官方渠道。写爬虫练手没问题,但心里要有一根弦,别给站点造成实际负担。
我个人在实际操作中的体会是,整本小说抓取最难的不是写代码,而是耐心。限速、重试、断点续爬这些机制,本质上都是在跟“急”这个心态对抗。把节奏放慢,把异常处理做扎实,一个几百章的脚本跑下来其实非常稳。反过来,如果一味追求速度,往往跑不到一半就各种报错,最后花的时间反而更多。