@danceiny/gotry 0.2.0-rc.30 → 0.2.0-rc.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +119 -136
- package/bin/gotry-extension-status.d.ts +12 -0
- package/bin/gotry-extension-status.js +113 -0
- package/bin/gotry-flyai-verification.d.ts +9 -0
- package/bin/gotry-flyai-verification.js +63 -0
- package/bin/gotry-hbcli-release.d.ts +3 -0
- package/bin/gotry-hbcli-release.js +4 -0
- package/bin/gotry-inner.js +2 -1
- package/client/client.js +228 -0
- package/dist/capabilities/doctor.js +14 -23
- 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/ctrip-city.js +186 -0
- package/dist/capabilities/session/extension-bridge.js +8 -1
- package/dist/capabilities/session/gui-flight.js +737 -0
- package/dist/capabilities/session/gui-hotel.js +413 -0
- package/dist/capabilities/session-search.js +98 -11
- package/dist/scripts/artifact-client-contract-tests.js +4 -3
- package/dist/scripts/artifact-delivery-tests.js +31 -0
- package/dist/scripts/bootstrap-tests.js +37 -10
- package/dist/scripts/ctrip-city-tests.js +80 -0
- package/dist/scripts/doctor-tests.js +36 -7
- package/dist/scripts/dsh-artifact-web-e2e.js +204 -43
- package/dist/scripts/effect-tests.js +602 -504
- package/dist/scripts/extension-tests.js +14 -8
- package/dist/scripts/flight-malformed-tests.js +1 -1
- package/dist/scripts/flyai-setup-tests.js +93 -5
- package/dist/scripts/flyai-tests.js +768 -653
- package/dist/scripts/gotry-web-api-tests.js +119 -0
- package/dist/scripts/gotry-web-client-tests.js +47 -0
- 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 +3 -1
- package/dist/scripts/session-tests.js +177 -6
- package/dist/src/artifact-delivery.js +26 -0
- package/dist/src/gotry-web-api.js +219 -0
- package/dist/src/index.js +45 -17
- package/extension/manifest.json +2 -2
- package/package.json +8 -2
- 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/ctrip-city.ts +158 -0
- package/ts/capabilities/session/extension-bridge.ts +9 -2
- package/ts/capabilities/session/gui-flight.ts +792 -0
- package/ts/capabilities/session/gui-hotel.ts +451 -0
- package/ts/capabilities/session-search.ts +115 -15
- package/ts/src/artifact-delivery.ts +25 -0
- package/ts/src/gotry-web-api.ts +189 -0
- package/ts/src/index.ts +46 -18
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/** Official hbcli credential-file contract; no credentials enter tool results. */
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
3
|
+
import { homedir, tmpdir } from 'node:os'
|
|
4
|
+
import { isAbsolute, join } from 'node:path'
|
|
5
|
+
import { atomicWriteJson, sha256Hex } from './flyai-config.ts'
|
|
6
|
+
import { hbcliBinCandidates } from './hbcli.ts'
|
|
7
|
+
import { spawnBounded } from './spawn-bounded.ts'
|
|
8
|
+
|
|
9
|
+
export const HBCLI_REGISTRATION_URL = 'https://hotelbyte.com/zh/guides/sandbox-verification'
|
|
10
|
+
const ENVIRONMENT = 'uat'
|
|
11
|
+
const ENTRY = `openapi:${ENVIRONMENT}`
|
|
12
|
+
export interface HbcliConfigOptions { homeDir?: string; env?: NodeJS.ProcessEnv; hbcliBin?: string }
|
|
13
|
+
export type HbcliSetupVerdict = 'hit' | 'miss' | 'no-suppliers'
|
|
14
|
+
interface Verification { schema: 'gotry.hbcli-verification.v1'; fingerprint: string; verdict: HbcliSetupVerdict; at: string }
|
|
15
|
+
|
|
16
|
+
export function hbcliCredentialRoot(options: HbcliConfigOptions): string {
|
|
17
|
+
return options.env?.STAICLI_HOME?.trim() || join(options.homeDir ?? homedir(), '.staicli')
|
|
18
|
+
}
|
|
19
|
+
export function hbcliCredentialPath(options: HbcliConfigOptions): string { return join(hbcliCredentialRoot(options), 'credentials.json') }
|
|
20
|
+
export function hbcliVerificationPath(homeDir: string): string { return join(homeDir, '.gotry', 'hbcli-verification.json') }
|
|
21
|
+
|
|
22
|
+
function safeEntry(path: string, directory: boolean): boolean {
|
|
23
|
+
try {
|
|
24
|
+
const entry = lstatSync(path)
|
|
25
|
+
return !entry.isSymbolicLink() && (directory ? entry.isDirectory() : entry.isFile())
|
|
26
|
+
&& (typeof process.getuid !== 'function' || entry.uid === process.getuid())
|
|
27
|
+
} catch (e) { return (e as NodeJS.ErrnoException).code === 'ENOENT' }
|
|
28
|
+
}
|
|
29
|
+
export function readHbcliCredentials(options: HbcliConfigOptions): { ok: true; data: Record<string, unknown>; bytes?: string } | { ok: false } {
|
|
30
|
+
const root = hbcliCredentialRoot(options), file = hbcliCredentialPath(options)
|
|
31
|
+
try {
|
|
32
|
+
if (!isAbsolute(root) || !safeEntry(root, true) || !safeEntry(file, false)) return { ok: false }
|
|
33
|
+
if (!existsSync(file)) return { ok: true, data: {} }
|
|
34
|
+
const bytes = readFileSync(file, 'utf8'), data: unknown = JSON.parse(bytes)
|
|
35
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? { ok: true, data: data as Record<string, unknown>, bytes } : { ok: false }
|
|
36
|
+
} catch { return { ok: false } }
|
|
37
|
+
}
|
|
38
|
+
function fingerprint(data: Record<string, unknown>): string | undefined {
|
|
39
|
+
const entry = data[ENTRY] as { appKey?: unknown; appSecret?: unknown } | undefined
|
|
40
|
+
return typeof entry?.appKey === 'string' && entry.appKey.trim() && typeof entry.appSecret === 'string' && entry.appSecret.trim()
|
|
41
|
+
? sha256Hex(JSON.stringify([ENVIRONMENT, entry.appKey, entry.appSecret])) : undefined
|
|
42
|
+
}
|
|
43
|
+
function readReceipt(home: string): Verification | undefined {
|
|
44
|
+
try {
|
|
45
|
+
const path = hbcliVerificationPath(home)
|
|
46
|
+
if (!safeEntry(join(home, '.gotry'), true) || !safeEntry(path, false)) return undefined
|
|
47
|
+
const v = JSON.parse(readFileSync(path, 'utf8')) as Verification
|
|
48
|
+
return v.schema === 'gotry.hbcli-verification.v1' && typeof v.fingerprint === 'string'
|
|
49
|
+
&& ['hit', 'miss', 'no-suppliers'].includes(v.verdict) && Number.isFinite(Date.parse(v.at)) ? v : undefined
|
|
50
|
+
} catch { return undefined }
|
|
51
|
+
}
|
|
52
|
+
export function hbcliSetupStatus(options: HbcliConfigOptions) {
|
|
53
|
+
const read = readHbcliCredentials(options), hash = read.ok ? fingerprint(read.data) : undefined
|
|
54
|
+
const receipt = readReceipt(options.homeDir ?? homedir())
|
|
55
|
+
const envToken = Boolean(options.env?.HOTELBYTE_TOKEN?.trim())
|
|
56
|
+
const matches = !envToken && hash && receipt?.fingerprint === hash
|
|
57
|
+
return { ok: true, configured: envToken || Boolean(hash), writable: read.ok && !envToken && process.platform !== 'win32',
|
|
58
|
+
source: envToken ? 'env' : hash ? 'config' : 'none', environment: ENVIRONMENT,
|
|
59
|
+
verified: Boolean(matches && receipt?.verdict !== 'no-suppliers'),
|
|
60
|
+
...(matches && receipt ? { verdict: receipt.verdict, checkedAt: receipt.at } : {}), registrationUrl: HBCLI_REGISTRATION_URL }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function prepareDirectory(dir: string): boolean {
|
|
64
|
+
try {
|
|
65
|
+
if (process.platform === 'win32' || !isAbsolute(dir) || !safeEntry(dir, true)) return false
|
|
66
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
67
|
+
if (!safeEntry(dir, true)) return false
|
|
68
|
+
chmodSync(dir, 0o700)
|
|
69
|
+
return true
|
|
70
|
+
} catch { return false }
|
|
71
|
+
}
|
|
72
|
+
export function writeHbcliCredentials(data: Record<string, unknown>, options: HbcliConfigOptions): boolean {
|
|
73
|
+
const path = hbcliCredentialPath(options)
|
|
74
|
+
return readHbcliCredentials(options).ok && prepareDirectory(hbcliCredentialRoot(options)) && atomicWriteJson(path, data).ok
|
|
75
|
+
}
|
|
76
|
+
export function writeHbcliVerification(data: Record<string, unknown>, verdict: HbcliSetupVerdict, options: HbcliConfigOptions): boolean {
|
|
77
|
+
const hash = fingerprint(data), home = options.homeDir ?? homedir()
|
|
78
|
+
if (!hash || !prepareDirectory(join(home, '.gotry'))) return false
|
|
79
|
+
const entry: Verification = { schema: 'gotry.hbcli-verification.v1', fingerprint: hash, verdict, at: new Date().toISOString() }
|
|
80
|
+
return safeEntry(hbcliVerificationPath(home), false) && atomicWriteJson(hbcliVerificationPath(home), entry).ok
|
|
81
|
+
}
|
|
82
|
+
export function removeHbcliVerification(homeDir: string): void {
|
|
83
|
+
if (safeEntry(join(homeDir, '.gotry'), true) && safeEntry(hbcliVerificationPath(homeDir), false)) {
|
|
84
|
+
try { rmSync(hbcliVerificationPath(homeDir), { force: true }) } catch { /* best effort */ }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function withHbcliCredentials(data: Record<string, unknown>, appKey: string, appSecret: string): Record<string, unknown> {
|
|
88
|
+
return { ...data, [ENTRY]: { appKey, appSecret } }
|
|
89
|
+
}
|
|
90
|
+
export function withoutHbcliCredentials(data: Record<string, unknown>): Record<string, unknown> {
|
|
91
|
+
const next = { ...data }; delete next[ENTRY]; return next
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Verify through the existing CLI bridge, with a private disposable credential root.
|
|
95
|
+
* An upstream no-suppliers verdict is retained separately from a completed query.
|
|
96
|
+
*/
|
|
97
|
+
export async function verifyHbcliCandidate(appKey: string, appSecret: string, options: HbcliConfigOptions, signal: AbortSignal): Promise<{ verdict: HbcliSetupVerdict | 'error' | 'cancelled'; error?: string }> {
|
|
98
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-hbcli-verify-'))
|
|
99
|
+
try {
|
|
100
|
+
if (!atomicWriteJson(join(root, 'credentials.json'), withHbcliCredentials({}, appKey, appSecret)).ok) {
|
|
101
|
+
return { verdict: 'error', error: '无法创建隔离的验证环境。' }
|
|
102
|
+
}
|
|
103
|
+
const checkIn = new Date(); checkIn.setUTCDate(checkIn.getUTCDate() + 1)
|
|
104
|
+
const checkOut = new Date(checkIn); checkOut.setUTCDate(checkOut.getUTCDate() + 1)
|
|
105
|
+
const args = ['--json', '--env', ENVIRONMENT, 'search', 'hotel-list', '--destination-name', '上海',
|
|
106
|
+
'--check-in', checkIn.toISOString().slice(0, 10), '--check-out', checkOut.toISOString().slice(0, 10),
|
|
107
|
+
'--room-occupancies', '[{"adultCount":2,"childrenAges":[]}]', '--page-size', '1']
|
|
108
|
+
const env: NodeJS.ProcessEnv = { ...(options.env ?? process.env), STAICLI_HOME: root, HOTELBYTE_ENV: ENVIRONMENT }
|
|
109
|
+
delete env.HOTELBYTE_TOKEN
|
|
110
|
+
let result: Awaited<ReturnType<typeof spawnBounded>> | undefined
|
|
111
|
+
for (const bin of hbcliBinCandidates(options.hbcliBin ?? options.env?.GOTRY_HBCLI_BIN ?? 'hbcli', options.homeDir)) {
|
|
112
|
+
result = await spawnBounded(bin, args, { env, signal, timeoutMs: 20_000 })
|
|
113
|
+
if (!/ENOENT/.test(result.error ?? '') || signal.aborted) break
|
|
114
|
+
}
|
|
115
|
+
if (signal.aborted || result?.aborted) return { verdict: 'cancelled', error: '验证已取消,旧配置保留。' }
|
|
116
|
+
if (!result || result.error || result.timedOut || result.drainTimedOut || result.groupReaped === false || result.code !== 0) {
|
|
117
|
+
return { verdict: 'error', error: 'HotelByte 验证未完成,请检查 hbcli 安装、网络和凭证;旧配置保留。' }
|
|
118
|
+
}
|
|
119
|
+
let value: { code?: unknown; list?: unknown }
|
|
120
|
+
try { value = JSON.parse(result.stdout) } catch { return { verdict: 'error', error: 'HotelByte 回包无法识别,旧配置保留。' } }
|
|
121
|
+
if (value?.code === 300010002 || value?.code === '300010002') return { verdict: 'no-suppliers' }
|
|
122
|
+
if (!value || (value.code !== undefined && value.code !== 0 && value.code !== '0') || !Array.isArray(value.list)
|
|
123
|
+
|| value.list.some(item => !item || typeof item !== 'object' || Array.isArray(item))) {
|
|
124
|
+
return { verdict: 'error', error: 'HotelByte 未接受凭证或返回了无效的查询结果;旧配置保留。' }
|
|
125
|
+
}
|
|
126
|
+
return { verdict: value.list.length ? 'hit' : 'miss' }
|
|
127
|
+
} finally { rmSync(root, { recursive: true, force: true }) }
|
|
128
|
+
}
|
package/ts/capabilities/hbcli.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface HbcliCallOptions {
|
|
|
32
32
|
export interface HbcliCallResult {
|
|
33
33
|
/** 是否走了实时 hbcli */
|
|
34
34
|
via: 'hbcli-realtime' | 'hbcli-cache' | 'hbcli-error'
|
|
35
|
-
/** hbcli
|
|
35
|
+
/** hbcli 进程退出码;零不代表 API 业务成功 */
|
|
36
36
|
exitCode: number
|
|
37
37
|
/** 解析后的 JSON(若 --json 模式输出可解析;否则为空) */
|
|
38
38
|
result: unknown
|
|
@@ -99,6 +99,24 @@ async function attemptHbcli(
|
|
|
99
99
|
if (jsonStr) {
|
|
100
100
|
try { result = JSON.parse(jsonStr) } catch { /* 非 JSON 输出,留给调用方处理 */ }
|
|
101
101
|
}
|
|
102
|
+
// CLI exit status describes the process, not the API verdict. hbcli can exit
|
|
103
|
+
// zero with an API error envelope (for example an unconfigured supplier pool).
|
|
104
|
+
const code = (result as { code?: unknown } | null)?.code
|
|
105
|
+
const invalid = result === null
|
|
106
|
+
const upstreamFailure = code !== undefined && code !== 0 && code !== '0'
|
|
107
|
+
if (invalid || upstreamFailure) {
|
|
108
|
+
const msg = (result as { msg?: unknown; message?: unknown } | null)?.msg
|
|
109
|
+
?? (result as { message?: unknown } | null)?.message
|
|
110
|
+
const detail = code === 300010002 || code === '300010002'
|
|
111
|
+
? '当前账号没有可用供应商;库存查询未完成'
|
|
112
|
+
: typeof msg === 'string' ? msg.slice(0, 200) : 'API request failed'
|
|
113
|
+
return {
|
|
114
|
+
via: 'hbcli-error', exitCode: 0, result: null,
|
|
115
|
+
evidence: `[实时API:hbcli@error@${ts}]`,
|
|
116
|
+
stdout: r.stdout.slice(0, 2000), latencyMs,
|
|
117
|
+
error: invalid ? 'failed to parse hbcli output as JSON' : `hbcli upstream code ${String(code)}: ${detail}`,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
102
120
|
return {
|
|
103
121
|
via: 'hbcli-realtime', exitCode: 0, result,
|
|
104
122
|
evidence: `[实时API:hbcli@${ts}]`,
|
|
@@ -177,7 +195,7 @@ export async function searchHotels(
|
|
|
177
195
|
if (query.checkIn) hbArgs.push('--check-in', query.checkIn)
|
|
178
196
|
if (query.checkOut) hbArgs.push('--check-out', query.checkOut)
|
|
179
197
|
if (query.adults) hbArgs.push('--room-occupancies', JSON.stringify([{ adultCount: query.adults, childrenAges: [] }]))
|
|
180
|
-
|
|
198
|
+
let live = await callHbcliJson(hbArgs, opts)
|
|
181
199
|
// 取消立即结束:不读静态包、不产出命中/无结果/估算,也不计上游故障
|
|
182
200
|
if (opts.signal?.aborted || /abort/i.test(live.error ?? '')) {
|
|
183
201
|
const ts = new Date().toISOString()
|
|
@@ -188,6 +206,15 @@ export async function searchHotels(
|
|
|
188
206
|
latencyMs: live.latencyMs, error: `aborted by host signal${cleanupError}`, summary: `酒店「${query.destination}」检索已取消,未产生事实`,
|
|
189
207
|
}
|
|
190
208
|
}
|
|
209
|
+
const list = (live.result as { list?: unknown } | null)?.list
|
|
210
|
+
if (live.via === 'hbcli-realtime' && (!Array.isArray(list)
|
|
211
|
+
|| list.some(item => item === null || typeof item !== 'object' || Array.isArray(item)))) {
|
|
212
|
+
live = {
|
|
213
|
+
...live, via: 'hbcli-error', result: null,
|
|
214
|
+
evidence: `[实时API:hbcli@error@${new Date().toISOString()}]`,
|
|
215
|
+
error: 'invalid hotel-list response: expected list array of hotels; inventory not observed',
|
|
216
|
+
}
|
|
217
|
+
}
|
|
191
218
|
if (live.via === 'hbcli-realtime') {
|
|
192
219
|
// summary 即模型面(render 只透 summary):必须自带紧凑数据行 + 证据链,
|
|
193
220
|
// 否则模型只看到「实时返回」占位一句话(issue #195 实锤)。
|
|
@@ -297,7 +324,7 @@ export async function checkAvail(
|
|
|
297
324
|
* ~/.staicli/credentials.json)——hbcli 侧 customer 档是最低优先级凭据(无
|
|
298
325
|
* portal/API-key 凭据时才生效),登录不劫持既有凭据。本层不透出 token 值
|
|
299
326
|
* (CLI 只回 token_saved,凭据留在 CLI 仓内,不进 gotry 会话/日志)。
|
|
300
|
-
* auth 命令族要求 hbcli 带 register 支持(
|
|
327
|
+
* auth 命令族要求 hbcli 带 register 支持(官方原生 0.0.4 起;0.0.3 报 unknown
|
|
301
328
|
* command,经 hbcli-error 面优雅失败)。
|
|
302
329
|
*/
|
|
303
330
|
export async function customerSendCode(
|
|
@@ -7,12 +7,21 @@
|
|
|
7
7
|
* 只读:适配器不含任何提交/预订语义;URL 直开即零 DOM 交互。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
import { resolveCtripFlightCity, cityResolutionHint, type CityResolution } from '../ctrip-city.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 离线起步缓存;未命中由编排层查询供应商城市数据(ctrip-city.ts),不推断航线覆盖。
|
|
14
|
+
* 码表只做性能优化,不在主链路上(显式码/供应商解析是正路)。
|
|
15
|
+
* 实测校准(2026-10-14):香港=hkg/迪拜=dxb 经 list 页实证——合法组合 HTTP 200 且
|
|
16
|
+
* title 含航线名(「中国香港(HKG)到迪拜(阿联酋)(DXB)机票预订」),非法组合 302 跳
|
|
17
|
+
* international/search(该 200/302 判别式即真数据校准手段)。
|
|
18
|
+
*/
|
|
19
|
+
export const CITY_CODES: Record<string, string> = {
|
|
12
20
|
上海: 'sha', 北京: 'bjs', 广州: 'can', 深圳: 'szx', 成都: 'ctu', 昆明: 'kmg',
|
|
13
21
|
大理: 'dlu', 丽江: 'ljg', 西安: 'sia', 杭州: 'hgh', 三亚: 'syx', 厦门: 'xmn',
|
|
14
22
|
重庆: 'cqg', 青岛: 'taa', 长沙: 'csx', 武汉: 'wuh', 南京: 'nkg', 郑州: 'cgo',
|
|
15
23
|
贵阳: 'kwe', 桂林: 'kwl', 西双版纳: 'jhg', 香格里拉: 'dig',
|
|
24
|
+
香港: 'hkg', 迪拜: 'dxb',
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
export interface AdapterEntry {
|
|
@@ -20,16 +29,43 @@ export interface AdapterEntry {
|
|
|
20
29
|
url?: string
|
|
21
30
|
/** 词表外城市逐字保留(调用方降级无日期/无线路搜索) */
|
|
22
31
|
unresolved?: string[]
|
|
32
|
+
error?: string
|
|
23
33
|
}
|
|
24
34
|
|
|
25
|
-
|
|
35
|
+
/** Resolve unknown names against current supplier metadata before inventory navigation. */
|
|
36
|
+
export async function resolveFlightEntryUrl(from: string, to: string, depDate: string,
|
|
37
|
+
codes: { fromCode?: string; toCode?: string } = {},
|
|
38
|
+
resolve: (query: string) => Promise<CityResolution> = resolveCtripFlightCity,
|
|
39
|
+
backDate?: string): Promise<AdapterEntry> {
|
|
40
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(depDate)) return { ok: false, unresolved: [depDate], error: '出发日期需要 YYYY-MM-DD' }
|
|
41
|
+
for (const c of [codes.fromCode, codes.toCode]) if (c !== undefined && !/^[a-z]{3}$/i.test(c)) return { ok: false, error: '城市/机场码必须是三个英文字母' }
|
|
42
|
+
const resolveCode = async (name: string, explicit?: string) => {
|
|
43
|
+
const known = explicit ?? (/^[a-z]{3}$/i.test(name.trim()) ? name.trim() : undefined)
|
|
44
|
+
if (known) return { code: known }
|
|
45
|
+
const result = await resolve(name)
|
|
46
|
+
return result.ok ? { code: result.city.code } : { error: cityResolutionHint(name, result) }
|
|
47
|
+
}
|
|
48
|
+
const [origin, destination] = await Promise.all([resolveCode(from, codes.fromCode), resolveCode(to, codes.toCode)])
|
|
49
|
+
if (!origin.code || !destination.code) return { ok: false, unresolved: [!origin.code ? from : '', !destination.code ? to : ''].filter(Boolean), error: [origin.error, destination.error].filter(Boolean).join(';') }
|
|
50
|
+
return buildEntryUrl(from, to, depDate, { fromCode: origin.code, toCode: destination.code }, backDate)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildEntryUrl(from: string, to: string, depDate: string, codes: { fromCode?: string; toCode?: string } = {}, backDate?: string): AdapterEntry {
|
|
26
54
|
const unresolved: string[] = []
|
|
27
|
-
|
|
28
|
-
const
|
|
55
|
+
// 显式码优先(形状合格才采纳),词表只是缓存——任何城市都可经显式码成行。
|
|
56
|
+
const fromCode = codes.fromCode === undefined ? CITY_CODES[from.trim()] : /^[a-z]{3}$/i.test(codes.fromCode) ? codes.fromCode.toLowerCase() : undefined
|
|
57
|
+
const toCode = codes.toCode === undefined ? CITY_CODES[to.trim()] : /^[a-z]{3}$/i.test(codes.toCode) ? codes.toCode.toLowerCase() : undefined
|
|
29
58
|
if (!fromCode) unresolved.push(from)
|
|
30
59
|
if (!toCode) unresolved.push(to)
|
|
31
|
-
|
|
32
|
-
|
|
60
|
+
const badDate = !/^\d{4}-\d{2}-\d{2}$/.test(depDate)
|
|
61
|
+
|| (backDate !== undefined && (!/^\d{4}-\d{2}-\d{2}$/.test(backDate) || backDate <= depDate))
|
|
62
|
+
if (unresolved.length > 0 || badDate) {
|
|
63
|
+
return { ok: false, unresolved: unresolved.length > 0 ? unresolved : [badDate ? (backDate !== undefined && backDate <= depDate ? backDate : depDate) : depDate], ...(backDate !== undefined && backDate <= depDate ? { error: `回程(${backDate})须晚于去程(${depDate})` } : {}) }
|
|
64
|
+
}
|
|
65
|
+
// 往返形状(2026-10-14 GUI URL 教师实测定稿):round-{code}0-{code}0?depdate=去_回&cabin=y_s
|
|
66
|
+
// ——此前 roundtrip-{3字码}?depdate&retdate 的猜测形状实测 302,已废弃。
|
|
67
|
+
if (backDate) {
|
|
68
|
+
return { ok: true, url: `https://flights.ctrip.com/online/list/round-${fromCode}0-${toCode}0?depdate=${depDate}_${backDate}&cabin=y_s&adult=1&child=0&infant=0` }
|
|
33
69
|
}
|
|
34
70
|
return { ok: true, url: `https://flights.ctrip.com/online/list/oneway-${fromCode}-${toCode}?depdate=${depDate}` }
|
|
35
71
|
}
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
* 页面照常生效(下单/支付面 URL 仍被物理拦截)。
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
import { resolveCtripHotelCity, hotelResolutionHint, type HotelCityResolution } from '../ctrip-city.ts'
|
|
18
|
+
|
|
19
|
+
/** 离线兼容缓存(**实测校准 2026-09-03**:每条 id 均经 hotels.ctrip.com list 页
|
|
18
20
|
* <title> 验证——探测法曾纠出 104=平遥县(传猜成都)等 5 处错猜,表内只收
|
|
19
21
|
* 验证过的 id;词表外 unresolved 逐字保留,调用方可显式传 cityId 覆盖) */
|
|
20
|
-
const HOTEL_CITY_CODES: Record<string, string> = {
|
|
22
|
+
export const HOTEL_CITY_CODES: Record<string, string> = {
|
|
21
23
|
上海: '2', 北京: '1', 广州: '32', 深圳: '30', 成都: '28',
|
|
22
24
|
杭州: '17', 南京: '12', 苏州: '14', 无锡: '13', 扬州: '15', 镇江: '16',
|
|
23
25
|
青岛: '7', 厦门: '25', 珠海: '31', 三亚: '43', 海口: '42',
|
|
@@ -25,6 +27,9 @@ const HOTEL_CITY_CODES: Record<string, string> = {
|
|
|
25
27
|
贵阳: '38', 拉萨: '41', 乌鲁木齐: '39', 太原: '105', 长春: '158', 南通: '82',
|
|
26
28
|
敦煌: '11', 舟山: '19', 张家界: '27', 黄山市: '23', 九江: '24', 武夷山: '26',
|
|
27
29
|
迪拜: '220',
|
|
30
|
+
// Verified against the official list page and its displayed destination/date
|
|
31
|
+
// controls on 2026-10-07; https://hotels.ctrip.com/hotels/list?city=5
|
|
32
|
+
哈尔滨: '5',
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
export interface AdapterEntry {
|
|
@@ -32,6 +37,7 @@ export interface AdapterEntry {
|
|
|
32
37
|
url?: string
|
|
33
38
|
/** 词表外城市逐字保留(调用方提示:web 搜携程酒店 list 页取 city id 后带 cityId 重试) */
|
|
34
39
|
unresolved?: string[]
|
|
40
|
+
error?: string
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
export interface HotelEntryQuery {
|
|
@@ -39,6 +45,7 @@ export interface HotelEntryQuery {
|
|
|
39
45
|
to: string
|
|
40
46
|
/** 显式城市 id(携程/trip.com 酒店 list 页 URL 的 city= 数字;覆盖码表) */
|
|
41
47
|
cityId?: number | string
|
|
48
|
+
country?: string
|
|
42
49
|
/** YYYY-MM-DD */
|
|
43
50
|
checkIn?: string
|
|
44
51
|
/** YYYY-MM-DD(与 checkIn 成对) */
|
|
@@ -46,14 +53,23 @@ export interface HotelEntryQuery {
|
|
|
46
53
|
adults?: number
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
export async function resolveHotelEntryUrl(q: HotelEntryQuery,
|
|
57
|
+
resolve: (query: string, country?: string) => Promise<HotelCityResolution> = resolveCtripHotelCity): Promise<AdapterEntry> {
|
|
58
|
+
if (q.cityId !== undefined) return buildHotelEntryUrl(q)
|
|
59
|
+
const result = await resolve(q.to, q.country)
|
|
60
|
+
if (!result.ok) return { ok: false, unresolved: [q.to], error: hotelResolutionHint(q.to, result) }
|
|
61
|
+
return buildHotelEntryUrl({ ...q, cityId: result.city.id })
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
export function buildHotelEntryUrl(q: HotelEntryQuery): AdapterEntry {
|
|
50
65
|
const unresolved: string[] = []
|
|
51
|
-
const
|
|
66
|
+
const candidate = q.cityId === undefined ? HOTEL_CITY_CODES[q.to.trim()] : String(q.cityId).trim()
|
|
67
|
+
const cityId = candidate && /^[1-9]\d{0,9}$/.test(candidate) ? candidate : undefined
|
|
52
68
|
if (!cityId) unresolved.push(q.to)
|
|
53
69
|
if (unresolved.length > 0) {
|
|
54
70
|
return { ok: false, unresolved }
|
|
55
71
|
}
|
|
56
|
-
const params = new URLSearchParams({ city: cityId })
|
|
72
|
+
const params = new URLSearchParams({ city: cityId! })
|
|
57
73
|
if (q.checkIn) params.set('checkin', q.checkIn)
|
|
58
74
|
if (q.checkOut) params.set('checkout', q.checkOut)
|
|
59
75
|
if (q.adults && q.adults > 0) params.set('adult', String(q.adults))
|
|
@@ -236,3 +252,34 @@ export function parseCtripHotelList(body: string, opts: { maxItems?: number } =
|
|
|
236
252
|
walk(raw, 0, new Set())
|
|
237
253
|
return out
|
|
238
254
|
}
|
|
255
|
+
|
|
256
|
+
/** Only an explicit empty hotel array proves a miss; parser failure proves nothing. */
|
|
257
|
+
export function classifyCtripHotelList(body: string): {
|
|
258
|
+
shape: 'parsed' | 'recognized-empty' | 'malformed' | 'unrecognized'
|
|
259
|
+
hotels: SessionHotelOption[]
|
|
260
|
+
} {
|
|
261
|
+
let raw: unknown
|
|
262
|
+
try { raw = JSON.parse(body) } catch { return { shape: 'malformed', hotels: [] } }
|
|
263
|
+
const envelope = raw as { code?: unknown; success?: unknown; ok?: unknown; ResponseStatus?: { Ack?: unknown } } | null
|
|
264
|
+
if (envelope?.success === false || envelope?.ok === false
|
|
265
|
+
|| (envelope?.code !== undefined && envelope.code !== 0 && envelope.code !== '0')
|
|
266
|
+
|| /^(failure|error)$/i.test(String(envelope?.ResponseStatus?.Ack ?? ''))) {
|
|
267
|
+
return { shape: 'unrecognized', hotels: [] }
|
|
268
|
+
}
|
|
269
|
+
const hotels = parseCtripHotelList(body)
|
|
270
|
+
if (hotels.length > 0) return { shape: 'parsed', hotels }
|
|
271
|
+
let emptyList = false
|
|
272
|
+
let unparsedItems = false
|
|
273
|
+
const inspectHotelLists = (node: unknown, depth: number): void => {
|
|
274
|
+
if (depth > 12 || node === null || typeof node !== 'object') return
|
|
275
|
+
for (const [key, value] of Object.entries(node)) {
|
|
276
|
+
if (['hotelList', 'hotelMatchInfos'].includes(key) && Array.isArray(value)) {
|
|
277
|
+
if (value.length === 0) emptyList = true
|
|
278
|
+
else unparsedItems = true
|
|
279
|
+
}
|
|
280
|
+
inspectHotelLists(value, depth + 1)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
inspectHotelLists(raw, 0)
|
|
284
|
+
return { shape: emptyList && !unparsedItems ? 'recognized-empty' : 'unrecognized', hotels: [] }
|
|
285
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/** Public supplier city metadata, independent of inventory and account sessions. */
|
|
2
|
+
export const CTRIP_CITY_CATALOG_URL = 'https://m.ctrip.com/restapi/soa2/17909/SearchBoxRecommend'
|
|
3
|
+
export const CTRIP_CITY_SUGGEST_URL = 'https://flights.ctrip.com/international/search/api/poi/search'
|
|
4
|
+
export const CTRIP_HOTEL_CITY_URL = 'https://m.ctrip.com/restapi/soa2/34951/getHotelKeywords'
|
|
5
|
+
export interface CtripCity {
|
|
6
|
+
name: string
|
|
7
|
+
code: string
|
|
8
|
+
aliases: string[]
|
|
9
|
+
context?: string
|
|
10
|
+
airport?: boolean
|
|
11
|
+
requiresQualification?: boolean
|
|
12
|
+
}
|
|
13
|
+
export type CityResolution =
|
|
14
|
+
| { ok: true; city: CtripCity }
|
|
15
|
+
| { ok: false; reason: 'ambiguous' | 'not-found' | 'unavailable'; candidates: CtripCity[] }
|
|
16
|
+
const normalized = (s: string) => s.normalize('NFKC').trim().replace(/^中国(?=香港|澳门)/, '').toLowerCase()
|
|
17
|
+
const text = (v: unknown) => typeof v === 'string' ? v.trim() : ''
|
|
18
|
+
const code = (v: unknown) => /^[a-z]{3}$/i.test(text(v)) ? text(v).toUpperCase() : undefined
|
|
19
|
+
const object = (v: unknown): Record<string, unknown> => v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {}
|
|
20
|
+
|
|
21
|
+
export function parseCtripCityCatalog(value: unknown): CtripCity[] {
|
|
22
|
+
const root = object(value)
|
|
23
|
+
if (object(root.ResponseStatus).Ack !== 'Success' || object(root.responseHead).errorCode !== '0' || !Array.isArray(root.recommendGroupList)) throw new Error('unrecognized city catalog')
|
|
24
|
+
const records: CtripCity[] = []
|
|
25
|
+
let visited = 0
|
|
26
|
+
const walk = (v: unknown, depth = 0) => {
|
|
27
|
+
if (++visited > 20_000 || depth > 12) throw new Error('city catalog bounds')
|
|
28
|
+
if (Array.isArray(v)) { for (const item of v) walk(item, depth + 1); return }
|
|
29
|
+
const r = object(v), c = code(r.code), name = text(r.name)
|
|
30
|
+
if (c && name && Number(r.id) > 0) {
|
|
31
|
+
records.push({ name, code: c, aliases: [name, text(r.eName), text(r.nameEn), text(r.namePy), text(r.specName)].filter(Boolean), context: text(r.specName) || undefined, requiresQualification: r.sameSpecCity === 1 })
|
|
32
|
+
if (Array.isArray(r.airportlst)) for (const item of r.airportlst) {
|
|
33
|
+
const a = object(item), ac = code(a.code), an = text(a.name)
|
|
34
|
+
if (ac && an) records.push({ name: an, code: ac, aliases: [an], context: name, airport: true })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const [key, child] of Object.entries(r)) if (key !== 'airportlst' && child && typeof child === 'object') walk(child, depth + 1)
|
|
38
|
+
}
|
|
39
|
+
walk(root.recommendGroupList)
|
|
40
|
+
return records
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseCtripCitySuggestions(value: unknown): CtripCity[] {
|
|
44
|
+
const root = object(value)
|
|
45
|
+
if (!Array.isArray(root.Data)) throw new Error('unrecognized city suggestions')
|
|
46
|
+
const records: CtripCity[] = []
|
|
47
|
+
let visited = 0
|
|
48
|
+
const walk = (items: unknown[], depth = 0) => {
|
|
49
|
+
if (depth > 5) throw new Error('city suggestion bounds')
|
|
50
|
+
for (const value of items) {
|
|
51
|
+
if (++visited > 2_000) throw new Error('city suggestion bounds')
|
|
52
|
+
const r = object(value), c = code(r.Code), name = text(r.Name)
|
|
53
|
+
if (c && name && [3, 4, 5].includes(Number(r.Type))) records.push({
|
|
54
|
+
name, code: c, aliases: [name, text(r.EName), text(r.NameAlias), text(r.ShortSpell)].filter(Boolean),
|
|
55
|
+
context: [text(r.Country), text(r.Province)].filter(Boolean).join(' / ') || undefined, airport: Number(r.Type) === 3,
|
|
56
|
+
})
|
|
57
|
+
if (Array.isArray(r.Datas)) walk(r.Datas, depth + 1)
|
|
58
|
+
// Nearby cities must not be substituted for the requested destination.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
walk(root.Data)
|
|
62
|
+
return records
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function matchCtripCity(query: string, records: CtripCity[]): CityResolution {
|
|
66
|
+
const input = normalized(query)
|
|
67
|
+
const matches = records.filter(r => r.code.toLowerCase() === input || r.aliases.some(a => normalized(a) === input))
|
|
68
|
+
const unique = [...new Map(matches.map(r => [`${r.code}:${r.context ?? ''}`, r])).values()]
|
|
69
|
+
// A city and its airport may share one code. An explicitly typed code remains exact.
|
|
70
|
+
const codes = new Set(unique.map(r => r.code))
|
|
71
|
+
if (codes.size === 1 && /^[a-z]{3}$/i.test(query.trim())) return { ok: true, city: unique[0]! }
|
|
72
|
+
if (unique.length === 1 && (!unique[0]!.requiresQualification || normalized(unique[0]!.context ?? '') === input)) return { ok: true, city: unique[0]! }
|
|
73
|
+
return { ok: false, reason: unique.length ? 'ambiguous' : 'not-found', candidates: unique.slice(0, 8) }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type CityFetch = typeof fetch
|
|
77
|
+
async function readMetadata(url: string, init: RequestInit, fetcher: CityFetch): Promise<unknown> {
|
|
78
|
+
const response = await fetcher(url, { ...init, signal: AbortSignal.timeout(8_000), redirect: 'error' })
|
|
79
|
+
if (!response.ok || !response.body) throw new Error('city metadata unavailable')
|
|
80
|
+
const reader = response.body.getReader(), chunks: Uint8Array[] = []
|
|
81
|
+
let size = 0
|
|
82
|
+
try {
|
|
83
|
+
for (;;) {
|
|
84
|
+
const { done, value } = await reader.read()
|
|
85
|
+
if (done) break
|
|
86
|
+
size += value.byteLength
|
|
87
|
+
if (size > 512_000) throw new Error('city metadata bounds')
|
|
88
|
+
chunks.push(value)
|
|
89
|
+
}
|
|
90
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
91
|
+
} finally { await reader.cancel().catch(() => {}) }
|
|
92
|
+
}
|
|
93
|
+
let catalog: { at: number; promise: Promise<CtripCity[]> } | undefined
|
|
94
|
+
const catalogBody = {
|
|
95
|
+
head: { cver: '3', cid: '', extension: [{ name: 'source', value: 'ONLINE' }, { name: 'sotpGroup', value: 'CTrip' }, { name: 'sotpLocale', value: 'zh-CN' }] },
|
|
96
|
+
locale: 'zh-CN', departureCity: '', dataType: 0,
|
|
97
|
+
}
|
|
98
|
+
export async function resolveCtripFlightCity(query: string, fetcher: CityFetch = fetch): Promise<CityResolution> {
|
|
99
|
+
if (!query.trim() || query.length > 120) return { ok: false, reason: 'not-found', candidates: [] }
|
|
100
|
+
if (fetcher === fetch && process.env.GOTRY_SESSION_LIVE === '0') return { ok: false, reason: 'unavailable', candidates: [] }
|
|
101
|
+
try {
|
|
102
|
+
const load = () => readMetadata(CTRIP_CITY_CATALOG_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(catalogBody) }, fetcher).then(parseCtripCityCatalog)
|
|
103
|
+
let records: CtripCity[]
|
|
104
|
+
if (fetcher !== fetch) records = await load()
|
|
105
|
+
else {
|
|
106
|
+
if (!catalog || Date.now() - catalog.at > 3_600_000) catalog = { at: Date.now(), promise: load() }
|
|
107
|
+
try { records = await catalog.promise } catch { catalog = undefined; records = [] }
|
|
108
|
+
}
|
|
109
|
+
const matched = matchCtripCity(query, records)
|
|
110
|
+
if (matched.ok || matched.reason === 'ambiguous') return matched
|
|
111
|
+
const url = new URL(CTRIP_CITY_SUGGEST_URL)
|
|
112
|
+
url.searchParams.set('key', query.trim().toUpperCase())
|
|
113
|
+
url.searchParams.set('filterAirport', 'false')
|
|
114
|
+
return matchCtripCity(query, parseCtripCitySuggestions(await readMetadata(url.href, {}, fetcher)))
|
|
115
|
+
} catch { return { ok: false, reason: 'unavailable', candidates: [] } }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function cityResolutionHint(query: string, result: Exclude<CityResolution, { ok: true }>): string {
|
|
119
|
+
if (result.reason === 'ambiguous') return `城市「${query}」有多个候选,请确认城市或机场码:${result.candidates.map(c => `${c.name} ${c.code}${c.context ? `(${c.context})` : ''}`).join(';')}`
|
|
120
|
+
return `unresolved 城市「${query}」:${result.reason === 'unavailable' ? '携程城市解析服务暂不可用' : '供应商城市候选未精确匹配'};请确认城市/机场名称,或提供 fromCityCode/toCityCode。城市解析失败不能证明该航线无库存或不受支持。`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface CtripHotelCity { id: string; name: string; aliases: string[]; countries: string[]; context: string }
|
|
124
|
+
export type HotelCityResolution = { ok: true; city: CtripHotelCity } | { ok: false; reason: 'ambiguous' | 'not-found' | 'unavailable'; candidates: CtripHotelCity[] }
|
|
125
|
+
export function parseCtripHotelCities(value: unknown): CtripHotelCity[] {
|
|
126
|
+
const root = object(value), data = object(root.data), keywords = object(data.mainKeywordList).keywords
|
|
127
|
+
if (object(root.ResponseStatus).Ack !== 'Success' || data.htlSpiderActionErrorCode || !Array.isArray(keywords) || keywords.length > 1_000) throw new Error('hotel city metadata unavailable')
|
|
128
|
+
const records: CtripHotelCity[] = []
|
|
129
|
+
for (const item of keywords) {
|
|
130
|
+
const r = object(item), k = object(object(r.keyword).keywordContentInfo), region = object(object(r.controlInfo).regionInfo)
|
|
131
|
+
const display = object(region.displayCityModel), basic = object(region.basicCityModel), id = String(basic.cityId ?? '')
|
|
132
|
+
if (k.tripType !== 'CT' || !/^[1-9]\d{0,9}$/.test(id)) continue
|
|
133
|
+
if (k.keywordCode !== undefined && String(k.keywordCode) !== id) throw new Error('hotel city ID conflict')
|
|
134
|
+
const titles = Array.isArray(k.displayTexts) ? k.displayTexts.map(object).filter(t => t.key === 'MAIN_TITLE').map(t => text(t.value)) : []
|
|
135
|
+
const name = text(display.cityName) || text(display.destinationName)
|
|
136
|
+
if (!name) continue
|
|
137
|
+
records.push({ id, name, aliases: [name, text(display.destinationName), text(display.destinationNameEN), ...titles].filter(Boolean),
|
|
138
|
+
countries: [text(display.countryName), text(display.countryNameEN)].filter(Boolean),
|
|
139
|
+
context: [text(display.countryName), text(display.provinceName)].filter(Boolean).join(' / ') })
|
|
140
|
+
}
|
|
141
|
+
return records
|
|
142
|
+
}
|
|
143
|
+
export async function resolveCtripHotelCity(query: string, country?: string, fetcher: CityFetch = fetch): Promise<HotelCityResolution> {
|
|
144
|
+
if (!query.trim() || query.length > 120 || (country?.length ?? 0) > 120) return { ok: false, reason: 'not-found', candidates: [] }
|
|
145
|
+
if (fetcher === fetch && process.env.GOTRY_SESSION_LIVE === '0') return { ok: false, reason: 'unavailable', candidates: [] }
|
|
146
|
+
try {
|
|
147
|
+
const value = await readMetadata(CTRIP_HOTEL_CITY_URL, { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
148
|
+
body: JSON.stringify({ head: { platform: 'PC', bu: 'HBU', group: 'ctrip', locale: 'zh-CN' }, queryInfo: { keyword: query.trim(), actionType: 'destination' } }) }, fetcher)
|
|
149
|
+
const input = normalized(query)
|
|
150
|
+
const candidates = parseCtripHotelCities(value).filter(c => (!country || c.countries.some(a => normalized(a) === normalized(country))) && c.aliases.some(a => normalized(a) === input))
|
|
151
|
+
const unique = [...new Map(candidates.map(c => [c.id, c])).values()]
|
|
152
|
+
return unique.length === 1 ? { ok: true, city: unique[0]! } : { ok: false, reason: unique.length ? 'ambiguous' : 'not-found', candidates: unique.slice(0, 8) }
|
|
153
|
+
} catch { return { ok: false, reason: 'unavailable', candidates: [] } }
|
|
154
|
+
}
|
|
155
|
+
export function hotelResolutionHint(query: string, result: Exclude<HotelCityResolution, { ok: true }>): string {
|
|
156
|
+
if (result.reason === 'ambiguous') return `城市「${query}」有多个候选,请确认国家或城市:${result.candidates.map(c => `${c.name}(${c.context},cityId=${c.id})`).join(';')}`
|
|
157
|
+
return `城市「${query}」${result.reason === 'unavailable' ? '的携程酒店城市解析服务暂不可用' : '未精确匹配供应商城市候选'};可提供 country 消歧,或携程酒店 list 页 city= 数字作为 cityId。城市解析失败不能证明目的地不受支持。`
|
|
158
|
+
}
|
|
@@ -133,7 +133,7 @@ export interface ExtensionJobResult {
|
|
|
133
133
|
|
|
134
134
|
export type SubmitOutcome =
|
|
135
135
|
| { ok: true; result: ExtensionJobResult; origin?: string; clientId?: string }
|
|
136
|
-
| { ok: false; reason: 'bridge-unavailable' | 'extension-not-connected' | 'timeout'; summary: string }
|
|
136
|
+
| { ok: false; reason: 'bridge-unavailable' | 'extension-not-connected' | 'timeout'; summary: string; jobKind?: ExtensionJobKind; stage?: 'queued' | 'claimed' }
|
|
137
137
|
|
|
138
138
|
export interface SessionJobHandle {
|
|
139
139
|
submit(job: Omit<ExtensionJob, 'jobId'>, opts?: { timeoutMs?: number; extensionWaitMs?: number; preferredOrigin?: string; preferredClientId?: string }): Promise<SubmitOutcome>
|
|
@@ -668,7 +668,14 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
|
|
|
668
668
|
const settle = armSettle(entry, resolve)
|
|
669
669
|
entry.resolve = settle
|
|
670
670
|
entry.timer = setTimeout(() => {
|
|
671
|
-
|
|
671
|
+
const stage = entry.claimedOrigin === undefined ? 'queued' : 'claimed'
|
|
672
|
+
const task = full.kind === 'cookie-names' ? '登录态检查' : full.kind === 'search' ? '检索' : '登录页打开'
|
|
673
|
+
settle({
|
|
674
|
+
ok: false, reason: 'timeout', jobKind: full.kind, stage,
|
|
675
|
+
summary: stage === 'queued'
|
|
676
|
+
? `扩展${task}等待领取超时(${timeoutMs}ms);尚未执行,请检查扩展是否支持当前站点或正在处理其他作业`
|
|
677
|
+
: `扩展${task}已领取但未在 ${timeoutMs}ms 内回传;请检查扩展运行错误与桥连接`,
|
|
678
|
+
})
|
|
672
679
|
}, timeoutMs)
|
|
673
680
|
if (!dispatchToParked(entry)) queue.push(entry)
|
|
674
681
|
// 扩展未上线:有界等待(桥刚拉起时扩展 ≤5s 即连上);宽限过仍无 → 立即 no-spend 失败,不空耗
|