想给朋友抓一本全本小说离线看,结果手动复制了几十章实在顶不住,干脆花了半小时写了个 Python 爬虫脚本,把整本书自动存成了 txt。这个活儿听着简单,实际做起来有不少门道。这篇东西就把完整的思路、代码和坑都摊开讲,适合刚接触 Python 爬虫、想拿“小说下载”这种真实场景练手的人。你不需要特别高深的基础,会装 Python、能跑 pip 就行,剩下的我一步步带。
小说网站算是对新手最友好的爬虫练手对象:目录规律清晰、正文结构统一、反爬强度适中。但也正因为它太典型,很多教程喜欢给你一段复制粘贴就能跑的代码,跑完只会爽那么一下,换一个网站就废了。所以我会先讲分析思路,再给可运行代码,最后说并发和反爬的取舍。
1. 先搞清楚要爬什么:站点分析与请求链路拆解
1.1 别急着写代码,先看页面怎么给你数据
我见过太多新手拿到需求就pip install requests然后开始写循环,结果要么解析不到内容,要么被封 IP,然后跑过来问“是不是我代码有问题”。其实八成不是你语法问题,是你压根没搞明白目标页面是怎么把数据送到你面前的。
爬虫的第一步永远是同一件事:打开浏览器,按 F12,看 Network 面板。这一步省不得。小说网站看着千差万别,但链路就两种:
- 目录页直接返回完整 HTML,正文也直接写在 HTML 里。这种是最理想的,requests 加 BeautifulSoup 就能搞定。
- 目录和正文都是通过 JavaScript 异步加载的,HTML 里是空的,你需要找接口或者上浏览器自动化工具。
我们拿最多见的“目录页 + 正文页”模式来说。它的流程是:访问书籍目录页,HTML 里已经包含了全部章节的标题和链接;再访问每个章节页,HTML 里就是正文内容。没有隐藏接口,没有加密参数,跟 2015 年的网站结构差不多。这种站点最适合新手练手。
在实际操作里,我会先在浏览器里打开目录页,随便点进一章正文,然后用同一句话在目录页响应里搜一下,判断正文是不是直接返回了。这个方法耗时只要一分钟,但能帮你避开后面 80% 的坑。
1.2 用开发者工具定位目录页和正文页的真实请求
打开开发者工具之后,有几个动手细节很重要。
先把 Network 面板底部的 Preserve log 勾上,不然页面跳转的时候请求记录就清了。然后刷新目录页,你会看到很多条目:图片、CSS、JS、文档。不要慌,按类型筛选,只看Doc类型的请求,那个才是真正返回页面 HTML 的请求。
点开这条文档请求,切到 Response 标签页,搜你刚才在正文里看到的一句话。搜得到,说明正文直接包含在 HTML 里,可以走静态爬取;搜不到,那大概率正文是动态加载的,需要另想办法。
然后看请求头(Headers)。记住几个关键字段:
User-Agent:浏览器说自己是谁,很多网站靠这个做第一层过滤。Referer:你从哪个页面跳转过来的,部分小说站会校验这个字段。Cookie:如果章节内容需要登录才能看,这里会带上你的登录凭证。
很多爬虫新手第一版代码 403,就是没带User-Agent,被服务器当成爬虫拒了。这个问题后面单独讲。
1.3 URL规律、链接过滤与编码问题
目录页和正文页的 URL 都有规律可循。举个例子,目录页可能是这种地址:
https://example-novel.com/book/123/点进第一章,地址变成了:
https://example-novel.com/book/123/456.html这里的456是章节 ID,数字通常是递增的。虽然目录页里已经给了完整 URL,有经验的做法是直接从目录页抓链接,而不是自己拼接。自己拼 URL 看起来简单,但万一中间有几章被删了、ID 跳号了,你自己拼出来的地址全是 404,用目录页抓就不会有这种问题。
但也不是目录页里所有链接都能用。小说网站页面上总会混着“返回首页”“排行榜”“上一章/下一章”之类的导航链接,这些链接的 URL 看起来跟正文链接很像。所以在写代码时,要加一层过滤条件,通常做法是:
- 链接以
.html结尾; - 链接路径里包含书籍 ID
/book/123/; - 链接文本(章节标题)长度在合适范围,过滤掉“上一章”“下一章”这类短文本。
再加上 BeautifulSoup 的 CSS 选择器,几行代码就把有效章节筛出来了。
还有一个特别容易踩的坑:编码。小说站很多还用的是 GBK/GB2312 编码,而 requests 默认会用HTTP 头里的 charset去解,大概率猜不准。这种情况你在浏览器里看是正常的,requests 拿回来全是乱码。
我自己的习惯是拿到响应后不依赖 requests 的自动判断,直接用:
resp.encoding = resp.apparent_encodingapparent_encoding是 requests 基于内容编码检测出来的结果,虽然慢一点,但准。如果检测结果还是错的,就手动指定resp.encoding = "gbk"硬解。
2. 环境准备与第一版爬虫代码
2.1 Python环境、虚拟环境与VSCode调试配置
这一步没太多可说的,但有几个细节会影响体验。
Python 版本建议用 3.10 或 3.11,别用太老的 2.7,也别用刚发布的最新版。理由很简单:第三方库兼容性。像lxml这类带编译的库,在太新的 Python 版本上偶尔装不上,折腾环境的时间够你写十遍爬虫了。
项目依赖用虚拟环境隔离,这是我一直坚持的习惯。你不想要全局装一堆乱七八糟的库,就来个干净的虚拟环境:
# 在项目目录下执行 python -m venv venv # Windows 激活 venv\Scripts\activate # Linux / macOS 激活 source venv/bin/activate激活之后,命令行前面会出现(venv)字样。再安装依赖:
pip install requests beautifulsoup4 lxmlbeautifulsoup4是解析 HTML 用的,lxml是它的解析引擎。这里说明一下,html.parser是 Python 自带的解析器,也能用,但容错性和速度不如lxml。解析 HTML 这件事,页面结构越乱越需要 lxml 兜底,所以直接装它。
我用 VSCode 写调试比较多。装好 Python 扩展之后,按Ctrl+Shift+P,执行Python: Select Interpreter,把解释器指到venv下的 Python,这样运行和调试都会用虚拟环境,不会出现“命令行里能跑、编辑器里报模块不存在”的诡异情况。
2.2 第一版代码:解析目录、爬正文、清洗文本
直接给一份能跑通的基本代码。我用的是example-novel.com这个示例域名,纯演示,你本地复现时换成你实际要爬的、允许爬取的站点。
import re 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.0.0 Safari/537.36", "Referer": "https://example-novel.com/book/123/" } CATALOG_URL = "https://example-novel.com/book/123/" def get_chapter_list(catalog_url): """解析目录页,返回章节列表""" resp = requests.get(catalog_url, headers=HEADERS, timeout=10) resp.encoding = resp.apparent_encoding soup = BeautifulSoup(resp.text, "lxml") chapters = [] for a in soup.select("a[href$='.html']"): href = a["href"] # 只保留本书的章节链接,过滤导航链接 if "/book/123/" in href: title = a.get_text(strip=True) # 过滤无意义的短文本,比如“上一章”“下一章” if len(title) >= 4: chapters.append({ "title": title, "url": href if href.startswith("http") else "https://example-novel.com" + href }) return chapters def fetch_chapter(url): """下载单个章节正文,返回清洗后的文本""" resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = resp.apparent_encoding soup = BeautifulSoup(resp.text, "lxml") # 优先找常见的正文容器,页面结构不同可自行调整 content = soup.find("div", id="content") if content is None: content = soup.find("div", class_="chapter-content") if content is None: content = soup.find("article") if content is None: return "" # 去掉脚本、广告等干扰节点 for tag in content.find_all(["script", "style", "ins"]): tag.decompose() # 换成换行符并清理多余空行 text = content.get_text("\n", strip=True) text = re.sub(r"\n{3,}", "\n\n", text) return text if __name__ == "__main__": chapter_list = get_chapter_list(CATALOG_URL) print(f"目录解析完成,共 {len(chapter_list)} 章") # 先测试抓前 3 章 for chapter in chapter_list[:3]: text = fetch_chapter(chapter["url"]) print(chapter["title"], "字数:", len(text))这段代码里几个逻辑说清楚。
get_chapter_list返回的是一个字典列表,每个章节对象包含title和url。这样设计是为了方便后续并发下载和断点续爬,你要把它改成只保存链接也行,但建议一开始就用结构化数据。
fetch_chapter里我优先用几种常见选择器去找正文容器。id="content"是早年小说站用得最多的,class="chapter-content"也挺常见。真实站点可能用别的类名,你在开发者工具里看一下正文节点对应的选择器,改一行就行。
清洗文本时,先把script、style、ins(广告插入块)这些干扰节点删掉,再调用get_text("\n", strip=True)。注意这个写法,get_text的strip=True会把每个文本块的空白都清掉,再配合正则\n{3,}压缩空行,最后拿到的文本就干净了,不会再出现正文里夹着一堆链接或者广告文字的情况。
2.3 跑通后的检查清单
第一版代码跑通之后,不要急着开始全量下载,先做三件事。
第一,抽查章节。随机挑中间和末尾的章节抓一下,看是不是都能正常解析。有些小说站前几章免费、中间章节格式会变,比如“防盗章节”会故意塞乱码。提前发现,比抓完整本才发现要强。
第二,统计字数。上面代码已经打印了每章字数,正常一章网文大概 2000 到 4000 字。如果你发现某章只有几百甚至几十字,那大概率是解析失败了,或者那一章本身就是作者请假条。
第三,检查文本首尾。我把这一条放在清单最后,但它是很多人容易忽略的:清洗后的文本开头有没有多余的“章节名 + 书名”重复,结尾有没有“最新章节请记住本站域名”这类自动植入的文字。如果存在,就在清洗函数里加一条正则替换,把固定前缀后缀去掉。
3. 并发下载:线程池的使用与边界
3.1 单线程为什么慢
用第一版代码串行下载一本 500 章的小说,每章发起一个 HTTP 请求,等服务器响应,再解析保存。请求来回的网络延迟通常在 100ms 到 500ms 之间,解析和保存也就几十毫秒。也就是说,一整本小说的耗时基本上等于网络延迟总和。
打个比方:你去图书馆借书,一次拿一本书,跑回来放下,再跑一次。大部分时间都花在路上。HTTP 请求也是一样的道理,CPU 在等待网络响应的这段时间里完全闲着。所以想要快,就得同时多跑几路请求。这就是并发下载的核心动机。
但这里强调一句:爬虫领域的“快”,永远要建立在“不给目标站点造成压力”的前提下。我们是要下载一本书,不是为了把别人的服务器打挂。所以并发数要克制。
3.2 ThreadPoolExecutor的写法与注意点
Python 里做 IO 密集型并发,最顺手的就是concurrent.futures模块下的ThreadPoolExecutor。它底层是线程池,写法非常简单,不需要自己管理线程生命周期。
配合前面写的fetch_chapter,改造后的下载部分长这样:
import random import time from concurrent.futures import ThreadPoolExecutor, as_completed def download_one(chapter): """下载单章并保存,返回章节信息""" text = fetch_chapter(chapter["url"]) if not text: return {"status": "failed", "chapter": chapter} save_chapter(chapter, text) return {"status": "ok", "chapter": chapter} def save_chapter(chapter, text): """把文本保存为 txt 文件,文件名做安全处理""" # 去掉 Windows 文件名非法字符 safe_title = re.sub(r'[\\/:*?"<>|]', "_", chapter["title"]) filepath = f"{safe_title}.txt" with open(filepath, "w", encoding="utf-8") as f: f.write(text) with ThreadPoolExecutor(max_workers=5) as pool: futures = [pool.submit(download_one, chapter) for chapter in chapter_list] for future in as_completed(futures): result = future.result() if result["status"] == "ok": print("完成:", result["chapter"]["title"])三个细节值得注意。
文件名的安全处理别省略。Windows 下\ / : * ? " < > |这些字符不能出现在文件名里。章节标题里很容易出现?或冒号,不处理,程序会在保存时报错。
as_completed返回的顺序是完成顺序,不是任务提交顺序。你看到的“完成”日志可能是第 100 章先打印、第 1 章后打印。这是因为网络响应时间各不相同。如果你想把 txt 按章节顺序保存成一本完整的书,就不要靠文件名排序,而是给每个章节对象加一个index字段,保存时按 index 重排。
异常处理别漏。网络请求随时可能失败,future.result()会抛出异常,导致整个线程池崩溃。稳妥的做法是在download_one函数内部用try/except包住网络请求,失败时返回失败状态,而不是让异常直接抛到线程池外面。
3.3 限速、重试、连接数配置
并发数不是越高越好。开 50 个线程疯狂请求,服务器响应变慢甚至直接封你 IP 是大概率事件。而且 Python 的 GIL 决定了多线程对 CPU 密集型任务没用,但对 IO 密集型任务(比如网络请求)确实有效,这也是为什么爬虫场景下线程池够用了。
我自己的经验是,小说站这种个人站长站点,max_workers=5已经非常冒进了。更稳妥的是每个线程里加上随机延时,模拟真人翻页的节奏:
import random import time # 每次请求之间随机等待 0.1~0.5 秒 time.sleep(random.uniform(0.1, 0.5))随机延时而不是固定延时,原因很简单:固定间隔本身就是机器行为的特征,随机间隔更像人手操作。
重试机制也不能少。requests 自带的 Session 可以通过HTTPAdapter挂上重试策略:
from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() retry = Retry( total=3, backoff_factor=1, status_forcelist=[500, 502, 503, 504], allowed_methods=["GET"] ) adapter = HTTPAdapter(max_retries=retry, pool_connections=10, pool_maxsize=10) session.mount("http://", adapter) session.mount("https://", adapter)backoff_factor=1表示重试间隔按 1 秒、2 秒、4 秒指数递增。重试三次中间间隔拉开,比一口气连续重试更容易成功,也不会给服务器造成额外压力。把网络请求统一走session,而不是直接用requests.get,这个习惯在爬虫项目越写越大的时候会非常受用。
3.4 并发不是万能药:什么时候不用上分布式
碰到“并发”这个词,很多人会联想到分布式爬虫、消息队列、Redis 任务调度。这里直接泼一盆冷水:下载一本小说,完全不需要那套东西。
我在实际项目里的判断标准就一条:数据量和目标站点规模决定架构复杂度。单机线程池能解决的问题,不要上分布式。分布式爬虫解决的是海量 URL 的管理和调度问题,你只是抓一本书,用列表存几百个 URL 就已经绰绰有余了。上分布式属于拿大炮打蚊子,除了让自己陷入运维泥潭,没有任何收益。
如果你真的对分布式爬虫感兴趣,那也是另一个话题了,需要的不是这个项目的代码改造,而是去学习消息队列和任务调度的设计思路。当前这篇,线程池就是性能和复杂度的最佳平衡点。
4. 反爬机制与应对策略
4.1 请求头:先把User-Agent和Referer配好
我把请求头称为“爬虫的第一道身份证”,但其实它更像是“假装真人”的第一层伪装。
最常被检查的字段是User-Agent,也就是告诉服务器“我用的是什么浏览器”。requests 默认的 UA 是python-requests/x.x.x,服务器一看就知道不是浏览器,直接拒绝或者返回验证码。解决办法很简单,把 UA 换成你当前浏览器的 UA 字符串。在你的浏览器地址栏输入chrome://version就能看到完整的 UA,复制过来用就行。
Referer字段表示“我从哪个页面跳转过来”。很多小说站会校验这个字段,如果你直接访问正文页而没有带上目录页作为来源,会被当成盗链拒绝。所以前面代码里HEADERS我特意加了Referer: https://example-novel.com/book/123/指向目录页。
还有一个容易被忽略的字段是Accept-Language。有些站点会根据语言返回不同版本页面,或者对非中文浏览器 UA 直接拒绝访问。带上中文语言标识,能减少被误判的概率。但别把所有请求头都堆上去,够用就好,堆太多反而显得刻意。
4.2 频率控制:伪装成人而不是机器
爬虫最忌讳的其实是“速度”。人看小说是有节奏的,翻页、停顿、阅读,每一章之间必然有间隔。而爬虫默认的行为是连续请求,中间没有停顿。
应对策略就是前面提到的随机延时。但这里我想再多说一个层次:随机延时不是简单的每个请求之间睡 0.5 秒,而是模拟人类的阅读节奏。比如你抓完目录页,可以先休息 1~2 秒;抓完一章正文,休息 0.2~0.8 秒。时间不要完全固定,用均匀分布或者正态分布来随机生成都比固定间隔好。
有些站点还有更严格的频率限制,比如同一 IP 一分钟内只能请求 N 次。碰到这种情况,单纯靠随机延时不够了,需要主动记录请求次数,自己控制速率。这也是很多爬虫框架里Rate Limiter(限速器)组件做的事情。自己实现一个也不难:维护一个时间戳列表,每次发起请求前检查最近一分钟内的请求次数,超过阈值就 sleep 等待。
4.3 代理IP的简单思路与误区
很多人一听到反爬就想到代理 IP。我要说实话:学习阶段,代理 IP 不是必需品,大多数情况靠降速 + 换 UA 就能解决。
真正需要代理 IP 的场景是:网站专门针对单一 IP 做了高频访问封锁,你降速了也还是封你,因为网站要求每个 IP 的请求频率非常低。这时你才需要考虑维护一个代理池,让每次请求走不同 IP。
用 requests 设置代理很简单:
proxies = { "http": "http://your-proxy-ip:port", "https": "http://your-proxy-ip:port", } resp = requests.get(url, headers=HEADERS, proxies=proxies, timeout=10)实现一个简易代理池的思路是:维护一个可用代理列表,每次随机选一个,请求失败就换下一个。真正要做到高可用,还需要定时检查代理的存活状态和响应速度。但公开代理池的质量参差不齐,很多代理自己都不稳定,实际用起来可能比你直接用自己的 IP 还慢。
所以,如果你不是要抓取海量数据,别花太多时间搞代理。把精力放在控制请求频率、优化解析逻辑上,性价比高得多。
4.4 动态渲染页面的兜底方案:Playwright
有些小说站已经把静态页面改成了 Vue/React 前端渲染,进页面时 HTML 里没有正文,需要 JS 执行完才动态把正文塞进 DOM。这时候requests拿不到任何内容,你 Network 面板里看到的接口可能是加密的,参数还带签名。
应对这种情况,最常见也最无脑的方案是上浏览器自动化工具。如果你是近两年新接触爬虫,我比较推荐直接从 Playwright 入手。它的定位跟 Selenium 差不多,都是驱动一个真实浏览器去访问页面,但 Playwright 的安装体验、API 设计和稳定性都比 Selenium 好很多。
Playwright 的思路特别直观:真的打开一个浏览器,真的等 JS 执行完,然后把最终渲染出来的页面 HTML 交给你。你原来 BeautifulSoup 解析的逻辑全然不用改,只是把“获取 HTML”这一步从 requests 换成了 Playwright:
from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto("https://example-novel.com/book/123/", timeout=30000) # 等待正文容器出现 page.wait_for_selector("#content") html = page.content() browser.close()代价也很明显:慢、重、占内存。一个无头浏览器比一个 HTTP 请求慢十倍以上。所以我的建议是,能先用 requests 解决的问题不要急着上 Playwright;确认页面是动态渲染或者有复杂 JS 加密后再上,它属于兜底方案,不是默认方案。
用 Playwright 的时候有个非常实用的细节:可以用page.wait_for_selector等待某个关键节点出现,而不是用time.sleep傻等。前者是“内容真的加载出来了继续”,后者是“盲等,时间不好把握,等到或等不到都不可控”。
5. 数据落地、断点续爬与合规底线
5.1 先存原始HTML,再离线解析
第一版代码直接下载完就解析成 txt,流程简单,但有一个隐患:如果正文解析逻辑有 bug,你只能重新发起网络请求去下载,浪费网络资源也浪费时间。
更稳的做法是分两步走:第一步,把每个章节页面的原始 HTML 保存下来;第二步,离线解析这些 HTML 文件,提取正文文本。这样网络请求和解析逻辑彻底解耦,任何一步出错都可以单独重跑。
对整本小说来说,原始 HTML 其实占不了多少空间,一章大概几十 KB,500 章也就是几十 MB,完全可以接受。保存文件名可以直接用章节 index 或者 URL 里的章节 ID,不要用完整标题,避免文件名太长或包含非法字符。下载完之后,再遍历所有 HTML 文件做解析。哪怕你不想保留原始 HTML,这个“先落地再处理”的思路也适合所有爬虫项目。
5.2 用JSON做下载状态记录
爬完一整本 500 章的小说,跑到第 400 章的时候网络断了,程序退出。重启程序,如果不做任何状态记录,它会从第一章开始重新爬。你当然可以靠本地文件是否存在来判断跳过,但如果某一章下载失败但文件已经创建了空文件,这个逻辑就有问题。
最省事的做法是用一个 JSON 文件记录下载状态。数据结构类似这样:
{ "https://example-novel.com/book/123/456.html": { "title": "第一章 起点", "downloaded": true }, "https://example-novel.com/book/123/457.html": { "title": "第二章 试探", "downloaded": false } }每次成功下载一章,就把对应 URL 的downloaded设为true,并且立刻写回 JSON。下次启动程序时先加载这个文件,已经下载过的章节直接跳过。这个 JSON 文件本质上就是任务队列的简易版。
5.3 断点续爬代码实现
结合前面几部分,我给一个带断点续爬的最小实现骨架:
import json import os import random import time from concurrent.futures import ThreadPoolExecutor, as_completed PROGRESS_FILE = "progress.json" def load_progress(): if os.path.exists(PROGRESS_FILE): with open(PROGRESS_FILE, "r", encoding="utf-8") as f: return json.load(f) return {} def save_progress(progress): with open(PROGRESS_FILE, "w", encoding="utf-8") as f: json.dump(progress, f, ensure_ascii=False, indent=2) def download_one(chapter, progress): url = chapter["url"] # 已经下载过的跳过 if progress.get(url, {}).get("downloaded"): return False try: text = fetch_chapter(url) if text: save_chapter(chapter, text) progress[url] = {"title": chapter["title"], "downloaded": True} save_progress(progress) time.sleep(random.uniform(0.1, 0.5)) return True except Exception as e: print("下载失败:", url, e) return False if __name__ == "__main__": chapter_list = get_chapter_list(CATALOG_URL) progress = load_progress() with ThreadPoolExecutor(max_workers=5) as pool: futures = [ pool.submit(download_one, chapter, progress) for chapter in chapter_list ] for future in as_completed(futures): future.result() print("全部任务执行完毕")这里有个并发安全的小细节要提醒。多个线程同时读写同一个 JSON 文件,理论上存在写冲突。但实际跑下来,只要你的请求量不大,这个冲突的概率非常非常低。真要做到完全安全,得引入文件锁或者把它放进队列里统一串行写入,那对这个项目来说就过度设计了。知道这个问题的存在,心里有数就行。
5.4 关于版权:爬之前先想想这些
技术是中性的,但技术怎么用是有立场的。小说是有版权的作品,未经授权批量下载并传播,不管是出于什么目的,都会对原作者和正版平台造成伤害。这篇博文的代码和思路,我只建议用在下面这几种场景:
- 你自己写的或者你拥有版权的文字内容;
- 作者或平台明确允许下载、抓取的公开内容;
- 个人学习爬虫技术,在本地测试环境里用少量章节做功能验证。
不要拿这个代码去爬正版付费章节,不要爬完传到网盘或社交媒体上“分享”。我见过太多人因为“这个站免费,不爬白不爬”就把整站内容打包带走,这跟偷东西没什么区别。爬虫这项本事,应该用在正道上,比如采集公开的行业数据、监控商品价格变化、整理个人笔记。技术能力是有价值的,但前提是你得先想清楚边界在哪。
我在实际做爬虫项目的时候,有个一直保留的习惯:代码里写清楚数据来源、抓取目的和数据保留时间。这个东西看着不起眼,但等你回看几个月前的爬虫脚本时,你会庆幸自己当时留了这些记录。它能帮你在项目变成灰色地带之前及时刹车。