资讯动态

Scrapy爬虫框架实战:核心组件、中间件与动态页面处理全解析

发布时间:2026/9/14 15:41:59 来源:尧图企业网站定制
Python爬虫写多了你会发现一个规律requests加BeautifulSoup写起来确实快但项目一大真正耗时间的不是请求和解析而是去重、重试、并发控制、数据清洗、断点续爬这些“周边工程”。Scrapy是我在生产环境里用得最多的爬虫框架它把这类能力基本都内置了让你专注在核心两件事上页面数据怎么解析解析完怎么存储。这篇文章以一个完整的爬虫工程为例带你把Scrapy框架的核心组件、运行流程、中间件机制、动态页面处理和常见坑位都过一遍。还没装环境的朋友可以先看第三部分把Python和Scrapy装好再回头看原理会更容易上手。1. 为什么选择Scrapy而不是继续用requests脚本1.1 从“脚本爬虫”切换到Scrapy的收益先用我自己的一段经历来说。早年间我写过很多一次性脚本结构都差不多requests拿HTMLBeautifulSoup抽字段最后写到CSV里。代码少的时候三五十行搞定爽。但一旦页面有几百上千个、需要翻页、需要处理登录态、需要断点继续跑麻烦就来了——每个脚本都在重复造轮子而且轮子造得都不太圆。比如并发请求要自己开线程池还得小心线程安全问题请求失败要自己写重试逻辑URL去重要自己维护一个set内存一多就有点慌数据清洗和存储代码跟抓取逻辑缠在一起改一个字段要动半个文件。Scrapy把这些横切关注点全部抽成了框架级能力。你写的是Spider但跑起来的是一个由引擎、调度器、下载器、管道组成的异步处理系统。爬虫只要把“提取什么、提取后干什么”讲清楚剩下的调度、去重、并发、重试框架替你处理。1.2 Scrapy和requests/bs4方案的本质区别很多新手会问Scrapy是不是就是一个封装好的HTTP客户端其实不是。requests是同步阻塞的发一个请求等一个响应Scrapy基于Twisted异步网络库底层是事件循环可以在等待响应的同时继续发其他请求。这意味着同样一台机器用Scrapy的并发能力通常比requests手写线程池要高一个台阶而且代码还更简单。我用一个对比表来说清楚维度requests BeautifulSoupScrapy请求方式同步阻塞异步并发自带的调度器管理请求顺序数据解析BeautifulSoup/lxmlSelector支持XPath和CSS底层也是lxml去重自己写自带基于请求指纹的去重队列重试/异常自己try/except中间件机制统一处理数据存储自己写IO逻辑Item Pipeline可拆分成多个清洗和存储阶段扩展生态基本没有中间件、扩展、管道、命令行工具齐全适合场景快速脚本、单页少量抓取中大型爬虫、长期运行的抓取任务当然requests脚本并非一无是处。如果只是抓一个接口、解析几行JSONrequests仍然是最轻的选择。但当你开始考虑“这个爬虫要跑一周”“每天增量更新”“需要监控异常”时Scrapy的工程化价值就体现出来了。1.3 哪些项目适合用Scrapy哪些不适合适合Scrapy的典型场景站点页面结构相对规整比如列表页加详情页、分页遍历需要抓的页面数量大对吞吐量有要求需要定时增量抓取、断点续跑需要把不同来源的数据统一清洗后入库。不适合或者需要额外处理的场景单页面全动态渲染数据全靠JavaScript异步生成且内嵌大量复杂交互。这种单靠Scrapy默认下载器拿不到内容需要配合Playwright/Selenium或者直接分析XHR接口目标站点反爬非常强甚至需要滑块验证、指纹检测。这时Scrapy的轻量请求反而容易被识别要考虑更完整的浏览器方案数据源本身是公开API返回的就是JSON。这种情况用requests也很快不一定要上Scrapy除非你想借用它的调度和管道能力。我的建议是先判断页面内容在“查看源代码”里能否看到。能直接看到Scrapy就是优选看不到就得在动态渲染这一层想办法。后面第五章会专门讲这个场景。2. 核心组件与请求生命周期搞懂一次抓取是怎么跑起来的2.1 五大核心组件各自的分工Scrapy的架构设计借鉴了很多经典框架的思路核心组件之间职责单一、通过引擎调度协作。我习惯把它的架构理解成一个小型工厂流水线组件职责生活化类比Scrapy Engine引擎负责数据流在各组件间流转控制所有事件触发工厂的中央调度台Scheduler调度器接收引擎发来的请求按优先级排序、去重后排队任务分发队列Downloader下载器真正执行HTTP请求把响应交给引擎负责跑腿取货的物流Spider爬虫解析响应生成Item数据同时生成新的请求车间里的拆解工Item Pipeline管道接收Item做清洗、验证、去重、存储质检和打包发货区除了这五大组件还有两个重要的扩展位Downloader Middleware下载器中间件和Spider Middleware爬虫中间件。它们像流水线上的插件工位可以在请求发出前、响应回来后、Item送出前做各种横切处理。后面第四章专门说。2.2 一次请求从发起到落库的完整链路只看组件名可能有点抽象我按一次典型请求的流程走一遍Spider的start_requests方法生成初始Request交给引擎引擎把这个Request交给调度器调度器计算请求指纹并去重然后按照优先级放入待处理队列调度器从队列里取出一个Request交给下载器去执行网络请求下载器向目标站点发起HTTP请求拿到Response后先经过下载器中间件链再回传给引擎引擎把Response交给对应的Spider调用Spider里注册好的回调函数通常是parseSpider解析出结构化数据Item以及需要继续抓取的URL新的RequestItem会被引擎交给Spider Middleware再依次经过Item Pipeline做清洗和存储新的Request重新进入调度器重复以上过程直到整个抓取任务完成。这里最关键的点是整个链路是异步的。调度器、下载器、Spider之间通过引擎传递数据但下载器不会傻等一个请求返回才继续下一步。Twisted的事件循环让等待I/O的时间被其他请求填满所以当你看到CONCURRENT_REQUESTS16这种配置时含义是同一时刻最多有16个请求在网络链路里“飞行”。2.3 Item到底要不要定义字段Scrapy官方建议定义Item类但实际开发中很多人直接用字典。我的经验是小项目用字典确实快但一旦字段多了、对接多个数据源时字段名写错在字典里是静默发生的而在Item类上定义字段后能提前发现问题。更重要的是Item类可以加元数据、可以嵌套配合ItemLoader做输入处理器数据清洗能做得非常规整。举个例子import scrapy class BookItem(scrapy.Item): title scrapy.Field() price scrapy.Field() rating scrapy.Field() detail_url scrapy.Field()定义完Item后在Spider里使用item BookItem() item[title] book.css(h3 a::attr(title)).get() item[price] book.css(p.price_color::text).get()如果你现在还理解不了为什么不用普通字典没关系先照这个写法来后面字段多了自然会体会到好处。3. 从零开始搭一个可运行的Scrapy项目3.1 环境准备与项目结构先解决环境问题。Scrapy是Python库理论上Python 3.8以上都能跑我推荐用Python 3.10或3.11兼容性最稳。在Python官网下载安装包后安装时记得勾选“Add Python to PATH”。装好后打开终端创建虚拟环境并安装Scrapypython -m venv venv source venv/bin/activate # Windows下是 venv\Scripts\activate pip install scrapy然后创建项目scrapy startproject bookspider cd bookspider这时Scrapy会给你生成一套完整项目骨架bookspider/ ├── scrapy.cfg # 项目配置文件 ├── bookspider/ │ ├── __init__.py │ ├── items.py # Item定义 │ ├── middlewares.py # 中间件 │ ├── pipelines.py # 管道 │ ├── settings.py # 全局配置 │ └── spiders/ │ ├── __init__.py │ └── books.py # 你自己写的爬虫这套结构是Scrapy的约定比你自己从零组织代码要省心得多。scrapy.cfg是部署相关配置平时基本不用动settings.py是所有开关的集合后面会逐个说。3.2 编写SpiderXPath与CSS的取舍Spider是爬虫的核心。我用一个常见的图书列表页为例写一个最基础的爬虫import scrapy from bookspider.items import BookItem class BooksSpider(scrapy.Spider): name books allowed_domains [books.toscrape.com] start_urls [https://books.toscrape.com/] def parse(self, response): for book in response.css(article.product_pod): item BookItem() item[title] book.css(h3 a::attr(title)).get() item[price] book.css(p.price_color::text).get() item[rating] book.css(p.star-rating::attr(class)).re_first(rstar-rating (\w)) item[detail_url] book.css(h3 a::attr(href)).get() yield item next_page response.css(li.next a::attr(href)).get() if next_page: yield response.follow(next_page, callbackself.parse)这里有两个细节值得展开。第一提取标题我用的是CSS选择器而如果需要更复杂条件XPath会更灵活。比如想提取“所有带onclick属性的a标签”CSS要做额外处理XPath可以写成//a[onclick]。实际工作中我经常混用CSS写简单提取XPath处理复杂定位。第二response.follow会自动处理相对URL拼接不用自己去拼域名。很多新手在这里踩坑直接response.urljoin(next_page)也能用但follow更语义化。选择器如果用不熟我的建议是先在Scrapy Shell里验证再写进代码。具体方式在第七章详说。3.3 用Item Pipeline做数据清洗和存储Spider只管提取数据存储交给Pipeline。这样做的最大好处是换存储后端JSON、MySQL、MongoDB时Spider代码不用动。下面是一个把价格字符串转成浮点数、最后写入JSON文件的Pipelineimport json class BookPipeline: def open_spider(self, spider): self.file open(books.json, w, encodingutf-8) def close_spider(self, spider): self.file.close() def process_item(self, item, spider): price item.get(price, ) if isinstance(price, str): item[price] float(price.replace(£, ).strip()) return item写完后要去settings.py里注册Pipeline才能生效ITEM_PIPELINES { bookspider.pipelines.BookPipeline: 300, }那个300是执行顺序的权重数值越小越先执行。如果你有多个Pipeline比如先清洗再入库就把清洗的设为200入库的设为300。3.4 settings.py里那些影响成败的开关settings.py里的参数非常多但新手只要先掌握这几个ROBOTSTXT_OBEY默认为True表示会遵守目标站点的robots协议。如果你确认目标站点允许抓取或者只是学习练习很多人会把它设为False否则有可能一个请求都发不出去CONCURRENT_REQUESTS并发请求数默认16实际看目标站点负载能力调整DOWNLOAD_DELAY请求间隔单位秒。设0.5到2之间的值比较稳妥太快容易被封USER_AGENT默认是“Scrapy/版本号”这个UA太明显目标站点很容易识别并拦掉。建议改成常见浏览器的UALOG_LEVEL日志级别开发和调试时可以设成DEBUG正式跑设INFO。我的习惯是拿到一个新项目先花十分钟把这些参数过一遍。很多爬虫跑不动不是代码有问题而是settings没配好。4. 中间件实战把反爬策略做成统一基础设施4.1 中间件到底解决什么问题中间件是整个Scrapy架构里最灵活的部分可以插在请求发出前、响应返回后、Item送出生化等各个阶段。理解一个核心原则不要把UA伪装、代理切换、重试逻辑写在每个Spider里而应该做成中间件对所有Spider统一生效。Downloader Middleware里面的方法包括process_request(request, spider)请求发给下载器之前调用可以改请求头、换代理、直接返回响应跳过下载process_response(request, response, spider)下载器拿到响应后调用可以做状态码检查、重试、换代理重发。4.2 一个随机User-Agent中间件实例很多站点最简单的反爬就是看UA。Scrapy默认UA会暴露身份所以我一般会用一个随机UA中间件import random class RandomUserAgentMiddleware: USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, ] def process_request(self, request, spider): request.headers[User-Agent] random.choice(self.USER_AGENTS)在settings.py里注册DOWNLOADER_MIDDLEWARES { bookspider.middlewares.RandomUserAgentMiddleware: 400, }数字大小决定中间件执行顺序Scrapy内置中间件在500左右你可以把自定义中间件设为400让它先执行。这里有个经验随机UA只解决最粗浅的问题真正严格的反爬会校验TLS指纹、Cookie 一致性、浏览器环境特征。所以UA随机化只是基础配置不是万能药。4.3 请求限速别把目标站点打挂中间件里也可以做全局限速。Scrapy内置了AutoThrottle扩展它会根据响应延迟自动调节请求速率。我个人比较喜欢在settings里手动控制DOWNLOAD_DELAY 1.5 RANDOMIZE_DOWNLOAD_DELAY TrueRANDOMIZE_DOWNLOAD_DELAY设为True后实际延迟会在DOWNLOAD_DELAY的0.5到1.5倍之间随机这样请求间隔不是固定节奏更像真人浏览。很多新手只设了DOWNLOAD_DELAY0觉得越快越好结果抓几十个页面就被封这个代价远大于慢一点。4.4 错误重试与异常处理Scrapy自带RetryMiddleware默认会对500、502、503等状态码重试3次。我一般会在settings里再调整RETRY_TIMES 3 RETRY_HTTP_CODES [500, 502, 503, 504, 408, 429]把429Too Many Requests也加进来是因为很多限流策略就是用429提示你被限速了。遇到429最好的办法是退避重试而不是硬冲。如果你需要更复杂的重试策略可以继承RetryMiddleware重写也可以写在自定义中间件的process_response里。我的原则是尽量复用内置能力别重复造轮子。5. 动态页面与iframe浏览器渲染方案怎么融入Scrapy5.1 静态下载器拿不到动态内容Scrapy的Downloader本质上是一个HTTP客户端它拿到的是服务器返回的原始HTML。如果目标站点是前后端分离页面内容由JavaScript执行后动态生成那么原始HTML里根本没有你要的数据。判断方法很简单浏览器里右键“查看网页源代码”搜索一下你要抓取的字段如果搜不到那它一定是异步渲染的。针对这种情况通常有三条路分析网络请求找到页面数据对应的XHR接口直接请求接口拿JSON用Playwright/Selenium驱动真实浏览器等JS执行完再把渲染后的HTML交给Scrapy解析如果页面数据写在script变量里可以先用正则或XPath提取那段变量再用json.loads解析。能走第一条路尽量不要走第二条接口请求轻量、速度快、不容易被封。但有些站点做了复杂的加密参数接口分析成本太高这时候才考虑浏览器渲染。5.2 在Scrapy里集成Playwright的常见做法Playwright是目前我比较推荐的浏览器自动化工具它基于CDP协议控制Chromium等浏览器能模拟真实用户行为。在Scrapy里集成Playwright常见做法有两种。一种是直接用scrapy-playwright这个第三方扩展它把Playwright的异步API和Scrapy的下载器整合起来可以把Request标记为需要浏览器渲染。配置方式大致是DOWNLOAD_HANDLERS { http: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, https: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, }然后在Spider里把一个普通的Request包装成带playwrightTrue的请求yield scrapy.Request(url, meta{playwright: True, playwright_include_page: True}, callbackself.parse_detail)这个方案的好处是只有标记过的请求才走浏览器渲染普通请求仍然走默认下载器性能不会整体下降。另一种是自己写一个Playwright Service在Spider外部异步获取HTML后再交给Scrapy的Selector解析。这个方法更灵活适合需求不依赖Scrapy调度框架的场景。5.3 iframe页面怎么处理iframe是一个让很多爬虫头疼的东西。遇到嵌入的iframe页面获取的数据往往不在主页面HTML里而在子frame里。Playwright处理这个场景非常直接因为浏览器本身就有frame概念。frame page.frames[1] # 按索引或名称取frame content await frame.locator(div.content).inner_text()如果你用scrapy-playwright在页面加载完成后可以拿到page对象遍历它的frames属性找到对应frame再定位元素。需要注意iframes会有加载延迟最好加一个显式等待比如等待frame的某个元素出现await frame.locator(div.data-table).wait_for()这里有一个实战细节很多iframe不是直接嵌套在顶层文档里而是套了好几层frame嵌套frame。这时用Playwright的page.frames列表需要看清楚层级也可以用frame_locator链式定位。实在不行直接在浏览器里先人工确认iframe的src地址看看能不能直接请求那个src有时比绕frame更简单。如果你没有引入Playwright只是用普通HTTP请求那处理iframe的思路就完全不同定位iframe标签从src属性拿到子页面的URL再单独发一个请求去抓子页面。这个方案速度快但前提是iframe的src是静态地址数据不在父页面里。6. 性能调优与工程化从能跑变成好维护6.1 并发数的估算逻辑很多人一上来就把CONCURRENT_REQUESTS调到100结果目标站点响应变慢或者直接封IP。并发数不是越大越好它取决于目标站点的响应速度和你的机器资源。一个简单估算公式并发数 期望每秒请求数 × 单个请求平均耗时。比如我希望每秒发出20个请求目标站点平均响应耗时1秒那么并发至少要20。如果响应耗时0.5秒那10个并发就够。设置完之后再看下载延迟和CPU占用再回调。我个人的安全做法是从16开始观察日志里请求耗时和错误率没有超时和429再逐步往上调。爬虫工程讲究“稳”而不是“快”长时间稳定运行比一时的高吞吐重要得多。6.2 日志、断点续爬与增量抓取Scrapy的日志很详细默认情况下每个请求、每个Item都会输出。跑生产任务时我一般这样配置LOG_LEVEL INFO LOG_FILE crawl.log日志文件很重要任务崩了、数据少了、请求被封全靠它复盘。如果任务需要中断后继续可以在启动命令里指定JOBDIRscrapy crawl books -s JOBDIRjob_state这样Scrapy会把调度队列、去重指纹存下来下次从断点继续跑。增量抓取是另一个高频需求。最简单粗暴的做法是给Item加一个唯一键在Pipeline里去重或者用爬虫启动时的文件时间戳判断是否要重跑。如果你想做得工程化一点可以把已抓取的URL存到Redis的Set里Spider生成新Request时先检查是否已经在集合里这个思路配合scrapy-redis可以直接用。6.3 分布式爬虫与后续扩展单个Scrapy实例的并发能力总有上限。当任务规模大到需要分布式时常见的方案是引入scrapy-redis把调度器里的待抓取队列和去重集合从内存迁移到Redis里多个爬虫节点共享同一个队列自然就不会重复抓取。分布式不是银弹它带来的额外复杂度包括节点状态同步、任务分配策略、数据汇总方案。如果你单机调优后能满足需求不必硬上分布式。我的经验是先把并发、延迟、去重规则调清楚再考虑横向扩展。工程化层面还有几个建议值得提一下用Pipenv或Poetry做依赖管理把settings里的关键配置抽成环境变量比如数据库地址、代理池地址写一个简单的命令行工具或者Makefile把启动、清理、部署这些操作固化下来。爬虫代码写得好不好运行一个月后维护起来才见真章。7. 高频问题与排查技巧实录7.1 高频问题排查速查表下面这些问题是我在各种项目里反复遇到过的整理成一张速查表现象可能原因解决思路抓取返回403UA被识别、IP被限制设置真实UA、降低请求频率、用代理池抓取返回301/302站点强制跳转、登录态丢失检查Cookie、检查Referer头、看Location在哪页面能打开但解析不到数据数据是JS动态渲染分析XHR接口或用Playwright渲染后再解析只能抓到第一页回调里没有返回下一页请求确认页面选择器是否正确检查response.follow保存的Item文件是空的Pipeline没有注册或Item没有yield检查ITEM_PIPELINES配置检查parse里是否yield了Item日志刷太快找不到重点日志级别太低设成INFO必要时只打印错误跑一会儿CPU占用极高并发过高、选择器复杂调低并发数优化XPath表达式报错Filtered offsite requestallowed_domains限制域名没写全或者用Request的dont_filterTrue不推荐滥用7.2 用scrapy shell调试别再盲猜选择器我见过太多人写四五行XPath跑一次改一次浪费大量时间。Scrapy提供了一个非常趁手的调试工具scrapy shell https://books.toscrape.com/进入Shell后你会得到一个response对象直接用response.xpath或response.css验证选择器比如response.css(article.product_pod h3 a::attr(title)).extract()选择器提取到的内容会立刻打印出来调整起来非常快。我写爬虫的固定流程是先在Shell里摸清页面结构确认选择器和字段提取都没问题再回头写Spider。这能省掉八成调试时间。另外建议在Shell里多看看response.headers检查返回的Content-Type、Server等响应头判断是否走了缓存或特殊节点。有时候解析不到数据不是选择器问题而是拿到的根本不是正常页面。7.3 我踩过的几个坑与应对习惯第一个坑是太相信默认配置。最早用Scrapy时ROBOTSTXT_OBEY保持默认True结果请求全部被robots规则拦掉日志里一堆Ignoring response。现在我的习惯是确认目标站点允许抓取后在项目最开始就把settings理顺。第二个坑是只重代码不重状态。长时间任务跑着跑着挂了没有JOBDIR没有日志数据丢了一半也没法续跑。现在我的每个正式爬虫项目都必须带日志文件和适当的存储方案。第三个坑是动态页面硬解析。之前遇到过页面数据藏在iframe里主页面HTML完全没有我还在那边写了半天XPath。现在我接到一个新站点第一件事永远是“查看源代码验证”确认数据位置后再动手。如果让我给新接触Scrapy的人一个建议我会让他先在Shell里花半小时把页面结构摸清再写Spider这比反复改脚本跑脚本要省力得多。另一个我一直沿用的习惯是把项目里会变的部分——UA、链接规则、字段映射——全部抽成配置或常量而不是散落在Spider代码里。这样当目标站点改版时你只需要改配置不用重写解析逻辑维护成本能降一个档次。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价