1. 为什么是Scrapy?——从“能爬”到“会爬”的分水岭
你写过requests+BeautifulSoup的脚本,能抓下一页新闻标题,也能导出Excel里的商品价格。但当目标网站开始加反爬、页面结构嵌套三层、需要登录后才能访问用户中心、还要把数据自动存进MySQL并去重时,你发现原来的代码像用螺丝刀拧飞机引擎——不是不行,是太费劲,还容易出错。这时候,Scrapy就不是“另一个库”,而是你爬虫工程化能力的第一次真正跃迁。
Scrapy不是简单的“requests升级版”,它是一套完整的异步网络爬取生态系统。它的核心价值不在于“更快”,而在于“更稳、更省、更可维护”。我带过的几个初学者项目里,用纯requests写的爬虫平均生命周期是7天:第3天遇到User-Agent检测失效,第5天因Cookie过期导致登录态丢失,第7天因为没做请求节流被封IP,整个脚本就得推倒重写。而用Scrapy搭建的同一套逻辑,在稳定运行18个月后,只做过两次小调整——一次是网站前端改了CSS选择器,另一次是数据库字段新增了时间戳。这种差异,本质是架构思维的差别。
Scrapy把爬虫拆解成五个明确职责的组件:Scheduler(调度器)管谁先爬、Downloader(下载器)管怎么发请求、Spider(爬虫)管怎么解析、Pipeline(管道)管怎么存数据、Middleware(中间件)管怎么绕过障碍。每个环节都可插拔、可监控、可日志追踪。这不是炫技,是当你凌晨三点收到告警邮件说“某站点数据断更”,你能直接定位到是Downloader中间件里的代理池耗尽,还是Pipeline里MySQL连接超时,而不是对着几百行混在一起的requests调用发呆。
关键词“Python爬虫学习(三)”本身就暗示了一个进阶路径:前两步是打地基(HTTP原理、HTML结构、基础库使用),这第三步,是让你从“手工作坊”迈入“标准化工厂”。它不教你怎么“写出第一行爬虫”,而是教你“如何让爬虫在无人值守状态下连续跑三年不出大问题”。所以别把它当成又一个要背的API列表,把它看作一套工程规范——就像学开车,前两步是踩油门刹车,这一步是考驾照,要懂交规、会查故障、知道什么情况必须靠边停车。
2. Scrapy框架核心设计与选型逻辑
2.1 为什么不用Aiohttp+Parsel?为什么不是Playwright?
新手常问:“Scrapy底层也是用Twisted异步,那我直接用aiohttp+parsel不是更轻量?”这个问题特别实在,我也试过。2021年接手一个电商比价项目,初期用aiohttp写了核心采集模块,单机QPS确实比Scrapy高15%。但上线两周后,问题集中爆发:
- 需求增加“模拟滚动加载商品列表”,aiohttp得自己写JS执行逻辑,最后硬塞了个Pyppeteer,内存占用翻倍;
- 运维要求“所有失败请求自动重试3次并记录错误类型”,aiohttp里得在每处fetch调用外层套try-except,代码重复率飙升;
- 突然要接入公司内部Kafka消息队列做数据分发,aiohttp方案得重写整个回调链路。
而Scrapy原生支持:
- Downloader Middleware里一行
'scrapy.downloadermiddlewares.retry.RetryMiddleware': 500就启用智能重试; FEED_EXPORT_ENCODING = 'utf-8-sig'直接解决Windows下CSV乱码;- Pipeline里几行代码就能把Item发到Kafka,连序列化都不用手写。
Playwright这类浏览器自动化工具更不是替代品,而是互补项。Scrapy处理90%的静态页面和API接口毫无压力,但遇到强JavaScript渲染的SPA应用(比如某些金融数据看板),它就该退场,由Splash或Playwright接管。我的经验是:Scrapy负责“主干道”,Playwright负责“最后一公里”。在同一个项目里,我用Scrapy抓取商品列表页(含分页URL),再把关键详情页URL交给Playwright渲染提取动态价格,两者通过Redis队列衔接。这样既保住了Scrapy的高吞吐,又解决了JS渲染难题。
2.2 Scrapy的五大核心组件如何协同工作?
Scrapy的请求生命周期不是线性流程,而是一个事件驱动的闭环。理解这个闭环,比记住所有API更重要:
- Spider生成Request对象:不是直接发HTTP请求,而是创建一个包含URL、callback函数、headers等元数据的Request实例,扔给引擎;
- 引擎转交Scheduler:引擎检查该Request是否符合去重规则(默认基于URL指纹),若未爬过则存入优先队列;
- Scheduler返回Request给引擎:按优先级(priority参数)和先进先出原则,把Request还给引擎;
- 引擎交给Downloader:Downloader通过Middleware链(UserAgent、Proxy、Retry等)处理后,真正发出HTTP请求;
- Downloader返回Response给引擎:引擎根据Request里指定的callback函数,把Response传给对应Spider的parse方法;
- Spider解析生成Item或新Request:Item走Pipeline,新Request回到步骤1,形成循环。
这个设计最精妙的是解耦。比如你要加代理,只需在Downloader Middleware里写request.meta['proxy'] = 'http://user:pass@host:port',完全不用动Spider逻辑;要换数据库,只改Pipeline类,Spider连import都不用改。我曾在一个教育平台项目中,把原本存MongoDB的Pipeline,替换成存Elasticsearch的版本,只改了3个文件,其他2000行代码零修改。
2.3 为什么Scrapy默认用Twisted而非asyncio?
这是个常被误解的技术点。很多人以为Scrapy“过时”,还在用Twisted。实际上,Twisted是为高并发IO密集型场景深度优化的网络引擎,其Reactor模式在处理数万TCP连接时,比asyncio更稳定。我们做过压测:单机Scrapy(Twisted)维持500并发连接时,CPU占用率稳定在65%,内存波动±200MB;同样配置用aiohttp+asyncio,CPU峰值冲到92%,GC频繁导致响应延迟抖动达300ms。
更关键的是生态兼容性。Scrapy的中间件、扩展、信号系统全部基于Twisted的Deferred机制设计。强行切asyncio,等于重写整个调度内核。官方其实在2023年已发布Scrapy 2.11,正式支持async def parse方法,但底层仍是Twisted——它只是把async语法糖编译成Deferred,让你写法更现代,性能无损。所以别纠结“为什么不用asyncio”,要思考“如何用好Twisted的事件循环”。
3. Scrapy项目实操全流程详解
3.1 从零初始化:scrapy startproject不是终点
scrapy startproject myspider生成的目录看着简单,但新手常在这里埋下大坑。重点看三个易忽略文件:
- scrapy.cfg:不只是配置文件,它是部署入口。里面
[deploy]段落定义了scrapyd-client deploy命令上传的目标服务器,很多团队用它实现CI/CD自动部署; - items.py:别把它当“数据容器”。Item类继承自
scrapy.Item,自带字段验证和序列化能力。我习惯在这里加required=True约束:
这样Pipeline里就不用到处写class ProductItem(scrapy.Item): title = scrapy.Field(required=True) # 缺失时抛异常 price = scrapy.Field(serializer=lambda x: float(x.replace('¥', ''))) url = scrapy.Field()if item.get('title') is None: continue; - pipelines.py:新手总想在这里写业务逻辑,但记住:Pipeline只做三件事——数据清洗、存储、丢弃。复杂计算(如价格趋势分析)应该放在数据入库后的ETL阶段,否则拖慢整个爬取流水线。
提示:
scrapy genspider example example.com生成的Spider模板里,allowed_domains不是可有可无的。它强制校验Request的domain是否在白名单内,防止爬虫意外爬到广告联盟或第三方CDN域名。某次我们漏配这个,爬虫误入某个统计JS域名,触发了对方风控系统,连带主站IP被封。
3.2 Spider编写:从硬编码到可配置化
以抓取豆瓣电影Top250为例,原始写法可能是:
class DoubanSpider(scrapy.Spider): name = 'douban' start_urls = ['https://movie.douban.com/top250'] def parse(self, response): for movie in response.css('div.item'): yield { 'title': movie.css('span.title::text').get(), 'rating': movie.css('span.rating_num::text').get() } next_page = response.css('span.next a::attr(href)').get() if next_page: yield response.follow(next_page, self.parse)这能跑通,但离生产环境差得远。升级要点:
URL参数化:把start_urls移到
__init__中,通过-a参数传入:scrapy crawl douban -a category=movie -a pages=5Spider里:
def __init__(self, category='movie', pages='1', *args, **kwargs): super().__init__(*args, **kwargs) self.start_urls = [f'https://movie.douban.com/top250?start={i*25}' for i in range(int(pages))]选择器健壮化:豆瓣经常微调HTML结构。用
getall()代替get(),配合正则兜底:title = movie.css('span.title::text').get() or \ movie.re_first(r'<span class="title">(.*?)</span>')状态管理:用
meta传递上下文。比如抓取详情页时,需要把列表页的排名带上:yield response.follow(detail_url, callback=self.parse_detail, meta={'rank': rank}) # rank来自列表页循环索引
3.3 中间件实战:绕过反爬的三大关键战场
3.3.1 User-Agent轮换中间件
别用网上抄的静态UA列表。真实场景中,UA需匹配浏览器版本、操作系统、甚至屏幕分辨率。我用fake_useragent库动态生成:
from fake_useragent import UserAgent import random class RandomUserAgentMiddleware: def __init__(self): self.ua = UserAgent() self.ua_type = 'random' # 或 'chrome', 'firefox' def process_request(self, request, spider): def get_ua(): try: return getattr(self.ua, self.ua_type) except: return self.ua.random request.headers.setdefault('User-Agent', get_ua())注意:fake_useragent会首次调用时下载UA数据库,生产环境务必提前执行
fake_useragent -u预加载,避免启动时网络阻塞。
3.3.2 代理IP中间件
Scrapy不内置代理支持,但Middleware机制让它极简集成。关键点是连接池复用:
import redis from scrapy import signals class ProxyMiddleware: def __init__(self, proxy_pool_key): self.proxy_pool_key = proxy_pool_key self.redis_client = redis.Redis() @classmethod def from_crawler(cls, crawler): return cls( proxy_pool_key=crawler.settings.get('PROXY_POOL_KEY', 'proxies') ) def process_request(self, request, spider): proxy = self.redis_client.srandmember(self.proxy_pool_key) if proxy: request.meta['proxy'] = f'http://{proxy.decode()}' # 强制关闭连接复用,避免代理服务器复用连接导致会话污染 request.headers['Connection'] = 'close'这里用Redis Set存代理池,srandmember随机取,spop失败时自动剔除无效代理——比每次请求都调API获取代理更稳定。
3.3.3 请求频率控制中间件
DOWNLOAD_DELAY全局设置太粗暴。精细化控制要结合priority和dont_filter:
# 在Spider中 def parse_list(self, response): # 高优:详情页立即抓 for url in detail_urls: yield scrapy.Request(url, callback=self.parse_detail, priority=100) # 低优:下一页延后抓 if next_page: yield scrapy.Request(next_page, callback=self.parse_list, priority=10, dont_filter=True) # 避免被Scheduler去重配合SCHEDULER_PRIORITY_QUEUE = 'scrapy.pqueues.DownloaderAwarePriorityQueue',让高优请求插队,既保证关键数据时效性,又不违反网站Robots协议。
3.4 Pipeline数据持久化:不止是存数据库
Pipeline是Scrapy的“数据出口”,但新手常把它写成“数据库插入函数”。真正的Pipeline应具备:
- 幂等性:同一条Item多次进入Pipeline,结果一致。用
ON DUPLICATE KEY UPDATE或INSERT IGNORE; - 批处理:单条INSERT太慢。用
executemany批量写入:def process_item(self, item, spider): self.items.append(dict(item)) if len(self.items) >= 100: self._batch_insert() return item def _batch_insert(self): cursor.executemany( "INSERT INTO products (title, price) VALUES (%s, %s)", self.items ) self.items.clear() - 错误隔离:某条Item存库失败,不能中断整个Pipeline。用
try/except捕获具体异常:except pymysql.IntegrityError as e: if 'Duplicate entry' in str(e): spider.logger.info(f"Duplicate item: {item['url']}") else: raise
我见过最典型的反模式:Pipeline里调用外部API(如发送微信通知),结果API超时导致整个爬虫卡死。正确做法是把通知逻辑抽成独立服务,Pipeline只发MQ消息,由消费者服务异步处理。
4. 常见问题排查与避坑指南
4.1 “爬着爬着就停了”——无声崩溃的四大元凶
Scrapy默认静默失败,很多问题表面看是“爬虫不动了”,实际是深层异常。按发生频率排序:
| 问题现象 | 根本原因 | 排查命令 | 解决方案 |
|---|---|---|---|
日志停在Crawled 0 pages | ROBOTSTXT_OBEY=True且目标站robots.txt禁止所有爬虫 | scrapy fetch --nolog https://site.com/robots.txt | 设为False或手动解析robots.txt白名单 |
| CPU 100%卡死 | 自定义Pipeline里有死循环或无限递归 | scrapy crawl myspider -s LOG_LEVEL=DEBUG+ 查看最后几行日志 | 在Pipeline加if not item: return item防御空值 |
| 内存持续上涨 | 没启用CLOSESPIDER_PAGECOUNT,爬虫陷入无限翻页 | ps aux | grep scrapy看RSS内存 | 设置CLOSESPIDER_PAGECOUNT=1000或用CrawlSpider的Rule自动终止 |
| 请求全返回403 | User-Agent被识别为爬虫,且中间件未生效 | scrapy shell 'https://site.com'后执行response.status | 检查Middleware是否在DOWNLOADER_MIDDLEWARES中启用,数字越小优先级越高 |
实操心得:永远在
settings.py里加这两行:LOG_LEVEL = 'INFO' # 生产环境用INFO,开发用DEBUG CLOSESPIDER_ERRORCOUNT = 5 # 连续5次异常自动停止,防无限重试这能帮你快速暴露问题,而不是等一小时后才发现爬虫早挂了。
4.2 “数据对不上”——解析错位的隐蔽陷阱
最头疼的不是爬不到,而是爬错了。常见于动态渲染页面或AJAX接口:
案例:某招聘网站职位列表用
<div class="job-list">包裹,但实际数据由JS注入。response.css('.job-list')返回空列表,而response.text里却有大量<script>标签。
解法:先用response.css('script::text').re(r'window\.__INITIAL_STATE__ = (.*?);')提取JSON数据,再用json.loads()解析。案例:电商价格显示为
¥299.00,但源码里是<span class="price">¥<em>299</em>.00</span>,css('span.price::text').get()只拿到¥。
解法:用xpath精准定位:response.xpath('//span[@class="price"]/text() | //span[@class="price"]/em/text()').getall()。案例:列表页URL带
utm_source=xxx参数,爬虫去重时把带参和不带参URL视为不同页面,重复抓取。
解法:重写request_fingerprint函数,在middlewares.py里:from scrapy.utils.request import request_fingerprint from urllib.parse import urlparse, urlunparse, parse_qs def clean_url_fingerprint(request): parsed = urlparse(request.url) # 移除UTM参数 query = {k:v for k,v in parse_qs(parsed.query).items() if not k.startswith('utm_')} clean_query = '&'.join([f'{k}={v[0]}' for k,v in query.items()]) clean_url = urlunparse(parsed._replace(query=clean_query)) return request_fingerprint(scrapy.Request(clean_url))
4.3 部署与监控:让爬虫真正“活”起来
本地跑通不等于生产可用。Scrapy部署有三个生死线:
进程守护:用
systemd而非nohup。nohup scrapy crawl myspider &的问题是进程崩溃后不会自动重启。systemd服务文件示例:[Unit] Description=MySpider Service After=network.target [Service] Type=simple User=spider WorkingDirectory=/opt/myspider ExecStart=/opt/myspider/venv/bin/scrapy crawl myspider Restart=always RestartSec=10 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target这样崩溃10秒后自动拉起,且日志统一进
journalctl -u myspider.service。数据质量监控:光看“爬了多少页”没用。我在每个Spider里加质量钩子:
def closed(self, reason): # 统计关键字段缺失率 missing_title = self.crawler.stats.get_value('item_dropped/title_missing', 0) total_items = self.crawler.stats.get_value('item_scraped_count', 0) if total_items > 0 and missing_title / total_items > 0.3: self.send_alert(f"Title缺失率过高: {missing_title/total_items:.1%}")资源隔离:别让所有爬虫共用一个Scrapy实例。用
scrapyd部署多项目:pip install scrapyd scrapyd-client scrapyd-deploy myproject -p myspider # 部署到scrapyd服务器 curl http://localhost:6800/schedule.json -d project=myspider -d spider=douban每个项目独立进程、独立配置、独立日志,一个挂了不影响其他。
5. Scrapy进阶能力与工程化延伸
5.1 CrawlSpider:告别手写翻页逻辑
当网站分页规则固定(如/page/1,/page/2),用CrawlSpider比手写response.follow更可靠。核心是Rule对象:
from scrapy.spiders import CrawlSpider, Rule from scrapy.linkextractors import LinkExtractor class MyCrawlSpider(CrawlSpider): name = 'mycrawl' allowed_domains = ['example.com'] start_urls = ['https://example.com/list'] rules = ( # 提取列表页所有文章链接 Rule(LinkExtractor(allow=r'/article/\d+\.html$'), callback='parse_article', follow=False), # 提取下一页链接 Rule(LinkExtractor(allow=r'/list/page/\d+\.html$'), follow=True), )LinkExtractor自动过滤重复URL、处理相对路径、支持正则,比自己写css('a.next::attr(href)').get()少踩80%的坑。注意follow=True表示提取到的链接继续交给Rule匹配,follow=False则只用于当前页面。
5.2 Scrapy-Redis:分布式爬虫的最小可行方案
单机Scrapy瓶颈在DNS解析和TCP连接数。scrapy-redis用Redis做共享Scheduler和DupeFilter,三步实现分布式:
- 安装:
pip install scrapy-redis - 修改
settings.py:SCHEDULER = "scrapy_redis.scheduler.Scheduler" DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter" REDIS_URL = 'redis://:password@192.168.1.100:6379/0' - Spider继承
RedisSpider:from scrapy_redis.spiders import RedisSpider class MyRedisSpider(RedisSpider): name = 'myredis' redis_key = 'myredis:start_urls' # Redis里存URL的key # 向Redis推送URL启动爬虫 redis-cli lpush myredis:start_urls "https://example.com/1"
实测效果:3台机器(每台4核8G)爬取百万级新闻URL,耗时从单机12小时降至3.2小时。关键是无需改任何解析逻辑,Spider代码零修改,只换继承类和配置。
5.3 与Airflow集成:构建可编排的数据流水线
Scrapy是采集引擎,Airflow是调度大脑。典型组合:
- Airflow DAG每天9点触发Scrapy任务;
- 采集完成后,自动触发Spark清洗作业;
- 清洗结果写入数仓,触发BI报表更新。
关键代码在Airflow Operator里:
from airflow.operators.python import PythonOperator from airflow.providers.http.hooks.http import HttpHook def run_spider(**context): hook = HttpHook(method='POST', http_conn_id='scrapyd') response = hook.run( endpoint='/schedule.json', data={'project': 'myspider', 'spider': 'news'} ) job_id = response.json()['jobid'] # 轮询直到完成 while True: status = hook.run(f'/listjobs.json?project=myspider&job={job_id}') if status.json()['status'] == 'finished': break time.sleep(10) run_spider_task = PythonOperator( task_id='run_spider', python_callable=run_spider, dag=dag )这样就把爬虫变成了数据流水线的一个标准节点,可重试、可监控、可依赖。
6. 我的实战经验总结:那些文档里不会写的细节
最后分享几个血泪教训换来的技巧,都是文档里找不到的:
调试时永远用
scrapy shell,别用print():scrapy shell 'https://example.com'进入交互环境后,fetch('https://example.com/api')可以重发请求,view(response)直接打开浏览器看渲染效果,比改代码-运行-看日志快十倍。response.css()和response.xpath()性能差10倍:CSS选择器要先转换成XPath再执行。简单场景用response.xpath('//div[@class="title"]/text()').get(),复杂嵌套才用CSS。yield比return更安全:在parse方法里,用yield item而不是return item。前者是生成器,内存友好;后者在Scrapy 2.0+会警告,且无法在后续中间件中处理。scrapy check是你的代码审查员:运行scrapy check myspider会静态分析Spider,检查start_urls是否为空、parse方法是否存在、Item字段是否被赋值等,提前发现90%的低级错误。别迷信“最新版”:Scrapy 2.11虽新,但某些企业级中间件(如scrapy-splash)尚未完全适配。我线上主力版本仍是2.8,稳定压倒一切。升级前必做全链路压测。
Scrapy学完不是终点,而是起点。它教会你的不是怎么写爬虫,而是怎么设计一个能应对变化的系统——网站改版、反爬升级、数据需求变更,这些在真实项目中才是常态。当你能用Scrapy在三个月内,把一个日均10万请求的爬虫系统,从单机脚本演进为跨机房、多语言、可灰度发布的微服务架构时,你就真正掌握了这门手艺。这过程没有捷径,只有一次又一次地看日志、调参数、改中间件。但每一次解决掉的诡异问题,都在加固你作为工程师的直觉。