@danceiny/gotry 0.2.0-rc.31 → 0.2.0-rc.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +63 -53
  4. package/bin/gotry-hbcli-release.d.ts +3 -0
  5. package/bin/gotry-hbcli-release.js +4 -0
  6. package/client/client.js +66 -21
  7. package/dist/capabilities/doctor.js +7 -8
  8. package/dist/capabilities/effect.js +45 -12
  9. package/dist/capabilities/flyai-config.js +12 -10
  10. package/dist/capabilities/flyai.js +87 -56
  11. package/dist/capabilities/hbcli-config.js +156 -0
  12. package/dist/capabilities/hbcli.js +29 -2
  13. package/dist/capabilities/resilience.js +6 -6
  14. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  16. package/dist/capabilities/session/bridge-ledger.js +168 -67
  17. package/dist/capabilities/session/ctrip-city.js +186 -0
  18. package/dist/capabilities/session/extension-bridge.js +148 -47
  19. package/dist/capabilities/session/gui-flight.js +949 -0
  20. package/dist/capabilities/session/gui-hotel.js +470 -0
  21. package/dist/capabilities/session-search.js +102 -14
  22. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  23. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  24. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  25. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  26. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  27. package/dist/scripts/bootstrap-tests.js +23 -6
  28. package/dist/scripts/bridge-ledger-tests.js +20 -18
  29. package/dist/scripts/ctrip-city-tests.js +80 -0
  30. package/dist/scripts/effect-tests.js +602 -504
  31. package/dist/scripts/extension-tests.js +14 -8
  32. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  33. package/dist/scripts/flight-malformed-tests.js +1 -1
  34. package/dist/scripts/flyai-setup-tests.js +19 -4
  35. package/dist/scripts/flyai-tests.js +768 -653
  36. package/dist/scripts/gotry-web-api-tests.js +5 -0
  37. package/dist/scripts/gotry-web-client-tests.js +20 -1
  38. package/dist/scripts/gui-flight-live.js +27 -0
  39. package/dist/scripts/hbcli-tests.js +1 -1
  40. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  41. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  42. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  43. package/dist/scripts/hotel-result-tests.js +169 -0
  44. package/dist/scripts/issue-271-liveness-tests.js +1 -1
  45. package/dist/scripts/mutation-check.js +153 -0
  46. package/dist/scripts/nightly-evidence.js +4 -111
  47. package/dist/scripts/session-tests.js +211 -6
  48. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  49. package/dist/scripts/tenant-capacity-probe.js +263 -0
  50. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  51. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  52. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  53. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  54. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  55. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  56. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  57. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  58. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  59. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  60. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  61. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  62. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  63. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  64. package/dist/scripts/tenant-state-router-tests.js +464 -0
  65. package/dist/scripts/tenant-state-test-kit.js +92 -0
  66. package/dist/scripts/tenant-state-tests.js +564 -0
  67. package/dist/src/backend/modules/booking-executor.js +61 -6
  68. package/dist/src/backend/modules/session-search.js +136 -39
  69. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  70. package/dist/src/booking-surface/dsh-planner.js +150 -19
  71. package/dist/src/booking-surface/index.js +1 -0
  72. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  73. package/dist/src/booking-surface/server.js +193 -80
  74. package/dist/src/booking-surface/startup.js +153 -0
  75. package/dist/src/booking-surface/tenancy.js +188 -0
  76. package/dist/src/gotry-web-api.js +64 -2
  77. package/dist/src/index.js +70 -18
  78. package/dist/src/llm-price-table.js +148 -0
  79. package/dist/src/tenant-seam/index.js +8 -0
  80. package/dist/src/tenant-seam/llm-broker.js +508 -0
  81. package/dist/src/tenant-seam/process-governor.js +281 -0
  82. package/dist/src/tenant-seam/quotas.js +360 -0
  83. package/dist/src/tenant-seam/seam.js +92 -0
  84. package/dist/src/tenant-state/durable-store.js +158 -0
  85. package/dist/src/tenant-state/epoch-copies.js +422 -0
  86. package/dist/src/tenant-state/errors.js +108 -0
  87. package/dist/src/tenant-state/ids.js +101 -0
  88. package/dist/src/tenant-state/index.js +11 -0
  89. package/dist/src/tenant-state/lease-store.js +203 -0
  90. package/dist/src/tenant-state/tenant-lease.js +150 -0
  91. package/dist/src/tenant-state/tenant-router.js +650 -0
  92. package/extension/manifest.json +2 -2
  93. package/package.json +5 -1
  94. package/ts/capabilities/flyai-config.ts +12 -10
  95. package/ts/capabilities/flyai.ts +91 -50
  96. package/ts/capabilities/hbcli-config.ts +128 -0
  97. package/ts/capabilities/hbcli.ts +30 -3
  98. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  99. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  100. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  101. package/ts/capabilities/session/ctrip-city.ts +158 -0
  102. package/ts/capabilities/session/extension-bridge.ts +185 -67
  103. package/ts/capabilities/session/gui-flight.ts +1022 -0
  104. package/ts/capabilities/session/gui-hotel.ts +505 -0
  105. package/ts/capabilities/session-search.ts +130 -18
  106. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  107. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  108. package/ts/src/booking-surface/index.ts +1 -0
  109. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  110. package/ts/src/booking-surface/server.ts +215 -84
  111. package/ts/src/booking-surface/startup.ts +155 -0
  112. package/ts/src/booking-surface/tenancy.ts +242 -0
  113. package/ts/src/gotry-web-api.ts +51 -2
  114. package/ts/src/index.ts +67 -19
  115. package/ts/src/llm-price-table.ts +180 -0
  116. package/ts/src/tenant-seam/index.ts +9 -0
  117. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  118. package/ts/src/tenant-seam/process-governor.ts +343 -0
  119. package/ts/src/tenant-seam/quotas.ts +433 -0
  120. package/ts/src/tenant-seam/seam.ts +142 -0
  121. package/ts/src/tenant-state/durable-store.ts +171 -0
  122. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  123. package/ts/src/tenant-state/errors.ts +128 -0
  124. package/ts/src/tenant-state/ids.ts +108 -0
  125. package/ts/src/tenant-state/index.ts +12 -0
  126. package/ts/src/tenant-state/lease-store.ts +266 -0
  127. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  128. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -0,0 +1,505 @@
1
+ /**
2
+ * 携程酒店 GUI 车道(2026-10-14,#664 首片):gotry 隔离匿名窗口驱动 hotels.ctrip.com
3
+ * PC 检索表单(目的地/入住/退房),被动收站点自己的酒店列表回包——城市解析交给站点
4
+ * 自动补全(cityId 码表退出主链路),匿名公开价(非登录态价)。
5
+ *
6
+ * 与机票 GUI 车道(gui-flight.ts)同构:动作闭集 + 提交件黑名单 + readback 硬闸 +
7
+ * 可注入 driver;复用 ctrip-hotel 适配器的三态分类器(classifyCtripHotelList:
8
+ * parsed/recognized-empty/unrecognized——只有显式空列表才算 miss,绝不猜)。
9
+ * 证据链 [会话:ctrip-hotel-gui@ts];anonymous 标注(登录态配对后为 false)。
10
+ */
11
+
12
+ import { mkdtempSync, rmSync } from 'node:fs'
13
+ import { tmpdir } from 'node:os'
14
+ import { join } from 'node:path'
15
+ import type { Page } from 'playwright-core'
16
+ import { isSubmitText } from './read-guard.ts'
17
+ import { HOTEL_NETWORK_HINTS, looksLikeHotelListBody, classifyCtripHotelList, type SessionHotelOption } from './adapters/ctrip-hotel.ts'
18
+
19
+ export const HOTEL_GUI_HOME = 'https://hotels.ctrip.com/'
20
+ const YMD = /^\d{4}-\d{2}-\d{2}$/
21
+ const GUI_CHALLENGE_RE = /验证|校验|拖动滑块|请输入验证码/
22
+ /** 登录票据 cookie 名单(与 bridge 车道 LOGIN_COOKIE_NAMES 同源;只读名字) */
23
+ const TICKET_COOKIE_NAMES = ['cticket', 'uid', 'uname', 'passport']
24
+
25
+ export interface GuiHotelQuery {
26
+ /** 目的地中文(站点自动补全解析,码表不在主链路) */
27
+ to: string
28
+ /** 入住 YYYY-MM-DD */
29
+ checkIn: string
30
+ /** 退房 YYYY-MM-DD(须晚于入住) */
31
+ checkOut: string
32
+ /** 登录态配对(2026-10-15,#664 余尾):匿名列表强制跳 passport 时,窗口停在官方登录页
33
+ * 等用户本人完成登录(cookie 只读名字),随后原窗重驾拿登录态真实价。 */
34
+ interactiveLoginMs?: number
35
+ timeoutMs?: number
36
+ }
37
+
38
+ export type GuiHotelAction =
39
+ | { t: 'goto'; url: string }
40
+ | { t: 'typeCity'; text: string }
41
+ | { t: 'pickSuggest'; text: string }
42
+ | { t: 'setDate'; which: 'in' | 'out'; ymd: string }
43
+ | { t: 'press'; key: string }
44
+ | { t: 'readback'; expect: { to: string; checkIn: string; checkOut: string } }
45
+ | { t: 'clickSearch' }
46
+ | { t: 'awaitApi' }
47
+
48
+ /** 纯函数:查询 → 动作序列(离线测试锚点) */
49
+ export function planHotelGuiActions(q: GuiHotelQuery): GuiHotelAction[] {
50
+ return [
51
+ { t: 'goto', url: HOTEL_GUI_HOME },
52
+ { t: 'typeCity', text: q.to },
53
+ { t: 'pickSuggest', text: q.to },
54
+ { t: 'setDate', which: 'in', ymd: q.checkIn },
55
+ { t: 'setDate', which: 'out', ymd: q.checkOut },
56
+ { t: 'press', key: 'Escape' },
57
+ { t: 'readback', expect: { to: q.to, checkIn: q.checkIn, checkOut: q.checkOut } },
58
+ { t: 'clickSearch' },
59
+ { t: 'awaitApi' },
60
+ ]
61
+ }
62
+
63
+ /** 安全白名单(与机票车道同款纪律) */
64
+ export function assertHotelPlanSafe(plan: GuiHotelAction[]): { ok: true } | { ok: false; error: string } {
65
+ for (const a of plan) {
66
+ switch (a.t) {
67
+ case 'goto':
68
+ if (a.url !== HOTEL_GUI_HOME) return { ok: false, error: `GUI 车道只允许固定入口 ${HOTEL_GUI_HOME}` }
69
+ break
70
+ case 'typeCity':
71
+ case 'pickSuggest': {
72
+ const text = a.text.trim()
73
+ if (!text || text.length > 24) return { ok: false, error: `目的地文本非法:${text.slice(0, 30)}` }
74
+ if (isSubmitText(text)) return { ok: false, error: `目的地位提交件文本,拒绝:${text}` }
75
+ break
76
+ }
77
+ case 'setDate':
78
+ if (!YMD.test(a.ymd)) return { ok: false, error: `日期需 YYYY-MM-DD,实际 ${a.ymd}` }
79
+ break
80
+ case 'press':
81
+ if (a.key !== 'Escape' && a.key !== 'Enter') return { ok: false, error: `按键白名单外:${a.key}` }
82
+ break
83
+ case 'clickSearch':
84
+ case 'awaitApi':
85
+ break
86
+ case 'readback':
87
+ for (const v of [a.expect.to, a.expect.checkIn, a.expect.checkOut]) {
88
+ if (!v.trim() || v.length > 24) return { ok: false, error: `readback 期望值非法:${v}` }
89
+ }
90
+ break
91
+ default:
92
+ return { ok: false, error: `动作闭集外:${JSON.stringify(a).slice(0, 60)}` }
93
+ }
94
+ }
95
+ return { ok: true }
96
+ }
97
+
98
+ export interface GuiHotelPage {
99
+ goto(url: string): Promise<void>
100
+ typeCity(text: string): Promise<void>
101
+ pickSuggest(text: string): Promise<void>
102
+ setDate(which: 'in' | 'out', ymd: string): Promise<void>
103
+ press(key: string): Promise<void>
104
+ clickSearch(): Promise<void>
105
+ visibleFormText(): Promise<string>
106
+ destFieldValue(): Promise<string>
107
+ pageText(): Promise<string>
108
+ /** 当前页 URL(list 页真实形状回传) */
109
+ currentUrl(): Promise<string>
110
+ /** 登录态配对:窗口停在 passport 登录页等人完成登录(cookie 只读名字);超时 false */
111
+ waitForHumanLogin(timeoutMs: number): Promise<boolean>
112
+ waitForApiBody(timeoutMs: number): Promise<{ url: string; body: string } | null>
113
+ recentApiUrls?(): Promise<string[]>
114
+ }
115
+
116
+ export interface GuiHotelDriver {
117
+ open(): Promise<GuiHotelPage>
118
+ close(): Promise<void>
119
+ }
120
+
121
+ export type GuiHotelVerdict = 'hit' | 'miss' | 'error' | 'challenged' | 'needs-login'
122
+
123
+ export interface GuiHotelResult {
124
+ ok: boolean
125
+ via: 'session-ctrip-hotel-gui' | 'session-ctrip-hotel-gui-error'
126
+ evidence: string
127
+ latencyMs: number
128
+ verdict: GuiHotelVerdict
129
+ hotels?: SessionHotelOption[]
130
+ error?: string
131
+ anonymous: boolean
132
+ /** 检索后站点落到的 list 页 URL(真实形状回传) */
133
+ listUrl?: string
134
+ }
135
+
136
+ let hotelDriverFactory: (() => GuiHotelDriver) | null = null
137
+ export function __setHotelDriverForTest(factory: (() => GuiHotelDriver) | null): void {
138
+ hotelDriverFactory = factory
139
+ }
140
+
141
+ export async function runHotelGuiSearch(q: GuiHotelQuery): Promise<GuiHotelResult> {
142
+ const started = Date.now()
143
+ const ts = new Date().toISOString()
144
+ let humanLoggedIn = false
145
+ const fail = (error: string, verdict: GuiHotelVerdict = 'error'): GuiHotelResult => ({
146
+ ok: false, via: 'session-ctrip-hotel-gui-error', latencyMs: Date.now() - started, verdict, error,
147
+ evidence: `[会话:ctrip-hotel-gui@error@${ts}] ${error.slice(0, 120)}`, anonymous: !humanLoggedIn,
148
+ })
149
+ const needsLoginMessage = (): string =>
150
+ '酒店列表需登录态:匿名窗口被重定向到携程登录页(红线:不自动登录)——interactiveLogin=true 可在窗口内本人登录后自动重查,或 gotry_session_login 后走 bridge 车道'
151
+
152
+ if (!(q.checkIn < q.checkOut)) return fail(`退房日期(${q.checkOut})须晚于入住(${q.checkIn})`)
153
+
154
+ const plan = planHotelGuiActions(q)
155
+ const safe = assertHotelPlanSafe(plan)
156
+ if (!safe.ok) return fail(`GUI 计划被安全闸拒绝:${safe.error}`)
157
+
158
+ const budget = q.timeoutMs ?? 90_000
159
+ const driver = hotelDriverFactory ? hotelDriverFactory() : await createHotelPlaywrightDriver()
160
+ let page: GuiHotelPage
161
+ try {
162
+ page = await withDeadline(driver.open(), Math.min(budget, 30_000), 'GUI 浏览器窗口启动失败/超时(本机需已装 Chrome)')
163
+ } catch (e) {
164
+ await driver.close().catch(() => {})
165
+ return fail((e as Error).message.slice(0, 400))
166
+ }
167
+
168
+ let api: { url: string; body: string } | null = null
169
+ let listUrl = ''
170
+ class HotelFlowAbort { constructor(public result: GuiHotelResult) {} }
171
+ const driveOnce = async (): Promise<void> => {
172
+ for (const a of plan) {
173
+ const remain = budget - (Date.now() - started)
174
+ if (remain <= 0) throw new HotelFlowAbort(fail(`GUI 预算耗尽(${budget}ms)`))
175
+ const step = <T,>(p: Promise<T>, what: string): Promise<T> =>
176
+ withDeadline(p, Math.min(remain, 20_000), `GUI 步骤失败(${what})`)
177
+ switch (a.t) {
178
+ case 'goto': await step(page.goto(a.url), '打开携程酒店首页'); break
179
+ case 'typeCity': await step(page.typeCity(a.text), '填写目的地'); break
180
+ case 'pickSuggest': await step(page.pickSuggest(a.text), '选择目的地补全'); break
181
+ case 'setDate': await step(page.setDate(a.which, a.ymd), `设置${a.which === 'in' ? '入住' : '退房'}日期`); break
182
+ case 'press': await step(page.press(a.key), `按键 ${a.key}`); break
183
+ case 'clickSearch':
184
+ await step(page.clickSearch(), '点击搜索')
185
+ // live 校准(2026-10-14):hotels list 对匿名窗口强制跳 passport 登录——
186
+ // 诚实判 needs-login(不绕过、不重试),登录态走 bridge 车道。
187
+ {
188
+ const after = await page.currentUrl().catch(() => '')
189
+ if (/passport\.ctrip\.com\/.*login/i.test(after)) {
190
+ throw new HotelFlowAbort(fail('__NEEDS_LOGIN__', 'needs-login'))
191
+ }
192
+ }
193
+ break
194
+ case 'readback': {
195
+ const dest = await step(page.destFieldValue(), '读回目的地框')
196
+ const form = await step(page.visibleFormText(), '读回表单')
197
+ if (!dest.includes(a.expect.to)) {
198
+ throw new HotelFlowAbort(fail(`readback 硬闸:目的地框未确认「${a.expect.to}」(实际「${dest.slice(0, 30)}」)——已终止,未发起搜索`))
199
+ }
200
+ // live 校准:酒店表单日期显示为本地化格式(如 11月13日(周五))而非 YMD——双格式核对
201
+ const dateShown = (ymd: string): boolean => {
202
+ const cn = `${Number(ymd.slice(5, 7))}月${Number(ymd.slice(8, 10))}日`
203
+ return form.includes(ymd) || form.includes(cn)
204
+ }
205
+ for (const [label, value] of [['入住日期', a.expect.checkIn], ['退房日期', a.expect.checkOut]] as const) {
206
+ if (!dateShown(value)) {
207
+ throw new HotelFlowAbort(fail(`readback 硬闸:${label}「${value}」未在表单确认(表单摘要:${form.slice(0, 200)})——已终止,未发起搜索`))
208
+ }
209
+ }
210
+ break
211
+ }
212
+ case 'awaitApi': {
213
+ const text = await step(page.pageText(), '检查页面')
214
+ if (GUI_CHALLENGE_RE.test(text)) {
215
+ throw new HotelFlowAbort(fail('风控/验证码命中;按红线不重试不绕过,交还用户', 'challenged'))
216
+ }
217
+ api = await step(page.waitForApiBody(Math.min(remain - 500, 18_000)), '等待酒店列表回包')
218
+ if (!api) {
219
+ const recent = page.recentApiUrls ? (await page.recentApiUrls().catch(() => [])) : []
220
+ const nowUrl = await page.currentUrl().catch(() => '?')
221
+ if (/passport\.ctrip\.com\/.*login/i.test(nowUrl)) {
222
+ throw new HotelFlowAbort(fail('__NEEDS_LOGIN__', 'needs-login'))
223
+ }
224
+ throw new HotelFlowAbort(fail(`酒店列表回包嗅探超时(href=${nowUrl.slice(0, 120)})${recent.length ? ` ‖recent-apis: ${recent.slice(-8).join(' ; ').slice(0, 500)}` : ''}`))
225
+ }
226
+ break
227
+ }
228
+ }
229
+ }
230
+ return
231
+ }
232
+
233
+ try {
234
+ await driveOnce()
235
+ } catch (e) {
236
+ // 登录态配对(#664 余尾,2026-10-15):needs-login 且调用方授权等待时,窗口停在官方
237
+ // 登录页等用户本人完成登录(cookie 只读名字),随后原窗重驾一次拿登录态真实价。
238
+ if (e instanceof HotelFlowAbort && e.result.verdict === 'needs-login' && q.interactiveLoginMs && q.interactiveLoginMs > 0) {
239
+ const loggedIn = await withDeadline(page.waitForHumanLogin(q.interactiveLoginMs), q.interactiveLoginMs + 5_000, '等待人工登录').catch(() => false)
240
+ if (!loggedIn) {
241
+ return fail(`登录等待超时(${q.interactiveLoginMs}ms):用户未在 GUI 窗口完成登录——可稍后带 interactiveLogin 重试,或登录后走 bridge 车道`, 'needs-login')
242
+ }
243
+ humanLoggedIn = true
244
+ api = null
245
+ try {
246
+ await driveOnce()
247
+ } catch (e2) {
248
+ if (e2 instanceof HotelFlowAbort) {
249
+ return e2.result.error === '__NEEDS_LOGIN__'
250
+ ? fail(needsLoginMessage(), 'needs-login')
251
+ : e2.result
252
+ }
253
+ return fail((e2 as Error).message.slice(0, 400))
254
+ }
255
+ } else if (e instanceof HotelFlowAbort) {
256
+ return e.result.error === '__NEEDS_LOGIN__'
257
+ ? fail(needsLoginMessage(), 'needs-login')
258
+ : e.result
259
+ } else {
260
+ return fail((e as Error).message.slice(0, 400))
261
+ }
262
+ } finally {
263
+ await driver.close().catch(() => {})
264
+ }
265
+ try {
266
+ listUrl = await page.currentUrl().catch(() => '')
267
+ } catch { /* 已关窗口 */ }
268
+
269
+ const captured = api as { url: string; body: string } | null
270
+ if (captured === null) return fail('内部不变量失败:api 未结算(awaitApi 已过)')
271
+ const classified = classifyCtripHotelList(captured.body)
272
+ if (classified.shape === 'unrecognized') {
273
+ return fail(`酒店列表回包形状未识别(body头:${captured.body.replace(/\s+/g, ' ').slice(0, 240)})`)
274
+ }
275
+ if (classified.shape === 'malformed') {
276
+ return fail('酒店列表回包非 JSON(接口改版?)')
277
+ }
278
+ const hotels = classified.hotels
279
+ const verdict: GuiHotelVerdict = hotels.length > 0 ? 'hit' : 'miss'
280
+ return {
281
+ ok: verdict === 'hit', via: 'session-ctrip-hotel-gui', latencyMs: Date.now() - started, verdict,
282
+ ...(hotels.length > 0 ? { hotels: hotels.slice(0, 20) } : {}),
283
+ ...(listUrl ? { listUrl } : {}),
284
+ evidence: `[会话:ctrip-hotel-gui@${ts}] GUI 面检索:${hotels.length} 家(shape=${classified.shape};${humanLoggedIn ? '用户本人已在本窗口官方页登录,登录态真实价' : '匿名公开价,非登录态价'})`,
285
+ anonymous: !humanLoggedIn,
286
+ }
287
+ }
288
+
289
+ async function withDeadline<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
290
+ let timer: NodeJS.Timeout | undefined
291
+ try {
292
+ return await Promise.race([
293
+ p,
294
+ new Promise<never>((_, reject) => {
295
+ timer = setTimeout(() => reject(new Error(`${what}:${ms}ms`)), ms)
296
+ }),
297
+ ])
298
+ } finally {
299
+ if (timer) clearTimeout(timer)
300
+ }
301
+ }
302
+
303
+ // ── 生产 driver:与机票车道同策略(隔离临时 profile headful Chrome) ──
304
+
305
+ const HOTEL_FORM_TEXT = `(() => Array.from(document.querySelectorAll('input'))
306
+ .map(e => [e.value, e.placeholder].filter(Boolean).join('¤')).join(' | '))()`
307
+ const HOTEL_DEST_VALUE = `(() => { const els = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
308
+ const hit = els.find(e => /目的地|城市|地点/.test(e.placeholder || '') && !/搜索/.test(e.placeholder || ''))
309
+ || els.find(e => /目的地/.test(((e.parentElement && e.parentElement.innerText) || '').slice(0, 60)))
310
+ return hit ? (hit.value || '') : '' })()`
311
+ const HOTEL_DATE_IDX = (which: 'in' | 'out'): string => `(() => { const els = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
312
+ const hits = els.map((e, i) => ({ i, ph: e.placeholder || '', v: e.value || '' }))
313
+ .filter(x => /入住|退房|离店|日期|date/i.test(x.ph) || /^\\d{4}-\\d{2}-\\d{2}/.test(x.v))
314
+ const out = ${which === 'out'}
315
+ if (hits.length >= (out ? 2 : 1)) return hits[out ? 1 : 0].i
316
+ return els.length > 1 ? (out ? Math.min(2, els.length - 1) : 1) : 0 })()`
317
+ const HOTEL_CLICK_CELL = (ymd: string): string => {
318
+ const day = String(Number(ymd.slice(8, 10)))
319
+ const ymCn = `${ymd.slice(0, 4)}年${Number(ymd.slice(5, 7))}月`
320
+ return `(() => {
321
+ const all = Array.from(document.querySelectorAll('*'))
322
+ const dayLeaves = all.filter(e => e.children.length === 0 && (e.textContent || '').trim() === '${day}')
323
+ const clickEl = (e) => {
324
+ const target = e.parentElement && /day|cell|date/.test(String(e.parentElement.className)) ? e.parentElement : e
325
+ target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
326
+ target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
327
+ target.click()
328
+ return true }
329
+ const ariaHit = dayLeaves.find(e =>
330
+ ((e.getAttribute('aria-label') || '') + (e.getAttribute('title') || '')).includes('${ymd}'))
331
+ if (ariaHit) return clickEl(ariaHit)
332
+ const enabled = dayLeaves.filter(e => !/disabled|prev|next/.test(String(e.parentElement && e.parentElement.className)))
333
+ if (enabled.length === 1) return clickEl(enabled[0])
334
+ const norm = (s) => s.replace(/\\s+/g, '')
335
+ let inTargetMonth = false
336
+ for (const e of all) {
337
+ const ownText = norm(Array.from(e.childNodes).filter(n => n.nodeType === 3).map(n => n.textContent).join(''))
338
+ if (/^\\d{4}年\\d{1,2}月$/.test(ownText)) { inTargetMonth = ownText === norm('${ymCn}'); continue }
339
+ if (inTargetMonth && e.children.length === 0 && (e.textContent || '').trim() === '${day}') return clickEl(e)
340
+ }
341
+ return false })()`
342
+ }
343
+ const HOTEL_CLICK_SEARCH = `(() => {
344
+ const els = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
345
+ const anchor = els.find(e => /入住|退房|离店|日期|date/i.test(e.placeholder || '') || /^\\d{4}-\\d{2}-\\d{2}/.test(e.value || ''))
346
+ if (!anchor) throw new Error('搜索按钮定位失败:日期框不存在')
347
+ const click = (el) => {
348
+ let target = el
349
+ for (let k = 0; k < 3 && target.parentElement; k += 1) {
350
+ const p = target.parentElement
351
+ if (getComputedStyle(p).cursor === 'pointer' || p.tagName === 'BUTTON' || p.hasAttribute('role')) { target = p; break }
352
+ }
353
+ target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
354
+ target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
355
+ target.click()
356
+ return true }
357
+ let node = anchor
358
+ for (let k = 0; k < 8 && node; k += 1) {
359
+ node = node.parentElement
360
+ if (!node) break
361
+ const hit = Array.from(node.querySelectorAll('*'))
362
+ .find(e => e.children.length === 0 && (e.textContent || '').trim() === '搜索' && e.offsetParent)
363
+ if (hit) return click(hit)
364
+ }
365
+ const rect = anchor.getBoundingClientRect()
366
+ let best = null, bestDist = 1e9
367
+ for (const e of Array.from(document.querySelectorAll('*'))) {
368
+ if (e === anchor || !e.offsetParent || e.contains(anchor)) continue
369
+ const r = e.getBoundingClientRect()
370
+ if (r.width < 50 || r.width > 320 || r.height < 36) continue
371
+ if (getComputedStyle(e).cursor !== 'pointer') continue
372
+ const cx = r.left + r.width / 2, cy = r.top + r.height / 2
373
+ if (cx < anchor.getBoundingClientRect().right - 40 || cx > anchor.getBoundingClientRect().right + 500) continue
374
+ if (cy < rect.top - 80 || cy > rect.bottom + 160) continue
375
+ const d = Math.abs(cy - (rect.top + rect.height / 2))
376
+ if (d < bestDist) { best = e; bestDist = d }
377
+ }
378
+ if (best) return click(best)
379
+ throw new Error('搜索按钮未找到') })()`
380
+
381
+ async function createHotelPlaywrightDriver(): Promise<GuiHotelDriver> {
382
+ const { chromium } = await import('playwright-core')
383
+ const profile = mkdtempSync(join(tmpdir(), 'gotry-gui-hotel-'))
384
+ const ctx = await chromium.launchPersistentContext(profile, {
385
+ channel: 'chrome',
386
+ headless: false,
387
+ viewport: { width: 1480, height: 950 },
388
+ })
389
+ const page: Page = ctx.pages()[0] ?? (await ctx.newPage())
390
+ let waiter: ((v: { url: string; body: string } | null) => void) | null = null
391
+ const recentUrls: string[] = []
392
+ page.on('request', r => {
393
+ const u = r.url()
394
+ if (/api|search|restapi|soa2|hotel|list/i.test(u) && !/\.(js|css|png|jpg|svg|woff)/.test(u)) {
395
+ recentUrls.push(u.slice(0, 140))
396
+ if (recentUrls.length > 30) recentUrls.shift()
397
+ }
398
+ })
399
+ page.on('response', async r => {
400
+ if (!waiter) return
401
+ const url = r.url()
402
+ if (!HOTEL_NETWORK_HINTS.some((re: RegExp) => re.test(url))) return
403
+ try {
404
+ const body = await r.text()
405
+ if (!looksLikeHotelListBody(body)) return
406
+ const resolve = waiter
407
+ waiter = null
408
+ resolve?.({ url, body })
409
+ } catch { /* body gone */ }
410
+ })
411
+ return {
412
+ async open(): Promise<GuiHotelPage> {
413
+ const sleep = (ms: number): Promise<void> => page.waitForTimeout(ms)
414
+ return {
415
+ async goto(url) {
416
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 40_000 })
417
+ await page.locator('input:visible').first().waitFor({ state: 'visible', timeout: 30_000 })
418
+ await sleep(2500)
419
+ },
420
+ async typeCity(text) {
421
+ const direct = page.locator('input[placeholder*="目的地"]:visible, input[placeholder*="城市"]:visible').first()
422
+ const fallback = page.locator('input:visible').first()
423
+ const use = (await direct.count()) > 0 ? direct : fallback
424
+ await use.click({ timeout: 6000 })
425
+ await use.fill('', { timeout: 3000 }).catch(() => {})
426
+ await use.pressSequentially(text, { delay: 160 })
427
+ },
428
+ async pickSuggest(text) {
429
+ await sleep(1600)
430
+ await page.keyboard.press('ArrowDown')
431
+ await sleep(150)
432
+ await page.keyboard.press('Enter')
433
+ await sleep(700)
434
+ const v = await page.evaluate<string>(HOTEL_DEST_VALUE).catch(() => '')
435
+ if (!v.includes(text)) {
436
+ await page.keyboard.press('ArrowDown')
437
+ await sleep(150)
438
+ await page.keyboard.press('Enter')
439
+ await sleep(700)
440
+ }
441
+ },
442
+ async setDate(which, ymd) {
443
+ const idx = await page.evaluate<number>(HOTEL_DATE_IDX(which))
444
+ const input = page.locator('input:visible').nth(idx)
445
+ await input.click({ timeout: 5000 })
446
+ await sleep(600)
447
+ const typed = await input.evaluate((el: HTMLInputElement, v: string) => {
448
+ if (el.readOnly) return false
449
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
450
+ setter?.call(el, v)
451
+ el.dispatchEvent(new Event('input', { bubbles: true }))
452
+ el.dispatchEvent(new Event('change', { bubbles: true }))
453
+ return el.value === v
454
+ }, ymd).catch(() => false)
455
+ if (typed) { await sleep(400); return }
456
+ const clicked = await page.evaluate<boolean>(HOTEL_CLICK_CELL(ymd)).catch(() => false)
457
+ if (!clicked) throw new Error(`日期 ${ymd} 未能设置——readback 将兜底`)
458
+ await sleep(400)
459
+ },
460
+ async press(key) { await page.keyboard.press(key) },
461
+ async clickSearch() {
462
+ await page.keyboard.press('Escape').catch(() => {})
463
+ await sleep(500)
464
+ await page.evaluate<void>(HOTEL_CLICK_SEARCH)
465
+ },
466
+ async visibleFormText() {
467
+ return String(await page.evaluate<string>(HOTEL_FORM_TEXT).catch(() => ''))
468
+ },
469
+ async destFieldValue() {
470
+ return String(await page.evaluate<string>(HOTEL_DEST_VALUE).catch(() => ''))
471
+ },
472
+ async pageText() {
473
+ const title = await page.title().catch(() => '')
474
+ const text = String(await page.evaluate<string>('document.body.innerText.slice(0, 3000)').catch(() => ''))
475
+ return title + text
476
+ },
477
+ async currentUrl() { return page.url() },
478
+ async waitForHumanLogin(timeoutMs: number) {
479
+ // 红线:人自己完成登录;只轮询票据 cookie 名字(值即弃)。窗口此刻通常已在 passport 页。
480
+ const deadline = Date.now() + timeoutMs
481
+ while (Date.now() < deadline) {
482
+ const names = await ctx.cookies().then(cs => cs.map(c => c.name)).catch(() => [] as string[])
483
+ if (names.some(n => TICKET_COOKIE_NAMES.includes(n))) return true
484
+ await page.waitForTimeout(5_000)
485
+ }
486
+ return false
487
+ },
488
+ async recentApiUrls() { return [...recentUrls] },
489
+ async waitForApiBody(timeoutMs) {
490
+ return new Promise(resolve => {
491
+ const timer = setTimeout(() => {
492
+ waiter = null
493
+ resolve(null)
494
+ }, timeoutMs)
495
+ waiter = v => { clearTimeout(timer); resolve(v) }
496
+ })
497
+ },
498
+ }
499
+ },
500
+ async close() {
501
+ await ctx.close().catch(() => {})
502
+ rmSync(profile, { recursive: true, force: true })
503
+ },
504
+ }
505
+ }