# 如何不用浏览器抓取酒店价格：逆向分析 Radisson 的请求流程

要抓取受机器人检测保护的网站，使用浏览器是最简单的办法。它也是最慢、成本最高的办法。在本文中，我们为 [Radisson Hotels](https://www.radissonhotels.com) 构建了另一种爬虫：它只发送能返回数据的 HTTP 请求，全程不运行浏览器就能通过 Akamai Bot Manager。

Radisson 是检验酒店价格抓取的一个好案例。价格不在 HTML 中，而是来自页面加载后调用的 JSON API，而且在放行这些调用之前，网站还会运行 Akamai 的三项检测。

下面介绍我们是如何做到的。我们录制了一次真实的浏览器会话，从发往 Radisson 的 148 个请求中找出了真正关键的四个 API 调用，弄清了网站运行哪些 Akamai 检测、分别在哪里运行，然后用 Go 重建了整个流程。之后，我们通过位于英国的 [Massive](https://joinmassive.com) 住宅代理运行了这个爬虫。

> **要点总结**
>
> - Radisson 的搜索结果来自四个 JSON 端点。其中城市查询和房价（rates）调用这两个端点，是 Akamai 拦截可疑会话的地方。
> - 网站在页面加载时会运行三项 Akamai 检测：SBSD、`_abck` 传感器（sensor）和像素（pixel）挑战。基于请求的爬虫必须按照页面的顺序，逐一应答这三项检测。
> - 在通过 Massive 英国住宅 IP 运行的 100 个会话中，每个会话都通过了 Akamai，计划的 500 次搜索中有 490 次返回了房价（98%）。
> - 每个会话通过 Akamai 需要消耗 520 KB 代理流量，其中四分之三来自两个 JavaScript 文件。此后每次搜索消耗 12.5 KB。
> - 通过 Akamai 的会话可以持续使用。五个会话各运行了 100 次搜索，一次都没有被拦截，算上所有开销，每次搜索 18 KB。

## 为什么选择基于请求的方式

基于请求的爬虫会跳过浏览器所做的、而爬虫并不需要的一切：渲染、图片、字体、地图、分析脚本以及页面自身的 JavaScript。剩下的只有少量 HTTP 请求，因此它速度更快，一台机器就能运行数百个会话，经过您代理的流量也少得多。

难点在于，您必须确切知道要发送哪些请求。机器人检测不只看您请求了什么。它还看您是怎么请求的：TLS 握手、HTTP/2 请求头的顺序、Cookie，以及真实浏览器在后台应答的 JavaScript 挑战。所以，真正的工作在于先逆向分析请求流程。

## 第 1 步：录制一次真实会话

我们首先在 Chrome 中对 Radisson 进行了一次搜索，并让流量经过 HTTP 调试代理 [powhttp](https://hypersolutions.co/blog/how-to-use-powhttp-web-scraping)。我们打开首页，输入“Amsterdam”，选择日期，然后搜索。

我们使用 powhttp 而不是浏览器的 DevTools，原因只有一个。DevTools 显示的是每个请求经过整理后的版本。powhttp 显示的则是实际在网络上传输的内容：真实的请求头顺序、HTTP/2 伪头部（pseudo-headers）、TLS 指纹，以及浏览器发送的每一个 Cookie 请求头。这些细节正是机器人检测要检查的内容，也是爬虫必须照搬的内容。

![powhttp 中录制的 Radisson 搜索请求列表，已筛选为以 radissonhotels.com 结尾的主机](https://cdn.sanity.io/images/6hf2slow/production/bdd569f92c8c3614f766f6f5d35e8a570f104f74-2316x1224.png "录制的搜索，已筛选为 Radisson 自有域名：页面、页面的脚本和图片、Akamai 的端点以及 API 调用。")

这一次搜索就向 46 个主机发出了 568 个请求。其中大部分是噪音：Google Maps、分析服务、同意横幅、图片。按 Radisson 自有域名筛选后，剩下 148 个请求：JavaScript 包、图片、Akamai 的脚本，以及 38 个 JSON API 调用。

## 第 2 步：找到返回酒店价格的请求

酒店价格不在 HTML 中。结果页是一个 JavaScript 应用，会在渲染完成后再加载价格，因此我们逐一检查了这 38 个 API 调用，看看每个调用返回了什么。其中四个调用包含了一次搜索所需的全部内容：

- `GET /zimba-api/autocomplete/amsterdam` 以 Google place ID 的形式返回您输入的地点。
- `GET /zimba-api/search-result/hotels/search?placeId=…` 返回该地点的 Radisson 酒店代码及距离。
- `POST /availability-api/multiple-hotels-rates` 返回每家酒店在所选日期和入住人数下的最低房价。
- `GET /zimba-api/search-result/hotels?code=…` 返回酒店的名称、地址、星级和评分。

房价调用才是关键。它以 JSON 请求体接收日期、入住人数和酒店代码列表，并返回每家酒店的价格、含税总价以及剩余房间数。

![powhttp 显示 multiple-hotels-rates 请求的请求体及其 JSON 响应](https://cdn.sanity.io/images/6hf2slow/production/44408488a04a170bf57d722821d932ab49236319-1200x1555.png "房价调用：传入日期、入住人数和酒店代码，返回每家酒店的价格。")

其余 34 个调用为页面加载标签文案、货币、汇率、电话号码和会员计划内容。爬虫一个都不需要。

## 第 3 步：精确复制请求

接下来，我们查看了这四个调用的请求头。其中大多数是任何 Chrome fetch 请求都会发送的。有几个是 Radisson 特有的：

- 每个调用都带有 `x-source-of-business: desktop`，房价调用还带有 `x-version: V2` 和 `x-currency`。
- `transactionid`，一个 40 个字符的值，在同一次访问的所有调用中保持不变。

`transactionid` 看起来像是服务器下发的令牌。其实不是。我们在 powhttp 中搜索网站 JavaScript 里的 `transactionId`，找到了生成它的函数：40 位随机的 15 进制数字，在首次加载页面时生成，并保存在浏览器的会话存储（session storage）中。因此，我们的爬虫以同样的方式为每个会话生成一个。

![powhttp 中 StoreTransactionId 的搜索结果，已在 home.js 中定位到生成 transactionid 的函数](https://cdn.sanity.io/images/6hf2slow/production/573e831ceba36a2415fef8927409e3124942d812-2881x1550.png "transactionid 是 40 位随机的 15 进制数字，在浏览器中生成并保存在会话存储中。")

然后我们复制了请求头顺序。Chrome 按固定顺序发送请求头，而这个顺序因请求类型而异：页面加载、脚本、`fetch()` 和表单提交各有各的顺序。我们从录制中逐一复制了每种顺序，包括 Cookie 请求头所在的位置。

连接本身我们使用了 Go 版的 [tls-client](https://github.com/bogdanfinn/tls-client)，它以 Chrome 的 TLS 和 HTTP/2 指纹建立连接。Go 标准 HTTP 客户端的 TLS 指纹是任何浏览器都没有的，而 TLS 指纹正是机器人检测最先查看的内容之一。

## 第 4 步：找出 Akamai 所在的位置

弄清数据请求之后，下一个问题是：Akamai 在放行这些请求之前需要什么。答案就在首页 HTML 以及页面加载后立即发出的请求中。

页面包含三个 Akamai 脚本：

- **传感器脚本**，位于一个看似随机的长路径下，例如 `/hLJOkaPJ/…/XMmkuCU0`。它收集浏览器的相关数据，并将其提交回同一路径，共三次。每次响应都会更新 `_abck` Cookie，一旦该 Cookie 包含 `~0~`，Akamai 就认为该会话有效。
- **SBSD 脚本**，位于类似的路径下，带有 `?v=` 参数。SBSD（State Based Scraping Detection）是 Akamai 的第二项检测。页面在运行过程中会向同一路径提交载荷，在我们的录制中共三次，每次响应都会更新 `bm_s` Cookie。我们的爬虫只提交两次，这就足够了。
- **像素脚本**，位于 `/akam/13/…`，页面中写有一个数字。页面会通过一次表单提交来应答它，提交到同一路径，并在前面加上 `pixel_`。

![Radisson 首页的 HTML：head 中有 Akamai 像素数字，body 末尾有传感器和 SBSD 的 script 标签](https://cdn.sanity.io/images/6hf2slow/production/7b219dad0603443a4d9d6a656dd55942b76e1a2d-2015x536.png "首页从看似随机的路径加载 Akamai 的传感器脚本和 SBSD 脚本，并将像素数字写入 head。")

![powhttp 显示一次状态码为 201 的传感器 POST、成功响应及其设置的 _abck Cookie](https://cdn.sanity.io/images/6hf2slow/production/bc4d033eff1c203a0e75bef2d5808e1b304abcfd-2015x783.png "一次传感器提交：Akamai 返回 201，并设置包含 ~0~ 的 _abck Cookie。")

这里有两个细节很重要。脚本路径会变化，因此爬虫必须从每次加载的首页中读取路径，而不能硬编码。在我们的测试期间，SBSD 路径就变了好几次。另外，在某些 IP 上，首页根本不是首页。它是一个简短的 SBSD 挑战页面，其中的脚本带有 `t=` 参数。爬虫必须先应答这个挑战并重新加载页面，然后才能进行其他操作。

这些检测都无法靠复制录制下来的载荷通过。每个载荷都是根据当前会话的脚本、Cookie、User-Agent 和 IP 生成的。我们使用 [Hyper Solutions API](https://hypersolutions.co/products/akamai) 生成这些载荷：爬虫通过其代理下载脚本，将脚本连同会话的 Cookie 和 IP 发送给 API，然后提交 API 返回的载荷。为此，我们使用了自己的 [Go SDK](https://github.com/Hyper-Solutions/hyper-sdk-go)。

每项检测各自如何运作，以及 Go、Python 和 JavaScript 版本的 SDK 代码，详见我们的[不用无头浏览器应对 Akamai 指南](https://hypersolutions.co/blog/bypass-akamai-without-headless-browser)。

## 第 5 步：对照浏览器检查您的爬虫

在扩大规模之前，我们也让爬虫的流量经过 powhttp，这样它的请求就会和浏览器的请求出现在同一个工具里。这让对比变得非常直接：

- TLS 指纹与 Chrome 一致。浏览器和爬虫的 JA4 哈希只在 Chrome 有意插入的随机 GREASE 值上有所不同。
- 每种请求类型的请求头顺序都一致。
- Cookie 请求头被拆分为每个 Cookie 一个请求头，与 Chrome 在 HTTP/2 上的做法相同。

![powhttp 中并排显示的浏览器与爬虫的房价请求，请求头顺序相同](https://cdn.sanity.io/images/6hf2slow/production/25f8a22d0a860b08e005651a8ad47dacc2b1aec6-2881x1240.png "浏览器的请求（左）与爬虫的请求（右）：请求头相同、顺序相同，只有日期和货币等值不同。")

如果哪里不对，您会在这一步发现它；在这里发现问题，要比日后从拦截率中发现便宜得多。

## 完整流程

综合起来，一个抓取会话会执行以下步骤：

1. 查询自己的出口 IP，因为 Akamai 载荷是针对该 IP 生成的。
2. 加载首页。如果返回的是 SBSD 挑战页面，则应答挑战并重新加载。
3. 下载 SBSD 脚本并提交两个 SBSD 载荷。
4. 下载传感器脚本并提交传感器数据，直到 `_abck` 包含 `~0~`，并在第一次提交后应答像素挑战。
5. 搜索：先查询一次城市，然后在每次搜索时获取酒店列表并提交房价请求。

此后，会话可以持续搜索。如果 Akamai 对受保护的调用返回挑战，会话会应答挑战并重试一次。

代码已开源：[github.com/Hyper-Solutions/radisson-scraper](https://github.com/Hyper-Solutions/radisson-scraper)。它包含一个 Go 包和一个小型命令行工具，可以以 JSON 格式输出某个城市的酒店和房价：

<div data-block="html"><div style="background:#011627;border:1px solid rgba(250,244,236,0.08);border-radius:10px;margin:1.5rem 0;"><div style="padding:10px 18px 0;font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:0.08em;color:rgba(214,222,235,0.6);">BASH</div><pre style="margin:0;padding:10px 18px 16px;background:transparent;color:#d6deeb;font-family:'JetBrains Mono',monospace;font-size:13px;line-height:1.65;white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:visible;"><code>git clone https://github.com/Hyper-Solutions/radisson-scraper.git &amp;&amp; cd radisson-scraper
export HYPER_API_KEY=your-key
export PROXY_URL='http://your-username-country-GB-session-{session}-sessionttl-30:your-password@network.joinmassive.com:65534'
go run ./cmd/radisson -city london -checkin 2026-11-20 -checkout 2026-11-22 -currency GBP</code></pre></div></div>

## 配置 Massive 代理

每个会话都需要自己的住宅 IP，并且必须在会话结束前一直保持这个 IP。Akamai 的载荷是针对单个 IP 生成的，因此如果 IP 在会话中途发生变化，该会话就会失效。

Massive 控制台中有一个 Proxy Generator（代理生成器），可以为您生成连接字符串。我们选择了基于 HTTP 的住宅代理（Residential Proxies），将轮换类型设为 Sticky（粘性），并将 Session TTL 设为 30 分钟。

![Massive Proxy Generator 设置为基于 HTTP 的住宅代理，采用 Sticky 轮换，Session TTL 为 30 分钟](https://cdn.sanity.io/images/6hf2slow/production/50c42e181ca54839cf29788fc38d7390fecdb58d-1403x1167.png "Sticky 轮换为每个会话 ID 保持同一个 IP，此处为 30 分钟。")

然后，我们在 Geotargeting（地理定位）下选择了 United Kingdom（英国）。生成器会把所有这些设置都写进代理用户名中：

![已选择 United Kingdom 的 Massive Proxy Generator，以及生成的 cURL 命令，其中显示了 Sticky 的 GB 用户名](https://cdn.sanity.io/images/6hf2slow/production/b762ea2745768cb53b30ef0c29359fe77b35f9b8-921x1200.png "选择 United Kingdom 后，用户名变为 your-username-country-GB-session-ID-sessionttl-30。")

- `country-GB` 选择英国住宅 IP。
- `session-<id>` 让会话保持粘性。使用相同 ID 的每个请求都从同一个 IP 发出，新的 ID 则会获得新的 IP。生成器会填入一个随机 ID；我们的爬虫为每个会话填入一个新的 ID。
- `sessionttl-30` 让该会话保持这个 IP 30 分钟，比我们任何一个会话的运行时间都长。

## 测试结果

我们通过 Massive 英国住宅 IP 运行了 100 个会话，每次并发 10 个。每个会话先通过 Akamai，然后运行五次搜索，每次搜索从 16 个城市的列表中随机选取一个城市，并随机选择 2 到 12 周之后的日期。我们本机上的一个计数代理记录了爬虫与 Massive 之间的每一个字节。测试于 2026 年 10 月 22 日进行。

- **通过 Akamai 的会话**：100 个中有 100 个。
- **完成全部五次搜索的会话**：100 个中有 98 个。
- **返回房价的搜索**：计划的 500 次中有 490 次（98%）。
- **通过 Akamai 的中位耗时**：2.6 秒。
- **每次搜索的中位耗时**：1.5 秒。

失败的两个会话都通过了 Akamai，但随后在第一次房价调用时被拒绝。两者都被永久封禁：在同一 IP 上再次通过 Akamai 也无济于事。这说明 Akamai 不信任该 IP，解决办法是换一个新 IP 开启新会话。

### 代理流量成本

![条形图：基于请求的爬虫各步骤的代理流量，包括 Akamai 引导阶段、城市查询、单次搜索和 IP 查询](https://cdn.sanity.io/images/6hf2slow/production/ab3e852b1577a97a6812ec9bd3221de0224e8c21-2400x1164.png "通过 Massive 英国住宅 IP 时各步骤的平均代理流量。")

通过 Akamai 这一步（我们称之为引导阶段，即 bootstrap）是会话中开销最大的部分：520 KB。一次搜索消耗 12.5 KB：酒店列表 5.2 KB，房价 7.2 KB。查询城市消耗 6.4 KB，每个城市只需一次。

![条形图：引导阶段各步骤的代理流量，其中 SBSD 脚本和传感器脚本最大](https://cdn.sanity.io/images/6hf2slow/production/2e57c4b745fb13ef563d19d5771a422d4fe39187-2400x1764.png "引导阶段各步骤的平均代理流量。")

引导阶段的流量几乎全部来自两次下载：SBSD 脚本（189 KB）和传感器脚本（195 KB）。两者合计占 74%。首页为 64 KB，所有提交请求加起来不到 60 KB。

按每个会话五次搜索计算，算上所有开销，每次搜索的代理流量为 122 KB。会话存活越久，引导阶段的占比就越小。在第二次测试中，五个会话各自在一次引导之后运行了 100 次搜索。全部 500 次搜索都返回了房价，Akamai 一次也没有拦截，算上所有开销，每次搜索的流量为 18 KB。

## 实用建议

- **用能看到实际线上流量的工具录制**。请求头顺序、HTTP/2 伪头部和 TLS 指纹在 DevTools 中都看不到。而它们正是机器人检测要检查的内容。
- **找 JSON，而不是 HTML**。如果页面是一个 JavaScript 应用，它的数据就来自某个 API。直接调用该 API 比解析页面更便宜，也更稳定。
- **先读网站的 JavaScript，再做猜测**。像 `transactionid` 这样的值看起来像服务器令牌，实际上却是随机生成的。在 JavaScript 包中搜索一下，几分钟就能找到答案。
- **每次都解析 Akamai 的脚本路径**。它们会变化。
- **在与浏览器相同的工具中验证您的爬虫**。并排对比可以在错误的请求头顺序或指纹体现为拦截率之前就发现问题。
- **每个会话保持一个 IP**。使用粘性会话，检查出口 IP，一旦某个会话被封禁，就用新 IP 开启新会话。
- **复用会话**。引导阶段是开销最大的部分，因此在网站允许的范围内，尽可能在同一个会话上运行更多搜索。

## 常见问题

### 不用无头浏览器能抓取酒店价格吗？

在 Radisson 上可以。价格来自一个 JSON 房价 API。在我们的测试中，一个只发送页面、Akamai 和 API 请求，并使用 Chrome 的 TLS 指纹和请求头顺序的爬虫，在计划的 500 次搜索中有 490 次返回了房价。

### 为什么抓取酒店数据要使用粘性住宅代理？

Akamai 的载荷是针对单个 IP 生成的。如果 IP 在会话中途发生变化，该会话就会失效，因此每个会话都需要在结束前一直保持同一个住宅 IP。

### 基于请求的酒店爬虫会消耗多少代理流量？

在我们的测试中，通过 Akamai 每个会话消耗 520 KB，每次搜索消耗 12.5 KB。每个会话五次搜索时，算上所有开销，每次搜索为 122 KB；同一个会话运行 100 次搜索时，每次搜索降至 18 KB。

## 方法说明

- **日期与规模**。我们于 2026 年 10 月 22 日通过 Massive 英国住宅 IP 进行了测试：共 100 个会话，每次并发 10 个，每个会话五次搜索。较长的那次测试另外运行了五个会话，每次并发五个，每个会话 100 次搜索。
- **搜索**。一次搜索包括某个地点的酒店列表，以及针对该地点所有酒店的一次房价请求，入住条件为两位成人、一至三晚。酒店内容（名称和地址）很少变化，因此我们没有将其计入每次搜索的成本。
- **计入范围**。流量指我们的计数代理在爬虫与 Massive 之间观察到的数据，包括连接建立、TLS 握手和失败的尝试。对 Hyper Solutions API 的调用直接从我们的机器发出，不经过代理，因此不计入。
- **浏览器身份**。爬虫将自己呈现为 Windows 上的 Chrome 155，使用录制会话中的请求头及请求头顺序。

## 自己动手运行

- **代码**：[radisson-scraper 代码仓库](https://github.com/Hyper-Solutions/radisson-scraper)包含完整的 Go 流程。
- **代理**：按照上文所示，在 [Massive](https://joinmassive.com) 的 Proxy Generator 中设置粘性住宅会话。
- **Akamai 载荷**：[Hyper Solutions Akamai API](https://hypersolutions.co/products/akamai) 可生成传感器、SBSD 和像素载荷；建议从 [Akamai 入门文档](https://docs.hypersolutions.co/akamai-web/getting-started)开始。每个新注册的 Hyper Solutions 账户都可免费试用一周。
