
1. 为什么是Scrapy——从“能爬”到“会爬”的分水岭你写过requestsBeautifulSoup的脚本能抓下一页新闻标题也能导出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 为什么不用AiohttpParsel为什么不是Playwright新手常问“Scrapy底层也是用Twisted异步那我直接用aiohttpparsel不是更轻量”这个问题特别实在我也试过。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还给引擎引擎交给DownloaderDownloader通过Middleware链UserAgent、Proxy、Retry等处理后真正发出HTTP请求Downloader返回Response给引擎引擎根据Request里指定的callback函数把Response传给对应Spider的parse方法Spider解析生成Item或新RequestItem走Pipeline新Request回到步骤1形成循环。这个设计最精妙的是解耦。比如你要加代理只需在Downloader Middleware里写request.meta[proxy] http://user:passhost:port完全不用动Spider逻辑要换数据库只改Pipeline类Spider连import都不用改。我曾在一个教育平台项目中把原本存MongoDB的Pipeline替换成存Elasticsearch的版本只改了3个文件其他2000行代码零修改。2.3 为什么Scrapy默认用Twisted而非asyncio这是个常被误解的技术点。很多人以为Scrapy“过时”还在用Twisted。实际上Twisted是为高并发IO密集型场景深度优化的网络引擎其Reactor模式在处理数万TCP连接时比asyncio更稳定。我们做过压测单机ScrapyTwisted维持500并发连接时CPU占用率稳定在65%内存波动±200MB同样配置用aiohttpasyncioCPU峰值冲到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自带字段验证和序列化能力。我习惯在这里加requiredTrue约束class ProductItem(scrapy.Item): title scrapy.Field(requiredTrue) # 缺失时抛异常 price scrapy.Field(serializerlambda x: float(x.replace(¥, ))) url scrapy.Field()这样Pipeline里就不用到处写if item.get(title) is None: continuepipelines.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 categorymovie -a pages5Spider里def __init__(self, categorymovie, pages1, *args, **kwargs): super().__init__(*args, **kwargs) self.start_urls [fhttps://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(rspan classtitle(.*?)/span)状态管理用meta传递上下文。比如抓取详情页时需要把列表页的排名带上yield response.follow(detail_url, callbackself.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_keycrawler.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] fhttp://{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, callbackself.parse_detail, priority100) # 低优下一页延后抓 if next_page: yield scrapy.Request(next_page, callbackself.parse_list, priority10, dont_filterTrue) # 避免被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(fDuplicate item: {item[url]}) else: raise我见过最典型的反模式Pipeline里调用外部API如发送微信通知结果API超时导致整个爬虫卡死。正确做法是把通知逻辑抽成独立服务Pipeline只发MQ消息由消费者服务异步处理。4. 常见问题排查与避坑指南4.1 “爬着爬着就停了”——无声崩溃的四大元凶Scrapy默认静默失败很多问题表面看是“爬虫不动了”实际是深层异常。按发生频率排序问题现象根本原因排查命令解决方案日志停在Crawled 0 pagesROBOTSTXT_OBEYTrue且目标站robots.txt禁止所有爬虫scrapy fetch --nolog https://site.com/robots.txt设为False或手动解析robots.txt白名单CPU 100%卡死自定义Pipeline里有死循环或无限递归scrapy crawl myspider -s LOG_LEVELDEBUG 查看最后几行日志在Pipeline加if not item: return item防御空值内存持续上涨没启用CLOSESPIDER_PAGECOUNT爬虫陷入无限翻页ps aux | grep scrapy看RSS内存设置CLOSESPIDER_PAGECOUNT1000或用CrawlSpider的Rule自动终止请求全返回403User-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 classjob-list包裹但实际数据由JS注入。response.css(.job-list)返回空列表而response.text里却有大量script标签。解法先用response.css(script::text).re(rwindow\.__INITIAL_STATE__ (.*?);)提取JSON数据再用json.loads()解析。案例电商价格显示为¥299.00但源码里是span classprice¥em299/em.00/spancss(span.price::text).get()只拿到¥。解法用xpath精准定位response.xpath(//span[classprice]/text() | //span[classprice]/em/text()).getall()。案例列表页URL带utm_sourcexxx参数爬虫去重时把带参和不带参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(queryclean_query)) return request_fingerprint(scrapy.Request(clean_url))4.3 部署与监控让爬虫真正“活”起来本地跑通不等于生产可用。Scrapy部署有三个生死线进程守护用systemd而非nohup。nohup scrapy crawl myspider 的问题是进程崩溃后不会自动重启。systemd服务文件示例[Unit] DescriptionMySpider Service Afternetwork.target [Service] Typesimple Userspider WorkingDirectory/opt/myspider ExecStart/opt/myspider/venv/bin/scrapy crawl myspider Restartalways RestartSec10 StandardOutputjournal StandardErrorjournal [Install] WantedBymulti-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(fTitle缺失率过高: {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 projectmyspider -d spiderdouban每个项目独立进程、独立配置、独立日志一个挂了不影响其他。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(allowr/article/\d\.html$), callbackparse_article, followFalse), # 提取下一页链接 Rule(LinkExtractor(allowr/list/page/\d\.html$), followTrue), )LinkExtractor自动过滤重复URL、处理相对路径、支持正则比自己写css(a.next::attr(href)).get()少踩80%的坑。注意followTrue表示提取到的链接继续交给Rule匹配followFalse则只用于当前页面。5.2 Scrapy-Redis分布式爬虫的最小可行方案单机Scrapy瓶颈在DNS解析和TCP连接数。scrapy-redis用Redis做共享Scheduler和DupeFilter三步实现分布式安装pip install scrapy-redis修改settings.pySCHEDULER scrapy_redis.scheduler.Scheduler DUPEFILTER_CLASS scrapy_redis.dupefilter.RFPDupeFilter REDIS_URL redis://:password192.168.1.100:6379/0Spider继承RedisSpiderfrom 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(methodPOST, http_conn_idscrapyd) response hook.run( endpoint/schedule.json, data{project: myspider, spider: news} ) job_id response.json()[jobid] # 轮询直到完成 while True: status hook.run(f/listjobs.json?projectmyspiderjob{job_id}) if status.json()[status] finished: break time.sleep(10) run_spider_task PythonOperator( task_idrun_spider, python_callablerun_spider, dagdag )这样就把爬虫变成了数据流水线的一个标准节点可重试、可监控、可依赖。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[classtitle]/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万请求的爬虫系统从单机脚本演进为跨机房、多语言、可灰度发布的微服务架构时你就真正掌握了这门手艺。这过程没有捷径只有一次又一次地看日志、调参数、改中间件。但每一次解决掉的诡异问题都在加固你作为工程师的直觉。