@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
@@ -6,7 +6,7 @@
6
6
  * 与机票 GUI 车道(gui-flight.ts)同构:动作闭集 + 提交件黑名单 + readback 硬闸 +
7
7
  * 可注入 driver;复用 ctrip-hotel 适配器的三态分类器(classifyCtripHotelList:
8
8
  * parsed/recognized-empty/unrecognized——只有显式空列表才算 miss,绝不猜)。
9
- * 证据链 [会话:ctrip-hotel-gui@ts],恒标 anonymous。
9
+ * 证据链 [会话:ctrip-hotel-gui@ts];anonymous 标注(登录态配对后为 false)。
10
10
  */
11
11
 
12
12
  import { mkdtempSync, rmSync } from 'node:fs'
@@ -19,6 +19,8 @@ import { HOTEL_NETWORK_HINTS, looksLikeHotelListBody, classifyCtripHotelList, ty
19
19
  export const HOTEL_GUI_HOME = 'https://hotels.ctrip.com/'
20
20
  const YMD = /^\d{4}-\d{2}-\d{2}$/
21
21
  const GUI_CHALLENGE_RE = /验证|校验|拖动滑块|请输入验证码/
22
+ /** 登录票据 cookie 名单(与 bridge 车道 LOGIN_COOKIE_NAMES 同源;只读名字) */
23
+ const TICKET_COOKIE_NAMES = ['cticket', 'uid', 'uname', 'passport']
22
24
 
23
25
  export interface GuiHotelQuery {
24
26
  /** 目的地中文(站点自动补全解析,码表不在主链路) */
@@ -27,6 +29,9 @@ export interface GuiHotelQuery {
27
29
  checkIn: string
28
30
  /** 退房 YYYY-MM-DD(须晚于入住) */
29
31
  checkOut: string
32
+ /** 登录态配对(2026-10-15,#664 余尾):匿名列表强制跳 passport 时,窗口停在官方登录页
33
+ * 等用户本人完成登录(cookie 只读名字),随后原窗重驾拿登录态真实价。 */
34
+ interactiveLoginMs?: number
30
35
  timeoutMs?: number
31
36
  }
32
37
 
@@ -102,6 +107,8 @@ export interface GuiHotelPage {
102
107
  pageText(): Promise<string>
103
108
  /** 当前页 URL(list 页真实形状回传) */
104
109
  currentUrl(): Promise<string>
110
+ /** 登录态配对:窗口停在 passport 登录页等人完成登录(cookie 只读名字);超时 false */
111
+ waitForHumanLogin(timeoutMs: number): Promise<boolean>
105
112
  waitForApiBody(timeoutMs: number): Promise<{ url: string; body: string } | null>
106
113
  recentApiUrls?(): Promise<string[]>
107
114
  }
@@ -121,7 +128,7 @@ export interface GuiHotelResult {
121
128
  verdict: GuiHotelVerdict
122
129
  hotels?: SessionHotelOption[]
123
130
  error?: string
124
- anonymous: true
131
+ anonymous: boolean
125
132
  /** 检索后站点落到的 list 页 URL(真实形状回传) */
126
133
  listUrl?: string
127
134
  }
@@ -134,10 +141,13 @@ export function __setHotelDriverForTest(factory: (() => GuiHotelDriver) | null):
134
141
  export async function runHotelGuiSearch(q: GuiHotelQuery): Promise<GuiHotelResult> {
135
142
  const started = Date.now()
136
143
  const ts = new Date().toISOString()
144
+ let humanLoggedIn = false
137
145
  const fail = (error: string, verdict: GuiHotelVerdict = 'error'): GuiHotelResult => ({
138
146
  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,
147
+ evidence: `[会话:ctrip-hotel-gui@error@${ts}] ${error.slice(0, 120)}`, anonymous: !humanLoggedIn,
140
148
  })
149
+ const needsLoginMessage = (): string =>
150
+ '酒店列表需登录态:匿名窗口被重定向到携程登录页(红线:不自动登录)——interactiveLogin=true 可在窗口内本人登录后自动重查,或 gotry_session_login 后走 bridge 车道'
141
151
 
142
152
  if (!(q.checkIn < q.checkOut)) return fail(`退房日期(${q.checkOut})须晚于入住(${q.checkIn})`)
143
153
 
@@ -157,10 +167,11 @@ export async function runHotelGuiSearch(q: GuiHotelQuery): Promise<GuiHotelResul
157
167
 
158
168
  let api: { url: string; body: string } | null = null
159
169
  let listUrl = ''
160
- try {
170
+ class HotelFlowAbort { constructor(public result: GuiHotelResult) {} }
171
+ const driveOnce = async (): Promise<void> => {
161
172
  for (const a of plan) {
162
173
  const remain = budget - (Date.now() - started)
163
- if (remain <= 0) return fail(`GUI 预算耗尽(${budget}ms)`)
174
+ if (remain <= 0) throw new HotelFlowAbort(fail(`GUI 预算耗尽(${budget}ms)`))
164
175
  const step = <T,>(p: Promise<T>, what: string): Promise<T> =>
165
176
  withDeadline(p, Math.min(remain, 20_000), `GUI 步骤失败(${what})`)
166
177
  switch (a.t) {
@@ -176,7 +187,7 @@ export async function runHotelGuiSearch(q: GuiHotelQuery): Promise<GuiHotelResul
176
187
  {
177
188
  const after = await page.currentUrl().catch(() => '')
178
189
  if (/passport\.ctrip\.com\/.*login/i.test(after)) {
179
- return fail('酒店列表需登录态:匿名窗口被重定向到携程登录页(红线:不自动登录)——gotry_session_login 为用户打开官方登录入口后走 bridge 车道(ui 默认),或登录后重试', 'needs-login')
190
+ throw new HotelFlowAbort(fail('__NEEDS_LOGIN__', 'needs-login'))
180
191
  }
181
192
  }
182
193
  break
@@ -184,7 +195,7 @@ export async function runHotelGuiSearch(q: GuiHotelQuery): Promise<GuiHotelResul
184
195
  const dest = await step(page.destFieldValue(), '读回目的地框')
185
196
  const form = await step(page.visibleFormText(), '读回表单')
186
197
  if (!dest.includes(a.expect.to)) {
187
- return fail(`readback 硬闸:目的地框未确认「${a.expect.to}」(实际「${dest.slice(0, 30)}」)——已终止,未发起搜索`)
198
+ throw new HotelFlowAbort(fail(`readback 硬闸:目的地框未确认「${a.expect.to}」(实际「${dest.slice(0, 30)}」)——已终止,未发起搜索`))
188
199
  }
189
200
  // live 校准:酒店表单日期显示为本地化格式(如 11月13日(周五))而非 YMD——双格式核对
190
201
  const dateShown = (ymd: string): boolean => {
@@ -193,7 +204,7 @@ export async function runHotelGuiSearch(q: GuiHotelQuery): Promise<GuiHotelResul
193
204
  }
194
205
  for (const [label, value] of [['入住日期', a.expect.checkIn], ['退房日期', a.expect.checkOut]] as const) {
195
206
  if (!dateShown(value)) {
196
- return fail(`readback 硬闸:${label}「${value}」未在表单确认(表单摘要:${form.slice(0, 200)})——已终止,未发起搜索`)
207
+ throw new HotelFlowAbort(fail(`readback 硬闸:${label}「${value}」未在表单确认(表单摘要:${form.slice(0, 200)})——已终止,未发起搜索`))
197
208
  }
198
209
  }
199
210
  break
@@ -201,32 +212,65 @@ export async function runHotelGuiSearch(q: GuiHotelQuery): Promise<GuiHotelResul
201
212
  case 'awaitApi': {
202
213
  const text = await step(page.pageText(), '检查页面')
203
214
  if (GUI_CHALLENGE_RE.test(text)) {
204
- return fail('风控/验证码命中;按红线不重试不绕过,交还用户', 'challenged')
215
+ throw new HotelFlowAbort(fail('风控/验证码命中;按红线不重试不绕过,交还用户', 'challenged'))
205
216
  }
206
217
  api = await step(page.waitForApiBody(Math.min(remain - 500, 18_000)), '等待酒店列表回包')
207
218
  if (!api) {
208
219
  const recent = page.recentApiUrls ? (await page.recentApiUrls().catch(() => [])) : []
209
220
  const nowUrl = await page.currentUrl().catch(() => '?')
210
221
  if (/passport\.ctrip\.com\/.*login/i.test(nowUrl)) {
211
- return fail('酒店列表需登录态:匿名窗口被重定向到携程登录页(红线:不自动登录)——gotry_session_login 为用户打开官方登录入口后走 bridge 车道,或登录后重试', 'needs-login')
222
+ throw new HotelFlowAbort(fail('__NEEDS_LOGIN__', 'needs-login'))
212
223
  }
213
- return fail(`酒店列表回包嗅探超时(href=${nowUrl.slice(0, 120)})${recent.length ? ` ‖recent-apis: ${recent.slice(-8).join(' ; ').slice(0, 500)}` : ''}`)
224
+ throw new HotelFlowAbort(fail(`酒店列表回包嗅探超时(href=${nowUrl.slice(0, 120)})${recent.length ? ` ‖recent-apis: ${recent.slice(-8).join(' ; ').slice(0, 500)}` : ''}`))
214
225
  }
215
226
  break
216
227
  }
217
228
  }
218
229
  }
219
- listUrl = await page.currentUrl().catch(() => '')
230
+ return
231
+ }
232
+
233
+ try {
234
+ await driveOnce()
220
235
  } catch (e) {
221
- return fail((e as Error).message.slice(0, 400))
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
+ }
222
262
  } finally {
223
263
  await driver.close().catch(() => {})
224
264
  }
265
+ try {
266
+ listUrl = await page.currentUrl().catch(() => '')
267
+ } catch { /* 已关窗口 */ }
225
268
 
226
- if (!api) return fail('内部不变量失败:api 未结算(awaitApi 已过)')
227
- const classified = classifyCtripHotelList(api.body)
269
+ const captured = api as { url: string; body: string } | null
270
+ if (captured === null) return fail('内部不变量失败:api 未结算(awaitApi 已过)')
271
+ const classified = classifyCtripHotelList(captured.body)
228
272
  if (classified.shape === 'unrecognized') {
229
- return fail(`酒店列表回包形状未识别(body头:${api.body.replace(/\s+/g, ' ').slice(0, 240)})`)
273
+ return fail(`酒店列表回包形状未识别(body头:${captured.body.replace(/\s+/g, ' ').slice(0, 240)})`)
230
274
  }
231
275
  if (classified.shape === 'malformed') {
232
276
  return fail('酒店列表回包非 JSON(接口改版?)')
@@ -237,8 +281,8 @@ export async function runHotelGuiSearch(q: GuiHotelQuery): Promise<GuiHotelResul
237
281
  ok: verdict === 'hit', via: 'session-ctrip-hotel-gui', latencyMs: Date.now() - started, verdict,
238
282
  ...(hotels.length > 0 ? { hotels: hotels.slice(0, 20) } : {}),
239
283
  ...(listUrl ? { listUrl } : {}),
240
- evidence: `[会话:ctrip-hotel-gui@${ts}] 匿名 GUI 面检索:${hotels.length} 家(shape=${classified.shape};匿名公开价,非登录态价)`,
241
- anonymous: true,
284
+ evidence: `[会话:ctrip-hotel-gui@${ts}] GUI 面检索:${hotels.length} 家(shape=${classified.shape};${humanLoggedIn ? '用户本人已在本窗口官方页登录,登录态真实价' : '匿名公开价,非登录态价'})`,
285
+ anonymous: !humanLoggedIn,
242
286
  }
243
287
  }
244
288
 
@@ -431,6 +475,16 @@ async function createHotelPlaywrightDriver(): Promise<GuiHotelDriver> {
431
475
  return title + text
432
476
  },
433
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
+ },
434
488
  async recentApiUrls() { return [...recentUrls] },
435
489
  async waitForApiBody(timeoutMs) {
436
490
  return new Promise(resolve => {
@@ -66,6 +66,10 @@ export interface SessionFlightQuery {
66
66
  date: string
67
67
  /** 回程日期:存在即往返检索——bridge 车道不知往返 URL 形状(诚实边界,报错指路 GUI);GUI 车道原生支持 */
68
68
  backDate?: string
69
+ /** 多程段(2–3 段;仅 ui=gui;存在时忽略顶层 from/to/date/backDate) */
70
+ legs?: Array<{ from: string; to: string; date: string }>
71
+ /** 登录态配对等待上限 ms(仅 ui=gui;needUserLogin/passport 命中时窗口停官方登录页等人) */
72
+ interactiveLoginMs?: number
69
73
  /** 隔离 profile 目录(测试必传;默认 /tmp 专用目录) */
70
74
  profileDir?: string
71
75
  headless?: boolean
@@ -149,6 +153,8 @@ export function appendExtensionAudit(auditPath: string | undefined, entry: { kin
149
153
  // ---------------------------------------------------------------------------
150
154
 
151
155
  export interface SessionHotelQuery {
156
+ /** 登录态配对等待上限 ms(仅 ui=gui;passport 命中时窗口停官方登录页等人) */
157
+ interactiveLoginMs?: number
152
158
  /** 目的地(中文);城市码表未收录时须带 cityId */
153
159
  to: string
154
160
  /** 显式城市 id(携程/trip.com 酒店 list 页 URL 的 city= 数字;覆盖码表) */
@@ -216,7 +222,7 @@ export async function sessionHotelGuiSearch(q: SessionHotelQuery): Promise<Sessi
216
222
  if (!q.checkIn || !q.checkOut) {
217
223
  return err('error', 'GUI 车道需要 checkIn/checkOut(YYYY-MM-DD,成对)')
218
224
  }
219
- const r = await runHotelGuiSearch({ to: q.to, checkIn: q.checkIn, checkOut: q.checkOut, ...(q.timeoutMs ? { timeoutMs: q.timeoutMs } : {}) })
225
+ const r = await runHotelGuiSearch({ to: q.to, checkIn: q.checkIn, checkOut: q.checkOut, ...(q.interactiveLoginMs ? { interactiveLoginMs: q.interactiveLoginMs } : {}), ...(q.timeoutMs ? { timeoutMs: Math.max(q.timeoutMs, q.interactiveLoginMs ? q.interactiveLoginMs + 120_000 : 0) } : {}) })
220
226
  return {
221
227
  ok: r.ok, via: r.via, evidence: r.evidence, latencyMs: r.latencyMs, verdict: r.verdict,
222
228
  ...(r.hotels ? { hotels: r.hotels } : {}),
@@ -410,6 +416,11 @@ export interface SessionDidaQuery {
410
416
  * 远程扩展经鉴权面连入;缺省走 loopback 懒单例(桌面形态)
411
417
  */
412
418
  bridge?: SessionJobHandle
419
+ /**
420
+ * 进程内节律闸的键(AaaS A2):缺省 = 站点,单车道行为不变。多组织形态的挂载模块传 `<组织>/<站点>`,
421
+ * 一个组织的检索不给另一个组织定节律,另一个组织也读不到「上一次是多久前」。
422
+ */
423
+ pacingKey?: string
413
424
  }
414
425
 
415
426
  export interface SessionDidaResult {
@@ -465,12 +476,13 @@ export async function sessionDidaSearch(q: SessionDidaQuery): Promise<SessionDid
465
476
  ok: false, via: 'session-dida-portal-error', evidence: `[会话:${site}@error@${ts}] ${error}`, latencyMs: Date.now() - started, verdict, error,
466
477
  })
467
478
 
468
- // 节律闸:超间隔即拒,不发起导航
469
- const last = lastCallAt.get(site) ?? 0
479
+ // 节律闸:超间隔即拒,不发起导航。键缺省是站点;多组织形态按组织分键(见 SessionDidaQuery.pacingKey)
480
+ const paceKey = q.pacingKey ?? site
481
+ const last = lastCallAt.get(paceKey) ?? 0
470
482
  if (Date.now() - last < MIN_INTERVAL_MS) {
471
483
  return err('cooldown', `rate limit: last call ${Date.now() - last}ms ago, min ${MIN_INTERVAL_MS}ms`)
472
484
  }
473
- lastCallAt.set(site, Date.now())
485
+ lastCallAt.set(paceKey, Date.now())
474
486
 
475
487
  const entry = buildDidaEntryUrl({ entryUrl: q.entryUrl })
476
488
  if (!entry.ok || !entry.url) {
@@ -981,7 +993,7 @@ export async function sessionFlightGuiSearch(q: SessionFlightQuery): Promise<Ses
981
993
  return err('error', `回程日期(${q.backDate})须晚于去程(${q.date})`)
982
994
  }
983
995
 
984
- const r = await runFlightGuiSearch({ from: q.from, to: q.to, date: q.date, ...(q.backDate ? { backDate: q.backDate } : {}), ...(q.timeoutMs ? { timeoutMs: q.timeoutMs } : {}) })
996
+ const r = await runFlightGuiSearch({ from: q.from, to: q.to, date: q.date, ...(q.backDate ? { backDate: q.backDate } : {}), ...(q.legs ? { legs: q.legs } : {}), ...(q.interactiveLoginMs ? { interactiveLoginMs: q.interactiveLoginMs } : {}), ...(q.timeoutMs ? { timeoutMs: Math.max(q.timeoutMs, q.interactiveLoginMs ? q.interactiveLoginMs + 120_000 : 0) } : {}) })
985
997
  return {
986
998
  ok: r.ok, via: r.via, evidence: r.evidence, latencyMs: r.latencyMs,
987
999
  verdict: r.verdict,