资讯详情

Python爬虫实战:整本小说抓取脚本从零搭建与工程化

📅 2026/10/12 18:16:58 | 华诺云谱 👁 阅读
Python爬虫实战:整本小说抓取脚本从零搭建与工程化
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, headersheaders, timeout10) resp.encoding resp.apparent_encoding # 关键先探测真实编码 soup BeautifulSoup(resp.text, html.parser) # 打印所有链接观察章节链接的规律 for a in soup.find_all(a, hrefTrue)[:30]: print(a.get_text(stripTrue), -, a[href])跑一遍就能看出章节链接有没有统一的前缀规律比如都带/chapter/或者都以数字结尾。这个规律是后面写选择器的依据。2.2 用 CSS 选择器精准锁定章节列表目录页往往还混着“最新更新”“推荐阅读”“分类导航”等一堆链接如果直接find_all(a)会把无关链接也抓进来。正确做法是先用开发者工具定位到包裹章节列表的那个容器再在容器内部取链接。假设章节列表在一个idlist的div里每个章节是dd a代码就是这样chapter_list [] container soup.find(div, idlist) if container: for a in container.find_all(a, hrefTrue): chapter_list.append({ title: a.get_text(stripTrue), 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, headersheaders, timeout10) 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 正文选择器从容器到段落的逐层定位正文提取的核心是找到那个包裹正文的容器通常是idcontent、classcontent或idchaptercontent这类命名。定位到容器后正文往往是一堆br分隔的文本或者包在p标签里。soup BeautifulSoup(html, html.parser) content_div soup.find(div, idcontent) 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, retries3): for i in range(retries): try: resp requests.get(url, headersheaders, timeout10) if resp.status_code 200: resp.encoding resp.apparent_encoding return resp.text except requests.RequestException as e: print(f第 {i1} 次请求失败: {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, encodingutf-8) as f: return set(line.strip() for line in f) def mark_done(progress_file, chapter_url): with open(progress_file, a, encodingutf-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, encodingutf-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, idre.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;q0.9, }Referer设成站点首页很多站点会校验这个字段缺失时直接拒绝。这是实测下来很有效的一个小改动。6.3 关于合规与频率的最后提醒技术本身是中性的但抓取行为要有边界。我的原则是只抓自己需要阅读的内容控制频率不并发轰炸不用于任何形式的再分发。如果站点提供了官方阅读渠道优先用官方渠道。写爬虫练手没问题但心里要有一根弦别给站点造成实际负担。我个人在实际操作中的体会是整本小说抓取最难的不是写代码而是耐心。限速、重试、断点续爬这些机制本质上都是在跟“急”这个心态对抗。把节奏放慢把异常处理做扎实一个几百章的脚本跑下来其实非常稳。反过来如果一味追求速度往往跑不到一半就各种报错最后花的时间反而更多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑