☰
Scrapy爬虫框架入门:从requests脚本到工程化数据采集
2026/10/15 11:56:31 网站建设 项目流程

最近处理一个采集需求时有点感触:页面量不算大,但要求每天增量跑,还要自动重试、去重、定时调度。一开始图省事用 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 数据流逐步拆解

我按照一次完整流程的顺序拆一下,每一步都对应到上面的组件。

  1. 引擎从爬虫中获取初始的起始请求(Start Requests),比如 start_urls 中配置的 URL。
  2. 引擎把这些请求交给调度器,调度器将它们放入请求队列,等待调度。
  3. 引擎从调度器获取下一个待发送的请求。
  4. 引擎将请求交给下载器去下载页面。这个过程中请求会经过下载中间件,所以可以在这里统一修改请求头、设置代理等。
  5. 下载器拿到响应之后,将 Response 交回给引擎。
  6. 引擎将 Response 交给对应的爬虫。爬虫的 parse 方法收到这个 Response 后进行解析,产出的可能是 Item,也可能是新的 Request。
  7. 如果是新 Request,流程回到第 2 步继续循环;如果是 Item,则交给 Item Pipeline 处理。
  8. 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/activate

Windows 环境下激活虚拟环境使用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 item

process_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 = True
  • DOWNLOAD_DELAY:每次请求之间至少间隔的秒数。
  • CONCURRENT_REQUESTS:最大并发数。
  • AUTOTHROTTLE_ENABLED:自动限速,根据服务器响应时间动态调整延迟。

访问频率越文明,爬虫活下来的概率越高。不要为了追求速度把并发调到上百,结果被站点拉黑,得不偿失。

最后补一个我养成的习惯:每写一个新爬虫,先用scrapy shell把页面和选择器调好,再写进 parse 方法。scrapy shell <url>可以打开一个交互式环境,在那里即时试验response.css和response.xpath,不用反复改代码、重新跑爬虫。别看这个动作小,它省掉的时间远比想象中多。Scrapy 能帮你把请求、调度和存储都理顺,但页面结构永远是你自己需要花心思的地方。希望这篇能让你少走几步弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询