最近处理一个采集需求时有点感触:页面量不算大,但要求每天增量跑,还要自动重试、去重、定时调度。一开始图省事用 requests 配合 BeautifulSoup 写脚本,结果跑了没两天就发现,各种突发状况全得自己兜——某个页面超时了没人管,同一条数据反复下载,改一个字段名要翻好几处代码。后来把脚本推倒重来,换成了 Scrapy 爬虫框架,这些工程化的问题基本都被框架接管了。
这篇内容主要面向两类人:一类是刚接触爬虫、想系统了解 Scrapy 是什么的初学者;另一类是写过不少 requests 脚本,但发现项目越来越大、越来越难维护,想找一个更规范方案的开发者。我会把 Scrapy 的概念、核心组件、工作流程和入门操作完整讲一遍,中间穿插一些我实际使用中总结出来的细节和坑。
1. Scrapy 到底是什么?先理解它解决了什么问题
1.1 用流水线来理解框架
如果把爬虫比作一条自动流水线,那么 Scrapy 就是这条流水线的总控系统。你只需要站在某个工位(Spider)旁边,负责把送过来的页面资料进行加工解析,剩下的事情——原材料从哪拿、怎么排队、谁来下载、成品送到哪里——都由流水线自动完成。
很多初学者会把 Scrapy 误当成一个"发起 HTTP 请求的库",其实它远不止这么简单。它是一整套爬虫工程解决方案,包含了调度、并发、下载、解析、去重、重试、中间件、数据管道等完整环节。用它写爬虫,本质上是按照它的规则去组织自己的代码,而不是像用 requests 那样每次手动构造请求、手动处理响应。
1.2 Scrapy 与 requests + BeautifulSoup 脚本的区别
我自己早期写爬虫,基本都是 requests 拿页面,BeautifulSoup/lxml 解析,再手动存数据库。这种方式在小规模场景下完全够用,但一旦页面数量多了、需求复杂了,差距就非常明显。我用表格列一下两者典型差异:
| 对比项 | requests 手写脚本 | Scrapy 框架 |
|---|---|---|
| 并发控制 | 串行请求或手写线程池,代码容易失控 | 内置异步并发,通过配置项控制并发数 |
| 请求去重 | 自己维护已访问集合,重启进程就丢失 | 内置去重过滤器,基于请求指纹全局去重 |
| 失败重试 | 手动捕获异常、自己写重试逻辑 | 下载中间件自动重试,可配置次数与延迟 |
| 请求调度 | 基本靠循环和队列,优先级很难做 | 内置调度器,支持请求优先级和深度控制 |
| 数据出口 | 自己写读写文件的代码 | Item Pipeline 统一清洗、校验、存储 |
| 扩展能力 | 改代码到处打补丁 | 中间件机制,在请求前后插入自定义逻辑 |
这里不是说手写脚本就不好,而是当爬虫从"一次性脚本"变成"持续运行的数据采集任务"时,Scrapy 能让代码结构更清晰、运行更稳定。它把很多重复性的工程问题固定成了标准流程,我们只需要关注页面解析和数据处理本身。
1.3 Scrapy 适合什么场景
基于我自己的使用经验,Scrapy 适合这几类场景:
- 多页面、多层级的站点,比如需要先爬列表页、再跟进详情页;
- 需要长期增量运行的采集任务,比如每天定时更新价格或文章;
- 对数据结构化程度要求较高的项目,需要输出统一字段;
- 需要一定并发吞吐量,又不希望手动维护线程池和请求队列的场景。
但如果你只是临时取一个页面的几个字段,或者任务高度依赖浏览器渲染、需要大量模拟点击操作,那 Scrapy 反而不一定是最轻的选择。它更擅长的是"下载 HTML 并解析"这个模式。对于复杂的 JS 渲染页面,Scrapy 可以搭配无头浏览器使用,但那属于进阶话题,入门阶段还是先专注它最擅长的部分。
2. Scrapy 的工作流程:一个请求的完整一生
2.1 核心组件一览
Scrapy 的整套架构核心组件并不多,搞清楚它们各自负责什么,整个工作流程就很好理解了。
| 组件 | 职责 |
|---|---|
| 引擎(Engine) | 整个系统的中枢,负责在各组件之间传递数据和控制信号 |
| 调度器(Scheduler) | 接收引擎发来的请求,放入队列并按规则调度 |
| 下载器(Downloader) | 真正执行 HTTP 请求,获取页面响应 |
| 爬虫(Spiders) | 编写解析规则的地方,从响应中提取数据或生成新请求 |
| 项目管道(Item Pipeline) | 对爬虫产出的 Item 做清洗、校验、去重、存储 |
| 下载中间件(Downloader Middlewares) | 在请求发出前、响应返回后插入处理逻辑,比如改 UA、加代理 |
| 爬虫中间件(Spider Middlewares) | 在爬虫收到响应、生成 Item/Request 时插入处理逻辑 |
初学者不用急着把所有组件都研究透,先记住一个简化版心智模型:引擎发命令,调度器排任务,下载器取网页,爬虫做解析,管道存数据。
2.2 数据流逐步拆解
我按照一次完整流程的顺序拆一下,每一步都对应到上面的组件。
- 引擎从爬虫中获取初始的起始请求(Start Requests),比如 start_urls 中配置的 URL。
- 引擎把这些请求交给调度器,调度器将它们放入请求队列,等待调度。
- 引擎从调度器获取下一个待发送的请求。
- 引擎将请求交给下载器去下载页面。这个过程中请求会经过下载中间件,所以可以在这里统一修改请求头、设置代理等。
- 下载器拿到响应之后,将 Response 交回给引擎。
- 引擎将 Response 交给对应的爬虫。爬虫的 parse 方法收到这个 Response 后进行解析,产出的可能是 Item,也可能是新的 Request。
- 如果是新 Request,流程回到第 2 步继续循环;如果是 Item,则交给 Item Pipeline 处理。
- Item Pipeline 对数据进行清洗、校验、去重,最终输出到文件或数据库。当调度器队列为空,引擎关闭整个爬虫,流程结束。
这个流程看着简单,但它恰恰是 Scrapy 最核心的设计思路:所有环节之间并不直接互相调用,而是通过引擎传递。这也意味着你可以在任意环节插入自定义处理,而不影响其他部分。
2.3 用示例站点走一遍流程
为了不让概念停留在纸面上,我用一个公开的演示站点 quotes.toscrape.com 来串一遍。这个站点是一个分页的名言列表,非常适合入门演示。
假设第一条起始请求是http://quotes.toscrape.com/,引擎会把请求交给调度器,调度器排队后,下载器请求该页面并拿到 HTML。引擎把 Response 交给爬虫的 parse 方法,parse 从页面里提取名言文本、作者和标签,生成一个字典类型的 Item,同时从页面底部找到"Next"链接,生成一个新的 Request 继续爬下一页。
新 Request 重新进入调度器,如此往复,直到最后一页没有下一页链接,整个爬虫自然结束。这个过程看起来没什么特别,但正因为调度、下载和重试都被框架接管了,代码才变得非常简洁。
2.4 关于异步和并发,新手需要知道的底线
Scrapy 基于 Twisted 异步事件循环实现并发,下载器在等待一个请求响应的时候,不会傻傻阻塞在那里,而是会同时发起其他请求。所以你会看到,明明代码里没有写多线程,Scrapy 却可以同时下载很多页面。
对初学者来说,不需要一开始就深究事件循环的实现原理,但建议记住一个事实:Scrapy 的并发是异步非阻塞的,它能同时跑很多请求。真正需要你操心的是目标站点的承受能力,以及你自己配置的并发数和下载延迟。
3. 从零开始:跑起你的第一个 Scrapy 项目
3.1 环境准备与安装
Scrapy 需要 Python 3.8 以上版本。我建议所有项目都使用虚拟环境,避免不同项目依赖互相打架。以下命令在终端中执行:
python -m venv venv source venv/bin/activateWindows 环境下激活虚拟环境使用venv\Scripts\activate。激活后安装 Scrapy:
pip install scrapy scrapy version如果能看到版本号,说明安装成功。Scrapy 在安装时会自动带上 lxml、parsel、Twisted 这些核心依赖,所以正常情况下不需要额外手动装选择器相关库。
3.2 创建项目与目录结构
在终端中执行:
scrapy startproject quote_demo执行后当前目录下会生成一个名为 quote_demo 的项目,结构大致如下:
quote_demo/ ├── scrapy.cfg ├── quote_demo/ │ ├── __init__.py │ ├── items.py │ ├── middlewares.py │ ├── pipelines.py │ ├── settings.py │ └── spiders/ │ └── __init__.py几个关键文件的作用:
scrapy.cfg:项目全局配置文件,部署或运行时会读取这里。settings.py:所有核心配置集中地,UA、并发数、下载延迟、管道、日志级别都在这里调整。items.py:定义我们要提取的字段结构。pipelines.py:写数据处理管道的地方。middlewares.py:写下载中间件和爬虫中间件的地方。spiders/:存放爬虫代码的目录,一个站点一个爬虫文件。
第一次创建项目时,不建议看到 settings.py 里一堆配置就慌,绝大多数默认值都能跑通,后面再按需调。
3.3 先想清楚要什么字段:定义 Item
很多新手一上来就直接写解析代码,这是很常见的误区。我更习惯先打开 items.py,把最终要输出的字段定义好,哪怕后面还会调整。字段定义决定了你解析时该提取什么,也决定了管道里能处理什么。
用刚才的演示站点举例,一条名言数据包含三个核心字段:内容、作者、标签。在 items.py 中定义如下:
import scrapy class QuoteItem(scrapy.Item): text = scrapy.Field() author = scrapy.Field() tags = scrapy.Field()Item 本质上是一个增强版的字典,字段定义出来后,代码提示更友好,后续在管道中做字段级处理也更方便。当然,Scrapy 也允许你直接 yield 一个字典,比如yield {'text': ..., 'author': ...},这在快速验证时完全没问题。但项目稍微正式一点,我还是建议用 Item 把结构固定下来。
3.4 编写第一个 Spider
在 spiders 目录下新建quotes.py,内容如下:
import scrapy from quote_demo.items import QuoteItem class QuotesSpider(scrapy.Spider): name = 'quotes' allowed_domains = ['quotes.toscrape.com'] start_urls = ['http://quotes.toscrape.com/'] def parse(self, response): for quote in response.css('div.quote'): item = QuoteItem() item['text'] = quote.css('span.text::text').get() item['author'] = quote.css('small.author::text').get() item['tags'] = quote.css('div.tags a.tag::text').getall() yield item next_page = response.css('li.next a::attr(href)').get() if next_page: yield response.follow(next_page, callback=self.parse)代码逻辑非常直白:
start_urls告诉爬虫第一个要访问的地址。parse是 Scrapy 默认的解析回调方法,Response 会先到这里。- 每个
div.quote代表一条名言,用 CSS 选择器提取对应字段。 - 页面底部有下一页链接时,通过
response.follow生成新请求,继续让parse处理。
这里最值得学习的是"递归式翻页"的思路。response.follow会自己处理相对 URL,你不用手动拼接站点域名,也基本不会写错链接。
3.5 提取数据的两种选择器写法
Scrapy 内置了 CSS 和 XPath 两套选择器,Response 对象上分别对应response.css(...)和response.xpath(...)。我个人的习惯是优先用 CSS 写结构简单的选择,遇到复杂条件再换 XPath。
几个高频方法的区别需要记牢:
.get():返回第一个匹配结果,没有则返回None,常用于取单个字段。.getall():返回所有匹配结果的列表,常用于标签、图片链接这类多值字段。::text:提取标签内的文本内容。::attr(href):提取指定属性的值。
XPath 的写法对应关系大致是:
# CSS 写法提取文本 quote.css('span.text::text').get() # 等价 XPath 写法 quote.xpath('./span[@class="text"]/text()').get()XPath 里有个特别容易坑新手的点:索引从 1 开始。如果你用 XPath 取第一项,应该写[1]而不是[0]。CSS 的:first-child则不存在这个混淆,但结构复杂时不太好表达,所以我建议两种语法都顺手能用。
3.6 运行爬虫与输出数据
回到项目根目录 quote_demo 下,执行:
scrapy crawl quotes终端会输出日志。想直接把结果保存成文件,可以加-o参数:
scrapy crawl quotes -o quotes.json看到输出文件里有名言数据,第一个 Scrapy 爬虫就算跑通了。
-o会不断追加内容,如果希望每次运行都覆盖文件,可以把小写-o换成大写-O,让它直接重写文件。快速验证阶段非常实用。
4. 数据清洗与落地的标准姿势:Item Pipeline
4.1 Pipeline 到底能干什么
Pipeline 是 Scrapy 中处理数据最后的关口,它承担四类常见任务:
- 清洗:去掉字符串两端的空白、合并脏数据、统一格式。
- 校验:检查字段是否完整,不完整的数据可以直接丢弃或打日志。
- 去重:判断数据是否已经处理过,避免重复入库。
- 存储:把 Item 写入文件、数据库或发送到下游接口。
Pipeline 是按顺序执行的,你可以写多个类分别处理不同的事,而不是把所有逻辑堆在一个类里。
4.2 Pipeline 的生命周期方法
一个最小的 Pipeline 长这样:
class CleanPipeline: def process_item(self, item, spider): return itemprocess_item是必须实现的方法,Scrapy 会把爬虫产出的每个 Item 依次传进来。这里有个关键规则:处理完必须把 Item 返回出去。如果你忘记 return,这个 Item 会在当前管道被截断,后续管道统统收不到它。
除了process_item,还有两个生命周期方法很常用:
open_spider(spider):爬虫启动时调用一次,适合用来建立数据库连接、打开文件句柄。close_spider(spider):爬虫关闭时调用一次,适合关闭连接、提交事务。
4.3 一个清洗 Pipeline 加一个存储 Pipeline 的示例
先用清洗管道去除字符串空白:
class CleanPipeline: def process_item(self, item, spider): if item.get('text'): item['text'] = item['text'].strip() if item.get('author'): item['author'] = item['author'].strip() return item再用存储管道把数据写入 SQLite 文件:
import sqlite3 class SQLitePipeline: def open_spider(self, spider): self.conn = sqlite3.connect('quotes.db') self.conn.execute( 'create table if not exists quotes(text text, author text, tags text)' ) def close_spider(self, spider): self.conn.commit() self.conn.close() def process_item(self, item, spider): self.conn.execute( 'insert into quotes values(?, ?, ?)', (item.get('text'), item.get('author'), ','.join(item.get('tags') or [])) ) return item这里用open_spider创建连接、close_spider提交并关闭,是数据库类管道最标准的写法。SQLite 是 Python 自带的,不需要额外服务,适合练习和轻量存储。真实项目中换成 MySQL、PostgreSQL 等也只是连接方式和 SQL 语句不同,套路完全一致。
4.4 在 settings.py 中启用 Pipeline
Pipeline 写完后,必须在 settings.py 的ITEM_PIPELINES中注册才会生效:
ITEM_PIPELINES = { 'quote_demo.pipelines.CleanPipeline': 300, 'quote_demo.pipelines.SQLitePipeline': 400, }字典的值是执行顺序,数字越小越先执行。上面例子中,CleanPipeline 先跑,SQLitePipeline 后跑,基本符合预期。
新手很容易忽略这个注册步骤,写了半天管道发现数据没有入库,最后发现是 settings.py 里没加配置。这个坑我至少见过十次。
4.5 建议先从文件输出起步
虽然 Pipeline 可以存储数据,但我还是建议入门阶段先用scrapy crawl quotes -o quotes.json或FEEDS配置把数据落成文件,确认解析本身没问题之后,再引入数据库管道。原因很简单:文件输出不需要额外环境,排查问题更快;数据库管道一旦出现连接、事务、字段类型问题,会和解析问题混在一起,排错难度直接翻倍。
settings.py 中也可以用 FEEDS 配置替代命令行参数,比如:
FEEDS = { 'quotes.json': { 'format': 'json', 'encoding': 'utf-8', 'overwrite': True, }, }这样每次运行都会自动输出到 json 文件,不用再手敲-o。
5. 入门阶段最常见的坑,以及我的处理习惯
5.1 忘记改 User-Agent,响应 403
Scrapy 默认的 User-Agent 长这样:Scrapy/版本号 (+https://scrapy.org)。这个 UA 太明显了,很多站点看到就直接拒绝访问,表现就是大量 403 响应。
解决方式是修改 settings.py 中的配置:
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'或者写一个下载中间件,在每次请求前动态设置 UA。入门阶段先把配置文件里的 UA 改成一个常规浏览器的 UA 即可,不要小看这一步,它往往比调并发参数更有效。
5.2 日志太吵,找不到关键信息
第一次跑scrapy crawl quotes时,控制台会刷出大量 INFO 级别日志。页面多的时候,真正关心的错误信息会被淹没。可以在 settings.py 中把日志级别调高:
LOG_LEVEL = 'WARNING'这样只有警告和错误才会输出。需要排查细节时可以临时改成DEBUG,跑完再改回来。这个小配置能让调试体验提升非常多。
5.3 选择器细节:XPath 索引、get 与 getall
前面提过 XPath 索引从 1 开始,实际上这是我见过最多的低级错误。另外就是.get()和.extract()的混用。
在比较新的 Scrapy 版本里,官方更推荐.get()和.getall()。旧代码里常见的.extract()和.extract_first()虽然还能用,但新项目直接使用新API即可,也避免以后迁移代码。
如果你用 CSS 选择器取属性,一定要带::attr(属性名),比如a::attr(href)。忘了写属性名只写a,取到的是整个 a 标签的 HTML,字段值就会变得很奇怪。
5.4 翻页时不要手动拼 URL
有些页面下一页链接是相对路径,比如/page/2/。新手常见的做法是'http://quotes.toscrape.com' + href,这样能用,但容易出问题——万一域名变了,或者链接里带了查询参数,拼接逻辑就乱了。
正确做法是使用response.follow:
yield response.follow(next_page, callback=self.parse)它会自动处理相对 URL 和绝对 URL,也允许传params参数追加查询串。另一个相关方法是scrapy.Request,当你需要构建一个全新 URL 并且不基于当前 response 时,再使用scrapy.Request也不迟。
5.5 回调函数传参,用 cb_kwargs
爬虫进入详情页后,通常还需要带上列表页已经提取的字段。很多人会用实例变量或者全局变量去存,结果多个并发请求一跑,数据全串了。
正确做法是用cb_kwargs传参:
def parse_list(self, response): for link in response.css('a.detail::attr(href)').getall(): yield scrapy.Request( url=response.urljoin(link), callback=self.parse_detail, cb_kwargs={'source': 'list_page'} ) def parse_detail(self, response, source): yield { 'url': response.url, 'source': source, }cb_kwargs是随请求绑定的一组参数,响应回来后原样传给回调函数,不会因为并发而错乱。这是一个非常实用的技巧,宁可多写两个参数,也不要依赖全局状态。
5.6 重启爬虫后的重复数据
Scrapy 的去重是基于请求指纹的,默认在一次运行过程中生效。如果你重复运行爬虫,同一批 URL 依然会被重复请求、重复输出。这不是 bug,而是设计如此——每一次运行都被视为全新任务。
如果你希望历史数据不重复,通常有两条路:一是在 Pipeline 里根据业务主键做去重,比如查询数据库里是否已存在相同内容的记录;二是让爬虫本身只请求增量页面,比如带上时间参数或使用dont_filter=True绕过默认去重。前者更稳妥,后者要确保你真的需要重复请求。
5.7 被限制访问,先调低频率再想其他办法
遇到站点突然返回验证码或大量超时,第一反应不应该是去对抗,而是先降低访问压力。settings.py 里几个参数非常管用:
DOWNLOAD_DELAY = 2 CONCURRENT_REQUESTS = 8 AUTOTHROTTLE_ENABLED = TrueDOWNLOAD_DELAY:每次请求之间至少间隔的秒数。CONCURRENT_REQUESTS:最大并发数。AUTOTHROTTLE_ENABLED:自动限速,根据服务器响应时间动态调整延迟。
访问频率越文明,爬虫活下来的概率越高。不要为了追求速度把并发调到上百,结果被站点拉黑,得不偿失。
最后补一个我养成的习惯:每写一个新爬虫,先用scrapy shell把页面和选择器调好,再写进 parse 方法。scrapy shell <url>可以打开一个交互式环境,在那里即时试验response.css和response.xpath,不用反复改代码、重新跑爬虫。别看这个动作小,它省掉的时间远比想象中多。Scrapy 能帮你把请求、调度和存储都理顺,但页面结构永远是你自己需要花心思的地方。希望这篇能让你少走几步弯路。