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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +63 -53
  4. package/bin/gotry-hbcli-release.d.ts +3 -0
  5. package/bin/gotry-hbcli-release.js +4 -0
  6. package/client/client.js +66 -21
  7. package/dist/capabilities/doctor.js +7 -8
  8. package/dist/capabilities/effect.js +45 -12
  9. package/dist/capabilities/flyai-config.js +12 -10
  10. package/dist/capabilities/flyai.js +87 -56
  11. package/dist/capabilities/hbcli-config.js +156 -0
  12. package/dist/capabilities/hbcli.js +29 -2
  13. package/dist/capabilities/resilience.js +6 -6
  14. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  16. package/dist/capabilities/session/ctrip-city.js +186 -0
  17. package/dist/capabilities/session/extension-bridge.js +8 -1
  18. package/dist/capabilities/session/gui-flight.js +737 -0
  19. package/dist/capabilities/session/gui-hotel.js +413 -0
  20. package/dist/capabilities/session-search.js +98 -11
  21. package/dist/scripts/bootstrap-tests.js +23 -6
  22. package/dist/scripts/ctrip-city-tests.js +80 -0
  23. package/dist/scripts/effect-tests.js +602 -504
  24. package/dist/scripts/extension-tests.js +14 -8
  25. package/dist/scripts/flight-malformed-tests.js +1 -1
  26. package/dist/scripts/flyai-setup-tests.js +19 -4
  27. package/dist/scripts/flyai-tests.js +768 -653
  28. package/dist/scripts/gotry-web-api-tests.js +5 -0
  29. package/dist/scripts/gotry-web-client-tests.js +20 -1
  30. package/dist/scripts/gui-flight-live.js +27 -0
  31. package/dist/scripts/hbcli-tests.js +1 -1
  32. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  33. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  34. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  35. package/dist/scripts/hotel-result-tests.js +169 -0
  36. package/dist/scripts/issue-271-liveness-tests.js +1 -1
  37. package/dist/scripts/session-tests.js +177 -6
  38. package/dist/src/gotry-web-api.js +64 -2
  39. package/dist/src/index.js +40 -18
  40. package/extension/manifest.json +2 -2
  41. package/package.json +2 -1
  42. package/ts/capabilities/flyai-config.ts +12 -10
  43. package/ts/capabilities/flyai.ts +91 -50
  44. package/ts/capabilities/hbcli-config.ts +128 -0
  45. package/ts/capabilities/hbcli.ts +30 -3
  46. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  47. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  48. package/ts/capabilities/session/ctrip-city.ts +158 -0
  49. package/ts/capabilities/session/extension-bridge.ts +9 -2
  50. package/ts/capabilities/session/gui-flight.ts +792 -0
  51. package/ts/capabilities/session/gui-hotel.ts +451 -0
  52. package/ts/capabilities/session-search.ts +115 -15
  53. package/ts/src/gotry-web-api.ts +51 -2
  54. package/ts/src/index.ts +41 -19
@@ -0,0 +1,792 @@
1
+ /**
2
+ * 携程机票 GUI 车道(2026-10-14,创始人指示「直接新开 tab 打开 ctrip 页面操作 GUI」):
3
+ * gotry 亲自开一个**隔离匿名**浏览器窗口(headful Chrome,临时 profile),驱动携程
4
+ * PC 检索表单完成搜索,被动收站点自己的 batchSearch 回包——构造性覆盖「携程 PC
5
+ * 有的功能」:往返/多程不需要知道 URL 形状,城市解析交给站点自己的自动补全
6
+ * (城市码表退居缓存,永不在主链路上)。
7
+ *
8
+ * 与既有车道的关系(data-sources §8):
9
+ * - 扩展桥(默认):用户本人登录态,被动嗅探,用户 Chrome,零写;
10
+ * - GUI 车道:gotry 自己的匿名窗口,主动驱动**检索设置面**(trip/城市/日期),
11
+ * 结果仍只从站点自己的 API 回包读取——写面红线不变:绝不触碰
12
+ * 下单/支付/登录(动作白名单 + read-guard 提交件黑名单双闸);
13
+ * - Python browser-use 仍被否决(AGENTS 红线:不新增 Python 面)——本车道
14
+ * 用仓库既有 playwright-core(TS,零新依赖)。
15
+ *
16
+ * 安全契约:
17
+ * - 动作闭集(goto/clickTripTab/typeCity/pickSuggest/setDate/press/readback/
18
+ * clickSearch/awaitApi),规划器产物先过 assertGuiPlanSafe 才可执行;
19
+ * - 一切可点文本过 isSubmitText(立即下单/去支付/提交订单…)→ 直接拒绝;
20
+ * - readback 硬闸:点搜索前逐项核对表单里已填的城市/日期与请求一致,
21
+ * 不一致即 fail-closed 终止——绝不静默搜错城市/错日期;
22
+ * - 永不抛错;浏览器缺失/超时/风控页一律降级为结构化 verdict;
23
+ * - 节律闸不在本层(复用 session-search 的 ctrip-flight 桶,同站同闸)。
24
+ *
25
+ * 证据链:[会话:ctrip-flight-gui@ts](匿名 GUI 面标 anonymous,无登录态价格主张)。
26
+ * 测试注入:__setGuiDriverForTest(离线全流程假 driver;默认 playwright 实现)。
27
+ */
28
+
29
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'
30
+ import { tmpdir } from 'node:os'
31
+ import { join } from 'node:path'
32
+ import type { Page } from 'playwright-core'
33
+ import { isSubmitText } from './read-guard.ts'
34
+ import { NETWORK_HINTS, parseBatchSearchResult, type SessionFlightOption } from './adapters/ctrip-flight.ts'
35
+
36
+ export const FLIGHT_GUI_HOME = 'https://flights.ctrip.com/'
37
+ /**
38
+ * 低价日历接口(live 校准 2026-10-14):新版 PC 匿名 batchSearch 只回 needUserLogin
39
+ * 配置探测(无行程数据),而该日历接口匿名有数据——GUI 车道的结果绑定面(#661)。
40
+ */
41
+ export const CALENDAR_HINT = /FlightIntlAndInlandLowestPriceSearch/
42
+ const YMD = /^\d{4}-\d{2}-\d{2}$/
43
+ /** 挑战页特征(与桥车道同口径:验证/确认中文词) */
44
+ const GUI_CHALLENGE_RE = /验证|校验|拖动滑块|请输入验证码/
45
+
46
+ /** 动作闭集:GUI 车道能做的全部事情(检索设置面;无任何写语义) */
47
+ export type GuiAction =
48
+ | { 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 }
53
+ | { t: 'press'; key: string }
54
+ | { t: 'readback'; expect: { from: string; to: string; dep: string; back?: string } }
55
+ | { t: 'clickSearch' }
56
+ | { t: 'awaitApi' }
57
+
58
+ export interface GuiFlightQuery {
59
+ from: string
60
+ to: string
61
+ /** YYYY-MM-DD(今天或未来) */
62
+ date: string
63
+ /** 存在即往返检索(回程日期) */
64
+ backDate?: string
65
+ /** 总预算 ms(浏览器启动+SPA+检索),默认 90_000 */
66
+ timeoutMs?: number
67
+ }
68
+
69
+ /** 纯函数:查询 → 动作序列(离线测试锚点;执行前必须过 assertGuiPlanSafe) */
70
+ export function planFlightGuiActions(q: GuiFlightQuery): GuiAction[] {
71
+ const expect = { from: q.from, to: q.to, dep: q.date, ...(q.backDate ? { back: q.backDate } : {}) }
72
+ // live 校准(2026-10-14 pre-search 截图):该表单默认即「往返」态(双日期框预填)——
73
+ // 单程必须显式点「单程」,否则按非法日期对(回<去)搜索且日历面板挂在回程框上。
74
+ const tripTab: GuiAction[] = [{ t: 'clickTripTab', trip: q.backDate ? 'roundtrip' : 'oneway' }]
75
+ const backDate: GuiAction[] = q.backDate ? [{ t: 'setDate', which: 'back', ymd: q.backDate }] : []
76
+ return [
77
+ { t: 'goto', url: FLIGHT_GUI_HOME },
78
+ ...tripTab,
79
+ { t: 'typeCity', which: 'from', text: q.from },
80
+ { t: 'pickSuggest', which: 'from', text: q.from },
81
+ { t: 'typeCity', which: 'to', text: q.to },
82
+ { t: 'pickSuggest', which: 'to', text: q.to },
83
+ { t: 'setDate', which: 'dep', ymd: q.date },
84
+ ...backDate,
85
+ { t: 'press', key: 'Escape' },
86
+ { t: 'readback', expect },
87
+ { t: 'clickSearch' },
88
+ { t: 'awaitApi' },
89
+ ]
90
+ }
91
+
92
+ /**
93
+ * 安全白名单(纯函数):闭集外动作/提交件文本/非固定入口 URL/坏日期/坏城市
94
+ * 一律拒绝并给出原因;通过才允许执行器消费。
95
+ */
96
+ export function assertGuiPlanSafe(plan: GuiAction[]): { ok: true } | { ok: false; error: string } {
97
+ for (const a of plan) {
98
+ switch (a.t) {
99
+ case 'goto':
100
+ if (a.url !== FLIGHT_GUI_HOME) return { ok: false, error: `GUI 车道只允许固定入口 ${FLIGHT_GUI_HOME},拒绝 ${a.url}` }
101
+ break
102
+ case 'clickTripTab':
103
+ if (a.trip !== 'oneway' && a.trip !== 'roundtrip') return { ok: false, error: `trip 闭集外:${a.trip}` }
104
+ break
105
+ case 'typeCity':
106
+ case 'pickSuggest': {
107
+ const text = a.text.trim()
108
+ if (!text || text.length > 24) return { ok: false, error: `城市文本非法(空/超长):${text.slice(0, 30)}` }
109
+ if (isSubmitText(text)) return { ok: false, error: `城市位出现提交件文本,拒绝:${text}` }
110
+ if (a.which !== 'from' && a.which !== 'to') return { ok: false, error: `which 闭集外:${a.which}` }
111
+ break
112
+ }
113
+ case 'setDate':
114
+ if (!YMD.test(a.ymd)) return { ok: false, error: `日期需 YYYY-MM-DD,实际 ${a.ymd}` }
115
+ if (a.which !== 'dep' && a.which !== 'back') return { ok: false, error: `which 闭集外:${a.which}` }
116
+ break
117
+ case 'press':
118
+ if (a.key !== 'Escape' && a.key !== 'Enter') return { ok: false, error: `按键白名单只接受 Escape/Enter,拒绝 ${a.key}` }
119
+ break
120
+ case 'clickSearch':
121
+ break // 「搜索」是本车道唯一允许的按钮;提交件黑名单语义(下单/支付)不在其列
122
+ 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}` }
125
+ }
126
+ break
127
+ case 'awaitApi':
128
+ break
129
+ default:
130
+ return { ok: false, error: `动作闭集外:${JSON.stringify(a).slice(0, 60)}` }
131
+ }
132
+ }
133
+ return { ok: true }
134
+ }
135
+
136
+ // ── driver 面(执行器与浏览器解耦;测试注入假 driver,生产 playwright) ──
137
+
138
+ export interface GuiPage {
139
+ 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>
144
+ /** 选自动补全首项并验证归一化(形如 上海(SHA);未选中即重试一次,仍失败抛错) */
145
+ pickSuggest(which: 'from' | 'to', text: string): Promise<void>
146
+ /** 日期设值:输入框可打字则打字,否则点日历单元格(aria/title/文本含目标日期);正确性由 readback 兜底 */
147
+ setDate(which: 'dep' | 'back', ymd: string): Promise<void>
148
+ press(key: string): Promise<void>
149
+ clickSearch(): Promise<void>
150
+ /** 读回当前表单全部输入的值+占位符(readback 硬闸的数据面) */
151
+ visibleFormText(): Promise<string>
152
+ /** 出发/到达城市框当前值(逐字段核对;防全局搜索框文本假阳) */
153
+ cityFieldValues(): Promise<{ from: string; to: string }>
154
+ /** 挑战页检测(title+页面前 3KB 文本) */
155
+ pageText(): Promise<string>
156
+ /** 当前页 URL(#663 URL 教师:GUI 检索后回传 list 页真实形状) */
157
+ currentUrl(): Promise<string>
158
+ /** 等待结果回包:行程 batchSearch(优先)或低价日历(匿名可用);两者都到或超时结算 */
159
+ waitForApiCapture(timeoutMs: number): Promise<GuiApiCapture | null>
160
+ /** 最近 API 请求 URL(超时诊断;可选) */
161
+ recentApiUrls?(): Promise<string[]>
162
+ }
163
+
164
+ /** 双槽捕获:itinerary=含 flightItineraryList 的 batchSearch;calendar=低价日历(匿名可用) */
165
+ export interface GuiApiCapture {
166
+ itinerary?: { url: string; body: string }
167
+ calendar?: { url: string; body: string }
168
+ }
169
+
170
+ /** 低价日历单日条目(parser 按实测形状产出;绝不发明价格) */
171
+ export interface GuiCalendarDay {
172
+ date: string
173
+ price?: number
174
+ priceRaw?: string
175
+ }
176
+
177
+ export interface GuiDriver {
178
+ open(): Promise<GuiPage>
179
+ close(): Promise<void>
180
+ }
181
+
182
+ export type GuiFlightVerdict = 'hit' | 'miss' | 'error' | 'challenged'
183
+
184
+ export interface GuiFlightResult {
185
+ ok: boolean
186
+ via: 'session-ctrip-flight-gui' | 'session-ctrip-flight-gui-error'
187
+ evidence: string
188
+ latencyMs: number
189
+ verdict: GuiFlightVerdict
190
+ options?: SessionFlightOption[]
191
+ /** 低价日历(匿名可用;#662:该航线哪些日期有票/低价) */
192
+ calendar?: GuiCalendarDay[]
193
+ /** 检索后站点自己落到的 list 页 URL(#663 URL 教师:真实形状回传,不猜) */
194
+ listUrl?: string
195
+ error?: string
196
+ /** 匿名公开面:显式标注,不冒充登录态价格 */
197
+ anonymous: true
198
+ trip: 'oneway' | 'roundtrip'
199
+ }
200
+
201
+ /** 测试注入 seam(__setTrainSessionTransportForTest 同款) */
202
+ let guiDriverFactory: (() => GuiDriver) | null = null
203
+ export function __setGuiDriverForTest(factory: (() => GuiDriver) | null): void {
204
+ guiDriverFactory = factory
205
+ }
206
+
207
+ /**
208
+ * GUI 检索执行:规划→安全闸→driver 走动作→readback 硬闸→API 回包→既有解析器。
209
+ * 永不抛错;driver 缺省时 lazy 起 playwright 隔离窗口(生产路径)。
210
+ */
211
+ export async function runFlightGuiSearch(q: GuiFlightQuery): Promise<GuiFlightResult> {
212
+ const started = Date.now()
213
+ const ts = new Date().toISOString()
214
+ const trip: 'oneway' | 'roundtrip' = q.backDate ? 'roundtrip' : 'oneway'
215
+ const base = { anonymous: true as const, trip }
216
+ const fail = (error: string, verdict: GuiFlightVerdict = 'error'): GuiFlightResult => ({
217
+ ok: false, via: 'session-ctrip-flight-gui-error', latencyMs: Date.now() - started, verdict, error,
218
+ evidence: `[会话:ctrip-flight-gui@error@${ts}] ${error.slice(0, 120)}`, ...base,
219
+ })
220
+
221
+ const plan = planFlightGuiActions(q)
222
+ const safe = assertGuiPlanSafe(plan)
223
+ if (!safe.ok) return fail(`GUI 计划被安全闸拒绝:${safe.error}`)
224
+
225
+ const budget = q.timeoutMs ?? 90_000
226
+ const trace: string[] = []
227
+ const driver = guiDriverFactory ? guiDriverFactory() : await createPlaywrightDriver()
228
+ let page: GuiPage
229
+ try {
230
+ page = await withDeadline(driver.open(), Math.min(budget, 30_000), 'GUI 浏览器窗口启动失败/超时(本机需已装 Chrome)')
231
+ } catch (e) {
232
+ await driver.close().catch(() => {})
233
+ return fail((e as Error).message.slice(0, 1200))
234
+ }
235
+
236
+ let apiBody: GuiApiCapture | null = null
237
+ let listUrl = ''
238
+ try {
239
+ for (const a of plan) {
240
+ const remain = budget - (Date.now() - started)
241
+ if (remain <= 0) return fail(`GUI 预算耗尽(${budget}ms)`)
242
+ const step = <T,>(p: Promise<T>, what: string): Promise<T> =>
243
+ withDeadline(p, Math.min(remain, 20_000), `GUI 步骤失败(${what})`)
244
+ switch (a.t) {
245
+ 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
248
+ 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(() => ({})))}`)
251
+ break
252
+ case 'setDate': await step(page.setDate(a.which, a.ymd), `设置${a.which === 'dep' ? '去程' : '回程'}日期`); break
253
+ case 'press': await step(page.press(a.key), `按键 ${a.key}`); break
254
+ case 'clickSearch': await step(page.clickSearch(), '点击搜索'); break
255
+ case 'readback': {
256
+ // 城市:逐字段核对出发/到达框(占位符同名的全局搜索框/相邻框文本不再能假阳)
257
+ const cities = await step(page.cityFieldValues(), '读回城市框')
258
+ 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)})——已终止,未发起搜索`)
265
+ }
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)})——已终止,未发起搜索`)
273
+ }
274
+ }
275
+ break
276
+ }
277
+ case 'awaitApi': {
278
+ const text = await step(page.pageText(), '检查页面')
279
+ if (GUI_CHALLENGE_RE.test(text)) {
280
+ return fail('风控/验证码命中;按红线不重试不绕过,交还用户', 'challenged')
281
+ }
282
+ apiBody = await step(page.waitForApiCapture(Math.min(remain - 500, 18_000)), '等待结果回包(batchSearch/低价日历)')
283
+ if (!apiBody || (!apiBody.itinerary && !apiBody.calendar)) {
284
+ const recent = page.recentApiUrls ? (await page.recentApiUrls().catch(() => [])) : []
285
+ return fail(`结果回包嗅探超时:batchSearch(行程)与低价日历均未收到;本次检索未完成${recent.length ? ` ‖recent-apis: ${recent.slice(-8).join(' ; ').slice(0, 600)}` : ''}`)
286
+ }
287
+ break
288
+ }
289
+ }
290
+ }
291
+ listUrl = await page.currentUrl().catch(() => '')
292
+ } catch (e) {
293
+ return fail(`${(e as Error).message.slice(0, 500)}${trace.length ? ` ‖trace: ${trace.join(' ; ').slice(0, 400)}` : ''}`)
294
+ } finally {
295
+ await driver.close().catch(() => {})
296
+ }
297
+
298
+ dumpCapture(apiBody, listUrl)
299
+ // 结算优先级:行程 batchSearch(登录态/部分构建)> 低价日历(匿名可用,#661 绑定面)
300
+ if (apiBody?.itinerary) {
301
+ const parsed = parseBatchSearchResult(apiBody.itinerary.body)
302
+ if (parsed.verdict === 'error') {
303
+ return fail(`batchSearch 响应形状异常(${parsed.shape};非合法空响应)body头:${apiBody.itinerary.body.replace(/\s+/g, ' ').slice(0, 500)}`)
304
+ }
305
+ const verdict: GuiFlightVerdict = parsed.verdict === 'hit' ? 'hit' : 'miss'
306
+ const calendar = apiBody.calendar ? parseLowestPriceCalendar(apiBody.calendar.body) : undefined
307
+ return {
308
+ ok: verdict === 'hit', via: 'session-ctrip-flight-gui', latencyMs: Date.now() - started, verdict,
309
+ ...(verdict === 'hit' ? { options: parsed.options } : {}),
310
+ ...(calendar && calendar.length > 0 ? { calendar } : {}),
311
+ ...(listUrl ? { listUrl } : {}),
312
+ evidence: `[会话:ctrip-flight-gui@${ts}] ${trip} 匿名 GUI 面检索:${parsed.options.length} 条(transport=gui shape=${parsed.shape}${calendar?.length ? `;calendar=${calendar.length}天` : ''})`,
313
+ ...base,
314
+ }
315
+ }
316
+ if (apiBody?.calendar) {
317
+ const calendar = parseLowestPriceCalendar(apiBody.calendar.body)
318
+ if (calendar.length === 0) {
319
+ return fail(`低价日历回包形状未识别(body头:${apiBody.calendar.body.replace(/\s+/g, ' ').slice(0, 300)};GOTRY_GUI_DUMP 可落盘原样供校准)`)
320
+ }
321
+ return {
322
+ ok: true, via: 'session-ctrip-flight-gui', latencyMs: Date.now() - started, verdict: 'hit',
323
+ calendar, ...(listUrl ? { listUrl } : {}),
324
+ evidence: `[会话:ctrip-flight-gui@${ts}] ${trip} 匿名 GUI 面·低价日历:${calendar.length} 天(行程明细需登录态,见 #661;日历为匿名公开价)`,
325
+ ...base,
326
+ }
327
+ }
328
+ return fail('内部不变量失败:apiBody 双槽皆空(awaitApi 已结算)')
329
+ }
330
+
331
+ /**
332
+ * 低价日历解析(#662):按 live 实测形状产出 date/price;形状未识别返回空数组
333
+ * (fail-closed,绝不发明价格)。形状锚定见 session-tests §L fixture。
334
+ */
335
+ export function parseLowestPriceCalendar(body: string): GuiCalendarDay[] {
336
+ let raw: unknown
337
+ try {
338
+ raw = JSON.parse(body)
339
+ } catch {
340
+ return []
341
+ }
342
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return []
343
+ // live 实测(2026-10-14,香港/上海 GUI 捕获):顶层 priceList 数组(responseStatus.Ack
344
+ // 信封);兼容 data.* 形态。条目键按实测锚定,未识别=空(fail-closed)。
345
+ const scopes: Array<Record<string, unknown>> = [raw as Record<string, unknown>]
346
+ const data = (raw as { data?: unknown }).data
347
+ if (data !== null && typeof data === 'object' && !Array.isArray(data)) scopes.push(data as Record<string, unknown>)
348
+ for (const key of ['priceList', 'priceCalendar', 'calendarList', 'lowPriceList', 'flightLowPrice']) {
349
+ const list = scopes.map(scope => scope[key]).find(v => Array.isArray(v))
350
+ if (!list) continue
351
+ const days: GuiCalendarDay[] = []
352
+ for (const item of list) {
353
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) continue
354
+ const it = item as Record<string, unknown>
355
+ // live 实测锚点(2026-10-14):departDate = .NET /Date(ms+0800)/;price>0 才是可售低价
356
+ const date = netDateToYmd(it.departDate) ?? (typeof it.date === 'string' ? it.date.trim() : '')
357
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue
358
+ if (typeof it.price !== 'number' || !Number.isFinite(it.price) || it.price <= 0) continue
359
+ days.push({ date, price: it.price })
360
+ }
361
+ if (days.length > 0) {
362
+ // 往返回包含去/回两程日历(同日双价,live 实测)——按日聚合取最低,evidence 可追溯。
363
+ const byDate = new Map<string, GuiCalendarDay>()
364
+ for (const d of days) {
365
+ const prev = byDate.get(d.date)
366
+ if (!prev || (d.price ?? Infinity) < (prev.price ?? Infinity)) byDate.set(d.date, d)
367
+ }
368
+ return [...byDate.values()].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
369
+ }
370
+ }
371
+ return []
372
+ }
373
+
374
+ /** /Date(1791993600000+0800)/ → YYYY-MM-DD(按 +0800 面向用户的日历日) */
375
+ function netDateToYmd(value: unknown): string | undefined {
376
+ if (typeof value !== 'string') return undefined
377
+ const match = /^\/Date\((\d+)(?:[+-]\d{4})?\)\/$/.exec(value.trim())
378
+ if (!match) return undefined
379
+ const ms = Number(match[1])
380
+ if (!Number.isFinite(ms)) return undefined
381
+ const d = new Date(ms + 8 * 3600 * 1000) // 日历面向 +0800 时区
382
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`
383
+ }
384
+
385
+ /** 校准/漂移诊断钩子:GOTRY_GUI_DUMP=<dir> 落盘原始回包与 listUrl(不含任何账号面) */
386
+ function dumpCapture(capture: GuiApiCapture | null, listUrl: string): void {
387
+ const dir = process.env.GOTRY_GUI_DUMP
388
+ if (!dir || !capture) return
389
+ try {
390
+ mkdirSync(dir, { recursive: true })
391
+ if (capture.itinerary) writeFileSync(join(dir, 'itinerary.json'), capture.itinerary.body)
392
+ if (capture.calendar) writeFileSync(join(dir, 'calendar.json'), capture.calendar.body)
393
+ if (listUrl) writeFileSync(join(dir, 'page-url.txt'), listUrl)
394
+ } catch { /* best effort */ }
395
+ }
396
+
397
+ async function withDeadline<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
398
+ let timer: NodeJS.Timeout | undefined
399
+ try {
400
+ return await Promise.race([
401
+ p,
402
+ new Promise<never>((_, reject) => {
403
+ timer = setTimeout(() => reject(new Error(`${what}:${ms}ms`)), ms)
404
+ }),
405
+ ])
406
+ } finally {
407
+ if (timer) clearTimeout(timer)
408
+ }
409
+ }
410
+
411
+ // ── 生产 driver:隔离临时 profile 的 headful Chrome(匿名公开面;不碰用户 Chrome) ──
412
+
413
+ const VIS_FORM_TEXT = `(() => Array.from(document.querySelectorAll('input'))
414
+ .map(e => [e.value, e.placeholder].filter(Boolean).join('¤')).join(' | '))()`
415
+ /**
416
+ * 城市框定位(2026-10-14 live 校准,第 11 轮定稿):两框占位符同为「可输入城市或机场」,
417
+ * 标签「出发地」「目的地」是与输入框同容器的叶子——祖先 ctx 判别会爬到共享行同时命中
418
+ * 两框(实测 from/to 双双解析到同一框)。改文档顺序叶子邻接:标签叶子 ±10 叶子窗口内
419
+ * 最近的「城市或机场」输入框即归属框。标签失配返回 -1(fail-closed 带实证)。
420
+ */
421
+ /** CITY_LABEL_ADJOIN 思路(文档顺序标签±10 叶子窗口邻接)的落地:返回目标框在可见 input 序 */
422
+ const CITY_INPUT_IDX_FROM_LEAVES = (which: 'from' | 'to'): string => `(() => {
423
+ const leaves = Array.from(document.querySelectorAll('*'))
424
+ .filter(e => e.offsetParent && e.children.length === 0)
425
+ const isCityInput = (e) => e.tagName === 'INPUT'
426
+ && /城市|机场/.test(e.placeholder || '') && !/旅游|搜索/.test(e.placeholder || '')
427
+ const inputs = leaves.map((e, i) => ({ e, i })).filter(({ e }) => isCityInput(e))
428
+ const visibleInputs = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
429
+ const nearest = (label) => {
430
+ let li = -1
431
+ for (let i = 0; i < leaves.length; i += 1) {
432
+ const e = leaves[i]
433
+ if ((e.textContent || '').trim() === label) { li = i; break }
434
+ }
435
+ if (li < 0) return -1
436
+ let best = -1, bestDist = 99
437
+ for (const inp of inputs) {
438
+ const d = Math.abs(inp.i - li)
439
+ if (d < bestDist && d <= 10) { best = inp.i; bestDist = d }
440
+ }
441
+ if (best < 0) return -1
442
+ return visibleInputs.indexOf(leaves[best])
443
+ }
444
+ return nearest(${which === 'from' ? "'出发地'" : "'目的地'"}) })()`
445
+ const CITY_INPUT_IDX = CITY_INPUT_IDX_FROM_LEAVES
446
+ /** 城市框当前值(出发/到达各自;readback 逐字段核对用,防全局搜索框文本假阳) */
447
+ const CITY_FIELD_VALUES = `(() => {
448
+ const leaves = Array.from(document.querySelectorAll('*'))
449
+ .filter(e => e.offsetParent && e.children.length === 0)
450
+ const isCityInput = (e) => e.tagName === 'INPUT'
451
+ && /城市|机场/.test(e.placeholder || '') && !/旅游|搜索/.test(e.placeholder || '')
452
+ const inputs = leaves.map((e, i) => ({ e, i })).filter(({ e }) => isCityInput(e))
453
+ const nearest = (label) => {
454
+ let li = -1
455
+ for (let i = 0; i < leaves.length; i += 1) {
456
+ if ((leaves[i].textContent || '').trim() === label) { li = i; break }
457
+ }
458
+ if (li < 0) return null
459
+ let best = null, bestDist = 99
460
+ for (const inp of inputs) {
461
+ const d = Math.abs(inp.i - li)
462
+ if (d < bestDist && d <= 10) { best = inp; bestDist = d }
463
+ }
464
+ return best
465
+ }
466
+ const f = nearest('出发地'), t = nearest('目的地')
467
+ return JSON.stringify({ from: f ? (f.e.value || '') : '', to: t ? (t.e.value || '') : '' }) })()`
468
+ /** 日期框定位(live 校准:只读,占位 yyyy-mm-dd;往返时第二个) */
469
+ const DATE_INPUT_IDX = (which: 'dep' | 'back'): string => `(() => { const els = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
470
+ const hits = els.map((e, i) => ({ i, ph: e.placeholder || '', ro: e.readOnly, v: e.value || '' }))
471
+ .filter(x => /y{2,4}[-/]m{1,2}|日期|date/i.test(x.ph) || /^\\d{4}-\\d{2}-\\d{2}/.test(x.v))
472
+ const back = ${which === 'back'}
473
+ if (hits.length >= (back ? 2 : 1)) return hits[back ? 1 : 0].i
474
+ return els.length - 1 })()`
475
+ /** 表单内搜索按钮(live 校准:按钮是无语义 div,文本「搜索」为精确叶子;全局搜索框旁
476
+ * 也有「搜索」——只在日期框 8 层祖先窗口内找,绝不全局兜底) */
477
+ const CLICK_FORM_SEARCH = `(() => {
478
+ 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 || ''))
480
+ if (!dateEl) throw new Error('搜索按钮定位失败:日期框不存在')
481
+ const click = (el) => {
482
+ let target = el
483
+ for (let k = 0; k < 3 && target.parentElement; k += 1) {
484
+ const p = target.parentElement
485
+ const st = getComputedStyle(p)
486
+ if (st.cursor === 'pointer' || p.tagName === 'BUTTON' || p.hasAttribute('role')) { target = p; break }
487
+ }
488
+ target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
489
+ target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
490
+ target.click()
491
+ return true }
492
+ // 几何判据 v2(live 校准 2026-10-14:按钮在表单行右端的无语义 styled div,文本非
493
+ // 「搜索」叶子;首版「下方最大块」会误中低价推广卡——SHA→TGO fuzzy 跳页实测):
494
+ // ①行右端:cx 在日期框右侧 500px 内、cy 与行对齐,宽 60–320、高 ≥40,取最近;
495
+ // ②次选:日期框正下方 250px 内的窄块(兼容改版)。绝不取大横幅(宽>320)。
496
+ const dateRect = dateEl.getBoundingClientRect()
497
+ let rowBtn = null, rowDist = 1e9
498
+ let belowBtn = null, belowDist = 1e9
499
+ for (const e of Array.from(document.querySelectorAll('*'))) {
500
+ if (e === dateEl || !e.offsetParent || e.contains(dateEl)) continue
501
+ const r = e.getBoundingClientRect()
502
+ if (r.width < 60 || r.width > 320 || r.height < 40) continue
503
+ if (getComputedStyle(e).cursor !== 'pointer') continue
504
+ const cx = r.left + r.width / 2
505
+ const cy = r.top + r.height / 2
506
+ if (cx >= dateRect.right - 20 && cx <= dateRect.right + 500
507
+ && cy >= dateRect.top - 60 && cy <= dateRect.bottom + 120) {
508
+ const d = Math.abs(cy - (dateRect.top + dateRect.height / 2)) + (cx - dateRect.right) * 0.2
509
+ if (d < rowDist) { rowBtn = e; rowDist = d }
510
+ }
511
+ if (cy >= dateRect.bottom && cy <= dateRect.bottom + 250
512
+ && cx >= dateRect.left - 80 && cx <= dateRect.right + 200) {
513
+ const d = cy - dateRect.bottom
514
+ if (d < belowDist) { belowBtn = e; belowDist = d }
515
+ }
516
+ }
517
+ const best = rowBtn || belowBtn
518
+ if (best) return click(best)
519
+ const ptr = Array.from(document.querySelectorAll('*'))
520
+ .filter(e => e.offsetParent && getComputedStyle(e).cursor === 'pointer' && e.textContent && e.textContent.trim().length <= 6)
521
+ .slice(0, 10).map(e => ({ t: (e.textContent || '').trim(), tag: e.tagName, cls: String(e.className).slice(0, 40) }))
522
+ throw new Error('搜索按钮未找到(日期框下方几何窗口内无 pointer 大块)pointer 候选:' + JSON.stringify(ptr))
523
+ })()`
524
+ /** 校准采集版(GOTRY_GUI_DUMP 时 driver 落盘):行内全部 pointer 候选(文本/标签/类名/矩形) */
525
+ const CLICK_FORM_SEARCH_CANDIDATES = `(() => {
526
+ const els = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
527
+ 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 || ''))
528
+ if (!dateEl) return JSON.stringify({ err: 'no dateEl' })
529
+ const dateRect = dateEl.getBoundingClientRect()
530
+ const cands = Array.from(document.querySelectorAll('*'))
531
+ .filter(e => e.offsetParent && getComputedStyle(e).cursor === 'pointer')
532
+ .map(e => { const r = e.getBoundingClientRect()
533
+ return { t: (e.textContent || '').trim().slice(0, 24), tag: e.tagName, cls: String(e.className).slice(0, 60),
534
+ x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } })
535
+ .filter(c => c.w >= 20 && c.h >= 12)
536
+ .slice(0, 100)
537
+ return JSON.stringify({ dateRect: { x: Math.round(dateRect.left), y: Math.round(dateRect.top), w: Math.round(dateRect.width), h: Math.round(dateRect.height) }, cands })
538
+ })()`
539
+
540
+ /**
541
+ * 日历单元格(live 校准 2026-10-14):channel/domestic 日历同屏双月(「2026年10月」「2026年11月」),
542
+ * 日号格不匹配通用选择器——改走文档顺序状态机:叶子元素流里,目标月标题(YYYY年M月)之后、
543
+ * 下一个月标题之前,文本恰为日号的第一个元素即目标格;aria-label/title 含完整日期者优先。
544
+ */
545
+ const CLICK_DATE_CELL = (ymd: string, anchorYmd?: string): string => {
546
+ const day = String(Number(ymd.slice(8, 10)))
547
+ const ymCn = `${ymd.slice(0, 4)}年${Number(ymd.slice(5, 7))}月`
548
+ return `(() => {
549
+ const all = Array.from(document.querySelectorAll('*'))
550
+ const dayLeaves = all.filter(e => e.children.length === 0 && (e.textContent || '').trim() === '${day}')
551
+ const clickEl = (e) => {
552
+ const target = e.parentElement && /date-day|day|cell/.test(String(e.parentElement.className)) ? e.parentElement : e
553
+ target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
554
+ target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
555
+ target.click()
556
+ return true }
557
+ // 捷径:aria-label/title 含完整日期
558
+ const ariaHit = dayLeaves.find(e =>
559
+ ((e.getAttribute('aria-label') || '') + (e.getAttribute('title') || '')).includes('${ymd}'))
560
+ if (ariaHit) return clickEl(ariaHit)
561
+ // 捷径:候选日号中父级非 disabled 的恰一个 → 直接点(过去月禁用时天然消歧)
562
+ const enabled = dayLeaves.filter(e => !/disabled/.test(String(e.parentElement && e.parentElement.className)))
563
+ if (enabled.length === 1) return clickEl(enabled[0])
564
+ // 几何月消歧(live 校准 2026-10-14 往返回程:双月面板两个同日号都可点,月标题元素
565
+ // 不可寻):日号格按 x 聚簇=月列,以「今天」锚定第 0 列=当前月,目标月偏移选列。
566
+ const rect = (e) => e.getBoundingClientRect()
567
+ const xs = enabled.map(e => rect(e).left + rect(e).width / 2).sort((a, b) => a - b)
568
+ if (xs.length >= 2 && xs[xs.length - 1] - xs[0] > 120) {
569
+ const split = (xs[0] + xs[xs.length - 1]) / 2
570
+ const left = enabled.filter(e => rect(e).left + rect(e).width / 2 < split)
571
+ const right = enabled.filter(e => rect(e).left + rect(e).width / 2 >= split)
572
+ const anchor = ${anchorYmd ? `{ y: ${Number(anchorYmd.slice(0, 4))}, m: ${Number(anchorYmd.slice(5, 7))} }` : 'null'}
573
+ const now = new Date()
574
+ const ay = anchor ? anchor.y : now.getFullYear()
575
+ const am = anchor ? anchor.m : now.getMonth() + 1
576
+ const delta = (${Number(ymd.slice(0, 4))} * 12 + ${Number(ymd.slice(5, 7))}) - (ay * 12 + am)
577
+ const pick = delta <= 0 ? left[0] : delta === 1 ? right[0] : null
578
+ if (pick) return clickEl(pick)
579
+ }
580
+ // 兜底:月边界状态机(月标题去空白精确匹配;标题可能拆分文本节点)
581
+ const norm = (s) => s.replace(/\\s+/g, '')
582
+ let inTargetMonth = false
583
+ for (const e of all) {
584
+ const ownText = norm(Array.from(e.childNodes).filter(n => n.nodeType === 3).map(n => n.textContent).join(''))
585
+ if (/^\\d{4}年\\d{1,2}月$/.test(ownText)) { inTargetMonth = ownText === norm('${ymCn}'); continue }
586
+ if (inTargetMonth && e.children.length === 0 && (e.textContent || '').trim() === '${day}') return clickEl(e)
587
+ }
588
+ return false })()`
589
+ }
590
+
591
+ async function createPlaywrightDriver(): Promise<GuiDriver> {
592
+ const { chromium } = await import('playwright-core')
593
+ const profile = mkdtempSync(join(tmpdir(), 'gotry-gui-flight-'))
594
+ const ctx = await chromium.launchPersistentContext(profile, {
595
+ channel: 'chrome', // 本机 Chrome,不下载浏览器(session PoC 同款)
596
+ headless: false, // headless 下携程回壳页(data-sources §8 已验证)
597
+ viewport: { width: 1480, height: 950 },
598
+ })
599
+ const page: Page = ctx.pages()[0] ?? (await ctx.newPage())
600
+
601
+ // 双槽捕获:行程 batchSearch(优先)与低价日历(#661);监听常挂,waitForApiCapture 注册等待者
602
+ const capture: GuiApiCapture = {}
603
+ let apiWaiter: ((v: GuiApiCapture | null) => void) | null = null
604
+ let itinerarySeen = false
605
+ const recentUrls: string[] = []
606
+ page.on('request', r => {
607
+ const u = r.url()
608
+ if (/api|search|restapi|soa2|itinerary/i.test(u) && !/\\.(js|css|png|jpg|svg|woff)/.test(u)) {
609
+ recentUrls.push(u.slice(0, 140))
610
+ if (recentUrls.length > 30) recentUrls.shift()
611
+ }
612
+ })
613
+ page.on('response', async r => {
614
+ const url = r.url()
615
+ const isItineraryHint = NETWORK_HINTS.some((re: RegExp) => re.test(url))
616
+ const isCalendarHint = CALENDAR_HINT.test(url)
617
+ if (!isItineraryHint && !isCalendarHint) return
618
+ try {
619
+ const body = await r.text()
620
+ if (isCalendarHint) {
621
+ capture.calendar = { url, body }
622
+ } else if (body.includes('flightItineraryList')) {
623
+ // 同 URL 会先发配置预检回包(needUserLogin 探测,无行程数据)——只收真结果
624
+ capture.itinerary = { url, body }
625
+ itinerarySeen = true
626
+ }
627
+ // 行程一到位即早结算(最富数据);否则等日历或超时
628
+ if (itinerarySeen && apiWaiter) {
629
+ const resolve = apiWaiter
630
+ apiWaiter = null
631
+ resolve?.({ ...capture })
632
+ }
633
+ } catch { /* 回包体已释放 */ }
634
+ })
635
+
636
+ return {
637
+ async open(): Promise<GuiPage> {
638
+ const sleep = (ms: number): Promise<void> => page.waitForTimeout(ms)
639
+ const visibleInput = (i: number) => page.locator('input:visible').nth(i)
640
+ 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 }
643
+ return {
644
+ async goto(url) {
645
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 40_000 })
646
+ // 就绪闸(live 校准 2026-10-14:channel/domestic 冷启动是骨架屏,提前操作会被
647
+ // hydrate 重建表单吞掉——城市值消失/框互换都源于此)。等表单真出现再动手。
648
+ await page.locator('input[placeholder*="城市或机场"]').first().waitFor({ state: 'visible', timeout: 30_000 })
649
+ await sleep(2500) // hydrate 后的二次稳定
650
+ },
651
+ async clickTripTab(trip) {
652
+ const label = trip === 'roundtrip' ? '往返' : '单程'
653
+ try {
654
+ await page.getByText(label, { exact: true }).first().click({ timeout: 4000 })
655
+ } catch {
656
+ try {
657
+ await page.getByText(label, { exact: false }).first().click({ timeout: 4000 })
658
+ } catch {
659
+ // 实证诊断:把页面真实事实(url/title/候选文本)带进错误,供下一轮校准
660
+ const facts = String(await page.evaluate<string>(
661
+ `(() => JSON.stringify({ url: location.href.slice(0, 120), title: document.title.slice(0, 80),
662
+ cands: Array.from(document.querySelectorAll('*'))
663
+ .filter(e => e.children.length === 0 && /单程|往返|多程/.test(e.textContent || '') && e.offsetParent)
664
+ .slice(0, 8).map(e => (e.textContent || '').trim()) }))()`,
665
+ ).catch(() => '?'))
666
+ throw new Error(`行程 tab「${label}」未命中;页面事实:${facts}`)
667
+ }
668
+ }
669
+ await sleep(800)
670
+ },
671
+ async typeCity(which, text) {
672
+ // 标签即时定位(「出发地」/「目的地」在向上第 4~5 层祖先;就绪闸后 DOM 已稳定)
673
+ const idx = await page.evaluate<number>(CITY_INPUT_IDX(which))
674
+ if (idx < 0) {
675
+ await page.screenshot({ path: `/tmp/gotry-gui-typecity-${which}-${Date.now()}.png` }).catch(() => {})
676
+ const href = String(await page.evaluate<string>('location.href').catch(() => '?'))
677
+ throw new Error(`${which === 'from' ? '出发' : '到达'}城市输入框未定位到(标签未命中;href=${href})——表单改版或页面未就绪?`)
678
+ }
679
+ const input = visibleInput(idx)
680
+ await input.click({ timeout: 5000 })
681
+ await input.fill('', { timeout: 3000 }).catch(() => {})
682
+ await input.pressSequentially(text, { delay: 160 })
683
+ },
684
+ async pickSuggest(which, text) {
685
+ // 站点补全默认高亮首项:ArrowDown+Enter 显式选中(live 校准:裸 Enter 可能
686
+ // 未命中面板,失焦后字段回弹默认值);选中后验证归一化 (XXX),不成立重试一次。
687
+ const pick = async (): Promise<boolean> => {
688
+ await sleep(1800)
689
+ await page.keyboard.press('ArrowDown')
690
+ await sleep(150)
691
+ await page.keyboard.press('Enter')
692
+ await sleep(700)
693
+ const values = await cityValues()
694
+ const v = which === 'from' ? values.from : values.to
695
+ return v.includes(text) && /\([A-Z]{3}\)/.test(v)
696
+ }
697
+ if (await pick()) return
698
+ if (await pick()) return
699
+ await page.screenshot({ path: `/tmp/gotry-gui-pick-${which}-${Date.now()}.png` }).catch(() => {})
700
+ const href = String(await page.evaluate<string>('location.href').catch(() => '?'))
701
+ const values = await cityValues()
702
+ const ctxDump = String(await page.evaluate<string>(
703
+ `(() => JSON.stringify(Array.from(document.querySelectorAll('input'))
704
+ .filter(e => e.offsetParent && /城市|机场/.test(e.placeholder || '') && !/旅游|搜索/.test(e.placeholder || ''))
705
+ .map(e => { let node = e, ctx = ''
706
+ for (let k = 0; k < 4 && node; k++) { node = node.parentElement; if (node) ctx += '∥' + node.innerText.slice(0, 100) }
707
+ return { ph: e.placeholder, v: e.value, ctx: ctx.slice(0, 260) } })))()`,
708
+ ).catch(() => '?'))
709
+ throw new Error(`城市补全未选中:${which} 框实际「${(which === 'from' ? values.from : values.to).slice(0, 30)}」(期望含 ${text} 且归一化为 (三字码);href=${href});城市框实证:${ctxDump.slice(0, 900)}`)
710
+ },
711
+ async setDate(which, ymd) {
712
+ if (which === 'dep') lastDepYmd = ymd
713
+ const idx = await page.evaluate<number>(DATE_INPUT_IDX(which))
714
+ const input = visibleInput(idx)
715
+ await input.click({ timeout: 5000 })
716
+ await sleep(600)
717
+ // 策略①:输入框可写(部分构建支持键入;React 受控件用原生 setter+input 事件)
718
+ const typed = await input.evaluate((el: HTMLInputElement, v: string) => {
719
+ if (el.readOnly) return false
720
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
721
+ setter?.call(el, v)
722
+ el.dispatchEvent(new Event('input', { bubbles: true }))
723
+ el.dispatchEvent(new Event('change', { bubbles: true }))
724
+ return el.value === v
725
+ }, ymd).catch(() => false)
726
+ if (typed) { await sleep(400); return }
727
+ // 策略②:日历单元格(aria/title 含完整日期优先;否则面板可见目标月时按日号)
728
+ const clicked = await page.evaluate<boolean>(CLICK_DATE_CELL(ymd, which === 'back' ? lastDepYmd : undefined)).catch(() => false)
729
+ if (!clicked) {
730
+ await page.screenshot({ path: `/tmp/gotry-gui-date-${which}-${Date.now()}.png` }).catch(() => {})
731
+ // 实证诊断:日号叶子(任意可见性)+ 月标题叶子的真实形状,供下一轮校准
732
+ const facts = String(await page.evaluate<string>(
733
+ `(() => JSON.stringify({
734
+ href: location.href.slice(0, 140),
735
+ days: Array.from(document.querySelectorAll('*'))
736
+ .filter(e => e.children.length === 0 && /^${String(Number(ymd.slice(8, 10)))}$/.test((e.textContent || '').trim()))
737
+ .slice(0, 8).map(e => ({ tag: e.tagName, cls: String(e.className).slice(0, 50),
738
+ vis: e.getClientRects().length > 0, offParent: !!e.offsetParent,
739
+ pCls: String(e.parentElement && e.parentElement.className).slice(0, 50) })),
740
+ months: Array.from(document.querySelectorAll('*'))
741
+ .filter(e => e.children.length === 0 && /年\\d{1,2}月$/.test((e.textContent || '').trim()))
742
+ .slice(0, 6).map(e => ({ t: (e.textContent || '').trim(), vis: e.getClientRects().length > 0 }))
743
+ }))()`,
744
+ ).catch(() => '?'))
745
+ throw new Error(`日期 ${ymd} 未能设置(输入框只读且日历未命中);页面事实:${facts}`)
746
+ }
747
+ await sleep(400)
748
+ },
749
+ async press(key) { await page.keyboard.press(key) },
750
+ async clickSearch() {
751
+ await page.keyboard.press('Escape').catch(() => {})
752
+ await sleep(500)
753
+ const dumpDir = process.env.GOTRY_GUI_DUMP
754
+ if (dumpDir) {
755
+ const cands = String(await page.evaluate<string>(CLICK_FORM_SEARCH_CANDIDATES).catch(() => '{"err":"eval-fail"}'))
756
+ try { mkdirSync(dumpDir, { recursive: true }); writeFileSync(join(dumpDir, 'search-candidates.json'), cands) } catch { /* best effort */ }
757
+ await page.screenshot({ path: join(dumpDir, 'pre-search.png') }).catch(() => {})
758
+ }
759
+ await page.evaluate<void>(CLICK_FORM_SEARCH)
760
+ },
761
+ async visibleFormText() {
762
+ return String(await page.evaluate<string>(VIS_FORM_TEXT).catch(() => ''))
763
+ },
764
+ async cityFieldValues() {
765
+ return JSON.parse(String(await page.evaluate<string>(CITY_FIELD_VALUES).catch(() => '{"from":"","to":""}'))) as { from: string; to: string }
766
+ },
767
+ async pageText() {
768
+ const title = await page.title().catch(() => '')
769
+ const text = String(await page.evaluate<string>('document.body.innerText.slice(0, 3000)').catch(() => ''))
770
+ return title + text
771
+ },
772
+ async currentUrl() {
773
+ return page.url()
774
+ },
775
+ async waitForApiCapture(timeoutMs) {
776
+ return new Promise(resolve => {
777
+ const timer = setTimeout(() => {
778
+ apiWaiter = null
779
+ resolve(capture.itinerary || capture.calendar ? { ...capture } : null)
780
+ }, timeoutMs)
781
+ apiWaiter = v => { clearTimeout(timer); resolve(v) }
782
+ })
783
+ },
784
+ async recentApiUrls() { return [...recentUrls] },
785
+ }
786
+ },
787
+ async close() {
788
+ await ctx.close().catch(() => {})
789
+ rmSync(profile, { recursive: true, force: true })
790
+ },
791
+ }
792
+ }