@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
@@ -29,7 +29,7 @@ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
29
29
  import { tmpdir } from 'node:os';
30
30
  import { join } from 'node:path';
31
31
  import { isSubmitText } from './read-guard.js';
32
- import { NETWORK_HINTS, parseBatchSearchResult } from './adapters/ctrip-flight.js';
32
+ import { NETWORK_HINTS, parseBatchSearchResult, LOGIN_COOKIE_NAMES } from './adapters/ctrip-flight.js';
33
33
  export const FLIGHT_GUI_HOME = 'https://flights.ctrip.com/';
34
34
  /**
35
35
  * 低价日历接口(live 校准 2026-10-14):新版 PC 匿名 batchSearch 只回 needUserLogin
@@ -41,7 +41,19 @@ const YMD = /^\d{4}-\d{2}-\d{2}$/;
41
41
  const GUI_CHALLENGE_RE = /验证|校验|拖动滑块|请输入验证码/;
42
42
  /** 纯函数:查询 → 动作序列(离线测试锚点;执行前必须过 assertGuiPlanSafe) */
43
43
  export function planFlightGuiActions(q) {
44
- const expect = { from: q.from, to: q.to, dep: q.date, ...(q.backDate ? { back: q.backDate } : {}) };
44
+ // 多程(2026-10-15 live 实测:三行 [出发地|目的地|出发日期],标签第 N 次出现消歧)
45
+ if (q.legs && q.legs.length >= 2) {
46
+ const actions = [
47
+ { t: 'goto', url: FLIGHT_GUI_HOME },
48
+ { t: 'clickTripTab', trip: 'multi' },
49
+ ];
50
+ q.legs.slice(0, 3).forEach((leg, i) => {
51
+ const seg = i + 1;
52
+ actions.push({ t: 'typeCity', which: 'from', text: leg.from, seg }, { t: 'pickSuggest', which: 'from', text: leg.from, seg }, { t: 'typeCity', which: 'to', text: leg.to, seg }, { t: 'pickSuggest', which: 'to', text: leg.to, seg }, { t: 'setDate', which: 'dep', ymd: leg.date, seg });
53
+ });
54
+ actions.push({ t: 'press', key: 'Escape' }, { t: 'readback', expect: { legs: q.legs.slice(0, 3).map(l => ({ from: l.from, to: l.to, date: l.date })) } }, { t: 'clickSearch' }, { t: 'awaitApi' });
55
+ return actions;
56
+ }
45
57
  // live 校准(2026-10-14 pre-search 截图):该表单默认即「往返」态(双日期框预填)——
46
58
  // 单程必须显式点「单程」,否则按非法日期对(回<去)搜索且日历面板挂在回程框上。
47
59
  const tripTab = [{ t: 'clickTripTab', trip: q.backDate ? 'roundtrip' : 'oneway' }];
@@ -56,7 +68,7 @@ export function planFlightGuiActions(q) {
56
68
  { t: 'setDate', which: 'dep', ymd: q.date },
57
69
  ...backDate,
58
70
  { t: 'press', key: 'Escape' },
59
- { t: 'readback', expect },
71
+ { t: 'readback', expect: { legs: [{ from: q.from, to: q.to, date: q.date, ...(q.backDate ? { back: q.backDate } : {}) }] } },
60
72
  { t: 'clickSearch' },
61
73
  { t: 'awaitApi' },
62
74
  ];
@@ -73,7 +85,7 @@ export function assertGuiPlanSafe(plan) {
73
85
  return { ok: false, error: `GUI 车道只允许固定入口 ${FLIGHT_GUI_HOME},拒绝 ${a.url}` };
74
86
  break;
75
87
  case 'clickTripTab':
76
- if (a.trip !== 'oneway' && a.trip !== 'roundtrip')
88
+ if (a.trip !== 'oneway' && a.trip !== 'roundtrip' && a.trip !== 'multi')
77
89
  return { ok: false, error: `trip 闭集外:${a.trip}` };
78
90
  break;
79
91
  case 'typeCity':
@@ -85,6 +97,8 @@ export function assertGuiPlanSafe(plan) {
85
97
  return { ok: false, error: `城市位出现提交件文本,拒绝:${text}` };
86
98
  if (a.which !== 'from' && a.which !== 'to')
87
99
  return { ok: false, error: `which 闭集外:${a.which}` };
100
+ if (a.seg !== undefined && (![1, 2, 3].includes(a.seg)))
101
+ return { ok: false, error: `seg 闭集外:${a.seg}` };
88
102
  break;
89
103
  }
90
104
  case 'setDate':
@@ -92,6 +106,8 @@ export function assertGuiPlanSafe(plan) {
92
106
  return { ok: false, error: `日期需 YYYY-MM-DD,实际 ${a.ymd}` };
93
107
  if (a.which !== 'dep' && a.which !== 'back')
94
108
  return { ok: false, error: `which 闭集外:${a.which}` };
109
+ if (a.seg !== undefined && (![1, 2, 3].includes(a.seg)))
110
+ return { ok: false, error: `seg 闭集外:${a.seg}` };
95
111
  break;
96
112
  case 'press':
97
113
  if (a.key !== 'Escape' && a.key !== 'Enter')
@@ -100,9 +116,14 @@ export function assertGuiPlanSafe(plan) {
100
116
  case 'clickSearch':
101
117
  break; // 「搜索」是本车道唯一允许的按钮;提交件黑名单语义(下单/支付)不在其列
102
118
  case 'readback':
103
- for (const v of [a.expect.from, a.expect.to, a.expect.dep, a.expect.back]) {
104
- if (v !== undefined && (!v.trim() || v.length > 24))
105
- return { ok: false, error: `readback 期望值非法:${v}` };
119
+ if (!Array.isArray(a.expect.legs) || a.expect.legs.length < 1 || a.expect.legs.length > 3) {
120
+ return { ok: false, error: 'readback legs 须 1–3 段' };
121
+ }
122
+ for (const leg of a.expect.legs) {
123
+ for (const v of [leg.from, leg.to, leg.date, leg.back]) {
124
+ if (v !== undefined && (!v.trim() || v.length > 24))
125
+ return { ok: false, error: `readback 期望值非法:${v}` };
126
+ }
106
127
  }
107
128
  break;
108
129
  case 'awaitApi':
@@ -125,8 +146,9 @@ export function __setGuiDriverForTest(factory) {
125
146
  export async function runFlightGuiSearch(q) {
126
147
  const started = Date.now();
127
148
  const ts = new Date().toISOString();
128
- const trip = q.backDate ? 'roundtrip' : 'oneway';
129
- const base = { anonymous: true, trip };
149
+ const trip = q.legs && q.legs.length >= 2 ? 'multi' : q.backDate ? 'roundtrip' : 'oneway';
150
+ let humanLoggedIn = false;
151
+ const base = { get anonymous() { return !humanLoggedIn; }, trip };
130
152
  const fail = (error, verdict = 'error') => ({
131
153
  ok: false, via: 'session-ctrip-flight-gui-error', latencyMs: Date.now() - started, verdict, error,
132
154
  evidence: `[会话:ctrip-flight-gui@error@${ts}] ${error.slice(0, 120)}`, ...base,
@@ -144,33 +166,42 @@ export async function runFlightGuiSearch(q) {
144
166
  }
145
167
  catch (e) {
146
168
  await driver.close().catch(() => { });
147
- return fail(e.message.slice(0, 1200));
169
+ return fail(e.message.slice(0, 500));
148
170
  }
149
171
  let apiBody = null;
150
172
  let listUrl = '';
151
- try {
173
+ /** 流程性失败(硬闸/挑战页/超时)以结果对象抛出,由外层统一返回 */
174
+ class GuiFlowAbort {
175
+ result;
176
+ constructor(result) {
177
+ this.result = result;
178
+ }
179
+ }
180
+ /** 单次驾驶:整计划走到结果回包,返回捕获 */
181
+ const driveOnce = async () => {
182
+ let localCapture = null;
152
183
  for (const a of plan) {
153
184
  const remain = budget - (Date.now() - started);
154
185
  if (remain <= 0)
155
- return fail(`GUI 预算耗尽(${budget}ms)`);
186
+ throw new GuiFlowAbort(fail(`GUI 预算耗尽(${budget}ms)`));
156
187
  const step = (p, what) => withDeadline(p, Math.min(remain, 20_000), `GUI 步骤失败(${what})`);
157
188
  switch (a.t) {
158
189
  case 'goto':
159
190
  await step(page.goto(a.url), '打开携程机票首页');
160
191
  break;
161
192
  case 'clickTripTab':
162
- await step(page.clickTripTab(a.trip), a.trip === 'roundtrip' ? '切换往返' : '切换单程');
193
+ await step(page.clickTripTab(a.trip), `切换${a.trip === 'roundtrip' ? '往返' : a.trip === 'multi' ? '多程' : '单程'}`);
163
194
  break;
164
195
  case 'typeCity':
165
- await step(page.typeCity(a.which, a.text), `填写${a.which === 'from' ? '出发' : '到达'}城市`);
196
+ await step(page.typeCity(a.which, a.text, a.seg ?? 1), `填写第${a.seg ?? 1}段${a.which === 'from' ? '出发' : '到达'}城市`);
166
197
  break;
167
198
  case 'pickSuggest':
168
- await step(page.pickSuggest(a.which, a.text), '选择城市自动补全项');
169
- if (a.which === 'to')
170
- trace.push(`after to-pick: ${JSON.stringify(await page.cityFieldValues().catch(() => ({})))}`);
199
+ await step(page.pickSuggest(a.which, a.text, a.seg ?? 1), '选择城市自动补全项');
200
+ if (a.which === 'to' && (a.seg ?? 1) === 1)
201
+ trace.push(`after to-pick: ${JSON.stringify(await page.cityFieldValues(1).catch(() => ({})))}`);
171
202
  break;
172
203
  case 'setDate':
173
- await step(page.setDate(a.which, a.ymd), `设置${a.which === 'dep' ? '去程' : '回程'}日期`);
204
+ await step(page.setDate(a.which, a.ymd, a.seg ?? 1), `设置第${a.seg ?? 1}段${a.which === 'dep' ? '去程' : '回程'}日期`);
174
205
  break;
175
206
  case 'press':
176
207
  await step(page.press(a.key), `按键 ${a.key}`);
@@ -179,23 +210,17 @@ export async function runFlightGuiSearch(q) {
179
210
  await step(page.clickSearch(), '点击搜索');
180
211
  break;
181
212
  case 'readback': {
182
- // 城市:逐字段核对出发/到达框(占位符同名的全局搜索框/相邻框文本不再能假阳)
183
- const cities = await step(page.cityFieldValues(), '读回城市框');
184
213
  const form = await step(page.visibleFormText(), '读回表单');
185
- const cityChecks = [
186
- ['出发城市', cities.from, a.expect.from], ['到达城市', cities.to, a.expect.to],
187
- ];
188
- for (const [label, actual, value] of cityChecks) {
189
- if (!actual.includes(value)) {
190
- return fail(`readback 硬闸:${label}框未确认「${value}」(实际「${actual.slice(0, 30)}」;表单摘要:${form.slice(0, 100)})——已终止,未发起搜索`);
214
+ for (const [i, leg] of a.expect.legs.entries()) {
215
+ const cities = await step(page.cityFieldValues(i + 1), `读回第${i + 1}段城市框`);
216
+ if (!cities.from.includes(leg.from) || !cities.to.includes(leg.to)) {
217
+ 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)})——已终止,未发起搜索`));
191
218
  }
192
- }
193
- const dateChecks = [
194
- ['去程日期', a.expect.dep], ...(a.expect.back ? [['回程日期', a.expect.back]] : []),
195
- ];
196
- for (const [label, value] of dateChecks) {
197
- if (!form.includes(value)) {
198
- return fail(`readback 硬闸:${label}「${value}」未在表单确认(表单摘要:${form.slice(0, 120)})——已终止,未发起搜索`);
219
+ if (!form.includes(leg.date)) {
220
+ throw new GuiFlowAbort(fail(`readback 硬闸:第${i + 1}段日期「${leg.date}」未在表单确认(表单摘要:${form.slice(0, 120)})——已终止,未发起搜索`));
221
+ }
222
+ if (leg.back && !form.includes(leg.back)) {
223
+ throw new GuiFlowAbort(fail(`readback 硬闸:回程日期「${leg.back}」未在表单确认——已终止,未发起搜索`));
199
224
  }
200
225
  }
201
226
  break;
@@ -203,20 +228,37 @@ export async function runFlightGuiSearch(q) {
203
228
  case 'awaitApi': {
204
229
  const text = await step(page.pageText(), '检查页面');
205
230
  if (GUI_CHALLENGE_RE.test(text)) {
206
- return fail('风控/验证码命中;按红线不重试不绕过,交还用户', 'challenged');
231
+ throw new GuiFlowAbort(fail('风控/验证码命中;按红线不重试不绕过,交还用户', 'challenged'));
207
232
  }
208
- apiBody = await step(page.waitForApiCapture(Math.min(remain - 500, 18_000)), '等待结果回包(batchSearch/低价日历)');
209
- if (!apiBody || (!apiBody.itinerary && !apiBody.calendar)) {
233
+ localCapture = await step(page.waitForApiCapture(Math.min(remain - 500, 18_000)), '等待结果回包(batchSearch/低价日历)');
234
+ if (!localCapture || (!localCapture.itinerary && !localCapture.calendar)) {
210
235
  const recent = page.recentApiUrls ? (await page.recentApiUrls().catch(() => [])) : [];
211
- return fail(`结果回包嗅探超时:batchSearch(行程)与低价日历均未收到;本次检索未完成${recent.length ? ` ‖recent-apis: ${recent.slice(-8).join(' ; ').slice(0, 600)}` : ''}`);
236
+ throw new GuiFlowAbort(fail(`结果回包嗅探超时:batchSearch(行程)与低价日历均未收到;本次检索未完成${recent.length ? ` ‖recent-apis: ${recent.slice(-8).join(' ; ').slice(0, 600)}` : ''}`));
212
237
  }
213
238
  break;
214
239
  }
215
240
  }
216
241
  }
242
+ return localCapture;
243
+ };
244
+ try {
245
+ apiBody = await driveOnce();
246
+ // 登录态配对(#661 余尾,2026-10-15):匿名探测(needUserLogin)命中且调用方授权等待时,
247
+ // 窗口停在携程官方登录页等用户本人完成登录(cookie 只读名字),随后原窗重驾一次拿登录态明细。
248
+ if (q.interactiveLoginMs && q.interactiveLoginMs > 0 && apiBody?.loginProbe && !apiBody.itinerary) {
249
+ const loggedIn = await withDeadline(page.waitForHumanLogin(q.interactiveLoginMs), q.interactiveLoginMs + 5_000, '等待人工登录');
250
+ if (!loggedIn) {
251
+ return fail(`登录等待超时(${q.interactiveLoginMs}ms):用户未在 GUI 窗口完成登录——本次未取得登录态行程;可稍后带 interactiveLogin 重试或用匿名日历面`, 'needs-login');
252
+ }
253
+ humanLoggedIn = true;
254
+ trace.push('human-login ok; re-drive');
255
+ apiBody = await driveOnce();
256
+ }
217
257
  listUrl = await page.currentUrl().catch(() => '');
218
258
  }
219
259
  catch (e) {
260
+ if (e instanceof GuiFlowAbort)
261
+ return e.result;
220
262
  return fail(`${e.message.slice(0, 500)}${trace.length ? ` ‖trace: ${trace.join(' ; ').slice(0, 400)}` : ''}`);
221
263
  }
222
264
  finally {
@@ -236,7 +278,7 @@ export async function runFlightGuiSearch(q) {
236
278
  ...(verdict === 'hit' ? { options: parsed.options } : {}),
237
279
  ...(calendar && calendar.length > 0 ? { calendar } : {}),
238
280
  ...(listUrl ? { listUrl } : {}),
239
- evidence: `[会话:ctrip-flight-gui@${ts}] ${trip} 匿名 GUI 面检索:${parsed.options.length} 条(transport=gui shape=${parsed.shape}${calendar?.length ? `;calendar=${calendar.length}天` : ''})`,
281
+ evidence: `[会话:ctrip-flight-gui@${ts}] ${trip} GUI 面检索:${parsed.options.length} 条(transport=gui shape=${parsed.shape}${calendar?.length ? `;calendar=${calendar.length}天` : ''};${humanLoggedIn ? '用户本人已在本窗口官方页登录,登录态真实数据' : '匿名公开面'})`,
240
282
  ...base,
241
283
  };
242
284
  }
@@ -248,7 +290,7 @@ export async function runFlightGuiSearch(q) {
248
290
  return {
249
291
  ok: true, via: 'session-ctrip-flight-gui', latencyMs: Date.now() - started, verdict: 'hit',
250
292
  calendar, ...(listUrl ? { listUrl } : {}),
251
- evidence: `[会话:ctrip-flight-gui@${ts}] ${trip} 匿名 GUI 面·低价日历:${calendar.length} 天(行程明细需登录态,见 #661;日历为匿名公开价)`,
293
+ evidence: `[会话:ctrip-flight-gui@${ts}] ${trip} 匿名 GUI 面·低价日历:${calendar.length} 天(行程明细需登录态:interactiveLogin=true 可在窗口内本人登录后自动重查)`,
252
294
  ...base,
253
295
  };
254
296
  }
@@ -362,7 +404,7 @@ const CITY_INPUT_IDX_FROM_LEAVES = (which) => `(() => {
362
404
  const leaves = Array.from(document.querySelectorAll('*'))
363
405
  .filter(e => e.offsetParent && e.children.length === 0)
364
406
  const isCityInput = (e) => e.tagName === 'INPUT'
365
- && /城市|机场/.test(e.placeholder || '') && !/旅游|搜索/.test(e.placeholder || '')
407
+ && (!e.placeholder || /城市|机场/.test(e.placeholder)) && !/旅游|搜索|请输入/.test(e.placeholder || '')
366
408
  const inputs = leaves.map((e, i) => ({ e, i })).filter(({ e }) => isCityInput(e))
367
409
  const visibleInputs = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
368
410
  const nearest = (label) => {
@@ -382,27 +424,58 @@ const CITY_INPUT_IDX_FROM_LEAVES = (which) => `(() => {
382
424
  }
383
425
  return nearest(${which === 'from' ? "'出发地'" : "'目的地'"}) })()`;
384
426
  const CITY_INPUT_IDX = CITY_INPUT_IDX_FROM_LEAVES;
427
+ /** 多程行定位(live 实测 2026-10-15):三行 [出发地|目的地|出发日期],标签同名——
428
+ * 「出发地/目的地」第 N 次出现(occurrence)的 ±10 叶子窗口内城市输入框即第 N 段框。 */
429
+ const CITY_INPUT_IDX_N = (which, occurrence) => `(() => {
430
+ const leaves = Array.from(document.querySelectorAll('*'))
431
+ .filter(e => e.offsetParent && e.children.length === 0)
432
+ const isCityInput = (e) => e.tagName === 'INPUT'
433
+ && (!e.placeholder || /城市|机场/.test(e.placeholder)) && !/旅游|搜索|请输入/.test(e.placeholder || '')
434
+ const inputs = leaves.map((e, i) => ({ e, i })).filter(({ e }) => isCityInput(e))
435
+ const visibleInputs = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
436
+ const label = ${which === 'from'} ? '出发地' : '目的地'
437
+ const yOf = (e) => { const r = e.getBoundingClientRect(); return r.top + r.height / 2 }
438
+ let seen = 0
439
+ for (let li = 0; li < leaves.length; li += 1) {
440
+ if ((leaves[li].textContent || '').trim() !== label) continue
441
+ seen += 1
442
+ if (seen !== ${occurrence}) continue
443
+ const ly = yOf(leaves[li])
444
+ let best = -1, bestDist = 99
445
+ for (const inp of inputs) {
446
+ const d = Math.abs(inp.i - li)
447
+ // 同行 y 对齐(±60px):防止上一行已物化框抢亲(多程行惰性物化,live 实测)
448
+ if (d < bestDist && d <= 10 && Math.abs(yOf(inp.e) - ly) <= 60) { best = inp.i; bestDist = d }
449
+ }
450
+ if (best >= 0) return visibleInputs.indexOf(leaves[best])
451
+ }
452
+ return -1 })()`;
385
453
  /** 城市框当前值(出发/到达各自;readback 逐字段核对用,防全局搜索框文本假阳) */
386
- const CITY_FIELD_VALUES = `(() => {
454
+ const CITY_FIELD_VALUES_FN = (occurrence) => `(() => {
387
455
  const leaves = Array.from(document.querySelectorAll('*'))
388
456
  .filter(e => e.offsetParent && e.children.length === 0)
389
457
  const isCityInput = (e) => e.tagName === 'INPUT'
390
- && /城市|机场/.test(e.placeholder || '') && !/旅游|搜索/.test(e.placeholder || '')
458
+ && (!e.placeholder || /城市|机场/.test(e.placeholder)) && !/旅游|搜索|请输入/.test(e.placeholder || '')
391
459
  const inputs = leaves.map((e, i) => ({ e, i })).filter(({ e }) => isCityInput(e))
392
- const nearest = (label) => {
393
- let li = -1
460
+ const nearest = (label, occurrence) => {
461
+ let li = -1, seen = 0
394
462
  for (let i = 0; i < leaves.length; i += 1) {
395
- if ((leaves[i].textContent || '').trim() === label) { li = i; break }
463
+ if ((leaves[i].textContent || '').trim() === label) {
464
+ seen += 1
465
+ if (seen === (occurrence || 1)) { li = i; break }
466
+ }
396
467
  }
397
468
  if (li < 0) return null
469
+ const yOf = (e) => { const r = e.getBoundingClientRect(); return r.top + r.height / 2 }
470
+ const ly = yOf(leaves[li])
398
471
  let best = null, bestDist = 99
399
472
  for (const inp of inputs) {
400
473
  const d = Math.abs(inp.i - li)
401
- if (d < bestDist && d <= 10) { best = inp; bestDist = d }
474
+ if (d < bestDist && d <= 10 && Math.abs(yOf(inp.e) - ly) <= 60) { best = inp; bestDist = d }
402
475
  }
403
476
  return best
404
477
  }
405
- const f = nearest('出发地'), t = nearest('目的地')
478
+ const f = nearest('出发地', ${occurrence ?? 1}), t = nearest('目的地', ${occurrence ?? 1})
406
479
  return JSON.stringify({ from: f ? (f.e.value || '') : '', to: t ? (t.e.value || '') : '' }) })()`;
407
480
  /** 日期框定位(live 校准:只读,占位 yyyy-mm-dd;往返时第二个) */
408
481
  const DATE_INPUT_IDX = (which) => `(() => { const els = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
@@ -411,11 +484,44 @@ const DATE_INPUT_IDX = (which) => `(() => { const els = Array.from(document.quer
411
484
  const back = ${which === 'back'}
412
485
  if (hits.length >= (back ? 2 : 1)) return hits[back ? 1 : 0].i
413
486
  return els.length - 1 })()`;
487
+ /** 多程日期框(live 实测 2026-10-15:只读无占位符,按序=第 N 个只读空占位输入) */
488
+ const DATE_INPUT_IDX_SEG = (seg) => `(() => { const els = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
489
+ const dates = els.map((e, i) => ({ i, ro: e.readOnly, ph: e.placeholder || '' }))
490
+ .filter(x => (x.ro && !x.ph) || /日期|date/i.test(x.ph))
491
+ const idx = ${seg} - 1
492
+ return dates.length > idx ? dates[idx].i : -1 })()`;
493
+ /** 多程段就绪谓词:第 N 个「出发地」标签 ±6 叶子内出现城市输入框(行惰性物化,live 实测 2026-10-15) */
494
+ const SEG_READY = (occurrence) => `(() => {
495
+ const leaves = Array.from(document.querySelectorAll('*')).filter(e => e.offsetParent && e.children.length === 0)
496
+ let seen = 0
497
+ for (let li = 0; li < leaves.length; li += 1) {
498
+ if ((leaves[li].textContent || '').trim() !== '出发地') continue
499
+ seen += 1
500
+ if (seen !== ${occurrence}) continue
501
+ return leaves.some((e, i) => Math.abs(i - li) <= 6 && e.tagName === 'INPUT'
502
+ && (!e.placeholder || /城市|机场/.test(e.placeholder || '')) && !/旅游|搜索|请输入/.test(e.placeholder || ''))
503
+ }
504
+ return false })()`;
505
+ /** 点击第 N 个行标签(出发地/目的地;行惰性物化的物化触发) */
506
+ const CLICK_SEG_LABEL = (label, occurrence) => `(() => {
507
+ const leaves = Array.from(document.querySelectorAll('*')).filter(e => e.offsetParent && e.children.length === 0)
508
+ let seen = 0
509
+ for (const e of leaves) {
510
+ if ((e.textContent || '').trim() !== '${label}') continue
511
+ seen += 1
512
+ if (seen === ${occurrence}) { (e.parentElement || e).click(); return true }
513
+ }
514
+ return false })()`;
414
515
  /** 表单内搜索按钮(live 校准:按钮是无语义 div,文本「搜索」为精确叶子;全局搜索框旁
415
516
  * 也有「搜索」——只在日期框 8 层祖先窗口内找,绝不全局兜底) */
416
517
  const CLICK_FORM_SEARCH = `(() => {
417
518
  const els = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
418
- 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 || ''))
519
+ const dateEls = els.filter(e => /y{2,4}[-/]m{1,2}|日期|date/i.test(e.placeholder || '')
520
+ || (!e.placeholder && e.readOnly) || /^\d{4}-\d{2}-\d{2}/.test(e.value || ''))
521
+ // 多程(live 实测 2026-10-15):行堆叠,搜索按钮在末行右/下方——锚=最底部日期框
522
+ const dateEl = dateEls.length
523
+ ? dateEls.reduce((a, b) => ((a.getBoundingClientRect().top >= b.getBoundingClientRect().top) ? a : b))
524
+ : undefined
419
525
  if (!dateEl) throw new Error('搜索按钮定位失败:日期框不存在')
420
526
  const click = (el) => {
421
527
  let target = el
@@ -443,18 +549,44 @@ const CLICK_FORM_SEARCH = `(() => {
443
549
  const cx = r.left + r.width / 2
444
550
  const cy = r.top + r.height / 2
445
551
  if (cx >= dateRect.right - 20 && cx <= dateRect.right + 500
446
- && cy >= dateRect.top - 60 && cy <= dateRect.bottom + 120) {
552
+ && cy >= dateRect.top - 320 && cy <= dateRect.bottom + 400) {
447
553
  const d = Math.abs(cy - (dateRect.top + dateRect.height / 2)) + (cx - dateRect.right) * 0.2
448
554
  if (d < rowDist) { rowBtn = e; rowDist = d }
449
555
  }
450
- if (cy >= dateRect.bottom && cy <= dateRect.bottom + 250
451
- && cx >= dateRect.left - 80 && cx <= dateRect.right + 200) {
556
+ if (cy >= dateRect.bottom && cy <= dateRect.bottom + 400
557
+ && cx >= dateRect.left - 80 && cx <= dateRect.right + 420) {
452
558
  const d = cy - dateRect.bottom
453
559
  if (d < belowDist) { belowBtn = e; belowDist = d }
454
560
  }
455
561
  }
456
562
  const best = rowBtn || belowBtn
457
563
  if (best) return click(best)
564
+ // 兜底(live 实测 2026-10-15 多程):按钮=右侧蓝色「搜索」大块,若指针游标不在本体上,
565
+ // 按右半区的「搜索」精确叶子点其可点祖先。
566
+ const searchLeaf = Array.from(document.querySelectorAll('*'))
567
+ .find(e => e.children.length === 0 && (e.textContent || '').trim() === '搜索' && e.offsetParent
568
+ && e.getBoundingClientRect().left > dateRect.left)
569
+ if (searchLeaf) return click(searchLeaf)
570
+ // 终极兜底(live 截图实证 2026-10-15 多程:按钮=表单右侧蓝色圆角大块):蓝色主导背景
571
+ // 且 ≥80×80、位于表单纵向范围内 → 点击(白字「搜索」可能在非叶子嵌套里,不依赖文本)。
572
+ const top = dateEls.reduce((m, e) => Math.min(m, e.getBoundingClientRect().top), 1e9)
573
+ const bottom = dateEls.reduce((m, e) => Math.max(m, e.getBoundingClientRect().bottom), -1e9)
574
+ let blue = null, blueArea = 0
575
+ for (const e of Array.from(document.querySelectorAll('*'))) {
576
+ if (!e.offsetParent) continue
577
+ const r = e.getBoundingClientRect()
578
+ if (r.width < 80 || r.height < 80 || r.width > 360) continue
579
+ const cy = r.top + r.height / 2
580
+ if (cy < top - 200 || cy > bottom + 300) continue
581
+ const bgNums = getComputedStyle(e).backgroundColor.replace(/[^0-9,]/g, '').split(',').filter(s2 => s2 !== '').map(Number)
582
+ if (bgNums.length < 3) continue
583
+ const [rr, gg, bb] = [bgNums[0], bgNums[1], bgNums[2]]
584
+ if (bb > 140 && bb > rr + 40 && bb > gg + 30) {
585
+ const area = r.width * r.height
586
+ if (area > blueArea) { blue = e; blueArea = area }
587
+ }
588
+ }
589
+ if (blue) return click(blue)
458
590
  const ptr = Array.from(document.querySelectorAll('*'))
459
591
  .filter(e => e.offsetParent && getComputedStyle(e).cursor === 'pointer' && e.textContent && e.textContent.trim().length <= 6)
460
592
  .slice(0, 10).map(e => ({ t: (e.textContent || '').trim(), tag: e.tagName, cls: String(e.className).slice(0, 40) }))
@@ -496,9 +628,12 @@ const CLICK_DATE_CELL = (ymd, anchorYmd) => {
496
628
  const ariaHit = dayLeaves.find(e =>
497
629
  ((e.getAttribute('aria-label') || '') + (e.getAttribute('title') || '')).includes('${ymd}'))
498
630
  if (ariaHit) return clickEl(ariaHit)
499
- // 捷径:候选日号中父级非 disabled 的恰一个 → 直接点(过去月禁用时天然消歧)
631
+ // 捷径:目标月可视(面板 body 含 YYYY年M月)且候选日号中父级非 disabled 的恰一个 → 直接点
632
+ // (2026-10-15 教训:目标月不在可视双月时,enabled-unique 会选中相邻月的同号日)
633
+ const ymVisible = Array.from(document.querySelectorAll('.month'))
634
+ .some(e => ((e.textContent || '').trim() === '${Number(ymd.slice(5, 7))}月') || ((e.textContent || '').trim().includes('${ymCn}')))
500
635
  const enabled = dayLeaves.filter(e => !/disabled/.test(String(e.parentElement && e.parentElement.className)))
501
- if (enabled.length === 1) return clickEl(enabled[0])
636
+ if (ymVisible && enabled.length === 1) return clickEl(enabled[0])
502
637
  // 几何月消歧(live 校准 2026-10-14 往返回程:双月面板两个同日号都可点,月标题元素
503
638
  // 不可寻):日号格按 x 聚簇=月列,以「今天」锚定第 0 列=当前月,目标月偏移选列。
504
639
  const rect = (e) => e.getBoundingClientRect()
@@ -525,6 +660,17 @@ const CLICK_DATE_CELL = (ymd, anchorYmd) => {
525
660
  }
526
661
  return false })()`;
527
662
  };
663
+ /** 日历翻月(live 实测 2026-10-15:面板头 iconf-right=下月,iconf-left.disa=上月禁用) */
664
+ const CLICK_NEXT_MONTH = `(() => {
665
+ const next = Array.from(document.querySelectorAll('[class*=in-date-picker][class*=iconf-right],[class*=in-date-picker][class*=next-ico]'))
666
+ .filter(e => e.offsetParent && !/disa/.test(String(e.className)))[0]
667
+ if (!next) return false
668
+ ;(next.parentElement || next).click()
669
+ return true })()`;
670
+ /** 目标月是否可视(live 实测:面板月标题=SPAN.month,形如「10月」无年份) */
671
+ const YM_VISIBLE = (ymd) => `Array.from(document.querySelectorAll('.month'))
672
+ .some(e => ((e.textContent || '').trim() === '${Number(ymd.slice(5, 7))}月')
673
+ || ((e.textContent || '').trim().includes('${ymd.slice(0, 4)}年${Number(ymd.slice(5, 7))}月')))`;
528
674
  async function createPlaywrightDriver() {
529
675
  const { chromium } = await import('playwright-core');
530
676
  const profile = mkdtempSync(join(tmpdir(), 'gotry-gui-flight-'));
@@ -559,10 +705,13 @@ async function createPlaywrightDriver() {
559
705
  capture.calendar = { url, body };
560
706
  }
561
707
  else if (body.includes('flightItineraryList')) {
562
- // 同 URL 会先发配置预检回包(needUserLogin 探测,无行程数据)——只收真结果
563
708
  capture.itinerary = { url, body };
564
709
  itinerarySeen = true;
565
710
  }
711
+ else if (body.includes('needUserLogin')) {
712
+ // 配置预检回包(needUserLogin 探测)——作为登录态配对触发信号捕获
713
+ capture.loginProbe = { url, body };
714
+ }
566
715
  // 行程一到位即早结算(最富数据);否则等日历或超时
567
716
  if (itinerarySeen && apiWaiter) {
568
717
  const resolve = apiWaiter;
@@ -577,7 +726,7 @@ async function createPlaywrightDriver() {
577
726
  const sleep = (ms) => page.waitForTimeout(ms);
578
727
  const visibleInput = (i) => page.locator('input:visible').nth(i);
579
728
  let lastDepYmd = '';
580
- const cityValues = async () => JSON.parse(String(await page.evaluate(CITY_FIELD_VALUES).catch(() => '{"from":"","to":""}')));
729
+ const cityValues = async (seg = 1) => JSON.parse(String(await page.evaluate(CITY_FIELD_VALUES_FN(seg)).catch(() => '{"from":"","to":""}')));
581
730
  return {
582
731
  async goto(url) {
583
732
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 40_000 });
@@ -587,7 +736,7 @@ async function createPlaywrightDriver() {
587
736
  await sleep(2500); // hydrate 后的二次稳定
588
737
  },
589
738
  async clickTripTab(trip) {
590
- const label = trip === 'roundtrip' ? '往返' : '单程';
739
+ const label = trip === 'roundtrip' ? '往返' : trip === 'multi' ? '多程' : '单程';
591
740
  try {
592
741
  await page.getByText(label, { exact: true }).first().click({ timeout: 4000 });
593
742
  }
@@ -606,20 +755,47 @@ async function createPlaywrightDriver() {
606
755
  }
607
756
  await sleep(800);
608
757
  },
609
- async typeCity(which, text) {
610
- // 标签即时定位(「出发地」/「目的地」在向上第 4~5 层祖先;就绪闸后 DOM 已稳定)
611
- const idx = await page.evaluate(CITY_INPUT_IDX(which));
758
+ async typeCity(which, text, seg = 1) {
759
+ // 多程行惰性物化(live 实测 2026-10-15):第 N 行城市框在前行填完后才出现——
760
+ // 先等就绪,超时点该行「出发地」标签触发物化再等,仍无则 fail-closed。
761
+ if (seg > 1) {
762
+ const ready = await page.waitForFunction(SEG_READY(seg), { timeout: 6000 }).then(() => true).catch(() => false);
763
+ if (!ready) {
764
+ await page.evaluate(CLICK_SEG_LABEL('出发地', seg)).catch(() => { });
765
+ await page.waitForTimeout(1200);
766
+ const ready2 = await page.waitForFunction(SEG_READY(seg), { timeout: 4000 }).then(() => true).catch(() => false);
767
+ if (!ready2)
768
+ throw new Error(`第${seg}段城市框未物化(点「出发地」标签后仍未出现)——表单改版?`);
769
+ }
770
+ }
771
+ // 标签即时定位(「出发地」/「目的地」;多程=标签第 N 次出现;就绪闸后 DOM 已稳定)
772
+ let idx = await page.evaluate(seg > 1 ? CITY_INPUT_IDX_N(which, seg) : CITY_INPUT_IDX(which));
773
+ // 「到达」框可能仍惰性:点该行「目的地」标签触发物化后重定位(严格同行,绝不覆写前行)
774
+ if (idx < 0 && seg > 1 && which === 'to') {
775
+ await page.evaluate(CLICK_SEG_LABEL('目的地', seg)).catch(() => { });
776
+ await page.waitForTimeout(1200);
777
+ idx = await page.evaluate(CITY_INPUT_IDX_N(which, seg));
778
+ }
612
779
  if (idx < 0) {
613
780
  await page.screenshot({ path: `/tmp/gotry-gui-typecity-${which}-${Date.now()}.png` }).catch(() => { });
781
+ const rows = String(await page.evaluate(`(() => JSON.stringify({
782
+ inputs: Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent).map(e => {
783
+ const r = e.getBoundingClientRect()
784
+ return { ph: (e.placeholder || '').slice(0, 10), y: Math.round(r.top), v: (e.value || '').slice(0, 16) }
785
+ }).slice(0, 12),
786
+ labels: Array.from(document.querySelectorAll('*'))
787
+ .filter(e => e.offsetParent && e.children.length === 0 && /^(出发地|目的地)$/.test((e.textContent || '').trim()))
788
+ .map(e => { const r = e.getBoundingClientRect(); return { t: (e.textContent || '').trim(), y: Math.round(r.top) } })
789
+ }))()`).catch(() => '?'));
614
790
  const href = String(await page.evaluate('location.href').catch(() => '?'));
615
- throw new Error(`${which === 'from' ? '出发' : '到达'}城市输入框未定位到(标签未命中;href=${href})——表单改版或页面未就绪?`);
791
+ throw new Error(`${which === 'from' ? '出发' : '到达'}城市输入框未定位到(seg=${seg};href=${href});行结构:${rows.slice(0, 700)}`);
616
792
  }
617
793
  const input = visibleInput(idx);
618
794
  await input.click({ timeout: 5000 });
619
795
  await input.fill('', { timeout: 3000 }).catch(() => { });
620
796
  await input.pressSequentially(text, { delay: 160 });
621
797
  },
622
- async pickSuggest(which, text) {
798
+ async pickSuggest(which, text, seg = 1) {
623
799
  // 站点补全默认高亮首项:ArrowDown+Enter 显式选中(live 校准:裸 Enter 可能
624
800
  // 未命中面板,失焦后字段回弹默认值);选中后验证归一化 (XXX),不成立重试一次。
625
801
  const pick = async () => {
@@ -628,7 +804,7 @@ async function createPlaywrightDriver() {
628
804
  await sleep(150);
629
805
  await page.keyboard.press('Enter');
630
806
  await sleep(700);
631
- const values = await cityValues();
807
+ const values = await cityValues(seg);
632
808
  const v = which === 'from' ? values.from : values.to;
633
809
  return v.includes(text) && /\([A-Z]{3}\)/.test(v);
634
810
  };
@@ -636,9 +812,11 @@ async function createPlaywrightDriver() {
636
812
  return;
637
813
  if (await pick())
638
814
  return;
815
+ if (seg > 1)
816
+ await sleep(1500); // 行内后续框的物化窗口(live 实测)
639
817
  await page.screenshot({ path: `/tmp/gotry-gui-pick-${which}-${Date.now()}.png` }).catch(() => { });
640
818
  const href = String(await page.evaluate('location.href').catch(() => '?'));
641
- const values = await cityValues();
819
+ const values = await cityValues(seg);
642
820
  const ctxDump = String(await page.evaluate(`(() => JSON.stringify(Array.from(document.querySelectorAll('input'))
643
821
  .filter(e => e.offsetParent && /城市|机场/.test(e.placeholder || '') && !/旅游|搜索/.test(e.placeholder || ''))
644
822
  .map(e => { let node = e, ctx = ''
@@ -646,10 +824,10 @@ async function createPlaywrightDriver() {
646
824
  return { ph: e.placeholder, v: e.value, ctx: ctx.slice(0, 260) } })))()`).catch(() => '?'));
647
825
  throw new Error(`城市补全未选中:${which} 框实际「${(which === 'from' ? values.from : values.to).slice(0, 30)}」(期望含 ${text} 且归一化为 (三字码);href=${href});城市框实证:${ctxDump.slice(0, 900)}`);
648
826
  },
649
- async setDate(which, ymd) {
650
- if (which === 'dep')
827
+ async setDate(which, ymd, seg = 1) {
828
+ if (which === 'dep' && seg === 1)
651
829
  lastDepYmd = ymd;
652
- const idx = await page.evaluate(DATE_INPUT_IDX(which));
830
+ const idx = await page.evaluate(seg > 1 ? DATE_INPUT_IDX_SEG(seg) : DATE_INPUT_IDX(which));
653
831
  const input = visibleInput(idx);
654
832
  await input.click({ timeout: 5000 });
655
833
  await sleep(600);
@@ -668,7 +846,21 @@ async function createPlaywrightDriver() {
668
846
  return;
669
847
  }
670
848
  // 策略②:日历单元格(aria/title 含完整日期优先;否则面板可见目标月时按日号)
671
- const clicked = await page.evaluate(CLICK_DATE_CELL(ymd, which === 'back' ? lastDepYmd : undefined)).catch(() => false);
849
+ let clicked = await page.evaluate(CLICK_DATE_CELL(ymd, which === 'back' ? lastDepYmd : undefined)).catch(() => false);
850
+ if (!clicked) {
851
+ // 目标月不在可视双月(多程跨月,live 实测):点 iconf-right 翻月至可见再消歧
852
+ for (let k = 0; k < 12 && !clicked; k += 1) {
853
+ // 真实鼠标事件点击面板翻月箭头(evaluate 的 .click() 实测被 React 层吞)
854
+ const next = page.locator('[class*=in-date-picker][class*=iconf-right]').first();
855
+ const advanced = await next.click({ timeout: 2000 }).then(() => true).catch(() => false);
856
+ if (!advanced)
857
+ break;
858
+ await sleep(600);
859
+ if (!(await page.evaluate(YM_VISIBLE(ymd)).catch(() => false)))
860
+ continue;
861
+ clicked = await page.evaluate(CLICK_DATE_CELL(ymd, which === 'back' ? lastDepYmd : undefined)).catch(() => false);
862
+ }
863
+ }
672
864
  if (!clicked) {
673
865
  await page.screenshot({ path: `/tmp/gotry-gui-date-${which}-${Date.now()}.png` }).catch(() => { });
674
866
  // 实证诊断:日号叶子(任意可见性)+ 月标题叶子的真实形状,供下一轮校准
@@ -691,6 +883,14 @@ async function createPlaywrightDriver() {
691
883
  async clickSearch() {
692
884
  await page.keyboard.press('Escape').catch(() => { });
693
885
  await sleep(500);
886
+ // 语义优先(live 实测 2026-10-15 多程:几何/叶子/色块三策在该构建全部 miss,
887
+ // role=button name=搜索 命中蓝色大按钮):短超时,失败回落 evaluate 几何族。
888
+ const byRole = page.getByRole('button', { name: '搜索' }).first();
889
+ const roleOk = await byRole.click({ timeout: 5000 }).then(() => true).catch(() => false);
890
+ if (roleOk) {
891
+ await sleep(600);
892
+ return;
893
+ }
694
894
  const dumpDir = process.env.GOTRY_GUI_DUMP;
695
895
  if (dumpDir) {
696
896
  const cands = String(await page.evaluate(CLICK_FORM_SEARCH_CANDIDATES).catch(() => '{"err":"eval-fail"}'));
@@ -706,8 +906,20 @@ async function createPlaywrightDriver() {
706
906
  async visibleFormText() {
707
907
  return String(await page.evaluate(VIS_FORM_TEXT).catch(() => ''));
708
908
  },
709
- async cityFieldValues() {
710
- return JSON.parse(String(await page.evaluate(CITY_FIELD_VALUES).catch(() => '{"from":"","to":""}')));
909
+ async cityFieldValues(seg = 1) {
910
+ return cityValues(seg);
911
+ },
912
+ async waitForHumanLogin(timeoutMs) {
913
+ // 红线:窗口导航到携程官方登录页,人自己完成登录;只轮询票据 cookie 的名字(值即弃)。
914
+ await page.goto('https://passport.ctrip.com/user/login?backurl=' + encodeURIComponent('https://flights.ctrip.com/online/channel/domestic'), { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => { });
915
+ const deadline = Date.now() + timeoutMs;
916
+ while (Date.now() < deadline) {
917
+ const names = await ctx.cookies().then(cs => cs.map(c => c.name)).catch(() => []);
918
+ if (names.some(n => LOGIN_COOKIE_NAMES.includes(n)))
919
+ return true;
920
+ await page.waitForTimeout(5_000);
921
+ }
922
+ return false;
711
923
  },
712
924
  async pageText() {
713
925
  const title = await page.title().catch(() => '');