1. 为什么在Scrapy项目里还要回头聊requests
很多人学爬虫的路径都差不多:先听说Scrapy是个“大而全”的框架,于是兴冲冲地装好、建项目、写Spider,结果第一步就卡在请求发不出去、返回内容对不上、编码乱码这些破事上。折腾半天才发现,问题根本不在Scrapy本身,而在于对最底层的HTTP请求库——requests——理解得不够透。我自己带过几个做数据采集的同事,凡是Scrapy用得顺的,几乎都是先把requests玩明白了再上框架的。
这篇笔记就是写给这类人的:你已经知道Scrapy能干嘛,也打算用它做正经的采集项目,但你想先把requests这个基础库彻底吃透,搞清楚它在Scrapy体系里到底扮演什么角色、什么时候该用它、什么时候该让位给Scrapy自己的下载器。内容会从requests的核心用法讲起,一路延伸到它和Scrapy的配合方式、常见坑的排查,以及我在实际项目里踩过的那些坑。不管你是刚入门的新手,还是已经写过几个Spider但总觉得“差点意思”的老手,应该都能从里面捞到点能直接用的东西。
先把定位说清楚:requests不是Scrapy的替代品,也不是必须品。Scrapy自带基于Twisted的异步下载器,性能上碾压同步的requests。但requests的价值在于它的简单、直观、可控——调试接口、处理登录、抓取少量动态数据、写测试脚本,这些场景下用requests比启动一整个Scrapy项目快得多。更重要的是,理解requests的请求-响应模型,是理解Scrapy下载器中间件、请求调度、响应处理这些概念的前提。你可以把requests当成学Scrapy之前的“手动挡练习车”,开熟了再上自动挡,心里才有底。
2. requests核心用法拆解:从发一个请求到拿到干净数据
2.1 一个请求的完整生命周期
很多人用requests就是requests.get(url)一把梭,拿到response.text就开始正则匹配。这种写法在简单页面上能跑通,但一旦遇到需要登录、需要带Cookie、需要处理重定向、需要控制超时的场景,立刻就抓瞎。要真正用好它,得先搞清楚一个请求从发出到拿到响应,中间到底经历了什么。
一个完整的requests请求,大致经过这几个阶段:构造Request对象(包含URL、方法、headers、cookies、params、data/json等)→ 通过Session或直接调用发送 → urllib3处理连接池、重试、SSL → 拿到Response对象 → 根据编码解析内容。这里面每一个环节都有可以干预的地方,而大部分人的问题就出在“没干预”上。
举个最常见的例子:你请求一个中文网站,response.text出来一堆乱码。为什么?因为requests会优先根据HTTP响应头里的Content-Type字段猜编码,如果响应头没写或者写错了,它就退而求其次用chardet去猜,猜不准就默认ISO-8859-1。解决办法很简单,手动指定response.encoding = 'utf-8',或者直接用response.content.decode('utf-8')。这个细节在Scrapy里同样存在,Scrapy的TextResponse也有编码推断逻辑,理解requests的这套机制,到了Scrapy里就不会对response.text和response.body的区别感到困惑。
再说超时。requests.get(url)默认是不设超时的,也就是说如果对方服务器不响应,你的程序会一直挂在那里。生产环境里这是致命的。正确的做法是显式传入timeout参数,比如timeout=(3.05, 27),前者是连接超时,后者是读取超时。这个元组写法是有讲究的,连接超时设短一点(3秒左右),因为TCP握手正常很快;读取超时设长一点,给服务器处理时间。Scrapy里对应的配置是DOWNLOAD_TIMEOUT,默认180秒,实际项目里通常要调小。
2.2 Session与Cookie管理:别再用字典手动传了
新手处理登录态最常见的做法是:先发一个登录请求,从响应里抠出Cookie,存到一个字典里,后续请求手动带上。这种写法能跑,但极其脆弱——Cookie过期了要重新登录、多个域名要分别管理、并发请求时字典可能被改乱。
requests提供了Session对象,它自动帮你管理Cookie、连接池、默认headers。用法很简单:
import requests session = requests.Session() session.headers.update({ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' }) # 登录,Cookie自动存入session session.post('https://example.com/login', data={'user': 'xxx', 'pass': 'yyy'}) # 后续请求自动带Cookie resp = session.get('https://example.com/profile')这里有个实操心得:Session对象的连接池默认大小是10,如果你要发大量请求,可以调整HTTPAdapter的pool_connections和pool_maxsize。另外,Session不是线程安全的,多线程场景下每个线程应该用自己的Session实例。这一点在Scrapy里被下载器中间件和调度器接管了,你不需要操心,但理解Session的机制有助于你理解Scrapy的Downloader是怎么复用连接的。
还有一个坑:有些网站的登录不是简单的表单POST,而是先GET登录页拿一个隐藏的token,再带着token POST。这种场景下用Session特别方便,因为GET和POST之间的Cookie自动串联。如果手动管理Cookie,很容易漏掉某个Set-Cookie。
2.3 请求头与反爬的博弈
requests默认的User-Agent是python-requests/x.x.x,这等于举着牌子告诉对方“我是爬虫”。所以第一件事就是改UA。但光改UA不够,很多网站还会检查Referer、Accept-Language、Accept-Encoding,甚至检查请求头的顺序。
我一般会准备几套完整的headers,模拟不同浏览器:
headers_list = [ { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive', }, # ... 其他浏览器组合 ]注意Accept-Encoding里如果带了br(Brotli),需要安装brotli库,否则requests解压会报错。这个坑我踩过,当时抓一个国外站点,响应一直是乱码,排查了半天才发现是Brotli没装。
另外,Referer在抓取分页数据时经常是必须的。比如列表页是/list?page=1,详情页是/detail/123,有些网站会检查Referer是不是来自列表页。这种时候在请求详情页时带上列表页的URL作为Referer就行。
2.4 参数传递:params、data、json的区别
这三个参数新手最容易搞混。简单记:
params:拼在URL后面的查询字符串,用于GET请求,比如?key=value&page=2data:表单数据,用于POST请求,Content-Type是application/x-www-form-urlencodedjson:JSON body,用于POST请求,Content-Type是application/json
# GET带查询参数 requests.get('https://example.com/search', params={'q': 'python', 'page': 1}) # 实际请求 https://example.com/search?q=python&page=1 # POST表单 requests.post('https://example.com/login', data={'user': 'a', 'pass': 'b'}) # POST JSON requests.post('https://example.com/api', json={'name': 'test', 'value': 123})有个细节:params里的值如果是中文或特殊字符,requests会自动URL编码,不用手动quote。但如果你自己拼URL字符串,就得手动处理。这也是用params比手拼URL更稳妥的原因。
3. requests与Scrapy的配合:什么时候用谁
3.1 明确边界:requests做不了Scrapy能做的事
先泼盆冷水。requests是同步阻塞的,发一个请求等一个响应,并发能力靠多线程或多进程硬撑。Scrapy基于Twisted异步框架,单线程就能跑出很高的并发。如果你要抓几十万页面,用requests+多线程,线程数开高了CPU和内存都吃不消,开低了速度上不去。Scrapy的CONCURRENT_REQUESTS配置可以轻松控制并发,配合AUTOTHROTTLE自动限速,这是requests很难优雅实现的。
所以边界很清楚:小规模、需要精细控制、调试性质的任务用requests;大规模、结构化、需要调度和去重的任务用Scrapy。
但这不是说学了Scrapy就可以扔掉requests。实际项目里,我经常在Scrapy项目里用requests做这几件事:
- 接口预检:正式写Spider之前,用requests快速试一下目标接口能不能通、返回什么结构、需要什么headers。这比反复改Spider再跑一遍快得多。
- Token获取:有些网站的登录token需要通过复杂的JS计算或者多个接口串联获取,用requests写个小脚本单独跑,拿到token后再喂给Scrapy。
- 数据补全:Scrapy抓完主数据后,有些字段需要调用第三方API补全,这种零散的请求用requests更灵活。
- 测试与Mock:写单元测试时,用requests-mock或者responses库模拟HTTP响应,比启动整个Scrapy引擎轻量得多。
3.2 在Scrapy中复用requests的Session
有人会问:能不能在Scrapy的Spider里直接用requests?技术上可以,但不推荐。因为Scrapy的下载器有自己的连接池、重试、限速机制,你绕过它直接用requests,等于放弃了这些能力,而且会阻塞Twisted的事件循环,导致整个爬虫变慢。
正确的做法是:如果某个请求必须用requests的特殊能力(比如某些复杂的SSL配置、特殊的认证方式),可以把它放到单独的线程里执行,或者用Scrapy的DOWNLOAD_HANDLERS自定义下载处理器。但绝大多数情况下,Scrapy的Request对象配合meta参数已经能覆盖requests的所有功能。
举个例子,requests里设置超时是timeout=10,Scrapy里是Request(url, meta={'download_timeout': 10})。requests里用Session管理Cookie,Scrapy里是COOKIES_ENABLED=True加上DownloaderMiddleware自动处理。requests里用proxies参数,Scrapy里是Request(url, meta={'proxy': 'http://...'})。功能是一一对应的,只是配置方式不同。
3.3 用requests做Scrapy的“侦察兵”
这是我个人最常用的模式。拿到一个新目标网站,先不急着建Scrapy项目,而是用requests写个几十行的脚本,把关键流程跑一遍:
import requests session = requests.Session() session.headers.update({'User-Agent': '...'}) # 1. 访问首页,拿Cookie r1 = session.get('https://example.com') print('首页状态:', r1.status_code) print('Cookie:', session.cookies.get_dict()) # 2. 访问列表页,看数据结构 r2 = session.get('https://example.com/list?page=1') print('列表页长度:', len(r2.text)) # 用正则或简单解析看看有没有目标数据 # 3. 访问详情页 r3 = session.get('https://example.com/detail/123') print('详情页标题:', r3.text[:200])这个脚本跑通之后,你对整个请求链路就心里有数了:需要哪些headers、Cookie怎么流转、数据在HTML的哪个位置、有没有反爬拦截。然后再把这些逻辑翻译成Scrapy的Spider,效率高很多,而且不容易在Scrapy里反复调试。
4. 实操中的高频问题与排查手册
4.1 请求返回403/401怎么办
403通常是反爬拦截,401是认证失败。排查顺序:
- 检查User-Agent:是不是用了默认的python-requests
- 检查Referer:有些接口必须带Referer
- 检查Cookie:是不是登录态过期了
- 检查请求频率:是不是发太快被限流了
- 检查IP:是不是被封了(这个只能换IP或降低频率)
我遇到过一个案例:某网站对同一IP的请求做了频率限制,超过每秒5次就返回403。解决办法是在请求之间加time.sleep(random.uniform(0.5, 1.5)),模拟人类操作间隔。这个思路在Scrapy里对应的是DOWNLOAD_DELAY和RANDOMIZE_DOWNLOAD_DELAY。
4.2 响应内容乱码的三种情况
乱码是requests最常被吐槽的问题,但原因其实就三种:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 中文变问号或方块 | 编码推断错误 | response.encoding = 'utf-8' |
| 中文变乱码字符 | 实际编码与声明不符 | response.content.decode('gbk') |
| 压缩内容未解压 | 缺少解压库 | 安装brotli或zstandard |
最稳妥的做法是:先看response.headers.get('Content-Type')里有没有charset,有就用它;没有就试utf-8;还不行就用chardet.detect(response.content)检测。但chardet对短文本检测不准,所以优先信任响应头。
4.3 超时与重试的正确配置
前面提过timeout要设元组,这里补充重试。requests本身没有内置重试,需要配合urllib3的Retry:
from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() retry = Retry( total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504] ) adapter = HTTPAdapter(max_retries=retry) session.mount('http://', adapter) session.mount('https://', adapter)backoff_factor=0.5意味着重试间隔是0.5、1、2秒递增。status_forcelist指定哪些状态码触发重试。注意不要对403、404重试,没意义还浪费资源。
Scrapy里对应的配置是RETRY_TIMES和RETRY_HTTP_CODES,逻辑是一样的。
4.4 常见问题速查表
| 问题 | 可能原因 | 快速排查 |
|---|---|---|
| 连接超时 | 网络不通或目标不可达 | ping目标域名,检查代理 |
| SSL证书错误 | 证书过期或自签名 | verify=False(仅测试用) |
| 返回内容为空 | 被拦截或需要JS渲染 | 检查状态码,考虑Selenium |
| Cookie丢失 | Session未复用 | 确认用同一个Session对象 |
| 请求被重定向 | 目标URL变了 | allow_redirects=False看Location |
注意:
verify=False会关闭SSL验证,生产环境绝对不要用,否则中间人攻击防不住。测试环境临时用一下可以,但要在代码里加注释说明。
5. 从requests到Scrapy的思维转换
5.1 同步到异步的思维跳跃
用requests时,代码是线性的:发请求、等响应、解析、再发下一个。用Scrapy时,你要习惯“定义规则,让框架去跑”。Spider里写的是parse方法,返回的是Request对象或Item,调度和并发由引擎控制。
这个转换最难的地方是状态管理。requests里你可以用局部变量在函数间传递状态,Scrapy里因为异步,parse方法可能被并发调用,共享状态要用self属性或者meta传递。我见过不少新手在Spider里用全局变量存数据,结果并发一开就乱套。
5.2 中间件:Scrapy版的“请求拦截器”
requests里你要改请求,得在每个请求前手动改headers或params。Scrapy提供了下载器中间件,可以在请求发出前、响应返回后统一处理。比如统一加UA、统一加代理、统一处理异常状态码。
这相当于把requests里散落各处的逻辑集中到了中间件里,代码更干净。理解requests的请求-响应流程,写中间件时就知道该在哪个环节下手。
5.3 调试技巧的迁移
用requests调试时,我习惯打印response.request.headers和response.headers,对比看请求头和响应头。这个习惯迁移到Scrapy里,就是开启DEBUG日志级别,看Downloader打印的请求详情。Scrapy的日志比requests详细得多,但核心信息是一样的:发了什么请求、带了什么头、返回了什么状态码。
还有一个技巧:在Scrapy里用scrapy shell交互式调试,相当于在命令行里用requests。fetch(request)发请求,response.text看内容,view(response)在浏览器里打开。这个工具用好了,写Spider的效率能翻倍。
6. 几个我踩过的坑和对应的解法
6.1 坑一:Session复用导致的Cookie污染
有一次我写了个多线程脚本,所有线程共用一个Session,结果A线程的登录Cookie被B线程覆盖了,导致部分请求返回未登录状态。排查了很久才意识到Session不是线程安全的。解法很简单:每个线程创建自己的Session,或者用threading.local()。
这个坑在Scrapy里不会遇到,因为Scrapy的下载器是单线程异步的,Cookie管理由框架统一处理。但理解这个坑有助于你明白为什么Scrapy要自己实现一套下载器,而不是直接用requests。
6.2 坑二:gzip和br混用导致的解码失败
前面提过Brotli的坑,这里展开说。requests默认支持gzip和deflate,但Brotli需要额外安装brotli库。如果你在headers里声明了Accept-Encoding: br,但没装库,requests拿到Brotli压缩的内容后无法解压,response.text就是乱码。
解法:要么不声明br,要么装上brotli。我现在的习惯是headers里只写gzip, deflate,除非确认目标站点强制Brotli。
6.3 坑三:重定向导致的Cookie丢失
requests默认跟随重定向,但跨域重定向时Cookie可能丢失。比如从a.com重定向到b.com,Session里的Cookie是绑定域名的,不会自动带到b.com。这种场景需要手动处理,或者用allow_redirects=False先拿到Location,再手动发请求。
Scrapy里对应的配置是REDIRECT_ENABLED和RedirectMiddleware,默认也是跟随重定向,跨域时的Cookie处理逻辑类似。理解requests的行为,到了Scrapy里遇到类似问题就能快速定位。
6.4 坑四:超时设置不当导致的“假死”
有一次线上脚本跑了几个小时没动静,查日志发现卡在某个请求上。原因是timeout设了None(默认),对方服务器接受了连接但不返回数据,程序就一直等。后来改成timeout=(5, 30),问题解决。
这个教训是:任何网络请求都必须设超时。Scrapy里DOWNLOAD_TIMEOUT默认180秒,对于大多数场景太长了,我一般调到30秒左右,配合重试机制。
7. 给不同阶段读者的实操建议
如果你刚接触爬虫,我的建议是:先用requests把基础打牢。找几个简单的静态网站,练习发请求、处理编码、管理Cookie、解析HTML。这个阶段不要碰Scrapy,因为框架会帮你隐藏太多细节,你反而学不到东西。
如果你已经能用requests熟练抓取数据,下一步就是用requests写一个完整的采集脚本:从列表页到详情页,带分页、带去重、带重试、带日志。这个脚本写完之后,你会对爬虫的完整流程有深刻理解。
如果你已经开始用Scrapy,但总觉得配置不顺手,那就回头看看requests是怎么做的。Scrapy的每个配置项,几乎都能在requests里找到对应的手动实现。理解了手动实现,再去看Scrapy的自动化配置,就知道它在帮你做什么、什么时候需要干预。
最后分享一个我常用的调试组合:requests+scrapy shell+ 浏览器开发者工具。先用浏览器看请求,再用requests复现,最后在scrapy shell里验证。这三板斧下来,几乎没有搞不定的采集需求。