# curl 返回 403，但浏览器正常工作：找出缺失的部分

浏览器加载成功，而 curl 返回 403。这两种请求之间的差距总是有限且可查的——以下是如何在五分钟内将其分解。

你将一个 URL 粘贴到 Chrome 中，页面加载成功。你将相同的 URL 粘贴到 curl 中，却得到 403 Forbidden。在这两秒之间，资源没有变化，因此差异完全在于请求——而请求是一个有限且可检查的东西。本指南是一个二分程序：精确重放浏览器发送的内容，然后逐步删除部分，直到 403 返回。你最后删除的部分就是答案。嫌疑项按通常的顺序排列：User-Agent、Referer、cookies、TLS 指纹和 JavaScript。

## 步骤 0：证明请求确实不同

在进行理论化之前，查看 curl 实际发送的内容。使用 `-v` 你可以看到请求行、每个头和 TLS 握手。默认的 curl 请求非常简陋——通常只有 `Host`、`User-Agent: curl/8.x` 和 `Accept: */*`。而浏览器会发送十多个。

```bash
# what you send, what you get back, and the TLS details
curl -v -o /dev/null https://target.example/page

# just the response headers, quickly
curl -sS -o /dev/null -D - https://target.example/page
```

像仔细阅读状态行一样仔细阅读响应头。在最常见的情况下，其中一个可以直接解决问题：`Vary: User-Agent` 意味着服务器故意根据你声称的身份提供不同的响应。在一个文档详尽的 Stack Overflow 案例中，`curl -f` 对一个普通的 Apache 2.4.38 主机返回 403，而 `wget` 则以 200 获取相同的文件——成功的响应正好带有 `Vary: User-Agent` 头。将 `-A 'Wget/1.21.2'` 传递给 curl 立即解决了问题。站点所有者在滥用后将 curl 的用户代理列入黑名单；请求的其他部分无关紧要。

在你阅读输出时：`curl: (22) The requested URL returned error: 403` 不是一个单独的问题。退出代码 22 是 `-f/--fail` 对任何 HTTP 错误的处理——该标志抑制正文并使命令失败。暂时删除 `-f` 以便你可以实际阅读阻止页面，通常会指出阻止你的系统。

## 步骤 1：复制为 cURL，30 秒答案

两个主要浏览器都可以提供它们刚刚发出的确切请求。打开 DevTools，进入网络选项卡，右键单击请求并选择“复制为 cURL”。Chrome 自版本 26 起提供此功能，Firefox 自版本 31 起提供，输出包括每个头、每个 cookie 和 referer。将其粘贴到终端中：如果返回 200，问题肯定出在请求形状上，步骤 2 找出哪一部分。

这里有一个陷阱会浪费很多时间。如果 URL 重定向，网络面板会在导航时清除，你会复制错误的请求。首先在 Chrome 中勾选“保留日志”或在 Firefox 中勾选“持久日志”，这样你就可以看到重定向的请求和最终提供内容的请求。重定向链很重要：在一个著名的 Unix Stack Exchange 线程中，服务器检查 `Referer`，然后通过 302 跳转到一个不检查任何内容的位置——这使得失败看起来是随机的，直到整个链可见。

![针对同一 URL 的默认 curl 请求与 Chrome 请求并列：curl 发送约三个请求头、不带 Cookie、使用 OpenSSL 握手，结果收到 403；Chrome 发送十几个请求头、一组 Cookie 和 Chrome 握手，收到 200；卡片逐栏列出了差距，从请求头到 JavaScript。](https://res.cloudinary.com/dmqliab0t/image/upload/f_auto,q_auto,w_1200/v1785335151/blog/generated/qp-b9-013-diag1.png)
*在这种情况下的每个 403 都隐藏在这个差距中。逐列关闭它。*

## 步骤 2：分解头

从工作中的“复制为 cURL”命令开始，一次删除一个头，每次删除后重新运行。第一个导致 403 返回的删除就是你的罪魁祸首。实际上，它几乎总是四个之一。

```bash
curl -sS -o /dev/null -w '%{http_code}\n' \
  -A 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' \
  -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' \
  -H 'Accept-Language: en-GB,en;q=0.9' \
  -e 'https://target.example/' \
  -b 'session=abc123; consent=1' \
  -L \
  'https://target.example/page'
```

- **User-Agent** (`-A`) — 直接被阻止，或者服务器根据它进行分支。测试当前的 Chrome 字符串，然后是 wget 的，再然后是一个无意义的；结果模式告诉你是否遇到了黑名单或白名单。
- **Referer** (`-e`) — 资产和下载链接通常返回 403，除非请求看起来像是来自站点自己的页面。根据规范，这个头是可选的，这正是人们忘记它的原因。
- **Cookies** (`-b`) — 在早期页面设置的同意、会话或反机器人 cookie。几秒钟内确认：在隐私窗口中打开 URL。如果浏览器在此处也返回 403，cookies 就是答案。
- **Authorization** (`-u`，或一个 bearer 头) — 签名或令牌化的 URL 在上下文外复制时经常返回 403，因为令牌绑定到会话或已过期。

此步骤的两个结束细节。引用 URL：否则，包含 `&` 或访问令牌的查询字符串会被 shell 搅乱，导致的 403 与服务器无关。如果你不是从 shell 而是从 PHP 或 Node 进行调试，请在那里复制相同的头集——PHP 内的 libcurl 默认值与命令行工具的不同，这就是为什么相同的请求可以在终端中通过而在代码中失败。我们的 [curl 代理示例](https://quantumproxies.io/blog/curl-with-proxy-examples)完整介绍了标志语法。

## 步骤 3：当相同的头仍然返回 403

如果浏览器头的逐字节副本仍然失败，那么决定是在解析头之前做出的。它们下面有两层。

**TLS 指纹。** 你的 ClientHello——密码套件、扩展、曲线偏好、ALPN 以及随后的 HTTP/2 设置帧——哈希为一个 JA3 或 JA4 值。基于 OpenSSL 构建的 curl 产生的结果没有任何浏览器会产生，而反机器人系统会根据你声称的 User-Agent 进行比较。声称是 Chrome 而握手像 OpenSSL 是一个他们专门捕捉的矛盾。解决方法是一个能重现浏览器握手的客户端：命令行中的 `curl-impersonate` 或 Python 中的 `curl_cffi`。

```python
# pip install curl_cffi
from curl_cffi import requests

proxy = "http://USER:PASS@residentialpremium.quantumproxies.io:8080"

r = requests.get(
    "https://target.example/page",
    impersonate="chrome",          # browser ClientHello + HTTP/2 settings
    proxies={"http": proxy, "https": proxy},
    timeout=20,
)
print(r.status_code, r.headers.get("content-type"))
```

**你的 IP。** 工作的浏览器通常在你的家庭连接上，而 curl 则在 VPS 上运行。托管 ASN 是公开的且预先评分的，因此来自住宅地址的相同请求在被读取之前就被不同地判断了。更换出口是一个使用 [住宅代理](https://quantumproxies.io/residential-proxies) 的单行更改——90M+ IP 覆盖 200+ 国家，HTTP 和 SOCKS5 在每个计划中——这是排除网络最快的方法。指纹层的机制在 [JA3/JA4 TLS 指纹识别](https://quantumproxies.io/blog/tls-ja3-fingerprinting-scraping)中。

[使用住宅代理排除网络](https://quantumproxies.io/residential-proxies)

![从 curl 403 到 200 的四个步骤：把浏览器请求复制为 cURL，逐个参数二分排查请求头，通过住宅出口模仿浏览器的 TLS 握手，遇到 JavaScript 挑战时再进行渲染或使用 Scraper API；卡片建议，状态码一变成 200 就停下。](https://res.cloudinary.com/dmqliab0t/image/upload/f_auto,q_auto,w_1200/v1785335152/blog/generated/qp-b9-013-diag2.png)
*四个步骤，每个都是一个测试。一旦状态变为 200 就停止——不要爬得比你需要的更高。*

## 步骤 4：页面需要浏览器，而不是客户端

有时 403 并不是对你的判断——它是一个你从未尝试过的挑战的失败模式。关于链接检查器访问 npmjs.com 的公共 GitHub 讨论明确指出：curl 无法产生有效的挑战解决方案，因此请求被 403 阻止。服务器发布一个小的 JavaScript 问题，等待片刻以获取答案，并拒绝任何无法执行的请求。没有头集、没有指纹和没有 IP 能通过需要运行代码的测试。

在那时你有三个诚实的选择：驱动一个真实的浏览器并支付成本，找到页面本身调用的 JSON 端点（通常就在你已经打开的网络选项卡中），或将 URL 交给一个按需渲染的服务。QuantumProxies 的 [Scraper API](https://quantumproxies.io/scraper-api) 就是最后一个选项——浏览器级 TLS，住宅出口，仅在页面需要时渲染 JavaScript，从一个请求中返回 markdown、JSON 或原始 HTML。如果你得到的是一个空页面而不是一个禁止页面，那是不同的诊断：请参阅 [为什么你的爬虫返回空页面](https://quantumproxies.io/blog/scraper-returns-empty-page-javascript)。如果阻止页面带有 Cloudflare Ray ID，请转到 [Cloudflare 错误 1020](https://quantumproxies.io/blog/cloudflare-error-1020-access-denied)。

## 常见问题解答

### 为什么 curl 得到 403 而我的浏览器没有？

因为 curl 发送的头大约有三个，没有 cookies，没有 referer，并且是非浏览器的 TLS 指纹，而你的浏览器发送十多个头，一个 cookie jar 和一个 Chrome 握手。服务器拒绝的是请求，而不是资源。用“复制为 cURL”重放浏览器的确切请求，然后逐个删除头以找出哪个差异重要。

### 为什么 wget 成功而 curl 得到 403？

几乎总是 User-Agent 的问题。一些服务器在滥用后专门将 curl 的 UA 列入黑名单，而 wget 的则不受影响——一个记录的案例显示了一个 `Vary: User-Agent` 响应头，确认服务器在此基础上进行分支，`curl -A 'Wget/1.21.2'` 恢复了 200。wget 还默认发送 `Accept-Encoding` 和 `Connection`，这有时也很重要。

### 如何在 curl 中设置 User-Agent？

使用 `-A 'string'`，或等效的 `-H 'User-Agent: string'`。优选完整、当前的浏览器字符串，而不是截断的 `Mozilla/5.0`，因为一些服务器现在拒绝仅发送两个标记的请求。将其与匹配的 `Accept` 和 `Accept-Language` 值配对，以保持整个集合的一致性。

### curl 错误 22 是什么意思？

退出代码 22 是由 `-f/--fail` 产生的，只要服务器返回 HTTP 错误，消息就会引用状态——通常是 403。这是一个报告标志，而不是一个独立的故障。删除 `-f` 以查看响应正文，这通常比退出代码更好地解释了阻止原因。

### 代理能修复 curl 403 吗？

它修复了由 IP 声誉或地理位置引起的子集——当你的脚本在云主机上运行而你的浏览器没有时，这是一个很大的子集。它不会修复缺失的 referer、缺失的 cookie 或 JavaScript 挑战。首先测试头，因为它们不花钱，然后更改出口 IP 以隔离网络层。

这里没有神秘，只有一个差距：浏览器发送了一个请求，而你发送了另一个。复制浏览器的请求，缩小它直到它出错，你总能找到重要的部分——通常是一个头，有时是一个指纹，偶尔是一个需要真实浏览器回答的挑战。

[使用 Scraper API 抓取任何页面](https://quantumproxies.io/scraper-api)
