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