用Scrapy爬取起点中文网小说数据:从环境搭建到入库完整实战
1. 为什么拿小说网站做爬虫练手最实在做爬虫开发这行有个很现实的问题练手的数据源找起来容易用起来安心的不多。我今年年初需要一份足够规整、字段稳定、又有一定反爬门槛的中文长文本数据用来做文本挖掘的实验挑来挑去选定了起点中文网的页面。原因很简单它的前端结构多年保持稳定书籍列表、书名、作者、简介、分类信息都有清晰的HTML标签不需要逆向复杂的加密接口对刚接触数据提取的开发者来说是教科书级别的案例。这篇文章就围绕“使用Spider提取数据”这件事展开以爬取起点中文网的热门书籍信息为线索走一遍从环境搭建、页面结构分析、Spider编写、数据清洗到结果落地的完整链路。涉及的整套思路不只能用在小说网站换到任何中等复杂度的资讯门户、垂直行业站流程都是一样的。适合刚学完Python基础、想用真实项目练手的朋友也适合想系统梳理Scrapy框架用法的人做参考。这次我用的主力工具是Scrapy。很多人问我为什么不用Requests加BeautifulSoup说实话如果只是单页抓个几十条数据直接用Requests确实就够了代码写起来更自由。但一旦目标变成“多个页面”“多个字段”“断点续爬”“清洗入库”Requests的代码会迅速膨胀每个环节都要自己管。Scrapy在这类场景下是真正的生产力工具内置异步并发调度、Selector提取引擎、Middlewares中间件、Item Pipeline数据管道框架把这套流程串成了一条清晰的生产线。再说一个非常现实的理由Scrapy的scrapy shell调试模式太方便了。写正则、写XPath的时候谁都会犯错用Shell直接贴一段XPath看返回结果马上知道表达式写没写对这比反复跑整个爬虫再回去看日志高效得多。不过开始之前得先把合规的事情说清楚。爬虫获取公开页面数据最核心的原则是三点严格遵守robots.txt规则、控制请求频率、不给目标服务器增加压力。起点中文网的robots.txt对爬虫有明确的路径限制实操中我建议至少做到以下几点把DOWNLOAD_DELAY设成1到2秒、关闭Ajaxcrawl重试、只抓自己实验真正需要的页面数量。这次的项目定位是学习与文本研究不涉及评论区、用户主页、付费章节内容的抓取这些敏感数据不属于数据提取的练习范围。做技术练习边界感从一开始就要有。2. 环境准备与项目骨架搭建2.1 虚拟环境与Scrapy安装这次我在一台只装了Python 3.10的干净机器上从零开始全程使用虚拟环境隔离依赖。强烈建议不要在系统环境里直接装Scrapy它的依赖包括Twisted、lxml、pyOpenSSL等一批底层库跟系统里其他项目的包版本很容易互相干扰。# 创建独立的虚拟环境 python -m venv novel_env # 激活环境Windows novel_env\Scripts\activate # 激活环境macOS / Linux source novel_env/bin/activate # 升级pip pip install --upgrade pip # 安装Scrapy pip install scrapy装完之后跑一句验证scrapy version看到类似Scrapy 2.11.x的输出就说明环境没问题。这里有一个安装期常见的坑在Windows上用pip安装Scrapy时偶尔会卡在Twisted的编译环节原因多半是缺少Microsoft C Build Tools。如果你遇到了这种报错装一个对应版本的Build Tools再重试就行macOS用户如果遇到ffi.h not found多半是libffi没装运行brew install libffi之后再装。这些是我实际跑过的路径不同系统差异很大但方向是一致的先把底层依赖搞定不要卡在初始环节。2.2 初始化项目与目录结构用Scrapy官方命令直接生成项目骨架不需要手写文件scrapy startproject qidian_books执行成功后项目目录长这样qidian_books/ ├── scrapy.cfg # 配置文件 ├── qidian_books/ │ ├── __init__.py │ ├── items.py # 数据模型定义 │ ├── middlewares.py # 中间件扩展 │ ├── pipelines.py # 数据管道 │ ├── settings.py # 全局配置 │ ├── spiders/ # 爬虫代码目录 │ │ └── __init__.py新手看到这么多文件容易慌其实中间几个文件起着完全不同的作用items.py决定你最终要拿到哪几个字段相当于给爬虫画了一张“订单表格”spiders/目录里写的是“怎么遍历页面、怎么从HTML里抠数据”这是整个项目最核心的部分pipelines.py负责“抠出来的数据怎么处理”去重、清洗、存数据库都在这一层做。整体的数据处理流向我理一个最简单的描述Spider把页面抓回来之后先从HTML中提取出结构化字段然后扔给PipelinePipeline逐条接收并处理最后写入文件或数据库。每个环节各干各的活这就是Scrapy设计上最舒服的地方——职责分离出了问题排查起来也快。2.3 settings.py里必须改的三个配置刚启动的项目默认配置偏向“安全”真正跑起来前有几个配置项必须调整不然要么速度太慢要么连什么都看不见。# settings.py 关键配置 # 1. 遵守robots协议设为False是因为本次练习目标页面在该站的robots中未禁止 ROBOTSTXT_OBEY False # 2. 设置请求UA不要用默认的Scrapy蜘蛛标识 USER_AGENT Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 # 3. 下载延迟设为1.5秒避免访问频率过高 DOWNLOAD_DELAY 1.5 # 4. 开启管道后面讲数据入库时会用到 ITEM_PIPELINES { qidian_books.pipelines.BookPipeline: 300, }写到这里很多人可能会问ROBOTSTXT_OBEY False是不是意味着可以无视robots协议不能这么理解。它只是让Scrapy不去自动读取robots文件属于技术层面的开关真正的合规判断得人工完成我前面已经说过我是先看了目标站点的robots约束再确定只抓公开书目页面。代码上的放开不等于行为上的放任每次做项目需要自己去把握边界。UAUser-Agent这个配置项很容易被忽略但实际影响很大。默认的Scrapy/2.xUA一眼就会被服务端的反爬策略识别出来换上浏览器UA并不意味着能绕过所有防护但它确实是最基本的身份伪装能减少大量无意义的拦截请求。DOWNLOAD_DELAY的设置是基于一个朴素逻辑1.5秒间隔在平摊到小几百页的抓取量时总时长完全可以接受又显著低于正常用户密集点击的频率。这个参数值的设置想清楚为什么比直接抄一个数字更有意义。3. 分析目标页面结构确定抓取字段与解析策略3.1 起点书架页的HTML结构观察我这次抓的是起点中文网的热门分类列表页用浏览器打开后按F12进入开发者工具先观察页面的HTML骨架。这类网站的列表页结构比较典型每本书的信息都放在一个大的容器节点里内部嵌着书名链接、作者、分类标签、简介文本、字数、连载状态等节点。我在分析的页面里看到的书架页结构具体来说是这样的一本完整的书籍卡片中书名在h2标签内的a标签里作者名在p标签带author的class中简介在一个p标签带intro的class中分类信息和状态信息则分布在几个a标签的class属性里。用XPath表述这个相对位置非常直观//div[classbook-mid-info]/h2/a/text() //p[classauthor]/a[1]/text() //p[classintro]/text()用scrapy shell可以直接验证这些表达式。很多人问为什么不直接用正则表达式我的答案是正则适合提取特征明显的纯字符串但面对嵌套标签、存在大量相对路径的场景写起来不仅容易错维护成本也高。XPath的本质是树的路径查找正好匹配HTML的层级结构而且表达式写错了报错信息会明确提示节点不存在调试难度低很多。3.2 在items.py中定义数据模型页面结构确认后先在items.py里把要提取的字段声明出来。这一步看起来像是形式化操作但它解决了爬虫开发中一个很具体的问题没有字段约束的爬虫写着写着就变成“看到什么提什么”最后字段要么缺失要么命名混乱。提前定好数据模型从源头明确了每一笔数据的边界。import scrapy class QidianBookItem(scrapy.Item): # 书名 book_name scrapy.Field() # 作者 author scrapy.Field() # 分类 category scrapy.Field() # 简介 intro scrapy.Field() # 字数 word_count scrapy.Field() # 连载状态 status scrapy.Field() # 书籍详情页链接 book_url scrapy.Field()这里的scrapy.Field()只是一个容器标记它并不限定字段的类型数据的类型转换在后面清洗环节处理。这个设计初看有点奇怪毕竟Python是动态语言直接给一个字典不是更省事但Item的真正优势在于字段的规范化声明在Pipeline阶段可以通过item.get()方法统一定位字段配合Field()的元信息可以挂载前后处理的配置。一旦数据量上去字段一多这种规范带来的维护收益会非常明显。3.3 编写第一个Spider在spiders目录下新建qidian.py这就是爬取动作的执行者。爬虫类的核心逻辑分为两步第一步是start_requests或者start_urls决定从哪些网址开始抓第二步是parse回调函数负责解析响应并提取数据。import scrapy from qidian_books.items import QidianBookItem class QidianSpider(scrapy.Spider): name qidian allowed_domains [qidian.com] start_urls [https://www.qidian.com/rank/yuepiao/] # 月票榜页面公开数据 def parse(self, response): # 定位每一本书的列表节点 book_list response.xpath(//div[contains(class,book-item)]) for book in book_list: item QidianBookItem() item[book_name] book.xpath(.//h2/a/text()).get() item[author] book.xpath(.//p[classauthor]/a[1]/text()).get() item[category] book.xpath(.//p[classauthor]/a[2]/text()).get() item[intro] book.xpath(.//p[classintro]/text()).get() item[status] book.xpath(.//p[classauthor]/span/text()).get() # 详情页链接由相对路径补全 relative_url book.xpath(.//h2/a/href).get() item[book_url] response.urljoin(relative_url) item[word_count] None # 字数通常不在列表页展示后续结合详情页处理 yield item这里有两个容易出错的地方我特意拆开讲。第一是allowed_domains的配置如果只填了qidian.com但页面里存在跳转到子域名的链接比如book.qidian.com后续请求会被过滤掉。实操中可以先把这个域名范围设宽一点比如[qidian.com, book.qidian.com]避免抓取过程中间被域名规则拦住。第二是.//h2/a/text()的前缀点号。列表节点已经定位到book-item节点内部如果这里写//而不是.//XPath会从整个文档根部重新查找结果就是每本书都提取到页面第一本书的数据。这个错误属于典型的“定位成功但取值失败”没有任何报错提示在实盘爬取中遇到这类问题最常见。用scrapy shell调试时可以刻意同时测带点号和不带点号的表达式对比节点集的区别。3.4 从第一步输出到完整跑通写完爬虫后在项目根目录运行scrapy crawl qidian -o books.json-o参数可以直接把数据输出到文件Scrapy会根据扩展名自动选择JSON格式序列化。跑完之后看一下books.json的内容一般能看到多条记录每本书的字段都填上了对应的值。这个环节我建议做一个看起来很笨但非常必要的操作先别急着继续写代码手动把输出文件和网页上的真实数据逐条对一遍。不需要全对抽个三到五条就行重点确认三件事没有错位、没有重复、没有大段空白。我过去在别人代码里见到过一种情况页面结构某一天微调之后简介节点的class属性变了旧的XPath表达式会静默失效页面抓下来全是一堆None值。如果等到数据入库之后才发现返工成本会非常高。4. 数据提取的实战难点Selector细节与踩坑记录4.1 extract()与get()的差异Scrapy的Selector返回的结果是一个SelectorList对象很多人一开始搞不清楚extract()、extract_first()、get()三者的区别。xpath(...)返回的是满足条件的所有节点extract()会返回一个字符串列表extract_first()返回列表的第一个字符串没有结果时返回Noneget()是extract_first()的简写方式。在解析单个字段时直接使用get()就好简洁且安全。如果用了extract()去取单个值拿到的会是一个列表存进Item后再写入数据库类型就变成了一堆带中括号的字符串后续的处理都得跟着擦屁股。推荐写法book.xpath(.//h2/a/text()).get() 不推荐写法book.xpath(.//h2/a/text()).extract()因为解析单个字段时列表类型会污染整个数据结构。4.2 连续空格的清洗问题一般页面中文本节点的内容都是规范的但当节点内部的标签中间混杂着span、i这类子标签时text()提取出来的字符串往往带着换行符和连续空格。比如简介文本实际内容是p classintro 某人穿越到玄幻世界 span却发现/span 灵气复苏的真相…… /p直接text()拿到的结果会和预期有很大偏差字符串前后带着\n和一堆四个空格宽度的缩进直接入库会显得很脏。我的处理方式是放在Pipeline里统一做后面会讲到。这里提一下是为了说明XPath取文本只是第一步清洗才是真正让数据可用的环节。4.3 三个我实测踩过的坑第一个坑是相对链接的缺失。详情页链接在HTML里通常是/book/12345/这种相对路径如果不做response.urljoin(relative_url)处理存进库里的链接就是残缺的后面想补爬详情页会非常麻烦。我一开始漏了这一步结果存进去的链接全是/book/12345/这种格式后来补数据时只能再手动拼URL白白折腾了一轮。第二个坑是“下一页”的处理。很多网站的分页链接放在列表底部而且第一页和后面页面的跳转标签结构相同。我最初的写法直接用response.xpath(//a[contains(text(),下一页)]/href)结果在最后一页会定位到“返回顶部”这类完全无关的链接导致最后一页反复重抓。正确做法是同时检查链接对象的有效性并且判断class属性里是否包含disabled状态。第三个坑是编码问题。起点中文网页面是UTF-8编码Scrapy的响应解码机制一般能处理好。但如果你在本地解析静态HTML文件或者手动保存过网页源码经常会遇到中文乱码。处理方式是显式指定编码with open(page.html, r, encodingutf-8) as f: html_content f.read() response scrapy.Selector(texthtml_content)别把编码处理想成“打开就是对的”一旦乱码所有后续解析都会变成无效功。4.4 善用scrapy shell定位问题在整个调试过程中scrapy shell是最常打开的工具。用法就是在命令行输入scrapy shell https://www.qidian.com/rank/yuepiao/进入交互环境后可以随便执行XPath表达式直接看到实时结果。我调试的固定流程是先用浏览器开发者工具复制一个简单的XPath验证节点存在性再在shell中测试精确表达式最后才写进Spider的parse方法里。这个流程能把错误拦截在进入爬虫之前而不是让爬虫跑一半再回头看日志。5. 数据落地Pipeline处理与结构化入库5.1 Item Pipeline的设计逻辑Spider把数据交给PipelinePipeline会按照settings.py里的ITEM_PIPELINES配置顺序依次处理。每个Pipeline类通常只负责一件事清洗、去重、入库这样做的好处是单项逻辑可以独立测试。我的管线分了两段先走清洗再走入库。清洗阶段的代码import re class BookCleanPipeline: def process_item(self, item, spider): # 清洗书名字段去除首尾空白 item[book_name] item[book_name].strip() if item.get(book_name) else item.get(book_name) # 清洗简介字段去除换行与多余空格 if item.get(intro): cleaned_intro re.sub(r\s, , item[intro]).strip() item[intro] cleaned_intro # 清洗字数从xxx万字提取数字转为整数 if item.get(word_count): match re.search(r(\d\.?\d*), item[word_count]) if match: item[word_count] float(match.group(1)) else: item[word_count] None return item清洗的核心是“一个字段一个规则”不要试图在一个函数里做太多事。re.sub(r\s, , ...)把换行、Tab、连续空格统一替换成单个空格看起来简单但在真实网页文本里非常管用。5.2 入库部分用SQLite起步最稳妥数据库选型不需要在起步阶段上重装备SQLite足够支撑学习项目而且零配置、单文件、随项目走后面做完实验直接拷贝数据库文件就能归档数据。建表和写入的代码我放在Pipeline里import sqlite3 class BookSQLitePipeline: def open_spider(self, spider): # 爬虫启动时建立数据库连接与表 self.conn sqlite3.connect(books.db) self.cursor self.conn.cursor() self.cursor.execute( CREATE TABLE IF NOT EXISTS books ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_name TEXT, author TEXT, category TEXT, intro TEXT, word_count REAL, status TEXT, book_url TEXT, crawled_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ) def process_item(self, item, spider): self.cursor.execute( INSERT INTO books (book_name, author, category, intro, word_count, status, book_url) VALUES (?, ?, ?, ?, ?, ?, ?) , ( item.get(book_name), item.get(author), item.get(category), item.get(intro), item.get(word_count), item.get(status), item.get(book_url) )) self.conn.commit() return item def close_spider(self, spider): self.conn.close()open_spider和close_spider是Pipeline的生命周期钩子分别在爬虫启动和结束时执行这种设计能确保数据库连接只在爬虫运行期间持有不会出现长连接悬挂的问题。crawled_at字段用数据库默认时间可以区分不同批次抓取的数据避免后续实验把多批数据混在一起分不清楚。5.3 关于JSON导出的补充如果用-o导出JSON文件Scrapy默认会以UTF-8编码保存文本这没问题。但如果你是导出CSV又要用Excel打开就会遇到中文乱码问题。原因比较基础但很典型Excel默认用ANSI编码读取CSV而UTF-8编码的中文用ANSI解码时就是乱码。我提供两个可行解法按自己情况选一个# 方式1导入时不直接打开在Excel的“数据-自文本/CSV”导入时选择UTF-8编码 # 方式2先导出CSV再用Python转码为utf-8-sigutf-8-sig这个编码会在文件开头写入BOM标记Excel识别到它就明确知道用UTF-8解析。实际操作中我一般图省事直接用JSON格式做交付避免这一层编码问题。6. 翻页抓取与并发控制实战6.1 实现多页抓取的正确姿势起点热门榜通常有不止一页数据。实现翻页的方式是在parse方法的末尾构造下一页的URL并通过yield scrapy.Request(next_url, callbackself.parse)发起新请求。这个回调循环是Scrapy处理翻页的标准方式。关键点在于翻页终止条件的判断我的做法是拿“下一页”按钮的class属性做判断存在明确标识时继续请求否则停止。next_page response.xpath(//a[contains(text(),下一页) and not(contains(class,disabled))]/href).get() if next_page: yield scrapy.Request(urlresponse.urljoin(next_page), callbackself.parse)这里用not(contains(class,disabled))非常关键最后一页的“下一页”虽然还渲染在页面上但class属性已经变了不带这个判断就会在最后一页死循环。类似这个判断逻辑的写法在各类网站的分页处理中通用性很强。6.2 跟上Scrapy的并发调度节奏Scrapy默认的并发请求数是16如果再加上DOWNLOAD_DELAY 1.5实际的抓取节奏大概是多少简单算一下1.5秒延迟平均到16个并发请求调度器会按照该配置自动错开请求时间整体的抓取速率不会造成目标服务器的访问压力。这个配置组合的出发点是稳妥优先宁可慢一点别让IP段被服务端注意。如果你的目标网站响应很快、页面也轻量可以适当把并发数调到8、延迟降到0.5秒来提升速度。但这个调整要在实测没有出现大量429或403响应码的前提下进行。每次看到这两个状态码我都会先停下手上的优化冲动回到“礼貌爬取”的思路上重新评估参数。6.3 关于动态加载页面的思考起点中文网的榜单列表页在目前环境下是服务端渲染的HTML里直接包含数据用Scrapy就能拿到全部内容。但如果目标站点是数据通过AJAX接口异步加载的情况就完全不同了——直接抓HTML只能拿到一个空壳子。处理这种事情有两个方向。第一个方向是分析浏览器发出的XHR请求找到真实的JSON接口这是效率最高、数据最干净的方法。第二个方向是使用无头浏览器渲染页面后再抓取比如配合Playwright这类工具但代价是内存开销大、爬取速度明显下降。我在做这个项目时没用上这个方案因为目标页面是SSR结构。这里展开讲是想提醒遇到页面内容抓不全先判断是不是动态加载问题别一上来就无脑换无头浏览器优先找接口才是正道。6.4 LIMIT的边界控制请求体量最后说一个经常被忽略的点爬虫的start_urls和翻页逻辑没有做任何数量限制理论上它会一直爬到最后一页。学习项目的目标不是把全站数据爬光我只抓了前五页的榜单数据足够验证爬虫逻辑的正确性。具体实现是在Spider里加一个页码计数器class QidianSpider(scrapy.Spider): name qidian start_urls [https://www.qidian.com/rank/yuepiao/] max_page 5 current_page 0 def parse(self, response): self.current_page 1 # ... 解析数据的逻辑 ... if self.current_page self.max_page: next_page response.xpath(//a[contains(text(),下一页) and not(contains(class,disabled))]/href).get() if next_page: yield scrapy.Request(urlresponse.urljoin(next_page), callbackself.parse)这个习惯会内化成一种规范不管爬什么网站先预估总共需要的量给爬虫一个明确的停止条件。对规模有意识的控制本身也是从业者对目标站点负责任的表现。7. 实测过程中最值得说的几点体会整个项目跑下来我最大的感受是爬虫开发里真正花时间的不是写代码而是调试页面结构和处理数据边界。这个项目从零到最终跑通大概花了一个晚上加一个上午其中接近一半时间是在和XPath表达式及字段清洗打交道。有几条心得我觉得值得单独拿出来讲。第一凡是涉及text()的提取不要对原始输出抱有幻想直接套一个清洗动作哪怕现在用不上后面数据量大了也一定会用上。第二断点续爬不要一开始就做先让单次全量跑通把数据链路稳定再考虑增量抓取这样问题定位会清晰很多。第三数据库字段在创建初期就预留一个crawled_at时间戳这个字段可能当下用不上但后续做数据对比时它就是分水岭。第四调试时不要每次改动代码都启动全量抓取用scrapy crawl qidian -a配合只抓前几页测试试错成本会低非常多。如果你想在这个项目的方向上继续拓展可以试试下面几条路线用CrawlSpider替换Spider自动化处理分页规则给Pipeline增加数据去重逻辑以书名和作者两个字段做联合主键判断把存储端从SQLite换成MySQL补上详情页的抓取把字数和章节数据一起入库。每一条路线的难度递增都不大但都能逼着你把对应模块吃得更深。我这儿准备的这份入门材料从页面分析到数据落地已经是一套可以完整跑通的闭环。剩下的事情就很简单了打开你自己的编辑器建一个qidian_books项目亲自把这段流程走一遍。踩几个坑比看十篇经验分享都管用。