@danceiny/gotry 0.2.0-rc.31 → 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/bin/gotry-bootstrap.js +63 -53
- package/bin/gotry-hbcli-release.d.ts +3 -0
- package/bin/gotry-hbcli-release.js +4 -0
- package/client/client.js +66 -21
- package/dist/capabilities/doctor.js +7 -8
- package/dist/capabilities/effect.js +45 -12
- package/dist/capabilities/flyai-config.js +12 -10
- package/dist/capabilities/flyai.js +87 -56
- package/dist/capabilities/hbcli-config.js +156 -0
- package/dist/capabilities/hbcli.js +29 -2
- package/dist/capabilities/resilience.js +6 -6
- package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
- package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
- package/dist/capabilities/session/bridge-ledger.js +168 -67
- package/dist/capabilities/session/ctrip-city.js +186 -0
- package/dist/capabilities/session/extension-bridge.js +148 -47
- package/dist/capabilities/session/gui-flight.js +949 -0
- package/dist/capabilities/session/gui-hotel.js +470 -0
- package/dist/capabilities/session-search.js +102 -14
- 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/bootstrap-tests.js +23 -6
- package/dist/scripts/bridge-ledger-tests.js +20 -18
- package/dist/scripts/ctrip-city-tests.js +80 -0
- package/dist/scripts/effect-tests.js +602 -504
- package/dist/scripts/extension-tests.js +14 -8
- package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
- package/dist/scripts/flight-malformed-tests.js +1 -1
- package/dist/scripts/flyai-setup-tests.js +19 -4
- package/dist/scripts/flyai-tests.js +768 -653
- package/dist/scripts/gotry-web-api-tests.js +5 -0
- package/dist/scripts/gotry-web-client-tests.js +20 -1
- package/dist/scripts/gui-flight-live.js +27 -0
- package/dist/scripts/hbcli-tests.js +1 -1
- package/dist/scripts/hbcli-upgrade-tests.js +107 -0
- package/dist/scripts/hbcli-web-setup-tests.js +114 -0
- package/dist/scripts/hotel-date-gate-tests.js +1 -1
- package/dist/scripts/hotel-result-tests.js +169 -0
- package/dist/scripts/issue-271-liveness-tests.js +1 -1
- package/dist/scripts/mutation-check.js +153 -0
- package/dist/scripts/nightly-evidence.js +4 -111
- package/dist/scripts/session-tests.js +211 -6
- 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/gotry-web-api.js +64 -2
- package/dist/src/index.js +70 -18
- 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 +5 -1
- package/ts/capabilities/flyai-config.ts +12 -10
- package/ts/capabilities/flyai.ts +91 -50
- package/ts/capabilities/hbcli-config.ts +128 -0
- package/ts/capabilities/hbcli.ts +30 -3
- package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
- package/ts/capabilities/session/bridge-ledger.ts +192 -78
- package/ts/capabilities/session/ctrip-city.ts +158 -0
- package/ts/capabilities/session/extension-bridge.ts +185 -67
- package/ts/capabilities/session/gui-flight.ts +1022 -0
- package/ts/capabilities/session/gui-hotel.ts +505 -0
- package/ts/capabilities/session-search.ts +130 -18
- 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/gotry-web-api.ts +51 -2
- package/ts/src/index.ts +67 -19
- 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
|
@@ -0,0 +1,949 @@
|
|
|
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
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { tmpdir } from 'node:os';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { isSubmitText } from './read-guard.js';
|
|
32
|
+
import { NETWORK_HINTS, parseBatchSearchResult, LOGIN_COOKIE_NAMES } from './adapters/ctrip-flight.js';
|
|
33
|
+
export const FLIGHT_GUI_HOME = 'https://flights.ctrip.com/';
|
|
34
|
+
/**
|
|
35
|
+
* 低价日历接口(live 校准 2026-10-14):新版 PC 匿名 batchSearch 只回 needUserLogin
|
|
36
|
+
* 配置探测(无行程数据),而该日历接口匿名有数据——GUI 车道的结果绑定面(#661)。
|
|
37
|
+
*/
|
|
38
|
+
export const CALENDAR_HINT = /FlightIntlAndInlandLowestPriceSearch/;
|
|
39
|
+
const YMD = /^\d{4}-\d{2}-\d{2}$/;
|
|
40
|
+
/** 挑战页特征(与桥车道同口径:验证/确认中文词) */
|
|
41
|
+
const GUI_CHALLENGE_RE = /验证|校验|拖动滑块|请输入验证码/;
|
|
42
|
+
/** 纯函数:查询 → 动作序列(离线测试锚点;执行前必须过 assertGuiPlanSafe) */
|
|
43
|
+
export function planFlightGuiActions(q) {
|
|
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
|
+
}
|
|
57
|
+
// live 校准(2026-10-14 pre-search 截图):该表单默认即「往返」态(双日期框预填)——
|
|
58
|
+
// 单程必须显式点「单程」,否则按非法日期对(回<去)搜索且日历面板挂在回程框上。
|
|
59
|
+
const tripTab = [{ t: 'clickTripTab', trip: q.backDate ? 'roundtrip' : 'oneway' }];
|
|
60
|
+
const backDate = q.backDate ? [{ t: 'setDate', which: 'back', ymd: q.backDate }] : [];
|
|
61
|
+
return [
|
|
62
|
+
{ t: 'goto', url: FLIGHT_GUI_HOME },
|
|
63
|
+
...tripTab,
|
|
64
|
+
{ t: 'typeCity', which: 'from', text: q.from },
|
|
65
|
+
{ t: 'pickSuggest', which: 'from', text: q.from },
|
|
66
|
+
{ t: 'typeCity', which: 'to', text: q.to },
|
|
67
|
+
{ t: 'pickSuggest', which: 'to', text: q.to },
|
|
68
|
+
{ t: 'setDate', which: 'dep', ymd: q.date },
|
|
69
|
+
...backDate,
|
|
70
|
+
{ t: 'press', key: 'Escape' },
|
|
71
|
+
{ t: 'readback', expect: { legs: [{ from: q.from, to: q.to, date: q.date, ...(q.backDate ? { back: q.backDate } : {}) }] } },
|
|
72
|
+
{ t: 'clickSearch' },
|
|
73
|
+
{ t: 'awaitApi' },
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 安全白名单(纯函数):闭集外动作/提交件文本/非固定入口 URL/坏日期/坏城市
|
|
78
|
+
* 一律拒绝并给出原因;通过才允许执行器消费。
|
|
79
|
+
*/
|
|
80
|
+
export function assertGuiPlanSafe(plan) {
|
|
81
|
+
for (const a of plan) {
|
|
82
|
+
switch (a.t) {
|
|
83
|
+
case 'goto':
|
|
84
|
+
if (a.url !== FLIGHT_GUI_HOME)
|
|
85
|
+
return { ok: false, error: `GUI 车道只允许固定入口 ${FLIGHT_GUI_HOME},拒绝 ${a.url}` };
|
|
86
|
+
break;
|
|
87
|
+
case 'clickTripTab':
|
|
88
|
+
if (a.trip !== 'oneway' && a.trip !== 'roundtrip' && a.trip !== 'multi')
|
|
89
|
+
return { ok: false, error: `trip 闭集外:${a.trip}` };
|
|
90
|
+
break;
|
|
91
|
+
case 'typeCity':
|
|
92
|
+
case 'pickSuggest': {
|
|
93
|
+
const text = a.text.trim();
|
|
94
|
+
if (!text || text.length > 24)
|
|
95
|
+
return { ok: false, error: `城市文本非法(空/超长):${text.slice(0, 30)}` };
|
|
96
|
+
if (isSubmitText(text))
|
|
97
|
+
return { ok: false, error: `城市位出现提交件文本,拒绝:${text}` };
|
|
98
|
+
if (a.which !== 'from' && a.which !== 'to')
|
|
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}` };
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
case 'setDate':
|
|
105
|
+
if (!YMD.test(a.ymd))
|
|
106
|
+
return { ok: false, error: `日期需 YYYY-MM-DD,实际 ${a.ymd}` };
|
|
107
|
+
if (a.which !== 'dep' && a.which !== 'back')
|
|
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}` };
|
|
111
|
+
break;
|
|
112
|
+
case 'press':
|
|
113
|
+
if (a.key !== 'Escape' && a.key !== 'Enter')
|
|
114
|
+
return { ok: false, error: `按键白名单只接受 Escape/Enter,拒绝 ${a.key}` };
|
|
115
|
+
break;
|
|
116
|
+
case 'clickSearch':
|
|
117
|
+
break; // 「搜索」是本车道唯一允许的按钮;提交件黑名单语义(下单/支付)不在其列
|
|
118
|
+
case 'readback':
|
|
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
|
+
}
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
case 'awaitApi':
|
|
130
|
+
break;
|
|
131
|
+
default:
|
|
132
|
+
return { ok: false, error: `动作闭集外:${JSON.stringify(a).slice(0, 60)}` };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { ok: true };
|
|
136
|
+
}
|
|
137
|
+
/** 测试注入 seam(__setTrainSessionTransportForTest 同款) */
|
|
138
|
+
let guiDriverFactory = null;
|
|
139
|
+
export function __setGuiDriverForTest(factory) {
|
|
140
|
+
guiDriverFactory = factory;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* GUI 检索执行:规划→安全闸→driver 走动作→readback 硬闸→API 回包→既有解析器。
|
|
144
|
+
* 永不抛错;driver 缺省时 lazy 起 playwright 隔离窗口(生产路径)。
|
|
145
|
+
*/
|
|
146
|
+
export async function runFlightGuiSearch(q) {
|
|
147
|
+
const started = Date.now();
|
|
148
|
+
const ts = new Date().toISOString();
|
|
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 };
|
|
152
|
+
const fail = (error, verdict = 'error') => ({
|
|
153
|
+
ok: false, via: 'session-ctrip-flight-gui-error', latencyMs: Date.now() - started, verdict, error,
|
|
154
|
+
evidence: `[会话:ctrip-flight-gui@error@${ts}] ${error.slice(0, 120)}`, ...base,
|
|
155
|
+
});
|
|
156
|
+
const plan = planFlightGuiActions(q);
|
|
157
|
+
const safe = assertGuiPlanSafe(plan);
|
|
158
|
+
if (!safe.ok)
|
|
159
|
+
return fail(`GUI 计划被安全闸拒绝:${safe.error}`);
|
|
160
|
+
const budget = q.timeoutMs ?? 90_000;
|
|
161
|
+
const trace = [];
|
|
162
|
+
const driver = guiDriverFactory ? guiDriverFactory() : await createPlaywrightDriver();
|
|
163
|
+
let page;
|
|
164
|
+
try {
|
|
165
|
+
page = await withDeadline(driver.open(), Math.min(budget, 30_000), 'GUI 浏览器窗口启动失败/超时(本机需已装 Chrome)');
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
await driver.close().catch(() => { });
|
|
169
|
+
return fail(e.message.slice(0, 500));
|
|
170
|
+
}
|
|
171
|
+
let apiBody = null;
|
|
172
|
+
let listUrl = '';
|
|
173
|
+
/** 流程性失败(硬闸/挑战页/超时)以结果对象抛出,由外层统一返回 */
|
|
174
|
+
class GuiFlowAbort {
|
|
175
|
+
result;
|
|
176
|
+
constructor(result) {
|
|
177
|
+
this.result = result;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** 单次驾驶:整计划走到结果回包,返回捕获 */
|
|
181
|
+
const driveOnce = async () => {
|
|
182
|
+
let localCapture = null;
|
|
183
|
+
for (const a of plan) {
|
|
184
|
+
const remain = budget - (Date.now() - started);
|
|
185
|
+
if (remain <= 0)
|
|
186
|
+
throw new GuiFlowAbort(fail(`GUI 预算耗尽(${budget}ms)`));
|
|
187
|
+
const step = (p, what) => withDeadline(p, Math.min(remain, 20_000), `GUI 步骤失败(${what})`);
|
|
188
|
+
switch (a.t) {
|
|
189
|
+
case 'goto':
|
|
190
|
+
await step(page.goto(a.url), '打开携程机票首页');
|
|
191
|
+
break;
|
|
192
|
+
case 'clickTripTab':
|
|
193
|
+
await step(page.clickTripTab(a.trip), `切换${a.trip === 'roundtrip' ? '往返' : a.trip === 'multi' ? '多程' : '单程'}`);
|
|
194
|
+
break;
|
|
195
|
+
case 'typeCity':
|
|
196
|
+
await step(page.typeCity(a.which, a.text, a.seg ?? 1), `填写第${a.seg ?? 1}段${a.which === 'from' ? '出发' : '到达'}城市`);
|
|
197
|
+
break;
|
|
198
|
+
case 'pickSuggest':
|
|
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(() => ({})))}`);
|
|
202
|
+
break;
|
|
203
|
+
case 'setDate':
|
|
204
|
+
await step(page.setDate(a.which, a.ymd, a.seg ?? 1), `设置第${a.seg ?? 1}段${a.which === 'dep' ? '去程' : '回程'}日期`);
|
|
205
|
+
break;
|
|
206
|
+
case 'press':
|
|
207
|
+
await step(page.press(a.key), `按键 ${a.key}`);
|
|
208
|
+
break;
|
|
209
|
+
case 'clickSearch':
|
|
210
|
+
await step(page.clickSearch(), '点击搜索');
|
|
211
|
+
break;
|
|
212
|
+
case 'readback': {
|
|
213
|
+
const form = await step(page.visibleFormText(), '读回表单');
|
|
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)})——已终止,未发起搜索`));
|
|
218
|
+
}
|
|
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}」未在表单确认——已终止,未发起搜索`));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
case 'awaitApi': {
|
|
229
|
+
const text = await step(page.pageText(), '检查页面');
|
|
230
|
+
if (GUI_CHALLENGE_RE.test(text)) {
|
|
231
|
+
throw new GuiFlowAbort(fail('风控/验证码命中;按红线不重试不绕过,交还用户', 'challenged'));
|
|
232
|
+
}
|
|
233
|
+
localCapture = await step(page.waitForApiCapture(Math.min(remain - 500, 18_000)), '等待结果回包(batchSearch/低价日历)');
|
|
234
|
+
if (!localCapture || (!localCapture.itinerary && !localCapture.calendar)) {
|
|
235
|
+
const recent = page.recentApiUrls ? (await page.recentApiUrls().catch(() => [])) : [];
|
|
236
|
+
throw new GuiFlowAbort(fail(`结果回包嗅探超时:batchSearch(行程)与低价日历均未收到;本次检索未完成${recent.length ? ` ‖recent-apis: ${recent.slice(-8).join(' ; ').slice(0, 600)}` : ''}`));
|
|
237
|
+
}
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
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
|
+
}
|
|
257
|
+
listUrl = await page.currentUrl().catch(() => '');
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
if (e instanceof GuiFlowAbort)
|
|
261
|
+
return e.result;
|
|
262
|
+
return fail(`${e.message.slice(0, 500)}${trace.length ? ` ‖trace: ${trace.join(' ; ').slice(0, 400)}` : ''}`);
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
await driver.close().catch(() => { });
|
|
266
|
+
}
|
|
267
|
+
dumpCapture(apiBody, listUrl);
|
|
268
|
+
// 结算优先级:行程 batchSearch(登录态/部分构建)> 低价日历(匿名可用,#661 绑定面)
|
|
269
|
+
if (apiBody?.itinerary) {
|
|
270
|
+
const parsed = parseBatchSearchResult(apiBody.itinerary.body);
|
|
271
|
+
if (parsed.verdict === 'error') {
|
|
272
|
+
return fail(`batchSearch 响应形状异常(${parsed.shape};非合法空响应)body头:${apiBody.itinerary.body.replace(/\s+/g, ' ').slice(0, 500)}`);
|
|
273
|
+
}
|
|
274
|
+
const verdict = parsed.verdict === 'hit' ? 'hit' : 'miss';
|
|
275
|
+
const calendar = apiBody.calendar ? parseLowestPriceCalendar(apiBody.calendar.body) : undefined;
|
|
276
|
+
return {
|
|
277
|
+
ok: verdict === 'hit', via: 'session-ctrip-flight-gui', latencyMs: Date.now() - started, verdict,
|
|
278
|
+
...(verdict === 'hit' ? { options: parsed.options } : {}),
|
|
279
|
+
...(calendar && calendar.length > 0 ? { calendar } : {}),
|
|
280
|
+
...(listUrl ? { listUrl } : {}),
|
|
281
|
+
evidence: `[会话:ctrip-flight-gui@${ts}] ${trip} GUI 面检索:${parsed.options.length} 条(transport=gui shape=${parsed.shape}${calendar?.length ? `;calendar=${calendar.length}天` : ''};${humanLoggedIn ? '用户本人已在本窗口官方页登录,登录态真实数据' : '匿名公开面'})`,
|
|
282
|
+
...base,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (apiBody?.calendar) {
|
|
286
|
+
const calendar = parseLowestPriceCalendar(apiBody.calendar.body);
|
|
287
|
+
if (calendar.length === 0) {
|
|
288
|
+
return fail(`低价日历回包形状未识别(body头:${apiBody.calendar.body.replace(/\s+/g, ' ').slice(0, 300)};GOTRY_GUI_DUMP 可落盘原样供校准)`);
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
ok: true, via: 'session-ctrip-flight-gui', latencyMs: Date.now() - started, verdict: 'hit',
|
|
292
|
+
calendar, ...(listUrl ? { listUrl } : {}),
|
|
293
|
+
evidence: `[会话:ctrip-flight-gui@${ts}] ${trip} 匿名 GUI 面·低价日历:${calendar.length} 天(行程明细需登录态:interactiveLogin=true 可在窗口内本人登录后自动重查)`,
|
|
294
|
+
...base,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
return fail('内部不变量失败:apiBody 双槽皆空(awaitApi 已结算)');
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* 低价日历解析(#662):按 live 实测形状产出 date/price;形状未识别返回空数组
|
|
301
|
+
* (fail-closed,绝不发明价格)。形状锚定见 session-tests §L fixture。
|
|
302
|
+
*/
|
|
303
|
+
export function parseLowestPriceCalendar(body) {
|
|
304
|
+
let raw;
|
|
305
|
+
try {
|
|
306
|
+
raw = JSON.parse(body);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
312
|
+
return [];
|
|
313
|
+
// live 实测(2026-10-14,香港/上海 GUI 捕获):顶层 priceList 数组(responseStatus.Ack
|
|
314
|
+
// 信封);兼容 data.* 形态。条目键按实测锚定,未识别=空(fail-closed)。
|
|
315
|
+
const scopes = [raw];
|
|
316
|
+
const data = raw.data;
|
|
317
|
+
if (data !== null && typeof data === 'object' && !Array.isArray(data))
|
|
318
|
+
scopes.push(data);
|
|
319
|
+
for (const key of ['priceList', 'priceCalendar', 'calendarList', 'lowPriceList', 'flightLowPrice']) {
|
|
320
|
+
const list = scopes.map(scope => scope[key]).find(v => Array.isArray(v));
|
|
321
|
+
if (!list)
|
|
322
|
+
continue;
|
|
323
|
+
const days = [];
|
|
324
|
+
for (const item of list) {
|
|
325
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item))
|
|
326
|
+
continue;
|
|
327
|
+
const it = item;
|
|
328
|
+
// live 实测锚点(2026-10-14):departDate = .NET /Date(ms+0800)/;price>0 才是可售低价
|
|
329
|
+
const date = netDateToYmd(it.departDate) ?? (typeof it.date === 'string' ? it.date.trim() : '');
|
|
330
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
|
|
331
|
+
continue;
|
|
332
|
+
if (typeof it.price !== 'number' || !Number.isFinite(it.price) || it.price <= 0)
|
|
333
|
+
continue;
|
|
334
|
+
days.push({ date, price: it.price });
|
|
335
|
+
}
|
|
336
|
+
if (days.length > 0) {
|
|
337
|
+
// 往返回包含去/回两程日历(同日双价,live 实测)——按日聚合取最低,evidence 可追溯。
|
|
338
|
+
const byDate = new Map();
|
|
339
|
+
for (const d of days) {
|
|
340
|
+
const prev = byDate.get(d.date);
|
|
341
|
+
if (!prev || (d.price ?? Infinity) < (prev.price ?? Infinity))
|
|
342
|
+
byDate.set(d.date, d);
|
|
343
|
+
}
|
|
344
|
+
return [...byDate.values()].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return [];
|
|
348
|
+
}
|
|
349
|
+
/** /Date(1791993600000+0800)/ → YYYY-MM-DD(按 +0800 面向用户的日历日) */
|
|
350
|
+
function netDateToYmd(value) {
|
|
351
|
+
if (typeof value !== 'string')
|
|
352
|
+
return undefined;
|
|
353
|
+
const match = /^\/Date\((\d+)(?:[+-]\d{4})?\)\/$/.exec(value.trim());
|
|
354
|
+
if (!match)
|
|
355
|
+
return undefined;
|
|
356
|
+
const ms = Number(match[1]);
|
|
357
|
+
if (!Number.isFinite(ms))
|
|
358
|
+
return undefined;
|
|
359
|
+
const d = new Date(ms + 8 * 3600 * 1000); // 日历面向 +0800 时区
|
|
360
|
+
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
|
|
361
|
+
}
|
|
362
|
+
/** 校准/漂移诊断钩子:GOTRY_GUI_DUMP=<dir> 落盘原始回包与 listUrl(不含任何账号面) */
|
|
363
|
+
function dumpCapture(capture, listUrl) {
|
|
364
|
+
const dir = process.env.GOTRY_GUI_DUMP;
|
|
365
|
+
if (!dir || !capture)
|
|
366
|
+
return;
|
|
367
|
+
try {
|
|
368
|
+
mkdirSync(dir, { recursive: true });
|
|
369
|
+
if (capture.itinerary)
|
|
370
|
+
writeFileSync(join(dir, 'itinerary.json'), capture.itinerary.body);
|
|
371
|
+
if (capture.calendar)
|
|
372
|
+
writeFileSync(join(dir, 'calendar.json'), capture.calendar.body);
|
|
373
|
+
if (listUrl)
|
|
374
|
+
writeFileSync(join(dir, 'page-url.txt'), listUrl);
|
|
375
|
+
}
|
|
376
|
+
catch { /* best effort */ }
|
|
377
|
+
}
|
|
378
|
+
async function withDeadline(p, ms, what) {
|
|
379
|
+
let timer;
|
|
380
|
+
try {
|
|
381
|
+
return await Promise.race([
|
|
382
|
+
p,
|
|
383
|
+
new Promise((_, reject) => {
|
|
384
|
+
timer = setTimeout(() => reject(new Error(`${what}:${ms}ms`)), ms);
|
|
385
|
+
}),
|
|
386
|
+
]);
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
if (timer)
|
|
390
|
+
clearTimeout(timer);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// ── 生产 driver:隔离临时 profile 的 headful Chrome(匿名公开面;不碰用户 Chrome) ──
|
|
394
|
+
const VIS_FORM_TEXT = `(() => Array.from(document.querySelectorAll('input'))
|
|
395
|
+
.map(e => [e.value, e.placeholder].filter(Boolean).join('¤')).join(' | '))()`;
|
|
396
|
+
/**
|
|
397
|
+
* 城市框定位(2026-10-14 live 校准,第 11 轮定稿):两框占位符同为「可输入城市或机场」,
|
|
398
|
+
* 标签「出发地」「目的地」是与输入框同容器的叶子——祖先 ctx 判别会爬到共享行同时命中
|
|
399
|
+
* 两框(实测 from/to 双双解析到同一框)。改文档顺序叶子邻接:标签叶子 ±10 叶子窗口内
|
|
400
|
+
* 最近的「城市或机场」输入框即归属框。标签失配返回 -1(fail-closed 带实证)。
|
|
401
|
+
*/
|
|
402
|
+
/** CITY_LABEL_ADJOIN 思路(文档顺序标签±10 叶子窗口邻接)的落地:返回目标框在可见 input 序 */
|
|
403
|
+
const CITY_INPUT_IDX_FROM_LEAVES = (which) => `(() => {
|
|
404
|
+
const leaves = Array.from(document.querySelectorAll('*'))
|
|
405
|
+
.filter(e => e.offsetParent && e.children.length === 0)
|
|
406
|
+
const isCityInput = (e) => e.tagName === 'INPUT'
|
|
407
|
+
&& (!e.placeholder || /城市|机场/.test(e.placeholder)) && !/旅游|搜索|请输入/.test(e.placeholder || '')
|
|
408
|
+
const inputs = leaves.map((e, i) => ({ e, i })).filter(({ e }) => isCityInput(e))
|
|
409
|
+
const visibleInputs = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
|
|
410
|
+
const nearest = (label) => {
|
|
411
|
+
let li = -1
|
|
412
|
+
for (let i = 0; i < leaves.length; i += 1) {
|
|
413
|
+
const e = leaves[i]
|
|
414
|
+
if ((e.textContent || '').trim() === label) { li = i; break }
|
|
415
|
+
}
|
|
416
|
+
if (li < 0) return -1
|
|
417
|
+
let best = -1, bestDist = 99
|
|
418
|
+
for (const inp of inputs) {
|
|
419
|
+
const d = Math.abs(inp.i - li)
|
|
420
|
+
if (d < bestDist && d <= 10) { best = inp.i; bestDist = d }
|
|
421
|
+
}
|
|
422
|
+
if (best < 0) return -1
|
|
423
|
+
return visibleInputs.indexOf(leaves[best])
|
|
424
|
+
}
|
|
425
|
+
return nearest(${which === 'from' ? "'出发地'" : "'目的地'"}) })()`;
|
|
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 })()`;
|
|
453
|
+
/** 城市框当前值(出发/到达各自;readback 逐字段核对用,防全局搜索框文本假阳) */
|
|
454
|
+
const CITY_FIELD_VALUES_FN = (occurrence) => `(() => {
|
|
455
|
+
const leaves = Array.from(document.querySelectorAll('*'))
|
|
456
|
+
.filter(e => e.offsetParent && e.children.length === 0)
|
|
457
|
+
const isCityInput = (e) => e.tagName === 'INPUT'
|
|
458
|
+
&& (!e.placeholder || /城市|机场/.test(e.placeholder)) && !/旅游|搜索|请输入/.test(e.placeholder || '')
|
|
459
|
+
const inputs = leaves.map((e, i) => ({ e, i })).filter(({ e }) => isCityInput(e))
|
|
460
|
+
const nearest = (label, occurrence) => {
|
|
461
|
+
let li = -1, seen = 0
|
|
462
|
+
for (let i = 0; i < leaves.length; i += 1) {
|
|
463
|
+
if ((leaves[i].textContent || '').trim() === label) {
|
|
464
|
+
seen += 1
|
|
465
|
+
if (seen === (occurrence || 1)) { li = i; break }
|
|
466
|
+
}
|
|
467
|
+
}
|
|
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])
|
|
471
|
+
let best = null, bestDist = 99
|
|
472
|
+
for (const inp of inputs) {
|
|
473
|
+
const d = Math.abs(inp.i - li)
|
|
474
|
+
if (d < bestDist && d <= 10 && Math.abs(yOf(inp.e) - ly) <= 60) { best = inp; bestDist = d }
|
|
475
|
+
}
|
|
476
|
+
return best
|
|
477
|
+
}
|
|
478
|
+
const f = nearest('出发地', ${occurrence ?? 1}), t = nearest('目的地', ${occurrence ?? 1})
|
|
479
|
+
return JSON.stringify({ from: f ? (f.e.value || '') : '', to: t ? (t.e.value || '') : '' }) })()`;
|
|
480
|
+
/** 日期框定位(live 校准:只读,占位 yyyy-mm-dd;往返时第二个) */
|
|
481
|
+
const DATE_INPUT_IDX = (which) => `(() => { const els = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
|
|
482
|
+
const hits = els.map((e, i) => ({ i, ph: e.placeholder || '', ro: e.readOnly, v: e.value || '' }))
|
|
483
|
+
.filter(x => /y{2,4}[-/]m{1,2}|日期|date/i.test(x.ph) || /^\\d{4}-\\d{2}-\\d{2}/.test(x.v))
|
|
484
|
+
const back = ${which === 'back'}
|
|
485
|
+
if (hits.length >= (back ? 2 : 1)) return hits[back ? 1 : 0].i
|
|
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 })()`;
|
|
515
|
+
/** 表单内搜索按钮(live 校准:按钮是无语义 div,文本「搜索」为精确叶子;全局搜索框旁
|
|
516
|
+
* 也有「搜索」——只在日期框 8 层祖先窗口内找,绝不全局兜底) */
|
|
517
|
+
const CLICK_FORM_SEARCH = `(() => {
|
|
518
|
+
const els = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
|
|
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
|
|
525
|
+
if (!dateEl) throw new Error('搜索按钮定位失败:日期框不存在')
|
|
526
|
+
const click = (el) => {
|
|
527
|
+
let target = el
|
|
528
|
+
for (let k = 0; k < 3 && target.parentElement; k += 1) {
|
|
529
|
+
const p = target.parentElement
|
|
530
|
+
const st = getComputedStyle(p)
|
|
531
|
+
if (st.cursor === 'pointer' || p.tagName === 'BUTTON' || p.hasAttribute('role')) { target = p; break }
|
|
532
|
+
}
|
|
533
|
+
target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
534
|
+
target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
|
|
535
|
+
target.click()
|
|
536
|
+
return true }
|
|
537
|
+
// 几何判据 v2(live 校准 2026-10-14:按钮在表单行右端的无语义 styled div,文本非
|
|
538
|
+
// 「搜索」叶子;首版「下方最大块」会误中低价推广卡——SHA→TGO fuzzy 跳页实测):
|
|
539
|
+
// ①行右端:cx 在日期框右侧 500px 内、cy 与行对齐,宽 60–320、高 ≥40,取最近;
|
|
540
|
+
// ②次选:日期框正下方 250px 内的窄块(兼容改版)。绝不取大横幅(宽>320)。
|
|
541
|
+
const dateRect = dateEl.getBoundingClientRect()
|
|
542
|
+
let rowBtn = null, rowDist = 1e9
|
|
543
|
+
let belowBtn = null, belowDist = 1e9
|
|
544
|
+
for (const e of Array.from(document.querySelectorAll('*'))) {
|
|
545
|
+
if (e === dateEl || !e.offsetParent || e.contains(dateEl)) continue
|
|
546
|
+
const r = e.getBoundingClientRect()
|
|
547
|
+
if (r.width < 60 || r.width > 320 || r.height < 40) continue
|
|
548
|
+
if (getComputedStyle(e).cursor !== 'pointer') continue
|
|
549
|
+
const cx = r.left + r.width / 2
|
|
550
|
+
const cy = r.top + r.height / 2
|
|
551
|
+
if (cx >= dateRect.right - 20 && cx <= dateRect.right + 500
|
|
552
|
+
&& cy >= dateRect.top - 320 && cy <= dateRect.bottom + 400) {
|
|
553
|
+
const d = Math.abs(cy - (dateRect.top + dateRect.height / 2)) + (cx - dateRect.right) * 0.2
|
|
554
|
+
if (d < rowDist) { rowBtn = e; rowDist = d }
|
|
555
|
+
}
|
|
556
|
+
if (cy >= dateRect.bottom && cy <= dateRect.bottom + 400
|
|
557
|
+
&& cx >= dateRect.left - 80 && cx <= dateRect.right + 420) {
|
|
558
|
+
const d = cy - dateRect.bottom
|
|
559
|
+
if (d < belowDist) { belowBtn = e; belowDist = d }
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const best = rowBtn || belowBtn
|
|
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)
|
|
590
|
+
const ptr = Array.from(document.querySelectorAll('*'))
|
|
591
|
+
.filter(e => e.offsetParent && getComputedStyle(e).cursor === 'pointer' && e.textContent && e.textContent.trim().length <= 6)
|
|
592
|
+
.slice(0, 10).map(e => ({ t: (e.textContent || '').trim(), tag: e.tagName, cls: String(e.className).slice(0, 40) }))
|
|
593
|
+
throw new Error('搜索按钮未找到(日期框下方几何窗口内无 pointer 大块)pointer 候选:' + JSON.stringify(ptr))
|
|
594
|
+
})()`;
|
|
595
|
+
/** 校准采集版(GOTRY_GUI_DUMP 时 driver 落盘):行内全部 pointer 候选(文本/标签/类名/矩形) */
|
|
596
|
+
const CLICK_FORM_SEARCH_CANDIDATES = `(() => {
|
|
597
|
+
const els = Array.from(document.querySelectorAll('input')).filter(e => e.offsetParent)
|
|
598
|
+
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 || ''))
|
|
599
|
+
if (!dateEl) return JSON.stringify({ err: 'no dateEl' })
|
|
600
|
+
const dateRect = dateEl.getBoundingClientRect()
|
|
601
|
+
const cands = Array.from(document.querySelectorAll('*'))
|
|
602
|
+
.filter(e => e.offsetParent && getComputedStyle(e).cursor === 'pointer')
|
|
603
|
+
.map(e => { const r = e.getBoundingClientRect()
|
|
604
|
+
return { t: (e.textContent || '').trim().slice(0, 24), tag: e.tagName, cls: String(e.className).slice(0, 60),
|
|
605
|
+
x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } })
|
|
606
|
+
.filter(c => c.w >= 20 && c.h >= 12)
|
|
607
|
+
.slice(0, 100)
|
|
608
|
+
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 })
|
|
609
|
+
})()`;
|
|
610
|
+
/**
|
|
611
|
+
* 日历单元格(live 校准 2026-10-14):channel/domestic 日历同屏双月(「2026年10月」「2026年11月」),
|
|
612
|
+
* 日号格不匹配通用选择器——改走文档顺序状态机:叶子元素流里,目标月标题(YYYY年M月)之后、
|
|
613
|
+
* 下一个月标题之前,文本恰为日号的第一个元素即目标格;aria-label/title 含完整日期者优先。
|
|
614
|
+
*/
|
|
615
|
+
const CLICK_DATE_CELL = (ymd, anchorYmd) => {
|
|
616
|
+
const day = String(Number(ymd.slice(8, 10)));
|
|
617
|
+
const ymCn = `${ymd.slice(0, 4)}年${Number(ymd.slice(5, 7))}月`;
|
|
618
|
+
return `(() => {
|
|
619
|
+
const all = Array.from(document.querySelectorAll('*'))
|
|
620
|
+
const dayLeaves = all.filter(e => e.children.length === 0 && (e.textContent || '').trim() === '${day}')
|
|
621
|
+
const clickEl = (e) => {
|
|
622
|
+
const target = e.parentElement && /date-day|day|cell/.test(String(e.parentElement.className)) ? e.parentElement : e
|
|
623
|
+
target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
624
|
+
target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
|
|
625
|
+
target.click()
|
|
626
|
+
return true }
|
|
627
|
+
// 捷径:aria-label/title 含完整日期
|
|
628
|
+
const ariaHit = dayLeaves.find(e =>
|
|
629
|
+
((e.getAttribute('aria-label') || '') + (e.getAttribute('title') || '')).includes('${ymd}'))
|
|
630
|
+
if (ariaHit) return clickEl(ariaHit)
|
|
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}')))
|
|
635
|
+
const enabled = dayLeaves.filter(e => !/disabled/.test(String(e.parentElement && e.parentElement.className)))
|
|
636
|
+
if (ymVisible && enabled.length === 1) return clickEl(enabled[0])
|
|
637
|
+
// 几何月消歧(live 校准 2026-10-14 往返回程:双月面板两个同日号都可点,月标题元素
|
|
638
|
+
// 不可寻):日号格按 x 聚簇=月列,以「今天」锚定第 0 列=当前月,目标月偏移选列。
|
|
639
|
+
const rect = (e) => e.getBoundingClientRect()
|
|
640
|
+
const xs = enabled.map(e => rect(e).left + rect(e).width / 2).sort((a, b) => a - b)
|
|
641
|
+
if (xs.length >= 2 && xs[xs.length - 1] - xs[0] > 120) {
|
|
642
|
+
const split = (xs[0] + xs[xs.length - 1]) / 2
|
|
643
|
+
const left = enabled.filter(e => rect(e).left + rect(e).width / 2 < split)
|
|
644
|
+
const right = enabled.filter(e => rect(e).left + rect(e).width / 2 >= split)
|
|
645
|
+
const anchor = ${anchorYmd ? `{ y: ${Number(anchorYmd.slice(0, 4))}, m: ${Number(anchorYmd.slice(5, 7))} }` : 'null'}
|
|
646
|
+
const now = new Date()
|
|
647
|
+
const ay = anchor ? anchor.y : now.getFullYear()
|
|
648
|
+
const am = anchor ? anchor.m : now.getMonth() + 1
|
|
649
|
+
const delta = (${Number(ymd.slice(0, 4))} * 12 + ${Number(ymd.slice(5, 7))}) - (ay * 12 + am)
|
|
650
|
+
const pick = delta <= 0 ? left[0] : delta === 1 ? right[0] : null
|
|
651
|
+
if (pick) return clickEl(pick)
|
|
652
|
+
}
|
|
653
|
+
// 兜底:月边界状态机(月标题去空白精确匹配;标题可能拆分文本节点)
|
|
654
|
+
const norm = (s) => s.replace(/\\s+/g, '')
|
|
655
|
+
let inTargetMonth = false
|
|
656
|
+
for (const e of all) {
|
|
657
|
+
const ownText = norm(Array.from(e.childNodes).filter(n => n.nodeType === 3).map(n => n.textContent).join(''))
|
|
658
|
+
if (/^\\d{4}年\\d{1,2}月$/.test(ownText)) { inTargetMonth = ownText === norm('${ymCn}'); continue }
|
|
659
|
+
if (inTargetMonth && e.children.length === 0 && (e.textContent || '').trim() === '${day}') return clickEl(e)
|
|
660
|
+
}
|
|
661
|
+
return false })()`;
|
|
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))}月')))`;
|
|
674
|
+
async function createPlaywrightDriver() {
|
|
675
|
+
const { chromium } = await import('playwright-core');
|
|
676
|
+
const profile = mkdtempSync(join(tmpdir(), 'gotry-gui-flight-'));
|
|
677
|
+
const ctx = await chromium.launchPersistentContext(profile, {
|
|
678
|
+
channel: 'chrome', // 本机 Chrome,不下载浏览器(session PoC 同款)
|
|
679
|
+
headless: false, // headless 下携程回壳页(data-sources §8 已验证)
|
|
680
|
+
viewport: { width: 1480, height: 950 },
|
|
681
|
+
});
|
|
682
|
+
const page = ctx.pages()[0] ?? (await ctx.newPage());
|
|
683
|
+
// 双槽捕获:行程 batchSearch(优先)与低价日历(#661);监听常挂,waitForApiCapture 注册等待者
|
|
684
|
+
const capture = {};
|
|
685
|
+
let apiWaiter = null;
|
|
686
|
+
let itinerarySeen = false;
|
|
687
|
+
const recentUrls = [];
|
|
688
|
+
page.on('request', r => {
|
|
689
|
+
const u = r.url();
|
|
690
|
+
if (/api|search|restapi|soa2|itinerary/i.test(u) && !/\\.(js|css|png|jpg|svg|woff)/.test(u)) {
|
|
691
|
+
recentUrls.push(u.slice(0, 140));
|
|
692
|
+
if (recentUrls.length > 30)
|
|
693
|
+
recentUrls.shift();
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
page.on('response', async (r) => {
|
|
697
|
+
const url = r.url();
|
|
698
|
+
const isItineraryHint = NETWORK_HINTS.some((re) => re.test(url));
|
|
699
|
+
const isCalendarHint = CALENDAR_HINT.test(url);
|
|
700
|
+
if (!isItineraryHint && !isCalendarHint)
|
|
701
|
+
return;
|
|
702
|
+
try {
|
|
703
|
+
const body = await r.text();
|
|
704
|
+
if (isCalendarHint) {
|
|
705
|
+
capture.calendar = { url, body };
|
|
706
|
+
}
|
|
707
|
+
else if (body.includes('flightItineraryList')) {
|
|
708
|
+
capture.itinerary = { url, body };
|
|
709
|
+
itinerarySeen = true;
|
|
710
|
+
}
|
|
711
|
+
else if (body.includes('needUserLogin')) {
|
|
712
|
+
// 配置预检回包(needUserLogin 探测)——作为登录态配对触发信号捕获
|
|
713
|
+
capture.loginProbe = { url, body };
|
|
714
|
+
}
|
|
715
|
+
// 行程一到位即早结算(最富数据);否则等日历或超时
|
|
716
|
+
if (itinerarySeen && apiWaiter) {
|
|
717
|
+
const resolve = apiWaiter;
|
|
718
|
+
apiWaiter = null;
|
|
719
|
+
resolve?.({ ...capture });
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
catch { /* 回包体已释放 */ }
|
|
723
|
+
});
|
|
724
|
+
return {
|
|
725
|
+
async open() {
|
|
726
|
+
const sleep = (ms) => page.waitForTimeout(ms);
|
|
727
|
+
const visibleInput = (i) => page.locator('input:visible').nth(i);
|
|
728
|
+
let lastDepYmd = '';
|
|
729
|
+
const cityValues = async (seg = 1) => JSON.parse(String(await page.evaluate(CITY_FIELD_VALUES_FN(seg)).catch(() => '{"from":"","to":""}')));
|
|
730
|
+
return {
|
|
731
|
+
async goto(url) {
|
|
732
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 40_000 });
|
|
733
|
+
// 就绪闸(live 校准 2026-10-14:channel/domestic 冷启动是骨架屏,提前操作会被
|
|
734
|
+
// hydrate 重建表单吞掉——城市值消失/框互换都源于此)。等表单真出现再动手。
|
|
735
|
+
await page.locator('input[placeholder*="城市或机场"]').first().waitFor({ state: 'visible', timeout: 30_000 });
|
|
736
|
+
await sleep(2500); // hydrate 后的二次稳定
|
|
737
|
+
},
|
|
738
|
+
async clickTripTab(trip) {
|
|
739
|
+
const label = trip === 'roundtrip' ? '往返' : trip === 'multi' ? '多程' : '单程';
|
|
740
|
+
try {
|
|
741
|
+
await page.getByText(label, { exact: true }).first().click({ timeout: 4000 });
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
try {
|
|
745
|
+
await page.getByText(label, { exact: false }).first().click({ timeout: 4000 });
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
// 实证诊断:把页面真实事实(url/title/候选文本)带进错误,供下一轮校准
|
|
749
|
+
const facts = String(await page.evaluate(`(() => JSON.stringify({ url: location.href.slice(0, 120), title: document.title.slice(0, 80),
|
|
750
|
+
cands: Array.from(document.querySelectorAll('*'))
|
|
751
|
+
.filter(e => e.children.length === 0 && /单程|往返|多程/.test(e.textContent || '') && e.offsetParent)
|
|
752
|
+
.slice(0, 8).map(e => (e.textContent || '').trim()) }))()`).catch(() => '?'));
|
|
753
|
+
throw new Error(`行程 tab「${label}」未命中;页面事实:${facts}`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
await sleep(800);
|
|
757
|
+
},
|
|
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
|
+
}
|
|
779
|
+
if (idx < 0) {
|
|
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(() => '?'));
|
|
790
|
+
const href = String(await page.evaluate('location.href').catch(() => '?'));
|
|
791
|
+
throw new Error(`${which === 'from' ? '出发' : '到达'}城市输入框未定位到(seg=${seg};href=${href});行结构:${rows.slice(0, 700)}`);
|
|
792
|
+
}
|
|
793
|
+
const input = visibleInput(idx);
|
|
794
|
+
await input.click({ timeout: 5000 });
|
|
795
|
+
await input.fill('', { timeout: 3000 }).catch(() => { });
|
|
796
|
+
await input.pressSequentially(text, { delay: 160 });
|
|
797
|
+
},
|
|
798
|
+
async pickSuggest(which, text, seg = 1) {
|
|
799
|
+
// 站点补全默认高亮首项:ArrowDown+Enter 显式选中(live 校准:裸 Enter 可能
|
|
800
|
+
// 未命中面板,失焦后字段回弹默认值);选中后验证归一化 (XXX),不成立重试一次。
|
|
801
|
+
const pick = async () => {
|
|
802
|
+
await sleep(1800);
|
|
803
|
+
await page.keyboard.press('ArrowDown');
|
|
804
|
+
await sleep(150);
|
|
805
|
+
await page.keyboard.press('Enter');
|
|
806
|
+
await sleep(700);
|
|
807
|
+
const values = await cityValues(seg);
|
|
808
|
+
const v = which === 'from' ? values.from : values.to;
|
|
809
|
+
return v.includes(text) && /\([A-Z]{3}\)/.test(v);
|
|
810
|
+
};
|
|
811
|
+
if (await pick())
|
|
812
|
+
return;
|
|
813
|
+
if (await pick())
|
|
814
|
+
return;
|
|
815
|
+
if (seg > 1)
|
|
816
|
+
await sleep(1500); // 行内后续框的物化窗口(live 实测)
|
|
817
|
+
await page.screenshot({ path: `/tmp/gotry-gui-pick-${which}-${Date.now()}.png` }).catch(() => { });
|
|
818
|
+
const href = String(await page.evaluate('location.href').catch(() => '?'));
|
|
819
|
+
const values = await cityValues(seg);
|
|
820
|
+
const ctxDump = String(await page.evaluate(`(() => JSON.stringify(Array.from(document.querySelectorAll('input'))
|
|
821
|
+
.filter(e => e.offsetParent && /城市|机场/.test(e.placeholder || '') && !/旅游|搜索/.test(e.placeholder || ''))
|
|
822
|
+
.map(e => { let node = e, ctx = ''
|
|
823
|
+
for (let k = 0; k < 4 && node; k++) { node = node.parentElement; if (node) ctx += '∥' + node.innerText.slice(0, 100) }
|
|
824
|
+
return { ph: e.placeholder, v: e.value, ctx: ctx.slice(0, 260) } })))()`).catch(() => '?'));
|
|
825
|
+
throw new Error(`城市补全未选中:${which} 框实际「${(which === 'from' ? values.from : values.to).slice(0, 30)}」(期望含 ${text} 且归一化为 (三字码);href=${href});城市框实证:${ctxDump.slice(0, 900)}`);
|
|
826
|
+
},
|
|
827
|
+
async setDate(which, ymd, seg = 1) {
|
|
828
|
+
if (which === 'dep' && seg === 1)
|
|
829
|
+
lastDepYmd = ymd;
|
|
830
|
+
const idx = await page.evaluate(seg > 1 ? DATE_INPUT_IDX_SEG(seg) : DATE_INPUT_IDX(which));
|
|
831
|
+
const input = visibleInput(idx);
|
|
832
|
+
await input.click({ timeout: 5000 });
|
|
833
|
+
await sleep(600);
|
|
834
|
+
// 策略①:输入框可写(部分构建支持键入;React 受控件用原生 setter+input 事件)
|
|
835
|
+
const typed = await input.evaluate((el, v) => {
|
|
836
|
+
if (el.readOnly)
|
|
837
|
+
return false;
|
|
838
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
|
839
|
+
setter?.call(el, v);
|
|
840
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
841
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
842
|
+
return el.value === v;
|
|
843
|
+
}, ymd).catch(() => false);
|
|
844
|
+
if (typed) {
|
|
845
|
+
await sleep(400);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
// 策略②:日历单元格(aria/title 含完整日期优先;否则面板可见目标月时按日号)
|
|
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
|
+
}
|
|
864
|
+
if (!clicked) {
|
|
865
|
+
await page.screenshot({ path: `/tmp/gotry-gui-date-${which}-${Date.now()}.png` }).catch(() => { });
|
|
866
|
+
// 实证诊断:日号叶子(任意可见性)+ 月标题叶子的真实形状,供下一轮校准
|
|
867
|
+
const facts = String(await page.evaluate(`(() => JSON.stringify({
|
|
868
|
+
href: location.href.slice(0, 140),
|
|
869
|
+
days: Array.from(document.querySelectorAll('*'))
|
|
870
|
+
.filter(e => e.children.length === 0 && /^${String(Number(ymd.slice(8, 10)))}$/.test((e.textContent || '').trim()))
|
|
871
|
+
.slice(0, 8).map(e => ({ tag: e.tagName, cls: String(e.className).slice(0, 50),
|
|
872
|
+
vis: e.getClientRects().length > 0, offParent: !!e.offsetParent,
|
|
873
|
+
pCls: String(e.parentElement && e.parentElement.className).slice(0, 50) })),
|
|
874
|
+
months: Array.from(document.querySelectorAll('*'))
|
|
875
|
+
.filter(e => e.children.length === 0 && /年\\d{1,2}月$/.test((e.textContent || '').trim()))
|
|
876
|
+
.slice(0, 6).map(e => ({ t: (e.textContent || '').trim(), vis: e.getClientRects().length > 0 }))
|
|
877
|
+
}))()`).catch(() => '?'));
|
|
878
|
+
throw new Error(`日期 ${ymd} 未能设置(输入框只读且日历未命中);页面事实:${facts}`);
|
|
879
|
+
}
|
|
880
|
+
await sleep(400);
|
|
881
|
+
},
|
|
882
|
+
async press(key) { await page.keyboard.press(key); },
|
|
883
|
+
async clickSearch() {
|
|
884
|
+
await page.keyboard.press('Escape').catch(() => { });
|
|
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
|
+
}
|
|
894
|
+
const dumpDir = process.env.GOTRY_GUI_DUMP;
|
|
895
|
+
if (dumpDir) {
|
|
896
|
+
const cands = String(await page.evaluate(CLICK_FORM_SEARCH_CANDIDATES).catch(() => '{"err":"eval-fail"}'));
|
|
897
|
+
try {
|
|
898
|
+
mkdirSync(dumpDir, { recursive: true });
|
|
899
|
+
writeFileSync(join(dumpDir, 'search-candidates.json'), cands);
|
|
900
|
+
}
|
|
901
|
+
catch { /* best effort */ }
|
|
902
|
+
await page.screenshot({ path: join(dumpDir, 'pre-search.png') }).catch(() => { });
|
|
903
|
+
}
|
|
904
|
+
await page.evaluate(CLICK_FORM_SEARCH);
|
|
905
|
+
},
|
|
906
|
+
async visibleFormText() {
|
|
907
|
+
return String(await page.evaluate(VIS_FORM_TEXT).catch(() => ''));
|
|
908
|
+
},
|
|
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;
|
|
923
|
+
},
|
|
924
|
+
async pageText() {
|
|
925
|
+
const title = await page.title().catch(() => '');
|
|
926
|
+
const text = String(await page.evaluate('document.body.innerText.slice(0, 3000)').catch(() => ''));
|
|
927
|
+
return title + text;
|
|
928
|
+
},
|
|
929
|
+
async currentUrl() {
|
|
930
|
+
return page.url();
|
|
931
|
+
},
|
|
932
|
+
async waitForApiCapture(timeoutMs) {
|
|
933
|
+
return new Promise(resolve => {
|
|
934
|
+
const timer = setTimeout(() => {
|
|
935
|
+
apiWaiter = null;
|
|
936
|
+
resolve(capture.itinerary || capture.calendar ? { ...capture } : null);
|
|
937
|
+
}, timeoutMs);
|
|
938
|
+
apiWaiter = v => { clearTimeout(timer); resolve(v); };
|
|
939
|
+
});
|
|
940
|
+
},
|
|
941
|
+
async recentApiUrls() { return [...recentUrls]; },
|
|
942
|
+
};
|
|
943
|
+
},
|
|
944
|
+
async close() {
|
|
945
|
+
await ctx.close().catch(() => { });
|
|
946
|
+
rmSync(profile, { recursive: true, force: true });
|
|
947
|
+
},
|
|
948
|
+
};
|
|
949
|
+
}
|