用 Python 标准库去抓一个网站,read() 回来一看:满屏 \x1f\x8b\x08... 之类的字节,复制到编辑器里也是一堆乱码。第一反应是"对方是不是返回了异常内容",甚至怀疑自己请求错了地址。其实对方返回的完全正常,只是你少解了一道压。
现象
典型代码长这样:
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
resp = urllib.request.urlopen(req)
raw = resp.read()
print(raw[:64])
输出类似:
b'\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03\xed]\xdb\x8e\xdc\xb8\x11...'
开头那几个字节 \x1f\x8b 非常关键——这不是什么随机乱码,而是一个明确的信号。
根因
\x1f\x8b 是 gzip 格式的固定魔数(magic number)。也就是说,服务器返回的响应体是一段 gzip 压缩数据,而不是纯文本。
为什么会这样?因为现代网站为了省带宽,普遍会在响应头里带上 Content-Encoding: gzip,把正文压缩后再发。这是 HTTP 协议的正常行为,客户端本该自动解压。
而问题的根源在于:你用的标准库 HTTP 客户端不会自动解压。requests 这类高层封装库会替你处理 gzip / deflate / br,所以平时用 requests 抓网页从没遇到过这个问题;一旦换回 urllib 这种更底层的方式,自动解压就没了,拿到的自然是压缩后的原始字节。于是"没解压"就被误读成了"内容是乱的"。
顺带一提:很多网站只在请求头里带了 Accept-Encoding: gzip 时才压缩。requests 默认会带上这个头,而手写的 urllib 请求往往没带——但有些服务器无论如何都压缩,所以无论怎样都可能撞上。
要区分"真乱码"和"压缩数据"其实不难:真乱码是字符编码对不上,字节流里通常还能看出一些接近可读的片段;而压缩数据的特征非常明显——开头是固定的 \x1f\x8b,后面跟着一串高熵的随机字节,几乎看不到任何像文字的连续片段。下次再遇到"一坨看不懂的字节",先瞄一眼头两个字节是不是 \x1f\x8b,是的话问题就已经解了九成。
解决
读取响应后,先看响应头,判断压缩类型,再调用对应的解压函数:
import gzip
import zlib
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0",
"Accept-Encoding": "gzip, deflate"})
resp = urllib.request.urlopen(req)
raw = resp.read()
encoding = resp.headers.get("Content-Encoding", "").lower()
if encoding == "gzip":
raw = gzip.decompress(raw)
elif encoding == "deflate":
try:
raw = zlib.decompress(raw)
except zlib.error:
raw = zlib.decompress(raw, -zlib.MAX_WBITS) # 有些服务器发的是裸 deflate
text = raw.decode("utf-8", errors="replace")
print(text[:200])
几处细节值得说明:
- 按响应头选解压方式,不要只认 gzip——
deflate、br(Brotli)也常见,br需要额外装brotli库才能解。 - deflate 有两种封装:带 zlib 头的和不带的,
zlib.decompress对后者要传负数窗口位,所以上面加了兜底。 - 解码时给
errors="replace",避免个别非法字节直接把整个脚本打崩。
由于多处脚本都踩过这一点,更省事的做法是把它封装成一个统一的"取文本"函数,全项目复用:
def fetch_text(url, headers=None):
req = urllib.request.Request(url, headers=headers or {"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req) as resp:
raw = resp.read()
enc = resp.headers.get("Content-Encoding", "").lower()
if enc == "gzip":
raw = gzip.decompress(raw)
elif enc == "deflate":
raw = zlib.decompress(raw)
return raw.decode("utf-8", errors="replace")
延伸与预防
这条教训可以总结成一句话:先怀疑格式,再怀疑内容。 当抓回来的东西"看起来像乱码"时,先别急着找对方麻烦,按顺序排除三件事:
- 是不是压缩?看响应头的
Content-Encoding,看响应体开头有没有\x1f\x8b这类魔数。 - 是不是编码问题?确认
charset,中文站常见 GBK / GB18030 而不是 UTF-8。 - 是不是真错了?排除了前两条,再考虑对方返回了错误页、验证码页。
另外,能确定目标就是 HTTP 抓取时,直接用 requests 会省掉很多这类底层细节;如果出于依赖最小化的考虑必须用标准库,那就把"解压 + 解码"这一步固化进公共函数里,别在每个脚本里手写一遍——手写的地方越多,漏掉解压的概率就越大。