做网络请求处理这行不管是写爬虫还是调接口拿到response对象都只算开了个头。真正见功夫的是怎么把响应里的数据榨干用尽——正文怎么解、状态码怎么判断、时间怎么统计、cookie怎么取、响应头怎么看。这篇接着我们系列前两篇的节奏专门把“获取响应”这五个关键要素掰开揉碎讲透。我按实际调试接口和写采集脚本的经验来展开内容针对Python的requests库但思路是通用的。适合刚学爬虫的初学者也适合想系统整理响应处理逻辑的开发者。1. 响应对象里到底藏了哪些东西1.1 一次HTTP交互的完整链条先说个基础认知。HTTP的一次请求-响应交互服务器返回给你的不是“一坨数据”而是一个结构完整的响应报文由三部分组成状态行、响应头、响应体。状态行里包含状态码和原因短语比如HTTP/1.1 200 OK。响应头是一堆键值对描述这次响应的元信息比如内容类型、内容长度、服务器类型、缓存策略、cookie等。响应体才是真正的业务数据可能是HTML、JSON、图片二进制流也可能是空字符串。requests库把这三部分都封装进了Response对象里对外暴露成属性status_code对应状态行.headers和.cookies对应响应头cookie在协议层面也是通过响应头传递的.text、.content、.json()这些方法对应响应体。还有一个.elapsed属性记录这次请求花了多长时间。五个关键信息其实就是对这三大结构的细分拆解。很多人刚接触时容易犯一个错误只取正文忽略其他部分。结果就是接口报错不知道为什么重定向后傻眼了cookie失效了不知道怎么排查。其实状态码、响应头、cookie、时间这四样东西才是判断一次请求是否真正成功、以及后续该怎么做决策的依据。1.2 为什么这五项信息是爬虫和接口调试的命根子我自己调试接口有个习惯拿到response之后第一时间不是看正文而是先看状态码、响应头和耗时。为什么因为正文有可能是被框架包装过的“假象”。举个例子某个接口你请求成功状态码返回200正文里却是{code: 40001, msg: token过期}。这时候你要是只看正文内容绝对会被绕进去。而状态码配合响应头能帮你快速判断这次请求在HTTP传输层是不是真的成功了有没有被重定向服务器是什么类型返回的内容是JSON还是HTMLcookie的重要性更是爬虫场景的核心。服务器下发身份凭证基本都靠Set-Cookie响应头你请求返回的response.cookies里装着的就是这些凭证。如果不会从响应里取cookie那登录后的会话状态就维持不住采集数据总是停留在未登录状态。响应时间这块则直接关系到你的采集效率。一个接口平均耗时200毫秒另一个耗时2秒同样的任务量耗时差距就是10倍。把.elapsed用好能直观地发现哪些站点、哪些接口在拖慢你的整体任务进度。2. 响应正文获取三种方式别用混2.1 text、content与json()三兄弟怎么选Response对象取正文有几种方式刚上手时最容易混。我用一个表先给你说清楚方法/属性返回类型适用场景resp.text字符串文本类响应HTML、JSON字符串、XML等resp.content字节串bytes图片、视频、压缩包等二进制数据resp.json()字典/列表等接口返回JSON且确认格式合法时resp.text虽然是“文本”但它内部是拿resp.content去解码得来的。也就是说text是content的“解码后版本”。如果你直接打印resp.text出现乱码本质上是解码用的字符集错了。resp.json()是json.loads(resp.text)的封装。但它有个前提响应内容必须是合法的JSON否则会抛异常。所以实际项目里我不会直接裸调json()而是先判断状态码和Content-Type再用try包一层避免一个非JSON响应直接把整个流程打崩。选型的核心逻辑就一句话要文本用text要字节用content要结构化的数据用json()。别在返回图片的接口上去调text也别在返回JSON的接口上手动做字符串切片。2.2 编码问题乱码的根源与三招解决中文乱码是响应正文环节出现频率最高的问题没有之一。乱码的根源在于字符集不匹配服务器返回的是UTF-8编码的字节流你这边却按GBK去解码出来的自然就是一堆“锟斤拷”。requests在解码resp.text时优先依据响应头Content-Type里的charset参数如果响应头里没写charsetrequests会用ISO-8859-1这个编码在中文场景下几乎必乱。这就是为什么很多接口的resp.text打印出来是乱码而用resp.content.decode(utf-8)却正常。我处理乱码的思路有三个层级第一层显式指定编码。拿到响应后先判断如果乱码直接给resp.encoding赋正确的字符集再重新访问resp.text。import requests resp requests.get(https://目标站点/api/info, timeout5) if GBK in resp.headers.get(Content-Type, ).upper() or GB2312 in resp.headers.get(Content-Type, ).upper(): resp.encoding gbk print(resp.text)第二层用resp.apparent_encoding自动探测。这个属性会根据正文内容推断字符集实测下来对中文站点准确率还不错。但它是基于统计学的推断会消耗额外耗时所以我不建议在性能敏感的高频请求中用只在排查阶段用。第三层像上面的代码一样直接基于响应头里的字符集信息写一个判断函数统一处理多个目标站点的编码差异。采集任务里的乱码问题基本都是这么理性地消掉的。注意resp.text一旦被访问内部就按当时的resp.encoding解码了一次之后你再改resp.encoding再访问resp.text结果依然会变因为requests每次访问text都会重新解码。但频繁改来改去容易让逻辑混乱最好在第一次访问text之前就把编码固定好。2.3 大文件与二进制内容怎么拿用resp.content下载一个几百MB的文件时如果直接content一把梭内存会直接爆掉。正确姿势是用streamTrue模式配合迭代读取。我习惯这么写import requests resp requests.get(https://目标站点/files/backup.zip, streamTrue, timeout(5, 60)) with open(backup.zip, wb) as f: for chunk in resp.iter_content(chunk_size1024 * 512): if chunk: f.write(chunk)这里有个关键细节iter_content是按指定块大小从响应体里迭代字节的每块默认是None时是按chunk_size从原始流里读。512KB是块大小要根据文件大小和内存情况调整不用死守。另一个细节是判断if chunk是为了过滤掉某些流式传输中可能出现的空块避免空写。关于图片、音视频等二进制内容直接resp.content拿完整字节问题不大只要确认目标不是超大文件。拿到字节之后可以配合io.BytesIO做内存中的文件对象处理或者直接写盘。3. 状态码判断200不是唯一答案3.1 状态码分类与常见速查状态码是服务器给你的“HTTP层面”的回执。虽然业务是否成功要看业务码但HTTP状态码决定了你这一次请求在传输层到底是什么结果。理解分类比死记数字更重要2xx请求成功200标准成功201创建成功204无内容。3xx重定向301永久转移302临时转移304缓存未修改这个在爬虫里很常见表示资源没变直接用本地缓存即可。4xx客户端错误400参数错误401未认证403禁止访问404不存在429请求太频繁。5xx服务端错误500服务器内部异常502网关错误503服务不可用504网关超时。这份速查表不需要背全但你要能在遇到状态码的第一时间反应出大致方向。比如403优先怀疑是IP被封、UA被拦或签名缺失429优先考虑降低并发或加代理轮换503优先考虑对方服务过载可能是对方风控触发或者机房限流。3.2 raise_for_status()的正确用法与坑requests提供一个方法叫raise_for_status()作用是如果状态码是4xx或5xx就抛出对应的HTTPError异常。它是个偷懒但实用的工具。resp requests.get(https://目标站点/api/data, timeout5) resp.raise_for_status() print(resp.text)这么写4xx/5xx会自动抛异常省去手动判断状态码的代码。但这里有个坑很多人不知道raise_for_status()只管HTTP状态码根本不管业务状态码。服务器返回200但正文里{code: 500}它照样不抛异常。所以你该知道它替代不了业务层的状态判断。还有一个场景要警惕目标站点用302重定向做用户身份校验时requests默认会跟随重定向。如果重定向最终落到一个登录页面状态码是200但内容明显不是你要的数据。这种场景下raise_for_status()完全没有用武之地你得靠检查resp.url或者正文特征来判断是否真的到了目标页面。3.3 重定向时状态码的变化requests在发起请求时默认allow_redirectsTrue也就是说遇到301/302/307等重定向状态码它会自动跟进最终给你的resp.status_code是最后一次请求的状态码而不是第一次的。我举个例子请求https://目标站点/a返回302跳转到https://目标站点/bresp.status_code拿到的是b的200resp.url也是b的地址。如果服务器在跳转过程中把cookie种到了a的域名下直接取resp.cookies拿到的只是最后一次响应的cookie前一次响应里种的cookie就丢了。如果想看每一次跳转过程中的状态码和响应头可以把allow_redirectsFalse关掉自己处理重定向。或者用resp.history这是一个包含重定向链上所有响应对象的列表resp.history[0].status_code就是第一次跳转的状态码。这个细节在登录流程里特别重要。很多登录接口会先302到某个中间页再302回到主页面中间页的响应头里才带着关键token或cookie。如果你只看最后的resp等于把中间过程的信息全弄丢了。4. 响应时间与超时设置接口慢在哪一截4.1 elapsed到底算的是什么时间resp.elapsed是一个timedelta对象记录的是从发送请求到收到响应头这段时间的耗时。注意它只到“响应头到达”为止不包括下载响应体的时间。import requests resp requests.get(https://目标站点/api/data, timeout5) print(resp.elapsed.total_seconds())total_seconds()把它转成秒适合做日志记录。实测里这个值基本能反映一次请求的关键耗时因为大多数接口的正文都不大下载正文的时间占比很低。但如果你要下载大文件resp.elapsed就不够用了它不包括iter_content读完整体的时间得自己用时间戳包一层算。4.2 超时设置连接超时与读取超时分开配新手最常见的错误之一是不设timeout。不设超时的情况下请求可能一直挂着整个爬虫任务卡死在某个站点上。timeout参数我推荐用元组形式resp requests.get(https://目标站点/api/data, timeout(3, 5))第一个数3是连接超时表示建立TCP连接最多等3秒第二个数5是读取超时表示连接建立后每次从服务器读取数据最多等5秒。分开配的好处是连接超时设短一些快速跳过连不上的主机读取超时设长一些给那些处理慢但确实在干活的服务器留足时间。如果你只传一个数3它同时作用于连接和读取阶段。这在某些慢接口上可能会误杀正常请求因为一个接口逻辑本身就要跑好几秒读取阶段卡在3秒上就被掐断了。4.3 真实场景用时间字段排查慢接口我在做大规模采集时会把elapsed和status_code一起写进日志。怎么用举个例子某个采集任务突然变慢把日志里的耗时字段按站点聚合直接定位是哪一个站点平均耗时暴涨。再配合状态码分布就能判断是对方限流了大量429/503还是网络链路出问题了连接超时增多。这里有个实用技巧在请求重试机制里把上次的elapsed作为本次的超时参考。比如某个站点的接口平均250毫秒返回那超时可以设置成3, 8但如果连续几次都逼近超时值就要警惕是不是IP被风控响应被对方故意拖慢。这时候继续闷头重试只会浪费时间不如直接切换代理或暂停一会儿。另外我用time.perf_counter()测过一些特殊场景。比如要计算包括“下载整个响应体”在内的完整时间就这么做import time import requests start time.perf_counter() resp requests.get(https://目标站点/largefile, streamTrue, timeout(5, 30)) for _ in resp.iter_content(chunk_size65536): pass cost time.perf_counter() - start print(f完整耗时: {cost:.3f}s)5. Cookie获取与状态维持别把会话丢了5.1 response.cookies与Set-Cookie头HTTP协议里服务器下发cookie依赖的是Set-Cookie响应头。requests会把响应里的cookie解析成RequestsCookieJar对象挂在resp.cookies上。取某个具体cookie的值用.get()方法resp requests.post(https://目标站点/login, data{user: test, pass: 123456}) session_id resp.cookies.get(sessionid)要注意有些站点会一次下发多个Set-Cookie头每个头里可能包含多个键值对。resp.cookies这个jar对象可以把它们合并起来用resp.cookies.get(key)按名取值或者用resp.cookies.items()列出所有键值。但如果你非要从响应头里手动解析用resp.headers.get(Set-Cookie)只能拿到一个原始字符串多个Set-Cookie会被合并或者只留其中一个很容易漏。所以我一直建议取cookie直接用resp.cookies别去解析响应头字符串。5.2 手动提取与Session自动管理的取舍手动提取cookie然后带到下一次请求是一种“控制狂”做法resp requests.post(https://目标站点/login, data...) cookie_value resp.cookies.get(sessionid) requests.get(https://目标站点/profile, headers{Cookie: fsessionid{cookie_value}})这在简单场景能用但麻烦在于目标站点的cookie可能不止一个多个cookie拼接顺序有讲究某些cookie还会在后续请求的响应中更新手动维护很容易出错。用requests.Session()自动管理cookie才是舒服的做法s requests.Session() s.post(https://目标站点/login, data...) resp s.get(https://目标站点/profile)Session对象内部维护了一套cookie状态登录接口返回的Set-Cookie会自动存起来后续请求自动带上响应里更新了cookie也会自动替换。我的建议是凡是涉及登录态或者多步骤流程的采集一律用Session不要手动维护cookie。手动提取适合那种“只需要把cookie传到另一个脚本或另一个环境”的场景比如你本地脚本登录完要把cookie交给服务器上的另一个程序用。5.3 跨请求携带cookie的几个细节这里有几个容易踩的细节都跟Cookie的作用域和属性有关。第一个是过期时间。resp.cookies里的cookie对象有.expires属性如果你发现某次请求返回401或跳转到登录页先检查拿到的cookie是不是已经过期了。有些站点cookie的有效期只有半小时登录后挂着不干活cookie悄悄就没了。第二个是Domain作用域。服务器在Set-Cookie里写明了这个cookie适用于哪个域名。手动把登录域名的cookie带到另一个域名的请求里对方根本不会认。用Session就不会出这种错它会根据域名自动匹配。第三个是HttpOnly属性。常规浏览器里JS拿不到HttpOnly的cookie但requests不受这个限制resp.cookies里照样能取到。这对爬虫来说是个好消息你不用担心脚本里取不到某个HttpOnly cookie。6. 响应头解析藏着服务端的小秘密6.1 headers对象的特性大小写不敏感resp.headers返回的可不是普通字典它是CaseInsensitiveDict也就是键的大小写不敏感。所以你写成resp.headers[Content-Type]和resp.headers[content-type]都是同一个东西这对处理那些响应头大小写不标准的站点特别管用。不过它也有个缺点它不是重复键友好的字典。正常情况下响应头里同名键只会出现一次但Set-Cookie是个例外可能会有多个同名头。前面提过这种情况下用resp.headers.get(Set-Cookie)不能拿到全部原始头应该走resp.raw.headers.get_all(Set-Cookie)去拿完整列表。import requests resp requests.get(https://目标站点/login-page, allow_redirectsFalse) raw_headers resp.raw.headers all_set_cookie raw_headers.get_all(Set-Cookie) for item in all_set_cookie: print(item)注意resp.raw只有在访问之后才可用而且它是urllib3层的原始响应头对象字段大小写和取值方式跟resp.headers略有不同。用到的时候才去调它别过度依赖。6.2 值得重点看的几个响应头响应头里值得重点关注的我挑几个说Content-Type告诉你怎么解析正文。里面还带着charset参数比如application/json; charsetutf-8这是处理编码问题的第一依据。Content-Length正文长度。如果实际下载的正文大小跟它偏差巨大说明中间可能被篡改或者网络有问题。但注意使用Transfer-Encoding: chunked的响应没有Content-Length。Location重定向的目标地址。配合3xx状态码看手动处理跳转时用得上。Cache-Control缓存策略。no-cache表示不缓存max-age3600表示可以缓存一小时。爬虫里可以用这个字段决定要不要缓存响应结果。Server服务器类型或框架指纹有可能是伪装的别太当真。Set-Cookiecookie下发入口前面详细说过。6.3 从响应头中识别服务端指纹附带“被伪装”提醒很多开发者在识别目标站点的技术栈时第一反应就是看Server头和X-Powered-By头。比如Server: nginx说明大概率是NginxX-Powered-By: Express说明可能是Node.js。但实际操作里这两个字段非常容易被伪装。我遇到过某个站点响应头里写的是Server: nginx但实际逻辑判断出后端是个Python应用。因为它的错误响应里的某个特征字段暴露了真实技术栈。所以要我说识别服务端指纹不能只看一两个头要多维度交叉验证看Set-Cookie的命名风格看错误页的HTML结构看接口返回数据的大小写习惯看某些框架特有的响应头组合。有个更隐蔽的点Date响应头。它代表服务器当前时间结合业务逻辑可以判断对方服务器时钟是否精准。有些签名校验算法依赖时间戳如果对方服务器时间跟真实时间偏差很大你本地生成的签名可能一直校验失败排查半天才发现是时区或服务器时间的问题。另外Content-Encoding响应头也值得注意。如果值是gzip或br说明响应正文经过了压缩传输。requests会自动解压gzip和deflate但如果你用某些原生HTTP客户端就得手动处理解压逻辑。7. 常见问题与排查实录速查表最后把实际过程中踩过的坑整理成表每个问题都对应着一条排查思路遇到类似情况可以直接照着查问题现象可能原因排查与解决办法resp.text中文乱码charset缺失或错判改resp.encoding或用resp.content.decode(...)参考2.2节思路请求一直卡住不返回没设置超时设置timeout尤其是元组形式的连接/读取分离超时状态码200但拿不到正文重定向到了登录页检查resp.url、resp.history看是否发生了意外跳转登录后请求仍提示未登录Cookie没带或已过期改用Session检查cookie的Domain、Expires属性错误日志里全是429/503请求频率过高触发限流降低并发、加延时、轮换代理IP别硬重试Set-Cookie头只取到一个多个同名响应头被合并用resp.raw.headers.get_all(Set-Cookie)取原始列表接口耗时突然暴涨网络链路或对方风控用elapsed做耗时监控结合状态码分布定位方向resp.json()抛异常响应体不是合法JSON先用resp.text打印前面几百字符确认返回内容到底是啥大文件下载内存爆掉直接用resp.content换成streamTrue配合iter_content分块写盘服务端技术栈误判响应头被伪装多维度特征交叉验证别只信某个单一响应头我在实际采集和维护接口调用的过程里最深的体会是响应对象的处理不是一个“拿到就完事”的步骤它几乎决定了整个任务的稳定性和可排查性。正文、状态码、时间、cookie、响应头每一项都不复杂但组合在一起就是一套完整的接口健康检查方案。你把这五个维度都记录下来写进日志出问题时能省下大把排查时间。最后再分享一个小技巧我在调试响应头或cookie时经常用一段几行的小脚本把一次响应的“体检报告”完整打出来——状态码、最终URL、耗时、所有响应头、所有cookie、正文前500字符。每次排查未知接口先用这个脚本拍个照再针对性深入效率比瞎猜高得多。
阅读完成 · 觉得有帮助?