1. 项目缘起与整体设计思路
1.1 这个脚本到底能干什么
先说说我为什么要折腾这个东西。平时追更一些连载小说,一章一章点开网页看,广告弹窗满天飞,翻页还卡顿,体验实在谈不上好。后来我就想,能不能写个脚本,把整本小说一次性抓下来,存成纯文本,丢到阅读器里慢慢看。这个项目就是干这个的——给定一本小说的目录页地址,脚本自动把目录里所有章节的正文全部抓取下来,按顺序合并成一个完整的txt文件。
它解决的核心问题有三个:第一是去广告去干扰,抓下来的正文是干净的纯文本,没有弹窗没有推荐位;第二是离线可读,抓完一次之后再也不需要联网,手机、电纸书、电脑随便看;第三是批量高效,一本几百章的小说,手动复制粘贴得搞到天荒地老,脚本几分钟就跑完了。
适合谁来参考呢?我觉得有三类人比较合适:一是刚学完Python基础语法,想找个真实项目练手的同学;二是平时爱看网络小说、又嫌网页阅读体验差的普通读者;三是对网络请求、HTML解析这些概念还比较模糊,想通过一个完整案例把它们串起来的初学者。如果你已经能熟练写爬虫了,这篇可能对你帮助有限,但里面关于反爬应对和编码处理的几个坑,说不定还是能给你提个醒。
1.2 为什么选Python而不是别的语言
做这类文本抓取任务,Python几乎是默认选项,原因很实在。第一,请求库和解析库生态成熟,requests负责发请求,BeautifulSoup或者lxml负责解析HTML,几行代码就能把网页结构拆得明明白白,换成其他语言你得自己拼不少轮子。第二,字符串处理方便,小说正文的清洗涉及大量文本替换、正则匹配、编码转换,Python在这方面的语法糖是真的甜。第三,调试成本低,脚本跑一半出错了,改一行再跑,不用编译不用打包,迭代速度极快。
当然也有用Node.js或者Go写的,性能上确实有优势,尤其是并发抓取的时候。但对于个人自用的小规模任务,Python的开发效率优势远大于运行效率劣势。一本两千章的小说,单线程慢慢抓也就十几分钟的事,完全没必要为了那点速度去折腾并发框架,反而容易把目标站点搞崩,惹来不必要的麻烦。
1.3 整体架构是怎么搭的
整个脚本的逻辑其实很线性,我把它拆成四个阶段:目录解析 → 章节链接提取 → 正文抓取 → 内容清洗与合并。
目录解析阶段,脚本访问小说的目录页,从HTML里找出所有章节链接。这里有个关键判断:目录页的链接通常有规律,比如都包含/chapter/这样的路径片段,或者都在某个特定的div容器里。找到这个规律,就能精准提取,不会把导航栏、推荐位的链接也混进来。
章节链接提取完之后,脚本会遍历这个链接列表,逐个发起请求获取章节页面的HTML。这一步是耗时大头,也是反爬最容易触发的地方,所以必须加延时。
正文抓取阶段,从章节页HTML里定位到正文所在的标签,把里面的文字抠出来。不同网站的正文标签不一样,有的是<div id="content">,有的是<div class="read-content">,这个需要针对目标站点做适配。
最后是内容清洗与合并,把抓下来的正文去掉多余的空格、换行、广告残留,按章节顺序拼成一个完整的字符串,写入txt文件。文件名就用小说名,简单直接。
这个架构的好处是模块清晰、易于调试。哪个环节出问题,单独把那部分拎出来测就行,不用每次都从头跑一遍。而且后续想换目标网站,只需要改目录解析和正文定位这两块,其他逻辑基本不用动。
2. 核心细节解析与实操要点
2.1 请求头的伪装为什么这么重要
很多人写爬虫第一步就栽在请求头上。你直接用requests.get(url)发请求,服务器一看,User-Agent是python-requests/2.x.x,这不摆明了告诉人家"我是脚本"吗?稍微有点防护的站点直接就把你拒了,返回403或者一个空页面。
所以第一步必须伪装请求头。最核心的是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" }除了User-Agent,有时候还需要加Referer,告诉服务器你是从哪个页面跳过来的。有些站点会检查这个,没有Referer就拒绝访问。Referer一般设成目标站点的首页或者目录页地址。
注意:请求头不是越多越好。加一堆用不上的字段反而可能引起怀疑。通常User-Agent加Referer就够了,实在不行再考虑加Cookie。
还有一个细节是请求超时设置。不设超时的话,遇到响应慢的服务器,脚本会一直卡在那里,你以为它在跑,其实早就死住了。我一般设timeout=10,十秒没响应就重试或者跳过。
2.2 HTML解析工具怎么选
解析HTML有两个主流选择:BeautifulSoup和lxml。BeautifulSoup上手简单,容错率高,就算HTML写得乱七八糟它也能解析;lxml速度快,但遇到不规范HTML容易报错。
我的建议是用BeautifulSoup配lxml解析器,兼顾易用性和速度。安装的时候两个都装上:
pip install requests beautifulsoup4 lxml用的时候这样写:
from bs4 import BeautifulSoup soup = BeautifulSoup(html_text, "lxml")定位元素的方法,最常用的是find和find_all。find返回第一个匹配的元素,find_all返回所有匹配的列表。定位方式可以是标签名、class、id、属性等。比如找正文:
content_div = soup.find("div", id="content")或者:
content_div = soup.find("div", class_="read-content")具体用哪种,得看目标网站的HTML结构。打开浏览器的开发者工具,右键正文区域选"检查",就能看到它对应的标签和属性。
实操心得:不要一上来就写代码,先用浏览器开发者工具把目录页和章节页的结构看清楚。目录链接在哪个容器里,正文在哪个标签里,这些搞明白了再动手,能省掉大量试错时间。
2.3 编码问题:中文乱码的根源与解法
中文小说抓取最容易遇到的就是乱码问题。你抓下来的正文打开一看,全是"æ–‡å—"这种鬼东西,这就是编码没对上。
根源在于,网页的编码方式可能是UTF-8、GBK、GB2312等好几种,而requests默认会根据HTTP响应头里的Content-Type来猜测编码,猜错了就乱码。解决办法是手动指定编码。
最稳妥的做法是让requests自己检测:
response = requests.get(url, headers=headers, timeout=10) response.encoding = response.apparent_encodingapparent_encoding会用chardet库去分析内容,猜出最可能的编码。但这个方法有时候也不准,更可靠的是看网页源码里的<meta charset="xxx">标签,手动指定:
response.encoding = "gbk" # 或者 "utf-8"如果还是乱码,可以试试response.content.decode("gbk", errors="ignore"),用errors="ignore"跳过无法解码的字符,虽然会丢一点内容,但至少不会整个报错。
注意:编码问题一定要在抓取阶段就解决,不要等到写文件的时候再处理。因为一旦字符串在Python里已经是乱码状态,后面再怎么转都救不回来。
2.4 延时与重试:让脚本跑得稳
不加延时的爬虫,就像不刹车下坡,迟早出事。目标网站服务器承受不住高频请求,轻则限速,重则封IP。所以每次请求之间必须加延时。
我一般用time.sleep(),间隔设1到3秒。这个范围是经验值:太短了容易触发防护,太长了抓一本小说要等太久。如果目标站点比较敏感,可以设到5秒。
import time import random time.sleep(random.uniform(1, 3))用random.uniform而不是固定值,是为了让请求间隔看起来更"自然",不那么机械。
重试机制也很关键。网络请求失败是常态,可能是超时,可能是服务器临时抽风。如果不重试,一章抓失败就断了,整本小说就不完整。我的做法是给每个请求包一个重试循环:
def fetch(url, retries=3): for i in range(retries): try: response = requests.get(url, headers=headers, timeout=10) if response.status_code == 200: return response except requests.RequestException: time.sleep(2) return None重试三次还失败,就返回None,在调用处记录下这个失败的章节,最后统一报告。这样不会因为某一章的问题导致整个任务中断。
3. 实操过程与核心环节实现
3.1 第一步:分析目标网站的目录结构
动手写代码之前,先做侦察。打开小说的目录页,按F12打开开发者工具,用元素选择器点一下章节链接,看看它的HTML长什么样。
假设我们看到的结构是这样的:
<div id="list"> <dl> <dd><a href="/book/12345/1.html">第一章 开端</a></dd> <dd><a href="/book/12345/2.html">第二章 变故</a></dd> ... </dl> </div>那么提取所有章节链接的代码就是:
soup = BeautifulSoup(html, "lxml") list_div = soup.find("div", id="list") links = list_div.find_all("a") chapter_urls = [] for link in links: href = link.get("href") if href and "/book/" in href: chapter_urls.append(base_url + href)这里加了个"/book/" in href的判断,是为了过滤掉目录页里可能混进来的其他链接,比如"返回首页"、"加入书架"之类的。
实操心得:目录页的链接有时候是相对路径,比如
/book/12345/1.html,需要拼上域名前缀才能访问。用urljoin函数处理更稳妥:
from urllib.parse import urljoin full_url = urljoin(base_url, href)3.2 第二步:定位并提取章节正文
进入章节页,同样先用开发者工具看正文结构。常见的形式是:
<div id="content"> <p>正文第一段</p> <p>正文第二段</p> ... </div>提取代码:
content_div = soup.find("div", id="content") if content_div: paragraphs = content_div.find_all("p") text = "\n".join(p.get_text(strip=True) for p in paragraphs)get_text(strip=True)会把标签里的文字提取出来,并去掉首尾空白。用"\n".join把段落拼起来,保留段落之间的换行。
有些网站的正文不是用<p>标签分段,而是直接用<br>换行,那就得换个方式:
text = content_div.get_text("\n", strip=True)get_text的第一个参数是分隔符,用"\n"就能把各个文本节点用换行连起来。
3.3 第三步:内容清洗的几道工序
抓下来的正文往往不干净,需要过几道清洗工序。
第一道是去广告残留。有些网站会在正文里插"请收藏本站"、"最快更新"之类的句子,这些得用正则去掉:
import re text = re.sub(r"请收藏本站.*?更新", "", text) text = re.sub(r"手机用户请浏览.*?阅读", "", text)第二道是统一换行。抓下来的文本可能有多余的空行,或者换行符不统一(有\n有\r\n),统一处理一下:
text = text.replace("\r\n", "\n").replace("\r", "\n") text = re.sub(r"\n{3,}", "\n\n", text)第三道是去首尾空白。每章正文前后可能有多余的空格或换行,用strip()去掉。
注意:清洗规则要针对具体网站来定,不要写太通用的正则,否则可能误伤正文内容。比如有的小说正文里就有"更新"这个词,你的正则如果写得太宽泛,会把正文也删掉。
3.4 第四步:合并写入文件
所有章节抓完之后,按顺序拼起来,写入txt文件。文件名用小说名,编码用UTF-8,保证各种阅读器都能正常打开。
with open(f"{book_name}.txt", "w", encoding="utf-8") as f: for chapter_title, chapter_text in chapters: f.write(chapter_title + "\n\n") f.write(chapter_text + "\n\n")这里chapters是一个列表,每个元素是(章节标题, 章节正文)的元组。按抓取顺序存进去,写出来就是完整的。
如果章节很多,建议边抓边写,而不是全部抓完再写。这样万一中途出错,已经抓到的部分不会丢。可以用追加模式:
with open(f"{book_name}.txt", "a", encoding="utf-8") as f: f.write(chapter_title + "\n\n") f.write(chapter_text + "\n\n")3.5 完整脚本骨架
把上面的环节串起来,一个可运行的脚本骨架大概长这样:
import requests from bs4 import BeautifulSoup from urllib.parse import urljoin import time import random import re base_url = "https://example.com" catalog_url = "https://example.com/book/12345/" 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", "Referer": base_url } def fetch(url, 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: time.sleep(2) return None def get_chapter_urls(catalog_url): html = fetch(catalog_url) if not html: return [] soup = BeautifulSoup(html, "lxml") list_div = soup.find("div", id="list") if not list_div: return [] urls = [] for a in list_div.find_all("a"): href = a.get("href") if href: urls.append(urljoin(base_url, href)) return urls def get_chapter_content(url): html = fetch(url) if not html: return None, None soup = BeautifulSoup(html, "lxml") title_tag = soup.find("h1") title = title_tag.get_text(strip=True) if title_tag else "" content_div = soup.find("div", id="content") if not content_div: return title, None text = content_div.get_text("\n", strip=True) text = re.sub(r"请收藏本站.*?更新", "", text) text = re.sub(r"\n{3,}", "\n\n", text) return title, text.strip() def main(): chapter_urls = get_chapter_urls(catalog_url) print(f"共找到 {len(chapter_urls)} 章") book_name = "小说名" for i, url in enumerate(chapter_urls): title, content = get_chapter_content(url) if content: with open(f"{book_name}.txt", "a", encoding="utf-8") as f: f.write(title + "\n\n") f.write(content + "\n\n") print(f"第 {i+1} 章完成:{title}") else: print(f"第 {i+1} 章失败:{url}") time.sleep(random.uniform(1, 3)) if __name__ == "__main__": main()这个骨架可以直接拿去改,把base_url、catalog_url、book_name换成实际值,再根据目标网站的HTML结构调整选择器,基本就能跑。
4. 常见问题与排查技巧实录
4.1 抓下来是空白的怎么办
这是最常见的问题,原因通常有三个。
第一,请求被拒了。检查response.status_code,如果是403、404或者503,说明服务器没给你正常内容。403一般是请求头没伪装好,404是URL拼错了,503是服务器过载或者被限流了。
第二,选择器写错了。你以为正文在<div id="content">里,实际上人家改版了,变成了<div class="content">。用开发者工具重新确认一下。
第三,内容是动态加载的。有些网站正文不是直接写在HTML里,而是用JavaScript异步加载的。这种情况下requests抓到的HTML里根本没有正文,只有一个空壳。解决办法是用浏览器自动化工具,或者找到真正的数据接口。不过大多数小说网站还是服务端渲染的,这种情况相对少见。
排查方法很简单:把response.text打印出来,看看里面到底有没有你要的内容。有内容但解析不出来,是选择器问题;没内容,是请求或动态加载问题。
4.2 抓了几章就被封了怎么处理
被封的表现是突然开始返回403,或者返回一个验证页面。这说明你的请求频率太高,触发了站点的防护机制。
应对措施分短期和长期。短期就是加大延时,把time.sleep从1秒调到3秒甚至5秒,然后换个时间再跑。长期来看,可以考虑使用代理IP,但这个涉及成本和复杂度,个人自用的话,加大延时通常就够了。
还有一个技巧是模拟真实用户的访问节奏。不要一上来就连续抓几十章,可以先访问一下首页,再访问目录页,再开始抓章节,让请求序列看起来更像正常浏览。
实操心得:如果目标站点有"分页"的目录,比如目录分成好几页,记得把每一页的链接都抓全。有些脚本只抓了第一页目录,结果只下到前几十章,后面的全漏了。
4.3 章节顺序乱了怎么解决
目录页的链接顺序通常就是章节顺序,但有时候网站会把最新章节置顶,或者有"倒序排列"的选项。如果你直接按find_all返回的顺序抓,可能会乱。
解决办法是从章节标题里提取章节号,然后按章节号排序。比如标题是"第一章 开端",就用正则提取出"一",转成数字,按数字排序。
def extract_chapter_num(title): match = re.search(r"第(.+?)章", title) if match: num_str = match.group(1) # 处理中文数字和阿拉伯数字 # 这里简化处理,实际需要更完整的转换逻辑 return num_str return ""中文数字转阿拉伯数字稍微麻烦点,可以写个映射表,或者用现成的库。如果章节标题里本来就是阿拉伯数字,那就简单了,直接int()转换。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 返回403 | 请求头未伪装 | 检查User-Agent | 设置浏览器UA和Referer |
| 正文空白 | 选择器错误 | 打印HTML确认结构 | 重新定位正文标签 |
| 中文乱码 | 编码未指定 | 查看meta charset | 手动设置response.encoding |
| 抓几章后失败 | 触发反爬 | 观察失败时间点 | 加大延时、降低频率 |
| 章节不全 | 目录分页 | 检查目录页是否有下一页 | 抓取所有分页目录 |
| 章节顺序乱 | 目录倒序 | 对比标题和实际顺序 | 按章节号排序 |
| 脚本卡死 | 未设超时 | 观察卡住的位置 | 设置timeout参数 |
| 文件打不开 | 编码不兼容 | 用不同阅读器测试 | 统一用UTF-8写入 |
4.5 几个我踩过的坑
第一个坑是忽略了robots.txt。虽然个人自用的小规模抓取通常不会有人管,但养成看一下robots.txt的习惯是好的,知道哪些路径是站点不希望被访问的,尽量避开。
第二个坑是把小说名写死在代码里。后来我想抓另一本小说,还得改代码。更好的做法是从目录页的<h1>或者<title>里自动提取小说名,这样脚本更通用。
第三个坑是没有断点续传。抓了一千章,跑到九百章的时候网络断了,重新跑又得从第一章开始。后来我加了个记录已抓章节的功能,把已完成的章节号存到一个文件里,下次跑的时候跳过这些章节。
import os def load_done_chapters(record_file): if os.path.exists(record_file): with open(record_file, "r") as f: return set(f.read().splitlines()) return set() def mark_done(record_file, chapter_num): with open(record_file, "a") as f: f.write(str(chapter_num) + "\n")这个改进虽然简单,但实用性提升巨大,尤其是抓长篇小说的时候。
第四个坑是正文里混入了HTML实体。比如 、&这些,get_text有时候不会自动转换。可以用html.unescape处理一下:
import html text = html.unescape(text)这样 就会变成空格,&变成&,读起来才正常。
5. 进阶优化与扩展思路
5.1 多线程加速的正确姿势
单线程抓一本两千章的小说,按每章2秒算,得一个多小时。用多线程可以把这个时间压缩到十几分钟。但多线程不是无脑开越多越好,线程太多反而容易触发反爬,而且Python的GIL限制了真正的并行。
我的建议是用线程池,控制在5到10个线程。用concurrent.futures实现起来很简单:
from concurrent.futures import ThreadPoolExecutor, as_completed def download_chapter(args): i, url = args title, content = get_chapter_content(url) return i, title, content with ThreadPoolExecutor(max_workers=5) as executor: futures = {executor.submit(download_chapter, (i, url)): i for i, url in enumerate(chapter_urls)} results = [] for future in as_completed(futures): i, title, content = future.result() results.append((i, title, content))注意,多线程下写入文件要加锁,否则多个线程同时写会乱套。或者更简单的做法是先把结果存到内存里,最后统一排序写入。
注意:多线程抓取对目标站点的压力更大,延时不能省。每个线程内部的请求间隔还是要保留,只是整体吞吐量上去了。
5.2 把结果存成其他格式
txt是最简单的,但如果你想要更好的阅读体验,可以存成其他格式。
存成Markdown,章节标题用##,正文分段,这样在支持Markdown的阅读器里看起来更舒服。
存成EPUB,这是电纸书的标准格式,可以用ebooklib库生成。EPUB支持目录跳转、字体调整,阅读体验比txt好很多。
存成JSON,每章一个对象,包含标题和正文,方便后续做其他处理,比如导入数据库或者做全文搜索。
import json chapters_data = [{"title": t, "content": c} for _, t, c in results] with open("novel.json", "w", encoding="utf-8") as f: json.dump(chapters_data, f, ensure_ascii=False, indent=2)ensure_ascii=False是关键,不然中文会被转成\uXXXX的形式,虽然不影响程序读取,但人看着难受。
5.3 做成命令行工具
每次改代码里的URL太麻烦,可以把它做成命令行工具,用argparse接收参数:
import argparse parser = argparse.ArgumentParser(description="小说下载工具") parser.add_argument("url", help="小说目录页地址") parser.add_argument("-o", "--output", default="novel.txt", help="输出文件名") parser.add_argument("-d", "--delay", type=float, default=2.0, help="请求间隔秒数") args = parser.parse_args()这样用起来就是:
python novel_downloader.py "https://example.com/book/12345/" -o "我的小说.txt" -d 3比改代码方便多了,也更容易分享给别人用。
5.4 处理需要登录的站点
有些小说站点需要登录才能看完整内容。这种情况需要带上Cookie。获取Cookie的方法是:在浏览器里登录后,打开开发者工具,在Network标签里找到任意一个请求,复制它的Cookie值。
headers = { "User-Agent": "...", "Cookie": "你的cookie字符串" }Cookie会过期,过期了就得重新获取。如果只是临时用一下,手动复制就行;如果要长期用,可以考虑用requests.Session自动管理Cookie,先模拟登录,再抓取。
注意:涉及登录的站点,抓取频率一定要控制得更低,因为账号被封的代价比IP被封大得多。
5.5 内容去重的思路
有时候同一章会被抓两次,或者目录页里有重复链接。去重的方法很简单,用集合记录已抓的URL:
seen = set() unique_urls = [] for url in chapter_urls: if url not in seen: seen.add(url) unique_urls.append(url)内容层面的去重可以用哈希,把每章正文算个MD5,如果两章的MD5一样,说明内容重复,只保留一个。
import hashlib def content_hash(text): return hashlib.md5(text.encode("utf-8")).hexdigest()这个技巧在合并多个来源的小说时特别有用,能避免重复章节。
6. 一些个人体会
这个脚本我从最开始几十行的简陋版本,慢慢改到现在能稳定跑几千章,中间踩的坑比写代码的时间还多。最大的感受是,爬虫的核心难点从来不是代码本身,而是对目标网站的理解。你得知道它的目录怎么组织、正文放在哪个标签、有没有反爬机制、编码是什么,这些搞清楚了,代码就是水到渠成的事。
另一个体会是尊重目标站点。加延时、控制频率、避开robots.txt里禁止的路径,这些不只是为了不被封,也是一种基本的网络礼仪。个人自用的小规模抓取和恶意爬取是两回事,心里要有这根弦。
最后说个实用的小技巧:抓完一本小说后,先别急着删脚本,把目标网站的域名和对应的选择器配置记下来。下次想抓同一个站点的其他小说,直接换URL就行,选择器都不用改。我现在的脚本里就维护了一个站点配置表,支持好几个常用站点,用起来省事很多。
SITE_CONFIGS = { "site_a.com": { "list_selector": ("div", {"id": "list"}), "content_selector": ("div", {"id": "content"}), "encoding": "gbk" }, "site_b.com": { "list_selector": ("div", {"class": "chapter-list"}), "content_selector": ("div", {"class": "read-content"}), "encoding": "utf-8" } }这个配置表让脚本的通用性上了一个台阶,也让我少写了很多重复代码。如果你也经常抓小说,强烈建议往这个方向优化。