@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.
Files changed (176) hide show
  1. package/README.md +42 -16
  2. package/README.zh-CN.md +37 -15
  3. package/bin/gotry-bootstrap.js +309 -30
  4. package/bin/gotry-inner.js +74 -23
  5. package/bin/gotry-runtime-resolution.d.ts +1 -1
  6. package/bin/gotry-runtime-resolution.js +7 -8
  7. package/cordis.gotry-patch.yml +22 -8
  8. package/data/stations-12306-verify.json +524 -0
  9. package/dist/capabilities/agent-reach.js +4 -4
  10. package/dist/capabilities/anything.js +62 -29
  11. package/dist/capabilities/channel-health.js +103 -0
  12. package/dist/capabilities/channel-registry.js +213 -0
  13. package/dist/capabilities/doctor.js +307 -0
  14. package/dist/capabilities/effect.js +178 -5
  15. package/dist/capabilities/flyai.js +13 -0
  16. package/dist/capabilities/hbcli.js +28 -2
  17. package/dist/capabilities/session/adapters/ctrip-hotel.js +207 -0
  18. package/dist/capabilities/session/adapters/rail-12306.js +285 -0
  19. package/dist/capabilities/session/extension-bridge.js +14 -1
  20. package/dist/capabilities/session/extension-channel.js +4 -2
  21. package/dist/capabilities/session/extension-distribution.js +1 -1
  22. package/dist/capabilities/session-search.js +311 -0
  23. package/dist/capabilities/visa-policy.js +126 -0
  24. package/dist/scripts/agent-planning-turn-deadline-e2e.js +305 -0
  25. package/dist/scripts/agent-planning-turn-deadline-tests.js +281 -0
  26. package/dist/scripts/anything-tests.js +70 -21
  27. package/dist/scripts/benchmark-environment-bridge-e2e.js +139 -37
  28. package/dist/scripts/benchmark-environment-bridge-tests.js +1099 -306
  29. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +26 -26
  30. package/dist/scripts/{booking-copilot-availability-policy-v2-tests.js → booking-copilot-availability-policy-tests.js} +115 -115
  31. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +41 -1
  32. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +75 -120
  33. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +370 -61
  34. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +2 -2
  35. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +7 -3
  36. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +15 -9
  37. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +19 -11
  38. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +16 -52
  39. package/dist/scripts/booking-copilot-runtime-proof-tests.js +3953 -155
  40. package/dist/scripts/booking-copilot-server-proof-tests.js +58 -23
  41. package/dist/scripts/booking-copilot-startup-proof-tests.js +10 -119
  42. package/dist/scripts/booking-surface-contract-proof-tests.js +1154 -326
  43. package/dist/scripts/bootstrap-tests.js +113 -2
  44. package/dist/scripts/build-metrics-report.js +339 -0
  45. package/dist/scripts/channel-probe-tests.js +148 -0
  46. package/dist/scripts/channel-probe.js +252 -0
  47. package/dist/scripts/channel-registry-tests.js +262 -0
  48. package/dist/scripts/doctor-tests.js +175 -0
  49. package/dist/scripts/effect-tests.js +274 -1
  50. package/dist/scripts/extension-tests.js +129 -11
  51. package/dist/scripts/fact-gate-tests.js +91 -2
  52. package/dist/scripts/flyai-tests.js +17 -1
  53. package/dist/scripts/hbcli-e2e-tests.js +1 -3
  54. package/dist/scripts/hbcli-tests.js +38 -1
  55. package/dist/scripts/metrics-report-tests.js +288 -0
  56. package/dist/scripts/persona-surface-guard-tests.js +19 -0
  57. package/dist/scripts/session-tests.js +401 -1
  58. package/dist/scripts/smoke.js +100 -83
  59. package/dist/scripts/turn-handoff-collect-tests.js +216 -0
  60. package/dist/scripts/turn-handoff-collect.js +189 -0
  61. package/dist/scripts/turn-policy-tests.js +56 -0
  62. package/dist/scripts/typed-contract-canary.js +261 -0
  63. package/dist/scripts/visa-policy-tests.js +75 -0
  64. package/dist/scripts/wish-channel-gate-tests.js +111 -0
  65. package/dist/src/artifact-gate.js +91 -3
  66. package/dist/src/benchmark-agent-conformance.js +39 -16
  67. package/dist/src/benchmark-environment-bridge.js +316 -52
  68. package/dist/src/bookable-facts.js +62 -3
  69. package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
  70. package/dist/src/booking-surface/canonical-schema.js +8 -22
  71. package/dist/src/booking-surface/contracts.js +70 -19
  72. package/dist/src/booking-surface/dsh-planner.js +310 -177
  73. package/dist/src/booking-surface/dsh-plugin.js +3 -3
  74. package/dist/src/booking-surface/index.js +1 -5
  75. package/dist/src/booking-surface/profile.js +1 -1
  76. package/dist/src/booking-surface/runtime.js +1682 -247
  77. package/dist/src/booking-surface/server.js +371 -185
  78. package/dist/src/booking-surface/startup.js +17 -27
  79. package/dist/src/booking-surface/validation.js +270 -760
  80. package/dist/src/index.js +739 -150
  81. package/dist/src/turn-deadline.js +292 -0
  82. package/dist/src/turn-policy.js +146 -0
  83. package/dist/src/wish-pool.js +5 -0
  84. package/extension/background.js +23 -4
  85. package/extension/content-main.js +47 -9
  86. package/extension/manifest.json +27 -9
  87. package/package.json +7 -26
  88. package/schemas/booking.surface.schema.json +2593 -0
  89. package/ts/capabilities/agent-reach.ts +4 -4
  90. package/ts/capabilities/anything.ts +89 -38
  91. package/ts/capabilities/flyai.ts +21 -3
  92. package/ts/capabilities/hbcli.ts +53 -4
  93. package/ts/capabilities/session/action-cache.ts +1 -1
  94. package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
  95. package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
  96. package/ts/capabilities/session/extension-bridge.ts +32 -2
  97. package/ts/capabilities/session/extension-channel.ts +9 -8
  98. package/ts/capabilities/session/extension-distribution.ts +3 -1
  99. package/ts/capabilities/session/health-watch.ts +1 -1
  100. package/ts/capabilities/session/read-guard.ts +1 -1
  101. package/ts/capabilities/session/wizard.ts +1 -1
  102. package/ts/capabilities/session-search.ts +323 -1
  103. package/ts/dsh-runtime/vendor/README.md +60 -0
  104. package/ts/dsh-runtime/vendor/dsh-map-tools/LICENSE +21 -0
  105. package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +200 -0
  106. package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +202 -0
  107. package/ts/dsh-runtime/vendor/dsh-map-tools/client/client.js +216 -0
  108. package/ts/dsh-runtime/vendor/dsh-map-tools/cordis.patch.yml +5 -0
  109. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/amap.js +371 -0
  110. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/nominatim.js +63 -0
  111. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/osrm.js +56 -0
  112. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/photon.js +55 -0
  113. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-file.js +87 -0
  114. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-route.js +126 -0
  115. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config.js +16 -0
  116. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/index.js +109 -0
  117. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +27 -0
  118. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/geocode.js +127 -0
  119. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/poi.js +84 -0
  120. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/routes.js +170 -0
  121. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/amap.d.ts +53 -0
  122. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/nominatim.d.ts +17 -0
  123. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/osrm.d.ts +15 -0
  124. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/photon.d.ts +22 -0
  125. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-file.d.ts +33 -0
  126. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-route.d.ts +20 -0
  127. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config.d.ts +23 -0
  128. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/index.d.ts +18 -0
  129. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +14 -0
  130. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/geocode.d.ts +11 -0
  131. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/poi.d.ts +9 -0
  132. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/routes.d.ts +16 -0
  133. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/types.d.ts +55 -0
  134. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types.js +16 -0
  135. package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +91 -0
  136. package/ts/package.json +9 -0
  137. package/ts/scripts/turn-handoff-collect.ts +178 -0
  138. package/ts/src/artifact-gate.ts +82 -2
  139. package/ts/src/benchmark-agent-conformance.ts +54 -15
  140. package/ts/src/benchmark-environment-bridge.ts +234 -46
  141. package/ts/src/bookable-facts.ts +109 -5
  142. package/ts/src/booking-saga.ts +1 -1
  143. package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
  144. package/ts/src/booking-surface/canonical-schema.js +8 -22
  145. package/ts/src/booking-surface/contracts.ts +278 -239
  146. package/ts/src/booking-surface/dsh-planner.ts +333 -194
  147. package/ts/src/booking-surface/dsh-plugin.js +3 -3
  148. package/ts/src/booking-surface/index.ts +1 -5
  149. package/ts/src/booking-surface/profile.ts +11 -11
  150. package/ts/src/booking-surface/runtime.ts +1351 -351
  151. package/ts/src/booking-surface/server.ts +282 -175
  152. package/ts/src/booking-surface/startup.ts +36 -48
  153. package/ts/src/booking-surface/validation.ts +194 -442
  154. package/ts/src/contracts.ts +1 -1
  155. package/ts/src/index.ts +492 -172
  156. package/ts/src/loop.ts +1 -1
  157. package/ts/src/state-ledger.ts +1 -1
  158. package/ts/src/turn-deadline.ts +365 -0
  159. package/ts/src/turn-policy.ts +154 -0
  160. package/ts/src/wish-pool.ts +17 -4
  161. package/dist/scripts/agent-planning-budget-e2e.js +0 -227
  162. package/dist/scripts/agent-planning-budget-tests.js +0 -173
  163. package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
  164. package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
  165. package/dist/src/booking-surface/contracts-v2.js +0 -89
  166. package/dist/src/booking-surface/runtime-v2.js +0 -1771
  167. package/dist/src/booking-surface/server-v2.js +0 -334
  168. package/dist/src/booking-surface/validation-v2.js +0 -319
  169. package/dist/src/tool-budget.js +0 -136
  170. package/schemas/booking.surface.v1.schema.json +0 -927
  171. package/schemas/booking.surface.v2.schema.json +0 -61
  172. package/ts/src/booking-surface/contracts-v2.ts +0 -118
  173. package/ts/src/booking-surface/runtime-v2.ts +0 -1466
  174. package/ts/src/booking-surface/server-v2.ts +0 -247
  175. package/ts/src/booking-surface/validation-v2.ts +0 -205
  176. 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 — 见 docs/tokens.md(agent-reach 安装)' }
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 不可执行(装 agent-reach: 见 docs/tokens.md)' }
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(pip install agent-reach,见 docs/tokens.md)' }
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: 'pip install agent-reach 于 .venv(见 docs/tokens.md)' }
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 [keywords...] --json <opts>
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
- /** FuzzySearchItem name(or hotel name);fallback 为 region.Name */
49
+ /** i18n name 解析(zh 优先,产品中文优先;回退 en/ar) */
42
50
  name: string
43
- /** score / 0..1(若返回) */
51
+ /** matchScore(文本相关度,越高越匹配) */
44
52
  score?: number
45
- /** lat/lng(若 region 给出) */
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
- name?: string
72
- label?: string
73
- region?: { id?: string; name?: string; latitude?: number; longitude?: number; typeScore?: number; name_en?: string; name_zh?: string }
74
- hotel?: { id?: string; name?: string; latitude?: number; longitude?: number }
75
- score?: number
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
- const upperFirst = (s: string): string => (s ? s[0]!.toUpperCase() + s.slice(1) : s)
119
- const lowerFirst = (s: string): string => (s ? s[0]!.toLowerCase() + s.slice(1) : s)
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 bin = q.hbcliBin ?? 'hbcli'
135
- const args = [bin, 'search', 'anything', kw]
136
- if (q.contentType) args.push('--content-type', q.contentType)
137
- if (q.parentDestinationId !== undefined) {
138
- args.push('--parent-destination-id', String(q.parentDestinationId))
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}] ${r.error ?? `exit ${r.code}`}`,
201
+ evidence: `[实时API:hbcli-anything@error@${ts}] ${raw}`,
152
202
  latencyMs,
153
203
  verdict: 'error',
154
- error: r.error ?? `${r.stderr.slice(0, 200)} (exit ${r.code})`,
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' || it.type === 'SupplierHotelList' || Boolean(hotel.id)
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' || it.type === 'City' ? lowerFirst(it.type) : 'city')) as AnythingHit['type'],
180
- name: it.name ?? region.name_en ?? region.name ?? hotel.name ?? '?',
181
- score: it.score ?? region.typeScore,
182
- latitude: hotel.latitude ?? region.latitude,
183
- longitude: hotel.longitude ?? region.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 }
@@ -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
- * - 无 key 可用;FLYAI_API_KEY 为可选增强(env 透传)。
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
- verdict: 'hit' | 'miss' | 'error'
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[]
@@ -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 v0.3.0(命令树扁平化后):--destination-name + --room-occupancies;
151
- // hotel-list 无日期旗标(房价按上游当前窗口),checkIn/checkOut 留给 hotel-rates 跟进
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
- return { ...live, hotels: live.result, summary: `${query.destination}:hbcli 实时返回${query.checkIn || query.checkOut ? '(日期不传上游 list,以当前窗口房价返回)' : ''}` }
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: `${query.destination}:hbcli 实时源不可用(${reason}),已降级到静态包(公开渠道估算,非实时),命中 ${matched.length} 个住宿块`,
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
+ }