1. 为什么我选择用 Scrapy 来啃下整站小说
1.1 从“手动复制粘贴”到“全站自动化”的转折点
最早接触小说站采集,其实动机特别朴素——追更太累。很多小说站章节分页做得稀碎,一章拆成好几页,翻页翻到手酸,广告还满天飞。后来想着干脆自己写个脚本把整本站的内容拉下来,存成纯文本,用本地阅读器看,清爽又省事。一开始用的是requests + BeautifulSoup那套组合拳,写个几十行也能跑,但一旦目标从“单本小说”变成“整个书库”,问题就全冒出来了:请求去重、失败重试、并发控制、断点续爬、数据落库……每一样都得自己手搓,代码越写越像在造一个残缺的框架。
Scrapy 就是在这个节点进入视野的。它本质上是一个“为大规模抓取而生”的异步爬虫框架,把请求调度、去重、中间件、管道、限速这些脏活累活全都封装好了,你只需要专注三件事:从哪个页面拿什么数据、怎么翻页、数据往哪存。对于“爬取小说网所有小说”这种典型的整站采集任务,Scrapy 的工程化优势几乎是碾压性的。
这篇文章我会把整个项目从零到跑通的完整思路拆开讲,包括站点结构怎么分析、Spider 怎么写、翻页逻辑怎么设计、数据怎么清洗入库、反爬怎么应对、断点续爬怎么做。适合已经会一点 Python、想从“写脚本”进阶到“做工程”的读者,也适合手上有整站采集需求、正在纠结工具选型的朋友。
1.2 整站采集和单页采集,难度差在哪
很多人低估了“所有小说”这四个字的分量。单页采集,你面对的是一个静态 HTML,解析规则写死就行。整站采集,你面对的是一个层级结构 + 动态分页 + 海量请求的组合体。
先看层级。一个典型的小说站,结构大致是这样的:
- 首页 → 分类页(玄幻、都市、历史……)
- 分类页 → 小说详情页(书名、作者、简介、状态)
- 详情页 → 章节列表页(可能分页)
- 章节列表页 → 具体章节内容页(可能分页)
这意味着你的 Spider 要处理至少四种页面类型,每种页面的解析逻辑不同,还要在它们之间建立正确的跳转关系。如果章节列表本身还分页,那翻页逻辑就得嵌套两层。
再看量级。一个中等规模的小说站,几千本书、每本几百到几千章,总请求量轻松上百万。这个量级下,同步请求的脚本基本没戏,必须上异步;同时你还得考虑对目标站点的压力,不能把人家打挂——这既是技术问题,也是基本的网络礼仪。
最后是数据一致性。爬到一半断了怎么办?某本书爬到第 300 章挂了,重跑的时候是从头来还是接着爬?重复的章节怎么去重?这些问题在单页采集里根本不存在,但在整站采集里是必须解决的。
2. 动手前的站点结构分析与采集边界确认
2.1 先摸清目标站的三层结构,别急着写代码
我见过太多人一上来就打开编辑器写 Spider,结果写到一半发现翻页规则不对,推倒重来。正确的顺序是:先用浏览器把站点结构摸透,画出页面跳转图,再动手写代码。
具体怎么做?打开目标小说站,从首页开始,手动点进去,记录每一层的 URL 规律。比如:
- 分类页:
/sort/1/、/sort/2/,数字代表分类 ID - 详情页:
/book/12345/,数字是书籍 ID - 章节列表:
/book/12345/list/,可能带分页参数?page=2 - 章节内容:
/book/12345/67890.html,数字是章节 ID
把这些 URL 规律记下来,你就有了整个站点的“地图”。这一步花半小时,能省掉后面几小时的调试。
提示:分析站点时优先看 URL 规律,而不是依赖页面上的按钮。按钮的跳转逻辑可能藏在 JavaScript 里,但 URL 规律是稳定的,直接构造 URL 比模拟点击可靠得多。
2.2 采集边界:哪些该爬,哪些不该碰
“爬取所有小说”听起来很爽,但实际操作时必须给自己划边界,否则很容易踩坑。
第一,只采集公开可访问的正文内容,不碰任何需要登录、付费才能看的章节。这既是技术上的现实(登录态维护成本高),也是基本的合规底线。
第二,控制请求频率。整站采集请求量大,如果不加限制地猛冲,轻则被封 IP,重则给目标站点造成实质压力。Scrapy 自带的AUTOTHROTTLE和DOWNLOAD_DELAY就是干这个的,后面会详细讲怎么配。
第三,明确数据用途。自己学习、研究、离线阅读是一回事,大规模分发是另一回事。这个边界心里要有数。
2.3 工具选型:为什么是 Scrapy 而不是别的
市面上做爬虫的工具不少,我简单对比一下我实际用过的几种:
| 方案 | 优势 | 整站采集时的短板 |
|---|---|---|
| requests + BeautifulSoup | 上手快,灵活 | 无并发、无去重、无重试,全靠手写 |
| Selenium / Playwright | 能处理 JS 渲染 | 慢,资源占用高,不适合百万级请求 |
| Scrapy | 异步高并发、内置去重调度、中间件体系完善 | 学习曲线略陡,纯静态解析需配合其他库 |
| Scrapy + Splash | 兼顾异步和 JS 渲染 | 部署复杂,维护成本高 |
对于小说站这种以静态 HTML 为主、请求量大、结构规整的场景,Scrapy 是最优解。它的异步引擎基于 Twisted,单机就能轻松跑到每秒几十个请求,配合去重和限速,既快又稳。
3. Scrapy 核心组件拆解与项目骨架搭建
3.1 五个核心组件,各管一摊
Scrapy 的架构看着复杂,其实核心就五个组件,理解了它们的分工,整个框架就通了:
- Engine(引擎):总调度,负责在各个组件之间传递数据和信号。
- Scheduler(调度器):管理请求队列,决定下一个要爬哪个 URL,内置去重。
- Downloader(下载器):真正发请求、拿响应的部分,异步执行。
- Spider(爬虫):你写的核心逻辑,负责解析响应、提取数据、生成新请求。
- Item Pipeline(管道):处理爬到的数据,清洗、去重、入库都在这里。
数据流向是这样的:Spider 产出 Request → 引擎交给调度器排队 → 调度器给下载器 → 下载器返回 Response → 引擎交回 Spider 解析 → Spider 产出 Item 和新 Request → Item 进管道,Request 回调度器。
理解这条链路的意义在于:当爬虫出问题时,你能快速定位是哪个环节的锅。比如请求重复了,是调度器去重的问题;数据没入库,是管道的问题;请求发不出去,是下载器或中间件的问题。
3.2 创建项目骨架与目录结构说明
安装好 Scrapy 后,用命令行创建项目:
pip install scrapy scrapy startproject novel_spider cd novel_spider scrapy genspider novel example.com生成的目录结构大致如下:
novel_spider/ ├── scrapy.cfg └── novel_spider/ ├── __init__.py ├── items.py # 定义数据结构 ├── middlewares.py # 中间件 ├── pipelines.py # 数据管道 ├── settings.py # 全局配置 └── spiders/ └── novel.py # 爬虫主体这个结构是 Scrapy 的约定,别随意改。items.py定义你要抓的字段,pipelines.py处理数据落库,settings.py调各种参数,spiders/下放具体的爬虫。
3.3 定义 Item:先把数据结构想清楚
写 Spider 之前,先在items.py里把要抓的字段定义好。这一步看似简单,但字段设计得好不好,直接决定后面数据清洗的工作量。
import scrapy class NovelItem(scrapy.Item): book_name = scrapy.Field() # 书名 author = scrapy.Field() # 作者 category = scrapy.Field() # 分类 status = scrapy.Field() # 连载状态 intro = scrapy.Field() # 简介 book_url = scrapy.Field() # 详情页链接 chapter_name = scrapy.Field() # 章节名 chapter_url = scrapy.Field() # 章节链接 content = scrapy.Field() # 正文内容 crawl_time = scrapy.Field() # 采集时间这里我把“书籍元信息”和“章节内容”放在同一个 Item 里,用字段是否为空来区分。你也可以拆成两个 Item,看个人习惯。加crawl_time是个好习惯,方便后续做增量更新和数据溯源。
实操心得:字段名尽量用英文小写下划线,别用中文或拼音缩写。后期做数据分析和入库时,英文命名省心太多。
4. Spider 编写:从分类页到章节正文的完整链路
4.1 起始请求与分类页解析
Spider 的入口是start_requests方法,从这里开始构造第一批请求。假设我们要遍历所有分类:
import scrapy from novel_spider.items import NovelItem class NovelSpider(scrapy.Spider): name = "novel" allowed_domains = ["example.com"] base_url = "https://www.example.com" def start_requests(self): # 假设有 10 个分类 for cat_id in range(1, 11): url = f"{self.base_url}/sort/{cat_id}/" yield scrapy.Request(url, callback=self.parse_category, meta={"category_id": cat_id}) def parse_category(self, response): # 提取该分类下所有书籍的详情页链接 book_links = response.css("div.book-list a.book-name::attr(href)").getall() for link in book_links: yield response.follow(link, callback=self.parse_book, meta=response.meta) # 分类页翻页 next_page = response.css("a.next::attr(href)").get() if next_page: yield response.follow(next_page, callback=self.parse_category, meta=response.meta)这里有几个关键点。response.follow会自动处理相对 URL,比手动拼接urljoin省事。meta用来在请求之间传递数据,比如分类 ID,这样后面入库时能带上分类信息。分类页的翻页用递归方式处理,只要还有“下一页”就继续跟。
4.2 详情页解析与章节列表翻页
详情页要抓书籍元信息,同时进入章节列表:
def parse_book(self, response): item = NovelItem() item["book_name"] = response.css("h1.book-title::text").get(default="").strip() item["author"] = response.css("span.author::text").get(default="").strip() item["status"] = response.css("span.status::text").get(default="").strip() item["intro"] = response.css("div.intro::text").get(default="").strip() item["book_url"] = response.url item["category"] = response.meta.get("category_id") # 进入章节列表 list_url = response.css("a.read-btn::attr(href)").get() if list_url: yield response.follow(list_url, callback=self.parse_chapter_list, meta={"book": item})章节列表页往往也分页,而且分页逻辑可能和分类页不同。有的站点用?page=N,有的用/list_N/,得具体分析。解析逻辑类似:
def parse_chapter_list(self, response): book = response.meta["book"] chapter_links = response.css("ul.chapter-list a::attr(href)").getall() for link in chapter_links: yield response.follow(link, callback=self.parse_chapter, meta={"book": book}) next_page = response.css("a.list-next::attr(href)").get() if next_page: yield response.follow(next_page, callback=self.parse_chapter_list, meta={"book": book})4.3 章节正文解析与多页章节合并
章节正文是最容易出问题的地方。很多小说站会把一章拆成多页,URL 上通常带_2.html、_3.html这样的后缀。处理思路是:先抓第一页,检测是否有下一页,如果有就继续跟,直到最后一页,再把所有页的内容合并。
def parse_chapter(self, response): book = response.meta["book"] item = NovelItem() # 复制书籍元信息 for key in ["book_name", "author", "category", "status", "intro", "book_url"]: item[key] = book.get(key) item["chapter_name"] = response.css("h1.chapter-title::text").get(default="").strip() item["chapter_url"] = response.url content = response.css("div.content::text").getall() item["content"] = "\n".join(t.strip() for t in content if t.strip()) # 检测章节内分页 next_page = response.css("a.chapter-next::attr(href)").get() if next_page and "下一页" in response.css("a.chapter-next::text").get(""): # 还有下一页,继续抓,把已抓内容通过 meta 传递 yield response.follow(next_page, callback=self.parse_chapter, meta={"book": book, "partial": item}) else: # 合并之前的分页内容 partial = response.meta.get("partial") if partial: partial["content"] += "\n" + item["content"] item = partial yield item这段逻辑稍微绕一点,核心是用meta在分页请求之间传递已抓取的部分内容,最后一页时合并。注意判断“下一页”时要确认是章节内的下一页,而不是跳到下一章——很多站点的“下一章”按钮和“下一页”按钮长得像,得靠文字或 class 区分。
注意:章节内分页的合并逻辑一定要测试。我踩过的坑是,有的站点最后一页的“下一页”按钮指向的是下一章,如果不加文字判断,会把下一章的内容错误地拼到当前章末尾。
5. 数据管道与持久化存储方案
5.1 数据清洗:把脏数据挡在入库之前
爬下来的原始数据往往很脏:正文里混着广告、多余空行、HTML 实体、全角空格。这些清洗工作放在 Pipeline 里做最合适,因为 Pipeline 是数据入库前的最后一道关卡。
import re from datetime import datetime class CleanPipeline: def process_item(self, item, spider): if item.get("content"): content = item["content"] # 去掉常见广告关键词所在行 ad_keywords = ["请记住本站", "手机阅读", "最新章节", "加入书签"] lines = content.split("\n") lines = [l for l in lines if not any(kw in l for kw in ad_keywords)] # 去掉多余空行 content = "\n".join(l for l in lines if l.strip()) # HTML 实体替换 content = content.replace(" ", " ").replace("&", "&") # 全角空格转半角 content = content.replace("\u3000", " ") item["content"] = content.strip() item["crawl_time"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") return item清洗规则要根据实际抓下来的数据不断调整。我的习惯是先跑一小批,把原始数据 dump 出来看,找出最常见的脏数据模式,再针对性写清洗规则。
5.2 去重策略:URL 去重和内容去重双保险
Scrapy 的调度器自带请求去重,基于 URL 的指纹。但整站采集时,光靠 URL 去重不够,因为同一个章节可能通过不同路径被多次发现。更稳妥的做法是在 Pipeline 里再做一层内容去重。
class DuplicatePipeline: def __init__(self): self.seen = set() def process_item(self, item, spider): key = f"{item.get('book_name')}|{item.get('chapter_name')}" if key in self.seen: from scrapy.exceptions import DropItem raise DropItem(f"重复章节: {key}") self.seen.add(key) return item用“书名 + 章节名”作为去重键,简单有效。如果内存吃紧,可以把seen换成 Redis 集合,Scrapy 生态里有现成的scrapy-redis组件,支持分布式去重。
5.3 落库方案:MySQL、MongoDB 还是纯文本
存储方案取决于你的用途。我实际用过三种,各有适用场景:
| 存储方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 纯文本文件 | 个人离线阅读 | 简单、无需数据库 | 查询困难、不易增量更新 |
| MySQL | 结构化查询、做数据分析 | 支持复杂查询、事务 | 正文大字段存储效率一般 |
| MongoDB | 文档型数据、字段灵活 | 天然适合嵌套结构、写入快 | 需要额外部署维护 |
如果只是自己看,我推荐按书分文件存纯文本,一本书一个.txt,章节按顺序拼接。如果要做数据分析或建站,MySQL 更合适,建两张表:books存元信息,chapters存章节内容,用book_id关联。
import pymysql class MySQLPipeline: def open_spider(self, spider): self.conn = pymysql.connect(host="localhost", user="root", password="yourpass", db="novel", charset="utf8mb4") self.cursor = self.conn.cursor() def process_item(self, item, spider): if item.get("content"): sql = """INSERT IGNORE INTO chapters (book_name, chapter_name, chapter_url, content, crawl_time) VALUES (%s, %s, %s, %s, %s)""" self.cursor.execute(sql, (item["book_name"], item["chapter_name"], item["chapter_url"], item["content"], item["crawl_time"])) self.conn.commit() return item def close_spider(self, spider): self.cursor.close() self.conn.close()INSERT IGNORE配合唯一索引,能自动跳过重复数据,比先查后插效率高。
6. 反爬应对与性能调优实战
6.1 请求头、代理与限速的合理配置
小说站的反爬强度普遍不高,但基本的伪装还是要做。在settings.py里配置:
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..." ROBOTSTXT_OBEY = False DOWNLOAD_DELAY = 1 RANDOMIZE_DOWNLOAD_DELAY = True CONCURRENT_REQUESTS = 16 CONCURRENT_REQUESTS_PER_DOMAIN = 8 AUTOTHROTTLE_ENABLED = True AUTOTHROTTLE_START_DELAY = 1 AUTOTHROTTLE_MAX_DELAY = 10 AUTOTHROTTLE_TARGET_CONCURRENCY = 4.0DOWNLOAD_DELAY设为 1 秒,配合RANDOMIZE_DOWNLOAD_DELAY让延迟在 0.5 到 1.5 秒之间随机波动,比固定延迟更自然。AUTOTHROTTLE是 Scrapy 的自动限速机制,它会根据服务器的响应时间动态调整请求速度——服务器响应慢就自动降速,响应快就适当提速。这个功能强烈建议开启,既保护目标站点,也降低自己被封的风险。
提示:
CONCURRENT_REQUESTS_PER_DOMAIN别设太高。我实测下来,单域名并发控制在 8 以内,配合自动限速,基本不会被封。设到 32 以上,很多站点几分钟内就会返回 403。
6.2 重试机制与异常处理
网络请求失败是常态,必须有重试机制。Scrapy 默认会重试失败的请求,但默认配置比较保守,可以调整:
RETRY_ENABLED = True RETRY_TIMES = 3 RETRY_HTTP_CODES = [500, 502, 503, 504, 408, 429]429 是“请求过多”,一定要加进重试列表,遇到限流时自动退避重试。另外,在 Spider 里对解析异常做兜底:
def parse_chapter(self, response): try: # 解析逻辑 pass except Exception as e: self.logger.error(f"解析失败: {response.url}, 错误: {e}")别让单个页面的解析失败中断整个爬虫。Scrapy 的容错性不错,但解析逻辑里的异常如果不捕获,会导致该请求被丢弃且不重试。
6.3 断点续爬:JOBDIR 的妙用
整站采集动辄跑几个小时甚至几天,中途断了怎么办?Scrapy 提供了一个非常实用的功能:JOBDIR。只要在启动时指定一个目录,Scrapy 会把调度器的状态持久化到磁盘,下次用同样的JOBDIR启动,就能从断点继续。
scrapy crawl novel -s JOBDIR=crawls/novel-1这个功能我强烈推荐。之前爬一个几万章的书库,跑到一半机器重启,如果没有JOBDIR,就得从头再来。有了它,重启后直接接着爬,省时省力。
实操心得:
JOBDIR目录不要复用。每次全新的采集任务用一个新目录,否则会继承上次的去重队列,导致新任务里本来该爬的 URL 被当成重复跳过。
7. 常见问题排查与避坑经验实录
7.1 抓不到内容?先分清是静态还是动态渲染
最常见的“抓不到数据”问题,八成是因为页面内容是 JavaScript 动态渲染的。判断方法很简单:在浏览器里右键查看网页源代码,搜索你要抓的内容。如果源代码里有,说明是静态的,Scrapy 能直接抓;如果没有,说明是 JS 渲染的,Scrapy 拿到的只是空壳。
遇到动态渲染,有几个选择:一是分析 XHR 请求,找到真正的数据接口,直接请求接口(最推荐,效率最高);二是用 Splash 或 Playwright 做渲染(成本高,慢);三是放弃这个站点。小说站大部分是静态的,但少数会用 JS 加载章节内容,得具体分析。
7.2 编码乱码:GBK 与 UTF-8 的坑
中文小说站很多用 GBK 编码,Scrapy 默认按响应头里的编码解析,但有些站点响应头写的是 UTF-8,实际内容是 GBK,就会乱码。解决办法是在 Spider 里手动指定编码:
def parse_chapter(self, response): response = response.replace(encoding="gbk") # 后续解析或者在settings.py里全局配置:
FEED_EXPORT_ENCODING = "utf-8"如果乱码问题反复出现,建议在 Pipeline 里加一层编码检测和修复,用chardet库自动识别。
7.3 请求被限流或封禁的排查思路
如果发现大量请求返回 403、429,或者响应变成验证码页面,说明被限流了。排查顺序:
- 检查
DOWNLOAD_DELAY是不是设得太小,先调到 2 秒试试。 - 检查
CONCURRENT_REQUESTS_PER_DOMAIN,降到 4 以下。 - 检查 User-Agent 是不是太单一,配置多个 UA 轮换。
- 如果还不行,考虑加代理池,但代理质量参差不齐,维护成本高。
我的经验是,大部分小说站只要把延迟调到 1-2 秒、并发降到 8 以下,就不会被封。真正需要代理池的场景很少,除非目标站点反爬特别激进。
7.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 抓到的内容是空 | JS 动态渲染 | 分析接口或上渲染方案 |
| 中文乱码 | 编码不一致 | 手动指定 encoding |
| 大量 403/429 | 请求过快被封 | 降并发、加延迟、换 UA |
| 章节内容重复 | 分页合并逻辑错误 | 检查“下一页”判断条件 |
| 爬虫中途停止 | 内存溢出或异常 | 开 JOBDIR、加异常捕获 |
| 数据没入库 | Pipeline 未启用 | 检查 settings 里的 ITEM_PIPELINES |
8. 增量更新与长期维护的几点思考
整站采集不是一锤子买卖。小说是持续更新的,今天爬完,明天又有新章节。如果每次都全量重爬,既浪费时间又给目标站点添麻烦。增量更新的核心思路是:记录每本书已爬到的最后一章,下次只爬新增部分。
实现方式很简单,在数据库里给每本书存一个last_chapter_url字段。下次爬的时候,从章节列表页开始遍历,遇到last_chapter_url就停止。这样每次只需要爬新增的几章,效率极高。
def parse_chapter_list(self, response): book = response.meta["book"] last_url = get_last_chapter_url(book["book_url"]) # 从数据库查 for link in response.css("ul.chapter-list a::attr(href)").getall(): full_url = response.urljoin(link) if full_url == last_url: break # 遇到上次爬到的位置,停止 yield response.follow(full_url, callback=self.parse_chapter, meta={"book": book})这个逻辑配合定时任务(比如每天凌晨跑一次),就能实现自动追更。我自己用这套方案维护了一个几千本书的书库,每天增量更新只需要十几分钟。
另外,长期维护还要注意解析规则的健壮性。小说站改版是常事,今天用的 CSS 选择器明天可能就失效了。建议把选择器集中配置,别散落在代码各处,改版时只改一处。同时加日志监控,一旦某个选择器连续返回空值,就报警提醒。
最后分享一个我踩过的坑:别在 Spider 里做太重的数据处理。我早期把正文清洗、分词、统计全塞在 Spider 的解析函数里,结果单章处理时间从几毫秒涨到几百毫秒,整体速度慢了好几倍。后来把这些工作全挪到 Pipeline 里,Spider 只负责提取原始数据,速度立刻回来了。记住,Spider 的职责是“抓”,不是“算”,重活交给 Pipeline 或后续的离线处理。