抓取万豪官网每家酒店的逐日价格日历,同一家酒店、同一批日期各抓两遍: 一遍标准价(Lowest Regular Rate),一遍 MMP 协议价(Corp/Promo Code = MMP), 输出成可入库的结构化数据,用于比价分析。
接口、参数、字段、限制都已经摸清并有真实产出数据(见第 6、7 节)。 当前唯一的瓶颈是会话获取需要人工介入(第 5 节),这是本次要委托解决的核心问题。
以 JW 万豪酒店 东京(marsha 代码 TYOJW) 为例,同一房型、同一入住日,两种价格的差距:
| 入住日 | 标准价 (JPY) | MMP 协议价 (JPY) | 节省 | 折扣 |
|---|---|---|---|---|
| 2026-08-17 | 66,500 | 36,285 | 30,215 | 45.4% |
| 2026-08-18 | 66,500 | 36,285 | 30,215 | 45.4% |
| 2026-08-19 | 61,750 | 36,285 | 25,465 | 41.2% |
| 2026-08-20 | 66,500 | 36,285 | 30,215 | 45.4% |
| 2026-08-21 | 71,250 | 63,750 | 7,500 | 10.5% |
注:折扣逐日波动很大(10%~62%),所以必须按天抓全,不能只抽样某一天。
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
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
在上面 URL 后追加 &clusterCode=corp&corporateCode=MMP:
URL 加上 isRateCalendar=true 可以切到「月历视图」,但日历格子里不渲染金额——
实测全部显示 “Not available for check-in”。页面的价格是前端拿到接口 JSON 后再渲染的。
| 接口 | 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?...(见下方"落地页一致性")
万豪开了 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(...)" // 原样透传
}
只改 rateRequestTypes 一处:
"rateRequestTypes": [{ "value": "MMP", "type": "CLUSTER" }]
万豪会校验「浏览器当前所在的落地页」与「请求的 rateRequestTypes」必须匹配:
STANDARD&clusterCode=corp&corporateCode=MMP)→ 只能查 CLUSTER/MMP不匹配会失败。所以实现上要分两轮跑:先把所有酒店的标准价跑完,再切到 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":"..."
}]
}}, ... ]}}}}
真实值 = amount / 10^decimalPoint,不要直接当元用。万豪全站挂 Akamai Bot Manager。这是本次委托要解决的唯一实质问题,其余部分都已跑通。 以下结论均为实测,不是推断,可以直接拿去用,省得重走弯路。
全新的、没有任何 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)。
| 做法 | 结果 |
|---|---|
| Playwright / patchright 全新浏览器直连目标页 | 403 |
| 分段导航(首页 → 搜索 → 酒店 → 日历)+ 伪造 Referer | 403 |
| 伪造 Google 搜索来路 | 403 |
| CDP 接管真实 Chrome(全新 / 无痕上下文) | 403 |
| 拒绝页上静置等待传感器上报后刷新(3 轮) | 403 |
| 操作系统级真实鼠标(pyautogui/CGEvent)模拟人类浏览 60 秒后刷新(3 轮) | 403 |
| 纯 HTTP 请求日历页「刷新会话」 | 会把 _abck 从 0 翻成 -1,直接作废整个会话 |
fetch() 调接口这样能稳定出数。但有硬限制:
bm_sc 是一次性令牌,先用 curl 验证一次就会把它消耗掉,之后再给自动化用必然失败akmCC=CN)、拿到境外出口用,第一次调用就 403核心诉求是把「签发会话」这一步也自动化,去掉人工点击。可能的方向(供参考,不限定):
_abck 传感器数据的实现。最终要入 MySQL 做比价分析。每家酒店 × 每个入住日一行:
| 字段 | 类型 | 说明 |
|---|---|---|
marsha | CHAR(5) | 酒店唯一代码,如 TYOJW(主键之一) |
name | VARCHAR | 酒店英文名 |
brand_name | VARCHAR | 品牌,如 JW Marriott |
country_code / city | VARCHAR | 国家代码 / 城市 |
stay_date | DATE | 入住日(主键之一,需从 edges 区间展开) |
currency | CHAR(3) | 币种,如 JPY |
standard_amount | DECIMAL(12,2) | 标准价(已按 decimalPoint 还原) |
corporate_amount | DECIMAL(12,2) | MMP 协议价 |
corporate_code | VARCHAR | 固定 MMP(后续可能扩展其他协议码) |
save_amount | DECIMAL(12,2) | 标准价 − 协议价 |
discount_pct | DECIMAL(5,2) | 节省百分比 |
captured_at | DATETIME | 采集时间(价格会变,必须留时间戳) |
接口原始 JSON、按区间(edges)存的中间表都要保留,方便回溯和重算。现有实现是边跑边写断点文件, 中断后可以从断点继续,不用重跑。
实测:41 天 ✅ / 45 天 ❌(HTTP 200 但 edges 为空,静默丢数据,最坑)/ 90 天 ❌ 403。 取 35 天留余量。要查更长区间必须切成多个窗口,每多一个窗口调用量翻一倍。
按「未来 35 天、每家 2 次调用」估算的调用量:
| 范围 | 酒店数 | 接口调用次数 | 按 25 次/会话,需要会话数 |
|---|---|---|---|
| 日本 | 132 | 264 | 11 |
| 中国 | 805 | 1,610 | 65 |
| 美国 | 6,362 | 12,724 | 509 |
| 全球 | 10,342 | 20,684 | 828 |
最后一列说明了为什么必须解决会话自动化:全球跑一轮需要约 828 个会话,靠人工点击不可能。
| 资产 | 状态 |
|---|---|
| 万豪全球酒店名单(10,342 家,含 marsha / 品牌 / 国家 / 经纬度) | 已完成 |
| 酒店详情采集脚本(城市 / 地址等) | 已完成 |
| 价格采集脚本(含断点续跑、两轮分跑、35 天自动切窗口、配额测量埋点) | 已完成 |
| GraphQL query 全文与变量模板 | 已完成 |
| 日本价格数据 | 进行中:标准价 60/132、协议价 21/132、1,586 条价格区间 |
| 东京比价表(19 家酒店 × 35 天,654 行,中文字段) | 已完成,可作为交付格式样例 |
| 会话自动获取 | 未解决 ← 本次委托重点 |