集成阅读约 20 分钟

curl --compressed:哪些客户端默认不请求压缩

curl、Wget 1.x、urllib、node:http、PHP curl、Guzzle、Java HttpClient 默认不发送 Accept-Encoding 或只发 identity。附实测修复与字节检查。

本页内容

除非传入 --compressed,否则 curl 不会发送 Accept-Encoding 请求头。GNU Wget 1.x 和 Python 的 urllib.request 发送 Accept-Encoding: identity,而 Node 的 node:http、PHP 的 ext-curl、Guzzle 和 Java 的 java.net.http.HttpClient 根本不发送这个请求头。这时,只在客户端请求时才压缩的服务器会返回完整的未压缩文本,而其中的每一个字节都要经过你的代理。Requests、httpx、aiohttp、Scrapy、Node 内置的 fetch、axios、got、Go 和浏览器已经会请求压缩:对它们来说没有什么需要打开的,最多是装一个可选的 br 解码器,在某些页面上再省下一点。

下面的每个默认行为都由 ipvolt 于 2026 年 9 月 27 日运行的本地实验记录,所用版本列在每张表格下方。结果是合成的:一个服务器和一个代理在 127.0.0.1 上提供一个本地测试页面,因此这里没有任何字节数来自公开站点或服务商账单。

哪些客户端默认请求压缩

不发送 Accept-Encoding,或只发送 identity

对于只在客户端请求时才压缩的服务器,这些客户端收到的是未压缩的文本。不发送该请求头:curl、node:http、node:https、PHP ext-curl、Guzzle、Laravel Http、Java HttpClient。identity:Wget、urllib、http.client。

客户端修复
curl添加 --compressed
Wget添加 --compression=auto
urllib、http.client改用 Requests 或 httpx
node:http、node:https改用 fetch、got 或 axios
PHP ext-curl把 CURLOPT_ACCEPT_ENCODING 设为 ''
Guzzle、Laravel Http把 decode_content 设为 'gzip'
Java HttpClient发送 Accept-Encoding: gzip,用 GZIPInputStream 解压

实测版本:curl 8.7.1、8.22.0;Wget 1.25.0;urllib.request、http.client(Python 3.13.15、3.14.7);node:http、node:https(Node.js 22.23.3、24.21.0、26.10.0);PHP 8.5.8 ext-curl(libcurl 8.20.0);Guzzle 8.2.0、Laravel Http 13.33.0;Java 25.0.4.1 HttpClient。

  • curl:手动设置的 -H 'Accept-Encoding: gzip' 会被发送,但响应体仍保持压缩状态。
  • Wget:使用 --compression=auto 时,它发送了 gzip 并解码了响应。
  • urllib.request 和 http.client 按到达时的原样返回压缩的响应体。
  • node:http 和 node:https 按到达时的原样返回压缩的响应体。
  • PHP ext-curl:把 CURLOPT_ACCEPT_ENCODING 设为 '' 时,这个 libcurl 发送了 deflate, gzip, br, zstd。手动设置的请求头会被发送,但不会被解码。
  • Laravel Http:使用 withOptions(['decode_content' => 'gzip'])。
  • Java HttpClient 从不解码:响应是 gzip 时,用 GZIPInputStream 包装响应体。

已经请求压缩

无需改动:这些客户端会请求压缩并解码响应。

客户端发送的请求头
RequestsPython 3.13.15:gzip, deflate;Python 3.14.7:gzip, deflate, zstd
httpxgzip, deflate
aiohttpPython 3.13.15:gzip, deflate;Python 3.14.7:gzip, deflate, zstd
Scrapygzip, deflate, br, zstd
Node.js 内置 fetchNode.js 22.23.3/24.21.0:br, gzip, deflate;Node.js 26.10.0:br, gzip, deflate, zstd
Go net/httpgzip

实测版本:Requests 2.34.2;httpx 0.28.1;aiohttp 3.14.3;Scrapy 2.19.0;内置 fetch(Node.js 22.23.3、24.21.0、26.10.0);Go 1.27.1 net/http。

  • Requests:安装 brotli 会加入 br;在 Python 3.13 上,安装 backports.zstd 会加入 zstd。
  • httpx:安装 brotli 会加入 br,安装 zstandard 会加入 zstd。
  • aiohttp:安装 Brotli 会加入 br;在 Python 3.13 上,安装 backports.zstd 会加入 zstd。
  • 内置 fetch 请求 http:// URL 时:gzip, deflate。
  • Go:自己设置该请求头会关闭自动解码。

如果你的客户端已经请求压缩,用下文的字节检查确认一下;在手动添加任何请求头之前,先读一读关于复制浏览器请求头的那一节。无论用哪个客户端,对图片、视频和其他本身已经压缩过的内容,压缩都毫无用处。

每一行都来自实验运行,而不是阅读源码:共记录 567 个用例,没有一个标记为「源码阅读」。tables.md 列出了每一行背后的记录。这里的「Wget」指 GNU Wget 1.x。GNU Wget2 默认请求它编译时支持的所有编码(wget.c),而 Fedora 40 及更高版本把 wget2 安装为 wget 命令(Fedora 变更说明);这一点来自源码阅读,实验没有运行 Wget2。

curl:不传 --compressed 就不发送请求头

普通的 curl URL 完全不发送 Accept-Encoding。在 libcurl 中,CURLOPT_ACCEPT_ENCODING 默认为 NULL,这会「让 libcurl 不发送 Accept-Encoding: 请求头,也不自动解压收到的内容」,而 curl 命令行工具只在使用 --compressed 时才会改变它。实验中的两个 curl 构建都没有发送这个请求头,无论是直连、经 http:// 代理还是经 CONNECT。curl#11091 用 nc 抓取 curl 7.88.1 的请求,展示了同样的情况。

--compressed 会请求你的构建能解码的每一种编码,然后解码响应:

  • macOS 系统自带的 curl 8.7.1 只编译了 zlib,发送的是 deflate, gzip。
  • Homebrew 的 curl 8.22.0 编译了 brotli 和 zstd,发送的是 deflate, gzip, br, zstd。

要知道你的 curl 会请求什么,看 curl -V 输出中的 Features: 一行:libz 表示 gzip 和 deflate,brotli 加上 br,zstd 加上 zstd。

有三个细节决定它是否有用:

  • 它「是请求,而不是命令;服务器可能压缩数据,也可能不压缩」(man 页面)。服务器忽略它时,curl 不报告任何错误,也没有让这种情况失败的开关(curl#7516),所以要按下文所示检查字节数。
  • 手动设置的 -H 'Accept-Encoding: gzip' 会被发送,但 curl 不会解码响应。在实验中,curl 输出的是 17,750 字节的 gzip 流,而不是 100,129 字节的页面。请用 --compressed,而不是手动加请求头。
  • --no-compressed 可以再把它关掉,例如 .curlrc 里已经有一行 --compressed 时。

当实验服务器发送 macOS 构建无法解码的 br 或 zstd 时,curl --compressed 以退出码 61 停止:

code
curl: (61) Unrecognized content encoding type. libcurl understands deflate, gzip content encodings.

解压本身也有代价。man 页面警告说「即使极小的传输也可能被展开,产生巨量的字节」,并建议使用 --max-filesize。这个选项只有「自 8.20.0 起」才会中止因 --compressed 解压而膨胀的传输(man 页面),所以它保护不了 macOS 系统自带的 curl 8.7.1。这个限制有文档记载;实验没有测试它。

不加 --compressed 时,如果服务器照样压缩,你拿到的就是原始 gzip 字节,就像 curl#2836 中那样;这就是常见的「二进制输出」情况,--compressed 可以解决。代理参数本身见在 curl 中使用代理。

Python:urllib 发送 identity;Requests、httpx、aiohttp 和 Scrapy 会压缩

  • urllib.request 和 http.client 在 CPython 3.13.15 和 3.14.7 上发送 Accept-Encoding: identity。除非你自己设置,否则 http.client 会加上它,并附有注释「我们不支持 x-gzip 或 x-deflate 之类的编码」(client.py),而且这两个模块都不解码响应。在实验中,urllib.request 按到达时的原样返回了 gzip、br 和 zstd 响应;urllib.request 通过 http.client 发送请求,而 http.client 只以默认请求头运行过,所以关于 http.client 的这一点依据的是源码。自己设置请求头只会让你拿到需要手动解码的压缩字节,所以不如换一个客户端。必须只用标准库的脚本可以发送 Accept-Encoding: gzip,并在响应带有 Content-Encoding: gzip 时用 gzip.decompress() 解码响应体;实验没有运行这条路线。
  • Requests 2.34.2(搭配 urllib3 2.8.0)的默认值取自 urllib3(utils.py):在 Python 3.13.15 上是 gzip, deflate,在 3.14.7 上是 gzip, deflate, zstd,此时 urllib3 使用标准库的 compression.zstd。安装 brotli 会加入 br;在 3.13 上,安装 backports.zstd 会加入 zstd。自 urllib3 2.6.0 起,单独的 zstandard 包不再能启用 zstd(request.py;源码阅读,未运行)。代理方面见配置 Python Requests。
  • 单独使用 urllib3 时,无论通过 urllib3.request() 还是 PoolManager,都不会添加压缩请求头。它的请求路径直接交给 http.client(connection.py),按源码来看就是 identity。这是源码阅读的结论;实验运行的是 Requests,而不是单独的 urllib3。
  • aiohttp 3.14.3 在 3.13.15 上发送 gzip, deflate,在 3.14.7 上发送 gzip, deflate, zstd;安装 Brotli 会加入 br,在 3.13 上安装 backports.zstd 会加入 zstd。它是这里唯一一个遇到无法解码的编码时会抛出异常的 Python 客户端;确切的消息见下文。
  • Scrapy 2.19.0 在两个 Python 版本上都发送 gzip, deflate, br, zstd。自 Scrapy 2.18.0 起,brotli 和 Zstandard 支持成为必需,「因此请求的 Accept-Encoding 请求头中总是包含 br 和 zstd」(发布说明)。

Python 3.14 上的 httpx 与 zstd

httpx 0.28.1 在两个 Python 版本上都发送 gzip, deflate。装了 brotli 它会加入 br,而 zstd 只有装了第三方 zstandard 包才会加入;与 Requests 和 aiohttp 不同,它不使用 Python 3.14 标准库的 zstd(_decoders.py)。HTTPX 文档用 pip install "httpx[brotli,zstd]" 安装这两个解码器。压缩本身并不需要这些,因为 gzip 总是会被请求。

当服务器发送 httpx 无法解码的编码时,这一点就很重要:httpx 会返回压缩字节,并且不抛出任何异常。在没有 zstandard 的 Python 3.14.7 上,一个 zstd 响应以 19,298 字节的 zstd 流原样到达调用方。代理配置见 HTTPX 异步代理指南。

Node.js:内置 fetch 会压缩,node:http 不会

内置 fetch(undici)按 URL scheme 和版本线选择请求头(源码,v26.10.0;v24.21.0):

  • https:// URL:在 Node.js 22.23.3 和 24.21.0 上是 br, gzip, deflate,在 26.10.0 上是 br, gzip, deflate, zstd。
  • http:// URL:三个版本都是 gzip, deflate。
  • 任何带 Range 请求头的请求:三个版本都是 identity。

三个版本都能解码 gzip 和 br。区别在于 zstd,包括服务器未被请求就发来的 zstd:

  • Node.js 22.23.3 的 fetch 没有 zstd 解码器,返回了压缩字节,没有报错(源码)。
  • Node.js 24.21.0 的 fetch 会解码它并未请求的 zstd。一个由四个串联 zstd 帧组成的响应体只返回了第一帧:100,129 字节中的 25,033 字节,没有报错。axios 1.20.0 和 got 16.0.0 在 Node.js 22.23.3 和 24.21.0 上也只返回了第一帧。
  • 在 Node.js 26.10.0 上,fetch、axios 和 got 都解码了全部四帧。

node:http 和 node:https 在三个版本上都不发送 Accept-Encoding,也从不解码;Node 的核心 HTTP 客户端不设置这个请求头(_http_client.js)。Node 的 zlib 文档展示了手动的做法:自己设置请求头,再把响应通过管道送进解压器。换用 fetch、got 或 axios 的代码更少。

内置 fetch 与 node-fetch 包

npm 包 node-fetch 是一个独立的客户端,有自己的默认值(node-fetch#1556)。在 Node.js 24.21.0 上,node-fetch 3.3.2 发送了 gzip, deflate, br,node-fetch 2.7.0 发送了 gzip,deflate,两者都解码了响应。axios 1.20.0 发送 gzip, compress, deflate, br,只有设置 transitional: { advertiseZstdAcceptEncoding: true } 时才加入 zstd(http.js)。got 16.0.0 在三个版本上都发送了 gzip, deflate, br, zstd(index.ts)。它们都不需要改动。内置 fetch 的代理 dispatcher 见在 Node.js fetch 中使用代理。

Go、Wget、PHP 和 Java

Go net/http 只请求 gzip

Go 1.27.1 发送 Accept-Encoding: gzip,解码响应,并从交给你的响应中移除 Content-Encoding 和 Content-Length(transport.go)。对于 HEAD 请求、带 Range 请求头的请求,或设置了 DisableCompression: true 时,它不发送这个请求头。由此有两个后果:

  • 自己设置 Accept-Encoding 会关闭解码:「如果用户显式请求了 gzip,它就不会被自动解压」。使用 req.Header.Set("Accept-Encoding", "gzip") 时,实验中的 Go 客户端收到的是 17,750 字节的 gzip 流。
  • net/http 没有 br 或 zstd 解码器。服务器未被请求就发来这两种编码时,客户端拿到的是原始字节。

GNU Wget 1.x 发送 identity

Wget 1.25.0 发送 Accept-Encoding: identity。关于 --compression,手册对「none」的说明是「这是默认值」(手册);同一页面的 .wgetrc 列表仍把 auto 称为默认值,但实验运行的结果与 none 一致。使用 --compression=auto 时,Wget 发送了 gzip 并解码 gzip,仅此而已:未被请求就发来的 br 或 zstd 以原始形式到达。如上所述,Wget2 的行为不同。

PHP:Guzzle、Laravel Http 和 ext-curl 不发送 Accept-Encoding

  • PHP ext-curl(PHP 8.5.8,搭配 libcurl 8.20.0)什么都不发送,因为 CURLOPT_ACCEPT_ENCODING「默认为 null」(PHP 手册)。CURLOPT_ACCEPT_ENCODING => '' 会请求所链接的 libcurl 支持的每一种编码(在这个构建中是 deflate, gzip, br, zstd),并进行解码。写在 CURLOPT_HTTPHEADER 中的 Accept-Encoding 行会被发送,但不会被解码。
  • Guzzle 8.2.0 是有意去掉这个请求头的。它的 cURL handler 打开所有解码器,然后加上一行空的 Accept-Encoding: 阻止 curl 发送该请求头,注释说这「会被解读为 'Accept-Encoding: *'」(CurlFactory.php)。从 6.5.0 到 8.2.0 检查过的每个标签里都是同样的代码(源码阅读)。给 decode_content 传一个字符串,例如 'decode_content' => 'gzip',Guzzle 就会把它作为请求头发送(Client.php);在 cURL handler 下,'gzip, deflate, br, zstd' 也可以。默认情况下,cURL handler 仍会解码服务器未被请求就发来的 gzip、br 或 zstd。Guzzle 的 StreamHandler 使用 PHP 流而不是 ext-curl,它解码了 gzip,但对 br 和 zstd 返回原始字节。
  • Laravel Http(illuminate/http 13.33.0)构建的是一个普通的 Guzzle 客户端,并继承了上述全部行为(PendingRequest.php)。withOptions(['decode_content' => 'gzip']) 和 withHeaders(['Accept-Encoding' => 'gzip']) 都发送了 gzip 并解码了响应。

Java HttpClient 从不解码

Temurin 25.0.4.1 上的 java.net.http.HttpClient 没有发送 Accept-Encoding,也没有解码服务器未被请求就发来的 gzip 响应。要获得压缩,就自己发送 Accept-Encoding: gzip,并在响应带有 Content-Encoding: gzip 时通过 java.util.zip.GZIPInputStream 读取响应体,实验中的 Java 客户端就是这样做的。没有运行其他 Java 客户端。

Accept-Encoding:identity、不发送请求头与乱码输出

RFC 9110 区分了请求的三种状态:

  • 不发送请求头(curl、node:http、ext-curl、Guzzle、Java):「用户代理可以接受任何内容编码」。
  • identity(urllib、Wget 1.x):「『不编码』的同义词」。
  • 空值:「用户代理不希望响应使用任何内容编码」。

所以标准允许服务器在请求不带该请求头时压缩响应,「只在被请求时才压缩」是服务器的惯例,不是 RFC 的规定。服务器和 CDN 的文档描述了这种惯例。Cloudflare 会根据请求中 accept-encoding 请求头的值、套餐以及任何匹配的压缩规则,选择 gzip、Brotli、Zstandard 或不压缩(Cloudflare,页面更新于 2026 年 4 月 17 日)。Apache 的 mod_deflate 发送 Vary: Accept-Encoding,以免压缩内容被「发送给无法理解它的客户端」(mod_deflate)。Guzzle 的注释依据的是 RFC 的解读;而对于遵循上述惯例的服务器,缺少该请求头就会得到未压缩的响应体。实验服务器也遵循同样的惯例。实际服务器在未被请求时压缩的频率没有测量。

确实有服务器这样做。nginx 的 gzip_static always 会「不检查客户端是否支持」就提供 gzip 压缩过的文件(nginx),curl#2836 也报告了一个这样做的真实主机。当实验服务器在未被请求时发送 gzip,不加 --compressed 的 curl、Wget、urllib、node:http、PHP ext-curl、Java,以及设置了 DisableCompression 的 Go,都把 17,750 字节的 gzip 流交给了调用方;Guzzle 和 Laravel Http 则解码了它。如果这些客户端中某一个的响应看起来像二进制乱码,先检查它的 Content-Encoding。

在你自己的目标站点上验证字节数

如果这里没有适用于你的客户端的字节计数片段,可通过同一代理对同一 URL 运行下面的 curl 对比,并将它的 Content-Encoding 与你的客户端收到的响应头进行比较。这测量的是 curl 的响应;如果编码或服务器响应不同,就不能把它当作你的客户端的字节测量。

下面的 curl、Requests、httpx 和 node-fetch-bytes.mjs 代码片段打印的是响应体传输字节:响应体穿过网络时的原样字节,未经任何解码。响应头、TLS 记录、CONNECT 交换和重试都不包括在内,所以这不是服务商计费的数量。在八组实验对照中(tables.md 列出了它们),同一个客户端经 CONNECT 分别在无压缩和有压缩的情况下获取测试页面,这些额外开销在代理到客户端这一段上每个响应增加了 2,343 到 3,520 字节。它们降低了压缩比,例如 curl 8.22.0 从响应体上的 5.19× 降到代理段上的 4.56×,但没有降低节省量:在每一组对照中,代理段减少的字节数都等于响应体的差值再加 87 字节。要计量经过代理的一整次运行并按你自己的费率换算,请使用 Scrapescope。

Go 片段打印解码后的大小和 resp.Uncompressed(它表明 Go 是否解码了响应),链接中的 node-fetch-verify.mjs 打印 fetch 发送的请求头;两者都不打印传输字节。每个片段在实验中都原样运行,只是把 https://example.com/ 换成了回环 URL,文中展示的输出就来自这些运行;Go 片段还与一个信任实验 CA 的实验文件一起编译,方法一节中有说明。把 PROXY_URL 设为你的代理。如果需要信任私有 CA,请使用客户端自己的设置,例如 --cacert、NODE_EXTRA_CA_CERTS 或 Go 的 tls.Config.RootCAs;永远不要关闭证书验证。

用 curl 验证

sh
# Wire body bytes and the Content-Encoding the server chose (curl 7.84.0+ for %header{}).
curl -sS -o /dev/null -x "$PROXY_URL" \
  -w '%{size_download} %header{content-encoding}\n' https://example.com/
curl -sS -o /dev/null -x "$PROXY_URL" --compressed \
  -w '%{size_download} %header{content-encoding}\n' https://example.com/

用 Homebrew curl 8.22.0 请求实验的测试页面时,第一行打印 100129 且没有编码,第二行打印 19298 zstd;macOS 系统自带的 curl 8.7.1 在第二行打印的是 17750 gzip。curl 的 size_download 是「传输的响应体/数据的大小,不包括头部」,而 %header{} 需要 curl 7.84.0 或更高版本(write-out)。这个页面的压缩比就是第一个数字除以第二个数字。

用 Requests 和 httpx 验证

python
import os

import requests

proxies = {"http": os.environ["PROXY_URL"], "https": os.environ["PROXY_URL"]}
with requests.get("https://example.com/", proxies=proxies, stream=True) as r:
    wire = r.raw.read(decode_content=False)  # body bytes as sent, still encoded
    print(r.headers.get("content-encoding"), len(wire))

Requests 在 Python 3.13.15 上打印 gzip 17750,在 3.14.7 上打印 zstd 19298。对于 httpx,r.num_bytes_downloaded 统计读取时的原始字节,len(r.content) 统计解码后的响应体:

python
import os

import httpx

with httpx.Client(proxy=os.environ["PROXY_URL"]) as client:
    r = client.get("https://example.com/")
    # wire body bytes (as received) vs decoded bytes your code sees
    print(r.headers.get("content-encoding"), r.num_bytes_downloaded, len(r.content))

它在 Python 3.14.7 上打印 gzip 17750 100129,装了 zstandard 后打印 zstd 19298 100129。urllib 从不解码,所以从它读到的内容长度就已经是响应体的传输字节数。

用 Node.js 验证

对于内置 fetch,Node 为该 URL 生成的 resource timing 条目给出了响应体的传输大小:

js
// Wire body bytes of a built-in fetch, from Node's resource timing entry for the URL.
// Run: NODE_USE_ENV_PROXY=1 HTTPS_PROXY="$PROXY_URL" node node-fetch-bytes.mjs
import { setImmediate } from 'node:timers/promises';

const url = new URL('https://example.com/');
const res = await fetch(url);
const body = await res.arrayBuffer(); // already decoded by fetch

// Node adds the entry after the body has been read, so give the event loop a turn.
let entry;
for (let turn = 0; !entry && turn < 100; turn++) {
  await setImmediate();
  entry = performance.getEntriesByType('resource').findLast((e) => e.name === url.href);
}
performance.clearResourceTimings(); // the buffer stops at 250 entries unless you clear it

// encodedBodySize: the body as it crossed the network, before decoding (no headers, TLS or CONNECT)
console.log('content-encoding', res.headers.get('content-encoding'),
  'wire body bytes', entry?.encodedBodySize, 'decoded bytes', body.byteLength);

经过实验的 CONNECT 代理,它在 Node.js 22.23.3 和 24.21.0 上打印 content-encoding br wire body bytes 18451 decoded bytes 100129,在 26.10.0 上打印 content-encoding zstd wire body bytes 19298 decoded bytes 100129,每一次都与实验服务器发送的内容一致。在这三个版本上另外 18 个实验用例中(直连 http://、直连 https:// 和经 CONNECT 代理,每种都分无压缩和有压缩),encodedBodySize 每次都等于服务器发送的响应体字节数;tables.md 列出了这些读数。读完响应体后,该条目从来不会立即出现,只有在一次事件循环轮转之后才出现,这就是片段要等待的原因。片段随后清除这些条目,因为 Node 的 resource timing 缓冲区默认只保存 250 条(observe.js)。不要改用 transferSize:它在每个用例中都是 encodedBodySize 加上固定的 300 字节,而经过代理时,客户端这一段实际比响应体多传输了 3,431 到 3,520 字节。

node-fetch-verify.mjs 打印 fetch 发送的请求头(在 Node.js 26.10.0 上是 sent accept-encoding: br, gzip, deflate, zstd)。对于从不解码的 node:https,node-http-verify.mjs 统计到达时的响应体,这就是响应体的传输字节。

用 Go 验证

go
// Did Go ask for gzip and decode it for you? resp.Uncompressed says so.
// Run: go run go-verify.go   (with PROXY_URL set in the environment)
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
)

func main() {
	proxy, err := url.Parse(os.Getenv("PROXY_URL"))
	if err != nil {
		panic(err)
	}
	t := http.DefaultTransport.(*http.Transport).Clone()
	t.Proxy = http.ProxyURL(proxy)
	resp, err := (&http.Client{Transport: t}).Get("https://example.com/")
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		panic(err)
	}
	// true: Go sent "Accept-Encoding: gzip" itself, received gzip and decoded it,
	// then removed Content-Encoding and set ContentLength to -1.
	fmt.Printf("uncompressed=%v content-encoding=%q content-length=%d decoded-bytes=%d\n",
		resp.Uncompressed, resp.Header.Get("Content-Encoding"), resp.ContentLength, len(body))
}

在 Go 1.27.1 上它打印 uncompressed=true content-encoding="" content-length=-1 decoded-bytes=100129。resp.Uncompressed 只表明 Go 解码了响应(response.go);响应中已不再带有它的传输大小。要统计传输字节,就自己设置请求头(这样响应体会保持压缩状态),测量它,再用 compress/gzip 解码。

经过代理:CONNECT 与绝对形式

对于 https:// URL,客户端打开一条 CONNECT 隧道,并在 TLS 内部发送请求。使用端到端 TLS 且没有 TLS 拦截时,代理无法读取或修改 Accept-Encoding,只能转发加密的响应。实验记录器没有解析 CONNECT 隧道内部的请求头。对于 http:// URL,大多数客户端以绝对形式把请求本身发给代理,例如 GET http://host/path,因此代理能读到每一个请求头:实验代理看到了 curl 8.22.0 带 --compressed 时的 deflate, gzip, br, zstd、Wget 的 identity 和 Go 的 gzip。这条路径上的中间设备也可能改写请求头或响应体。实验代理两者都没有做,也没有测试任何真实代理。

有两个客户端对 http:// URL 的处理不同:

  • Node.js 22.23.3 和 24.21.0 的内置 fetch 在设置 NODE_USE_ENV_PROXY=1 时,把 http:// URL 也通过 CONNECT 隧道发送。实验记录器没有解析内部请求头,但该 HTTP 请求仍是代理可以检查的明文:CONNECT 本身不加密流量。Node.js 26.10.0 发送的是绝对形式。
  • 除非设置了 request_fulluri,否则 Guzzle 的 StreamHandler 会向代理发送源形式(origin-form)的请求行,实验代理以 400 拒绝了它。

哪些流量计入账单,由各服务商自己规定。例如,Decodo 的文档把下载流量定义为「从目标网站收到的每一个字节,包括响应头和 cookie」,并说明对于 HTTPS,「通过代理隧道传输的字节也计入代理流量用量」,还给出一个例子:它自己的测试端点经 http:// 下载了 0.85 kB,而经 https:// 下载了 3.96 kB(Decodo 帮助文档,2026 年 9 月 26 日读取,页面修改于 2026 年 4 月 23 日)。Decodo 没有说明它统计的是压缩后的字节还是解码后的字节。对于在 CONNECT 内使用端到端 TLS 的 HTTPS,代理统计的是穿过隧道的加密字节,其中包括压缩的响应和 TLS 开销;这是推断,不是 Decodo 的说法。替你向目标站点发送请求的抓取 API 和解封服务会自行选择请求头、按自己的方式计量,所以请查阅你的服务商的文档。

复制浏览器的 Accept-Encoding

你可能想把浏览器的 Accept-Encoding: gzip, deflate, br, zstd 直接粘贴到爬虫里。这样做会向服务器请求你的客户端可能无法解码的编码,而且在好几个客户端中,手动设置请求头还会关闭解码。使用正是这个请求头时,实验服务器选择了 zstd:

  • curl(两个构建)、urllib、node:http、Go、PHP ext-curl、Java HttpClient 和 Guzzle 的 StreamHandler 返回的是压缩字节:响应一切正常,响应体却无法阅读。
  • 只要缺少对应的解码器,Requests 和 httpx 也一样。在 Python 3.13 上,未装额外包的 Requests 对 zstd 和 br 都返回原始字节。在 3.14 上它能解码 zstd,但服务器发送 br 时返回原始的 br 字节。没有 zstandard 的 httpx 在两个 Python 版本上都返回原始的 zstd 字节,且不抛出异常。
  • Node.js 22.23.3 的 fetch 返回原始的 zstd 字节;24.21.0 和 26.10.0 解码了它。
  • Guzzle 的 cURL handler 解码了 zstd 和 br。
  • aiohttp 只要缺少解码器就抛出异常:没有 Brotli 时遇到 br,以及在 Python 3.13 上没有 backports.zstd 时遇到 zstd。在 3.14 上它解码了 zstd。

解决办法是让客户端自己写这个请求头。如果你想要 br 或 zstd,就安装你的客户端所用的解码器包,例如 brotli、Python 3.13 上的 backports.zstd 或 httpx[brotli,zstd];客户端随后会自己加上相应的编码标记。

aiohttp 3.14.3:Can not decode content-encoding: brotli (br). Please install `Brotli`

当服务器发送 br 而缺少 Brotli 包时,aiohttp 3.14.3 会抛出下面的第一个错误,在 Python 3.13.15 和 3.14.7 上都一样。第二个是 zstd 的版本,在没有 backports.zstd 的 Python 3.13.15 上抛出(回环 URL 已缩写):

code
aiohttp.client_exceptions.ClientResponseError: 400, message='Can not decode content-encoding: brotli (br). Please install `Brotli`', url='…'
aiohttp.client_exceptions.ClientResponseError: 400, message='Can not decode content-encoding: zstandard (zstd). Please install `backports.zstd`', url='…'

400 是 aiohttp 为无法解码的响应自行设定的状态码;实验服务器实际应答的是 200(http_parser.py)。在实验中,安装 Brotli(第二个错误则安装 backports.zstd)解决了问题:aiohttp 随后解码了 br,也解码了 zstd,包括由四个 zstd 帧组成的响应体。更短的 Can not decode content-encoding: br 来自同一文件中的另一条路径:解码器已经安装,但响应体解压失败。

无法正确处理 zstd 声明的服务器

声明支持某种编码,本身就可能让请求出问题。当请求的 Accept-Encoding 包含 zstd 时(支持 zstd 的 curl 构建就会这样),OpenSearch 2.19.0 会挂起。这个问题已在 2.19.1 和 3.0.0 中修复(OpenSearch#17339)。如果某个主机只对支持 zstd 的客户端超时,发一个只请求 gzip 的请求,就能看出问题是否出在 zstd 协商上。

压缩帮不上忙的情况

  • 本身已经压缩过的内容,例如图片、视频和压缩包。「图片之类已经压缩过的媒体文件无法从 HTTP 压缩中获益」(Web Almanac 2021)。
  • 服务器或 CDN 不压缩的响应。 例如,Cloudflare 的文档说明它只压缩 200、403 和 404 响应,gzip 的最小大小为 48 字节,Brotli 和 Zstandard 为 50 字节(Cloudflare)。
  • Range 和 HEAD 请求。 Go 对这两种请求都不发送该请求头,而 Node.js fetch 只要设置了 Range 请求头就发送 identity。
  • 上传。 --compressed 及其等价选项作用于下载;对于请求体,「没有标准的压缩方式」(Everything curl)。
  • 浏览器自动化。 浏览器会自己协商压缩:MDN 给出的典型浏览器取值是 gzip, deflate, br, zstd(MDN),Chrome 自 123 版起默认解码 zstd(Chrome Platform Status),所以在 Playwright 或 Puppeteer 中,要削减的字节在浏览器下载的内容里,而不在这个请求头上。

对于反复抓取的页面,条件请求是另一个手段:304 Not Modified 响应完全不带响应体(RFC 9110;ETag 监控)。

能省多少

只有第一张表中的客户端有压缩可以打开。对它们来说:

节省的 $ ≈ 页面数 × 每页未压缩文本字节数 × (1 − 1/压缩比) ÷ 10⁹ × 你每 GB 支付的 $

压缩比取自你在自己页面上做的验证步骤,即无压缩时的字节数除以有压缩时的字节数;费率取自你自己的套餐。

只有按字节付费时,压缩才能降低账单。在按 Mbps 或线程数定价的固定套餐上,价格不变,不过同一通道能传输更多压缩后的页面。不限量住宅代理折合每 GB 多少钱比较了这两种计费方式。

一个虚构的例子,不是测量结果:100,000 个 100 KB(100,000 字节)的 HTML 页面,未压缩时共 10 GB。压缩比为 4 时,压缩可节省 7.5 GB;压缩比为 10 时,可节省 9.0 GB。按虚构的每 GB $3 到 $8 计算,抓取这些页面一次大约可省 $22.50 到 $72。

对于已经请求压缩的客户端,打开压缩省不了任何东西,因为它本来就开着。为 Requests、httpx 或 aiohttp 装一个可选的 br 解码器,可以在某些页面上再省一点:在实验的 23 个文档页面上,br 响应体合计 277,665 字节,gzip 为 325,873 字节,约少 15%;但在合成测试页面上,br 比 gzip 更大(18,451 字节对 17,750 字节)。

实验自身的压缩比不是预测。合成测试页面用 gzip 时的压缩比为 5.64×,用 zstd 时为 5.19×。在这个由 23 个 CPython 3.14.7 文档页面组成的语料中,未压缩的响应体是其 gzip 大小的 6.67×、zstd 大小的 6.61×、br 大小的 7.83×。这些数字只描述这些文件。

公式统计的是响应体字节;在实验中,响应头、TLS 和 CONNECT 交换几乎不随压缩而变化,验证一节已经说明了这一点。服务商如何把字节换算成账单(GB 还是 GiB、最低消费、失败的请求)见 Scrapescope。结果是估算,不是服务商账单。

方法、限制与下载

ipvolt 于 2026 年 9 月 27 日在 macOS 15.7.4(Apple M4 Pro,arm64)上把记录下来的 567 个用例的矩阵运行了一次:02:43 到 02:44 UTC 为准备阶段,随后 02:44 到 02:45 UTC 运行整个矩阵。127.0.0.1 上的一个 Python 服务器提供一个 100,129 字节的合成 HTML 页面,部分用例则使用 CPython 3.14.7 文档的 23 个页面,服务器前面是 127.0.0.1 上一个会计数的正向代理,同时处理绝对形式和 CONNECT 请求。没有该请求头时,服务器发送未压缩的页面;否则它从客户端列出的编码中挑选,优先 zstd,其次是 br、gzip 和 deflate。强制模式则不管请求如何,发送 gzip、br、zstd、四个 zstd 帧或不压缩的内容。每个 HTTPS 用例都通过客户端自己的设置信任一个一次性的本地 CA,21 个不带该 CA 的对照用例全部未通过证书验证。Go 片段与一个实验文件一起编译,该文件在片段克隆 http.DefaultTransport 之前把这个 CA 放入它的 RootCAs(其文本是 snippets.py 中的 LAB_TRUST_GO),因此信任不依赖于 SSL_CERT_FILE:macOS 上的 go1.27.1 会遵循这个变量,除非设置了 GODEBUG=x509sslcertoverrideplatform=0,而在声明 go 1.26 的模块中默认就是这样设置的。从已发布的归档出发、在空目录中重新获取所有下载的第二次运行,在每个比较字段上都与全部 567 条记录一致(compare.py --strict)。

版本:curl 8.7.1(macOS,LibreSSL)和 8.22.0(Homebrew,OpenSSL 3.6.4,brotli,zstd);GNU Wget 1.25.0;CPython 3.13.15 和 3.14.7,搭配 Requests 2.34.2(urllib3 2.8.0)、httpx 0.28.1、aiohttp 3.14.3 和 Scrapy 2.19.0;来自 nodejs.org 并已核对校验和的 Node.js 22.23.3、24.21.0 和 26.10.0,搭配 axios 1.20.0、got 16.0.0 以及 node-fetch 3.3.2 和 2.7.0;Go 1.27.1;PHP 8.5.8(静态构建,libcurl 8.20.0),搭配 Guzzle 8.2.0 和 illuminate/http 13.33.0;Temurin JDK 25.0.4.1。

实验没有覆盖 HTTP/2 或 HTTP/3(服务器只提供 HTTP/1.1)、Linux 或 Windows 构建、浏览器、Wget2、单独使用的 urllib3、curl_cffi、Apache HttpClient、OkHttp、Symfony HttpClient、.NET HttpClient、Rust reqwest、Ruby Net::HTTP 或 Faraday、其他 PHP 或 libcurl 构建,也没有覆盖 25 之后的 Java 版本。默认值会随版本变化。这些是实验运行时已安装或当时最新的版本:macOS 系统自带的 curl 8.7.1 比 curl 8.22.0 旧,PHP 的静态构建比 php.net 的 8.5.11 落后三个补丁版本,Java 25 是最新的 LTS 版本,而不是最新的功能版本。在依赖某一行之前,请重新运行实验。

所有文件都在 https://ipvolt.com/downloads/accept-encoding-defaults/ 之下:

要复现整次运行,解压归档,在 harness/ 中先运行 ./setup.sh 再运行 ./run.sh,然后运行 python3 ../compare.py ../results.json out/results.json。README 列出了准备阶段会下载什么,以及每组用例需要什么。不需要代理账户。

发布本指南的 ipvolt 是一个正在开发中的代理服务,上线时按 GB 计价。文中的测量使用的是本地测试代理;这些建议适用于任何服务商,不用服务商时也同样适用。

如果你想在 ipvolt 开放访问时收到通知,请加入早期访问名单。开放时只发一封邮件。别无其他。

参考来源与延伸阅读

本指南参考的技术资料。请以你所安装版本的文档以及代理服务商支持的配置为准。