@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
|
@@ -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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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,
|
|
169
|
+
return fail(e.message.slice(0, 500));
|
|
148
170
|
}
|
|
149
171
|
let apiBody = null;
|
|
150
172
|
let listUrl = '';
|
|
151
|
-
|
|
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
|
-
|
|
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),
|
|
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),
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
231
|
+
throw new GuiFlowAbort(fail('风控/验证码命中;按红线不重试不绕过,交还用户', 'challenged'));
|
|
207
232
|
}
|
|
208
|
-
|
|
209
|
-
if (!
|
|
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
|
-
|
|
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}
|
|
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} 天(
|
|
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
|
-
&&
|
|
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
|
|
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
|
-
&&
|
|
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) {
|
|
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
|
|
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 -
|
|
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 +
|
|
451
|
-
&& cx >= dateRect.left - 80 && cx <= dateRect.right +
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
611
|
-
|
|
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' ? '出发' : '到达'}城市输入框未定位到(
|
|
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
|
-
|
|
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
|
|
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(() => '');
|