@danceiny/gotry 0.2.0-rc.32 → 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 (89) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/capabilities/session/bridge-ledger.js +168 -67
  4. package/dist/capabilities/session/extension-bridge.js +140 -46
  5. package/dist/capabilities/session/gui-flight.js +281 -69
  6. package/dist/capabilities/session/gui-hotel.js +74 -17
  7. package/dist/capabilities/session-search.js +6 -5
  8. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  9. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  10. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  11. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  12. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  13. package/dist/scripts/bridge-ledger-tests.js +20 -18
  14. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  15. package/dist/scripts/mutation-check.js +153 -0
  16. package/dist/scripts/nightly-evidence.js +4 -111
  17. package/dist/scripts/session-tests.js +42 -8
  18. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  19. package/dist/scripts/tenant-capacity-probe.js +263 -0
  20. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  21. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  22. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  23. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  24. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  25. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  26. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  27. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  28. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  29. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  30. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  31. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  32. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  33. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  34. package/dist/scripts/tenant-state-router-tests.js +464 -0
  35. package/dist/scripts/tenant-state-test-kit.js +92 -0
  36. package/dist/scripts/tenant-state-tests.js +564 -0
  37. package/dist/src/backend/modules/booking-executor.js +61 -6
  38. package/dist/src/backend/modules/session-search.js +136 -39
  39. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  40. package/dist/src/booking-surface/dsh-planner.js +150 -19
  41. package/dist/src/booking-surface/index.js +1 -0
  42. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  43. package/dist/src/booking-surface/server.js +193 -80
  44. package/dist/src/booking-surface/startup.js +153 -0
  45. package/dist/src/booking-surface/tenancy.js +188 -0
  46. package/dist/src/index.js +31 -1
  47. package/dist/src/llm-price-table.js +148 -0
  48. package/dist/src/tenant-seam/index.js +8 -0
  49. package/dist/src/tenant-seam/llm-broker.js +508 -0
  50. package/dist/src/tenant-seam/process-governor.js +281 -0
  51. package/dist/src/tenant-seam/quotas.js +360 -0
  52. package/dist/src/tenant-seam/seam.js +92 -0
  53. package/dist/src/tenant-state/durable-store.js +158 -0
  54. package/dist/src/tenant-state/epoch-copies.js +422 -0
  55. package/dist/src/tenant-state/errors.js +108 -0
  56. package/dist/src/tenant-state/ids.js +101 -0
  57. package/dist/src/tenant-state/index.js +11 -0
  58. package/dist/src/tenant-state/lease-store.js +203 -0
  59. package/dist/src/tenant-state/tenant-lease.js +150 -0
  60. package/dist/src/tenant-state/tenant-router.js +650 -0
  61. package/extension/manifest.json +2 -2
  62. package/package.json +4 -1
  63. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  64. package/ts/capabilities/session/extension-bridge.ts +176 -65
  65. package/ts/capabilities/session/gui-flight.ts +318 -88
  66. package/ts/capabilities/session/gui-hotel.ts +72 -18
  67. package/ts/capabilities/session-search.ts +17 -5
  68. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  69. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  70. package/ts/src/booking-surface/index.ts +1 -0
  71. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  72. package/ts/src/booking-surface/server.ts +215 -84
  73. package/ts/src/booking-surface/startup.ts +155 -0
  74. package/ts/src/booking-surface/tenancy.ts +242 -0
  75. package/ts/src/index.ts +28 -2
  76. package/ts/src/llm-price-table.ts +180 -0
  77. package/ts/src/tenant-seam/index.ts +9 -0
  78. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  79. package/ts/src/tenant-seam/process-governor.ts +343 -0
  80. package/ts/src/tenant-seam/quotas.ts +433 -0
  81. package/ts/src/tenant-seam/seam.ts +142 -0
  82. package/ts/src/tenant-state/durable-store.ts +171 -0
  83. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  84. package/ts/src/tenant-state/errors.ts +128 -0
  85. package/ts/src/tenant-state/ids.ts +108 -0
  86. package/ts/src/tenant-state/index.ts +12 -0
  87. package/ts/src/tenant-state/lease-store.ts +266 -0
  88. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  89. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -31,7 +31,7 @@ import { tmpdir } from 'node:os'
31
31
  import { join } from 'node:path'
32
32
  import type { Page } from 'playwright-core'
33
33
  import { isSubmitText } from './read-guard.ts'
34
- import { NETWORK_HINTS, parseBatchSearchResult, type SessionFlightOption } from './adapters/ctrip-flight.ts'
34
+ import { NETWORK_HINTS, parseBatchSearchResult, LOGIN_COOKIE_NAMES, type SessionFlightOption } from './adapters/ctrip-flight.ts'
35
35
 
36
36
  export const FLIGHT_GUI_HOME = 'https://flights.ctrip.com/'
37
37
  /**
@@ -43,18 +43,27 @@ const YMD = /^\d{4}-\d{2}-\d{2}$/
43
43
  /** 挑战页特征(与桥车道同口径:验证/确认中文词) */
44
44
  const GUI_CHALLENGE_RE = /验证|校验|拖动滑块|请输入验证码/
45
45
 
46
- /** 动作闭集:GUI 车道能做的全部事情(检索设置面;无任何写语义) */
46
+ /** 动作闭集:GUI 车道能做的全部事情(检索设置面;无任何写语义)。
47
+ * seg=多程行号(1–3;缺省 1)——2026-10-15 live 实测:多程表单为三行
48
+ * [出发地|目的地|出发日期],标签同名按第 N 次出现消歧,日期框只读无占位符按序取。 */
47
49
  export type GuiAction =
48
50
  | { t: 'goto'; url: string }
49
- | { t: 'clickTripTab'; trip: 'oneway' | 'roundtrip' }
50
- | { t: 'typeCity'; which: 'from' | 'to'; text: string }
51
- | { t: 'pickSuggest'; which: 'from' | 'to'; text: string }
52
- | { t: 'setDate'; which: 'dep' | 'back'; ymd: string }
51
+ | { t: 'clickTripTab'; trip: 'oneway' | 'roundtrip' | 'multi' }
52
+ | { t: 'typeCity'; which: 'from' | 'to'; text: string; seg?: number }
53
+ | { t: 'pickSuggest'; which: 'from' | 'to'; text: string; seg?: number }
54
+ | { t: 'setDate'; which: 'dep' | 'back'; ymd: string; seg?: number }
53
55
  | { t: 'press'; key: string }
54
- | { t: 'readback'; expect: { from: string; to: string; dep: string; back?: string } }
56
+ | { t: 'readback'; expect: { legs: Array<{ from: string; to: string; date: string; back?: string }> } }
55
57
  | { t: 'clickSearch' }
56
58
  | { t: 'awaitApi' }
57
59
 
60
+ export interface GuiFlightLeg {
61
+ from: string
62
+ to: string
63
+ /** YYYY-MM-DD */
64
+ date: string
65
+ }
66
+
58
67
  export interface GuiFlightQuery {
59
68
  from: string
60
69
  to: string
@@ -62,13 +71,42 @@ export interface GuiFlightQuery {
62
71
  date: string
63
72
  /** 存在即往返检索(回程日期) */
64
73
  backDate?: string
74
+ /** 多程(2–3 段;存在即多程检索,忽略 from/to/date/backDate 顶层字段) */
75
+ legs?: GuiFlightLeg[]
76
+ /** 登录态配对(2026-10-15,#661 余尾):needUserLogin 探测命中时,窗口导航到携程官方
77
+ * 登录页并等待用户本人在该窗口完成登录(cookie 只读名字,值即弃),随后自动重跑检索
78
+ * 拿登录态行程明细。gotry 永不经手密码/验证码/cookie 值——登录永远发生在官方页。 */
79
+ interactiveLoginMs?: number
65
80
  /** 总预算 ms(浏览器启动+SPA+检索),默认 90_000 */
66
81
  timeoutMs?: number
67
82
  }
68
83
 
69
84
  /** 纯函数:查询 → 动作序列(离线测试锚点;执行前必须过 assertGuiPlanSafe) */
70
85
  export function planFlightGuiActions(q: GuiFlightQuery): GuiAction[] {
71
- const expect = { from: q.from, to: q.to, dep: q.date, ...(q.backDate ? { back: q.backDate } : {}) }
86
+ // 多程(2026-10-15 live 实测:三行 [出发地|目的地|出发日期],标签第 N 次出现消歧)
87
+ if (q.legs && q.legs.length >= 2) {
88
+ const actions: GuiAction[] = [
89
+ { t: 'goto', url: FLIGHT_GUI_HOME },
90
+ { t: 'clickTripTab', trip: 'multi' },
91
+ ]
92
+ q.legs.slice(0, 3).forEach((leg, i) => {
93
+ const seg = i + 1
94
+ actions.push(
95
+ { t: 'typeCity', which: 'from', text: leg.from, seg },
96
+ { t: 'pickSuggest', which: 'from', text: leg.from, seg },
97
+ { t: 'typeCity', which: 'to', text: leg.to, seg },
98
+ { t: 'pickSuggest', which: 'to', text: leg.to, seg },
99
+ { t: 'setDate', which: 'dep', ymd: leg.date, seg },
100
+ )
101
+ })
102
+ actions.push(
103
+ { t: 'press', key: 'Escape' },
104
+ { t: 'readback', expect: { legs: q.legs.slice(0, 3).map(l => ({ from: l.from, to: l.to, date: l.date })) } },
105
+ { t: 'clickSearch' },
106
+ { t: 'awaitApi' },
107
+ )
108
+ return actions
109
+ }
72
110
  // live 校准(2026-10-14 pre-search 截图):该表单默认即「往返」态(双日期框预填)——
73
111
  // 单程必须显式点「单程」,否则按非法日期对(回<去)搜索且日历面板挂在回程框上。
74
112
  const tripTab: GuiAction[] = [{ t: 'clickTripTab', trip: q.backDate ? 'roundtrip' : 'oneway' }]
@@ -83,7 +121,7 @@ export function planFlightGuiActions(q: GuiFlightQuery): GuiAction[] {
83
121
  { t: 'setDate', which: 'dep', ymd: q.date },
84
122
  ...backDate,
85
123
  { t: 'press', key: 'Escape' },
86
- { t: 'readback', expect },
124
+ { t: 'readback', expect: { legs: [{ from: q.from, to: q.to, date: q.date, ...(q.backDate ? { back: q.backDate } : {}) }] } },
87
125
  { t: 'clickSearch' },
88
126
  { t: 'awaitApi' },
89
127
  ]
@@ -100,7 +138,7 @@ export function assertGuiPlanSafe(plan: GuiAction[]): { ok: true } | { ok: false
100
138
  if (a.url !== FLIGHT_GUI_HOME) return { ok: false, error: `GUI 车道只允许固定入口 ${FLIGHT_GUI_HOME},拒绝 ${a.url}` }
101
139
  break
102
140
  case 'clickTripTab':
103
- if (a.trip !== 'oneway' && a.trip !== 'roundtrip') return { ok: false, error: `trip 闭集外:${a.trip}` }
141
+ if (a.trip !== 'oneway' && a.trip !== 'roundtrip' && a.trip !== 'multi') return { ok: false, error: `trip 闭集外:${a.trip}` }
104
142
  break
105
143
  case 'typeCity':
106
144
  case 'pickSuggest': {
@@ -108,11 +146,13 @@ export function assertGuiPlanSafe(plan: GuiAction[]): { ok: true } | { ok: false
108
146
  if (!text || text.length > 24) return { ok: false, error: `城市文本非法(空/超长):${text.slice(0, 30)}` }
109
147
  if (isSubmitText(text)) return { ok: false, error: `城市位出现提交件文本,拒绝:${text}` }
110
148
  if (a.which !== 'from' && a.which !== 'to') return { ok: false, error: `which 闭集外:${a.which}` }
149
+ if (a.seg !== undefined && (![1, 2, 3].includes(a.seg))) return { ok: false, error: `seg 闭集外:${a.seg}` }
111
150
  break
112
151
  }
113
152
  case 'setDate':
114
153
  if (!YMD.test(a.ymd)) return { ok: false, error: `日期需 YYYY-MM-DD,实际 ${a.ymd}` }
115
154
  if (a.which !== 'dep' && a.which !== 'back') return { ok: false, error: `which 闭集外:${a.which}` }
155
+ if (a.seg !== undefined && (![1, 2, 3].includes(a.seg))) return { ok: false, error: `seg 闭集外:${a.seg}` }
116
156
  break
117
157
  case 'press':
118
158
  if (a.key !== 'Escape' && a.key !== 'Enter') return { ok: false, error: `按键白名单只接受 Escape/Enter,拒绝 ${a.key}` }
@@ -120,8 +160,13 @@ export function assertGuiPlanSafe(plan: GuiAction[]): { ok: true } | { ok: false
120
160
  case 'clickSearch':
121
161
  break // 「搜索」是本车道唯一允许的按钮;提交件黑名单语义(下单/支付)不在其列
122
162
  case 'readback':
123
- for (const v of [a.expect.from, a.expect.to, a.expect.dep, a.expect.back]) {
124
- if (v !== undefined && (!v.trim() || v.length > 24)) return { ok: false, error: `readback 期望值非法:${v}` }
163
+ if (!Array.isArray(a.expect.legs) || a.expect.legs.length < 1 || a.expect.legs.length > 3) {
164
+ return { ok: false, error: 'readback legs 须 1–3 段' }
165
+ }
166
+ for (const leg of a.expect.legs) {
167
+ for (const v of [leg.from, leg.to, leg.date, leg.back]) {
168
+ if (v !== undefined && (!v.trim() || v.length > 24)) return { ok: false, error: `readback 期望值非法:${v}` }
169
+ }
125
170
  }
126
171
  break
127
172
  case 'awaitApi':
@@ -137,20 +182,23 @@ export function assertGuiPlanSafe(plan: GuiAction[]): { ok: true } | { ok: false
137
182
 
138
183
  export interface GuiPage {
139
184
  goto(url: string): Promise<void>
140
- /** 点行程 tab(单程/往返);找不到=表单改版,fail-closed */
141
- clickTripTab(trip: 'oneway' | 'roundtrip'): Promise<void>
142
- /** 城市框逐键打字(触发站点自动补全);站点内部定位输入框 */
143
- typeCity(which: 'from' | 'to', text: string): Promise<void>
185
+ /** 点行程 tab(单程/往返/多程);找不到=表单改版,fail-closed */
186
+ clickTripTab(trip: 'oneway' | 'roundtrip' | 'multi'): Promise<void>
187
+ /** 城市框逐键打字(触发站点自动补全);seg=多程行号(1–3,缺省 1) */
188
+ typeCity(which: 'from' | 'to', text: string, seg?: number): Promise<void>
144
189
  /** 选自动补全首项并验证归一化(形如 上海(SHA);未选中即重试一次,仍失败抛错) */
145
- pickSuggest(which: 'from' | 'to', text: string): Promise<void>
146
- /** 日期设值:输入框可打字则打字,否则点日历单元格(aria/title/文本含目标日期);正确性由 readback 兜底 */
147
- setDate(which: 'dep' | 'back', ymd: string): Promise<void>
190
+ pickSuggest(which: 'from' | 'to', text: string, seg?: number): Promise<void>
191
+ /** 日期设值:输入框可打字则打字,否则点日历单元格;seg=多程行号;正确性由 readback 兜底 */
192
+ setDate(which: 'dep' | 'back', ymd: string, seg?: number): Promise<void>
148
193
  press(key: string): Promise<void>
149
194
  clickSearch(): Promise<void>
150
195
  /** 读回当前表单全部输入的值+占位符(readback 硬闸的数据面) */
151
196
  visibleFormText(): Promise<string>
152
- /** 出发/到达城市框当前值(逐字段核对;防全局搜索框文本假阳) */
153
- cityFieldValues(): Promise<{ from: string; to: string }>
197
+ /** 出发/到达城市框当前值(逐字段核对;seg=多程行号) */
198
+ cityFieldValues(seg?: number): Promise<{ from: string; to: string }>
199
+ /** 登录态配对:窗口导航到携程官方登录页,等用户本人完成登录。
200
+ * 只轮询登录票据 cookie 的名字(值即弃,红线);超时返回 false。 */
201
+ waitForHumanLogin(timeoutMs: number): Promise<boolean>
154
202
  /** 挑战页检测(title+页面前 3KB 文本) */
155
203
  pageText(): Promise<string>
156
204
  /** 当前页 URL(#663 URL 教师:GUI 检索后回传 list 页真实形状) */
@@ -161,10 +209,12 @@ export interface GuiPage {
161
209
  recentApiUrls?(): Promise<string[]>
162
210
  }
163
211
 
164
- /** 双槽捕获:itinerary=含 flightItineraryList 的 batchSearch;calendar=低价日历(匿名可用) */
212
+ /** 双槽捕获:itinerary=含 flightItineraryList 的 batchSearch;calendar=低价日历(匿名可用);
213
+ * loginProbe=needUserLogin 配置探测(登录态配对触发信号)。 */
165
214
  export interface GuiApiCapture {
166
215
  itinerary?: { url: string; body: string }
167
216
  calendar?: { url: string; body: string }
217
+ loginProbe?: { url: string; body: string }
168
218
  }
169
219
 
170
220
  /** 低价日历单日条目(parser 按实测形状产出;绝不发明价格) */
@@ -179,7 +229,7 @@ export interface GuiDriver {
179
229
  close(): Promise<void>
180
230
  }
181
231
 
182
- export type GuiFlightVerdict = 'hit' | 'miss' | 'error' | 'challenged'
232
+ export type GuiFlightVerdict = 'hit' | 'miss' | 'error' | 'challenged' | 'needs-login'
183
233
 
184
234
  export interface GuiFlightResult {
185
235
  ok: boolean
@@ -193,9 +243,9 @@ export interface GuiFlightResult {
193
243
  /** 检索后站点自己落到的 list 页 URL(#663 URL 教师:真实形状回传,不猜) */
194
244
  listUrl?: string
195
245
  error?: string
196
- /** 匿名公开面:显式标注,不冒充登录态价格 */
197
- anonymous: true
198
- trip: 'oneway' | 'roundtrip'
246
+ /** 匿名公开面=显式标注;登录态配对后为 false(用户本人在 GUI 窗口官方页完成登录) */
247
+ anonymous: boolean
248
+ trip: 'oneway' | 'roundtrip' | 'multi'
199
249
  }
200
250
 
201
251
  /** 测试注入 seam(__setTrainSessionTransportForTest 同款) */
@@ -211,8 +261,9 @@ export function __setGuiDriverForTest(factory: (() => GuiDriver) | null): void {
211
261
  export async function runFlightGuiSearch(q: GuiFlightQuery): Promise<GuiFlightResult> {
212
262
  const started = Date.now()
213
263
  const ts = new Date().toISOString()
214
- const trip: 'oneway' | 'roundtrip' = q.backDate ? 'roundtrip' : 'oneway'
215
- const base = { anonymous: true as const, trip }
264
+ const trip: 'oneway' | 'roundtrip' | 'multi' = q.legs && q.legs.length >= 2 ? 'multi' : q.backDate ? 'roundtrip' : 'oneway'
265
+ let humanLoggedIn = false
266
+ const base = { get anonymous() { return !humanLoggedIn }, trip }
216
267
  const fail = (error: string, verdict: GuiFlightVerdict = 'error'): GuiFlightResult => ({
217
268
  ok: false, via: 'session-ctrip-flight-gui-error', latencyMs: Date.now() - started, verdict, error,
218
269
  evidence: `[会话:ctrip-flight-gui@error@${ts}] ${error.slice(0, 120)}`, ...base,
@@ -230,46 +281,45 @@ export async function runFlightGuiSearch(q: GuiFlightQuery): Promise<GuiFlightRe
230
281
  page = await withDeadline(driver.open(), Math.min(budget, 30_000), 'GUI 浏览器窗口启动失败/超时(本机需已装 Chrome)')
231
282
  } catch (e) {
232
283
  await driver.close().catch(() => {})
233
- return fail((e as Error).message.slice(0, 1200))
284
+ return fail((e as Error).message.slice(0, 500))
234
285
  }
235
286
 
236
287
  let apiBody: GuiApiCapture | null = null
237
288
  let listUrl = ''
238
- try {
289
+ /** 流程性失败(硬闸/挑战页/超时)以结果对象抛出,由外层统一返回 */
290
+ class GuiFlowAbort { constructor(public result: GuiFlightResult) {} }
291
+
292
+ /** 单次驾驶:整计划走到结果回包,返回捕获 */
293
+ const driveOnce = async (): Promise<GuiApiCapture | null> => {
294
+ let localCapture: GuiApiCapture | null = null
239
295
  for (const a of plan) {
240
296
  const remain = budget - (Date.now() - started)
241
- if (remain <= 0) return fail(`GUI 预算耗尽(${budget}ms)`)
297
+ if (remain <= 0) throw new GuiFlowAbort(fail(`GUI 预算耗尽(${budget}ms)`))
242
298
  const step = <T,>(p: Promise<T>, what: string): Promise<T> =>
243
299
  withDeadline(p, Math.min(remain, 20_000), `GUI 步骤失败(${what})`)
244
300
  switch (a.t) {
245
301
  case 'goto': await step(page.goto(a.url), '打开携程机票首页'); break
246
- case 'clickTripTab': await step(page.clickTripTab(a.trip), a.trip === 'roundtrip' ? '切换往返' : '切换单程'); break
247
- case 'typeCity': await step(page.typeCity(a.which, a.text), `填写${a.which === 'from' ? '出发' : '到达'}城市`); break
302
+ case 'clickTripTab': await step(page.clickTripTab(a.trip), `切换${a.trip === 'roundtrip' ? '往返' : a.trip === 'multi' ? '多程' : '单程'}`); break
303
+ case 'typeCity': await step(page.typeCity(a.which, a.text, a.seg ?? 1), `填写第${a.seg ?? 1}段${a.which === 'from' ? '出发' : '到达'}城市`); break
248
304
  case 'pickSuggest':
249
- await step(page.pickSuggest(a.which, a.text), '选择城市自动补全项')
250
- if (a.which === 'to') trace.push(`after to-pick: ${JSON.stringify(await page.cityFieldValues().catch(() => ({})))}`)
305
+ await step(page.pickSuggest(a.which, a.text, a.seg ?? 1), '选择城市自动补全项')
306
+ if (a.which === 'to' && (a.seg ?? 1) === 1) trace.push(`after to-pick: ${JSON.stringify(await page.cityFieldValues(1).catch(() => ({})))}`)
251
307
  break
252
- case 'setDate': await step(page.setDate(a.which, a.ymd), `设置${a.which === 'dep' ? '去程' : '回程'}日期`); break
308
+ case 'setDate': await step(page.setDate(a.which, a.ymd, a.seg ?? 1), `设置第${a.seg ?? 1}段${a.which === 'dep' ? '去程' : '回程'}日期`); break
253
309
  case 'press': await step(page.press(a.key), `按键 ${a.key}`); break
254
310
  case 'clickSearch': await step(page.clickSearch(), '点击搜索'); break
255
311
  case 'readback': {
256
- // 城市:逐字段核对出发/到达框(占位符同名的全局搜索框/相邻框文本不再能假阳)
257
- const cities = await step(page.cityFieldValues(), '读回城市框')
258
312
  const form = await step(page.visibleFormText(), '读回表单')
259
- const cityChecks: Array<[string, string, string]> = [
260
- ['出发城市', cities.from, a.expect.from], ['到达城市', cities.to, a.expect.to],
261
- ]
262
- for (const [label, actual, value] of cityChecks) {
263
- if (!actual.includes(value)) {
264
- return fail(`readback 硬闸:${label}框未确认「${value}」(实际「${actual.slice(0, 30)}」;表单摘要:${form.slice(0, 100)})——已终止,未发起搜索`)
313
+ for (const [i, leg] of a.expect.legs.entries()) {
314
+ const cities = await step(page.cityFieldValues(i + 1), `读回第${i + 1}段城市框`)
315
+ if (!cities.from.includes(leg.from) || !cities.to.includes(leg.to)) {
316
+ throw new GuiFlowAbort(fail(`readback 硬闸:第${i + 1}段城市框未确认「${leg.from}/${leg.to}」(实际「${cities.from.slice(0, 20)}/${cities.to.slice(0, 20)}」;表单摘要:${form.slice(0, 100)})——已终止,未发起搜索`))
265
317
  }
266
- }
267
- const dateChecks: Array<[string, string]> = [
268
- ['去程日期', a.expect.dep], ...(a.expect.back ? [['回程日期', a.expect.back] as [string, string]] : []),
269
- ]
270
- for (const [label, value] of dateChecks) {
271
- if (!form.includes(value)) {
272
- return fail(`readback 硬闸:${label}「${value}」未在表单确认(表单摘要:${form.slice(0, 120)})——已终止,未发起搜索`)
318
+ if (!form.includes(leg.date)) {
319
+ throw new GuiFlowAbort(fail(`readback 硬闸:第${i + 1}段日期「${leg.date}」未在表单确认(表单摘要:${form.slice(0, 120)})——已终止,未发起搜索`))
320
+ }
321
+ if (leg.back && !form.includes(leg.back)) {
322
+ throw new GuiFlowAbort(fail(`readback 硬闸:回程日期「${leg.back}」未在表单确认——已终止,未发起搜索`))
273
323
  }
274
324
  }
275
325
  break
@@ -277,19 +327,36 @@ export async function runFlightGuiSearch(q: GuiFlightQuery): Promise<GuiFlightRe
277
327
  case 'awaitApi': {
278
328
  const text = await step(page.pageText(), '检查页面')
279
329
  if (GUI_CHALLENGE_RE.test(text)) {
280
- return fail('风控/验证码命中;按红线不重试不绕过,交还用户', 'challenged')
330
+ throw new GuiFlowAbort(fail('风控/验证码命中;按红线不重试不绕过,交还用户', 'challenged'))
281
331
  }
282
- apiBody = await step(page.waitForApiCapture(Math.min(remain - 500, 18_000)), '等待结果回包(batchSearch/低价日历)')
283
- if (!apiBody || (!apiBody.itinerary && !apiBody.calendar)) {
332
+ localCapture = await step(page.waitForApiCapture(Math.min(remain - 500, 18_000)), '等待结果回包(batchSearch/低价日历)')
333
+ if (!localCapture || (!localCapture.itinerary && !localCapture.calendar)) {
284
334
  const recent = page.recentApiUrls ? (await page.recentApiUrls().catch(() => [])) : []
285
- return fail(`结果回包嗅探超时:batchSearch(行程)与低价日历均未收到;本次检索未完成${recent.length ? ` ‖recent-apis: ${recent.slice(-8).join(' ; ').slice(0, 600)}` : ''}`)
335
+ throw new GuiFlowAbort(fail(`结果回包嗅探超时:batchSearch(行程)与低价日历均未收到;本次检索未完成${recent.length ? ` ‖recent-apis: ${recent.slice(-8).join(' ; ').slice(0, 600)}` : ''}`))
286
336
  }
287
337
  break
288
338
  }
289
339
  }
290
340
  }
341
+ return localCapture
342
+ }
343
+
344
+ try {
345
+ apiBody = await driveOnce()
346
+ // 登录态配对(#661 余尾,2026-10-15):匿名探测(needUserLogin)命中且调用方授权等待时,
347
+ // 窗口停在携程官方登录页等用户本人完成登录(cookie 只读名字),随后原窗重驾一次拿登录态明细。
348
+ if (q.interactiveLoginMs && q.interactiveLoginMs > 0 && apiBody?.loginProbe && !apiBody.itinerary) {
349
+ const loggedIn = await withDeadline(page.waitForHumanLogin(q.interactiveLoginMs), q.interactiveLoginMs + 5_000, '等待人工登录')
350
+ if (!loggedIn) {
351
+ return fail(`登录等待超时(${q.interactiveLoginMs}ms):用户未在 GUI 窗口完成登录——本次未取得登录态行程;可稍后带 interactiveLogin 重试或用匿名日历面`, 'needs-login')
352
+ }
353
+ humanLoggedIn = true
354
+ trace.push('human-login ok; re-drive')
355
+ apiBody = await driveOnce()
356
+ }
291
357
  listUrl = await page.currentUrl().catch(() => '')
292
358
  } catch (e) {
359
+ if (e instanceof GuiFlowAbort) return e.result
293
360
  return fail(`${(e as Error).message.slice(0, 500)}${trace.length ? ` ‖trace: ${trace.join(' ; ').slice(0, 400)}` : ''}`)
294
361
  } finally {
295
362
  await driver.close().catch(() => {})
@@ -309,7 +376,7 @@ export async function runFlightGuiSearch(q: GuiFlightQuery): Promise<GuiFlightRe
309
376
  ...(verdict === 'hit' ? { options: parsed.options } : {}),
310
377
  ...(calendar && calendar.length > 0 ? { calendar } : {}),
311
378
  ...(listUrl ? { listUrl } : {}),
312
- evidence: `[会话:ctrip-flight-gui@${ts}] ${trip} 匿名 GUI 面检索:${parsed.options.length} 条(transport=gui shape=${parsed.shape}${calendar?.length ? `;calendar=${calendar.length}天` : ''})`,
379
+ evidence: `[会话:ctrip-flight-gui@${ts}] ${trip} GUI 面检索:${parsed.options.length} 条(transport=gui shape=${parsed.shape}${calendar?.length ? `;calendar=${calendar.length}天` : ''};${humanLoggedIn ? '用户本人已在本窗口官方页登录,登录态真实数据' : '匿名公开面'})`,
313
380
  ...base,
314
381
  }
315
382
  }
@@ -321,7 +388,7 @@ export async function runFlightGuiSearch(q: GuiFlightQuery): Promise<GuiFlightRe
321
388
  return {
322
389
  ok: true, via: 'session-ctrip-flight-gui', latencyMs: Date.now() - started, verdict: 'hit',
323
390
  calendar, ...(listUrl ? { listUrl } : {}),
324
- evidence: `[会话:ctrip-flight-gui@${ts}] ${trip} 匿名 GUI 面·低价日历:${calendar.length} 天(行程明细需登录态,见 #661;日历为匿名公开价)`,
391
+ evidence: `[会话:ctrip-flight-gui@${ts}] ${trip} 匿名 GUI 面·低价日历:${calendar.length} 天(行程明细需登录态:interactiveLogin=true 可在窗口内本人登录后自动重查)`,
325
392
  ...base,
326
393
  }
327
394
  }
@@ -423,7 +490,7 @@ const CITY_INPUT_IDX_FROM_LEAVES = (which: 'from' | 'to'): string => `(() => {
423
490
  const leaves = Array.from(document.querySelectorAll('*'))
424
491
  .filter(e => e.offsetParent && e.children.length === 0)
425
492
  const isCityInput = (e) => e.tagName === 'INPUT'
426
- && /城市|机场/.test(e.placeholder || '') && !/旅游|搜索/.test(e.placeholder || '')
493
+ && (!e.placeholder || /城市|机场/.test(e.placeholder)) && !/旅游|搜索|请输入/.test(e.placeholder || '')
427
494
  const inputs = leaves.map((e, i) => ({ e, i })).filter(({ e }) => isCityInput(e))
428
495
  const visibleInputs = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
429
496
  const nearest = (label) => {
@@ -443,27 +510,58 @@ const CITY_INPUT_IDX_FROM_LEAVES = (which: 'from' | 'to'): string => `(() => {
443
510
  }
444
511
  return nearest(${which === 'from' ? "'出发地'" : "'目的地'"}) })()`
445
512
  const CITY_INPUT_IDX = CITY_INPUT_IDX_FROM_LEAVES
513
+ /** 多程行定位(live 实测 2026-10-15):三行 [出发地|目的地|出发日期],标签同名——
514
+ * 「出发地/目的地」第 N 次出现(occurrence)的 ±10 叶子窗口内城市输入框即第 N 段框。 */
515
+ const CITY_INPUT_IDX_N = (which: 'from' | 'to', occurrence: number): string => `(() => {
516
+ const leaves = Array.from(document.querySelectorAll('*'))
517
+ .filter(e => e.offsetParent && e.children.length === 0)
518
+ const isCityInput = (e) => e.tagName === 'INPUT'
519
+ && (!e.placeholder || /城市|机场/.test(e.placeholder)) && !/旅游|搜索|请输入/.test(e.placeholder || '')
520
+ const inputs = leaves.map((e, i) => ({ e, i })).filter(({ e }) => isCityInput(e))
521
+ const visibleInputs = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
522
+ const label = ${which === 'from'} ? '出发地' : '目的地'
523
+ const yOf = (e) => { const r = e.getBoundingClientRect(); return r.top + r.height / 2 }
524
+ let seen = 0
525
+ for (let li = 0; li < leaves.length; li += 1) {
526
+ if ((leaves[li].textContent || '').trim() !== label) continue
527
+ seen += 1
528
+ if (seen !== ${occurrence}) continue
529
+ const ly = yOf(leaves[li])
530
+ let best = -1, bestDist = 99
531
+ for (const inp of inputs) {
532
+ const d = Math.abs(inp.i - li)
533
+ // 同行 y 对齐(±60px):防止上一行已物化框抢亲(多程行惰性物化,live 实测)
534
+ if (d < bestDist && d <= 10 && Math.abs(yOf(inp.e) - ly) <= 60) { best = inp.i; bestDist = d }
535
+ }
536
+ if (best >= 0) return visibleInputs.indexOf(leaves[best])
537
+ }
538
+ return -1 })()`
446
539
  /** 城市框当前值(出发/到达各自;readback 逐字段核对用,防全局搜索框文本假阳) */
447
- const CITY_FIELD_VALUES = `(() => {
540
+ const CITY_FIELD_VALUES_FN = (occurrence?: number): string => `(() => {
448
541
  const leaves = Array.from(document.querySelectorAll('*'))
449
542
  .filter(e => e.offsetParent && e.children.length === 0)
450
543
  const isCityInput = (e) => e.tagName === 'INPUT'
451
- && /城市|机场/.test(e.placeholder || '') && !/旅游|搜索/.test(e.placeholder || '')
544
+ && (!e.placeholder || /城市|机场/.test(e.placeholder)) && !/旅游|搜索|请输入/.test(e.placeholder || '')
452
545
  const inputs = leaves.map((e, i) => ({ e, i })).filter(({ e }) => isCityInput(e))
453
- const nearest = (label) => {
454
- let li = -1
546
+ const nearest = (label, occurrence) => {
547
+ let li = -1, seen = 0
455
548
  for (let i = 0; i < leaves.length; i += 1) {
456
- if ((leaves[i].textContent || '').trim() === label) { li = i; break }
549
+ if ((leaves[i].textContent || '').trim() === label) {
550
+ seen += 1
551
+ if (seen === (occurrence || 1)) { li = i; break }
552
+ }
457
553
  }
458
554
  if (li < 0) return null
555
+ const yOf = (e) => { const r = e.getBoundingClientRect(); return r.top + r.height / 2 }
556
+ const ly = yOf(leaves[li])
459
557
  let best = null, bestDist = 99
460
558
  for (const inp of inputs) {
461
559
  const d = Math.abs(inp.i - li)
462
- if (d < bestDist && d <= 10) { best = inp; bestDist = d }
560
+ if (d < bestDist && d <= 10 && Math.abs(yOf(inp.e) - ly) <= 60) { best = inp; bestDist = d }
463
561
  }
464
562
  return best
465
563
  }
466
- const f = nearest('出发地'), t = nearest('目的地')
564
+ const f = nearest('出发地', ${occurrence ?? 1}), t = nearest('目的地', ${occurrence ?? 1})
467
565
  return JSON.stringify({ from: f ? (f.e.value || '') : '', to: t ? (t.e.value || '') : '' }) })()`
468
566
  /** 日期框定位(live 校准:只读,占位 yyyy-mm-dd;往返时第二个) */
469
567
  const DATE_INPUT_IDX = (which: 'dep' | 'back'): string => `(() => { const els = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
@@ -472,11 +570,45 @@ const DATE_INPUT_IDX = (which: 'dep' | 'back'): string => `(() => { const els =
472
570
  const back = ${which === 'back'}
473
571
  if (hits.length >= (back ? 2 : 1)) return hits[back ? 1 : 0].i
474
572
  return els.length - 1 })()`
573
+ /** 多程日期框(live 实测 2026-10-15:只读无占位符,按序=第 N 个只读空占位输入) */
574
+ const DATE_INPUT_IDX_SEG = (seg: number): string => `(() => { const els = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
575
+ const dates = els.map((e, i) => ({ i, ro: e.readOnly, ph: e.placeholder || '' }))
576
+ .filter(x => (x.ro && !x.ph) || /日期|date/i.test(x.ph))
577
+ const idx = ${seg} - 1
578
+ return dates.length > idx ? dates[idx].i : -1 })()`
579
+ /** 多程段就绪谓词:第 N 个「出发地」标签 ±6 叶子内出现城市输入框(行惰性物化,live 实测 2026-10-15) */
580
+ const SEG_READY = (occurrence: number): string => `(() => {
581
+ const leaves = Array.from(document.querySelectorAll('*')).filter(e => e.offsetParent && e.children.length === 0)
582
+ let seen = 0
583
+ for (let li = 0; li < leaves.length; li += 1) {
584
+ if ((leaves[li].textContent || '').trim() !== '出发地') continue
585
+ seen += 1
586
+ if (seen !== ${occurrence}) continue
587
+ return leaves.some((e, i) => Math.abs(i - li) <= 6 && e.tagName === 'INPUT'
588
+ && (!e.placeholder || /城市|机场/.test(e.placeholder || '')) && !/旅游|搜索|请输入/.test(e.placeholder || ''))
589
+ }
590
+ return false })()`
591
+ /** 点击第 N 个行标签(出发地/目的地;行惰性物化的物化触发) */
592
+ const CLICK_SEG_LABEL = (label: '出发地' | '目的地', occurrence: number): string => `(() => {
593
+ const leaves = Array.from(document.querySelectorAll('*')).filter(e => e.offsetParent && e.children.length === 0)
594
+ let seen = 0
595
+ for (const e of leaves) {
596
+ if ((e.textContent || '').trim() !== '${label}') continue
597
+ seen += 1
598
+ if (seen === ${occurrence}) { (e.parentElement || e).click(); return true }
599
+ }
600
+ return false })()`
601
+
475
602
  /** 表单内搜索按钮(live 校准:按钮是无语义 div,文本「搜索」为精确叶子;全局搜索框旁
476
603
  * 也有「搜索」——只在日期框 8 层祖先窗口内找,绝不全局兜底) */
477
604
  const CLICK_FORM_SEARCH = `(() => {
478
605
  const els = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
479
- const dateEl = els.find(e => /y{2,4}[-/]m{1,2}|日期|date/i.test(e.placeholder || '') || /^\\d{4}-\\d{2}-\\d{2}/.test(e.value || ''))
606
+ const dateEls = els.filter(e => /y{2,4}[-/]m{1,2}|日期|date/i.test(e.placeholder || '')
607
+ || (!e.placeholder && e.readOnly) || /^\d{4}-\d{2}-\d{2}/.test(e.value || ''))
608
+ // 多程(live 实测 2026-10-15):行堆叠,搜索按钮在末行右/下方——锚=最底部日期框
609
+ const dateEl = dateEls.length
610
+ ? dateEls.reduce((a, b) => ((a.getBoundingClientRect().top >= b.getBoundingClientRect().top) ? a : b))
611
+ : undefined
480
612
  if (!dateEl) throw new Error('搜索按钮定位失败:日期框不存在')
481
613
  const click = (el) => {
482
614
  let target = el
@@ -504,18 +636,44 @@ const CLICK_FORM_SEARCH = `(() => {
504
636
  const cx = r.left + r.width / 2
505
637
  const cy = r.top + r.height / 2
506
638
  if (cx >= dateRect.right - 20 && cx <= dateRect.right + 500
507
- && cy >= dateRect.top - 60 && cy <= dateRect.bottom + 120) {
639
+ && cy >= dateRect.top - 320 && cy <= dateRect.bottom + 400) {
508
640
  const d = Math.abs(cy - (dateRect.top + dateRect.height / 2)) + (cx - dateRect.right) * 0.2
509
641
  if (d < rowDist) { rowBtn = e; rowDist = d }
510
642
  }
511
- if (cy >= dateRect.bottom && cy <= dateRect.bottom + 250
512
- && cx >= dateRect.left - 80 && cx <= dateRect.right + 200) {
643
+ if (cy >= dateRect.bottom && cy <= dateRect.bottom + 400
644
+ && cx >= dateRect.left - 80 && cx <= dateRect.right + 420) {
513
645
  const d = cy - dateRect.bottom
514
646
  if (d < belowDist) { belowBtn = e; belowDist = d }
515
647
  }
516
648
  }
517
649
  const best = rowBtn || belowBtn
518
650
  if (best) return click(best)
651
+ // 兜底(live 实测 2026-10-15 多程):按钮=右侧蓝色「搜索」大块,若指针游标不在本体上,
652
+ // 按右半区的「搜索」精确叶子点其可点祖先。
653
+ const searchLeaf = Array.from(document.querySelectorAll('*'))
654
+ .find(e => e.children.length === 0 && (e.textContent || '').trim() === '搜索' && e.offsetParent
655
+ && e.getBoundingClientRect().left > dateRect.left)
656
+ if (searchLeaf) return click(searchLeaf)
657
+ // 终极兜底(live 截图实证 2026-10-15 多程:按钮=表单右侧蓝色圆角大块):蓝色主导背景
658
+ // 且 ≥80×80、位于表单纵向范围内 → 点击(白字「搜索」可能在非叶子嵌套里,不依赖文本)。
659
+ const top = dateEls.reduce((m, e) => Math.min(m, e.getBoundingClientRect().top), 1e9)
660
+ const bottom = dateEls.reduce((m, e) => Math.max(m, e.getBoundingClientRect().bottom), -1e9)
661
+ let blue = null, blueArea = 0
662
+ for (const e of Array.from(document.querySelectorAll('*'))) {
663
+ if (!e.offsetParent) continue
664
+ const r = e.getBoundingClientRect()
665
+ if (r.width < 80 || r.height < 80 || r.width > 360) continue
666
+ const cy = r.top + r.height / 2
667
+ if (cy < top - 200 || cy > bottom + 300) continue
668
+ const bgNums = getComputedStyle(e).backgroundColor.replace(/[^0-9,]/g, '').split(',').filter(s2 => s2 !== '').map(Number)
669
+ if (bgNums.length < 3) continue
670
+ const [rr, gg, bb] = [bgNums[0], bgNums[1], bgNums[2]]
671
+ if (bb > 140 && bb > rr + 40 && bb > gg + 30) {
672
+ const area = r.width * r.height
673
+ if (area > blueArea) { blue = e; blueArea = area }
674
+ }
675
+ }
676
+ if (blue) return click(blue)
519
677
  const ptr = Array.from(document.querySelectorAll('*'))
520
678
  .filter(e => e.offsetParent && getComputedStyle(e).cursor === 'pointer' && e.textContent && e.textContent.trim().length <= 6)
521
679
  .slice(0, 10).map(e => ({ t: (e.textContent || '').trim(), tag: e.tagName, cls: String(e.className).slice(0, 40) }))
@@ -558,9 +716,12 @@ const CLICK_DATE_CELL = (ymd: string, anchorYmd?: string): string => {
558
716
  const ariaHit = dayLeaves.find(e =>
559
717
  ((e.getAttribute('aria-label') || '') + (e.getAttribute('title') || '')).includes('${ymd}'))
560
718
  if (ariaHit) return clickEl(ariaHit)
561
- // 捷径:候选日号中父级非 disabled 的恰一个 → 直接点(过去月禁用时天然消歧)
719
+ // 捷径:目标月可视(面板 body 含 YYYY年M月)且候选日号中父级非 disabled 的恰一个 → 直接点
720
+ // (2026-10-15 教训:目标月不在可视双月时,enabled-unique 会选中相邻月的同号日)
721
+ const ymVisible = Array.from(document.querySelectorAll('.month'))
722
+ .some(e => ((e.textContent || '').trim() === '${Number(ymd.slice(5, 7))}月') || ((e.textContent || '').trim().includes('${ymCn}')))
562
723
  const enabled = dayLeaves.filter(e => !/disabled/.test(String(e.parentElement && e.parentElement.className)))
563
- if (enabled.length === 1) return clickEl(enabled[0])
724
+ if (ymVisible && enabled.length === 1) return clickEl(enabled[0])
564
725
  // 几何月消歧(live 校准 2026-10-14 往返回程:双月面板两个同日号都可点,月标题元素
565
726
  // 不可寻):日号格按 x 聚簇=月列,以「今天」锚定第 0 列=当前月,目标月偏移选列。
566
727
  const rect = (e) => e.getBoundingClientRect()
@@ -588,6 +749,18 @@ const CLICK_DATE_CELL = (ymd: string, anchorYmd?: string): string => {
588
749
  return false })()`
589
750
  }
590
751
 
752
+ /** 日历翻月(live 实测 2026-10-15:面板头 iconf-right=下月,iconf-left.disa=上月禁用) */
753
+ const CLICK_NEXT_MONTH = `(() => {
754
+ const next = Array.from(document.querySelectorAll('[class*=in-date-picker][class*=iconf-right],[class*=in-date-picker][class*=next-ico]'))
755
+ .filter(e => e.offsetParent && !/disa/.test(String(e.className)))[0]
756
+ if (!next) return false
757
+ ;(next.parentElement || next).click()
758
+ return true })()`
759
+ /** 目标月是否可视(live 实测:面板月标题=SPAN.month,形如「10月」无年份) */
760
+ const YM_VISIBLE = (ymd: string): string => `Array.from(document.querySelectorAll('.month'))
761
+ .some(e => ((e.textContent || '').trim() === '${Number(ymd.slice(5, 7))}月')
762
+ || ((e.textContent || '').trim().includes('${ymd.slice(0, 4)}年${Number(ymd.slice(5, 7))}月')))`
763
+
591
764
  async function createPlaywrightDriver(): Promise<GuiDriver> {
592
765
  const { chromium } = await import('playwright-core')
593
766
  const profile = mkdtempSync(join(tmpdir(), 'gotry-gui-flight-'))
@@ -620,9 +793,11 @@ async function createPlaywrightDriver(): Promise<GuiDriver> {
620
793
  if (isCalendarHint) {
621
794
  capture.calendar = { url, body }
622
795
  } else if (body.includes('flightItineraryList')) {
623
- // 同 URL 会先发配置预检回包(needUserLogin 探测,无行程数据)——只收真结果
624
796
  capture.itinerary = { url, body }
625
797
  itinerarySeen = true
798
+ } else if (body.includes('needUserLogin')) {
799
+ // 配置预检回包(needUserLogin 探测)——作为登录态配对触发信号捕获
800
+ capture.loginProbe = { url, body }
626
801
  }
627
802
  // 行程一到位即早结算(最富数据);否则等日历或超时
628
803
  if (itinerarySeen && apiWaiter) {
@@ -638,8 +813,8 @@ async function createPlaywrightDriver(): Promise<GuiDriver> {
638
813
  const sleep = (ms: number): Promise<void> => page.waitForTimeout(ms)
639
814
  const visibleInput = (i: number) => page.locator('input:visible').nth(i)
640
815
  let lastDepYmd = ''
641
- const cityValues = async (): Promise<{ from: string; to: string }> =>
642
- JSON.parse(String(await page.evaluate<string>(CITY_FIELD_VALUES).catch(() => '{"from":"","to":""}'))) as { from: string; to: string }
816
+ const cityValues = async (seg = 1): Promise<{ from: string; to: string }> =>
817
+ JSON.parse(String(await page.evaluate<string>(CITY_FIELD_VALUES_FN(seg)).catch(() => '{"from":"","to":""}'))) as { from: string; to: string }
643
818
  return {
644
819
  async goto(url) {
645
820
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 40_000 })
@@ -649,7 +824,7 @@ async function createPlaywrightDriver(): Promise<GuiDriver> {
649
824
  await sleep(2500) // hydrate 后的二次稳定
650
825
  },
651
826
  async clickTripTab(trip) {
652
- const label = trip === 'roundtrip' ? '往返' : '单程'
827
+ const label = trip === 'roundtrip' ? '往返' : trip === 'multi' ? '多程' : '单程'
653
828
  try {
654
829
  await page.getByText(label, { exact: true }).first().click({ timeout: 4000 })
655
830
  } catch {
@@ -668,20 +843,46 @@ async function createPlaywrightDriver(): Promise<GuiDriver> {
668
843
  }
669
844
  await sleep(800)
670
845
  },
671
- async typeCity(which, text) {
672
- // 标签即时定位(「出发地」/「目的地」在向上第 4~5 层祖先;就绪闸后 DOM 已稳定)
673
- const idx = await page.evaluate<number>(CITY_INPUT_IDX(which))
846
+ async typeCity(which, text, seg = 1) {
847
+ // 多程行惰性物化(live 实测 2026-10-15):第 N 行城市框在前行填完后才出现——
848
+ // 先等就绪,超时点该行「出发地」标签触发物化再等,仍无则 fail-closed。
849
+ if (seg > 1) {
850
+ const ready = await page.waitForFunction(SEG_READY(seg), { timeout: 6000 }).then(() => true).catch(() => false)
851
+ if (!ready) {
852
+ await page.evaluate<void>(CLICK_SEG_LABEL('出发地', seg)).catch(() => {})
853
+ await page.waitForTimeout(1200)
854
+ const ready2 = await page.waitForFunction(SEG_READY(seg), { timeout: 4000 }).then(() => true).catch(() => false)
855
+ if (!ready2) throw new Error(`第${seg}段城市框未物化(点「出发地」标签后仍未出现)——表单改版?`)
856
+ }
857
+ }
858
+ // 标签即时定位(「出发地」/「目的地」;多程=标签第 N 次出现;就绪闸后 DOM 已稳定)
859
+ let idx = await page.evaluate<number>(seg > 1 ? CITY_INPUT_IDX_N(which, seg) : CITY_INPUT_IDX(which))
860
+ // 「到达」框可能仍惰性:点该行「目的地」标签触发物化后重定位(严格同行,绝不覆写前行)
861
+ if (idx < 0 && seg > 1 && which === 'to') {
862
+ await page.evaluate<void>(CLICK_SEG_LABEL('目的地', seg)).catch(() => {})
863
+ await page.waitForTimeout(1200)
864
+ idx = await page.evaluate<number>(CITY_INPUT_IDX_N(which, seg))
865
+ }
674
866
  if (idx < 0) {
675
867
  await page.screenshot({ path: `/tmp/gotry-gui-typecity-${which}-${Date.now()}.png` }).catch(() => {})
868
+ const rows = String(await page.evaluate<string>(`(() => JSON.stringify({
869
+ inputs: Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent).map(e => {
870
+ const r = e.getBoundingClientRect()
871
+ return { ph: (e.placeholder || '').slice(0, 10), y: Math.round(r.top), v: (e.value || '').slice(0, 16) }
872
+ }).slice(0, 12),
873
+ labels: Array.from(document.querySelectorAll('*'))
874
+ .filter(e => e.offsetParent && e.children.length === 0 && /^(出发地|目的地)$/.test((e.textContent || '').trim()))
875
+ .map(e => { const r = e.getBoundingClientRect(); return { t: (e.textContent || '').trim(), y: Math.round(r.top) } })
876
+ }))()`).catch(() => '?'))
676
877
  const href = String(await page.evaluate<string>('location.href').catch(() => '?'))
677
- throw new Error(`${which === 'from' ? '出发' : '到达'}城市输入框未定位到(标签未命中;href=${href})——表单改版或页面未就绪?`)
878
+ throw new Error(`${which === 'from' ? '出发' : '到达'}城市输入框未定位到(seg=${seg};href=${href});行结构:${rows.slice(0, 700)}`)
678
879
  }
679
880
  const input = visibleInput(idx)
680
881
  await input.click({ timeout: 5000 })
681
882
  await input.fill('', { timeout: 3000 }).catch(() => {})
682
883
  await input.pressSequentially(text, { delay: 160 })
683
884
  },
684
- async pickSuggest(which, text) {
885
+ async pickSuggest(which, text, seg = 1) {
685
886
  // 站点补全默认高亮首项:ArrowDown+Enter 显式选中(live 校准:裸 Enter 可能
686
887
  // 未命中面板,失焦后字段回弹默认值);选中后验证归一化 (XXX),不成立重试一次。
687
888
  const pick = async (): Promise<boolean> => {
@@ -690,15 +891,16 @@ async function createPlaywrightDriver(): Promise<GuiDriver> {
690
891
  await sleep(150)
691
892
  await page.keyboard.press('Enter')
692
893
  await sleep(700)
693
- const values = await cityValues()
894
+ const values = await cityValues(seg)
694
895
  const v = which === 'from' ? values.from : values.to
695
896
  return v.includes(text) && /\([A-Z]{3}\)/.test(v)
696
897
  }
697
898
  if (await pick()) return
698
899
  if (await pick()) return
900
+ if (seg > 1) await sleep(1500) // 行内后续框的物化窗口(live 实测)
699
901
  await page.screenshot({ path: `/tmp/gotry-gui-pick-${which}-${Date.now()}.png` }).catch(() => {})
700
902
  const href = String(await page.evaluate<string>('location.href').catch(() => '?'))
701
- const values = await cityValues()
903
+ const values = await cityValues(seg)
702
904
  const ctxDump = String(await page.evaluate<string>(
703
905
  `(() => JSON.stringify(Array.from(document.querySelectorAll('input'))
704
906
  .filter(e => e.offsetParent && /城市|机场/.test(e.placeholder || '') && !/旅游|搜索/.test(e.placeholder || ''))
@@ -708,9 +910,9 @@ async function createPlaywrightDriver(): Promise<GuiDriver> {
708
910
  ).catch(() => '?'))
709
911
  throw new Error(`城市补全未选中:${which} 框实际「${(which === 'from' ? values.from : values.to).slice(0, 30)}」(期望含 ${text} 且归一化为 (三字码);href=${href});城市框实证:${ctxDump.slice(0, 900)}`)
710
912
  },
711
- async setDate(which, ymd) {
712
- if (which === 'dep') lastDepYmd = ymd
713
- const idx = await page.evaluate<number>(DATE_INPUT_IDX(which))
913
+ async setDate(which, ymd, seg = 1) {
914
+ if (which === 'dep' && seg === 1) lastDepYmd = ymd
915
+ const idx = await page.evaluate<number>(seg > 1 ? DATE_INPUT_IDX_SEG(seg) : DATE_INPUT_IDX(which))
714
916
  const input = visibleInput(idx)
715
917
  await input.click({ timeout: 5000 })
716
918
  await sleep(600)
@@ -725,7 +927,19 @@ async function createPlaywrightDriver(): Promise<GuiDriver> {
725
927
  }, ymd).catch(() => false)
726
928
  if (typed) { await sleep(400); return }
727
929
  // 策略②:日历单元格(aria/title 含完整日期优先;否则面板可见目标月时按日号)
728
- const clicked = await page.evaluate<boolean>(CLICK_DATE_CELL(ymd, which === 'back' ? lastDepYmd : undefined)).catch(() => false)
930
+ let clicked = await page.evaluate<boolean>(CLICK_DATE_CELL(ymd, which === 'back' ? lastDepYmd : undefined)).catch(() => false)
931
+ if (!clicked) {
932
+ // 目标月不在可视双月(多程跨月,live 实测):点 iconf-right 翻月至可见再消歧
933
+ for (let k = 0; k < 12 && !clicked; k += 1) {
934
+ // 真实鼠标事件点击面板翻月箭头(evaluate 的 .click() 实测被 React 层吞)
935
+ const next = page.locator('[class*=in-date-picker][class*=iconf-right]').first()
936
+ const advanced = await next.click({ timeout: 2000 }).then(() => true).catch(() => false)
937
+ if (!advanced) break
938
+ await sleep(600)
939
+ if (!(await page.evaluate<boolean>(YM_VISIBLE(ymd)).catch(() => false))) continue
940
+ clicked = await page.evaluate<boolean>(CLICK_DATE_CELL(ymd, which === 'back' ? lastDepYmd : undefined)).catch(() => false)
941
+ }
942
+ }
729
943
  if (!clicked) {
730
944
  await page.screenshot({ path: `/tmp/gotry-gui-date-${which}-${Date.now()}.png` }).catch(() => {})
731
945
  // 实证诊断:日号叶子(任意可见性)+ 月标题叶子的真实形状,供下一轮校准
@@ -750,6 +964,11 @@ async function createPlaywrightDriver(): Promise<GuiDriver> {
750
964
  async clickSearch() {
751
965
  await page.keyboard.press('Escape').catch(() => {})
752
966
  await sleep(500)
967
+ // 语义优先(live 实测 2026-10-15 多程:几何/叶子/色块三策在该构建全部 miss,
968
+ // role=button name=搜索 命中蓝色大按钮):短超时,失败回落 evaluate 几何族。
969
+ const byRole = page.getByRole('button', { name: '搜索' }).first()
970
+ const roleOk = await byRole.click({ timeout: 5000 }).then(() => true).catch(() => false)
971
+ if (roleOk) { await sleep(600); return }
753
972
  const dumpDir = process.env.GOTRY_GUI_DUMP
754
973
  if (dumpDir) {
755
974
  const cands = String(await page.evaluate<string>(CLICK_FORM_SEARCH_CANDIDATES).catch(() => '{"err":"eval-fail"}'))
@@ -761,8 +980,19 @@ async function createPlaywrightDriver(): Promise<GuiDriver> {
761
980
  async visibleFormText() {
762
981
  return String(await page.evaluate<string>(VIS_FORM_TEXT).catch(() => ''))
763
982
  },
764
- async cityFieldValues() {
765
- return JSON.parse(String(await page.evaluate<string>(CITY_FIELD_VALUES).catch(() => '{"from":"","to":""}'))) as { from: string; to: string }
983
+ async cityFieldValues(seg = 1) {
984
+ return cityValues(seg)
985
+ },
986
+ async waitForHumanLogin(timeoutMs: number) {
987
+ // 红线:窗口导航到携程官方登录页,人自己完成登录;只轮询票据 cookie 的名字(值即弃)。
988
+ await page.goto('https://passport.ctrip.com/user/login?backurl=' + encodeURIComponent('https://flights.ctrip.com/online/channel/domestic'), { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {})
989
+ const deadline = Date.now() + timeoutMs
990
+ while (Date.now() < deadline) {
991
+ const names = await ctx.cookies().then(cs => cs.map(c => c.name)).catch(() => [] as string[])
992
+ if (names.some(n => LOGIN_COOKIE_NAMES.includes(n))) return true
993
+ await page.waitForTimeout(5_000)
994
+ }
995
+ return false
766
996
  },
767
997
  async pageText() {
768
998
  const title = await page.title().catch(() => '')