@danceiny/gotry 0.0.1-rc.10
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/LICENSE +21 -0
- package/README.md +267 -0
- package/bin/gotry-inner.js +214 -0
- package/bin/gotry-stdio-ask.js +59 -0
- package/bin/gotry.js +28 -0
- package/cordis.gotry-patch.yml +86 -0
- package/data/flights_2026.json +218 -0
- package/data/golden_erhai.json +92 -0
- package/data/golden_trip_2026.json +75 -0
- package/data/hotels_2026.json +151 -0
- package/data/openflights-skeleton.json +895 -0
- package/data/time-slot-eval.json +384 -0
- package/data/yunnan-pack.json +202 -0
- package/dist/capabilities/agent-reach-bridge.py +90 -0
- package/dist/capabilities/agent-reach-deep.js +172 -0
- package/dist/capabilities/agent-reach.js +204 -0
- package/dist/capabilities/anything.js +139 -0
- package/dist/capabilities/flyai.js +131 -0
- package/dist/capabilities/hbcli.js +143 -0
- package/dist/capabilities/incident-log.js +100 -0
- package/dist/capabilities/opensky.js +74 -0
- package/dist/capabilities/session/action-cache.js +120 -0
- package/dist/capabilities/session/adapters/ctrip-flight.js +83 -0
- package/dist/capabilities/session/adapters/meituan-local.js +73 -0
- package/dist/capabilities/session/extract.js +40 -0
- package/dist/capabilities/session/read-guard.js +99 -0
- package/dist/capabilities/session/transport.js +101 -0
- package/dist/capabilities/session-search.js +93 -0
- package/dist/capabilities/weather.js +180 -0
- package/dist/scripts/action-cache-tests.js +107 -0
- package/dist/scripts/agent-reach-deep-tests.js +54 -0
- package/dist/scripts/agent-reach-tests.js +38 -0
- package/dist/scripts/agent-reach-wrapper-tests.js +79 -0
- package/dist/scripts/anything-tests.js +95 -0
- package/dist/scripts/async-collect.js +37 -0
- package/dist/scripts/companion-tests.js +112 -0
- package/dist/scripts/diff-test.js +39 -0
- package/dist/scripts/engine-run.js +14 -0
- package/dist/scripts/engine-tests.js +45 -0
- package/dist/scripts/hbcli-tests.js +101 -0
- package/dist/scripts/incident-tests.js +96 -0
- package/dist/scripts/journey-tests.js +45 -0
- package/dist/scripts/ledger-tests.js +365 -0
- package/dist/scripts/ledger-workflow-crash.js +42 -0
- package/dist/scripts/memory-capture-tests.js +77 -0
- package/dist/scripts/memory-decay-tests.js +83 -0
- package/dist/scripts/memory-metrics.js +24 -0
- package/dist/scripts/nudge-digest.js +83 -0
- package/dist/scripts/opensky-check.js +20 -0
- package/dist/scripts/opensky-tests.js +52 -0
- package/dist/scripts/probe-poi-tests.js +88 -0
- package/dist/scripts/publish-preverify.js +68 -0
- package/dist/scripts/replay-async.js +44 -0
- package/dist/scripts/replay-real.js +38 -0
- package/dist/scripts/replay.js +116 -0
- package/dist/scripts/session-attach-diagnose.js +27 -0
- package/dist/scripts/session-attach-poc.js +65 -0
- package/dist/scripts/session-extract-tests.js +81 -0
- package/dist/scripts/session-tests.js +162 -0
- package/dist/scripts/skeleton-check.js +37 -0
- package/dist/scripts/skeleton-integration-test.js +22 -0
- package/dist/scripts/skills-contract-tests.js +85 -0
- package/dist/scripts/smoke.js +328 -0
- package/dist/scripts/state-cli-tests.js +136 -0
- package/dist/scripts/state-cli.js +248 -0
- package/dist/scripts/time-eval-tests.js +318 -0
- package/dist/scripts/travel-timeline-tests.js +123 -0
- package/dist/scripts/unified-tests.js +49 -0
- package/dist/scripts/weather-tests.js +48 -0
- package/dist/src/bridge.js +34 -0
- package/dist/src/companions.js +112 -0
- package/dist/src/contracts.js +64 -0
- package/dist/src/dsh-llm.js +174 -0
- package/dist/src/engine.js +331 -0
- package/dist/src/index.js +1239 -0
- package/dist/src/journey.js +147 -0
- package/dist/src/loop.js +360 -0
- package/dist/src/memory-capture.js +40 -0
- package/dist/src/memory-decay.js +31 -0
- package/dist/src/memory-utility.js +55 -0
- package/dist/src/mock-llm.js +103 -0
- package/dist/src/model.js +134 -0
- package/dist/src/slot-spec.js +165 -0
- package/dist/src/state-ledger.js +901 -0
- package/dist/src/time-anchor.js +100 -0
- package/dist/src/tool-packet.js +12 -0
- package/dist/src/travel-slots.js +144 -0
- package/dist/src/travel-timeline.js +78 -0
- package/dist/src/unified.js +538 -0
- package/dist/src/wish-pool.js +27 -0
- package/package.json +84 -0
- package/ts/capabilities/agent-reach-deep.ts +114 -0
- package/ts/capabilities/agent-reach.ts +158 -0
- package/ts/capabilities/anything.ts +201 -0
- package/ts/capabilities/flyai.ts +151 -0
- package/ts/capabilities/hbcli.ts +152 -0
- package/ts/capabilities/incident-log.ts +148 -0
- package/ts/capabilities/opensky.ts +121 -0
- package/ts/capabilities/session/action-cache.ts +148 -0
- package/ts/capabilities/session/adapters/ctrip-flight.ts +107 -0
- package/ts/capabilities/session/adapters/meituan-local.ts +70 -0
- package/ts/capabilities/session/extract.ts +54 -0
- package/ts/capabilities/session/read-guard.ts +141 -0
- package/ts/capabilities/session/transport.ts +114 -0
- package/ts/capabilities/session-search.ts +129 -0
- package/ts/capabilities/weather.ts +176 -0
- package/ts/package.json +27 -0
- package/ts/scripts/async-collect.ts +50 -0
- package/ts/scripts/skeleton-check.ts +49 -0
- package/ts/scripts/skeleton-integration-test.ts +23 -0
- package/ts/scripts/state-cli.ts +201 -0
- package/ts/src/bridge.ts +44 -0
- package/ts/src/companions.ts +113 -0
- package/ts/src/contracts.ts +165 -0
- package/ts/src/dsh-llm.ts +155 -0
- package/ts/src/index.ts +968 -0
- package/ts/src/loop.ts +411 -0
- package/ts/src/memory-capture.ts +59 -0
- package/ts/src/memory-decay.ts +55 -0
- package/ts/src/memory-utility.ts +70 -0
- package/ts/src/mock-llm.ts +100 -0
- package/ts/src/model.ts +220 -0
- package/ts/src/slot-spec.ts +172 -0
- package/ts/src/state-ledger.ts +849 -0
- package/ts/src/time-anchor.ts +134 -0
- package/ts/src/tool-packet.ts +32 -0
- package/ts/src/travel-slots.ts +219 -0
- package/ts/src/travel-timeline.ts +83 -0
- package/ts/src/unified.ts +582 -0
- package/ts/src/wish-pool.ts +66 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hotelbyte-cli(hbcli)能力层封装:进程内 spawn bun + hbcli --json。
|
|
3
|
+
*
|
|
4
|
+
* 拿到外部资源信息能力的三条路径:
|
|
5
|
+
* 1. 实时 hbcli 调用(能力强,延迟 1-3s,需凭证 + 证书有效)
|
|
6
|
+
* 2. 静态数据包(data/hotels_2026.json)——证据标注 [静态包:估算]
|
|
7
|
+
* 3. 简易降级返回(什么都不查得到时,提供"工具暂不可用"而不是抛错)
|
|
8
|
+
*
|
|
9
|
+
* 证书/凭证/网络问题一律降级,不抛到调用方——能力层契约:永远返回一种结果。
|
|
10
|
+
*
|
|
11
|
+
* §7-1/§7-2 L4 不变量:证据链标注([实时API:hbcli@<ts>] / [静态包:估算]);
|
|
12
|
+
* 估算必须显式标记。这是 L4 与 L1 透明卡片的接缝。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from 'node:child_process'
|
|
16
|
+
|
|
17
|
+
export interface HbcliCallOptions {
|
|
18
|
+
/** hbcli 二进制路径(默认 'hbcli',依赖 PATH) */
|
|
19
|
+
hbcliBin?: string
|
|
20
|
+
/** 超时(ms) */
|
|
21
|
+
timeoutMs?: number
|
|
22
|
+
/** 凭证(token)——空时不传,hbcli 用本地默认 */
|
|
23
|
+
token?: string
|
|
24
|
+
/** 环境(uat/prod/dev);默认 uat */
|
|
25
|
+
env?: 'uat' | 'prod' | 'dev'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HbcliCallResult {
|
|
29
|
+
/** 是否走了实时 hbcli */
|
|
30
|
+
via: 'hbcli-realtime' | 'hbcli-cache' | 'hbcli-error'
|
|
31
|
+
/** hbcli 退码 0=成功 */
|
|
32
|
+
exitCode: number
|
|
33
|
+
/** 解析后的 JSON(若 --json 模式输出可解析;否则为空) */
|
|
34
|
+
result: unknown
|
|
35
|
+
/** 证据链标注(L4 契约;无论成功失败都填) */
|
|
36
|
+
evidence: string
|
|
37
|
+
/** hbcli 原始 stdout/stderr(供调试,2000 字符上限) */
|
|
38
|
+
stdout?: string
|
|
39
|
+
stderr?: string
|
|
40
|
+
/** 经过的时长 */
|
|
41
|
+
latencyMs: number
|
|
42
|
+
/** 错误时降级原因 */
|
|
43
|
+
error?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 通用 hbcli JSON 调用封装:失败不抛,而是返回降级结果 */
|
|
47
|
+
export async function callHbcliJson(
|
|
48
|
+
args: string[],
|
|
49
|
+
opts: HbcliCallOptions = {},
|
|
50
|
+
): Promise<HbcliCallResult> {
|
|
51
|
+
const started = Date.now()
|
|
52
|
+
const bin = opts.hbcliBin ?? 'hbcli'
|
|
53
|
+
const timeoutMs = opts.timeoutMs ?? 15_000
|
|
54
|
+
const env = opts.env ?? 'uat'
|
|
55
|
+
const envVars: Record<string, string> = { HOTELBYTE_ENV: env }
|
|
56
|
+
if (opts.token) envVars['HOTELBYTE_TOKEN'] = opts.token
|
|
57
|
+
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
let stdout = ''
|
|
60
|
+
let stderr = ''
|
|
61
|
+
let settled = false
|
|
62
|
+
const child = spawn(bin, args, { env: { ...process.env, ...envVars } })
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
if (!settled) {
|
|
65
|
+
settled = true
|
|
66
|
+
child.kill('SIGKILL')
|
|
67
|
+
resolve({
|
|
68
|
+
via: 'hbcli-error', exitCode: -1, result: null,
|
|
69
|
+
evidence: `[实时API:hbcli@timeout@${new Date().toISOString()}]`,
|
|
70
|
+
latencyMs: Date.now() - started, error: `timeout after ${timeoutMs}ms`,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}, timeoutMs)
|
|
74
|
+
child.stdout.on('data', (d: Buffer) => { stdout += d.toString() })
|
|
75
|
+
child.stderr.on('data', (d: Buffer) => { stderr += d.toString() })
|
|
76
|
+
child.on('close', (code) => {
|
|
77
|
+
if (settled) return
|
|
78
|
+
settled = true
|
|
79
|
+
clearTimeout(timer)
|
|
80
|
+
const latencyMs = Date.now() - started
|
|
81
|
+
if (code !== 0) {
|
|
82
|
+
// 不抛错,降级返回。证据链显式标注:实时 API 调用失败+原因+时间戳。
|
|
83
|
+
resolve({
|
|
84
|
+
via: 'hbcli-error', exitCode: code ?? -1, result: null,
|
|
85
|
+
evidence: `[实时API:hbcli@error@${new Date().toISOString()}]`,
|
|
86
|
+
stderr: stderr.slice(0, 2000),
|
|
87
|
+
latencyMs, error: stderr.trim().slice(0, 200) || `exit ${code}`,
|
|
88
|
+
})
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
// 尝试 JSON 解析
|
|
92
|
+
const jStart = stdout.search(/[\{\[]/)
|
|
93
|
+
const jsonStr = jStart >= 0 ? stdout.slice(jStart) : ''
|
|
94
|
+
let result: unknown = null
|
|
95
|
+
if (jsonStr) {
|
|
96
|
+
try { result = JSON.parse(jsonStr) } catch { /* 非 JSON 输出,留给调用方处理 */ }
|
|
97
|
+
}
|
|
98
|
+
resolve({
|
|
99
|
+
via: 'hbcli-realtime', exitCode: 0, result,
|
|
100
|
+
evidence: `[实时API:hbcli@${new Date().toISOString()}]`,
|
|
101
|
+
stdout: stdout.slice(0, 2000), latencyMs,
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
child.on('error', (e) => {
|
|
105
|
+
if (settled) return
|
|
106
|
+
settled = true
|
|
107
|
+
clearTimeout(timer)
|
|
108
|
+
// ENOENT (二进制不存在) 等也走降级路径
|
|
109
|
+
resolve({
|
|
110
|
+
via: 'hbcli-error', exitCode: -1, result: null,
|
|
111
|
+
evidence: `[实时API:hbcli@spawn_error@${new Date().toISOString()}]`,
|
|
112
|
+
latencyMs: Date.now() - started, error: (e as Error).message,
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 高层语义化封装:酒店列表查询(down-tier to 静态包 + 证据链标注) */
|
|
119
|
+
export async function searchHotels(
|
|
120
|
+
query: { destination: string; checkIn?: string; checkOut?: string; adults?: number },
|
|
121
|
+
opts: HbcliCallOptions & { fallbackPath?: string } = {},
|
|
122
|
+
): Promise<HbcliCallResult & { hotels?: unknown; summary: string }> {
|
|
123
|
+
// 旗标对齐上游 CLI v0.3.0(命令树扁平化后):--destination-name + --room-occupancies;
|
|
124
|
+
// hotel-list 无日期旗标(房价按上游当前窗口),checkIn/checkOut 留给 hotel-rates 跟进
|
|
125
|
+
const hbArgs = ['search', 'hotel-list', '--json', '--page-size', '10']
|
|
126
|
+
if (query.destination) hbArgs.push('--destination-name', query.destination)
|
|
127
|
+
if (query.adults) hbArgs.push('--room-occupancies', JSON.stringify([{ adultCount: query.adults, childrenAges: [] }]))
|
|
128
|
+
const live = await callHbcliJson(hbArgs, opts)
|
|
129
|
+
if (live.via === 'hbcli-realtime') {
|
|
130
|
+
return { ...live, hotels: live.result, summary: `${query.destination}:hbcli 实时返回${query.checkIn || query.checkOut ? '(日期不传上游 list,以当前窗口房价返回)' : ''}` }
|
|
131
|
+
}
|
|
132
|
+
// 降级:读静态包
|
|
133
|
+
const fallback = opts.fallbackPath
|
|
134
|
+
if (fallback) {
|
|
135
|
+
try {
|
|
136
|
+
const { readFile } = await import('node:fs/promises')
|
|
137
|
+
const pack = JSON.parse(await readFile(fallback, 'utf-8')) as Record<string, unknown>
|
|
138
|
+
return {
|
|
139
|
+
...live,
|
|
140
|
+
hotels: pack,
|
|
141
|
+
summary: `${query.destination}:hbcli 不可用(${live.error ?? live.via}),降级到静态包`,
|
|
142
|
+
}
|
|
143
|
+
} catch { /* 静态包读不到也优雅降级 */ }
|
|
144
|
+
}
|
|
145
|
+
return { ...live, summary: `${query.destination}:hbcli 不可用且无静态包(仅返回错误)` }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 高层语义化封装:目的地列表(无数据依赖,通常 hbcli dest 命令可独立调通) */
|
|
149
|
+
export async function listDestinations(opts: HbcliCallOptions = {}): Promise<HbcliCallResult & { destinations?: unknown }> {
|
|
150
|
+
const r = await callHbcliJson(['search', 'destinations', '--json'], opts)
|
|
151
|
+
return { ...r, destinations: r.result }
|
|
152
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 进程级事故日志(D-NEW 护栏):fsync append-only JSONL。
|
|
3
|
+
*
|
|
4
|
+
* 目的: dsh 0.1.1-rc.1 只接 SIGINT/SIGTERM,缺 uncaughtException/unhandledRejection——
|
|
5
|
+
* 任一插件 wasm/runtime 异常穿透到 node 层都会让整个 web UI 进程死亡,
|
|
6
|
+
* 现场不存。这次(Z3 WASM mk_bool_var 崩溃)教训:连个取证都没有。
|
|
7
|
+
*
|
|
8
|
+
* 设计: 进程级 handler 同步写盘(fsync),尽量在进程被杀前留下事故证据。
|
|
9
|
+
* handler 自身不再抛——再次抛出会绕过 handler,变成无声死亡。
|
|
10
|
+
*
|
|
11
|
+
* §11 状态面同步:事故日志是 gotry-state/incidents.jsonl,
|
|
12
|
+
* 用户可见(README 第 x 节),可清理。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { dirname, isAbsolute, join } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
import type { ToolFailure } from '../src/tool-packet.ts'
|
|
19
|
+
|
|
20
|
+
export type IncidentKind = 'uncaughtException' | 'unhandledRejection' | 'plugin_error' | 'tool_execute_error'
|
|
21
|
+
|
|
22
|
+
export interface Incident {
|
|
23
|
+
ts: string
|
|
24
|
+
kind: IncidentKind
|
|
25
|
+
message: string
|
|
26
|
+
stack?: string
|
|
27
|
+
/** 来自哪个来源(plugin_name / service url 等) */
|
|
28
|
+
source?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 解析 gotry-state 绝对路径(stateRoot 可相对也可绝对) */
|
|
32
|
+
export function resolveIncidentsPath(stateRoot: string, filename = 'incidents.jsonl'): string {
|
|
33
|
+
const root = isAbsolute(stateRoot)
|
|
34
|
+
? stateRoot
|
|
35
|
+
: join(process.cwd(), stateRoot)
|
|
36
|
+
return join(root, 'gotry-state', filename)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ensureDir(path: string): void {
|
|
40
|
+
const dir = dirname(path)
|
|
41
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let installed = false
|
|
45
|
+
let logPath: string | null = null
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 写入一条事故并 fsync。同步执行——handler 内异步无效。
|
|
49
|
+
* 自身 try/catch 自保,不让写入失败再抛一次把进程杀掉。
|
|
50
|
+
*/
|
|
51
|
+
export function recordIncident(inc: Incident, stateRoot?: string): boolean {
|
|
52
|
+
const target = stateRoot ? resolveIncidentsPath(stateRoot) : (logPath ?? resolveIncidentsPath('.'))
|
|
53
|
+
try {
|
|
54
|
+
ensureDir(target)
|
|
55
|
+
appendFileSync(target, JSON.stringify(inc) + '\n', { flag: 'a' })
|
|
56
|
+
// fsync: 让盘数据落定,免得被杀时缓冲丢
|
|
57
|
+
const fd = require('node:fs').openSync(target, 'a')
|
|
58
|
+
require('node:fs').fsyncSync(fd)
|
|
59
|
+
require('node:fs').closeSync(fd)
|
|
60
|
+
return true
|
|
61
|
+
} catch {
|
|
62
|
+
// 绝对不能在这里抛——我们正在处理已经抛过一次的进程级错误
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 安装进程级护栏:进程整个生命周期仅生效一次(重复调用安全)。
|
|
69
|
+
* 必须同步执行——异步发生在事件循环下一次,而 wasm 崩溃后已无下一轮。
|
|
70
|
+
*
|
|
71
|
+
* @returns 一个 disposer,卸载所有监听(测试场景使用)
|
|
72
|
+
*/
|
|
73
|
+
export function installProcessGuards(stateRoot: string, labels?: { uncaughtException?: string; unhandledRejection?: string }): () => void {
|
|
74
|
+
if (installed) return () => { /* 已装,no-op */ }
|
|
75
|
+
installed = true
|
|
76
|
+
logPath = resolveIncidentsPath(stateRoot)
|
|
77
|
+
|
|
78
|
+
const uncaughtHandler = (err: Error, origin: NodeJS.UncaughtExceptionOrigin | 'uncaughtException') => {
|
|
79
|
+
recordIncident({
|
|
80
|
+
ts: new Date().toISOString(),
|
|
81
|
+
kind: 'uncaughtException',
|
|
82
|
+
message: (err?.message ?? String(err)).slice(0, 2000),
|
|
83
|
+
stack: (err?.stack ?? '').slice(0, 4000),
|
|
84
|
+
source: labels?.uncaughtException ?? String(origin),
|
|
85
|
+
}, stateRoot)
|
|
86
|
+
// 不调用 process.exit——让 dsh/上级容器的重启策略负责;
|
|
87
|
+
// 如果 dsh 不接住,我们至少留下事故记录。
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const rejectionHandler = (reason: unknown, promise: Promise<unknown>) => {
|
|
91
|
+
const msg = reason instanceof Error ? reason.message : String(reason)
|
|
92
|
+
const stack = reason instanceof Error ? (reason.stack ?? '') : ''
|
|
93
|
+
recordIncident({
|
|
94
|
+
ts: new Date().toISOString(),
|
|
95
|
+
kind: 'unhandledRejection',
|
|
96
|
+
message: msg.slice(0, 2000),
|
|
97
|
+
stack: stack.slice(0, 4000),
|
|
98
|
+
source: labels?.unhandledRejection ?? 'promise',
|
|
99
|
+
}, stateRoot)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
process.on('uncaughtException', uncaughtHandler)
|
|
103
|
+
process.on('unhandledRejection', rejectionHandler)
|
|
104
|
+
return () => {
|
|
105
|
+
process.off('uncaughtException', uncaughtHandler)
|
|
106
|
+
process.off('unhandledRejection', rejectionHandler)
|
|
107
|
+
installed = false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 工具执行面异常隔离(D-NEW gotry 侧收尾):dsh 的一个工具 execute 抛错/拒绝
|
|
113
|
+
* 会沿 cordis 传到主循环,拖垮整个会话。包装后:降级为结构化错误返回给 LLM、
|
|
114
|
+
* 事故落盘,永不向上抛。落盘失败也不抛(双保险,仍返回结构化错误)。
|
|
115
|
+
* 失败形状 = ToolFailure(RFC S1 observation envelope 的失败分支,编译期对齐)。
|
|
116
|
+
*/
|
|
117
|
+
export function guardToolExecute<A, R>(name: string, stateRoot: string, execute: (args: A, exec: unknown) => R | Promise<R>): (args: A, exec: unknown) => Promise<R> {
|
|
118
|
+
return async (args: A, exec: unknown): Promise<R> => {
|
|
119
|
+
try {
|
|
120
|
+
return await execute(args, exec)
|
|
121
|
+
} catch (e) {
|
|
122
|
+
const err = e instanceof Error ? e : new Error(String(e))
|
|
123
|
+
recordIncident({
|
|
124
|
+
ts: new Date().toISOString(),
|
|
125
|
+
kind: 'tool_execute_error',
|
|
126
|
+
message: `${name}: ${err.message}`.slice(0, 2000),
|
|
127
|
+
stack: (err.stack ?? '').slice(0, 4000),
|
|
128
|
+
source: name,
|
|
129
|
+
}, stateRoot)
|
|
130
|
+
const failure: ToolFailure = {
|
|
131
|
+
ok: false,
|
|
132
|
+
summary: `gotry_${name} 内部错误(已隔离,会话继续): ${err.message.slice(0, 300)}`,
|
|
133
|
+
evidence: `[incident:tool_execute_error@${new Date().toISOString()}]`,
|
|
134
|
+
}
|
|
135
|
+
return failure as R
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** CLI 调试:从命令行单独跑查看当前进程是否已挂护栏 */
|
|
141
|
+
if (import.meta.url === `file://${fileURLToPath(import.meta.url)}` && process.argv[2] === '--smoke') {
|
|
142
|
+
const stateRoot = process.argv[3] ?? '.'
|
|
143
|
+
installProcessGuards(stateRoot, { uncaughtException: 'smoke', unhandledRejection: 'smoke' })
|
|
144
|
+
writeFileSync(resolveIncidentsPath(stateRoot) + '.smoke', 'ok')
|
|
145
|
+
// 触发一次未捕获,应被写盘(不真正杀进程——进程继续跑完)
|
|
146
|
+
setTimeout(() => Promise.reject(new Error('intentional smoke-rejection')), 50)
|
|
147
|
+
setTimeout(() => { console.log('SMOKE OK'); process.exit(0) }, 500)
|
|
148
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenSky 实时观测桥(免费匿名,~400 credits/天,~4 req/s burst):
|
|
3
|
+
* 查某 callsign 的飞机是否在 ADS-B 网络的当前观测里——航线层的实时印证。
|
|
4
|
+
*
|
|
5
|
+
* 契约(与 weather.ts / hbcli.ts 同构):
|
|
6
|
+
* - 永不抛错:网络失败/超时/解析失败一律降级返回;
|
|
7
|
+
* - 三值语义:命中=强肯定;查空≠证伪(ADS-B 覆盖有限,降权不排除);服务不可用=无结论;
|
|
8
|
+
* - 证据链:成功 [实时API:opensky@ts];失败 [实时API:opensky@error@ts]。
|
|
9
|
+
*
|
|
10
|
+
* 接口面(2026-08 实际可达,匿名路径):
|
|
11
|
+
* GET /api/states/all — 当前全球 ADS-B 状态,~10000 架
|
|
12
|
+
* GET /api/states/all?icao24=<hex> — 单飞机
|
|
13
|
+
* /api/flights/airport 历史查 = 需鉴权(匿名 403)
|
|
14
|
+
*
|
|
15
|
+
* 数据意义:与 OpenFlights 骨架(通航性)+ 静态班次包(班期时刻) 三值互补——
|
|
16
|
+
* OpenFlights = 历史上是否通航过(offline)
|
|
17
|
+
* flights_2026.json = 调研估算的班次时刻
|
|
18
|
+
* OpenSky = 该 callsign 的飞机「现在」是否在观测网络里(实时刻正飞)
|
|
19
|
+
*
|
|
20
|
+
* v0.0.1-rc.3 +: 之前是 scripts/opensky-check.ts 一次性 CLI(M2 时设计为 7 天窗
|
|
21
|
+
* 历史校验,但匿名路径实际不可达),现升级为能力层封装 + dsh 插件工具。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface FlightLiveQuery {
|
|
25
|
+
/** 航班号/呼号(如 EK329 / CA985);大小写都行 */
|
|
26
|
+
callsign: string
|
|
27
|
+
/** 调用方所属机场 ICAO 四字码(用于地理命中),不传只返回 obs 状态 */
|
|
28
|
+
airport?: string
|
|
29
|
+
/** 超时(ms);默认 10_000 */
|
|
30
|
+
timeoutMs?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FlightHit {
|
|
34
|
+
icao24: string
|
|
35
|
+
callsign: string | null
|
|
36
|
+
/** OpenSky states/all 用 last_contact 列作为时间戳代理 */
|
|
37
|
+
lastSeen: number
|
|
38
|
+
firstSeen: number
|
|
39
|
+
/** 调用方传入的机场(OpenSky states/all 不返回) */
|
|
40
|
+
estArrivalAirport: string | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface FlightVerifyResult {
|
|
44
|
+
verdict: 'observed' | 'not_observed' | 'unavailable'
|
|
45
|
+
callsign: string
|
|
46
|
+
airport: string | null
|
|
47
|
+
sampleSize: number
|
|
48
|
+
hits?: FlightHit[]
|
|
49
|
+
evidence: string
|
|
50
|
+
via: 'opensky' | 'opensky-error'
|
|
51
|
+
latencyMs: number
|
|
52
|
+
error?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const BASE = 'https://opensky-network.org/api'
|
|
56
|
+
|
|
57
|
+
async function fetchOpenSky(url: string, timeoutMs: number): Promise<{ ok: boolean; data?: unknown; error?: string }> {
|
|
58
|
+
const ctrl = new AbortController()
|
|
59
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(url, {
|
|
62
|
+
signal: ctrl.signal,
|
|
63
|
+
headers: { Accept: 'application/json', 'User-Agent': 'gotry/0.0.1-rc.3 (+https://github.com/Danceiny/gotry)' },
|
|
64
|
+
})
|
|
65
|
+
if (!res.ok) return { ok: false, error: `opensky HTTP ${res.status}${res.status === 429 ? ' (rate limited, 400 credits/day anonymous)' : ''}` }
|
|
66
|
+
return { ok: true, data: await res.json() }
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return { ok: false, error: (e as Error).message.slice(0, 200) }
|
|
69
|
+
} finally {
|
|
70
|
+
clearTimeout(timer)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const upper = (s: string): string => s.trim().toUpperCase()
|
|
75
|
+
|
|
76
|
+
export async function verifyFlight(query: FlightLiveQuery): Promise<FlightVerifyResult> {
|
|
77
|
+
const started = Date.now()
|
|
78
|
+
const ts = new Date().toISOString()
|
|
79
|
+
const cs = upper(query.callsign)
|
|
80
|
+
const airport = query.airport ? upper(query.airport) : null
|
|
81
|
+
const r = await fetchOpenSky(`${BASE}/states/all`, query.timeoutMs ?? 10_000)
|
|
82
|
+
const latencyMs = Date.now() - started
|
|
83
|
+
|
|
84
|
+
if (!r.ok) {
|
|
85
|
+
return {
|
|
86
|
+
verdict: 'unavailable',
|
|
87
|
+
callsign: cs, airport,
|
|
88
|
+
sampleSize: 0, hits: [],
|
|
89
|
+
evidence: `[实时API:opensky@error@${ts}] ${r.error}`,
|
|
90
|
+
via: 'opensky-error',
|
|
91
|
+
latencyMs, error: r.error,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// /api/states/all → { time, states: Array<17 元素数组> }
|
|
96
|
+
// [0 icao24, 1 callsign, 2 origin_country, 3 time_position, 4 last_contact,
|
|
97
|
+
// 5 lon, 6 lat, 7 baro_altitude, 8 on_ground, ...]
|
|
98
|
+
const rawStates: unknown[][] = ((r.data ?? {}) as { states?: unknown[][] }).states ?? []
|
|
99
|
+
const observed: FlightHit[] = rawStates
|
|
100
|
+
.filter(row => ((row[1] ?? '') as string).trim().toUpperCase().includes(cs))
|
|
101
|
+
.map(row => ({
|
|
102
|
+
icao24: String(row[0] ?? ''),
|
|
103
|
+
callsign: ((row[1] ?? '') as string).trim() || null,
|
|
104
|
+
lastSeen: Number(row[4] ?? 0),
|
|
105
|
+
firstSeen: Number(row[4] ?? 0),
|
|
106
|
+
estArrivalAirport: airport,
|
|
107
|
+
}))
|
|
108
|
+
|
|
109
|
+
const verdict: FlightVerifyResult['verdict'] = observed.length > 0 ? 'observed' : 'not_observed'
|
|
110
|
+
const evidence = observed.length > 0
|
|
111
|
+
? `[实时API:opensky@${ts}] ✅ 全球 ADS-B 当前观测命中 ${cs} (${observed.length} 架)`
|
|
112
|
+
: `[实时API:opensky@${ts}] ○ 当前 ADS-B 全球观测列表未见 ${cs}(ADS-B 覆盖有限,不作否定结论)`
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
verdict, callsign: cs, airport,
|
|
116
|
+
sampleSize: rawStates.length,
|
|
117
|
+
hits: observed, evidence,
|
|
118
|
+
via: 'opensky',
|
|
119
|
+
latencyMs,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* action-cache 本地自愈层(RFC user-session-data-rfc.md §3.2 / §2.3;Stagehand 云端缓存的本地化):
|
|
3
|
+
*
|
|
4
|
+
* 目的:会话检索的查询模式高度重复(同站点同类型搜索),首跑用 LLM/规则定位,后续走
|
|
5
|
+
* 缓存的确定性载荷零成本重放;站点改版 → DOM 指纹失配 → miss → 重新定位并回写。
|
|
6
|
+
*
|
|
7
|
+
* 设计原则(Stagehand「错误的缓存点击比慢更糟」的被动失效):
|
|
8
|
+
* - key = site + action + 参数模板(%from%/%to%/%date% 变量化,同型查询共享条目);
|
|
9
|
+
* - 每条目绑 domFingerprint(快照/接口形状的 sha256 指纹);lookup 时现场指纹与条目不符 = miss(即使 key 命中);
|
|
10
|
+
* - miss 即回写(记录新定位),过期(默认 48h)与容量(每 site 默认 50 条,LRU)被动淘汰;
|
|
11
|
+
* - 纯函数 + JSON 文件持久化(隔离 stateRoot;损坏文件按空缓存处理,永不抛错)。
|
|
12
|
+
*
|
|
13
|
+
* P2 切片:模块+确定性测试先行;消费方(P2 美团适配器/a11y 兜底)下一切片接线。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
|
|
20
|
+
export interface ActionCacheEntry {
|
|
21
|
+
/** site:action:参数化模板 key,如 ctrip-flight:search:%from%->%to%:%date% */
|
|
22
|
+
key: string
|
|
23
|
+
/** 建条目时的 DOM/接口形状指纹;lookup 现场指纹必须一致才命中 */
|
|
24
|
+
domFingerprint: string
|
|
25
|
+
/** 确定性重放载荷(消费方自定义:URL 模板/selector 序列/解析器名) */
|
|
26
|
+
locator: Record<string, string>
|
|
27
|
+
createdAt: string
|
|
28
|
+
lastHitAt: string
|
|
29
|
+
hits: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ActionCacheOptions {
|
|
33
|
+
/** 条目 TTL ms,默认 48h(对齐 Stagehand) */
|
|
34
|
+
ttlMs?: number
|
|
35
|
+
/** 每站点条目上限(LRU 淘汰),默认 50 */
|
|
36
|
+
maxEntriesPerSite?: number
|
|
37
|
+
/** 覆盖「现在」(测试注入) */
|
|
38
|
+
now?: () => Date
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 把具体查询参数变量化为模板 key:日期→%date%,from/to 语义序在前,其余参数按字典序尾随 */
|
|
42
|
+
export function templateKey(site: string, action: string, params: Record<string, string>): string {
|
|
43
|
+
const val = (k: string): string => (k === 'date' ? '%date%' : (params[k] ?? '').trim())
|
|
44
|
+
const head = ['from', 'to', 'date'].filter((k) => k in params).map(val)
|
|
45
|
+
const tail = Object.keys(params).filter((k) => !['from', 'to', 'date'].includes(k)).sort().map(val)
|
|
46
|
+
return `${site}:${action}:${[...head, ...tail].join('|')}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 指纹:快照/接口形状文本 → sha256 前 16 位(微改版即失配——宁可 miss 不可错点) */
|
|
50
|
+
export function fingerprint(shapeText: string): string {
|
|
51
|
+
return createHash('sha256').update(shapeText).digest('hex').slice(0, 16)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ActionCache {
|
|
55
|
+
private entries = new Map<string, ActionCacheEntry>()
|
|
56
|
+
private loaded = false
|
|
57
|
+
private readonly ttlMs: number
|
|
58
|
+
private readonly maxPerSite: number
|
|
59
|
+
private readonly now: () => Date
|
|
60
|
+
|
|
61
|
+
constructor(private readonly filePath: string, opts: ActionCacheOptions = {}) {
|
|
62
|
+
this.ttlMs = opts.ttlMs ?? 48 * 3600_000
|
|
63
|
+
this.maxPerSite = opts.maxEntriesPerSite ?? 50
|
|
64
|
+
this.now = opts.now ?? (() => new Date())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private load(): void {
|
|
68
|
+
if (this.loaded) return
|
|
69
|
+
this.loaded = true
|
|
70
|
+
try {
|
|
71
|
+
if (existsSync(this.filePath)) {
|
|
72
|
+
const raw = JSON.parse(readFileSync(this.filePath, 'utf8')) as ActionCacheEntry[]
|
|
73
|
+
for (const e of raw) {
|
|
74
|
+
if (e?.key && e.domFingerprint && e.locator) this.entries.set(e.key, e)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch { /* 损坏文件按空缓存处理 */ }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private persist(): void {
|
|
81
|
+
try {
|
|
82
|
+
mkdirSync(dirname(this.filePath), { recursive: true })
|
|
83
|
+
const tmp = this.filePath + '.tmp'
|
|
84
|
+
writeFileSync(tmp, JSON.stringify([...this.entries.values()], null, 0), 'utf8')
|
|
85
|
+
writeFileSync(this.filePath, readFileSync(tmp, 'utf8'), 'utf8')
|
|
86
|
+
try { require('node:fs').rmSync(tmp) } catch { /* best effort */ }
|
|
87
|
+
} catch { /* 持久化失败不阻塞内存路径 */ }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 站点前缀条目数(LRU 淘汰用) */
|
|
91
|
+
private siteEntries(site: string): ActionCacheEntry[] {
|
|
92
|
+
return [...this.entries.values()].filter((e) => e.key.startsWith(site + ':'))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 命中三条件:key 存在 + 未过期 + 指纹一致;命中计 hits 并惰性落盘 */
|
|
96
|
+
lookup(key: string, domFingerprint: string, site: string): ActionCacheEntry | null {
|
|
97
|
+
this.load()
|
|
98
|
+
const e = this.entries.get(key)
|
|
99
|
+
if (!e) return null
|
|
100
|
+
if (this.now().getTime() - Date.parse(e.lastHitAt || e.createdAt) > this.ttlMs) {
|
|
101
|
+
this.entries.delete(key)
|
|
102
|
+
this.persist()
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
if (e.domFingerprint !== domFingerprint) return null // 改版:被动失效,等 record 回写
|
|
106
|
+
e.hits += 1
|
|
107
|
+
e.lastHitAt = this.now().toISOString()
|
|
108
|
+
this.persist()
|
|
109
|
+
void site
|
|
110
|
+
return e
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** miss 后回写(重新定位的产物);同 key 覆盖;超容 LRU 淘汰最旧 */
|
|
114
|
+
record(key: string, domFingerprint: string, locator: Record<string, string>, site: string): ActionCacheEntry {
|
|
115
|
+
this.load()
|
|
116
|
+
const ts = this.now().toISOString()
|
|
117
|
+
const prev = this.entries.get(key)
|
|
118
|
+
this.entries.set(key, {
|
|
119
|
+
key, domFingerprint, locator,
|
|
120
|
+
createdAt: prev?.createdAt ?? ts,
|
|
121
|
+
lastHitAt: ts,
|
|
122
|
+
hits: prev?.hits ?? 0,
|
|
123
|
+
})
|
|
124
|
+
const siteList = this.siteEntries(site)
|
|
125
|
+
if (siteList.length > this.maxPerSite) {
|
|
126
|
+
const victim = siteList.sort((a, b) => Date.parse(a.lastHitAt) - Date.parse(b.lastHitAt))[0]
|
|
127
|
+
if (victim) this.entries.delete(victim.key)
|
|
128
|
+
}
|
|
129
|
+
this.persist()
|
|
130
|
+
return this.entries.get(key)!
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
invalidate(key: string): void {
|
|
134
|
+
this.load()
|
|
135
|
+
this.entries.delete(key)
|
|
136
|
+
this.persist()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
size(site?: string): number {
|
|
140
|
+
this.load()
|
|
141
|
+
return site ? this.siteEntries(site).length : this.entries.size
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 便捷入口:隔离 stateRoot 下的缓存文件路径 */
|
|
146
|
+
export function actionCachePath(stateRoot: string): string {
|
|
147
|
+
return join(stateRoot, 'session-action-cache.json')
|
|
148
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 携程机票站点适配器(RFC §3.2 首个适配器;PoC 已识别接口面,data-sources.md §8):
|
|
3
|
+
* - entry:URL 参数直开(https://flights.ctrip.com/online/list/oneway-{from}-{to}?depdate=YYYY-MM-DD)
|
|
4
|
+
* - networkHints:主搜索接口 search/api/search/batchSearch(POST,~550KB,国内票同走)
|
|
5
|
+
* - 解析:flightItineraryList[].flightSegments[0].flightList[0] + priceList 最小 adultPrice
|
|
6
|
+
* (schema 实测 2026-08-28,见 data-sources.md §8)
|
|
7
|
+
* 只读:适配器不含任何提交/预订语义;URL 直开即零 DOM 交互。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** 城市三字码表(小起步集;词表外 unresolved 逐字保留,不做开放式猜测——ADR-12 同款边界) */
|
|
11
|
+
const CITY_CODES: Record<string, string> = {
|
|
12
|
+
上海: 'sha', 北京: 'bjs', 广州: 'can', 深圳: 'szx', 成都: 'ctu', 昆明: 'kmg',
|
|
13
|
+
大理: 'dlu', 丽江: 'ljg', 西安: 'sia', 杭州: 'hgh', 三亚: 'syx', 厦门: 'xmn',
|
|
14
|
+
重庆: 'cqg', 青岛: 'taa', 长沙: 'csx', 武汉: 'wuh', 南京: 'nkg', 郑州: 'cgo',
|
|
15
|
+
贵阳: 'kwe', 桂林: 'kwl', 西双版纳: 'jhg', 香格里拉: 'dig',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AdapterEntry {
|
|
19
|
+
ok: boolean
|
|
20
|
+
url?: string
|
|
21
|
+
/** 词表外城市逐字保留(调用方降级无日期/无线路搜索) */
|
|
22
|
+
unresolved?: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildEntryUrl(from: string, to: string, depDate: string): AdapterEntry {
|
|
26
|
+
const unresolved: string[] = []
|
|
27
|
+
const fromCode = CITY_CODES[from]
|
|
28
|
+
const toCode = CITY_CODES[to]
|
|
29
|
+
if (!fromCode) unresolved.push(from)
|
|
30
|
+
if (!toCode) unresolved.push(to)
|
|
31
|
+
if (unresolved.length > 0 || !/^\d{4}-\d{2}-\d{2}$/.test(depDate)) {
|
|
32
|
+
return { ok: false, unresolved: unresolved.length > 0 ? unresolved : [depDate] }
|
|
33
|
+
}
|
|
34
|
+
return { ok: true, url: `https://flights.ctrip.com/online/list/oneway-${fromCode}-${toCode}?depdate=${depDate}` }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** networkHints:响应 URL 命中即视为搜索回包(对 UI 改版免疫,只怕接口改版) */
|
|
38
|
+
export const NETWORK_HINTS = [/search\/api\/search\/batchSearch/]
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 登录态检测(用户自己的账号,不是匿名实例——founder 2026-08-28 纠偏):
|
|
42
|
+
* 任一登录票据 cookie 存在即视为已登录;名单基于公开常识,首个真登录后校准(D-13)。
|
|
43
|
+
*/
|
|
44
|
+
export const LOGIN_COOKIE_NAMES = ['cticket', 'uid', 'uname', 'passport']
|
|
45
|
+
export const SITE_DOMAIN = '.ctrip.com'
|
|
46
|
+
|
|
47
|
+
export interface SessionFlightOption {
|
|
48
|
+
flightNo: string
|
|
49
|
+
airline: string
|
|
50
|
+
depDateTime: string
|
|
51
|
+
arrDateTime: string
|
|
52
|
+
depAirport: string
|
|
53
|
+
arrAirport: string
|
|
54
|
+
durationMin: number
|
|
55
|
+
price: number
|
|
56
|
+
aircraft?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface RawBatch {
|
|
60
|
+
status?: number
|
|
61
|
+
data?: {
|
|
62
|
+
flightItineraryList?: Array<{
|
|
63
|
+
flightSegments?: Array<{
|
|
64
|
+
airlineName?: string
|
|
65
|
+
duration?: number
|
|
66
|
+
flightList?: Array<{
|
|
67
|
+
flightNo?: string
|
|
68
|
+
departureAirportName?: string
|
|
69
|
+
arrivalAirportName?: string
|
|
70
|
+
departureDateTime?: string
|
|
71
|
+
arrivalDateTime?: string
|
|
72
|
+
aircraftName?: string
|
|
73
|
+
}>
|
|
74
|
+
}>
|
|
75
|
+
priceList?: Array<{ adultPrice?: number }>
|
|
76
|
+
}>
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 解析 batchSearch 响应(纯函数,fixture 测试锚点);malformed 一律返空,不抛错 */
|
|
81
|
+
export function parseBatchSearch(body: string): SessionFlightOption[] {
|
|
82
|
+
let raw: RawBatch
|
|
83
|
+
try {
|
|
84
|
+
raw = JSON.parse(body) as RawBatch
|
|
85
|
+
} catch {
|
|
86
|
+
return []
|
|
87
|
+
}
|
|
88
|
+
const out: SessionFlightOption[] = []
|
|
89
|
+
for (const it of raw.data?.flightItineraryList ?? []) {
|
|
90
|
+
const seg = it.flightSegments?.[0]
|
|
91
|
+
const fl = seg?.flightList?.[0]
|
|
92
|
+
if (!fl?.flightNo || !fl.departureDateTime) continue
|
|
93
|
+
const prices = (it.priceList ?? []).map((p) => Number(p.adultPrice ?? 0)).filter((n) => n > 0)
|
|
94
|
+
out.push({
|
|
95
|
+
flightNo: fl.flightNo,
|
|
96
|
+
airline: seg?.airlineName ?? '',
|
|
97
|
+
depDateTime: fl.departureDateTime ?? '',
|
|
98
|
+
arrDateTime: fl.arrivalDateTime ?? '',
|
|
99
|
+
depAirport: fl.departureAirportName ?? '',
|
|
100
|
+
arrAirport: fl.arrivalAirportName ?? '',
|
|
101
|
+
durationMin: Number(seg?.duration ?? 0) || 0,
|
|
102
|
+
price: prices.length > 0 ? Math.min(...prices) : 0,
|
|
103
|
+
aircraft: fl.aircraftName,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
}
|