
1. 项目缘起与整体设计思路1.1 为什么选Scrapy而不是requestsBeautifulSoup很多刚接触爬虫的朋友第一反应是用requests加BeautifulSoup写个脚本几十行代码就能跑起来。我最早也是这么干的直到有一次需要抓取一个小说站的全站作品列表大概两万多本书每本还要进详情页拿章节列表再逐章抓正文。用脚本跑了一晚上第二天早上发现内存爆了程序卡死在某个环节而且没有任何断点续传机制只能从头再来。那次之后我认真评估了Scrapy。它本质上是一个为大规模抓取而生的异步框架内置了调度器、去重队列、中间件、管道、限速、重试等一整套机制。你不需要自己维护请求队列不需要手写去重逻辑不需要操心并发控制。对于“爬取小说网所有小说”这种量级的任务Scrapy几乎是唯一合理的选择。具体来说这个项目要解决的核心问题有三个第一如何高效地遍历小说站的全部作品入口第二如何稳定地抓取每本书的详情和章节内容第三如何把抓到的数据规整地存下来方便后续阅读或处理。Scrapy的Spider负责第一和第二步Item Pipeline负责第三步分工非常清晰。1.2 整体架构拆解整个项目的架构可以分成四层。最上层是Spider定义起始URL和解析规则中间是Downloader Middleware处理请求头、代理轮换、重试等再下面是Item Pipeline负责数据清洗和持久化最底层是Scrapy Engine和Scheduler框架自动管理。我习惯把配置拆成三个文件settings.py放全局配置items.py定义数据结构spiders目录下放具体的爬虫类。这样后期维护的时候改限速去settings改字段去items改解析逻辑去spider互不干扰。提示不要把所有代码塞进一个文件。我见过有人把Spider、Pipeline、配置全写在一个py文件里后来想加一个字段改了半小时才找到地方。1.3 目标站点结构分析思路在动手写代码之前必须先把目标站的结构摸清楚。小说站通常有三种页面列表页分类页、排行榜、完本列表、详情页书名、作者、简介、章节列表、阅读页章节正文。我的习惯是先用浏览器开发者工具把每种页面的URL规律找出来。比如列表页可能是/list/1.html、/list/2.html这样分页详情页可能是/book/12345/阅读页可能是/book/12345/67890.html。找到规律之后Spider的解析逻辑就很好写了。还有一个关键点要确认站点是否有反爬机制。有些站会检测User-Agent有些会限制请求频率有些会对正文内容做混淆。这些都要在写代码之前确认清楚否则写到一半发现被封了返工成本很高。2. 核心细节解析与实操要点2.1 项目初始化与目录结构安装Scrapy很简单一条命令搞定pip install scrapy然后创建项目scrapy startproject novel_spider生成的目录结构大概是这样的novel_spider/ scrapy.cfg novel_spider/ __init__.py items.py middlewares.py pipelines.py settings.py spiders/ __init__.py我一般会额外建一个utils目录放工具函数比如URL拼接、文本清洗、编码转换等。另外建一个data目录用来存抓取结果方便管理。2.2 定义Item数据结构在items.py里定义你要抓的字段。小说站通常需要这些import scrapy class NovelItem(scrapy.Item): novel_name scrapy.Field() author scrapy.Field() category scrapy.Field() status scrapy.Field() intro scrapy.Field() chapter_list scrapy.Field() source_url scrapy.Field()这里有个经验chapter_list我建议存成一个列表每个元素是字典包含章节名和章节URL。不要直接把正文塞进Item因为正文数据量太大应该单独处理。2.3 Spider的编写要点Spider是整个项目的核心。我通常把列表页解析和详情页解析分开写用不同的回调函数处理。import scrapy from novel_spider.items import NovelItem class NovelSpider(scrapy.Spider): name novel allowed_domains [example-novel-site.com] start_urls [https://www.example-novel-site.com/list/1.html] def parse(self, response): # 解析列表页提取详情页链接 book_links response.css(div.book-list a::attr(href)).getall() for link in book_links: yield response.follow(link, callbackself.parse_detail) # 翻页逻辑 next_page response.css(a.next::attr(href)).get() if next_page: yield response.follow(next_page, callbackself.parse) def parse_detail(self, response): item NovelItem() item[novel_name] response.css(h1::text).get().strip() item[author] response.css(span.author::text).get().strip() # ... 其他字段 yield item这里有几个细节要注意。第一response.follow会自动处理相对URL比手动拼接urljoin方便。第二翻页逻辑要加终止条件否则可能无限循环。第三get()的默认值参数可以避免None导致的报错。2.4 中间件与反爬应对小说站的反爬通常不会太激进但基本的礼貌还是要有的。在settings.py里设置ROBOTSTXT_OBEY True DOWNLOAD_DELAY 1 CONCURRENT_REQUESTS_PER_DOMAIN 4DOWNLOAD_DELAY设为1秒意味着每个请求间隔1秒。对于两万本书的站点如果每本书平均20章那就是40万个请求按1秒一个算需要111小时。这个时间太长了实际项目中我会根据站点承受能力适当调低比如0.3到0.5秒。User-Agent轮换也是必要的。在middlewares.py里写一个简单的中间件import random class RandomUserAgentMiddleware: def __init__(self, user_agents): self.user_agents user_agents classmethod def from_crawler(cls, crawler): return cls(crawler.settings.getlist(USER_AGENTS)) def process_request(self, request, spider): request.headers[User-Agent] random.choice(self.user_agents)然后在settings里配置一个User-Agent列表。注意不要用太老的浏览器版本容易被识别。注意不要设置过高的并发。我试过把CONCURRENT_REQUESTS_PER_DOMAIN调到32结果不到十分钟就被封了IP。后来老老实实降到4稳定跑了一周没出问题。2.5 数据存储方案选型存储方案我试过三种JSON文件、CSV、SQLite。最后长期用的是SQLite加JSON备份。JSON文件适合小规模测试但数据量大了之后读写都很慢。CSV的问题是小说正文里经常有换行和逗号处理起来很麻烦。SQLite的好处是支持断点续传可以随时查询已抓取的数据而且单文件管理方便。在pipelines.py里import sqlite3 class SQLitePipeline: def open_spider(self, spider): self.conn sqlite3.connect(novels.db) self.cursor self.conn.cursor() self.cursor.execute( CREATE TABLE IF NOT EXISTS novels ( id INTEGER PRIMARY KEY AUTOINCREMENT, novel_name TEXT, author TEXT, category TEXT, intro TEXT, source_url TEXT UNIQUE ) ) self.conn.commit() def process_item(self, item, spider): try: self.cursor.execute( INSERT OR IGNORE INTO novels (novel_name, author, category, intro, source_url) VALUES (?, ?, ?, ?, ?) , (item[novel_name], item[author], item[category], item[intro], item[source_url])) self.conn.commit() except Exception as e: spider.logger.error(f插入失败: {e}) return item def close_spider(self, spider): self.conn.close()INSERT OR IGNORE配合UNIQUE约束可以自动跳过重复数据非常适合断点续传的场景。3. 实操过程与核心环节实现3.1 列表页遍历的完整实现列表页遍历的关键是找到所有分页的URL规律。以某小说站为例分类列表页的URL是/list/1.html到/list/100.html每页有20本书。我可以直接生成所有列表页URLstart_urls [fhttps://www.example-novel-site.com/list/{i}.html for i in range(1, 101)]但更稳妥的做法是解析“下一页”按钮因为有些站点的分页不是连续数字。两种方法我都用过直接生成URL适合结构稳定的站解析下一页适合结构可能变化的站。在列表页解析时要注意区分“书籍链接”和“其他链接”。有些站点的列表页里混着广告链接、作者链接、分类链接。我的做法是观察书籍链接的URL模式比如都包含/book/然后用CSS选择器精确匹配book_links response.css(a[href*/book/]::attr(href)).getall()这样能过滤掉大部分无关链接。3.2 详情页解析与章节列表提取详情页的解析难点在于章节列表。有些站的章节列表是分卷的有些是平铺的有些甚至用JavaScript动态加载。对于静态加载的章节列表直接解析HTML就行chapters [] for li in response.css(div.chapter-list li): chapter_name li.css(a::text).get().strip() chapter_url li.css(a::attr(href)).get() if chapter_name and chapter_url: chapters.append({ name: chapter_name, url: response.urljoin(chapter_url) }) item[chapter_list] chapters如果章节列表是动态加载的就需要分析XHR请求。在开发者工具的Network面板里找到返回章节列表的接口然后用Scrapy直接请求那个接口。这种情况我遇到过几次接口通常返回JSON解析起来反而比HTML简单。提示章节列表一定要存下来。我一开始只存了书籍信息后来想抓正文的时候发现还得重新遍历一遍详情页白白浪费了一倍的时间。3.3 正文抓取的独立Spider设计正文抓取我建议单独写一个Spider不要和书籍信息抓取混在一起。原因是正文数据量大需要不同的限速策略和存储方案。class ChapterSpider(scrapy.Spider): name chapter def __init__(self, novel_idNone, *args, **kwargs): super().__init__(*args, **kwargs) self.novel_id novel_id def start_requests(self): # 从数据库读取该书的章节列表 conn sqlite3.connect(novels.db) cursor conn.cursor() cursor.execute(SELECT chapter_url FROM chapters WHERE novel_id ?, (self.novel_id,)) for row in cursor.fetchall(): yield scrapy.Request(row[0], callbackself.parse_chapter) def parse_chapter(self, response): content response.css(div.content::text).getall() content \n.join([c.strip() for c in content if c.strip()]) yield { url: response.url, content: content }这样设计的好处是你可以先跑完书籍信息抓取确认数据没问题之后再针对性地抓正文。而且正文Spider可以单独设置更低的并发和更长的延迟避免对站点造成太大压力。3.4 断点续传与增量抓取断点续传是长任务的生命线。我的做法是在数据库里记录每个URL的抓取状态class ChapterPipeline: def process_item(self, item, spider): self.cursor.execute( UPDATE chapters SET status done, content ? WHERE url ? , (item[content], item[url])) self.conn.commit() return item启动的时候只抓status ! done的记录cursor.execute(SELECT url FROM chapters WHERE status ! done)这样即使程序中途崩溃重启之后也能从断点继续不会重复抓取已经完成的部分。增量抓取则是针对更新中的小说。每次运行前先检查详情页的“最后更新时间”如果比数据库里记录的新就重新抓取章节列表只抓新增的章节。3.5 编码问题的处理小说站最常见的编码是UTF-8和GBK。Scrapy默认会从HTTP头或meta标签里推断编码但有时候会推断错误导致中文乱码。我的处理方式是在Spider里显式指定编码def parse(self, response): response response.replace(encodinggbk) # 后续解析或者在settings里设置FEED_EXPORT_ENCODING utf-8如果遇到个别页面乱码可以在Pipeline里做二次检测def fix_encoding(text): try: return text.encode(latin1).decode(gbk) except: return text这个技巧我用了很多次对于Scrapy误判编码的情况特别有效。4. 常见问题与排查技巧实录4.1 请求被拒绝的排查思路请求被拒绝通常有三种表现返回403、返回503、或者返回一个验证页面。403一般是User-Agent或Referer的问题。检查一下请求头是否完整特别是Referer有些站会校验来源。503通常是频率太高需要降低并发或增加延迟。返回验证页面的话说明站点有更复杂的反爬机制可能需要处理Cookie或执行JavaScript。我的排查顺序是先用浏览器正常访问确认站点可访问然后用curl带同样的请求头发送请求看返回什么最后在Scrapy里逐步调整配置直到返回正常内容。4.2 数据缺失与选择器失效选择器失效是爬虫维护中最常见的问题。站点改版之后原来的CSS选择器可能就匹配不到元素了。我的应对策略是写选择器的时候尽量用稳定的属性比如id、>novel_name response.css(h1::text).get() if not novel_name: spider.logger.warning(f书名解析失败: {response.url})这样跑完之后看日志就知道哪些页面解析出了问题。4.3 内存占用过大的优化Scrapy默认会把所有请求和响应都放在内存里数据量大了之后内存占用会很高。优化方法有几个第一设置DEPTH_PRIORITY和SCHEDULER_DISK_QUEUE把调度队列放到磁盘上SCHEDULER_DISK_QUEUE scrapy.squeues.PickleFifoDiskQueue SCHEDULER_MEMORY_QUEUE scrapy.squeues.FifoMemoryQueue第二及时释放不需要的数据。在Pipeline处理完之后不要保留对Item的引用。第三分批处理。不要一次性把所有URL都加载到start_urls里而是用生成器逐步产生请求。4.4 常见问题速查表问题现象可能原因解决方法返回403User-Agent被识别轮换User-Agent添加Referer返回503请求频率过高增加DOWNLOAD_DELAY降低并发中文乱码编码推断错误显式指定encoding或做二次解码选择器匹配不到站点改版更新CSS选择器加解析日志内存持续增长请求队列过大启用磁盘队列分批处理数据重复去重失效检查dont_filter参数加数据库唯一约束程序中途崩溃未处理异常加try-except记录断点状态抓取速度慢延迟设置过高适当降低延迟增加并发4.5 几个踩过的坑第一个坑是dont_filter参数。有一次我为了重新抓取某个页面把dont_filterTrue加上了结果整个Spider把所有URL都重新抓了一遍浪费了好几个小时。这个参数一定要慎用。第二个坑是Pipeline里的异常处理。如果Pipeline抛异常Scrapy默认会丢弃这个Item。我有一次数据库连接断了结果几千条数据全丢了。后来在Pipeline里加了重试机制并且把失败的Item写到本地文件备份。第三个坑是章节顺序。有些站的章节列表是倒序的最新的章节在最前面。如果直接按列表顺序抓取存下来的章节就是乱的。我的做法是在解析的时候记录章节序号存储的时候按序号排序。第四个坑是正文里的广告。很多小说站的正文里会插入广告文本比如“请记住本站域名”之类的。这些需要在Pipeline里用正则过滤掉import re def clean_content(text): patterns [ r请记住本站.*?[\n], r手机用户请浏览.*?[\n], r本章未完.*?[\n], ] for p in patterns: text re.sub(p, , text) return text这个清洗规则要根据具体站点来定没有通用方案。4.6 性能调优的实操记录我做过一次对比测试同样的任务不同的配置下耗时差异很大。配置项保守配置激进配置最终采用DOWNLOAD_DELAY1.00.10.3CONCURRENT_REQUESTS_PER_DOMAIN2164AUTOTHROTTLE_ENABLEDFalseTrueTrueRETRY_TIMES253完成时间万章8小时1.5小时被封3小时最终采用的配置是折中方案既保证了速度又不会被封。AUTOTHROTTLE开启之后Scrapy会根据站点的响应时间自动调整延迟比手动设置更智能。注意调优的时候一定要小规模测试。我一般先用100个请求测试观察响应状态和耗时确认没问题再放大规模。5. 数据后处理与阅读体验优化5.1 章节内容的分卷与合并抓下来的章节是零散的需要按书籍合并成一个文件。我写了一个脚本从数据库读取某本书的所有章节按序号排序然后合并成txtdef merge_novel(novel_id, output_path): conn sqlite3.connect(novels.db) cursor conn.cursor() cursor.execute( SELECT chapter_name, content FROM chapters WHERE novel_id ? AND status done ORDER BY chapter_order , (novel_id,)) with open(output_path, w, encodingutf-8) as f: for name, content in cursor.fetchall(): f.write(f\n\n{name}\n\n) f.write(content) conn.close()合并的时候要注意章节之间的分隔我习惯用两个换行加章节名这样在阅读器里看起来比较清晰。5.2 目录生成与元数据整理除了正文我还习惯生成一个目录文件包含书名、作者、简介、章节列表。这样方便快速浏览和检索。def generate_toc(novel_id): # 从数据库读取书籍信息和章节列表 # 生成markdown格式的目录 pass元数据整理还包括去重。同一个作者可能有多个笔名同一本书可能有多个版本。这些需要在数据清洗阶段处理。5.3 阅读格式的转换抓下来的txt可以直接看但如果想放到阅读器里可能需要转成epub或mobi。我一般用Calibre的命令行工具转换ebook-convert novel.txt novel.epub --title 书名 --author 作者转换之前要确保txt的格式规范章节标题单独成行段落之间有空行。这些在合并的时候就要处理好。6. 项目扩展与长期维护建议6.1 多站点适配的思路如果想把项目扩展到多个小说站最好的方式是抽象出一个基类Spider把通用的逻辑翻页、详情解析、章节提取放在基类里各个站点只需要实现差异化的部分。class BaseNovelSpider(scrapy.Spider): def parse_detail(self, response): item NovelItem() item[novel_name] self.extract_name(response) # ... return item def extract_name(self, response): raise NotImplementedError class SiteASpider(BaseNovelSpider): def extract_name(self, response): return response.css(h1::text).get()这样新增一个站点只需要写几十行代码维护成本大大降低。6.2 定时任务与更新监控对于连载中的小说需要定期检查更新。我一般用crontab设置每天凌晨跑一次增量抓取0 3 * * * cd /path/to/project scrapy crawl novel logs/crawl.log 21日志要保留方便排查问题。我习惯按日期分割日志文件避免单个文件过大。6.3 数据备份与迁移数据库文件要定期备份。我的做法是每天抓取完成后把novels.db复制一份到备份目录保留最近7天的备份。这样即使数据库损坏也能快速恢复。迁移的时候SQLite文件可以直接拷贝非常方便。如果数据量特别大可以考虑迁移到PostgreSQL但一般小说站的数据量用SQLite完全够用。6.4 合规与礼貌抓取的边界最后说一个很重要但容易被忽视的问题抓取的边界。我在实际项目中会严格遵守几条原则只抓公开可访问的内容不抓需要登录才能看的内容控制请求频率不对站点造成明显压力不抓取个人隐私信息抓取的数据仅用于个人学习研究不传播不商用。这些原则看起来简单但真正执行的时候需要克制。我见过有人为了追求速度把并发开到很高结果把人家小站搞挂了。这种行为不仅不道德也可能带来不必要的麻烦。提示在settings里设置ROBOTSTXT_OBEY True这是最基本的礼貌。虽然有些站的robots.txt写得很宽松但遵守它是对站点运营者的尊重。6.5 后续可以扩展的方向这个项目跑通之后可以往几个方向扩展。一是加一个简单的Web界面用Flask或FastAPI展示抓取进度和已抓书籍列表。二是加全文搜索功能用Whoosh或Elasticsearch建立索引。三是做数据分析比如统计作者的更新频率、作品的字数分布等。我个人最感兴趣的是全文搜索。抓了几百本书之后想找某个情节或某句话靠翻文件太慢了。用Whoosh建索引几行代码就能实现from whoosh.index import create_in from whoosh.fields import Schema, TEXT, ID schema Schema(titleTEXT(storedTrue), contentTEXT) ix create_in(indexdir, schema)这个扩展我还在折腾中等跑通了再单独写一篇分享。6.6 我个人的实操体会这个项目我从最初写脚本到现在前后迭代了大概七八个版本。最大的体会是爬虫项目的难点不在写代码而在维护。站点改版、反爬升级、数据清洗规则变化这些才是真正消耗时间的地方。所以我的建议是一开始就把架构搭好把配置和逻辑分离把日志和断点做扎实。前期多花两个小时后期能省两天。另外不要追求一次抓完分批抓、增量抓才是可持续的方式。还有一点抓下来的数据要及时整理。我有一段时间只顾着抓抓了几十万章堆在数据库里后来想用的时候发现格式乱七八糟又花了很多时间清洗。现在我习惯每抓完一本书就顺手合并成txt保持数据随时可用。最后分享一个小技巧在Spider里加一个统计计数器每抓1000章打印一次进度。这样跑长任务的时候心里有底不会因为看不到进度而焦虑。class ChapterSpider(scrapy.Spider): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.count 0 def parse_chapter(self, response): self.count 1 if self.count % 1000 0: self.logger.info(f已抓取 {self.count} 章) # ...这个习惯是从一次跑了三天三夜的任务之后养成的当时没有任何进度提示完全不知道跑到哪了那种感觉太煎熬了。