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

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 (54) 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/ctrip-city.js +186 -0
  17. package/dist/capabilities/session/extension-bridge.js +8 -1
  18. package/dist/capabilities/session/gui-flight.js +737 -0
  19. package/dist/capabilities/session/gui-hotel.js +413 -0
  20. package/dist/capabilities/session-search.js +98 -11
  21. package/dist/scripts/bootstrap-tests.js +23 -6
  22. package/dist/scripts/ctrip-city-tests.js +80 -0
  23. package/dist/scripts/effect-tests.js +602 -504
  24. package/dist/scripts/extension-tests.js +14 -8
  25. package/dist/scripts/flight-malformed-tests.js +1 -1
  26. package/dist/scripts/flyai-setup-tests.js +19 -4
  27. package/dist/scripts/flyai-tests.js +768 -653
  28. package/dist/scripts/gotry-web-api-tests.js +5 -0
  29. package/dist/scripts/gotry-web-client-tests.js +20 -1
  30. package/dist/scripts/gui-flight-live.js +27 -0
  31. package/dist/scripts/hbcli-tests.js +1 -1
  32. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  33. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  34. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  35. package/dist/scripts/hotel-result-tests.js +169 -0
  36. package/dist/scripts/issue-271-liveness-tests.js +1 -1
  37. package/dist/scripts/session-tests.js +177 -6
  38. package/dist/src/gotry-web-api.js +64 -2
  39. package/dist/src/index.js +40 -18
  40. package/extension/manifest.json +2 -2
  41. package/package.json +2 -1
  42. package/ts/capabilities/flyai-config.ts +12 -10
  43. package/ts/capabilities/flyai.ts +91 -50
  44. package/ts/capabilities/hbcli-config.ts +128 -0
  45. package/ts/capabilities/hbcli.ts +30 -3
  46. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  47. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  48. package/ts/capabilities/session/ctrip-city.ts +158 -0
  49. package/ts/capabilities/session/extension-bridge.ts +9 -2
  50. package/ts/capabilities/session/gui-flight.ts +792 -0
  51. package/ts/capabilities/session/gui-hotel.ts +451 -0
  52. package/ts/capabilities/session-search.ts +115 -15
  53. package/ts/src/gotry-web-api.ts +51 -2
  54. package/ts/src/index.ts +41 -19
@@ -0,0 +1,451 @@
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。
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
+
23
+ export interface GuiHotelQuery {
24
+ /** 目的地中文(站点自动补全解析,码表不在主链路) */
25
+ to: string
26
+ /** 入住 YYYY-MM-DD */
27
+ checkIn: string
28
+ /** 退房 YYYY-MM-DD(须晚于入住) */
29
+ checkOut: string
30
+ timeoutMs?: number
31
+ }
32
+
33
+ export type GuiHotelAction =
34
+ | { t: 'goto'; url: string }
35
+ | { t: 'typeCity'; text: string }
36
+ | { t: 'pickSuggest'; text: string }
37
+ | { t: 'setDate'; which: 'in' | 'out'; ymd: string }
38
+ | { t: 'press'; key: string }
39
+ | { t: 'readback'; expect: { to: string; checkIn: string; checkOut: string } }
40
+ | { t: 'clickSearch' }
41
+ | { t: 'awaitApi' }
42
+
43
+ /** 纯函数:查询 → 动作序列(离线测试锚点) */
44
+ export function planHotelGuiActions(q: GuiHotelQuery): GuiHotelAction[] {
45
+ return [
46
+ { t: 'goto', url: HOTEL_GUI_HOME },
47
+ { t: 'typeCity', text: q.to },
48
+ { t: 'pickSuggest', text: q.to },
49
+ { t: 'setDate', which: 'in', ymd: q.checkIn },
50
+ { t: 'setDate', which: 'out', ymd: q.checkOut },
51
+ { t: 'press', key: 'Escape' },
52
+ { t: 'readback', expect: { to: q.to, checkIn: q.checkIn, checkOut: q.checkOut } },
53
+ { t: 'clickSearch' },
54
+ { t: 'awaitApi' },
55
+ ]
56
+ }
57
+
58
+ /** 安全白名单(与机票车道同款纪律) */
59
+ export function assertHotelPlanSafe(plan: GuiHotelAction[]): { ok: true } | { ok: false; error: string } {
60
+ for (const a of plan) {
61
+ switch (a.t) {
62
+ case 'goto':
63
+ if (a.url !== HOTEL_GUI_HOME) return { ok: false, error: `GUI 车道只允许固定入口 ${HOTEL_GUI_HOME}` }
64
+ break
65
+ case 'typeCity':
66
+ case 'pickSuggest': {
67
+ const text = a.text.trim()
68
+ if (!text || text.length > 24) return { ok: false, error: `目的地文本非法:${text.slice(0, 30)}` }
69
+ if (isSubmitText(text)) return { ok: false, error: `目的地位提交件文本,拒绝:${text}` }
70
+ break
71
+ }
72
+ case 'setDate':
73
+ if (!YMD.test(a.ymd)) return { ok: false, error: `日期需 YYYY-MM-DD,实际 ${a.ymd}` }
74
+ break
75
+ case 'press':
76
+ if (a.key !== 'Escape' && a.key !== 'Enter') return { ok: false, error: `按键白名单外:${a.key}` }
77
+ break
78
+ case 'clickSearch':
79
+ case 'awaitApi':
80
+ break
81
+ case 'readback':
82
+ for (const v of [a.expect.to, a.expect.checkIn, a.expect.checkOut]) {
83
+ if (!v.trim() || v.length > 24) return { ok: false, error: `readback 期望值非法:${v}` }
84
+ }
85
+ break
86
+ default:
87
+ return { ok: false, error: `动作闭集外:${JSON.stringify(a).slice(0, 60)}` }
88
+ }
89
+ }
90
+ return { ok: true }
91
+ }
92
+
93
+ export interface GuiHotelPage {
94
+ goto(url: string): Promise<void>
95
+ typeCity(text: string): Promise<void>
96
+ pickSuggest(text: string): Promise<void>
97
+ setDate(which: 'in' | 'out', ymd: string): Promise<void>
98
+ press(key: string): Promise<void>
99
+ clickSearch(): Promise<void>
100
+ visibleFormText(): Promise<string>
101
+ destFieldValue(): Promise<string>
102
+ pageText(): Promise<string>
103
+ /** 当前页 URL(list 页真实形状回传) */
104
+ currentUrl(): Promise<string>
105
+ waitForApiBody(timeoutMs: number): Promise<{ url: string; body: string } | null>
106
+ recentApiUrls?(): Promise<string[]>
107
+ }
108
+
109
+ export interface GuiHotelDriver {
110
+ open(): Promise<GuiHotelPage>
111
+ close(): Promise<void>
112
+ }
113
+
114
+ export type GuiHotelVerdict = 'hit' | 'miss' | 'error' | 'challenged' | 'needs-login'
115
+
116
+ export interface GuiHotelResult {
117
+ ok: boolean
118
+ via: 'session-ctrip-hotel-gui' | 'session-ctrip-hotel-gui-error'
119
+ evidence: string
120
+ latencyMs: number
121
+ verdict: GuiHotelVerdict
122
+ hotels?: SessionHotelOption[]
123
+ error?: string
124
+ anonymous: true
125
+ /** 检索后站点落到的 list 页 URL(真实形状回传) */
126
+ listUrl?: string
127
+ }
128
+
129
+ let hotelDriverFactory: (() => GuiHotelDriver) | null = null
130
+ export function __setHotelDriverForTest(factory: (() => GuiHotelDriver) | null): void {
131
+ hotelDriverFactory = factory
132
+ }
133
+
134
+ export async function runHotelGuiSearch(q: GuiHotelQuery): Promise<GuiHotelResult> {
135
+ const started = Date.now()
136
+ const ts = new Date().toISOString()
137
+ const fail = (error: string, verdict: GuiHotelVerdict = 'error'): GuiHotelResult => ({
138
+ ok: false, via: 'session-ctrip-hotel-gui-error', latencyMs: Date.now() - started, verdict, error,
139
+ evidence: `[会话:ctrip-hotel-gui@error@${ts}] ${error.slice(0, 120)}`, anonymous: true,
140
+ })
141
+
142
+ if (!(q.checkIn < q.checkOut)) return fail(`退房日期(${q.checkOut})须晚于入住(${q.checkIn})`)
143
+
144
+ const plan = planHotelGuiActions(q)
145
+ const safe = assertHotelPlanSafe(plan)
146
+ if (!safe.ok) return fail(`GUI 计划被安全闸拒绝:${safe.error}`)
147
+
148
+ const budget = q.timeoutMs ?? 90_000
149
+ const driver = hotelDriverFactory ? hotelDriverFactory() : await createHotelPlaywrightDriver()
150
+ let page: GuiHotelPage
151
+ try {
152
+ page = await withDeadline(driver.open(), Math.min(budget, 30_000), 'GUI 浏览器窗口启动失败/超时(本机需已装 Chrome)')
153
+ } catch (e) {
154
+ await driver.close().catch(() => {})
155
+ return fail((e as Error).message.slice(0, 400))
156
+ }
157
+
158
+ let api: { url: string; body: string } | null = null
159
+ let listUrl = ''
160
+ try {
161
+ for (const a of plan) {
162
+ const remain = budget - (Date.now() - started)
163
+ if (remain <= 0) return fail(`GUI 预算耗尽(${budget}ms)`)
164
+ const step = <T,>(p: Promise<T>, what: string): Promise<T> =>
165
+ withDeadline(p, Math.min(remain, 20_000), `GUI 步骤失败(${what})`)
166
+ switch (a.t) {
167
+ case 'goto': await step(page.goto(a.url), '打开携程酒店首页'); break
168
+ case 'typeCity': await step(page.typeCity(a.text), '填写目的地'); break
169
+ case 'pickSuggest': await step(page.pickSuggest(a.text), '选择目的地补全'); break
170
+ case 'setDate': await step(page.setDate(a.which, a.ymd), `设置${a.which === 'in' ? '入住' : '退房'}日期`); break
171
+ case 'press': await step(page.press(a.key), `按键 ${a.key}`); break
172
+ case 'clickSearch':
173
+ await step(page.clickSearch(), '点击搜索')
174
+ // live 校准(2026-10-14):hotels list 对匿名窗口强制跳 passport 登录——
175
+ // 诚实判 needs-login(不绕过、不重试),登录态走 bridge 车道。
176
+ {
177
+ const after = await page.currentUrl().catch(() => '')
178
+ if (/passport\.ctrip\.com\/.*login/i.test(after)) {
179
+ return fail('酒店列表需登录态:匿名窗口被重定向到携程登录页(红线:不自动登录)——gotry_session_login 为用户打开官方登录入口后走 bridge 车道(ui 默认),或登录后重试', 'needs-login')
180
+ }
181
+ }
182
+ break
183
+ case 'readback': {
184
+ const dest = await step(page.destFieldValue(), '读回目的地框')
185
+ const form = await step(page.visibleFormText(), '读回表单')
186
+ if (!dest.includes(a.expect.to)) {
187
+ return fail(`readback 硬闸:目的地框未确认「${a.expect.to}」(实际「${dest.slice(0, 30)}」)——已终止,未发起搜索`)
188
+ }
189
+ // live 校准:酒店表单日期显示为本地化格式(如 11月13日(周五))而非 YMD——双格式核对
190
+ const dateShown = (ymd: string): boolean => {
191
+ const cn = `${Number(ymd.slice(5, 7))}月${Number(ymd.slice(8, 10))}日`
192
+ return form.includes(ymd) || form.includes(cn)
193
+ }
194
+ for (const [label, value] of [['入住日期', a.expect.checkIn], ['退房日期', a.expect.checkOut]] as const) {
195
+ if (!dateShown(value)) {
196
+ return fail(`readback 硬闸:${label}「${value}」未在表单确认(表单摘要:${form.slice(0, 200)})——已终止,未发起搜索`)
197
+ }
198
+ }
199
+ break
200
+ }
201
+ case 'awaitApi': {
202
+ const text = await step(page.pageText(), '检查页面')
203
+ if (GUI_CHALLENGE_RE.test(text)) {
204
+ return fail('风控/验证码命中;按红线不重试不绕过,交还用户', 'challenged')
205
+ }
206
+ api = await step(page.waitForApiBody(Math.min(remain - 500, 18_000)), '等待酒店列表回包')
207
+ if (!api) {
208
+ const recent = page.recentApiUrls ? (await page.recentApiUrls().catch(() => [])) : []
209
+ const nowUrl = await page.currentUrl().catch(() => '?')
210
+ if (/passport\.ctrip\.com\/.*login/i.test(nowUrl)) {
211
+ return fail('酒店列表需登录态:匿名窗口被重定向到携程登录页(红线:不自动登录)——gotry_session_login 为用户打开官方登录入口后走 bridge 车道,或登录后重试', 'needs-login')
212
+ }
213
+ return fail(`酒店列表回包嗅探超时(href=${nowUrl.slice(0, 120)})${recent.length ? ` ‖recent-apis: ${recent.slice(-8).join(' ; ').slice(0, 500)}` : ''}`)
214
+ }
215
+ break
216
+ }
217
+ }
218
+ }
219
+ listUrl = await page.currentUrl().catch(() => '')
220
+ } catch (e) {
221
+ return fail((e as Error).message.slice(0, 400))
222
+ } finally {
223
+ await driver.close().catch(() => {})
224
+ }
225
+
226
+ if (!api) return fail('内部不变量失败:api 未结算(awaitApi 已过)')
227
+ const classified = classifyCtripHotelList(api.body)
228
+ if (classified.shape === 'unrecognized') {
229
+ return fail(`酒店列表回包形状未识别(body头:${api.body.replace(/\s+/g, ' ').slice(0, 240)})`)
230
+ }
231
+ if (classified.shape === 'malformed') {
232
+ return fail('酒店列表回包非 JSON(接口改版?)')
233
+ }
234
+ const hotels = classified.hotels
235
+ const verdict: GuiHotelVerdict = hotels.length > 0 ? 'hit' : 'miss'
236
+ return {
237
+ ok: verdict === 'hit', via: 'session-ctrip-hotel-gui', latencyMs: Date.now() - started, verdict,
238
+ ...(hotels.length > 0 ? { hotels: hotels.slice(0, 20) } : {}),
239
+ ...(listUrl ? { listUrl } : {}),
240
+ evidence: `[会话:ctrip-hotel-gui@${ts}] 匿名 GUI 面检索:${hotels.length} 家(shape=${classified.shape};匿名公开价,非登录态价)`,
241
+ anonymous: true,
242
+ }
243
+ }
244
+
245
+ async function withDeadline<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
246
+ let timer: NodeJS.Timeout | undefined
247
+ try {
248
+ return await Promise.race([
249
+ p,
250
+ new Promise<never>((_, reject) => {
251
+ timer = setTimeout(() => reject(new Error(`${what}:${ms}ms`)), ms)
252
+ }),
253
+ ])
254
+ } finally {
255
+ if (timer) clearTimeout(timer)
256
+ }
257
+ }
258
+
259
+ // ── 生产 driver:与机票车道同策略(隔离临时 profile headful Chrome) ──
260
+
261
+ const HOTEL_FORM_TEXT = `(() => Array.from(document.querySelectorAll('input'))
262
+ .map(e => [e.value, e.placeholder].filter(Boolean).join('¤')).join(' | '))()`
263
+ const HOTEL_DEST_VALUE = `(() => { const els = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
264
+ const hit = els.find(e => /目的地|城市|地点/.test(e.placeholder || '') && !/搜索/.test(e.placeholder || ''))
265
+ || els.find(e => /目的地/.test(((e.parentElement && e.parentElement.innerText) || '').slice(0, 60)))
266
+ return hit ? (hit.value || '') : '' })()`
267
+ const HOTEL_DATE_IDX = (which: 'in' | 'out'): string => `(() => { const els = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
268
+ const hits = els.map((e, i) => ({ i, ph: e.placeholder || '', v: e.value || '' }))
269
+ .filter(x => /入住|退房|离店|日期|date/i.test(x.ph) || /^\\d{4}-\\d{2}-\\d{2}/.test(x.v))
270
+ const out = ${which === 'out'}
271
+ if (hits.length >= (out ? 2 : 1)) return hits[out ? 1 : 0].i
272
+ return els.length > 1 ? (out ? Math.min(2, els.length - 1) : 1) : 0 })()`
273
+ const HOTEL_CLICK_CELL = (ymd: string): string => {
274
+ const day = String(Number(ymd.slice(8, 10)))
275
+ const ymCn = `${ymd.slice(0, 4)}年${Number(ymd.slice(5, 7))}月`
276
+ return `(() => {
277
+ const all = Array.from(document.querySelectorAll('*'))
278
+ const dayLeaves = all.filter(e => e.children.length === 0 && (e.textContent || '').trim() === '${day}')
279
+ const clickEl = (e) => {
280
+ const target = e.parentElement && /day|cell|date/.test(String(e.parentElement.className)) ? e.parentElement : e
281
+ target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
282
+ target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
283
+ target.click()
284
+ return true }
285
+ const ariaHit = dayLeaves.find(e =>
286
+ ((e.getAttribute('aria-label') || '') + (e.getAttribute('title') || '')).includes('${ymd}'))
287
+ if (ariaHit) return clickEl(ariaHit)
288
+ const enabled = dayLeaves.filter(e => !/disabled|prev|next/.test(String(e.parentElement && e.parentElement.className)))
289
+ if (enabled.length === 1) return clickEl(enabled[0])
290
+ const norm = (s) => s.replace(/\\s+/g, '')
291
+ let inTargetMonth = false
292
+ for (const e of all) {
293
+ const ownText = norm(Array.from(e.childNodes).filter(n => n.nodeType === 3).map(n => n.textContent).join(''))
294
+ if (/^\\d{4}年\\d{1,2}月$/.test(ownText)) { inTargetMonth = ownText === norm('${ymCn}'); continue }
295
+ if (inTargetMonth && e.children.length === 0 && (e.textContent || '').trim() === '${day}') return clickEl(e)
296
+ }
297
+ return false })()`
298
+ }
299
+ const HOTEL_CLICK_SEARCH = `(() => {
300
+ const els = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
301
+ const anchor = els.find(e => /入住|退房|离店|日期|date/i.test(e.placeholder || '') || /^\\d{4}-\\d{2}-\\d{2}/.test(e.value || ''))
302
+ if (!anchor) throw new Error('搜索按钮定位失败:日期框不存在')
303
+ const click = (el) => {
304
+ let target = el
305
+ for (let k = 0; k < 3 && target.parentElement; k += 1) {
306
+ const p = target.parentElement
307
+ if (getComputedStyle(p).cursor === 'pointer' || p.tagName === 'BUTTON' || p.hasAttribute('role')) { target = p; break }
308
+ }
309
+ target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
310
+ target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
311
+ target.click()
312
+ return true }
313
+ let node = anchor
314
+ for (let k = 0; k < 8 && node; k += 1) {
315
+ node = node.parentElement
316
+ if (!node) break
317
+ const hit = Array.from(node.querySelectorAll('*'))
318
+ .find(e => e.children.length === 0 && (e.textContent || '').trim() === '搜索' && e.offsetParent)
319
+ if (hit) return click(hit)
320
+ }
321
+ const rect = anchor.getBoundingClientRect()
322
+ let best = null, bestDist = 1e9
323
+ for (const e of Array.from(document.querySelectorAll('*'))) {
324
+ if (e === anchor || !e.offsetParent || e.contains(anchor)) continue
325
+ const r = e.getBoundingClientRect()
326
+ if (r.width < 50 || r.width > 320 || r.height < 36) continue
327
+ if (getComputedStyle(e).cursor !== 'pointer') continue
328
+ const cx = r.left + r.width / 2, cy = r.top + r.height / 2
329
+ if (cx < anchor.getBoundingClientRect().right - 40 || cx > anchor.getBoundingClientRect().right + 500) continue
330
+ if (cy < rect.top - 80 || cy > rect.bottom + 160) continue
331
+ const d = Math.abs(cy - (rect.top + rect.height / 2))
332
+ if (d < bestDist) { best = e; bestDist = d }
333
+ }
334
+ if (best) return click(best)
335
+ throw new Error('搜索按钮未找到') })()`
336
+
337
+ async function createHotelPlaywrightDriver(): Promise<GuiHotelDriver> {
338
+ const { chromium } = await import('playwright-core')
339
+ const profile = mkdtempSync(join(tmpdir(), 'gotry-gui-hotel-'))
340
+ const ctx = await chromium.launchPersistentContext(profile, {
341
+ channel: 'chrome',
342
+ headless: false,
343
+ viewport: { width: 1480, height: 950 },
344
+ })
345
+ const page: Page = ctx.pages()[0] ?? (await ctx.newPage())
346
+ let waiter: ((v: { url: string; body: string } | null) => void) | null = null
347
+ const recentUrls: string[] = []
348
+ page.on('request', r => {
349
+ const u = r.url()
350
+ if (/api|search|restapi|soa2|hotel|list/i.test(u) && !/\.(js|css|png|jpg|svg|woff)/.test(u)) {
351
+ recentUrls.push(u.slice(0, 140))
352
+ if (recentUrls.length > 30) recentUrls.shift()
353
+ }
354
+ })
355
+ page.on('response', async r => {
356
+ if (!waiter) return
357
+ const url = r.url()
358
+ if (!HOTEL_NETWORK_HINTS.some((re: RegExp) => re.test(url))) return
359
+ try {
360
+ const body = await r.text()
361
+ if (!looksLikeHotelListBody(body)) return
362
+ const resolve = waiter
363
+ waiter = null
364
+ resolve?.({ url, body })
365
+ } catch { /* body gone */ }
366
+ })
367
+ return {
368
+ async open(): Promise<GuiHotelPage> {
369
+ const sleep = (ms: number): Promise<void> => page.waitForTimeout(ms)
370
+ return {
371
+ async goto(url) {
372
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 40_000 })
373
+ await page.locator('input:visible').first().waitFor({ state: 'visible', timeout: 30_000 })
374
+ await sleep(2500)
375
+ },
376
+ async typeCity(text) {
377
+ const direct = page.locator('input[placeholder*="目的地"]:visible, input[placeholder*="城市"]:visible').first()
378
+ const fallback = page.locator('input:visible').first()
379
+ const use = (await direct.count()) > 0 ? direct : fallback
380
+ await use.click({ timeout: 6000 })
381
+ await use.fill('', { timeout: 3000 }).catch(() => {})
382
+ await use.pressSequentially(text, { delay: 160 })
383
+ },
384
+ async pickSuggest(text) {
385
+ await sleep(1600)
386
+ await page.keyboard.press('ArrowDown')
387
+ await sleep(150)
388
+ await page.keyboard.press('Enter')
389
+ await sleep(700)
390
+ const v = await page.evaluate<string>(HOTEL_DEST_VALUE).catch(() => '')
391
+ if (!v.includes(text)) {
392
+ await page.keyboard.press('ArrowDown')
393
+ await sleep(150)
394
+ await page.keyboard.press('Enter')
395
+ await sleep(700)
396
+ }
397
+ },
398
+ async setDate(which, ymd) {
399
+ const idx = await page.evaluate<number>(HOTEL_DATE_IDX(which))
400
+ const input = page.locator('input:visible').nth(idx)
401
+ await input.click({ timeout: 5000 })
402
+ await sleep(600)
403
+ const typed = await input.evaluate((el: HTMLInputElement, v: string) => {
404
+ if (el.readOnly) return false
405
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
406
+ setter?.call(el, v)
407
+ el.dispatchEvent(new Event('input', { bubbles: true }))
408
+ el.dispatchEvent(new Event('change', { bubbles: true }))
409
+ return el.value === v
410
+ }, ymd).catch(() => false)
411
+ if (typed) { await sleep(400); return }
412
+ const clicked = await page.evaluate<boolean>(HOTEL_CLICK_CELL(ymd)).catch(() => false)
413
+ if (!clicked) throw new Error(`日期 ${ymd} 未能设置——readback 将兜底`)
414
+ await sleep(400)
415
+ },
416
+ async press(key) { await page.keyboard.press(key) },
417
+ async clickSearch() {
418
+ await page.keyboard.press('Escape').catch(() => {})
419
+ await sleep(500)
420
+ await page.evaluate<void>(HOTEL_CLICK_SEARCH)
421
+ },
422
+ async visibleFormText() {
423
+ return String(await page.evaluate<string>(HOTEL_FORM_TEXT).catch(() => ''))
424
+ },
425
+ async destFieldValue() {
426
+ return String(await page.evaluate<string>(HOTEL_DEST_VALUE).catch(() => ''))
427
+ },
428
+ async pageText() {
429
+ const title = await page.title().catch(() => '')
430
+ const text = String(await page.evaluate<string>('document.body.innerText.slice(0, 3000)').catch(() => ''))
431
+ return title + text
432
+ },
433
+ async currentUrl() { return page.url() },
434
+ async recentApiUrls() { return [...recentUrls] },
435
+ async waitForApiBody(timeoutMs) {
436
+ return new Promise(resolve => {
437
+ const timer = setTimeout(() => {
438
+ waiter = null
439
+ resolve(null)
440
+ }, timeoutMs)
441
+ waiter = v => { clearTimeout(timer); resolve(v) }
442
+ })
443
+ },
444
+ }
445
+ },
446
+ async close() {
447
+ await ctx.close().catch(() => {})
448
+ rmSync(profile, { recursive: true, force: true })
449
+ },
450
+ }
451
+ }
@@ -17,11 +17,13 @@ import { extensionCookieNames, extensionSearchJob, classifyBridgeFailure, needsE
17
17
  import { appendFileSync, mkdirSync } from 'node:fs'
18
18
  import { dirname } from 'node:path'
19
19
  import { randomUUID } from 'node:crypto'
20
- import { buildEntryUrl, NETWORK_HINTS, parseBatchSearchResult, LOGIN_COOKIE_NAMES, SITE_DOMAIN, type BatchSearchShape, type SessionFlightOption } from './session/adapters/ctrip-flight.ts'
21
- import { buildHotelEntryUrl, parseCtripHotelList, HOTEL_SITE_HOST, type SessionHotelOption } from './session/adapters/ctrip-hotel.ts'
20
+ import { resolveFlightEntryUrl, NETWORK_HINTS, parseBatchSearchResult, LOGIN_COOKIE_NAMES, SITE_DOMAIN, type BatchSearchShape, type SessionFlightOption } from './session/adapters/ctrip-flight.ts'
21
+ import { resolveHotelEntryUrl, classifyCtripHotelList, HOTEL_SITE_HOST, type SessionHotelOption } from './session/adapters/ctrip-hotel.ts'
22
22
  import { buildTrainEntryUrl, parseLeftTicketQueryResult, resolveTrainQueryTelecodes, validateTrainQueryResponseUrl, TRAIN_SITE_HOST, type SessionTrainOption, type TrainQueryParseOutcome, type TrainResponseBinding } from './session/adapters/rail-12306.ts'
23
23
  import { buildDidaEntryUrl, parseDidaRates, parseDidaSearchCache, parseDidaRecommendHotels, parseDidaPrices, DIDA_NETWORK_HINTS, DIDA_SITE_HOST, DIDA_SITE_DOMAIN, DIDA_LOGIN_COOKIE_NAMES, type SessionDidaRateOption } from './session/adapters/dida-portal.ts'
24
24
  import { EXTENSION_STORE_URL, type SessionJobHandle, type SniffBodies } from './session/extension-bridge.ts'
25
+ import { runFlightGuiSearch, type GuiCalendarDay } from './session/gui-flight.ts'
26
+ import { runHotelGuiSearch } from './session/gui-hotel.ts'
25
27
 
26
28
  export type SessionVerdict = 'hit' | 'miss' | 'error' | 'challenged' | 'cooldown' | 'needs-login' | 'needs-attach' | 'needs-extension'
27
29
  export type SessionFlightTransportShape = BatchSearchShape | 'sniff_timeout' | 'challenge_page' | 'precheck_unavailable' | 'needs_login' | 'search_job_unavailable'
@@ -38,7 +40,7 @@ function flightBridgeFailureSummary(kind: BridgeFailureKind): string {
38
40
 
39
41
  export interface SessionSearchResult {
40
42
  ok: boolean
41
- via: 'session-ctrip-flight' | 'session-ctrip-flight-error'
43
+ via: 'session-ctrip-flight' | 'session-ctrip-flight-error' | 'session-ctrip-flight-gui' | 'session-ctrip-flight-gui-error'
42
44
  evidence: string
43
45
  latencyMs: number
44
46
  verdict: SessionVerdict
@@ -49,13 +51,21 @@ export interface SessionSearchResult {
49
51
  /** needs-extension 时给出 Chrome Web Store URL —— dsh UI 应直接渲成可点链接(浏览器自己当安装器,gotry 不插手) */
50
52
  installUrl?: string
51
53
  installAction?: 'add-to-chrome'
54
+ /** 低价日历(GUI 车道匿名可用;#662:该航线哪些日期有票/低价) */
55
+ calendar?: GuiCalendarDay[]
56
+ /** GUI 检索后站点落到的 list 页真实 URL(#663 URL 教师) */
57
+ listUrl?: string
52
58
  }
53
59
 
54
60
  export interface SessionFlightQuery {
55
61
  from: string
56
62
  to: string
63
+ fromCityCode?: string
64
+ toCityCode?: string
57
65
  /** YYYY-MM-DD */
58
66
  date: string
67
+ /** 回程日期:存在即往返检索——bridge 车道不知往返 URL 形状(诚实边界,报错指路 GUI);GUI 车道原生支持 */
68
+ backDate?: string
59
69
  /** 隔离 profile 目录(测试必传;默认 /tmp 专用目录) */
60
70
  profileDir?: string
61
71
  headless?: boolean
@@ -143,6 +153,7 @@ export interface SessionHotelQuery {
143
153
  to: string
144
154
  /** 显式城市 id(携程/trip.com 酒店 list 页 URL 的 city= 数字;覆盖码表) */
145
155
  cityId?: number | string
156
+ country?: string
146
157
  /** YYYY-MM-DD */
147
158
  checkIn?: string
148
159
  /** YYYY-MM-DD(与 checkIn 成对) */
@@ -161,11 +172,13 @@ export interface SessionHotelQuery {
161
172
 
162
173
  export interface SessionHotelResult {
163
174
  ok: boolean
164
- via: 'session-ctrip-hotel' | 'session-ctrip-hotel-error'
175
+ via: 'session-ctrip-hotel' | 'session-ctrip-hotel-error' | 'session-ctrip-hotel-gui' | 'session-ctrip-hotel-gui-error'
165
176
  evidence: string
166
177
  latencyMs: number
167
178
  verdict: SessionVerdict
168
179
  hotels?: SessionHotelOption[]
180
+ /** GUI 检索后站点落到的 list 页真实 URL */
181
+ listUrl?: string
169
182
  error?: string
170
183
  /** needs-extension 时给出 Chrome Web Store URL(dsh UI 渲成可点链接) */
171
184
  installUrl?: string
@@ -177,6 +190,41 @@ export function hotelCityUnresolvedHint(cities: string[]): string {
177
190
  return `城市 ${cities.join('/')} 不在携程酒店城市码表——先 web 搜「hotels.ctrip.com ${cities[0] ?? ''} 酒店」拿到 list 页 URL 里的 city= 数字,带 cityId 重试`
178
191
  }
179
192
 
193
+ /** 机票城市三字码未收录时的人话指引(纯函数,测试锚点;码表只是缓存,主链路=显式码逃生口) */
194
+ export function flightCityUnresolvedHint(cities: string[]): string {
195
+ return `城市 ${cities.join('/')} 不在携程机票三字码表(缓存)——web 搜「flights.ctrip.com ${cities[0] ?? ''} 机票」从 list 页 URL 取三字码(形如 oneway-hkg-dxb),带 fromCityCode/toCityCode 重试;或改走 gotry_web_search`
196
+ }
197
+
198
+ /**
199
+ * 携程酒店 GUI 车道(2026-10-14,#664 首片):gotry 隔离匿名窗口驱动 hotels.ctrip.com
200
+ * 表单(目的地=站点自动补全,cityId 码表退出主链路),结果复用 ctrip-hotel 三态分类器。
201
+ * 节律闸与 bridge 酒店道同站同桶('ctrip-hotel');匿名公开价,恒标 anonymous。
202
+ */
203
+ export async function sessionHotelGuiSearch(q: SessionHotelQuery): Promise<SessionHotelResult> {
204
+ const started = Date.now()
205
+ const ts = new Date().toISOString()
206
+ const err = (verdict: SessionVerdict, error: string): SessionHotelResult => ({
207
+ ok: false, via: 'session-ctrip-hotel-error', evidence: `[会话:ctrip-hotel-gui@error@${ts}] ${error}`, latencyMs: Date.now() - started, verdict, error,
208
+ })
209
+
210
+ const last = lastCallAt.get('ctrip-hotel') ?? 0
211
+ if (Date.now() - last < MIN_INTERVAL_MS) {
212
+ return err('cooldown', `rate limit: last call ${Date.now() - last}ms ago, min ${MIN_INTERVAL_MS}ms`)
213
+ }
214
+ lastCallAt.set('ctrip-hotel', Date.now())
215
+
216
+ if (!q.checkIn || !q.checkOut) {
217
+ return err('error', 'GUI 车道需要 checkIn/checkOut(YYYY-MM-DD,成对)')
218
+ }
219
+ const r = await runHotelGuiSearch({ to: q.to, checkIn: q.checkIn, checkOut: q.checkOut, ...(q.timeoutMs ? { timeoutMs: q.timeoutMs } : {}) })
220
+ return {
221
+ ok: r.ok, via: r.via, evidence: r.evidence, latencyMs: r.latencyMs, verdict: r.verdict,
222
+ ...(r.hotels ? { hotels: r.hotels } : {}),
223
+ ...(r.listUrl ? { listUrl: r.listUrl } : {}),
224
+ ...(r.error ? { error: r.error } : {}),
225
+ }
226
+ }
227
+
180
228
  export async function sessionHotelSearch(q: SessionHotelQuery): Promise<SessionHotelResult> {
181
229
  const started = Date.now()
182
230
  const ts = new Date().toISOString()
@@ -202,13 +250,14 @@ export async function sessionHotelSearch(q: SessionHotelQuery): Promise<SessionH
202
250
  if (Date.now() - last < MIN_INTERVAL_MS) {
203
251
  return err('cooldown', `rate limit: last call ${Date.now() - last}ms ago, min ${MIN_INTERVAL_MS}ms`)
204
252
  }
205
- lastCallAt.set(site, Date.now())
206
-
207
- const entry = buildHotelEntryUrl({ to: q.to, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults })
253
+ const entry = await resolveHotelEntryUrl({ to: q.to, country: q.country, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults })
208
254
  if (!entry.ok || !entry.url) {
209
- return err('error', hotelCityUnresolvedHint(entry.unresolved ?? [q.to]))
255
+ return err('error', entry.error ?? hotelCityUnresolvedHint(entry.unresolved ?? [q.to]))
210
256
  }
211
257
 
258
+ const resolvedLast = lastCallAt.get(site) ?? 0
259
+ if (Date.now() - resolvedLast < MIN_INTERVAL_MS) return err('cooldown', `rate limit: min ${MIN_INTERVAL_MS}ms`)
260
+ lastCallAt.set(site, Date.now())
212
261
  const mode = resolveTransportMode(q.profileDir)
213
262
 
214
263
  if (mode === 'extension') {
@@ -239,10 +288,15 @@ export async function sessionHotelSearch(q: SessionHotelQuery): Promise<SessionH
239
288
  }
240
289
  const title = r.title
241
290
  const head = r.body.slice(0, 5000)
242
- if (CHALLENGE_RE.test(title + head)) {
291
+ if (r.challenge === true || CHALLENGE_RE.test(title + head)) {
243
292
  return err('challenged', `风控/验证码命中(title=${title.slice(0, 60)});按红线不重试不绕过,交还用户`)
244
293
  }
245
- const hotels = parseCtripHotelList(r.body)
294
+ if (r.timedOut || !r.body) return err('error', '酒店页嗅探超时,未取得报价;本次不能判断无房,请换用其他酒店通道')
295
+ const parsed = classifyCtripHotelList(r.body)
296
+ if (parsed.shape === 'malformed' || parsed.shape === 'unrecognized') {
297
+ return err('error', `酒店回包无法识别(${parsed.shape}),库存查询未完成;本次不能判断无房`)
298
+ }
299
+ const hotels = parsed.hotels
246
300
  const verdict: SessionVerdict = hotels.length > 0 ? 'hit' : 'miss'
247
301
  return {
248
302
  ok: true,
@@ -293,7 +347,12 @@ export async function sessionHotelSearch(q: SessionHotelQuery): Promise<SessionH
293
347
  if (CHALLENGE_RE.test(title + headHtml)) {
294
348
  return err('challenged', `风控/验证码命中(title=${title.slice(0, 60)});按红线不重试不绕过,交还用户`)
295
349
  }
296
- const hotels = parseCtripHotelList(body)
350
+ if (!body) return err('error', '酒店页嗅探超时,未取得报价;本次不能判断无房,请换用其他酒店通道')
351
+ const parsed = classifyCtripHotelList(body)
352
+ if (parsed.shape === 'malformed' || parsed.shape === 'unrecognized') {
353
+ return err('error', `酒店回包无法识别(${parsed.shape}),库存查询未完成;本次不能判断无房`)
354
+ }
355
+ const hotels = parsed.hotels
297
356
  const verdict: SessionVerdict = hotels.length > 0 ? 'hit' : 'miss'
298
357
  return {
299
358
  ok: true,
@@ -757,13 +816,20 @@ export async function sessionFlightSearch(q: SessionFlightQuery): Promise<Sessio
757
816
  if (Date.now() - last < MIN_INTERVAL_MS) {
758
817
  return err('cooldown', `rate limit: last call ${Date.now() - last}ms ago, min ${MIN_INTERVAL_MS}ms`)
759
818
  }
760
- lastCallAt.set(site, Date.now())
761
-
762
- const entry = buildEntryUrl(q.from, q.to, q.date)
819
+ // 往返(2026-10-14 解锁,#663):URL 形状由 GUI 车道教师实测定稿——
820
+ // round-{code}0-{code}0?depdate=去_回&cabin=y_s(curl 200+往返 title 实证)。
821
+ if (q.backDate && !(q.date < q.backDate)) {
822
+ return err('error', `回程日期(${q.backDate})须晚于去程(${q.date})`)
823
+ }
824
+ const entry = await resolveFlightEntryUrl(q.from, q.to, q.date, { fromCode: q.fromCityCode, toCode: q.toCityCode }, undefined, q.backDate)
763
825
  if (!entry.ok || !entry.url) {
764
- return err('error', `unresolved entry: ${(entry.unresolved ?? []).join('/')} 不在城市码表`)
826
+ return err('error', entry.error ?? '城市解析失败,请确认城市或机场码;不能据此判断航线覆盖')
765
827
  }
766
828
 
829
+ // Resolution never consumes inventory pacing; recheck after asynchronous lookup.
830
+ const resolvedLast = lastCallAt.get(site) ?? 0
831
+ if (Date.now() - resolvedLast < MIN_INTERVAL_MS) return err('cooldown', `rate limit: min ${MIN_INTERVAL_MS}ms`)
832
+ lastCallAt.set(site, Date.now())
767
833
  const mode = resolveTransportMode(q.profileDir)
768
834
 
769
835
  if (mode === 'extension') {
@@ -891,3 +957,37 @@ export async function sessionFlightSearch(q: SessionFlightQuery): Promise<Sessio
891
957
  await t.close()
892
958
  }
893
959
  }
960
+
961
+ /**
962
+ * 携程机票 GUI 车道(2026-10-14,创始人指示):gotry 自己的隔离匿名 headful 窗口,
963
+ * 驱动 PC 检索表单(往返/任意城市原生支持——城市解析=站点自动补全,码表退居缓存),
964
+ * 结果从站点自己的 batchSearch 回包解析。节律闸与 bridge 同站同桶('ctrip-flight')。
965
+ * 写面红线:动作白名单+提交件黑名单+readback 硬闸(gui-flight.ts 契约)。
966
+ */
967
+ export async function sessionFlightGuiSearch(q: SessionFlightQuery): Promise<SessionSearchResult> {
968
+ const started = Date.now()
969
+ const ts = new Date().toISOString()
970
+ const err = (verdict: SessionVerdict, error: string): SessionSearchResult => ({
971
+ ok: false, via: 'session-ctrip-flight-error', evidence: `[会话:ctrip-flight-gui@error@${ts}] ${error}`, latencyMs: Date.now() - started, verdict, error,
972
+ })
973
+
974
+ const last = lastCallAt.get('ctrip-flight') ?? 0
975
+ if (Date.now() - last < MIN_INTERVAL_MS) {
976
+ return err('cooldown', `rate limit: last call ${Date.now() - last}ms ago, min ${MIN_INTERVAL_MS}ms`)
977
+ }
978
+ lastCallAt.set('ctrip-flight', Date.now())
979
+
980
+ if (q.backDate && !(q.date < q.backDate)) {
981
+ return err('error', `回程日期(${q.backDate})须晚于去程(${q.date})`)
982
+ }
983
+
984
+ const r = await runFlightGuiSearch({ from: q.from, to: q.to, date: q.date, ...(q.backDate ? { backDate: q.backDate } : {}), ...(q.timeoutMs ? { timeoutMs: q.timeoutMs } : {}) })
985
+ return {
986
+ ok: r.ok, via: r.via, evidence: r.evidence, latencyMs: r.latencyMs,
987
+ verdict: r.verdict,
988
+ ...(r.options ? { options: r.options } : {}),
989
+ ...(r.calendar && r.calendar.length > 0 ? { calendar: r.calendar } : {}),
990
+ ...(r.listUrl ? { listUrl: r.listUrl } : {}),
991
+ ...(r.error ? { error: r.error } : {}),
992
+ }
993
+ }