- 浏览器控制
- GUI 自动化
- AI Agent
- 人工智能
- MCP 服务
- AI 技能
【免费下载链接】browser-harness
Browser Harness | Self-healing harness that enables LLMs to complete any task.
本文基于 agent-workspace/domain-skills/ctrip/hotels.md 这份实测领域技能文档整理成文。该文档于 2026-04-29 针对
hotels.ctrip.comPC 网页版(国内酒店)完成实测验证。本文以它为核心骨架,结合本仓库browser-harness的源码实现,系统讲解携程酒店价格的抓取原理、URL 参数模式、列表页/详情页 DOM 提取、登录重定向信号识别与完整可运行的 Python 示例。读完本文,你将掌握:如何用完整的"六参数"URL 模式免登录访问携程国内酒店列表页、如何从 React 渲染的 DOM 中稳定提取酒店名称/评分/价格对、如何识别并规避passport.ctrip.com登录重定向,以及如何用浏览器坐标点击绕过非<button>的搜索控件。
一、TL;DR:携程能免登录看到价格,但有一个前提
实测结论(与同为 OTA 平台的 同程 ly.com 形成鲜明对比):
- 价格无需登录即可见——但前提是你必须通过"自然"的导航流程到达列表页,使 URL 携带完整的参数结构。对简化 URL 直接 GET 会被重定向到登录页。
- 必须使用浏览器会话。页面外壳只有 144 KB,酒店数据由 XHR 在水合(hydration)之后加载,
http_get抓不到任何酒店数据(对应源码见 src/browser_harness/helpers.py 中http_get的纯 HTTP 实现——它不执行 JS)。 - 一旦进入合法列表页,价格以
¥<原价> ¥<当前价> 起的形式成对渲染,没有¥?占位符,也没有"请登录"弹层。 - 详情页 URL
https://hotels.ctrip.com/hotel/<hotelId>.html?checkin=...在未登录状态下可直接访问,并展示完整的房型/房价明细。 - 相比之下,ly.com 把每一个价格都藏在登录墙之后;携程只要尊重 URL 参数模式,对爬取要友好得多。
二、理解携程的反爬门:URL 参数模式(URL patterns)
携程的"签名式"反爬关卡是基于参数模式而非基于行为。它通过区分"真实客户端"(通过首页表单构建的 URL)和"脚本客户端"(手工输入 URL)来决定是否放行。三种 URL 形态:
列表页(规范形态,匿名友好): https://hotels.ctrip.com/hotels/list ?flexType=1 &cityId=<n> # 2 = 上海, 1 = 北京, 等 &provinceId=0 &districtId=0 &countryId=1 # 1 = 中国(国内) &checkin=YYYY-MM-DD &checkout=YYYY-MM-DD 酒店详情页(规范形态): https://hotels.ctrip.com/hotel/<hotelId>.html?checkin=...&checkout=... → 服务器会重写为 /hotels/detail/?... —— 两种写法都可用,数据一致 登录页(被判定可疑时重定向到这里): https://passport.ctrip.com/user/login?backurl=<url-encoded-target>关键结论:简化形态https://hotels.ctrip.com/hotels/list?city=2&checkin=...&checkout=...(注意是city而非cityId,且缺少provinceId/districtId/countryId/flexType)会被重定向到登录页。这就是携程区分"真实客户端"与"脚本客户端"的核心手段。
登录重定向信号与触发条件
当携程判定你的会话可疑时,会重定向到passport.ctrip.com/user/login?backurl=...。登录表单会自动预填用户 Chrome 中最近使用的手机号。看到这个 URL,不要输入任何凭据——应终止当前流程并按以下任一方式重启:
- (a)从首页表单重新走一遍流程(下文 Path B),修复 URL 参数模式不匹配的问题;
- (b)请用户交互式登录。
实测触发重定向的行为:
- 直接访问
/hotels/list?city=2&...(注意:city而非cityId); - 会话中从未先加载过
hotels.ctrip.com/首页就直接 GET。
实测不会触发重定向的行为:
- 冷启动(预设零个携程 cookie)直接访问
/hotels/list?flexType=1&cityId=2&provinceId=0&districtId=0&countryId=1&...; - 直接访问
/hotel/<id>.html?...。
由此可以断定:关卡基于 URL 参数模式而非行为模式。把参数模式写对,就不需要走登录流程。
三、环境准备:browser-harness 与本地 Chrome
本仓库的browser-harness通过 CDP(Chrome DevTools Protocol)直接控制真实浏览器,适合需要交互(点击、输入、导航)、需要用户已登录会话、需要 JS 渲染、或面对反爬页面的场景。调用方式为 CLI heredoc,helpers 已预先导入,run.py在exec前会自动调用ensure_daemon()拉起守护进程(见 src/browser_harness/run.py):
browser-harness <<'PY' print(page_info()) PY安装与首次连接请参考 install.md;若连接失败,运行诊断:
browser-harness --doctor本文涉及的核心 helpers 全部定义在 src/browser_harness/helpers.py:
| Helper | 作用 | 源码位置 |
|---|---|---|
new_tab(url) | 后台新建标签页并导航 | helpers.py |
wait_for_load(timeout) | 轮询document.readyState == 'complete' | helpers.py |
js(expression) | 在页面中执行 JS 表达式并取回值 | helpers.py |
click_at_xy(x, y) | 通过 CDP 鼠标事件在坐标处点击(合成器层面穿透 iframe/Shadow DOM) | helpers.py |
type_text(text) | 通过Input.insertText输入文本 | helpers.py |
press_key(key) | 派发真实按键事件(Enter、Tab、Backspace 等) | helpers.py |
cdp(method, **params) | 裸 CDP 调用,如Target.closeTarget | helpers.py |
page_info() | 返回当前页面的 url/title/视口/滚动信息 | helpers.py |
注:
wait_for_load()只能确认文档complete,对 SPA 不够;携程列表页的酒店数据由水合后的 XHR 加载,因此文档统一采用wait_for_load+time.sleep的组合等待。
四、可靠地进入列表页:两条路径
Path A —— 构建规范 URL(已知 cityId 时首选)
当你已经知道目标城市的cityId(见文末对照表),直接构建完整六参数 URL 即可:
from browser_harness.helpers import new_tab, wait_for_load import time url = ( "https://hotels.ctrip.com/hotels/list" "?flexType=1&cityId=2&provinceId=0&districtId=0&countryId=1" "&checkin=2026-04-29&checkout=2026-04-30" ) new_tab(url) wait_for_load(timeout=20) time.sleep(6) # XHR 拉取酒店列表Path B —— 模拟首页流程(只有城市名时)
如果只有城市名(如"上海"),就驱动首页表单。这里有两个关键 DOM 事实:
- 搜索按钮是
<div>而不是<button>——与 ly.com 相同(详见 ly-com/hotels.md 中的 "search button gotcha")。el.click()不可靠,坐标点击有效。 - 目的地输入框通过 placeholder 特征识别(
/目的地|城市|酒店/),输入城市名后按 Enter 选择第一个自动补全建议。
from browser_harness.helpers import new_tab, wait_for_load, js, click_at_xy, type_text, press_key import time new_tab("https://hotels.ctrip.com/") wait_for_load(timeout=20) time.sleep(2) # 通过 placeholder 定位目的地输入框 inp = js(""" const i = Array.from(document.querySelectorAll("input")) .find(i => i.placeholder && /目的地|城市|酒店/.test(i.placeholder)); if (!i) return null; const r = i.getBoundingClientRect(); return {x: r.x+r.width/2, y: r.y+r.height/2}; """) if not inp: raise RuntimeError("ctrip homepage: destination input not found — page DOM may have changed") click_at_xy(inp["x"], inp["y"]) time.sleep(0.4) type_text("上海") time.sleep(1.5) press_key("Enter") # 选择第一条自动补全建议 btn = js(""" const b = Array.from(document.querySelectorAll("button, div")) .find(el => (el.innerText||"").trim() === "搜索" && el.getBoundingClientRect().width > 60); if (!b) return null; const r = b.getBoundingClientRect(); return {x: r.x+r.width/2, y: r.y+r.height/2}; """) if not btn: raise RuntimeError("ctrip homepage: 搜索 button not found — page DOM may have changed") click_at_xy(btn["x"], btn["y"]) time.sleep(7) # 现在位于 /hotels/list?... 且带有完整的规范参数结构这就是真人会走的流程,也正是让参数结构保持完整的原因。这个过程中设置的 cookie 对后续直接访问 URL 的请求也有帮助。
从源码结构看,
click_at_xy通过Input.dispatchMouseEvent派发mousePressed/mouseReleased(helpers.py),是合成器级别的真实鼠标事件,因此对非标准控件(<div>按钮)也能生效。
五、列表页数据提取:从 React 卡片中抓取酒店
酒店卡片是<div class="list-item">。卡片上没有data-id,也没有<a href=...>——酒店 ID 只存在于 React 组件闭包中。要跳转详情页,要么视觉上点卡片(坐标点击),要么抓取酒店名、再用其他途径(如逐步积累的映射表)解析出 ID 后重新访问/hotel/<id>.html。
// 列表提取 —— 通过 js(...) 在浏览器内运行 return Array.from(document.querySelectorAll(".list-item")).slice(0, 30).map(card => { const text = (card.innerText || "").replace(/\s+/g, " "); const name = text.match(/^([^\s]{2,40}?(?:酒店|宾馆|公寓|民宿)(?:\([^)]*\))?)/)?.[1]; const score = text.match(/[1-5]\.\d/)?.[0]; // 先剥离 "1,234条点评" 再抓价格,否则 "1,234" 里的 ¥1 会泄漏进来 const priced = text.replace(/\d{1,3}(?:,\d{3})+/g, "").replace(/\d+条点评/g, ""); const prices = (priced.match(/¥\s*(\d{2,})/g) || []).map(s => parseInt(s.replace(/[^\d]/g, ""))); const [original, current] = prices; return {name, score, price_original: original ?? null, price_current: current ?? null}; });陷阱(phantom price):像1,234条点评、5,678条点评这样的点评数如果不先剥离,会匹配到¥\s*\d+——¥1、¥5会作为幽灵价格出现。必须先剥离千分位数字和\d+条点评再应用价格正则。
单张卡片的文本形状:
<name> [4.8] [超棒][1,994条点评] <tagline> 近<area> · <metro>查看地图 <room-type> 订单确认后30分钟内免费取消 ... ¥<original> ¥<current> 起 查看详情起后缀表示"起价";实际预订价可能因房价方案、早餐、取消政策而更高。
六、详情页:免登录查看完整房价明细
https://hotels.ctrip.com/hotel/<hotelId>.html?checkin=YYYY-MM-DD&checkout=YYYY-MM-DD该 URL 免登录可用。服务器会将其规范化为/hotels/detail/?...,但数据完全一致。页面标题格式为:🟢 <hotelName>预订价格,联系电话位置地址【携程酒店】(🟢前缀来自browser-harness的标签页标记机制,见 helpers.py 中的_mark_tab(),并非携程内容)。
详情页渲染多个房型/房价行,形态与列表页相同的¥orig ¥current。查找文本恰为预订的<button>或<a>,再向上回溯到房价卡片(与 ly.com 的做法一致,可对照 ly-com/hotels.md 的"价格三元组"一节)。
失效酒店检测:如果访问的酒店已不存在,URL 会静默存活(返回 200),但只显示通用标题"预订价格,联系电话位置地址【携程酒店】"且没有价格——用bodyText.match(/¥\s*\d+/) === null检测即可。
七、登录重定向:检测与处理
检测代码:
if "passport.ctrip.com" in page_info()["url"]: raise RuntimeError("ctrip wants login — restart from homepage")记住两条实测规则:
- 参数模式正确(六参数 + 冷启动)→ 不触发登录重定向;
- 参数模式不完整(
city=简化形态)或未先访问首页 → 触发登录重定向。
因此核心策略是:把 URL 参数结构写对,就不需要登录流程。
八、全局状态:不要碰window
window._objAllSearchResult存在但在真实会话中是空的。携程的酒店数据存放在React 组件状态中,不在window上。请使用 DOM 提取。这里没有__NEXT_DATA__、没有__APOLLO_STATE__、也没有window.__INITIAL_STATE__。(这与 ly.com 形成对照:同程基于 Nuxt,数据在window.__NUXT__的 SSR 状态里,见 ly-com/hotels.md。)
九、陷阱清单(完整版)
- 简化 URL(
?city=)会被重定向到登录页。使用规范六参数结构,或驱动首页表单。 - 首页默认标签是"海外酒店"。推荐酒店显示"暂无价格",并预选了新加坡/普吉岛的日期。不要从首页读价格——去
/hotels/list?...。 ¥1、¥5幽灵价格:来自点评数正则泄漏。在应用¥\d+之前先剥离\d+,\d+和\d+条点评。- 卡片上没有详情 URL。无
data-id、无<a>。没有外部 ID 来源时,坐标点击是唯一可靠的跳转详情方式。 - 不存在的酒店 ID 返回 200:
/hotel/{id}.html会带品牌外壳返回,无报错——通过正文中缺少价格来检测。 - 搜索按钮是
<div>。与 ly.com 相同的坑——坐标点击有效,el.click()不可靠。 hotels.ctrip.com是国内主机。首页标题写着"海外酒店预订",但/hotels/list?countryId=1&...是国内数据。海外等价页面使用countryId≠ 1(尚未映射)。
十、已观测的 cityId 对照表
| 城市 | cityId |
|---|---|
| 上海 | 2 |
| 北京 | 1 |
| 广州 | 32 |
| 深圳 | 30 |
获取更多 cityId 的办法:驱动首页流程一次,然后读取规范 URL 中的参数。
十一、Quick start:一键抓取上海酒店列表
完整的端到端示例(开新标签 → 等待加载与 XHR → DOM 提取 → 打印 JSON → 关闭标签):
from browser_harness.helpers import new_tab, wait_for_load, js, cdp import time, json url = ( "https://hotels.ctrip.com/hotels/list" "?flexType=1&cityId=2&provinceId=0&districtId=0&countryId=1" "&checkin=2026-04-29&checkout=2026-04-30" ) tid = new_tab(url) wait_for_load(timeout=20) time.sleep(6) hotels = js(""" return Array.from(document.querySelectorAll(".list-item")).slice(0, 20).map(card => { const raw = (card.innerText || "").replace(/\\s+/g, " "); const cleaned = raw.replace(/\\d{1,3}(?:,\\d{3})+/g, "").replace(/\\d+条点评/g, ""); const name = raw.match(/^([^\\s]{2,40}?(?:酒店|宾馆|公寓|民宿)(?:\\([^)]*\\))?)/)?.[1] || null; const prices = (cleaned.match(/¥\\s*\\d{2,}/g) || []) .map(s => parseInt(s.replace(/[^\\d]/g, ""))); const score = raw.match(/[1-5]\\.\\d/)?.[0] || null; return {name, score, price_original: prices[0] ?? null, price_current: prices[1] ?? null}; }); """) print(json.dumps(hotels, indent=2, ensure_ascii=False)) cdp("Target.closeTarget", targetId=tid)运行方式(heredoc 或直接保存为脚本后通过 CLI 执行均可)。Target.closeTarget对应 helpers.py 中close_tab的底层实现,用于清理本次任务创建的标签页。
十二、延伸阅读
- 领域技能原始文档:agent-workspace/domain-skills/ctrip/hotels.md
- 同城 OTA 对比:agent-workspace/domain-skills/ly-com/hotels.md
- 核心 helpers 源码:src/browser_harness/helpers.py
- CLI 入口与 daemon 引导:src/browser_harness/run.py
- 安装与连接:install.md;技能总览:SKILL.md
- 标签页管理规范:interaction-skills/tabs.md;连接与标签可见性:interaction-skills/connection.md
- 浏览器控制
- GUI 自动化
- AI Agent
- 人工智能
- MCP 服务
- AI 技能
【免费下载链接】browser-harness
Browser Harness | Self-healing harness that enables LLMs to complete any task.
相关推荐
Scrapling StealthyFetcher 实战指南:绕过 Cloudflare 与反爬指纹检测的隐身浏览器抓取
Scrapling StealthyFetcher 实战指南:绕过 Cloudflare 与反爬指纹检测的隐身浏览器抓取 StealthyFetcher 是 S
网页爬虫SurfSense 的 Walmart 公开页面抓取器:两大动词、`__NEXT_DATA__` 解析与反爬绕过实战
SurfSense 的 Walmart 公开页面抓取器:两大动词、 __NEXT_DATA__ 解析与反爬绕过实战 导读 本文深入剖析 SurfSense 开源
人工智能AI 应用后端AI Agent网页爬虫RAG深度研究MCP 服务前端Scrapling Agent Skill 实战:让 AI Agent 安全地掌握反爬抓取、浏览器自动化与 Spider 框架
Scrapling Agent Skill 实战:让 AI Agent 安全地掌握反爬抓取、浏览器自动化与 Spider 框架 本文围绕 Scrapling 仓
网页爬虫
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考