ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

手把手教你用Python爬虫下载全本小说为TXT

手把手教你用Python爬虫下载全本小说为TXT 想给朋友抓一本全本小说离线看结果手动复制了几十章实在顶不住干脆花了半小时写了个 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 扩展之后按CtrlShiftP执行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, headersHEADERS, timeout10) 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(stripTrue) # 过滤无意义的短文本比如“上一章”“下一章” 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, headersHEADERS, timeout10) resp.encoding resp.apparent_encoding soup BeautifulSoup(resp.text, lxml) # 优先找常见的正文容器页面结构不同可自行调整 content soup.find(div, idcontent) 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, stripTrue) 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里我优先用几种常见选择器去找正文容器。idcontent是早年小说站用得最多的classchapter-content也挺常见。真实站点可能用别的类名你在开发者工具里看一下正文节点对应的选择器改一行就行。清洗文本时先把script、style、ins广告插入块这些干扰节点删掉再调用get_text(\n, stripTrue)。注意这个写法get_text的stripTrue会把每个文本块的空白都清掉再配合正则\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, encodingutf-8) as f: f.write(text) with ThreadPoolExecutor(max_workers5) 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_workers5已经非常冒进了。更稳妥的是每个线程里加上随机延时模拟真人翻页的节奏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( total3, backoff_factor1, status_forcelist[500, 502, 503, 504], allowed_methods[GET] ) adapter HTTPAdapter(max_retriesretry, pool_connections10, pool_maxsize10) session.mount(http://, adapter) session.mount(https://, adapter)backoff_factor1表示重试间隔按 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, headersHEADERS, proxiesproxies, timeout10)实现一个简易代理池的思路是维护一个可用代理列表每次随机选一个请求失败就换下一个。真正要做到高可用还需要定时检查代理的存活状态和响应速度。但公开代理池的质量参差不齐很多代理自己都不稳定实际用起来可能比你直接用自己的 IP 还慢。所以如果你不是要抓取海量数据别花太多时间搞代理。把精力放在控制请求频率、优化解析逻辑上性价比高得多。4.4 动态渲染页面的兜底方案Playwright有些小说站已经把静态页面改成了 Vue/React 前端渲染进页面时 HTML 里没有正文需要 JS 执行完才动态把正文塞进 DOM。这时候requests拿不到任何内容你 Network 面板里看到的接口可能是加密的参数还带签名。应对这种情况最常见也最无脑的方案是上浏览器自动化工具。如果你是近两年新接触爬虫我比较推荐直接从 Playwright 入手。它的定位跟 Selenium 差不多都是驱动一个真实浏览器去访问页面但 Playwright 的安装体验、API 设计和稳定性都比 Selenium 好很多。Playwright 的思路特别直观真的打开一个浏览器真的等 JS 执行完然后把最终渲染出来的页面 HTML 交给你。你原来 BeautifulSoup 解析的逻辑全然不用改只是把“获取 HTML”这一步从 requests 换成了 Playwrightfrom playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(https://example-novel.com/book/123/, timeout30000) # 等待正文容器出现 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 其实占不了多少空间一章大概几十 KB500 章也就是几十 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, encodingutf-8) as f: return json.load(f) return {} def save_progress(progress): with open(PROGRESS_FILE, w, encodingutf-8) as f: json.dump(progress, f, ensure_asciiFalse, indent2) 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_workers5) 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 关于版权爬之前先想想这些技术是中性的但技术怎么用是有立场的。小说是有版权的作品未经授权批量下载并传播不管是出于什么目的都会对原作者和正版平台造成伤害。这篇博文的代码和思路我只建议用在下面这几种场景你自己写的或者你拥有版权的文字内容作者或平台明确允许下载、抓取的公开内容个人学习爬虫技术在本地测试环境里用少量章节做功能验证。不要拿这个代码去爬正版付费章节不要爬完传到网盘或社交媒体上“分享”。我见过太多人因为“这个站免费不爬白不爬”就把整站内容打包带走这跟偷东西没什么区别。爬虫这项本事应该用在正道上比如采集公开的行业数据、监控商品价格变化、整理个人笔记。技术能力是有价值的但前提是你得先想清楚边界在哪。我在实际做爬虫项目的时候有个一直保留的习惯代码里写清楚数据来源、抓取目的和数据保留时间。这个东西看着不起眼但等你回看几个月前的爬虫脚本时你会庆幸自己当时留了这些记录。它能帮你在项目变成灰色地带之前及时刹车。
返回列表