酒店数据抓取完整指南)
Browser Harness 实战锦江 WeHotelbestwehotel.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_getWeHotel 官方直订门户是一个典型的前后端分离站点纯 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/ ?checkinDateYYYY-MM-DD # 注意小写 i / d checkoutDateYYYY-MM-DD # 注意小写 o / d cityCodeAR04567 # WeHotel 内部字母数字混合编码 cityNameurlencoded-Chinese queryWords # 可选关键字过滤 extend1,2,0,0,0,0 # rooms,adults,children,... Hotel detail page详情页: https://www.bestwehotel.com/HotelDetail/ ?hotelIdJJ1888 # JJ数字JJ 前缀代表锦江系 checkInDateYYYY-MM-DD # 注意大写 I 和 D checkOutDateYYYY-MM-DD # 注意大写 O 和 D extend1,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-harness的click_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(timeout20) 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.xr.width/2, y: r.yr.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/ ?checkinDate2026-04-30checkoutDate2026-05-01 cityCodeAR04567cityName%E4%B8%8A%E6%B5%B7 queryWordsextend1,2,0,0,0,0 ) new_tab(url) wait_for_load(timeout20) 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, targetIdtid)关闭。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还会awaitPromiseTrue支持返回 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_MARKER0关闭该标记。6. 品牌矩阵锦江系全品牌都在同一套 URL 下WeHotel 覆盖锦江国际集团全部子品牌了解这张矩阵有助于按品牌过滤或猜测hotelId前缀LUXURY (奢华尊选): J酒店, 昆仑 PREMIUM (高端甄选): 锦江, 丽笙精选, 丽笙, 丽筠, 丽芮, 暻阁, 郁锦香, 丽柏, Park Plaza QUALITY (精品优选): 维也纳国际/酒店/智好/3好, 非繁云居, Park Inn, Renjoy, 锦江都城, 凯里亚德, Lavande ESSENTIALS (舒适智选): 锦江之星(品尚/风尚), 7天酒店, 7天优品, IU酒店, 派酒店, 白玉兰, 康铂, 麗枫, 喆啡, 希岸, 潮漫所有品牌都可通过同一个/HotelDetail/?hotelIdJJnURL 预订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_CLICKS1还能在截图坐标处画出红色十字标记便于肉眼核对点击位置。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/ ?checkinDate2026-04-30checkoutDate2026-05-01 cityCodeAR04567cityName%E4%B8%8A%E6%B5%B7 queryWordsextend1,2,0,0,0,0 ) tid new_tab(url) wait_for_load(timeout20) 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, indent2, ensure_asciiFalse)) cdp(Target.closeTarget, targetIdtid)运行方式browser-harness PY ... PY。查看 src/browser_harness/run.py 可知所有 helpernew_tab、wait_for_load、js、cdp、click_at_xy等在执行脚本前已被预导入到全局命名空间脚本里无需再次 importQuick Start 中显式 import 仅为自包含演示两种写法都可用。每个 helper 的调用还会被自动记录到调用轨迹与遥测中。9.1 运行前提本地 Chrome/Chromium 已开启远程调试若尚未开启browser-harness会引导打开chrome://inspect/#remote-debuggingmacOS 下可用browser-harness mac-approve完成授权详见 skills/browser-harness/SKILL.md 与 install.md。建议开启领域技能模式设BH_DOMAIN_SKILLS1后goto_url会自动为当前站点返回匹配的 domain-skills 文档见 helpers.py 中的 goto_urlWeHotel 场景下即为本文对应的agent-workspace/domain-skills/wehotel/hotels.md。每次任务建议只用一个工作标签页脚本末尾用cdp(Target.closeTarget, ...)清理自己创建的标签页避免遗留重复标签。10. 小结锦江 WeHotel 是当前实测三家中国酒店门户中匿名抓取成本最低的一个无登录墙、无参数 schema 门禁、列表与详情页数据一次到位。核心成功要素有三严格遵循两套大小写不同的 URL 参数规范写错即静默回退默认日期、使用真实浏览器会话等待 XHR 数据渲染wait_for_load之后留出网络等待窗口、从查看详情锚点向上回溯卡片容器做 DOM 提取不依赖任何全局状态。配合browser-harness的new_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),仅供参考