Browser Harness 实战:锦江 WeHotel(bestwehotel.com)酒店数据抓取完整指南
2026/9/21 16:07:07 网站建设 项目流程

Browser Harness 实战:锦江 WeHotel(bestwehotel.com)酒店数据抓取完整指南

【免费下载链接】browser-harnessBrowser Harness | Self-healing harness that enables LLMs to complete any task.项目地址: https://gitcode.com/gh_mirrors/br/browser-harness

本文基于 browser-harness 仓库中的实战笔记 agent-workspace/domain-skills/wehotel/hotels.md,完整讲解如何通过browser-harness的真实浏览器会话,对锦江国际集团官方直订门户www.bestwehotel.com(锦江 WeHotel,覆盖约 50 个子品牌)进行酒店列表与房态价格的无登录抓取。读完本文,你将掌握:列表页与详情页两套大小写不一致的 URL 参数规范、通过坐标点击驱动首页表单或直接构造规范 URL 的两种落地路径、基于 DOM 回溯的酒店卡片与房态表提取脚本,以及所有已知陷阱的规避方法,并理解背后browser-harness各 API 的源码级工作原理。


1. 为什么需要浏览器会话,而不是http_get

WeHotel 官方直订门户是一个典型的前后端分离站点:纯 HTTP 请求拿到的只是 SPA 外壳,真正的酒店数据由页面水合(hydration)之后的 XHR 请求异步加载。这一点与同仓库实测的其他两家中国酒店门户一致:

  • 同程旅行 ly.com 技能笔记 中,价格被登录墙完全挡住,未登录时所有价格渲染为字面量¥?http_get对价格数据无效;
  • 携程 ctrip.com 技能笔记 中,简化 URL 会被重定向到登录页,只有满足规范参数 schema 的 URL 才能匿名看到价格;
  • WeHotel 是三者中对抓取最友好的一个:列表页和详情页均可匿名访问,没有登录墙,没有参数 schema 门禁,也没有¥?占位符——新开一个标签页后立刻就能渲染出 200+ 家带价格的酒店。

因此本方案的标准路径是:启动browser-harness守护进程连接真实 Chrome,通过 CDP 控制浏览器完成导航与数据提取,而不是用http_get直接发请求。http_get在 src/browser_harness/helpers.py 中的实现是纯 urllib 请求,不带浏览器渲染能力,对 SPA 页面只能拿到空壳 HTML。


2. URL 参数规范(重点:大小写不一致的坑)

WeHotel 的列表页与详情页使用两套大小写风格完全不同的日期参数名,这是全网最容易被忽略的陷阱:

List page(列表页): https://www.bestwehotel.com/HotelSearch/ ?checkinDate=YYYY-MM-DD # 注意:小写 i / d &checkoutDate=YYYY-MM-DD # 注意:小写 o / d &cityCode=AR04567 # WeHotel 内部字母数字混合编码 &cityName=<urlencoded-Chinese> &queryWords= # 可选关键字过滤 &extend=1,2,0,0,0,0 # rooms,adults,children,... Hotel detail page(详情页): https://www.bestwehotel.com/HotelDetail/ ?hotelId=JJ1888 # JJ<数字>;"JJ" 前缀代表锦江系 &checkInDate=YYYY-MM-DD # 注意:大写 I 和 D &checkOutDate=YYYY-MM-DD # 注意:大写 O 和 D &extend=1,2,0,0,0,0

参数名大小写在列表页与详情页之间不一致:列表页用checkinDate/checkoutDate,详情页用checkInDate/checkOutDate。一旦写错,页面不会报错,而是静默回退到默认日期,导致抓到的房态价格与目标日期不符且毫无提示。文档作者的建议是:不要凭记忆手敲,每次都从文档复制。

另一个关键点是cityCode:它是字母数字混合编码(如上海 =AR04567),不是纯数字,也没有显而易见的映射规则。获取方式是一次性通过首页表单跑一次搜索,从结果 URL 中读出后缓存复用。这与携程用纯数字cityId(上海 = 2)不同,注意区分。

此外,WeHotel 不会像携程那样,在缺失provinceId/districtId/countryId这类参数时跳转登录页——单独的cityCode就足够定位城市,这正是它“对抓取友好”的体现之一。


3. 如何落到列表页:两条等价路径

首页表单默认目的地是上海,默认日期也合理,因此有两条等价路径可以到达列表页。

3.1 Path A:驱动首页表单(推荐用于不掌握 cityCode 的场景)

首页的“搜索”按钮是一个<div>(旁边有一个坐标相同<a>兄弟节点),因此必须用坐标点击而非el.click()。使用browser-harnessclick_at_xy定位并点击:

from browser_harness.helpers import new_tab, wait_for_load, click_at_xy, js, type_text import time new_tab("https://www.bestwehotel.com/") wait_for_load(timeout=20) time.sleep(2) # 搜索按钮是 <div>(且有一个坐标相同的 <a> 兄弟节点)——坐标点击即可 btn = js(""" const b = Array.from(document.querySelectorAll('button, div, a')) .find(el => (el.innerText||'').trim() === '搜索' && el.offsetParent !== null); const r = b.getBoundingClientRect(); return {x: r.x+r.width/2, y: r.y+r.height/2}; """) click_at_xy(btn["x"], btn["y"]) time.sleep(8) # 等待 XHR 列表数据拉取

3.2 Path B:构造规范 URL(知道 cityCode 时首选)

url = ( "https://www.bestwehotel.com/HotelSearch/" "?checkinDate=2026-04-30&checkoutDate=2026-05-01" "&cityCode=AR04567&cityName=%E4%B8%8A%E6%B5%B7" "&queryWords=&extend=1,2,0,0,0,0" ) new_tab(url) wait_for_load(timeout=20) time.sleep(8)

new_tab()是本方案的核心入口。查看 src/browser_harness/helpers.py 的实现会发现一个细节:new_tab总是先创建about:blank标签页、再执行导航,而不是直接createTarget(url)——因为直接把 URL 传给 createTarget 会与后续 attach 产生竞态,导致wait_for_load在导航真正开始前就误报完成。返回值为该标签页的targetId,后续可用cdp("Target.closeTarget", targetId=tid)关闭。

wait_for_load在 helpers.py 中实现为轮询document.readyState === 'complete',默认超时 15 秒。但注意:readyState只代表文档加载完毕,SPA 的框架渲染和数据请求不包含在内——这正是脚本中time.sleep(8)等待 XHR 列表拉取的原因。对于更严谨的等待,browser-harness还提供了 wait_for_element(轮询元素出现,可要求可见)和 wait_for_network_idle(等待网络请求全部结束且静默 idle_ms 毫秒)两个替代方案,对应测试见 tests/unit/test_helpers.py。


4. 列表页提取酒店:从“查看详情”链接回溯卡片

列表页每张酒店卡片会发射三个href 相同(都含同一hotelId)但 innerText 不同的<a>标签:第一个为空(图片链接)、第二个是酒店名、第三个是查看详情。可靠的提取路径是:先选出 innerText 恰为查看详情<a>,再向上回溯到同时包含三个锚点的最小祖先容器。

return Array.from(document.querySelectorAll("a[href*=HotelDetail]")) .filter(a => (a.innerText || "").trim() === "查看详情") .slice(0, 30) .map(detailA => { const id = (detailA.href.match(/hotelId=([A-Z]+\d+)/i) || [])[1]; // 向上回溯到包含酒店名 <a> 的最小容器 // (共享同一 hotelId href、但 innerText 不是 "查看详情" 的锚点—— // 该文本活在链接体上而非 href 里,所以按锚点身份/innerText 过滤, // 而不是按属性选择器过滤) let card = detailA.parentElement; const hasNameAnchor = (el) => Array.from(el.querySelectorAll("a[href*='" + id + "']")) .some(a => a !== detailA && (a.innerText || "").trim() && (a.innerText || "").trim() !== "查看详情"); while (card && !hasNameAnchor(card)) { card = card.parentElement; } if (!card) return null; const text = (card.innerText || "").replace(/\s+/g, " "); const name = text.match(/(?:\d+\s+)?([^\s]{2,40}?(?:酒店|宾馆|大酒店|饭店))/)?.[1] || null; const score = text.match(/(\d\.\d)\s*\/\s*5/)?.[1] || null; const grade = (text.match(/(豪华型|高档型|舒适型|经济型)/) || [])[1] || null; const distance = (text.match(/距离市中心\s*([\d.]+)\s*km/) || [])[1] || null; const fromPrice = (text.match(/(?:¥|¥)\s*(\d+)\s*起/) || [])[1] || null; const address = (text.match(/地址:([^|]+?)距离/) || [])[1]?.trim() || null; const amenities = (text.match(/(停车场|餐厅|新店|游泳池|健身房|wifi)/g) || []).slice(0, 5); return { hotelId: id, name, score, grade, distance, address, price_from: fromPrice ? parseInt(fromPrice) : null, amenities, }; }) .filter(x => x && x.hotelId);

页面正文会显示总数(查询到 N 家酒店),可用于校验提取是否完整。

这段提取脚本通过js(...)执行。从源码看,helpers.py 中的js首先按原样求值表达式,若 Chrome 报“非法顶层 return”,会自动把表达式包进函数重试——因此上述代码中既能写纯表达式、也能写带return的完整函数体,两种风格都安全。js还会awaitPromise=True,支持返回 Promise 的异步表达式。

4.1 卡片字段形状(实测观察)

<index> <hotel-name> 地址:<full-address> 距离市中心 X.X km <rating>/5分 <grade> # 豪华型/高档型/舒适型/经济型 <amenity tags> # 停车场/餐厅/新店/... ¥<price>起 查看详情

5. 详情页提取房态:房型 × 早餐 × 取消政策矩阵

详情页渲染一张扁平表格,列为房型 | 早餐 | 取消政策 | 人数上限 | 房价 | <预订按钮>。每一行包含一个完整套餐,且只有一个¥<price>——没有原价/折扣价三元组(列表页价格本身就是“起价”)。提取脚本按“包含预订按钮或 ¥ 符号”筛选房态行:

const rows = Array.from(document.querySelectorAll("[class*=room], [class*=Room]")) .filter(el => (el.innerText || "").includes("立即预订") || (el.innerText || "").includes("¥")) .slice(0, 30); return rows.map(row => { const text = (row.innerText || "").replace(/\s+/g, " "); return { room_type: text.match(/^(\S+(?:大床房|双床房|套房|标间|双人房)\S*)/)?.[1] || null, breakfast: text.match(/(无早餐|含早餐|含\d份早餐|\d份早餐)/)?.[1] || null, cancel: text.match(/(限时取消|免费取消|不可取消|订单确认后\d+分钟内可免费取消)/)?.[1] || null, price: parseInt((text.match(/(?:¥|¥)\s*(\d+)/) || [])[1] || "0"), full: text.slice(0, 200), }; }).filter(r => r.price > 0);

注意标题的坑:详情页的浏览器标题始终是🟢 锦江酒店WeHotel官网🟢browser-harness给标签页添加的前缀标记,不是页面本身的内容),酒店名只出现在页面正文中,不会进标题。要拿酒店名,需从页面头部提取,使用通用的[class*=name][class*=title]选择器。关于标题前缀,skills/browser-harness/SKILL.md 中说明可通过BH_TAB_MARKER=0关闭该标记。


6. 品牌矩阵:锦江系全品牌都在同一套 URL 下

WeHotel 覆盖锦江国际集团全部子品牌,了解这张矩阵有助于按品牌过滤或猜测hotelId前缀:

LUXURY (奢华尊选): J酒店, 昆仑 PREMIUM (高端甄选): 锦江, 丽笙精选, 丽笙, 丽筠, 丽芮, 暻阁, 郁锦香, 丽柏, Park Plaza QUALITY (精品优选): 维也纳国际/酒店/智好/3好, 非繁云居, Park Inn, Renjoy, 锦江都城, 凯里亚德, Lavande ESSENTIALS (舒适智选): 锦江之星(品尚/风尚), 7天酒店, 7天优品, IU酒店, 派酒店, 白玉兰, 康铂, 麗枫, 喆啡, 希岸, 潮漫

所有品牌都可通过同一个/HotelDetail/?hotelId=JJ<n>URL 预订,JJ前缀即锦江系编码。


7. 全局状态不可用:只能做 DOM 提取

页面上下文中window.__INITIAL_STATE____NUXT____NEXT_DATA____APOLLO_STATE__全部不存在。酒店数据经由 XHR 加载进 Vue/React 组件状态,从未暴露到window上。

这与同仓库的另外两家形成对比:ly.com 至少在未登录时还能从window.__NUXT__拿到 SSR 的酒店元数据(见 ly-com/hotels.md),而携程的window._objAllSearchResult在真实会话中为空(见 ctrip/hotels.md)。结论统一:全部走 DOM 提取,不要依赖任何全局状态。


8. 陷阱清单(务必逐条核对)

陷阱说明与对策
日期参数大小写不一致列表页checkinDate(小写 i/d),详情页checkInDate(大写 I/D)。混用会导致静默回退到默认日期。从文档复制而非手敲
cityCode是字母数字混合上海 =AR04567,非纯数字,无映射规则。通过首页表单跑一次搜索取出后缓存
卡片有 3 个相同 href 的<a>第一个空(图片链接)、第二个酒店名、第三个查看详情。按 innerText 过滤去重
首页默认日期每日漂移不要信任表单预填日期——通过 URL 显式设置,或先填表单再点“搜索”
“价格区间-”是 UI 元素不是数据正文中的“价格区间-”是界面文案,别误从中提取¥?

另外注意首页表单驱动的通用性问题:搜索按钮是<div>而非<button>,这是中国酒店门户的常见结构(携程、同程同款)。el.click()对其不可靠,必须用click_at_xy坐标点击。从源码看,click_at_xy通过Input.dispatchMouseEvent发送 mousePressed/mouseReleased,属于合成器级别的真实输入事件,能穿透 iframe 与 Shadow DOM;调试时设置环境变量BH_DEBUG_CLICKS=1还能在截图坐标处画出红色十字标记,便于肉眼核对点击位置。


9. Quick Start:完整可运行脚本

以下是文档附带的完整入门脚本(含标签页清理),可直接通过browser-harness的 heredoc 方式运行:

import time, json from browser_harness.helpers import new_tab, wait_for_load, js, cdp url = ( "https://www.bestwehotel.com/HotelSearch/" "?checkinDate=2026-04-30&checkoutDate=2026-05-01" "&cityCode=AR04567&cityName=%E4%B8%8A%E6%B5%B7" "&queryWords=&extend=1,2,0,0,0,0" ) tid = new_tab(url) wait_for_load(timeout=20) time.sleep(8) hotels = js(r""" return Array.from(document.querySelectorAll("a[href*=HotelDetail]")) .filter(a => (a.innerText||"").trim() === "查看详情") .slice(0, 30) .map(detailA => { const id = (detailA.href.match(/hotelId=([A-Z]+\d+)/i) || [])[1]; let card = detailA.parentElement; while (card && !card.querySelector(`a[href*='${id}']:not(:where([href*='%E6%9F%A5%E7%9C%8B%E8%AF%A6%E6%83%85']))`)) { card = card.parentElement; if (!card) break; } if (!card) return null; const text = (card.innerText || "").replace(/\s+/g, " "); return { hotelId: id, name: text.match(/(?:\d+\s+)?([^\s]{2,40}?(?:酒店|宾馆|大酒店|饭店))/)?.[1] || null, score: text.match(/(\d\.\d)\s*\/\s*5/)?.[1] || null, grade: (text.match(/(豪华型|高档型|舒适型|经济型)/) || [])[1] || null, distance_km: parseFloat((text.match(/距离市中心\s*([\d.]+)/) || [])[1] || "0"), price_from: parseInt((text.match(/(?:¥|¥)\s*(\d+)\s*起/) || [])[1] || "0") || null, }; }) .filter(x => x && x.hotelId && x.name); """) print(json.dumps(hotels, indent=2, ensure_ascii=False)) cdp("Target.closeTarget", targetId=tid)

运行方式:browser-harness <<'PY' ... PY。查看 src/browser_harness/run.py 可知,所有 helper(new_tabwait_for_loadjscdpclick_at_xy等)在执行脚本前已被预导入到全局命名空间,脚本里无需再次 import(Quick Start 中显式 import 仅为自包含演示,两种写法都可用)。每个 helper 的调用还会被自动记录到调用轨迹与遥测中。

9.1 运行前提

  • 本地 Chrome/Chromium 已开启远程调试;若尚未开启,browser-harness会引导打开chrome://inspect/#remote-debugging,macOS 下可用browser-harness mac-approve完成授权(详见 skills/browser-harness/SKILL.md 与 install.md)。
  • 建议开启领域技能模式:设BH_DOMAIN_SKILLS=1后,goto_url会自动为当前站点返回匹配的 domain-skills 文档(见 helpers.py 中的 goto_url),WeHotel 场景下即为本文对应的agent-workspace/domain-skills/wehotel/hotels.md
  • 每次任务建议只用一个工作标签页;脚本末尾用cdp("Target.closeTarget", ...)清理自己创建的标签页,避免遗留重复标签。

10. 小结

锦江 WeHotel 是当前实测三家中国酒店门户中匿名抓取成本最低的一个:无登录墙、无参数 schema 门禁、列表与详情页数据一次到位。核心成功要素有三:严格遵循两套大小写不同的 URL 参数规范(写错即静默回退默认日期)、使用真实浏览器会话等待 XHR 数据渲染wait_for_load之后留出网络等待窗口)、查看详情锚点向上回溯卡片容器做 DOM 提取(不依赖任何全局状态)。配合browser-harnessnew_tab/js/click_at_xy/cdp等 CDP 封装 API,整个抓取流程可以完全无头、后台化地稳定复现。

【免费下载链接】browser-harnessBrowser Harness | Self-healing harness that enables LLMs to complete any task.项目地址: https://gitcode.com/gh_mirrors/br/browser-harness

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询