Python 爬虫 gzip 解压报错:分清 response.content、raw 与字符编码,附 7 项验证

接口响应头明明写着 Content-Encoding: gzip,为什么调用 gzip.decompress(response.content) 却报 Not a gzipped file?使用 Requests 时,先检查是否解压了两次:response.content 通常已经经过内容解码,响应头仍可能保留服务端发来的 gzip 声明。

本文用只监听本机回环地址的 HTTP 服务,把“服务端发来的 gzip 内容”和“客户端交付给业务的字节”放在一起比对。适合排查采集响应乱码、解压失败,以及抓包字节与客户端结果不同的问题。

一、把解压、字符解码和解析分开

对于本文的 gzip JSON 接口,处理顺序是:gzip 编码的正文 → 解压得到 JSON 字节 → 按 UTF-8 解码为文本 → 解析为对象。

Content-Encoding 描述内容编码;Content-Type 描述媒体类型,可能附带字符集参数。HTTP 的传输分帧又是另一层。本文拿到的 raw 是 HTTP 库提供的正文流,不是含 TLS、响应头和 chunked 分帧的完整网络报文。HTTP 语义依据见 RFC 9110

在 Requests 中,contentiter_content() 会处理 gzip 内容解码。想保留本例 gzip 正文字节,需要从一个尚未消费的新响应中读取 raw,并明确 decode_content=False。不要先读 content,再期待 raw 还能从头读取。API 行为可对照 Requests 官方说明

二、一个能够看见两层字节的实验

实际运行日期为 2026-09-11,环境是 Python 3.13.13、Requests 2.33.1。下方是完整运行文件,保存为 gzip_layers_demo.py 后执行 python3 gzip_layers_demo.py。需要已安装 Requests;服务端部分仅使用 Python 标准库。

实验有三个端点:正确 gzip、普通 JSON、故意把普通 JSON 标成 gzip。另对固定压缩字节做一次截断校验。所有请求仅访问本机,不涉及真实业务接口。

"""Local HTTP fixture: distinguish coded body, decoded bytes, text and JSON."""
import gzip
import json
import platform
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

import requests

PAYLOAD = {"id": "sku-001", "name": "测试商品", "price": "19.90"}
PLAIN = json.dumps(PAYLOAD, ensure_ascii=False).encode("utf-8")
PACKED = gzip.compress(PLAIN, mtime=0)


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/gzip":
            body, coding = PACKED, "gzip"
        elif self.path == "/plain":
            body, coding = PLAIN, None
        elif self.path == "/wrong-header":
            body, coding = PLAIN, "gzip"
        else:
            self.send_error(404)
            return
        self.send_response(200)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        if coding:
            self.send_header("Content-Encoding", coding)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):
        pass


def must_fail(label, exc_type, operation):
    try:
        operation()
    except exc_type:
        print("PASS", label)
    else:
        raise AssertionError(label)


def main():
    server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
    worker = threading.Thread(target=server.serve_forever, daemon=True)
    worker.start()
    base = "http://127.0.0.1:%d" % server.server_port
    print("Python", platform.python_version(), "Requests", requests.__version__)
    print("coded_bytes", len(PACKED), "decoded_bytes", len(PLAIN))
    try:
        with requests.Session() as session:
            session.trust_env = False  # Keep this local fixture independent of proxies.
            with session.get(base + "/gzip", timeout=3) as response:
                response.raise_for_status()
                assert response.headers["Content-Encoding"] == "gzip"
                assert response.content == PLAIN
                assert int(response.headers["Content-Length"]) == len(PACKED)
                assert response.json() == PAYLOAD
                print("PASS content is decoded although header still says gzip")
                must_fail("double decompression rejected", gzip.BadGzipFile,
                          lambda: gzip.decompress(response.content))

            with session.get(base + "/gzip", stream=True, timeout=3) as response:
                response.raise_for_status()
                coded = response.raw.read(decode_content=False)
                assert coded == PACKED
                assert json.loads(gzip.decompress(coded).decode("utf-8")) == PAYLOAD
                print("PASS raw coded body can be decompressed once")

            with session.get(base + "/gzip", stream=True, timeout=3) as response:
                response.raise_for_status()
                decoded = b"".join(response.iter_content(chunk_size=7))
                assert decoded == PLAIN
                print("PASS iter_content returns decoded chunks")

            with session.get(base + "/plain", timeout=3) as response:
                response.raise_for_status()
                assert "Content-Encoding" not in response.headers
                assert response.content == PLAIN
                assert response.json() == PAYLOAD
                print("PASS plain JSON needs no gzip step")

            def wrong_header():
                with session.get(base + "/wrong-header", timeout=3) as response:
                    response.raise_for_status()
                    _ = response.content

            must_fail("incorrect gzip header rejected",
                      requests.exceptions.ContentDecodingError, wrong_header)
            must_fail("truncated gzip rejected by explicit decompressor", EOFError,
                      lambda: gzip.decompress(PACKED[:-8]))
    finally:
        server.shutdown()
        server.server_close()
        worker.join()


if __name__ == "__main__":
    main()

本轮实际输出:

Python 3.13.13 Requests 2.33.1
coded_bytes 72 decoded_bytes 59
PASS content is decoded although header still says gzip
PASS double decompression rejected
PASS raw coded body can be decompressed once
PASS iter_content returns decoded chunks
PASS plain JSON needs no gzip step
PASS incorrect gzip header rejected
PASS truncated gzip rejected by explicit decompressor

这组固定样本压缩后是 72 字节,解压后是 59 字节。很短的正文会因 gzip 元数据而变大,这不是压缩功能失效。此处响应头中的 Content-Length 是服务端写出的 72,不应拿它直接要求 len(response.content) 也等于 72。

第二项验证故意重复解压,得到预期异常才算通过。它没有“修复”任何目标站点。最后一项使用 Python 的显式解压器检查截断,不代表已测试所有 HTTP 库对截断 gzip 的处理。

三、按症状定位,不要统一套一层 decompress

响应头是 gzip,content 却以左花括号开头。 先确认客户端是否已经解码。本文直接把 content 与已知 JSON 字节比较,因此能确认该层;真实系统可以记录长度、哈希和有限的非敏感前缀。

raw 以 1f 8b 开头,直接 UTF-8 解码失败。 这两个字节是 gzip 的常见识别线索。按接口与库的约定完成解压,再解码文本。只检查前缀不足以验证整个 gzip 内容有效。

正文已经可读,但中文乱码。 排查字符集及错误的重复 encode/decode。response.encoding 影响文本转换,不能修复坏掉的 gzip 数据。对约定 UTF-8 的 JSON 样本,可显式解码验证。

ContentDecodingError。 本实验用错误的响应头复现了一种原因。实际还可能有内容损坏或中间层改写;保留状态、响应头与脱敏诊断样本再判断,不能见到该异常就断言是反爬。

JSONDecodeError。 先检查状态码、媒体类型和正文。HTML 提示页、空正文及 JSON 语法错误都可能到达这里;它与 gzip 错误属于不同阶段。

四、采集链路应约定谁负责哪一步

若共享下载模块交付的是已解码字节,就在它的接口说明中写清楚;解析器只负责字符解码和字段提取。保存调试样本时,文件名和元数据也标明 coded_bodydecoded_body,避免下次重放拿错层。

对于大响应,应限制下载量、解压后的累计字节数和处理时间。本文的 gzip.decompress() 一次把固定小样本解到内存,不是生产级限额解压实现;不能直接用于任意大或不可信的压缩包。

本文只覆盖单层 gzip,不扩展声称 Brotli、zstd、复合 Content-Encoding 或流式中途失败已通过测试。Requests 的其他编码能力还取决于安装依赖,具体按运行环境核查。

参考:Requests 二进制响应Python gzipRFC 9110 内容编码

本文使用 AI 辅助起草、整理与校核;所附本地实验已于上述日期实际运行。结果仅对应列出的环境和固定样本。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值