Scrapy整站小说采集实战:从架构设计到增量更新
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/可能带分页参数?page2章节内容/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, callbackself.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, callbackself.parse_book, metaresponse.meta) # 分类页翻页 next_page response.css(a.next::attr(href)).get() if next_page: yield response.follow(next_page, callbackself.parse_category, metaresponse.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, callbackself.parse_chapter_list, meta{book: item})章节列表页往往也分页而且分页逻辑可能和分类页不同。有的站点用?pageN有的用/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, callbackself.parse_chapter, meta{book: book}) next_page response.css(a.list-next::attr(href)).get() if next_page: yield response.follow(next_page, callbackself.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, callbackself.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(nbsp;, ).replace(amp;, ) # 全角空格转半角 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(hostlocalhost, userroot, passwordyourpass, dbnovel, charsetutf8mb4) 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 JOBDIRcrawls/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(encodinggbk) # 后续解析或者在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_PIPELINES8. 增量更新与长期维护的几点思考整站采集不是一锤子买卖。小说是持续更新的今天爬完明天又有新章节。如果每次都全量重爬既浪费时间又给目标站点添麻烦。增量更新的核心思路是记录每本书已爬到的最后一章下次只爬新增部分。实现方式很简单在数据库里给每本书存一个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, callbackself.parse_chapter, meta{book: book})这个逻辑配合定时任务比如每天凌晨跑一次就能实现自动追更。我自己用这套方案维护了一个几千本书的书库每天增量更新只需要十几分钟。另外长期维护还要注意解析规则的健壮性。小说站改版是常事今天用的 CSS 选择器明天可能就失效了。建议把选择器集中配置别散落在代码各处改版时只改一处。同时加日志监控一旦某个选择器连续返回空值就报警提醒。最后分享一个我踩过的坑别在 Spider 里做太重的数据处理。我早期把正文清洗、分词、统计全塞在 Spider 的解析函数里结果单章处理时间从几毫秒涨到几百毫秒整体速度慢了好几倍。后来把这些工作全挪到 Pipeline 里Spider 只负责提取原始数据速度立刻回来了。记住Spider 的职责是“抓”不是“算”重活交给 Pipeline 或后续的离线处理。