数据抓取

使用住宅代理设置正确的Headers和User-Agent

干净的住宅IP能让你到达门口。而Headers决定了进门之后你看起来是否像一个浏览器,一致性比任何单一数值都更重要。

Chris Collins

Chris Collins

2026年8月26日 · 2 分钟阅读

支持对话中常见的一种模式:有人切换到住宅代理,封锁率有所改善,然而仍有一小部分顽固的目标网站继续拒绝他们。IP 是干净的,地理位置是对的,请求却仍然被质询。答案几乎总是在请求头里,因为地址能让你走到门口,而请求头决定了走进去的东西看起来是不是浏览器。

大多数请求头问题背后的错误,是把它当作一份需要正确设置的数值清单。它不是。它是一组必须彼此一致、并与你连接的其他一切保持一致的声明,一个由不匹配部件拼凑出来的请求,比一个完全没花心思的请求更可疑。

一致性胜过任何单一数值

从这里开始,因为它重新定义了后面的一切。服务器不会孤立地评估你的 User-Agent。它看到的是一整捆东西:UA 字符串、随附的 client hints、请求头集合及其顺序、Accept-Language 的值、底层的 TLS 握手,以及整个请求所来自的 IP。真实浏览器产生的是内部一致的组合,因为同一套软件生成了所有这些内容。

爬虫产生的组合往往意外地不一致。一个 Chrome 的 UA 却带着 Python HTTP 库的请求头集合,一个 Windows 的 UA 却对应属于 Linux 工具的 TLS 指纹,一个美国出口却发送 Accept-Language: de-DE,或者一个自称是浏览器的客户端却从不请求浏览器会请求的资源。这些单独看都不会说”这是机器人”,但矛盾会,而且矛盾比任何单一信号都更容易可靠地检测出来。这与反检测浏览器中设备与网络配对的逻辑是一样的:每一层都必须讲述同一个故事。

所以目标不是最令人信服的 UA 字符串,而是一个请求,其中 UA、请求头、TLS 指纹和出口地址描述的是同一个可信的访问者。

真实浏览器实际发送的内容

如果你只设置 User-Agent,那你已经不一致了,因为没有浏览器只发送一个 UA 而不发送别的东西。一个现代 Chrome 请求一个页面时,至少会携带如下这样一套请求头:

User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.9
Accept-Encoding: gzip, deflate, br, zstd
sec-ch-ua: "Chromium";v="141", "Not?A_Brand";v="24", "Google Chrome";v="141"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1

其中有三点值得理解,而不只是照抄。

Client hints 必须与 UA 匹配。 sec-ch-ua 的品牌列表、sec-ch-ua-platformsec-ch-ua-mobile 是对 UA 字符串已经声明的内容的结构化复述。如果你的 UA 说是 Windows 上的 Chrome,而你的 platform hint 说是 macOS,或者品牌列表里的版本与 UA 里的版本不一致,你就在两个相邻的请求头里自相矛盾了。声称是最新版 Chrome 却完全不发送 client hints,同样是一种不匹配,因为真实的 Chrome 会发送它们。

Sec-Fetch 请求头描述的是上下文。 它们告诉服务器这是什么类型的请求:一次顶层导航、一次子资源获取、一次同源 XHR。直接打开时页面加载是 Dest: documentMode: navigateSite: none,如果是跟随内部链接则是 same-origin。对 API 的 XHR 请求则是 Dest: emptyMode: cors。把这些搞错正是一个泄露破绽的地方,因为它们在浏览器中是自动生成的,而在脚本中很容易被遗忘。

Accept-Encoding 是一个你必须兑现的声明。 只有在你的客户端真的能够解压 brzstd 时才去声明支持它们。有些库会声明支持某些编码,但随后却无法处理,这会导致报错,或者产生一种与浏览器实际协商结果不同的回退方式。

让 Accept-Language 匹配你的出口国家

这一点是代理工作中特有的,也是最常见的自造的不匹配。如果你通过一个德国的住宅 IP 出口,却发送 Accept-Language: en-US,你描述的就是一个浏览器配置为美式英语、却坐在德国家庭网络连接上的访问者。这种情况在现实中确实会发生,但它足够罕见,因此会成为一个信号,而且更实际的是,它可能改变你收到的内容:许多网站会根据这个请求头来提供内容,因此一个按地理定位的采集任务可能会取回错误的语言,同时表面上看起来运行正常。

把语言与出口绑定在一起,最好放在你选择国家的同一处代码里,这样两者就不可能出现偏差:

import requests

MARKETS = {
    "us": "en-US,en;q=0.9",
    "de": "de-DE,de;q=0.9,en;q=0.8",
    "fr": "fr-FR,fr;q=0.9,en;q=0.8",
    "br": "pt-BR,pt;q=0.9,en;q=0.8",
}

UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
      "(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36")

def fetch(url, country):
    proxy = f"http://customer-USERNAME-country-{country}:PASSWORD@p.shifter.io:443"
    headers = {
        "User-Agent": UA,
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,"
                  "image/avif,image/webp,*/*;q=0.8",
        "Accept-Language": MARKETS[country],      # follows the exit, always
        "Accept-Encoding": "gzip, deflate, br",
        "sec-ch-ua": '"Chromium";v="141", "Not?A_Brand";v="24", '
                     '"Google Chrome";v="141"',
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-platform": '"Windows"',        # agrees with the UA
        "Sec-Fetch-Dest": "document",
        "Sec-Fetch-Mode": "navigate",
        "Sec-Fetch-Site": "none",
        "Upgrade-Insecure-Requests": "1",
    }
    return requests.get(url, headers=headers,
                        proxies={"http": proxy, "https": proxy}, timeout=20)

同样的规范也适用于你在驱动浏览器时的时区和语言环境,这些是可以被单独观测到的,同样应该与出口匹配,而当你在做城市级定向时,这一点会更为严格。

不要随机轮换 User-Agent

这是一条广为流传、但弊大于利的建议。每个请求随机挑选 UA 会产生一种任何真实群体都不会有的模式:同一个 IP,一分钟内先是 Windows 上的 Chrome,然后是 Mac 上的 Safari,接着又是 Linux 上的 Firefox。更糟的是,如果你保持一个粘性会话,让一个地址服务于一个多步骤流程,在流程中途更改 UA 就意味着同一个访问者在点击搜索和查看结果之间,表面上换了设备。

一致的做法是每个会话一个身份。挑选一个可信的 UA,在该会话的整个生命周期内保持不变,并让其他一切都与之保持一致。如果你想在你的整个代理池中体现多样性,应该在会话之间变化,而不是在请求之间变化,而且要以现实的比例变化,而不是在所有曾经存在过的浏览器中均匀分布。同时也要保持版本更新:一个声称是三年前浏览器版本的 UA 本身就是异常的,因为真实的安装会更新。

请求头只是其中一层

这里有必要坦诚说明其局限性。完美的请求头不会让一个 Python 客户端看起来像 Chrome,因为底层仍然存在差异。你的客户端产生的 TLS 握手和 HTTP/2 设置本身就构成了一种指纹,一个 Chrome 的 UA 配上一个说明是 Python 的指纹,正是文章开头讨论的那种矛盾。这种不匹配正是TLS 与 HTTP/2 指纹识别所讨论的主题,也是为什么无论请求头做得多好,一些防护严密的目标仍然是普通 HTTP 客户端触及不到的,这也是选择使用无头浏览器的理由之一。

请求头的顺序同样重要,原因也是一样。浏览器以稳定的顺序发出请求头;许多 HTTP 库要么按字母顺序发出,要么按插入顺序发出,这是即使每个值都正确、整个组合仍会泄露其来源的另一种方式。有些客户端允许你控制顺序,在这重要的地方,匹配真实浏览器的顺序是值得花的功夫。这些信号更广泛的清单参见会阻止数据提取的指纹,常见的自造错误参见触发检测的错误

一份简短的清单

发送一整套浏览器请求头,而不只是一个 User-Agent。让 client hints 在品牌、版本、平台和移动标志上与 UA 保持一致。设置 Sec-Fetch 请求头以描述实际的请求类型。把 Accept-Language 与出口国家绑定,并让两者保持在同一段代码路径中。只声明你能够解码的编码。每个会话保持一个身份,而不是逐请求轮换,并保持 UA 版本更新。然后检查你的 TLS 指纹是否与你所声称的浏览器一致,因为这一层是请求头无法弥补的。

如果目标在做完这一切之后仍然拒绝你,问题就出在别处了:节奏控制、IP 信誉,或者行为信号,这些属于更广泛的范畴,参见避免被封锁抓取防护严密的网站

结论

请求头是代理所开启的身份的后半部分。一个干净的住宅地址让连接不引人注目;一个连贯的请求头组合让请求不引人注目,而连贯性就是整场博弈的核心。每一项声明都必须与其他每一项声明保持一致:client hints 与 UA 一致,语言与出口国家一致,fetch 元数据与请求类型一致,编码与你的实际能力一致,整套组合与底层的 TLS 指纹一致。每个会话保持一个身份、稳定不变,胜过任何巧妙的轮换。

这一连接层正是住宅代理所提供的,真实的家庭级地址,具备国家和城市级定向能力,让你的请求头所声称的地理位置正是你实际所在的出口地理位置,按每 GB计费,因此把请求调优得更精简,花费也就更少。

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.75/GB。

立即开始