简介:一套基于Python实现番茄小说TXT免费下载的自动化工具包,主要面向需要批量采集小说章节、离线阅读或持续追踪更新的读者,也适合刚接触爬虫的Python学习者作为练手项目。压缩包共3个文件,包含1个Python脚本、1个可直接运行的exe程序以及1个txt书目列表,总大小15.47MB。脚本中涉及requests网络请求、BeautifulSoup页面解析、for循环遍历章节、异常处理以及多线程提速等关键知识点,能够完整演示从获取小说页面到保存为本地txt文件的流程;exe文件让不熟悉编程的用户也能直接使用;txt文件则可用于记录图书清单或章节结构。已有7083人学习下载,说明该工具需求广泛。通过研读这套工具包,可学会构建定时检测新章节的更新机制、规范文件命名以及处理网络中断等健壮性技巧,并将这套思路迁移到其他小说网站,形成可复用的下载模板。
1. Python 批量下载某小说平台 TXT:增量更新到底是怎么做到的
追书的人应该都干过这种事:在阅读 App 里看到一本连载小说,想把前面几十章导出来做成 TXT,放到墨水屏阅读器或者本地书架里慢慢看。手动复制章节的效率实在太低,于是就有了这套基于 Python 的小说 TXT 免费下载脚本。它不是那种让你付完费就丢在网盘的静态资源,本质是一个能自己跑起来的抓取工具:输入书号,脚本先去解析目录接口拿到全本章节列表,再逐个请求正文并清洗成 TXT 落盘。所谓“永久更新”,靠的是脚本内置的最后更新时间戳和章节序号对比逻辑,原站更新章节时,脚本只拉新增内容,以增量方式追加到本地文件,不会反复抓全文。适合有一点 Python 基础、想解放双手的普通读者,也适合需要批量归档书库的本地阅读玩家。
2. 先搞清楚抓取链路:目录接口与正文接口的参数、签名和限频边界
把整套资源拆开看,其实就两件事:目录接口负责把一本书的章节结构拉到本地,正文接口负责按章节取正文。无论脚本界面做得多漂亮,追根到底都是这两个请求的组合。我在确认一个小说下载资源能不能用的时候,第一件事不是直接跑代码,而是先看它对接口的封装对不对,因为这一步决定了后面所有功能的地基。
2.1 目录接口与正文接口拆开请求,为什么更稳
从结构上讲,一本连载小说有几百上千章,如果每一章都先去请求一次目录再请求正文,请求量直接翻倍,还特别容易在目录请求偶尔超时的时候把整个任务搞乱。这类脚本的通用做法是两段式:先请求一次目录接口,把书号、章节 id、章节标题和发布时间存成一个本地列表,然后第二段循环按章节 id 去请求正文接口。这样目录只请求一次,后面哪怕某章失败了,重新跑脚本也能靠章节 id 直接重试,不依赖目录请求。
在我实际拆过的资源包里,适配层是整个脚本最关键的模块。目录接口往往返回的是整个章节树的 JSON,而正文接口返回的则是带有很多埋点字符的 HTML 片段。也就是说,目录接口拿的是“地图”,正文接口才拿“正文”,两者分开处理比揉在一个大函数里更稳。这也直接决定了后续能不能做增量更新:知道当前章节在所有章节中的序号和最近更新时间,就能判断哪些章节是新增的。
对应到脚本文件,一般会拆成三个文件而不是一个巨无霸脚本:config.py 放书号和请求头,adapter.py 负责目录与正文两个接口的参数拼接和解析,downloader.py 负责调度。这套资源里最值得研究的其实不在下载循环,而在 adapter 层怎么处理分页参数。部分平台的目录接口默认只回前 200 条,拿全部章节需要按 page 字段翻页,脚本里会把每页返回的 has_more 字段作为循环条件,直到最后一页为止。漏了这一步,下载下来的就只有一部残缺书。
2.2 签名、UA、Referer 与 Cookie:模拟正常浏览器访问的前提
接下来是必踩的环节:正文接口不是裸奔的。直接拿 requests.get 去请求正文 URL,大概率返回一个空壳,或者只有第一章试读内容。
常见做法是先在浏览器里登录某小说平台,手动复制 Cookie,填入脚本的配置文件。目录接口有些情况下匿名也能访问,但正文接口在访问频率稍微高起来之后,就会开始校验 UA 和 Referer,极端情况下还会校验接口签名。签名这部分在通用方案里有现成实现,一般是根据页面内嵌的固定算法把时间戳和书号拼接后做一次摘要运算。不建议自己临时从头啃这个加密过程,用资源里已经调通的 adapter 模块更稳妥,你只需要关注哪些参数需要按实际情况改。
我整理一份请求头参数清单,方便你对照着填:
| 请求头 | 默认值示例 | 是否需要改 | 说明 |
|---|---|---|---|
| User-Agent | Mozilla/5.0 (Windows NT 10.0; Win64; x64) | 一般不用改 | 伪装成正常浏览器访问 |
| Referer | 小说详情页 URL | 需要改 | 指向当前书对应的详情页地址 |
| Cookie | 登录会话值 | 需要改 | 跳过正文截断,识别为登录用户 |
| x-requested-with | XMLHttpRequest | 不用改 | 部分接口要求标记为异步请求 |
| sign | 动态生成的签名字符串 | 动态计算 | 由 adapter 模块按当前时间戳生成 |
如果你发现请求正文时接口报“签名错误”,大概率不是你的 Cookie 有问题,而是签名参数过期。这类签名通常有时间窗限制,比如 60 秒内有效,脚本里必须每次请求前重新生成,不能把签名值写死在配置里。我之前见过一个改配置的版本,把签名固定成字符串,结果凌晨跑任务时第一个章节成功,后面的全部失败,就是因为签名已经过期被服务端拒了。
Cookie 的获取也有一点讲究:不要从移动客户端复制,要从网页端复制。移动客户端过来的 Cookie 往往少了几个关键字段,导致接口认为你来自非法设备。我一般是打开浏览器开发者工具,切到 Network 面板,随便点开一个正文请求,直接从请求头里把完整的 Cookie 字符串复制出来,整段填进配置文件,不手动裁剪。
2.3 本地元信息表:永久更新的核心数据结构
永久更新听起来像玄学,其实数据结构简单得吓人。脚本会在本地生成一个 novel_meta.json 文件,记录每本书的抓取进度,类似于一个轻量级数据库。每次下载脚本启动时,先读这个文件,再决定哪些章节要抓、哪些章节可以跳过。
| 字段名 | 类型 | 用途 |
|---|---|---|
| book_id | int | 小说平台内部书号 |
| book_title | str | 书名,用于文件命名 |
| update_time | float | 最近一次成功更新的时间戳 |
| status | str | running / completed / paused |
| last_chapter_id | str | 已抓取文本中最后一章的章节 id |
| last_chapter_hash | str | 最后一章正文内容的哈希值,用于校验完整性 |
| chapter_ids | list | 已成功写入 TXT 的章节 id 列表 |
这里的 last_chapter_hash 是防止“假更新”的关键。有些连载小说章节会被作者修改或者替换,单纯比对章节 id 会发现新旧章节 id 完全一样,但内容变了。这时候只有比对正文哈希值才能发现差异。我之前遇到过一本书,作者把第 200 章重写了,目录接口返回的章节 id 没变,但正文内容变了,如果没有哈希比对,增量更新就会漏掉这次修订。
文件存储路径也建议固定成小说名加书号的组合,比如《某本小说_10001.txt`。因为平台上的书名可能有重名,但书号是唯一的,带上书号以后即使小说改名了,本地文件也能对上号。有些脚本用 cookie 里的用户信息作为子目录名,这就会造成换个账号登录后找不到原来下载目录的尴尬局面。
3. 跑通下载主流程:依赖装好后按书号抓全书
前两章把原理讲清楚了,这章就直接动真格。拿到这套 Python 资源后,你需要补几个依赖库、改一份配置文件,然后就能按书号把整本书抓到本地。
3.1 环境准备与依赖安装
这是一个 Python 项目,不是打包好的 exe,所以需要本机装 Python 3.10 以上版本。打开终端,先建虚拟环境,再安装依赖:
mkdir novel_downloader cd novel_downloader python -m venv venv source venv/bin/activate # Windows 下执行 venv\Scripts\activate pip install requests beautifulsoup4 lxml依赖只有三个,解释一下各自职责:requests 负责发起 HTTP 请求,把目录和正文从接口拉下来;beautifulsoup4 负责解析正文 HTML,从中提取干净的段落文本;lxml 是 beautifulsoup4 的解析引擎,解析速度比默认的 html.parser 快,处理几百章内容时能明显感觉到差距。
装完之后,把资源包里的 adapter.py、downloader.py、update_novel.py 复制到当前目录,再把 config.example.py 重命名为 config.py。config.py 就是你唯一需要手动编辑的文件,里面有明确的注释标注填书号和 Cookie 的位置。我一般不建议在这个文件里直接写死请求头,更稳的做法是让 config.py 从环境变量里读关键参数,防止脚本内容被传到别人手里时泄露账号信息。
3.2 按书号下载全本的主流程代码
看一段简化后的核心逻辑,这段代码的思路和资源里下载器主循环基本一致:
# download_novel.py import time import requests from bs4 import BeautifulSoup from config import HEADERS, COOKIE, API_CATALOG, API_CONTENT from adapter import build_catalog_url, build_content_url DOWNLOADED = set() def fetch_catalog(book_id: str) -> list: """拉取全书目录,返回章节信息列表""" url = build_catalog_url(API_CATALOG, book_id) resp = requests.get(url, headers=HEADERS, cookies=COOKIE, timeout=10) resp.raise_for_status() data = resp.json() chapters = data["data"]["chapters"] # 部分接口做分页,这里用 has_more 判断是否要继续翻页 page = 1 while data.get("data", {}).get("has_more", False): page += 1 url = build_catalog_url(API_CATALOG, book_id, page=page) resp = requests.get(url, headers=HEADERS, cookies=COOKIE, timeout=10) data = resp.json() chapters.extend(data["data"]["chapters"]) return chapters def fetch_content(book_id: str, chapter_id: str) -> str: """按章节 id 拉取正文 HTML""" url = build_content_url(API_CONTENT, book_id, chapter_id) resp = requests.get(url, headers=HEADERS, cookies=COOKIE, timeout=10) resp.raise_for_status() return resp.json()["data"]["content"] def clean_html(html_content: str) -> str: """把正文 HTML 转成纯文本""" soup = BeautifulSoup(html_content, "lxml") for tag in soup.find_all(["script", "style"]): tag.decompose() text = soup.get_text("\n", strip=True) return text def save_txt(book_title: str, chapter_title: str, content: str): """追加写入 TXT,每章之间用换行分隔""" with open(f"{book_title}.txt", "a", encoding="utf-8") as f: f.write(f"\n\n{chapter_title}\n\n") f.write(content) def main(book_id: str, book_title: str): chapters = fetch_catalog(book_id) for idx, ch in enumerate(chapters, 1): cid = ch["chapter_id"] # 已经在本地集合里就跳过,保证断点可续 if cid in DOWNLOADED: continue content = fetch_content(book_id, cid) text = clean_html(content) save_txt(book_title, ch["chapter_title"], text) DOWNLOADED.add(cid) print(f"[{idx}/{len(chapters)}] {ch['chapter_title']} 完成") time.sleep(0.5) # 每章间隔 0.5 秒,降低触发频控的概率 if __name__ == "__main__": main(book_id="10001", book_title="某本小说")代码里的 fetch_catalog 函数做了两件事:第一是获取未知的目录列表,第二是根据 has_more 字段不断翻页直到所有章节落入内存。部分版本会把这段逻辑放在 adapter 里封装,但核心循环不变,就是 while 循环加 extend。
fetch_content 单独提出来是因为它的失败率最高。接口签名过期、请求频控、Cookie 失效都会在这里体现。如果某章连续失败三次,脚本不会立即终止,而是把失败章节记入 fail_list.json,等整本书抓完之后再统一重试,避免因为一章的问题断了全书。
DOWNLOADED 集合是断点续传的命根子。如果你在下载过程中按了 Ctrl+C 中断,下一次重新运行时,脚本会先扫描同名 TXT 里已有的章节标题,把标题存进 DOWNLOADED,然后才进入主循环。这一招避免了重复下载大量章节,省下的流量和时间非常可观。
time.sleep(0.5) 这个参数值得多说一句。0.5 秒是我在多数情况下的起步值,不是最优值。如果只是下载几十章,0.3 秒问题不大;如果是全本上千章且连续跑,建议调到 1 秒以上。延长延时不会让下载慢到难以接受,但能显著降低被服务端临时封禁的概率。
3.3 正文清洗与 TXT 落盘规范
清洗这一步决定成品质量。直接用 requests 拿到的正文 HTML 里,经常夹杂着页面底部其他书籍的推荐位、特殊表情符号、版权声明,甚至有些章节末尾会带上下一章的试读片段。如果不清洗,你得到的 TXT 会是一锅粥。
清洗的通用规则是按顺序执行:先移除 script 和 style 标签,再移除所有 a 标签和图片标签,然后按段落分组,最后过滤只包含空格或特殊字符的空行。正则的写法如下:
import re def clean_final_text(raw_text: str) -> str: # 去掉连续空行,把段落间距收敛到一个空行 text = re.sub(r"\n{3,}", "\n\n", raw_text) # 去掉常见的“本书首发”占位文本 text = re.sub(r"本章未完,请点击下一页继续阅读", "", text) # 去掉页面推荐位留下的多余分隔符 text = re.sub(r"推荐一本书:【.*?】", "", text) return text.strip()这段正则看起来简单,但处理连载小说时很实用。第一行把三个以上连续换行收敛成两个,避免生成的文件出现大片空白区域;第二行和第三行都是针对平台正文常见埋点的定向清理。不同平台的占位文本不一样,你可以根据实际抓取结果自行补充。
TXT 落盘格式建议统一成三行模式:卷名、章名、正文段落。卷名只在卷首出现,章名前加两个换行符,正文段落之间保留一个空行。这样导出的文件在电纸书阅读器里能自动生成简单目录,翻页体验会好很多。文件编码强制用 UTF-8,不要用 GBK 或默认编码,因为 UTF-8 对繁体字、特殊符号的支持更好。
脚本最后还会做一次“全书校验”:统计 TXT 的总行数,和目录接口返回的章节总数做个除法,算每章平均行数。如果某本 300 章的书平均每章只有两行,那基本能确定清洗规则把正文段落误删了,需要回看清洗正则。
4. 常见问题避坑:403、假章节、乱码与中断恢复的翻车现场
写爬虫类的下载工具,最不缺的就是翻车现场。这一章把所有能想到的坑按照“现象 -> 原因 -> 解决”写成踩坑记录,你遇到问题时直接对照排查。
4.1 能取到目录但正文全部为空字符串
现象:脚本正常打印出章节列表,进度条也在走,但生成的 TXT 文件只有章节标题,正文部分全空。检查接口返回,发现 content 字段是空字符串。
原因:正文接口校验了 Cookie 或者签名。匿名请求可以被目录接口放行,但正文接口要求身份有效,否则只返回一个空壳。
解决:先从浏览器开发者工具里复制完整 Cookie,确认 config.py 里没有漏字段。然后重点检查签名参数是否每次请求前动态生成。还有一个隐蔽点:部分正文接口要求请求头里带上 x-requested-with 字段,值设为 XMLHttpRequest,不加这个字段也会被反爬识别为脚本请求。
4.2 下载到一半报 403,已经抓下的章节如何保留
现象:前 80 章正常下载,第 81 章开始连续报 403,脚本最终中断。重新运行脚本,发现从头开始扫描最新章节,已经下载的章节虽然被跳过,但整个流程要重新拉取目录。
原因:短时间请求太密集,服务端按 IP 维度做了频控。403 响应的响应头里通常带 Retry-After 字段,有些版本会忽略这个字段继续猛冲,导致封禁时间被拉长。
解决:第一次遇到 403 时立即进入冷却状态,暂停当前线程,等待至少 60 秒再重试,之后采用指数退避策略。已下载章节因为写入时做了 DOWNLOADED 记录,不会被重复下载,所以不用删掉任何文件,直接在退避结束后继续跑脚本即可。
4.3 章节乱序与重复写入
现象:一本 500 章的书下载完成后,TXT 里第 150 章出现了两次,第 100 章反而出现在第 200 章后面。
原因:用了并发下载却没给章节排序。多个线程同时写入同一个 TXT 文件,后写的线程覆盖了先写的章节内容,或者写入顺序没有按章节序号排列。
解决:主循环里强制按目录返回的章节序号顺序写入,不要使用线程池。即使要用并发,也只能把每个章节的内容下载到内存,最后按序号统一排序写入文件,绝不能多个线程直接追加写同一个 TXT。写入前再查一次 DOWNLOADED 集合,确保任何入口都不会重复写入。
4.4 繁体、特殊符号与乱码问题
现象:个别章节出现乱码,比如“的”变成“锝”,或者整个章节变成繁体与简体混杂,无法正常阅读。
原因:部分平台正文接口返回的是 Base64 编码后的文字,脚本直接当普通文本解析,解码之后自然错误。繁体混杂通常是因为原文本来就是台湾繁体输入,平台未做统一转简体处理。
解决:在 adapter.py 的 content 解析层先做一次 Base64 判断,如果内容中大量出现 + / = 字符并且无法解析为 HTML,就先用 base64.b64decode 解码再交给 BeautifulSoup。繁体转简体可以用 opencc 库,但有些地名和专有名词会被误转,所以我会在配置里加一个开关,默认保持原文,只有需要统一字体时才打开转换。
4.5 请求延时与频控参数建议
为了避免走到 403 那一步,直接在参数层面规避风险更划算。我整理一组常用于参考的下限值,不是一定按这个来,但至少能保证长时间运行不触发硬性封禁:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 每章间隔 sleep | 0.8 ~ 1.5 秒 | 越接近真实阅读速度越安全 |
| max_retry | 3 次 | 单章失败重试上限,超过即跳过并记录 |
| base_delay | 2 秒 | 第一次重试前的等待时间 |
| max_delay | 60 秒 | 重试等待时间的上限,防止指数增长失控 |
| jitter | 0.1 ~ 0.3 倍 | 在基础延时上增加随机抖动,避免请求节奏过于规律 |
睡眠时间的随机抖动很重要。如果每 1 秒整一次请求,这个节奏本身就会被识别为脚本特征;加上 0.4 秒以内的随机偏移,模拟出人的操作节奏,比固定的 sleep 更不容易触发风控。这套参数和网络带宽无关,只影响请求频率,可根据实际网络状况微调。
5. 把“永久更新”落地:增量逻辑、定时任务与完整性校验
前面几章把下载主流程跑通了,但离“永久更新”还差一步。真正好用的资源,一定在脚本里实现了增量更新模块,否则每次想等连载更新都要重新扫全本,费时费力还容易被封。
5.1 增量更新:只拉新增章节
增量更新的实现思路不复杂。脚本启动时读 novel_meta.json,取出 last_chapter_id 和 update_time,然后请求目录接口拿到全本最新章节列表。从最新的一章开始往前遍历,直到某个章节的更新时间早于本地记录的最后更新时间,就停止遍历,这段区间内的章节就是需要新增的章节。
# update_novel.py import json import time def read_meta(book_id: str) -> dict: with open(f"{book_id}_meta.json", "r", encoding="utf-8") as f: return json.load(f) def write_meta(book_id: str, meta: dict): with open(f"{book_id}_meta.json", "w", encoding="utf-8") as f: json.dump(meta, f, ensure_ascii=False, indent=2) def update_book(book_id: str, book_title: str): meta = read_meta(book_id) last_chapter_id = meta.get("last_chapter_id") chapters = fetch_catalog(book_id) new_chapters = [] for ch in chapters: if ch["chapter_id"] == last_chapter_id: break new_chapters.append(ch) # 反转列表,让新章节按从小到大的顺序写入 new_chapters.reverse() for ch in new_chapters: content = fetch_content(book_id, ch["chapter_id"]) text = clean_html(content) save_txt(book_title, ch["chapter_title"], text) meta["last_chapter_id"] = ch["chapter_id"] meta["update_time"] = time.time() # 每更新一章就写一次元信息,防止中途中断后丢进度 write_meta(book_id, meta) print(f"新增章节:{ch['chapter_title']}")这段代码里用了反向遍历和 break 的思路。从最新章节列表的头部开始找,一旦命中本地最后章节就停止,这样即使中间有章节被作者删除,也不会影响整体遍历逻辑。如果本地最后章节在最新章节列表中已经不存在,说明作者删过章节,这时候会触发一次全量重新比对,不做增量。
每更新一章就写一次元信息是特别容易忽略的点。很多人习惯全部章节更新完再写 meta,结果中途网络断了或程序崩了,meta 还停留在旧进度,下次启动又把已经写入的章节重新拉一遍。边写 TXT 边写 meta,即使中途出问题,下次也能从断点继续,这是我在多次断点恢复之后形成的习惯。
5.2 定时自动更新:用系统计划任务把脚本挂起来
更新逻辑有了,剩下就是定时触发。Linux 上用 crontab 是标准操作,Windows 上也可以用计划任务程序,核心命令只有一行:
# 每天凌晨 2 点执行一次增量更新 30 2 * * * cd /home/your_user/novel_downloader && /home/your_user/novel_downloader/venv/bin/python update_novel.py >> run.log 2>&1注意 cron 表达式里30 2 * * *表示每天凌晨 2 点 30 分执行,这个时间段源站流量低,接口响应快,也相对不容易触发风控。>> run.log 2>&1会把脚本的所有输出和错误信息追加写入 run.log,这个日志文件非常重要,第二天起来看一眼就能知道昨晚更新时间失败了。
Windows 计划任务创建时有个坑:要在“操作”里设置“程序或脚本”为 venv 目录下的 python.exe 绝对路径,而不是直接写 python,否则计划任务运行时可能找不到 Python 解释器。另外,触发条件不要勾选“只有在计算机使用交流电时才启动此任务”,否则笔记本拔电状态下更新任务会被跳过。
5.3 完整性校验:每次更新后的最后一步
增量更新写完后,我总会用一段校验逻辑确认 TXT 没有缺章节。最简单的办法是把 TXT 里的章节标题全部提出来,和目录接口返回的章节标题列表做一次集合差运算:
grep -c "^第.*章" 某本小说.txt # 和目录接口返回的总章节数对比,数值不一致就说明有漏章更精确的校验是统计 TXT 中每个章节的字符数。如果某章明显比其他章节短很多,比如其他章节平均 2000 字,这一章只有 50 字,大概率是正文请求失败后写入了一个占位文本。这类情况不会报错,但会严重影响阅读体验,只能靠字符数异常检测发现。
关于哈希校验,我在前面的元信息表里提过 last_chapter_hash,这个字段在校验时也有用:每次增量更新完成后,重新计算最后一章的正文哈希,如果和 meta 里记录的不一致,说明这个章节在写入后被人为修改过,需要二次检查。从那以后,我每次更新完都会强制走一遍“章节总数对比 + 末章哈希对比”这两步,确认无误才把脚本挂到计划任务里静默运行。这也算是一个执念,但确实帮我挡住了不少半夜静默更新带来的坏数据。希望这篇拆解能帮你在自己的下载脚本里少踩几个坑,真正把这套 Python 资源用顺手。
本文还有配套的精品资源,点击获取