写爬虫的第一次挫败,往往不是卡在解析逻辑上,而是请求发出去之后,回来的东西完全看不懂:要么一个 403 挡在门口,要么正文是一堆乱码,要么连连接都握不上手。这时候去翻资料,绕来绕去都会撞回同一组概念——请求头、响应头、HTTP 与 HTTPS 的区别、状态码。这四个东西听起来像教科书里的名词,实际它们是你和服务器之间唯一的对话通道。爬虫调不通,九成以上的原因都落在这四个词覆盖的范围内。
这篇内容适合两类人:一类是刚能跑通第一个 demo、但一换站点就抓瞎的入门者;另一类是写了半年爬虫,遇到问题只会无脑复制浏览器头、加time.sleep、换 IP 的熟练工。前者能拿到一套完整的认知框架,后者能在这几个最容易被忽略的细节里找到真正的瓶颈。文章里会给出可以直接抄的代码、能对照排查的表格,以及我在实际项目里踩过的具体坑。所有涉及链接的资源,我会给出检索关键词和标准编号,你按编号搜原文即可,比收藏一堆随时失效的网址靠谱得多。
1. 请求头:爬虫递出去的第一张名片
1.1 默认请求头等于主动自报家门
用 requests 写一行requests.get("https://example.com/"),实际发出去的报文大概是这个结构:
GET / HTTP/1.1 Host: example.com User-Agent: python-requests/2.31.0 Accept-Encoding: gzip, deflate Accept: */* Connection: keep-alive这五个头一说出口,等于在门口大喊"我是脚本"。User-Agent里明晃晃写着 python-requests,Accept是通配符*/*说明这个客户端对返回格式没有任何偏好,真实的浏览器绝不会这样——它会明确要求text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8这一长串。再加上现代浏览器还会带上Accept-Language、Referer、Sec-Fetch-*一整组头,而脚本只发三个,差异在服务端日志里像黑夜里的火把一样明显。
需要说明的是,很多站点并不真的做客户端指纹校验,它只是用 Nginx 或某个 WAF 的默认规则拦掉"明显不像浏览器"的请求。所以你会发现,把 UA 换成一个正常的浏览器标识,403 立刻就变成 200 了。这不是什么高深技术,就是让对方的第一道粗筛规则放你过去。
1.2 一份能跑通的请求头该包含什么
下面这张表是我按"必要程度"排的序,不是按字母顺序,因为实际动手时你该按这个优先级去补:
| 请求头 | 作用 | 爬虫里的处理建议 |
|---|---|---|
| User-Agent | 标识客户端类型与版本 | 必改,用常见浏览器的完整标识串 |
| Accept | 声明能接受的响应类型 | 必改,模仿浏览器的一长串 q 值写法 |
| Accept-Language | 语言偏好 | 建议改,zh-CN,zh;q=0.9,en;q=0.8 |
| Accept-Encoding | 支持的压缩算法 | 谨慎改,改完必须能正确解压 |
| Referer | 来源页面 | 有防盗链的站点必带,且要与目标页面同域 |
| Cookie | 会话凭证 | 登录态必需,优先用 Session 自动管理 |
| Content-Type | POST 请求体的格式 | POST 必带,表单和 JSON 完全不同 |
| Connection | 连接复用策略 | 一般保持 keep-alive |
| Host | 目标主机名 | 用域名访问时自动生成,直连 IP 时必须手写 |
| Sec-Fetch-Site 等 | 请求意图声明 | 现代站点会校验,缺了可能被判定为非浏览器 |
Sec-Fetch-*这一组是近几年才普及的,一共四个:Sec-Fetch-Site(同站/跨站/同源)、Sec-Fetch-Mode(navigate/cors/no-cors)、Sec-Fetch-Dest(document/image/script)、Sec-Fetch-User(是否由用户操作触发)。一个真正的页面跳转,通常是Sec-Fetch-Site: none(地址栏直接输入)或者same-origin,配合Sec-Fetch-Mode: navigate和Sec-Fetch-Dest: document。如果你的请求缺了这一组,某些做了行为校验的站点会直接返回一个空壳页面或者 403,而你从状态码上根本看不出原因。
1.3 三个改了反而更糟的头
第一个是Accept-Encoding。很多人看到浏览器发了gzip, deflate, br,就照抄一份过去,结果用urllib或http.client拿到的是二进制乱码。原因很简单:服务端按你声明的算法压缩了响应体,但你的客户端没有对应的解压逻辑。requests 会自动处理 gzip 和 deflate,对 br(Brotli)在新版本里也支持,但需要额外依赖。如果你用的是标准库,老老实实只写gzip, deflate,或者干脆用identity让服务端别压缩。
第二个是Content-Type。POST 数据时,application/x-www-form-urlencoded和application/json在服务端的解析路径完全不同。用 requests 的data=参数传字典,它会自动设成表单类型;用json=参数传字典,它会自动设成 JSON 并序列化。最怕的是手工拼字符串又忘了设头,服务端拿到一坨没有类型声明的字节,解析直接失败,返回 400 但你从错误信息里看不出问题出在格式上。
第三个是Cookie。手写 Cookie 字符串最容易出的错是漏掉分号后的空格,或者把多个 Cookie 的顺序搞乱。更常见的是混用:既手动设了headers={"Cookie": "..."},又用了requests.Session(),结果 Session 里的 Cookie 和手写的打架,服务端收到重复的键。正确做法是二选一——需要保持登录态就用 Session,登录后让它自己管理;只是一次性带个固定凭证,才手写。
1.4 头部的顺序和大小写也是信息
HTTP/1.1 规范里头部字段名是大小写不敏感的,User-Agent和user-agent等价。但现实是,很多服务端的指纹识别就是拿这个做文章。Python 的普通 dict 在 3.7 以后是有序的,requests 内部用的是一个大小写不敏感的字典结构,它会把你传进去的键按原样保留,但合并默认头的时候顺序可能会变。
如果你遇到的站点确实在这一层做校验,可以用OrderedDict显式控制顺序,让它和真实浏览器发出的顺序一致:Host最前(HTTP/1.1 里通常紧跟请求行),然后是Connection、Cache-Control、User-Agent、Accept、Accept-Encoding、Accept-Language,最后是Cookie。这个顺序不是玄学,它反映了主流浏览器内部头部的注册次序。
还有一个容易被忽略的点:HTTP/2 里根本没有传统意义上的请求行和头部行,它用的是伪头部字段:method、:path、:authority、:scheme,而且所有头部字段名被强制转成小写。所以同一个站点,走 HTTP/1.1 和走 HTTP/2,服务端看到的头部形态是不一样的。如果你在本地调试一切正常,上了服务器就出问题,先确认一下两边协商到的协议版本是否一致。
2. 响应头:服务端回话里的潜台词
2.1 Content-Type 与编码:乱码的账要算在谁头上
乱码是爬虫里最经典的问题,而且十有八九不是"编码错了",是"你没告诉程序该用哪个编码"。看一个标准的响应头:
HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Content-Length: 15234 Content-Encoding: gzipcharset=utf-8就是明确指令:请用 UTF-8 解码。但很多国内的老站点压根不写 charset,只写Content-Type: text/html,这时候 requests 会退回到 HTTP/1.1 规范里的历史默认值 ISO-8859-1,用它去解 GBK 编码的中文页面,必然是一片问号。
处理顺序应该是这样的:先看响应头里有没有 charset,有就直接用;没有的话,看页面<meta charset="...">或者<meta http-equiv="Content-Type" content="text/html; charset=gb2312">;两者都没有,才用自动探测。requests 提供了r.apparent_encoding,底层是字符集探测库,对中文页面准确率不错,但它是靠统计猜的,短文或纯英文页面会误判。
import requests r = requests.get("https://example.com/", timeout=10) if "charset" not in r.headers.get("Content-Type", "").lower(): r.encoding = r.apparent_encoding text = r.text这里有个细节值得说:r.text是按r.encoding解码的结果,r.content是原始字节。如果你手动改了r.encoding,必须用r.text才能生效;如果你用r.content.decode("gbk"),那r.encoding设成什么都不影响。两种写法都对,但别混着来,很多人改了 encoding 又去 decode content,白忙一场。
还有一个坑是Content-Encoding。如果响应头里有gzip或br,body 是被压缩过的字节流。requests 会自动解压并把Content-Encoding从r.headers里去掉,所以你看不到它。但如果你用的是http.client或者某些异步库,拿到的content就是压缩后的原始字节,直接解码会得到一堆乱码和方块。判断方法很简单:如果r.content[:2]是b'\x1f\x8b',那就是 gzip 没解开。
2.2 Set-Cookie、Location 与重定向链路
Set-Cookie是响应头里结构最复杂的一个,它带一堆属性:Domain限定生效域名,Path限定生效路径,Expires和Max-Age控制有效期,HttpOnly禁止脚本读取,Secure只在加密连接下发送,SameSite控制跨站携带策略。
爬虫里最常踩的坑是Domain属性。如果服务端设了Domain=.example.com,那么www.example.com和api.example.com都能收到这个 Cookie;如果没设,那就只有当前主机名生效。你用 Session 跨子域抓数据时,Cookie 带不过去,往往就是这里的原因。
Location出现在 3xx 响应里,指向新地址。requests 默认allow_redirects=True,会自动跟着跳,最多跟 30 次,跳转的每一跳都记录在r.history里。想看清楚跳转路径,就这么打:
r = requests.get("https://example.com/", timeout=10) for h in r.history: print(h.status_code, h.headers.get("Location")) print("final:", r.status_code, r.url)需要注意方法在跳转中的变化:301 和 302 在历史上被实现得五花八门,很多客户端会把 POST 改成 GET;303 明确规定改成 GET;307 和 308 则要求保持原方法。如果你在提交表单时遇到跳转后数据"丢了",先看看是哪个状态码。
2.3 限流、缓存和"别再来"的暗示
有一类响应头不参与业务逻辑,但它决定了你还能不能继续抓。X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Reset这三个是常见约定(前缀不一定是 X-,有的直接叫RateLimit-*),分别表示窗口内总配额、剩余配额、重置时间。有这几个头的站点,等于把限流规则明明白白告诉你了,你照着Remaining动态调整并发就行,比盲目试探高效得多。
Retry-After出现在 429 或 503 响应里,值可能是秒数,也可能是一个 HTTP 日期字符串。看到它就该老实等着,硬刚只会让封禁时间变长。
缓存相关的头是另一组:Cache-Control的max-age表示新鲜期,no-store表示别缓存,private表示只允许终端缓存;ETag是内容指纹,Last-Modified是最后修改时间。配合请求端的If-None-Match和If-Modified-Since,服务端在内容没变时返回 304 和一个空 body。对需要定期增量抓取的场景,这套机制能省掉大量带宽,但前提是你的客户端要正确处理 304——否则会把空内容当成"页面没了"。
Vary: Accept-Encoding这个头也值得留意,它告诉缓存服务器"同一个 URL 用不同压缩算法请求,要分别缓存"。看到它说明服务端在内容协商上做得比较细,你改动Accept-Encoding可能拿到不同形态的响应。
3. HTTP 与 HTTPS:加密层带来的真实差异
3.1 抓包时看到的世界完全不一样
HTTP 是明文协议,你在链路上抓到的字节,除了分帧外基本就是原文。请求行、头部、body 全都肉眼可读。HTTPS 是在 HTTP 和传输层之间插了一层 TLS,握手完成后所有应用层数据都被加密。抓包工具能看到 TCP 的连接过程,但内容是一堆密文,除非你让它作为中间人并信任它的证书。
这个差异直接决定了排查问题的思路。HTTP 站点出问题,你可以直接把原始报文打出来逐字节对比;HTTPS 站点出问题,你能看到的只有状态码、部分响应头(有些中间人代理会暴露)、以及时间线。所以我一般的习惯是:先用小脚本把能拿到的信息全打出来,别急着上抓包工具,很多时候光看状态码和响应头就能定位。
协议版本也影响很大。HTTP/1.1 是文本协议,一个连接上串行处理请求(虽然有管线化,但实际很少用);HTTP/2 是二进制分帧,一个连接上多路复用,头部用 HPACK 压缩,还强制小写;HTTP/3 直接跑在 UDP 上的 QUIC 之上,握手更快,但在一些网络环境里会被限速或阻断。用 requests 默认走的是 HTTP/1.1,想验证 HTTP/2 得换客户端库,比如支持 h2 的那些。
3.2 证书校验、SNI 和握手阶段的失败
requests 默认verify=True,会校验证书链和主机名。遇到自签名证书,会抛SSLError: certificate verify failed。网上流传的做法是verify=False一把梭,这确实能让请求过去,但同时也关掉了中间人攻击的防护,而且会刷一堆警告。更合理的做法是把目标站点的根证书下载下来,用verify="/path/to/ca.pem"指定,既过了校验又不牺牲安全性。
SNI(Server Name Indication)是 TLS 握手里携带目标域名的扩展。一个 IP 上可能挂了几十个站点,服务端靠 SNI 决定出示哪张证书。你用 IP 直连而不发 SNI,服务端可能给你一张默认证书,主机名对不上,校验就失败了。所以"先解析 IP 再直连加速"这种优化,在 HTTPS 场景下往往会引入证书错误。
握手失败的表现有很多种:EOF occurred in violation of protocol通常是服务端在握手阶段就断开了,可能是协议版本不匹配,也可能是对方识别出你不是正常客户端;ConnectionResetError是连接被重置,很多时候是网络中间设备干的;超时到 443 端口则可能是对方根本没开这个端口,或者被防火墙拦了。这几种错误的排查方向完全不同,别一律当成"被封了"。
3.3 常见报错与排查方向对照
| 报错信息关键词 | 大概率原因 | 排查动作 |
|---|---|---|
| certificate verify failed | 自签证书、证书链不全、根证书过期 | 用verify指定证书文件,或临时关闭校验确认 |
| hostname mismatch | 证书域名与实际访问域名不一致 | 检查是否用了 IP 直连、是否漏发 SNI |
| EOF occurred in violation of protocol | 握手阶段被断开、协议版本不匹配 | 换协议版本试、确认目标端口是否支持 TLS |
| ConnectionResetError | 连接被中间设备重置 | 降低频率、检查是否触发了连接数限制 |
| Read timed out | 服务端处理慢或链路慢 | 加大 timeout、区分连接超时和读取超时 |
| too many redirects | 重定向循环 | 关掉自动跟随,手动看 Location 链条 |
需要强调的是verify=False这个开关。它解决的问题只是"证书校验不通过",如果你的请求本来就被服务端拒绝(返回 403),关掉校验一点用都没有,反而会让你误以为问题在安全层。我见过不少人把它当成万能药,结果绕了一大圈才发现是 UA 的问题。
4. 状态码:判断"该不该继续"的第一手依据
4.1 1xx 和 2xx:那些很少见但要认识
1xx 是信息性状态码,实际爬虫里遇到的机会不多。100 Continue出现在客户端发了Expect: 100-continue的场景,通常是大文件上传前先问一句"你收不收",服务端回 100 才开始传。101 Switching Protocols是协议升级,WebSocket 握手成功时就是它。103 Early Hints比较新,服务端在最终响应前先推几个资源提示,主要给浏览器做预加载用。
2xx 里最熟的是 200。201 Created表示资源创建成功,常见于 POST 提交后。202 Accepted表示请求已接收但还没处理完,典型场景是异步任务——你提交一个导出任务,服务端回 202,真正的结果要过一会儿去另一个地址查。204 No Content表示成功但没有响应体,DELETE 操作经常这样。206 Partial Content是断点续传的基础,配合请求端的Range: bytes=0-1023使用。
204这个状态码坑过不少人:它的响应体确实是空的,但Content-Length可能是 0 或者干脆不出现。如果你的代码逻辑是"body 为空就认为失败",就会误判。同理,304也是空 body,但含义完全不同。
4.2 3xx:搬家通知的三种语义
3xx 家族的核心问题是"方法要不要保留"和"缓存要不要更新",这两点历史上被实现得很混乱。
301 Moved Permanently是永久搬家,搜索引擎会更新索引,浏览器会缓存这个跳转;302 Found是临时跳转,本意是保持原方法,但早期浏览器都擅自改成了 GET,导致后来的实现也跟着乱;303 See Other明确定义为"去另一个地址用 GET 取结果",POST 提交后跳转到结果页就用它;307 Temporary Redirect是 302 的修正版,明确要求保持原方法;308 Permanent Redirect是 301 的修正版,同样保持方法。
爬虫里最需要注意的是,很多站点的登录流程依赖 302 跳转。你用 POST 提交账号密码,服务端回 302 指向首页,同时Set-Cookie下发会话凭证。如果你关掉了自动跟随,就必须自己处理这个跳转并保存 Cookie;如果开着自动跟随,requests 会帮你做完,但 POST 会被改成 GET,某些严格的接口会因此报错。
304 Not Modified单独说一下。它不是错误,是"你手里的副本还能用"。触发条件是请求里带了If-None-Match(对应ETag)或If-Modified-Since(对应Last-Modified),服务端比对后认为没变化。这时候 body 是空的,你该用本地缓存,而不是把空内容当成页面内容存下来。
4.3 4xx:错的到底是谁
4xx 里最容易混淆的是 401 和 403。
401 Unauthorized严格来说是"未认证",你还没证明自己是谁。响应里通常带一个WWW-Authenticate头,告诉你该用哪种认证方式。403 Forbidden是"已认证但无权限",或者干脆是服务端不想理你。反爬拦截绝大多数返回 403,因为对方识别出你是脚本,但不想告诉你具体原因。
具体到爬虫场景,常见的有这么几个:
400 Bad Request:请求参数格式不对、缺少必填字段、body 编码有问题。POST 时忘了设Content-Type,或者 JSON 里多了个尾逗号,都会落到这里。404 Not Found:路径不对,或者资源确实没了。但要注意,有些站点对未登录用户统一返回 404 来隐藏资源存在性,别急着断定链接错了。405 Method Not Allowed:方法用错了,比如该用 POST 你发了 GET。响应里一般带Allow头告诉你支持哪些方法。408 Request Timeout:服务端等你的请求等超时了,通常是网络问题。410 Gone:资源被永久删除,比 404 更确定,遇到它就别再重试了。413 Payload Too Large:请求体太大,上传文件时会遇到。415 Unsupported Media Type:Content-Type声明了服务端不支持的格式。422 Unprocessable Entity:语法没问题但语义校验失败,表单校验不通过时很常见。429 Too Many Requests:限流了,看Retry-After决定等多久。451 Unavailable For Legal Reasons:因合规原因不可用,遇到就放弃这个目标,别想办法绕。
429是最需要认真对待的一个。它不是"你错了",而是"你太快了"。正确做法是读Retry-After,按它给的时间等,同时降低并发、加上随机间隔。如果继续硬冲,很多时候会从 429 升级成 403 甚至 IP 段封禁,那损失就大了。
4.4 5xx:该重试还是该放弃
5xx 是服务端的问题,但"服务端的问题"分两种:一种是暂时性的,重试有用;另一种是持续性的,重试只会浪费时间。
500 Internal Server Error是最笼统的,服务端代码抛异常了。可能是你的参数触发了它的 bug,也可能是它自己挂了。重试一两次没好转,基本可以判断是你的请求本身有问题。502 Bad Gateway和504 Gateway Timeout是网关层的错误,前者是网关从上游拿到无效响应,后者是网关等上游超时。这两个在负载高的时候很常见,退避重试往往有效。503 Service Unavailable是服务端主动声明自己不可用,可能在做维护或过载保护,通常带Retry-After。
重试策略我一般这么定:只对 5xx、429、连接超时、读取超时重试;4xx 里除了 429 一律不重试;重试次数 3 次起步,间隔用指数退避加随机抖动,比如 1 秒、2 秒、4 秒,各自上下浮动 0.5 秒。抖动很重要,不然一批任务同时失败又同时重试,等于自己给自己制造了一次小流量峰值。
5. 把概念跑一遍:最小验证脚本
5.1 打印一次完整的请求与响应
光看概念容易飘,跑一遍最实在。下面这段代码把双向的头部、状态码、编码、重定向链全打出来:
import requests def inspect(url, **kwargs): r = requests.get(url, timeout=10, **kwargs) print("=== request ===") for k, v in r.request.headers.items(): print(f"{k}: {v}") print("=== response ===") print("status:", r.status_code) print("final url:", r.url) print("redirect chain:", [(h.status_code, h.headers.get("Location")) for h in r.history]) print("content-type:", r.headers.get("Content-Type")) print("declared encoding:", r.encoding) print("apparent encoding:", r.apparent_encoding) print("body bytes:", len(r.content)) return r inspect("https://example.com/")跑完之后你会看到 requests 默认发出的那几个头,以及目标站点返回的Server、Date、Content-Type、Content-Length等等。把这些信息和你在浏览器开发者工具里看到的一对比,差异立刻就有了。
5.2 三组对照实验定位问题
第一组,只改 UA。同一个 URL,先用默认 UA 请求一次,再换成浏览器的完整标识串请求一次,对比状态码和响应体长度。如果默认 UA 拿到 403 或者一个明显偏短的页面,换成浏览器 UA 后正常了,问题就锁定在客户端识别上,接着往下补Accept、Accept-Language、Sec-Fetch-*,一次只改一个变量。
第二组,关掉重定向。用allow_redirects=False再请求一次,看第一跳返回什么。如果第一跳是 302 且带Set-Cookie,说明登录态是在跳转过程中下发的,后续请求必须带上这个 Cookie。很多人拿不到数据就是因为只保存了最后一跳的响应,中间那跳的 Cookie 丢了。
第三组,验证条件请求。先正常请求一次,从响应头里取出ETag,然后带上If-None-Match再请求一次,看是不是返回 304。这个实验能帮你确认目标站点是否支持缓存协商,如果支持,增量抓取的成本能降一大截。
r1 = requests.get("https://example.com/", timeout=10) etag = r1.headers.get("ETag") if etag: r2 = requests.get("https://example.com/", headers={"If-None-Match": etag}, timeout=10) print(r2.status_code) # 期望 3045.3 一个能长期用的小封装
每次调试都手写一堆 print 太累,我习惯封一个小函数,把关键信息一次性输出,出错时直接看日志:
def fetch(url, referer=None, session=None, **kwargs): headers = { "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,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept-Encoding": "gzip, deflate", } if referer: headers["Referer"] = referer client = session or requests r = client.get(url, headers=headers, timeout=(5, 15), **kwargs) if r.status_code >= 400: print(f"[{r.status_code}] {url} -> history={[h.status_code for h in r.history]}") return rtimeout我写的是元组(5, 15),分别对应连接超时和读取超时。很多教程只写一个数字,那是两者共用同一个值,实际排查时区分不开——连接超时说明握手阶段就失败了,读取超时说明连上了但对方迟迟不返回,两者的处理方向完全不同。
6. 资源清单与检索方式
关于资源链接,我有个习惯:不贴具体 URL,只给标准编号和检索关键词。原因是技术文档的地址经常变,收藏夹里的链接过半年打开就是 404,而标准编号是永久有效的,你拿编号去搜,永远能找到最新版本。
核心的几份规范文档,按重要性排序:
| 文档编号 | 内容 | 检索关键词 |
|---|---|---|
| RFC 9110 | HTTP 语义,方法、状态码、头部的权威定义 | RFC 9110 HTTP Semantics |
| RFC 9111 | HTTP 缓存,Cache-Control、ETag、304 的完整规则 | RFC 9111 HTTP Caching |
| RFC 9112 | HTTP/1.1 的报文格式与连接管理 | RFC 9112 HTTP/1.1 |
| RFC 9113 | HTTP/2 的帧结构、多路复用、头部压缩 | RFC 9113 HTTP/2 |
| RFC 9114 | HTTP/3 与 QUIC 的结合方式 | RFC 9114 HTTP/3 |
| RFC 6265 | Cookie 的存储与发送规则 | RFC 6265 HTTP State Management |
| RFC 8446 | TLS 1.3 的握手流程 | RFC 8446 TLS 1.3 |
状态码的权威列表在官方的注册表里维护,检索"HTTP status code registry"就能找到,里面每个码都有对应的 RFC 章节引用,比二手资料准确。如果你只想查某个码的具体含义,直接搜"状态码 + RFC 编号"最快。
客户端库的文档里,requests 的 Advanced Usage 章节值得完整读一遍,尤其是 Session 对象、SSL 证书校验、重定向与历史记录这三节,本文提到的很多细节在那里面都有官方说明。另外,Python 标准库文档里http.client和urllib.request两章的头部处理部分,能帮你理解库到底替你做了什么。
最后分享一个我在实际项目里养成的小习惯:每接手一个新目标站点,先花十分钟做一次"基线采集"——用浏览器正常访问一次,把请求头完整导出来存成文件;再用脚本发一次,把两边的头部逐项做差集。差集里剩下的那几项,就是这个站点真正校验的东西。这十分钟能省掉后面好几个小时的瞎试,比任何技巧都管用。