万豪酒店协议价采集 — 需求说明

版本 1.0 · 2026-08-24 · 目标读者:技术实现方

1需求一句话

抓取万豪官网每家酒店的逐日价格日历,同一家酒店、同一批日期各抓两遍: 一遍标准价(Lowest Regular Rate),一遍 MMP 协议价(Corp/Promo Code = MMP), 输出成可入库的结构化数据,用于比价分析。

已经跑通,不是从零研究

接口、参数、字段、限制都已经摸清并有真实产出数据(见第 6、7 节)。 当前唯一的瓶颈是会话获取需要人工介入(第 5 节),这是本次要委托解决的核心问题。

2为什么值得做

以 JW 万豪酒店 东京(marsha 代码 TYOJW) 为例,同一房型、同一入住日,两种价格的差距:

37.3%35 天平均折扣
26,554平均每晚节省(JPY)
49%2026-08-25 当日折扣
入住日标准价 (JPY)MMP 协议价 (JPY)节省折扣
2026-08-1766,50036,28530,21545.4%
2026-08-1866,50036,28530,21545.4%
2026-08-1961,75036,28525,46541.2%
2026-08-2066,50036,28530,21545.4%
2026-08-2171,25063,7507,50010.5%

注:折扣逐日波动很大(10%~62%),所以必须按天抓全,不能只抽样某一天。

3页面路径

第 1 步 · 搜索结果页(入口)

https://www.marriott.com/search/findHotels.mi?destinationAddress.destination=Tokyo,%20Japan&fromDate=08/25/2026&toDate=08/26/2026&roomCount=1&numAdultsPerRoom=1&isSearch=true

万豪搜索结果页,东京 23 家酒店
东京 23 家结果。注意左上 SPECIAL RATES 当前是 “Lowest Regular Rate”——这个下拉就是标准价/协议价的开关。

第 2 步 · 房价页(标准价)

https://www.marriott.com/search/availabilityCalendar.mi?propertyCode=TYOJW&isSearch=true&fromDate=08/25/2026&toDate=08/26/2026&lengthOfStay=1&roomCount=1&numAdultsPerRoom=1

JW 万豪东京标准价页面,71250 JPY
SPECIAL RATES = Lowest Regular Rate,Urban Deluxe 1 King 报价 71,250 JPY/晚。

第 3 步 · 同一页 + MMP 协议价

在上面 URL 后追加 &clusterCode=corp&corporateCode=MMP:

JW 万豪东京 MMP 协议价页面,36285 JPY
SPECIAL RATES 变成 Corp/Promo Code - MMP,房型卡片出现 “SPECIAL RATE AVAILABLE” 角标,同一房型报价 36,285 JPY/晚。
重要:价格不在页面 DOM 里,别去扒 HTML

URL 加上 isRateCalendar=true 可以切到「月历视图」,但日历格子里不渲染金额—— 实测全部显示 “Not available for check-in”。页面的价格是前端拿到接口 JSON 后再渲染的。

日历视图,格子里没有价格
月历视图:结构在、金额不在。唯一可靠的数据来源是第 4 节的接口。

4数据来源:ADF 价格日历接口

接口POST https://www.marriott.com/mi/query/phoenixShopADFSearchProductsByProperty
协议GraphQL(万豪自建网关,非标准 GraphQL 端点)
鉴权无需登录。靠 Cookie 里的 Akamai 会话(_abck / bm_sc / bm_sz / ak_bmsc)

必需请求头

content-type: application/json
accept: */*
apollographql-client-name: phoenix_shop
apollographql-client-version: v1
application-name: shop
graphql-operation-name: phoenixShopADFSearchProductsByProperty
graphql-operation-signature: 887375892e1ad2a43f46a9c95c55ea47cf6eca3af03331c2134f1b440cff3f9f
graphql-require-safelisting: true
origin: https://www.marriott.com
referer: https://www.marriott.com/search/availabilityCalendar.mi?...(见下方"落地页一致性")
signature 是 query 全文的哈希,query 一个字都不能改

万豪开了 GraphQL safelisting:graphql-operation-signature 是对 query 字符串算出来的固定哈希。 改动 query(哪怕只删一个用不到的字段)会导致签名不匹配、请求被拒。请原样透传。

请求体 · 标准价

{
  "operationName": "phoenixShopADFSearchProductsByProperty",
  "variables": {
    "id": ["TYOJW"],
    "search": {
      "propertyId": "TYOJW",
      "options": {
        "startDate": "2026-08-25",
        "endDate":   "2026-09-28",
        "numberOfRooms": 1,
        "numberInParty": 1,
        "numberOfDays": 1,
        "rateRequestTypes": [{ "value": "", "type": "STANDARD" }]
      }
    }
  },
  "query": "query phoenixShopADFSearchProductsByProperty(...)"   // 原样透传
}

请求体 · MMP 协议价

只改 rateRequestTypes 一处:

"rateRequestTypes": [{ "value": "MMP", "type": "CLUSTER" }]
落地页一致性校验(很容易踩)

万豪会校验「浏览器当前所在的落地页」与「请求的 rateRequestTypes」必须匹配:

不匹配会失败。所以实现上要分两轮跑:先把所有酒店的标准价跑完,再切到 MMP 页跑一轮协议价。 好消息是同一轮内换 propertyId 不需要重新导航,可以连续查很多家。

响应结构

返回的不是「每天一条」,而是连续同价日期区间(edges),需要自己展开成逐日:

{"data":{"search":{"calendarSearchByProperty":{"edges":[
  {"node":{
      "startDate":"2026-08-25",
      "endDate":"2026-08-28",          // 闭区间,含 endDate 当天
      "rateModes":[{
        "lowestAverageRate":{"amount":{
            "amount": 3628500,          // 整数最小货币单位
            "currency":"JPY",
            "decimalPoint": 2           // 真实金额 = amount / 10^decimalPoint = 36,285
        }},
        "sourceOfRate":"..."
      }]
  }}, ... ]}}}}
两个解析要点

5核心技术难点:Akamai Bot Manager

万豪全站挂 Akamai Bot Manager。这是本次委托要解决的唯一实质问题,其余部分都已跑通。 以下结论均为实测,不是推断,可以直接拿去用,省得重走弯路。

5.1 冷启动会被边缘直接拒绝

全新的、没有任何 Cookie 的客户端访问万豪首页,会在第一个 HTML 请求就被 Akamai 边缘节点拒掉, 连挑战流程都进不去:

$ curl -A "<正常 Chrome UA>" https://www.marriott.com/
HTTP 403   <title>Access Denied</title>   Reference #18.d41c1202...

用全新 profile 的真实 Chrome 打开同样如此。判定发生在任何 JS 执行之前, 所以与浏览器指纹、鼠标行为无关——是 IP 信誉层面的拦截(我们测试用的出口是机房 IP / Hetzner AS24940)。

5.2 已验证无效的做法(别再试了)

做法结果
Playwright / patchright 全新浏览器直连目标页403
分段导航(首页 → 搜索 → 酒店 → 日历)+ 伪造 Referer403
伪造 Google 搜索来路403
CDP 接管真实 Chrome(全新 / 无痕上下文)403
拒绝页上静置等待传感器上报后刷新(3 轮)403
操作系统级真实鼠标(pyautogui/CGEvent)模拟人类浏览 60 秒后刷新(3 轮)403
纯 HTTP 请求日历页「刷新会话」会把 _abck 从 0 翻成 -1,直接作废整个会话

5.3 已验证有效的做法(当前方案)

  1. 在一个有历史访问记录的真实浏览器里正常操作(进首页 → 搜酒店 → 看价格),Akamai 会签发有效会话
  2. 把该会话的完整 Cookie 取出来
  3. 注入到自动化浏览器,导航到日历页,在页面上下文里 fetch() 调接口

这样能稳定出数。但有硬限制:

会话额度有限 —— 这就是要解决的问题

5.4 希望技术方给出的方案

核心诉求是把「签发会话」这一步也自动化,去掉人工点击。可能的方向(供参考,不限定):

6交付数据格式

最终要入 MySQL 做比价分析。每家酒店 × 每个入住日一行:

字段类型说明
marshaCHAR(5)酒店唯一代码,如 TYOJW(主键之一)
nameVARCHAR酒店英文名
brand_nameVARCHAR品牌,如 JW Marriott
country_code / cityVARCHAR国家代码 / 城市
stay_dateDATE入住日(主键之一,需从 edges 区间展开)
currencyCHAR(3)币种,如 JPY
standard_amountDECIMAL(12,2)标准价(已按 decimalPoint 还原)
corporate_amountDECIMAL(12,2)MMP 协议价
corporate_codeVARCHAR固定 MMP(后续可能扩展其他协议码)
save_amountDECIMAL(12,2)标准价 − 协议价
discount_pctDECIMAL(5,2)节省百分比
captured_atDATETIME采集时间(价格会变,必须留时间戳)
过程数据也要落盘

接口原始 JSON、按区间(edges)存的中间表都要保留,方便回溯和重算。现有实现是边跑边写断点文件, 中断后可以从断点继续,不用重跑。

7规模与已知限制

10,342万豪全球酒店总数
151覆盖国家数
2每家酒店调用次数(标准 + MMP)
35单次查询最大天数
日期窗口硬上限 35 天

实测:41 天 ✅ / 45 天 ❌(HTTP 200 但 edges 为空,静默丢数据,最坑)/ 90 天 ❌ 403。 取 35 天留余量。要查更长区间必须切成多个窗口,每多一个窗口调用量翻一倍。

按「未来 35 天、每家 2 次调用」估算的调用量:

范围酒店数接口调用次数按 25 次/会话,需要会话数
日本13226411
中国8051,61065
美国6,36212,724509
全球10,34220,684828

最后一列说明了为什么必须解决会话自动化:全球跑一轮需要约 828 个会话,靠人工点击不可能。

8现有资产(可直接复用)

资产状态
万豪全球酒店名单(10,342 家,含 marsha / 品牌 / 国家 / 经纬度)已完成
酒店详情采集脚本(城市 / 地址等)已完成
价格采集脚本(含断点续跑、两轮分跑、35 天自动切窗口、配额测量埋点)已完成
GraphQL query 全文与变量模板已完成
日本价格数据进行中:标准价 60/132、协议价 21/132、1,586 条价格区间
东京比价表(19 家酒店 × 35 天,654 行,中文字段)已完成,可作为交付格式样例
会话自动获取未解决 ← 本次委托重点