@danceiny/gotry 0.0.1-rc.18 → 0.0.1-rc.20
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 +42 -16
- package/README.zh-CN.md +37 -15
- package/bin/gotry-bootstrap.js +309 -30
- package/bin/gotry-inner.js +74 -23
- package/bin/gotry-runtime-resolution.d.ts +1 -1
- package/bin/gotry-runtime-resolution.js +7 -8
- package/cordis.gotry-patch.yml +22 -8
- package/data/stations-12306-verify.json +524 -0
- package/dist/capabilities/agent-reach.js +4 -4
- package/dist/capabilities/anything.js +62 -29
- package/dist/capabilities/channel-health.js +103 -0
- package/dist/capabilities/channel-registry.js +213 -0
- package/dist/capabilities/doctor.js +307 -0
- package/dist/capabilities/effect.js +178 -5
- package/dist/capabilities/flyai.js +13 -0
- package/dist/capabilities/hbcli.js +28 -2
- package/dist/capabilities/session/adapters/ctrip-hotel.js +207 -0
- package/dist/capabilities/session/adapters/rail-12306.js +285 -0
- package/dist/capabilities/session/extension-bridge.js +14 -1
- package/dist/capabilities/session/extension-channel.js +4 -2
- package/dist/capabilities/session/extension-distribution.js +1 -1
- package/dist/capabilities/session-search.js +311 -0
- package/dist/capabilities/visa-policy.js +126 -0
- package/dist/scripts/agent-planning-turn-deadline-e2e.js +305 -0
- package/dist/scripts/agent-planning-turn-deadline-tests.js +281 -0
- package/dist/scripts/anything-tests.js +70 -21
- package/dist/scripts/benchmark-environment-bridge-e2e.js +139 -37
- package/dist/scripts/benchmark-environment-bridge-tests.js +1099 -306
- package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +26 -26
- package/dist/scripts/{booking-copilot-availability-policy-v2-tests.js → booking-copilot-availability-policy-tests.js} +115 -115
- package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +41 -1
- package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +75 -120
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +370 -61
- package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +2 -2
- package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +7 -3
- package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +15 -9
- package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +19 -11
- package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +16 -52
- package/dist/scripts/booking-copilot-runtime-proof-tests.js +3953 -155
- package/dist/scripts/booking-copilot-server-proof-tests.js +58 -23
- package/dist/scripts/booking-copilot-startup-proof-tests.js +10 -119
- package/dist/scripts/booking-surface-contract-proof-tests.js +1154 -326
- package/dist/scripts/bootstrap-tests.js +113 -2
- package/dist/scripts/build-metrics-report.js +339 -0
- package/dist/scripts/channel-probe-tests.js +148 -0
- package/dist/scripts/channel-probe.js +252 -0
- package/dist/scripts/channel-registry-tests.js +262 -0
- package/dist/scripts/doctor-tests.js +175 -0
- package/dist/scripts/effect-tests.js +274 -1
- package/dist/scripts/extension-tests.js +129 -11
- package/dist/scripts/fact-gate-tests.js +91 -2
- package/dist/scripts/flyai-tests.js +17 -1
- package/dist/scripts/hbcli-e2e-tests.js +1 -3
- package/dist/scripts/hbcli-tests.js +38 -1
- package/dist/scripts/metrics-report-tests.js +288 -0
- package/dist/scripts/persona-surface-guard-tests.js +19 -0
- package/dist/scripts/session-tests.js +401 -1
- package/dist/scripts/smoke.js +100 -83
- package/dist/scripts/turn-handoff-collect-tests.js +216 -0
- package/dist/scripts/turn-handoff-collect.js +189 -0
- package/dist/scripts/turn-policy-tests.js +56 -0
- package/dist/scripts/typed-contract-canary.js +261 -0
- package/dist/scripts/visa-policy-tests.js +75 -0
- package/dist/scripts/wish-channel-gate-tests.js +111 -0
- package/dist/src/artifact-gate.js +91 -3
- package/dist/src/benchmark-agent-conformance.js +39 -16
- package/dist/src/benchmark-environment-bridge.js +316 -52
- package/dist/src/bookable-facts.js +62 -3
- package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
- package/dist/src/booking-surface/canonical-schema.js +8 -22
- package/dist/src/booking-surface/contracts.js +70 -19
- package/dist/src/booking-surface/dsh-planner.js +310 -177
- package/dist/src/booking-surface/dsh-plugin.js +3 -3
- package/dist/src/booking-surface/index.js +1 -5
- package/dist/src/booking-surface/profile.js +1 -1
- package/dist/src/booking-surface/runtime.js +1682 -247
- package/dist/src/booking-surface/server.js +371 -185
- package/dist/src/booking-surface/startup.js +17 -27
- package/dist/src/booking-surface/validation.js +270 -760
- package/dist/src/index.js +739 -150
- package/dist/src/turn-deadline.js +292 -0
- package/dist/src/turn-policy.js +146 -0
- package/dist/src/wish-pool.js +5 -0
- package/extension/background.js +23 -4
- package/extension/content-main.js +47 -9
- package/extension/manifest.json +27 -9
- package/package.json +7 -26
- package/schemas/booking.surface.schema.json +2593 -0
- package/ts/capabilities/agent-reach.ts +4 -4
- package/ts/capabilities/anything.ts +89 -38
- package/ts/capabilities/flyai.ts +21 -3
- package/ts/capabilities/hbcli.ts +53 -4
- package/ts/capabilities/session/action-cache.ts +1 -1
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
- package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
- package/ts/capabilities/session/extension-bridge.ts +32 -2
- package/ts/capabilities/session/extension-channel.ts +9 -8
- package/ts/capabilities/session/extension-distribution.ts +3 -1
- package/ts/capabilities/session/health-watch.ts +1 -1
- package/ts/capabilities/session/read-guard.ts +1 -1
- package/ts/capabilities/session/wizard.ts +1 -1
- package/ts/capabilities/session-search.ts +323 -1
- package/ts/dsh-runtime/vendor/README.md +60 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/LICENSE +21 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +200 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +202 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/client/client.js +216 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/cordis.patch.yml +5 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/amap.js +371 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/nominatim.js +63 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/osrm.js +56 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/photon.js +55 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-file.js +87 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-route.js +126 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config.js +16 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/index.js +109 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +27 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/geocode.js +127 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/poi.js +84 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/routes.js +170 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/amap.d.ts +53 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/nominatim.d.ts +17 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/osrm.d.ts +15 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/photon.d.ts +22 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-file.d.ts +33 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-route.d.ts +20 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config.d.ts +23 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/index.d.ts +18 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +14 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/geocode.d.ts +11 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/poi.d.ts +9 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/routes.d.ts +16 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/types.d.ts +55 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types.js +16 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +91 -0
- package/ts/package.json +9 -0
- package/ts/scripts/turn-handoff-collect.ts +178 -0
- package/ts/src/artifact-gate.ts +82 -2
- package/ts/src/benchmark-agent-conformance.ts +54 -15
- package/ts/src/benchmark-environment-bridge.ts +234 -46
- package/ts/src/bookable-facts.ts +109 -5
- package/ts/src/booking-saga.ts +1 -1
- package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
- package/ts/src/booking-surface/canonical-schema.js +8 -22
- package/ts/src/booking-surface/contracts.ts +278 -239
- package/ts/src/booking-surface/dsh-planner.ts +333 -194
- package/ts/src/booking-surface/dsh-plugin.js +3 -3
- package/ts/src/booking-surface/index.ts +1 -5
- package/ts/src/booking-surface/profile.ts +11 -11
- package/ts/src/booking-surface/runtime.ts +1351 -351
- package/ts/src/booking-surface/server.ts +282 -175
- package/ts/src/booking-surface/startup.ts +36 -48
- package/ts/src/booking-surface/validation.ts +194 -442
- package/ts/src/contracts.ts +1 -1
- package/ts/src/index.ts +492 -172
- package/ts/src/loop.ts +1 -1
- package/ts/src/state-ledger.ts +1 -1
- package/ts/src/turn-deadline.ts +365 -0
- package/ts/src/turn-policy.ts +154 -0
- package/ts/src/wish-pool.ts +17 -4
- package/dist/scripts/agent-planning-budget-e2e.js +0 -227
- package/dist/scripts/agent-planning-budget-tests.js +0 -173
- package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
- package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
- package/dist/src/booking-surface/contracts-v2.js +0 -89
- package/dist/src/booking-surface/runtime-v2.js +0 -1771
- package/dist/src/booking-surface/server-v2.js +0 -334
- package/dist/src/booking-surface/validation-v2.js +0 -319
- package/dist/src/tool-budget.js +0 -136
- package/schemas/booking.surface.v1.schema.json +0 -927
- package/schemas/booking.surface.v2.schema.json +0 -61
- package/ts/src/booking-surface/contracts-v2.ts +0 -118
- package/ts/src/booking-surface/runtime-v2.ts +0 -1466
- package/ts/src/booking-surface/server-v2.ts +0 -247
- package/ts/src/booking-surface/validation-v2.ts +0 -205
- package/ts/src/tool-budget.ts +0 -165
|
@@ -77,7 +77,7 @@ export async function reach(q: { channel: string; method: string; args?: string[
|
|
|
77
77
|
}
|
|
78
78
|
const py = venvPython()
|
|
79
79
|
if (!existsSync(py)) {
|
|
80
|
-
return { ...base, ok: false, verdict: 'not-installed', evidence: evidence('not-installed'), latencyMs: 0, setup: 'gotry .venv 缺 python
|
|
80
|
+
return { ...base, ok: false, verdict: 'not-installed', evidence: evidence('not-installed'), latencyMs: 0, setup: 'gotry .venv 缺 python——可选依赖未装配。补装:终端跑 npx gotry doctor --fix,或让用户看体检报告 npx gotry doctor' }
|
|
81
81
|
}
|
|
82
82
|
const args = Array.isArray(q.args) ? q.args : (q.args ? q.args.split(/\s+/).filter(Boolean) : [])
|
|
83
83
|
const r = await run(py, [bridgeScript(), q.channel, q.method, ...args], q.timeoutMs ?? 30_000)
|
|
@@ -89,14 +89,14 @@ export async function reach(q: { channel: string; method: string; args?: string[
|
|
|
89
89
|
} catch { /* 走下面的兜底 */ }
|
|
90
90
|
|
|
91
91
|
if (r.err?.includes('ENOENT')) {
|
|
92
|
-
return { ...base, ok: false, verdict: 'not-installed', evidence: evidence('not-installed'), latencyMs, setup: '.venv/bin/python
|
|
92
|
+
return { ...base, ok: false, verdict: 'not-installed', evidence: evidence('not-installed'), latencyMs, setup: '.venv/bin/python 不可执行——补装:终端跑 npx gotry doctor --fix' }
|
|
93
93
|
}
|
|
94
94
|
if (parsed.ok === true) {
|
|
95
95
|
return { ...base, ok: true, verdict: 'found', evidence: evidence('found'), latencyMs, data: parsed.data }
|
|
96
96
|
}
|
|
97
97
|
// 上游 agent_reach 包未装(bridge 自己的 exit 3 输出)
|
|
98
98
|
if (parsed.error?.includes('agent_reach 未安装')) {
|
|
99
|
-
return { ...base, ok: false, verdict: 'not-installed', evidence: evidence('not-installed'), latencyMs, setup: '.venv 缺 agent-reach
|
|
99
|
+
return { ...base, ok: false, verdict: 'not-installed', evidence: evidence('not-installed'), latencyMs, setup: '.venv 缺 agent-reach 包——补装:终端跑 npx gotry doctor --fix' }
|
|
100
100
|
}
|
|
101
101
|
// 未知渠道/方法:自描述,附上游清单(不拦 LLM,让它看清单自纠)
|
|
102
102
|
if (r.code === 2) {
|
|
@@ -144,7 +144,7 @@ export async function reachStatus(timeoutMs = 90_000): Promise<ReachStatus> {
|
|
|
144
144
|
const started = Date.now()
|
|
145
145
|
const bin = resolve(repoRoot(), '.venv/bin/agent-reach')
|
|
146
146
|
if (!existsSync(bin)) {
|
|
147
|
-
return { ok: false, via: 'not-installed', evidence: `[agent-reach:doctor@not-installed@${ts()}]`, latencyMs: 0, output: '
|
|
147
|
+
return { ok: false, via: 'not-installed', evidence: `[agent-reach:doctor@not-installed@${ts()}]`, latencyMs: 0, output: 'Agent Reach 未装配(.venv 缺失)——可选依赖,补装:终端跑 npx gotry doctor --fix;或让助手调 gotry_doctor 看整体体检报告' }
|
|
148
148
|
}
|
|
149
149
|
const r = await run(bin, ['doctor'], timeoutMs)
|
|
150
150
|
const output = (r.stdout || r.stderr).trim()
|
|
@@ -3,10 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 链路(同构 hbcli.ts):
|
|
5
5
|
* gotry capabilities/anything.ts
|
|
6
|
-
* → spawn hbcli search anything [
|
|
7
|
-
* → hotel-be /api/search/anything
|
|
6
|
+
* → spawn hbcli --json search anything <kw> [opts]
|
|
7
|
+
* → hotel-be /api/search/anything(httpdispatch 自动注册;httpdispatch 路由面)
|
|
8
8
|
* → search/service/geography.go func Anything (mixed 城市+酒店搜索)
|
|
9
9
|
*
|
|
10
|
+
* wire 契约(2026-09-07 对齐上游源码;SearchItem = FuzzySearchItem + hotel):
|
|
11
|
+
* req { keyword, contentType?, context.destinationId?, minHotelCount? }
|
|
12
|
+
* resp { candidates: [{ type: 'city'|'place'|'hotel',
|
|
13
|
+
* region?: { id, name:{en,zh,ar}, coordinates:{centerLat,centerLng}, countryCode, … },
|
|
14
|
+
* place?: { latlngCoordinator },
|
|
15
|
+
* matchScore?, hotel?: { id, name:{en,zh,ar}, star, latlngCoordinator:{google|gaode:{lat,lng} } } }] }
|
|
16
|
+
*
|
|
10
17
|
* 三值语义:
|
|
11
18
|
* - hit: 命中候选(混合 city/hotel,maybe-coords)
|
|
12
19
|
* - miss: hbcli exit 0,但 candidates 为空(查无结果)
|
|
@@ -22,33 +29,36 @@
|
|
|
22
29
|
*/
|
|
23
30
|
|
|
24
31
|
import { spawn } from 'node:child_process'
|
|
32
|
+
import { hbcliBinCandidates } from './hbcli.ts'
|
|
25
33
|
|
|
26
34
|
export interface AnythingQuery {
|
|
27
35
|
/** 多词以空格 join,与 hbcli argument-parser 一致;前后 trim。空则报错(unless contentType 强限定) */
|
|
28
36
|
keyword: string
|
|
29
37
|
/** 'city' | 'hotel' | undefined(混合) */
|
|
30
38
|
contentType?: 'city' | 'hotel'
|
|
31
|
-
/** 限定子区域 ID(
|
|
39
|
+
/** 限定子区域 ID(上游 SearchReq.context.destinationId) */
|
|
32
40
|
parentDestinationId?: string | number
|
|
33
41
|
/** 默认 12_000 ms */
|
|
34
42
|
timeoutMs?: number
|
|
35
|
-
/** 显式 hbcli 路径(默认 'hbcli',从 $PATH
|
|
43
|
+
/** 显式 hbcli 路径(默认 'hbcli',从 $PATH 找;默认名自动回退 ~/.local/bin 等已知安装位) */
|
|
36
44
|
hbcliBin?: string
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
export interface AnythingHit {
|
|
40
48
|
type: 'city' | 'hotel' | 'place'
|
|
41
|
-
/**
|
|
49
|
+
/** i18n name 解析(zh 优先,产品中文优先;回退 en/ar) */
|
|
42
50
|
name: string
|
|
43
|
-
/**
|
|
51
|
+
/** matchScore(文本相关度,越高越匹配) */
|
|
44
52
|
score?: number
|
|
45
|
-
/** lat/lng(
|
|
53
|
+
/** lat/lng(region.center 或 hotel/place latlngCoordinator,若给出) */
|
|
46
54
|
latitude?: number
|
|
47
55
|
longitude?: number
|
|
48
|
-
/** city id(
|
|
56
|
+
/** city id(region.id;酒店候选为所属 destinationId) */
|
|
49
57
|
destinationId?: string
|
|
50
58
|
/** hotel id(若有) */
|
|
51
59
|
hotelId?: string
|
|
60
|
+
/** 星级(酒店候选,若返回) */
|
|
61
|
+
star?: number
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
export interface AnythingResult {
|
|
@@ -63,16 +73,46 @@ export interface AnythingResult {
|
|
|
63
73
|
error?: string
|
|
64
74
|
}
|
|
65
75
|
|
|
76
|
+
/** 上游 i18n.I18N {en,zh,ar};tolerant 读取(字段可能缺省,老字符串形态也兜住) */
|
|
77
|
+
type I18N = { en?: string; zh?: string; ar?: string } | string | undefined
|
|
78
|
+
|
|
79
|
+
function i18nText(v: I18N): string {
|
|
80
|
+
if (!v) return ''
|
|
81
|
+
if (typeof v === 'string') return v
|
|
82
|
+
return v.zh || v.en || v.ar || ''
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** hotel-be Latlng:{lat,lng};google/gaode 双源取先非零者 */
|
|
86
|
+
function latlngOf(coord: { google?: { lat?: number; lng?: number }; gaode?: { lat?: number; lng?: number } } | undefined): { latitude?: number; longitude?: number } {
|
|
87
|
+
for (const src of [coord?.google, coord?.gaode]) {
|
|
88
|
+
const lat = typeof src?.lat === 'number' ? src.lat : undefined
|
|
89
|
+
const lng = typeof src?.lng === 'number' ? src.lng : undefined
|
|
90
|
+
if (lat !== undefined && lng !== undefined && (lat !== 0 || lng !== 0)) return { latitude: lat, longitude: lng }
|
|
91
|
+
}
|
|
92
|
+
return {}
|
|
93
|
+
}
|
|
94
|
+
|
|
66
95
|
interface RawSearchResp {
|
|
67
96
|
candidates?: RawSearchItem[]
|
|
68
97
|
}
|
|
98
|
+
/** hotel-be SearchItem wire 形状(FuzzySearchItem + hotel,2026-09-07 对齐源码) */
|
|
69
99
|
interface RawSearchItem {
|
|
70
100
|
type?: string
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
101
|
+
matchScore?: number
|
|
102
|
+
region?: {
|
|
103
|
+
id?: string | number
|
|
104
|
+
name?: I18N
|
|
105
|
+
countryCode?: string
|
|
106
|
+
coordinates?: { centerLat?: number; centerLng?: number }
|
|
107
|
+
}
|
|
108
|
+
place?: { latlngCoordinator?: { google?: { lat?: number; lng?: number }; gaode?: { lat?: number; lng?: number } } }
|
|
109
|
+
hotel?: {
|
|
110
|
+
id?: string | number
|
|
111
|
+
name?: I18N
|
|
112
|
+
star?: number
|
|
113
|
+
destinationId?: string | number
|
|
114
|
+
latlngCoordinator?: { google?: { lat?: number; lng?: number }; gaode?: { lat?: number; lng?: number } }
|
|
115
|
+
}
|
|
76
116
|
}
|
|
77
117
|
|
|
78
118
|
function sh(cmd: string, args: string[], opts: { timeoutMs: number; env: NodeJS.ProcessEnv }) {
|
|
@@ -115,8 +155,20 @@ function sh(cmd: string, args: string[], opts: { timeoutMs: number; env: NodeJS.
|
|
|
115
155
|
})
|
|
116
156
|
}
|
|
117
157
|
|
|
118
|
-
|
|
119
|
-
|
|
158
|
+
/** --json 必须在子命令前(cli.ts 全局旗标预扫描只认子命令前位置;JSON 错误也走结构化 stderr) */
|
|
159
|
+
function buildArgs(keyword: string, q: AnythingQuery): string[] {
|
|
160
|
+
const args = ['--json', 'search', 'anything', keyword]
|
|
161
|
+
if (q.contentType) args.push('--content-type', q.contentType)
|
|
162
|
+
if (q.parentDestinationId !== undefined) args.push('--destination-id', String(q.parentDestinationId))
|
|
163
|
+
return args
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 旧版 CLI 无 search anything 子命令时的可行动升级指引(issue #195:裸 unknown command 读起来像工具坏了) */
|
|
167
|
+
function upgradeHint(stderr: string): string | null {
|
|
168
|
+
return /unknown command/i.test(stderr)
|
|
169
|
+
? 'hbcli 版本过旧(无 search anything 子命令)——请升级:hbcli update,或重跑 npx gotry setup'
|
|
170
|
+
: null
|
|
171
|
+
}
|
|
120
172
|
|
|
121
173
|
/** Anything 通用搜索 — 任何搜索失败走降级;不抛错 */
|
|
122
174
|
export async function anythingSearch(q: AnythingQuery): Promise<AnythingResult> {
|
|
@@ -131,27 +183,25 @@ export async function anythingSearch(q: AnythingQuery): Promise<AnythingResult>
|
|
|
131
183
|
error: 'keyword is required',
|
|
132
184
|
}
|
|
133
185
|
}
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
186
|
+
const args = buildArgs(kw, q)
|
|
187
|
+
// spawn 级失败(ENOENT)按已知安装位回退(hbcli.ts callHbcliJson 同款);其余失败无重试意义
|
|
188
|
+
const candidates = hbcliBinCandidates(q.hbcliBin ?? 'hbcli')
|
|
189
|
+
let r: { code: number; stdout: string; stderr: string; error?: string } = { code: -1, stdout: '', stderr: '' }
|
|
190
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
191
|
+
r = await sh(candidates[i]!, args, { timeoutMs: q.timeoutMs ?? 12_000, env: process.env })
|
|
192
|
+
if (!(r.error && i < candidates.length - 1)) break
|
|
139
193
|
}
|
|
140
|
-
|
|
141
|
-
const r = await sh(bin, args.slice(1), {
|
|
142
|
-
timeoutMs: q.timeoutMs ?? 12_000,
|
|
143
|
-
env: process.env,
|
|
144
|
-
})
|
|
145
194
|
const latencyMs = Date.now() - started
|
|
146
195
|
|
|
147
196
|
if (r.error || r.code !== 0) {
|
|
197
|
+
const raw = r.error ?? `${r.stderr.slice(0, 200)} (exit ${r.code})`
|
|
148
198
|
return {
|
|
149
199
|
ok: false,
|
|
150
200
|
via: 'hbcli-anything-error',
|
|
151
|
-
evidence: `[实时API:hbcli-anything@error@${ts}] ${
|
|
201
|
+
evidence: `[实时API:hbcli-anything@error@${ts}] ${raw}`,
|
|
152
202
|
latencyMs,
|
|
153
203
|
verdict: 'error',
|
|
154
|
-
error: r.
|
|
204
|
+
error: upgradeHint(r.stderr) ?? raw,
|
|
155
205
|
}
|
|
156
206
|
}
|
|
157
207
|
|
|
@@ -174,15 +224,19 @@ export async function anythingSearch(q: AnythingQuery): Promise<AnythingResult>
|
|
|
174
224
|
const hits: AnythingHit[] = rawItems.map((it) => {
|
|
175
225
|
const region = it.region ?? {}
|
|
176
226
|
const hotel = it.hotel ?? {}
|
|
177
|
-
const isHotel = it.type === 'hotel' ||
|
|
227
|
+
const isHotel = it.type === 'hotel' || Boolean(hotel.id)
|
|
228
|
+
const hotelCoords = latlngOf(hotel.latlngCoordinator)
|
|
229
|
+
const placeCoords = latlngOf(it.place?.latlngCoordinator)
|
|
230
|
+
const center = region.coordinates ?? {}
|
|
178
231
|
return {
|
|
179
|
-
type: (isHotel ? 'hotel' : (it.type === 'place'
|
|
180
|
-
name:
|
|
181
|
-
score: it.
|
|
182
|
-
latitude:
|
|
183
|
-
longitude:
|
|
184
|
-
destinationId: region.id,
|
|
185
|
-
hotelId: hotel.id,
|
|
232
|
+
type: (isHotel ? 'hotel' : (it.type === 'place' ? 'place' : 'city')) as AnythingHit['type'],
|
|
233
|
+
name: i18nText(hotel.name) || i18nText(region.name) || '?',
|
|
234
|
+
score: it.matchScore,
|
|
235
|
+
latitude: hotelCoords.latitude ?? placeCoords.latitude ?? (typeof center.centerLat === 'number' ? center.centerLat : undefined),
|
|
236
|
+
longitude: hotelCoords.longitude ?? placeCoords.longitude ?? (typeof center.centerLng === 'number' ? center.centerLng : undefined),
|
|
237
|
+
destinationId: hotel.destinationId !== undefined ? String(hotel.destinationId) : (region.id !== undefined ? String(region.id) : undefined),
|
|
238
|
+
hotelId: hotel.id !== undefined ? String(hotel.id) : undefined,
|
|
239
|
+
star: typeof hotel.star === 'number' && hotel.star > 0 ? hotel.star : undefined,
|
|
186
240
|
}
|
|
187
241
|
})
|
|
188
242
|
|
|
@@ -196,6 +250,3 @@ export async function anythingSearch(q: AnythingQuery): Promise<AnythingResult>
|
|
|
196
250
|
totalCandidates: rawItems.length,
|
|
197
251
|
}
|
|
198
252
|
}
|
|
199
|
-
|
|
200
|
-
// noop exports to keep file shape consistent with hbcli.ts
|
|
201
|
-
export const _ = { upperFirst }
|
package/ts/capabilities/flyai.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* FlyAI 官方通道能力层(飞猪开放平台,data-sources.md §8 / RFC user-session-data-rfc.md P0):
|
|
2
|
+
* FlyAI 官方通道能力层(飞猪开放平台,data-sources.md §8 / RFC docs/rfc/user-session-data-rfc.md P0):
|
|
3
3
|
*
|
|
4
4
|
* 链路(同构 anything.ts 的 CLI spawn 模式):
|
|
5
5
|
* gotry capabilities/flyai.ts
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
* - 只读:8 工具全只读,交易经 jumpUrl/detailUrl 由人完成(与 WriteGate 哲学同构);
|
|
12
12
|
* - 永不抛错:网络/超时/解析失败一律降级返回 verdict='error';
|
|
13
13
|
* - 证据链:成功 [实时API:flyai@ts];失败 [实时API:flyai@error@ts];
|
|
14
|
-
* -
|
|
14
|
+
* - 匿名试用额度可用但共享易达限(2026-09-02 迪拜 session:429 Trial limit reached
|
|
15
|
+
* 被当通用 error,LLM 跨轮盲重试 4 次)——达限归类 verdict='needs-setup' +
|
|
16
|
+
* setup 指引(申请正式 key 配 FLYAI_API_KEY),工具层据此阻断盲目重试。
|
|
17
|
+
* FLYAI_API_KEY 已配时 env 透传,不再走试用额度。
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
import { spawn } from 'node:child_process'
|
|
@@ -86,11 +89,14 @@ export interface FlyaiResult {
|
|
|
86
89
|
via: 'flyai' | 'flyai-error'
|
|
87
90
|
evidence: string
|
|
88
91
|
latencyMs: number
|
|
89
|
-
|
|
92
|
+
/** needs-setup = 配置问题(试用额度达限/需正式 key),不是检索失败——工具层应阻断盲目重试 */
|
|
93
|
+
verdict: 'hit' | 'miss' | 'error' | 'needs-setup'
|
|
90
94
|
kind: FlyaiKind
|
|
91
95
|
options?: FlyaiOption[]
|
|
92
96
|
hotels?: FlyaiHotelOption[]
|
|
93
97
|
error?: string
|
|
98
|
+
/** 上游指引原话/补配指引(仅 needs-setup 时) */
|
|
99
|
+
setup?: string
|
|
94
100
|
}
|
|
95
101
|
|
|
96
102
|
interface RawItem {
|
|
@@ -245,6 +251,18 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
|
|
|
245
251
|
})
|
|
246
252
|
const latencyMs = Date.now() - started
|
|
247
253
|
if (r.error || r.code !== 0) {
|
|
254
|
+
// 试用额度达限(2026-09-02 迪拜 session 实况:exit 1 + MCP HTTP 429 "Trial limit
|
|
255
|
+
// reached")是配置问题不是瞬时故障——归 needs-setup 带补配指引,让工具层阻断
|
|
256
|
+
// LLM 拿同一把 429 跨轮盲重试;正式 key 经 FLYAI_API_KEY 配入后此路径不再触发。
|
|
257
|
+
const upstream = `${r.stderr}\n${r.stdout}`
|
|
258
|
+
if (/Trial limit reached|HTTP\s*429|\b429\b/.test(upstream)) {
|
|
259
|
+
return {
|
|
260
|
+
...base, latencyMs, ok: false, via: 'flyai-error', verdict: 'needs-setup',
|
|
261
|
+
evidence: `[实时API:flyai@error@${ts}] trial quota exhausted`,
|
|
262
|
+
error: `FlyAI 匿名试用额度已用尽(上游 429):${upstream.replace(/\s+/g, ' ').slice(0, 160)}`,
|
|
263
|
+
setup: 'FlyAI 试用额度达限——到 flyai.open.fliggy.com 控制台申请正式 API Key,配入环境变量 FLYAI_API_KEY 即恢复;本会话请勿重试 gotry_flyai_search,机/火/酒改走 gotry_session_search(账号会话)或 web 检索推进。',
|
|
264
|
+
}
|
|
265
|
+
}
|
|
248
266
|
return { ...base, latencyMs, ok: false, via: 'flyai-error', verdict: 'error', evidence: `[实时API:flyai@error@${ts}] ${r.error ?? `exit ${r.code}`}`, error: r.error ?? r.stderr.replace(/\s+/g, ' ').slice(0, 200) }
|
|
249
267
|
}
|
|
250
268
|
let items: RawItem[]
|
package/ts/capabilities/hbcli.ts
CHANGED
|
@@ -142,19 +142,66 @@ export async function callHbcliJson(
|
|
|
142
142
|
return last!
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/** 上游 i18n 名称容错读取(zh 优先,中文优先产品口径;回退 en/ar) */
|
|
146
|
+
function i18nName(v: { zh?: string; en?: string; ar?: string } | string | undefined): string {
|
|
147
|
+
if (!v) return ''
|
|
148
|
+
if (typeof v === 'string') return v
|
|
149
|
+
return v.zh || v.en || v.ar || ''
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 实时 hotelList 响应 → 模型可读紧凑行(id/名称/星级/最低价)。
|
|
154
|
+
* issue #195:render 面只透 summary,若 summary 不含数据,模型只看到
|
|
155
|
+
* 「实时返回」四个字——工具调用等于白调。id 必须在行内,hotel-rates 的入参
|
|
156
|
+
* 就来自这里。上游 wire:HotelListResp{list:[Hotel{id,name:{en,zh},star,minPrice:{amount,currency}}]}。
|
|
157
|
+
*/
|
|
158
|
+
function hotelLines(result: unknown, max = 8): string {
|
|
159
|
+
const list = (result as { list?: unknown } | null)?.list
|
|
160
|
+
if (!Array.isArray(list) || list.length === 0) return ''
|
|
161
|
+
const lines: string[] = []
|
|
162
|
+
for (const item of list.slice(0, max)) {
|
|
163
|
+
const h = item as {
|
|
164
|
+
id?: string | number
|
|
165
|
+
name?: { zh?: string; en?: string; ar?: string }
|
|
166
|
+
star?: number
|
|
167
|
+
minPrice?: { amount?: number; currency?: string }
|
|
168
|
+
}
|
|
169
|
+
const name = i18nName(h.name) || `hotel ${h.id ?? '?'}`
|
|
170
|
+
const star = typeof h.star === 'number' && h.star > 0 ? ` ${h.star}★` : ''
|
|
171
|
+
const price = h.minPrice && typeof h.minPrice.amount === 'number'
|
|
172
|
+
? ` 低至 ${h.minPrice.amount}${h.minPrice.currency ? ` ${h.minPrice.currency}` : ''}`
|
|
173
|
+
: ''
|
|
174
|
+
lines.push(`- id=${h.id ?? '?'} ${name}${star}${price}`)
|
|
175
|
+
}
|
|
176
|
+
if (list.length > max) lines.push(`…(共 ${list.length} 家,仅列前 ${max})`)
|
|
177
|
+
return lines.join('\n')
|
|
178
|
+
}
|
|
179
|
+
|
|
145
180
|
/** 高层语义化封装:酒店列表查询(down-tier to 静态包 + 证据链标注) */
|
|
146
181
|
export async function searchHotels(
|
|
147
182
|
query: { destination: string; checkIn?: string; checkOut?: string; adults?: number },
|
|
148
183
|
opts: HbcliCallOptions & { fallbackPath?: string } = {},
|
|
149
184
|
): Promise<HbcliCallResult & { hotels?: unknown; summary: string }> {
|
|
150
|
-
// 旗标对齐上游 CLI
|
|
151
|
-
// hotel-list
|
|
185
|
+
// 旗标对齐上游 CLI(hotelbyte-com/hotelbyte-cli):--destination-name + --room-occupancies;
|
|
186
|
+
// hotel-list 支持日期旗标(--check-in/--check-out,上游帮助文档在册)——issue #195 教训:
|
|
187
|
+
// 「日期不传上游」会让实时价退化为当前窗口价,用户要下周五的房给的是今天的价。
|
|
152
188
|
const hbArgs = ['search', 'hotel-list', '--json', '--page-size', '10']
|
|
153
189
|
if (query.destination) hbArgs.push('--destination-name', query.destination)
|
|
190
|
+
if (query.checkIn) hbArgs.push('--check-in', query.checkIn)
|
|
191
|
+
if (query.checkOut) hbArgs.push('--check-out', query.checkOut)
|
|
154
192
|
if (query.adults) hbArgs.push('--room-occupancies', JSON.stringify([{ adultCount: query.adults, childrenAges: [] }]))
|
|
155
193
|
const live = await callHbcliJson(hbArgs, opts)
|
|
156
194
|
if (live.via === 'hbcli-realtime') {
|
|
157
|
-
|
|
195
|
+
// summary 即模型面(render 只透 summary):必须自带紧凑数据行 + 证据链,
|
|
196
|
+
// 否则模型只看到「实时返回」占位一句话(issue #195 实锤)。
|
|
197
|
+
const list = (live.result as { list?: unknown[] } | null)?.list
|
|
198
|
+
const n = Array.isArray(list) ? list.length : 0
|
|
199
|
+
const dateTag = query.checkIn
|
|
200
|
+
? `入住 ${query.checkIn}${query.checkOut ? ` → 退房 ${query.checkOut}` : ''}`
|
|
201
|
+
: '未传日期(上游按当前窗口价)'
|
|
202
|
+
const lines = hotelLines(live.result)
|
|
203
|
+
const summary = `${query.destination}:hbcli 实时 ${n} 家(${dateTag})${lines ? `\n${lines}` : ''}\n${live.evidence}`
|
|
204
|
+
return { ...live, hotels: live.result, summary }
|
|
158
205
|
}
|
|
159
206
|
// 降级原因人话化(issue #24):hbcli 未安装时按 gotry setup 指引(npm 安装期已
|
|
160
207
|
// 自动跑过官方脚本;PATH 未含 ~/.local/bin 时上方候选路径也已兜住),
|
|
@@ -176,7 +223,9 @@ export async function searchHotels(
|
|
|
176
223
|
return {
|
|
177
224
|
...live,
|
|
178
225
|
hotels: { stays: matched },
|
|
179
|
-
summary
|
|
226
|
+
// summary 即模型面:静态块内容(note/options/recommendation)直接内联,
|
|
227
|
+
// 模型不必再猜「命中 N 块」里有什么(issue #195)
|
|
228
|
+
summary: `${query.destination}:hbcli 实时源不可用(${reason}),已降级到静态包(公开渠道估算,非实时),命中 ${matched.length} 个住宿块:\n${JSON.stringify(matched).slice(0, 1600)}`,
|
|
180
229
|
}
|
|
181
230
|
}
|
|
182
231
|
return {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* action-cache 本地自愈层(RFC user-session-data-rfc.md §3.2 / §2.3;Stagehand 云端缓存的本地化):
|
|
2
|
+
* action-cache 本地自愈层(RFC docs/rfc/user-session-data-rfc.md §3.2 / §2.3;Stagehand 云端缓存的本地化):
|
|
3
3
|
*
|
|
4
4
|
* 目的:会话检索的查询模式高度重复(同站点同类型搜索),首跑用 LLM/规则定位,后续走
|
|
5
5
|
* 缓存的确定性载荷零成本重放;站点改版 → DOM 指纹失配 → miss → 重新定位并回写。
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 携程酒店站点适配器(2026-09-03 迪拜 session 复盘:账号会话面此前只有机票,
|
|
3
|
+
* 用户要「携程找酒店」时被迫撞 flyai 429 与打码 web 读页;会话面酒店从
|
|
4
|
+
* 「备份」实装为通道之一,rfc §2.2 同款只读纪律)。
|
|
5
|
+
*
|
|
6
|
+
* - entry:https://hotels.ctrip.com/hotels/list?city=<id>&checkin=&checkout=&adult=N
|
|
7
|
+
* - networkHints:酒店检索 XHR 的 URL 形态(接口名公开资料多版并存,**首个真会话
|
|
8
|
+
* 后校准**——D-13 同款边界);URL hint 未命中时由 content-main 的**形状嗅探**
|
|
9
|
+
* 兜底(响应 JSON 含酒店清单签名即转发),对接口改名免疫
|
|
10
|
+
* - 解析:走形(walk)JSON 找「名字 + 价格」签名的条目数组,归一化最小字段;
|
|
11
|
+
* 页面/接口 JSON 是不可信输入(RFC §3.5)——本层只归形状,语义判定在工具层
|
|
12
|
+
*
|
|
13
|
+
* 只读:适配器不含任何提交/预订语义;URL 直开即零 DOM 交互;ReadGuard 对酒店
|
|
14
|
+
* 页面照常生效(下单/支付面 URL 仍被物理拦截)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** 城市码表(**实测校准 2026-09-03**:每条 id 均经 hotels.ctrip.com list 页
|
|
18
|
+
* <title> 验证——探测法曾纠出 104=平遥县(传猜成都)等 5 处错猜,表内只收
|
|
19
|
+
* 验证过的 id;词表外 unresolved 逐字保留,调用方可显式传 cityId 覆盖) */
|
|
20
|
+
const HOTEL_CITY_CODES: Record<string, string> = {
|
|
21
|
+
上海: '2', 北京: '1', 广州: '32', 深圳: '30', 成都: '28',
|
|
22
|
+
杭州: '17', 南京: '12', 苏州: '14', 无锡: '13', 扬州: '15', 镇江: '16',
|
|
23
|
+
青岛: '7', 厦门: '25', 珠海: '31', 三亚: '43', 海口: '42',
|
|
24
|
+
桂林: '33', 昆明: '34', 大理: '36', 丽江: '37', 西双版纳: '35',
|
|
25
|
+
贵阳: '38', 拉萨: '41', 乌鲁木齐: '39', 太原: '105', 长春: '158', 南通: '82',
|
|
26
|
+
敦煌: '11', 舟山: '19', 张家界: '27', 黄山市: '23', 九江: '24', 武夷山: '26',
|
|
27
|
+
迪拜: '220',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AdapterEntry {
|
|
31
|
+
ok: boolean
|
|
32
|
+
url?: string
|
|
33
|
+
/** 词表外城市逐字保留(调用方提示:web 搜携程酒店 list 页取 city id 后带 cityId 重试) */
|
|
34
|
+
unresolved?: string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface HotelEntryQuery {
|
|
38
|
+
/** 目的地(中文);词表外须配 cityId */
|
|
39
|
+
to: string
|
|
40
|
+
/** 显式城市 id(携程/trip.com 酒店 list 页 URL 的 city= 数字;覆盖码表) */
|
|
41
|
+
cityId?: number | string
|
|
42
|
+
/** YYYY-MM-DD */
|
|
43
|
+
checkIn?: string
|
|
44
|
+
/** YYYY-MM-DD(与 checkIn 成对) */
|
|
45
|
+
checkOut?: string
|
|
46
|
+
adults?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildHotelEntryUrl(q: HotelEntryQuery): AdapterEntry {
|
|
50
|
+
const unresolved: string[] = []
|
|
51
|
+
const cityId = String(q.cityId ?? '').trim() || HOTEL_CITY_CODES[q.to.trim()]
|
|
52
|
+
if (!cityId) unresolved.push(q.to)
|
|
53
|
+
if (unresolved.length > 0) {
|
|
54
|
+
return { ok: false, unresolved }
|
|
55
|
+
}
|
|
56
|
+
const params = new URLSearchParams({ city: cityId })
|
|
57
|
+
if (q.checkIn) params.set('checkin', q.checkIn)
|
|
58
|
+
if (q.checkOut) params.set('checkout', q.checkOut)
|
|
59
|
+
if (q.adults && q.adults > 0) params.set('adult', String(q.adults))
|
|
60
|
+
return { ok: true, url: `https://hotels.ctrip.com/hotels/list?${params.toString()}` }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** networkHints:酒店检索 XHR 的 URL 形态(restapi/soa2/34951/fetchHotelList
|
|
64
|
+
* 为 2026-09-03 自 list 页一方确认的现行接口;其余为多版并存口径;命中即读) */
|
|
65
|
+
export const HOTEL_NETWORK_HINTS = [
|
|
66
|
+
/hotels\.ctrip\.com\/(hotels\/api|domestic\/pc\/api)/i,
|
|
67
|
+
/GetHotelListBySOA|GetHotelListByCity|HotelSearch|hotelsearch/i,
|
|
68
|
+
/restapi\/soa2\/\d+\/fetchHotelList/i,
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
/** 形状嗅探签名:URL hint 未命中时的兜底(对接口改名免疫);大小上限由调用方先把关 */
|
|
72
|
+
export function looksLikeHotelListBody(body: string): boolean {
|
|
73
|
+
if (!body || body.length > 2_000_000) return false
|
|
74
|
+
return /"hotelList"|"hotelMatchInfos"|"hotelName"/.test(body)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 酒店 page 域(content_scripts 注入面与 background 白名单的对账源) */
|
|
78
|
+
export const HOTEL_SITE_HOST = 'hotels.ctrip.com'
|
|
79
|
+
|
|
80
|
+
export interface SessionHotelOption {
|
|
81
|
+
/** 酒店名 */
|
|
82
|
+
name: string
|
|
83
|
+
/** 数字价(数值不可得时为 0;真实价以 jumpUrl 落地页为准) */
|
|
84
|
+
price: number
|
|
85
|
+
/** 价格原串(打码/区间等不可数值化的形态原样保留) */
|
|
86
|
+
priceRaw?: string
|
|
87
|
+
star?: number
|
|
88
|
+
/** 点评分(上游形态有数字有字符串,原样归一为字符串缺省) */
|
|
89
|
+
score?: string
|
|
90
|
+
address?: string
|
|
91
|
+
hotelId?: string
|
|
92
|
+
/** 酒店详情页跳转(由人完成预订;gotry 不碰) */
|
|
93
|
+
jumpUrl?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface WalkCandidate {
|
|
97
|
+
name: string
|
|
98
|
+
price?: number
|
|
99
|
+
priceRaw?: string
|
|
100
|
+
star?: number
|
|
101
|
+
score?: string
|
|
102
|
+
address?: string
|
|
103
|
+
hotelId?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 条目签名:像不像一家酒店(名字类字段必有 + 价格类字段至少其一) */
|
|
107
|
+
function hotelSignature(o: Record<string, unknown>): WalkCandidate | null {
|
|
108
|
+
const name = (o.hotelName ?? o.name ?? o.hotelNameEn) as unknown
|
|
109
|
+
if (typeof name !== 'string' || !name.trim()) return null
|
|
110
|
+
let price: number | undefined
|
|
111
|
+
let priceRaw: string | undefined
|
|
112
|
+
// 顶层扁平价(数字/字符串直给;字符串价是打码/区间形态,原样保留不数值化)
|
|
113
|
+
if (typeof o.price === 'number' && o.price > 0) price = o.price
|
|
114
|
+
else if (typeof o.price === 'string' && o.price.trim()) priceRaw = o.price.trim()
|
|
115
|
+
const probe = (v: unknown, seen = new Set<unknown>()): void => {
|
|
116
|
+
if (v == null || typeof v !== 'object') return
|
|
117
|
+
if (seen.has(v)) return
|
|
118
|
+
seen.add(v)
|
|
119
|
+
for (const [k, child] of Object.entries(v as Record<string, unknown>)) {
|
|
120
|
+
if (/^(avgPrice|totalPrice|price|amount|total|startPrice)$/i.test(k) && typeof child === 'number' && child > 0 && price === undefined) price = child
|
|
121
|
+
else if (/^(price|priceDisplay|amountText)$/i.test(k) && typeof child === 'string' && child.trim() && priceRaw === undefined) priceRaw = child.trim()
|
|
122
|
+
else probe(child, seen)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
probe(o.priceInfo)
|
|
126
|
+
const flatPrice = o.totalPrice ?? o.avgPrice ?? o.minPrice
|
|
127
|
+
if (typeof flatPrice === 'number' && flatPrice > 0 && price === undefined) price = flatPrice
|
|
128
|
+
const flatRaw = o.priceText ?? o.priceDisplay
|
|
129
|
+
if (typeof flatRaw === 'string' && flatRaw.trim() && priceRaw === undefined) priceRaw = flatRaw.trim()
|
|
130
|
+
if (price === undefined && priceRaw === undefined) return null
|
|
131
|
+
const starRaw = o.star ?? o.level ?? o.hotelStar
|
|
132
|
+
const star = typeof starRaw === 'number' && starRaw > 0 ? starRaw : Number.isFinite(Number(starRaw)) && Number(starRaw) > 0 ? Number(starRaw) : undefined
|
|
133
|
+
const scoreRaw = o.score ?? o.commentScore ?? o.reviewScore
|
|
134
|
+
const score = scoreRaw == null ? undefined : String(scoreRaw)
|
|
135
|
+
const pos = o.position as Record<string, unknown> | undefined
|
|
136
|
+
const address = (typeof pos?.address === 'string' ? pos.address : undefined) ?? (typeof o.address === 'string' ? o.address : undefined)
|
|
137
|
+
const hotelId = (typeof o.hotelId === 'number' || typeof o.hotelId === 'string' ? String(o.hotelId) : undefined)
|
|
138
|
+
?? (typeof o.hotelIdStr === 'string' ? o.hotelIdStr : undefined)
|
|
139
|
+
return { name: name.trim(), price, priceRaw, star, score, address, hotelId }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 结构化候选:携程现行 list 形态(**一方校准 2026-09-03**,取自 list 页官方
|
|
143
|
+
* 前端代码反查 + SSR initListData 实测):
|
|
144
|
+
* - 条目 = hotelList[].hotelInfo{summary{hotelId,nameInfo{name,names[]},hotelStar{star},positionInfo},commentInfo{commentScore}}
|
|
145
|
+
* - 价格 = roomInfo[].priceInfo.price(多房型取最低;页面 room_price 即读此);
|
|
146
|
+
* priceToken 在而无 price = 加密价(fetchHotelListPrices 页面内存解密),
|
|
147
|
+
* 嗅探面拿不到明文——如实不报价,summary 指引到 jumpUrl 落地页
|
|
148
|
+
* - displayPrice(如 "¥468")作 priceRaw 兜底;通用 price/amount 子树探测殿后
|
|
149
|
+
* (直连复现 fetchHotelList 被 hotel-spider-defence 反爬 404——「让站点自己
|
|
150
|
+
* 发请求」正是会话桥的设计原因;官方前端为公开静态资产,路径自其中反查) */
|
|
151
|
+
function ctripStructuredCandidate(item: Record<string, unknown>): WalkCandidate | null {
|
|
152
|
+
const info = item.hotelInfo as Record<string, unknown> | undefined
|
|
153
|
+
const summary = (info?.summary ?? item) as Record<string, unknown>
|
|
154
|
+
const nameInfo = summary.nameInfo as Record<string, unknown> | undefined
|
|
155
|
+
const names = nameInfo?.names
|
|
156
|
+
const nameRaw = nameInfo?.name ?? (Array.isArray(names) && names.length > 0 ? names[0] : undefined) ?? summary.hotelName ?? summary.name
|
|
157
|
+
if (typeof nameRaw !== 'string' || !nameRaw.trim()) return null
|
|
158
|
+
// 价格:roomInfo[].priceInfo 官方路径优先(数值 price 取 min;displayPrice/字符串价作 priceRaw)
|
|
159
|
+
let price: number | undefined
|
|
160
|
+
let priceRaw: string | undefined
|
|
161
|
+
let encrypted = false
|
|
162
|
+
const rooms = info?.roomInfo ?? item.roomInfo
|
|
163
|
+
if (Array.isArray(rooms)) {
|
|
164
|
+
for (const room of rooms) {
|
|
165
|
+
if (room == null || typeof room !== 'object') continue
|
|
166
|
+
const pi = (room as Record<string, unknown>).priceInfo as Record<string, unknown> | undefined
|
|
167
|
+
if (!pi) continue
|
|
168
|
+
if (typeof pi.price === 'number' && pi.price > 0) price = price === undefined ? pi.price : Math.min(price, pi.price)
|
|
169
|
+
if (typeof pi.price === 'string' && pi.price.trim() && !priceRaw) priceRaw = pi.price.trim()
|
|
170
|
+
if (typeof pi.displayPrice === 'string' && pi.displayPrice.trim() && !priceRaw) priceRaw = pi.displayPrice.trim()
|
|
171
|
+
if (pi.priceToken != null && pi.price === undefined) encrypted = true
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// 兜底:条目子树 price/amount 探测(SSR 形态 productInfo.priceInfo.price 等)
|
|
175
|
+
if (price === undefined && !priceRaw) {
|
|
176
|
+
const priceProbe = (v: unknown, depth: number, seen: Set<unknown>): void => {
|
|
177
|
+
if (price !== undefined || depth > 6 || v == null || typeof v !== 'object' || seen.has(v)) return
|
|
178
|
+
seen.add(v)
|
|
179
|
+
for (const [k, child] of Object.entries(v as Record<string, unknown>)) {
|
|
180
|
+
if (/^(price|avgPrice|totalPrice|minPrice|amount)$/i.test(k) && typeof child === 'number' && child > 0) { price = child; return }
|
|
181
|
+
if (/^(price|displayPrice|amountText)$/i.test(k) && typeof child === 'string' && child.trim()) { priceRaw = priceRaw ?? child.trim(); continue }
|
|
182
|
+
priceProbe(child, depth + 1, seen)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
priceProbe(item, 0, new Set())
|
|
186
|
+
}
|
|
187
|
+
// 加密价(priceToken 在而无明文 price):条目保留,价如实为 0——不伪造任何价
|
|
188
|
+
if (price === undefined && !priceRaw && !encrypted) return null
|
|
189
|
+
const starOf = summary.hotelStar as Record<string, unknown> | undefined
|
|
190
|
+
const starRaw = starOf?.star ?? summary.star
|
|
191
|
+
const star = typeof starRaw === 'number' && starRaw > 0 ? starRaw : undefined
|
|
192
|
+
const scoreRaw = ((info?.commentInfo ?? summary.commentInfo) as Record<string, unknown> | undefined)?.commentScore
|
|
193
|
+
const pos = summary.positionInfo as Record<string, unknown> | undefined
|
|
194
|
+
const address = (typeof pos?.address === 'string' ? pos.address : undefined) ?? (typeof summary.address === 'string' ? summary.address : undefined)
|
|
195
|
+
const hotelId = summary.hotelId != null ? String(summary.hotelId) : undefined
|
|
196
|
+
return { name: nameRaw.trim(), price, priceRaw, star, score: scoreRaw == null ? undefined : String(scoreRaw), address, hotelId }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 走形:深找第一个酒店签名数组(纯函数,fixture 测试锚点);malformed 一律返空,不抛错 */
|
|
200
|
+
export function parseCtripHotelList(body: string, opts: { maxItems?: number } = {}): SessionHotelOption[] {
|
|
201
|
+
let raw: unknown
|
|
202
|
+
try {
|
|
203
|
+
raw = JSON.parse(body)
|
|
204
|
+
} catch {
|
|
205
|
+
return []
|
|
206
|
+
}
|
|
207
|
+
const out: SessionHotelOption[] = []
|
|
208
|
+
const maxItems = opts.maxItems ?? 20
|
|
209
|
+
const walk = (node: unknown, depth: number, seen: Set<unknown>): void => {
|
|
210
|
+
if (out.length >= maxItems || depth > 12 || node == null || typeof node !== 'object' || seen.has(node)) return
|
|
211
|
+
if (Array.isArray(node)) {
|
|
212
|
+
for (const item of node) {
|
|
213
|
+
if (out.length >= maxItems) return
|
|
214
|
+
if (item == null || typeof item !== 'object' || Array.isArray(item)) continue
|
|
215
|
+
const cand = hotelSignature(item as Record<string, unknown>) ?? ctripStructuredCandidate(item as Record<string, unknown>)
|
|
216
|
+
if (!cand) continue
|
|
217
|
+
out.push({
|
|
218
|
+
name: cand.name,
|
|
219
|
+
price: cand.price ?? 0,
|
|
220
|
+
priceRaw: cand.priceRaw,
|
|
221
|
+
star: cand.star,
|
|
222
|
+
score: cand.score,
|
|
223
|
+
address: cand.address,
|
|
224
|
+
hotelId: cand.hotelId,
|
|
225
|
+
jumpUrl: cand.hotelId ? `https://hotels.ctrip.com/hotel/${cand.hotelId}` : undefined,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
seen.add(node)
|
|
231
|
+
for (const child of Object.values(node as Record<string, unknown>)) {
|
|
232
|
+
walk(child, depth + 1, seen)
|
|
233
|
+
if (out.length >= maxItems) return
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
walk(raw, 0, new Set())
|
|
237
|
+
return out
|
|
238
|
+
}
|