@danceiny/gotry 0.0.1-rc.17 → 0.0.1-rc.19

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 (137) hide show
  1. package/README.md +185 -148
  2. package/README.zh-CN.md +195 -154
  3. package/bin/gotry-bootstrap.js +246 -26
  4. package/bin/gotry-inner.js +68 -38
  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 +15 -5
  8. package/data/stations-12306-verify.json +524 -0
  9. package/dist/capabilities/agent-reach.js +4 -4
  10. package/dist/capabilities/channel-health.js +103 -0
  11. package/dist/capabilities/channel-registry.js +213 -0
  12. package/dist/capabilities/doctor.js +285 -0
  13. package/dist/capabilities/effect.js +178 -5
  14. package/dist/capabilities/flyai.js +13 -0
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +207 -0
  16. package/dist/capabilities/session/adapters/rail-12306.js +285 -0
  17. package/dist/capabilities/session/extension-bridge.js +14 -1
  18. package/dist/capabilities/session/extension-channel.js +4 -2
  19. package/dist/capabilities/session/extension-distribution.js +1 -1
  20. package/dist/capabilities/session-search.js +311 -0
  21. package/dist/capabilities/visa-policy.js +126 -0
  22. package/dist/scripts/agent-planning-turn-deadline-e2e.js +304 -0
  23. package/dist/scripts/agent-planning-turn-deadline-tests.js +281 -0
  24. package/dist/scripts/benchmark-environment-bridge-e2e.js +5 -10
  25. package/dist/scripts/benchmark-environment-bridge-tests.js +148 -142
  26. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +26 -26
  27. package/dist/scripts/{booking-copilot-availability-policy-v2-tests.js → booking-copilot-availability-policy-tests.js} +115 -115
  28. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +41 -1
  29. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +75 -120
  30. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +370 -61
  31. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +2 -2
  32. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +7 -3
  33. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +15 -9
  34. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +19 -11
  35. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +16 -52
  36. package/dist/scripts/booking-copilot-runtime-proof-tests.js +3953 -155
  37. package/dist/scripts/booking-copilot-server-proof-tests.js +58 -23
  38. package/dist/scripts/booking-copilot-startup-proof-tests.js +10 -119
  39. package/dist/scripts/booking-surface-contract-proof-tests.js +1154 -326
  40. package/dist/scripts/bootstrap-tests.js +115 -44
  41. package/dist/scripts/build-metrics-report.js +339 -0
  42. package/dist/scripts/channel-probe-tests.js +148 -0
  43. package/dist/scripts/channel-probe.js +252 -0
  44. package/dist/scripts/channel-registry-tests.js +262 -0
  45. package/dist/scripts/doctor-tests.js +121 -0
  46. package/dist/scripts/effect-tests.js +274 -1
  47. package/dist/scripts/extension-tests.js +129 -11
  48. package/dist/scripts/fact-gate-tests.js +91 -2
  49. package/dist/scripts/flyai-tests.js +17 -1
  50. package/dist/scripts/hbcli-e2e-tests.js +1 -3
  51. package/dist/scripts/metrics-report-tests.js +288 -0
  52. package/dist/scripts/persona-surface-guard-tests.js +19 -0
  53. package/dist/scripts/session-tests.js +401 -1
  54. package/dist/scripts/smoke.js +100 -83
  55. package/dist/scripts/turn-handoff-collect-tests.js +216 -0
  56. package/dist/scripts/turn-handoff-collect.js +189 -0
  57. package/dist/scripts/turn-policy-tests.js +56 -0
  58. package/dist/scripts/typed-contract-canary.js +261 -0
  59. package/dist/scripts/visa-policy-tests.js +75 -0
  60. package/dist/scripts/wish-channel-gate-tests.js +111 -0
  61. package/dist/src/artifact-gate.js +91 -3
  62. package/dist/src/benchmark-agent-conformance.js +7 -4
  63. package/dist/src/benchmark-environment-bridge.js +68 -7
  64. package/dist/src/bookable-facts.js +62 -3
  65. package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
  66. package/dist/src/booking-surface/canonical-schema.js +8 -22
  67. package/dist/src/booking-surface/contracts.js +70 -19
  68. package/dist/src/booking-surface/dsh-planner.js +310 -177
  69. package/dist/src/booking-surface/dsh-plugin.js +3 -3
  70. package/dist/src/booking-surface/index.js +1 -5
  71. package/dist/src/booking-surface/profile.js +1 -1
  72. package/dist/src/booking-surface/runtime.js +1682 -247
  73. package/dist/src/booking-surface/server.js +371 -185
  74. package/dist/src/booking-surface/startup.js +17 -27
  75. package/dist/src/booking-surface/validation.js +270 -760
  76. package/dist/src/index.js +735 -149
  77. package/dist/src/turn-deadline.js +292 -0
  78. package/dist/src/turn-policy.js +146 -0
  79. package/dist/src/wish-pool.js +5 -0
  80. package/extension/background.js +23 -4
  81. package/extension/content-main.js +47 -9
  82. package/extension/manifest.json +27 -9
  83. package/package.json +6 -26
  84. package/schemas/booking.surface.schema.json +2593 -0
  85. package/ts/capabilities/agent-reach.ts +4 -4
  86. package/ts/capabilities/flyai.ts +21 -3
  87. package/ts/capabilities/session/action-cache.ts +1 -1
  88. package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
  89. package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
  90. package/ts/capabilities/session/extension-bridge.ts +32 -2
  91. package/ts/capabilities/session/extension-channel.ts +9 -8
  92. package/ts/capabilities/session/extension-distribution.ts +3 -1
  93. package/ts/capabilities/session/health-watch.ts +1 -1
  94. package/ts/capabilities/session/read-guard.ts +1 -1
  95. package/ts/capabilities/session/wizard.ts +1 -1
  96. package/ts/capabilities/session-search.ts +323 -1
  97. package/ts/package.json +1 -0
  98. package/ts/scripts/turn-handoff-collect.ts +178 -0
  99. package/ts/src/artifact-gate.ts +82 -2
  100. package/ts/src/benchmark-agent-conformance.ts +14 -6
  101. package/ts/src/benchmark-environment-bridge.ts +29 -10
  102. package/ts/src/bookable-facts.ts +109 -5
  103. package/ts/src/booking-saga.ts +1 -1
  104. package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
  105. package/ts/src/booking-surface/canonical-schema.js +8 -22
  106. package/ts/src/booking-surface/contracts.ts +278 -239
  107. package/ts/src/booking-surface/dsh-planner.ts +333 -194
  108. package/ts/src/booking-surface/dsh-plugin.js +3 -3
  109. package/ts/src/booking-surface/index.ts +1 -5
  110. package/ts/src/booking-surface/profile.ts +11 -11
  111. package/ts/src/booking-surface/runtime.ts +1351 -351
  112. package/ts/src/booking-surface/server.ts +282 -175
  113. package/ts/src/booking-surface/startup.ts +36 -48
  114. package/ts/src/booking-surface/validation.ts +194 -442
  115. package/ts/src/contracts.ts +1 -1
  116. package/ts/src/index.ts +489 -171
  117. package/ts/src/loop.ts +1 -1
  118. package/ts/src/state-ledger.ts +1 -1
  119. package/ts/src/turn-deadline.ts +361 -0
  120. package/ts/src/turn-policy.ts +154 -0
  121. package/ts/src/wish-pool.ts +17 -4
  122. package/dist/scripts/agent-planning-budget-e2e.js +0 -227
  123. package/dist/scripts/agent-planning-budget-tests.js +0 -173
  124. package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
  125. package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
  126. package/dist/src/booking-surface/contracts-v2.js +0 -89
  127. package/dist/src/booking-surface/runtime-v2.js +0 -1771
  128. package/dist/src/booking-surface/server-v2.js +0 -334
  129. package/dist/src/booking-surface/validation-v2.js +0 -319
  130. package/dist/src/tool-budget.js +0 -136
  131. package/schemas/booking.surface.v1.schema.json +0 -927
  132. package/schemas/booking.surface.v2.schema.json +0 -61
  133. package/ts/src/booking-surface/contracts-v2.ts +0 -118
  134. package/ts/src/booking-surface/runtime-v2.ts +0 -1466
  135. package/ts/src/booking-surface/server-v2.ts +0 -247
  136. package/ts/src/booking-surface/validation-v2.ts +0 -205
  137. 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()
@@ -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[]
@@ -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
+ }
@@ -0,0 +1,293 @@
1
+ /**
2
+ * 12306 火车站点适配器(2026-09-03,继 ctrip-hotel 后会话面第三通道):
3
+ * - entry:https://kyfw.12306.cn/otn/leftTicket/init?linktypeid=dc&fs=<城市名,电报码>&ts=<...>&date=YYYY-MM-DD&flag=N,N,Y
4
+ * (电报码表官方站表全量校准 2026-09-03,129 城;曾纠出南宁 NIZ→NNZ 错码)
5
+ * - networkHints:kyfw leftTicket/query(负载均衡变体 queryG/queryZ/queryA 等全部命中)
6
+ * - 解析:leftTicket/query 返回 data.result[] 管道行(12306 经典 | 拼接格式)。
7
+ * **第一方校准(2026-09-03)**:字段索引与站名映射(data.map[电报码])逐一
8
+ * 取自官方前端 queryLeftTicket_end_js.js 的 cN(result,map) 转换函数——旧版
9
+ * 公开常识索引与官方现行映射不符(站名/座位桶全错),真会话前即纠;行级
10
+ * 签名校验(车次码/时刻形态)继续兜住未来漂移:失配整行跳过,fail-visible
11
+ *
12
+ * 与机/酒通道的两点刻意差异:
13
+ * - 无登录闸:12306 余票查询是公开面(登录只关系下单),无账号数据过手;
14
+ * 扩展照样只读被动嗅探(零写行为),证据链标注「公开查询面」
15
+ * - 列表接口不含票价:如实只呈现 车次/时刻/历时/余票,票价以 12306 落地页为准
16
+ */
17
+
18
+ /** 城市电报码表(**官方口径校准 2026-09-03**:全部条目逐一核对自
19
+ * kyfw.12306.cn/otn/resources/js/framework/station_name.js 官方全量站表
20
+ * ——曾借此抓出南宁 NIZ→NNZ 错码;代表站规则=城市同名站,fs/ts 用城市码
21
+ * 服务端扩到市内全部站。词表外 unresolved 逐字保留,调用方可显式传电报码
22
+ * 覆盖——kyfw 查询页 URL 里的 fs=城市,XXX 三位码;核对快照见
23
+ * data/stations-12306-verify.json) */
24
+ export const STATION_TELECODES: Record<string, string> = {
25
+ "三亚": 'SEQ',
26
+ "上海": 'SHH',
27
+ "上饶": 'SRG',
28
+ "东莞": 'RTQ',
29
+ "中卫": 'ZWJ',
30
+ "中山": 'ZSQ',
31
+ "丽江": 'LHM',
32
+ "乌鲁木齐": 'WAR',
33
+ "乐山": 'IVW',
34
+ "九江": 'JJG',
35
+ "伊宁": 'YMR',
36
+ "佛山": 'FSQ',
37
+ "保山": 'BAM',
38
+ "六盘水": 'UMW',
39
+ "兰州": 'LZJ',
40
+ "包头": 'BTC',
41
+ "北京": 'BJP',
42
+ "北海": 'BHZ',
43
+ "十堰": 'SNN',
44
+ "南京": 'NJH',
45
+ "南充": 'NCW',
46
+ "南宁": 'NNZ',
47
+ "南昌": 'NCG',
48
+ "南通": 'NUH',
49
+ "南阳": 'NFF',
50
+ "厦门": 'XMS',
51
+ "台州": 'TEU',
52
+ "合肥": 'HFH',
53
+ "吉林": 'JLL',
54
+ "吐鲁番": 'TFR',
55
+ "呼和浩特": 'HHC',
56
+ "咸阳": 'XYY',
57
+ "哈密": 'HMR',
58
+ "哈尔滨": 'HBB',
59
+ "喀什": 'KSR',
60
+ "嘉兴": 'JXH',
61
+ "嘉峪关": 'JGJ',
62
+ "大同": 'DTV',
63
+ "大庆": 'DZX',
64
+ "大理": 'DKM',
65
+ "大连": 'DLT',
66
+ "天水": 'TSJ',
67
+ "天津": 'TJP',
68
+ "太原": 'TYV',
69
+ "宁波": 'NGH',
70
+ "安顺": 'ASW',
71
+ "宜宾": 'YKE',
72
+ "宜昌": 'YCN',
73
+ "宝鸡": 'BJY',
74
+ "岳阳": 'YYQ',
75
+ "常州": 'CZH',
76
+ "常德": 'VGQ',
77
+ "广州": 'GZQ',
78
+ "库尔勒": 'KLR',
79
+ "延安": 'YWY',
80
+ "开封": 'KFF',
81
+ "张掖": 'ZYJ',
82
+ "徐州": 'XCH',
83
+ "德阳": 'DYW',
84
+ "惠州": 'HCQ',
85
+ "成都": 'CDW',
86
+ "扬州": 'YLH',
87
+ "承德": 'CDP',
88
+ "抚顺": 'FET',
89
+ "拉萨": 'LSO',
90
+ "揭阳": 'JYA',
91
+ "新乡": 'XXF',
92
+ "无锡": 'WGH',
93
+ "日喀则": 'RKO',
94
+ "昆明": 'KMM',
95
+ "普洱": 'PEM',
96
+ "景德镇": 'JCG',
97
+ "曲靖": 'QJM',
98
+ "杭州": 'HZH',
99
+ "林芝": 'LZO',
100
+ "柳州": 'LZZ',
101
+ "株洲": 'ZZQ',
102
+ "格尔木": 'GRO',
103
+ "桂林": 'GLZ',
104
+ "梧州": 'WZZ',
105
+ "武汉": 'WHN',
106
+ "汉中": 'HOY',
107
+ "汕头": 'OTQ',
108
+ "沈阳": 'SYT',
109
+ "泉州": 'QYS',
110
+ "泸州": 'LUE',
111
+ "洛阳": 'LYF',
112
+ "济南": 'JNK',
113
+ "深圳": 'SZQ',
114
+ "温州": 'RZH',
115
+ "湛江": 'ZJZ',
116
+ "漳州": 'ZUS',
117
+ "潮州": 'CKQ',
118
+ "牡丹江": 'MDB',
119
+ "珠海": 'ZHQ',
120
+ "盐城": 'AFH',
121
+ "石家庄": 'SJP',
122
+ "福州": 'FZS',
123
+ "秦皇岛": 'QTP',
124
+ "绍兴": 'SOH',
125
+ "绵阳": 'MYW',
126
+ "芜湖": 'WHH',
127
+ "苏州": 'SZH',
128
+ "茂名": 'MDQ',
129
+ "莆田": 'PTS',
130
+ "衡阳": 'HYQ',
131
+ "襄阳": 'XFN',
132
+ "西宁": 'XNO',
133
+ "西安": 'XAY',
134
+ "西昌": 'ECW',
135
+ "贵阳": 'GIW',
136
+ "赣州": 'GZG',
137
+ "达州": 'RXW',
138
+ "遵义": 'ZYE',
139
+ "郑州": 'ZZF',
140
+ "重庆": 'CUW',
141
+ "金华": 'JBH',
142
+ "银川": 'YIJ',
143
+ "镇江": 'ZJH',
144
+ "长春": 'CCT',
145
+ "长沙": 'CSQ',
146
+ "阜阳": 'FYH',
147
+ "青岛": 'QDK',
148
+ "鞍山": 'AST',
149
+ "韶关": 'SNQ',
150
+ "香格里拉": 'EUM',
151
+ "香港": 'XJA',
152
+ "齐齐哈尔": 'QHX',
153
+ "龙岩": 'LYS',
154
+ }
155
+
156
+ export interface TrainEntryQuery {
157
+ from: string
158
+ to: string
159
+ /** YYYY-MM-DD */
160
+ date: string
161
+ /** 显式城市电报码(覆盖码表;kyfw 查询页 URL fs=城市,XXX 的三位码) */
162
+ fromStationTelecode?: string
163
+ toStationTelecode?: string
164
+ }
165
+
166
+ export interface AdapterEntry {
167
+ ok: boolean
168
+ url?: string
169
+ unresolved?: string[]
170
+ }
171
+
172
+ export function buildTrainEntryUrl(q: TrainEntryQuery): AdapterEntry {
173
+ const unresolved: string[] = []
174
+ const fromTc = (q.fromStationTelecode ?? '').trim().toUpperCase() || STATION_TELECODES[q.from.trim()]
175
+ const toTc = (q.toStationTelecode ?? '').trim().toUpperCase() || STATION_TELECODES[q.to.trim()]
176
+ if (!fromTc) unresolved.push(q.from)
177
+ if (!toTc) unresolved.push(q.to)
178
+ if (unresolved.length > 0 || !/^\d{4}-\d{2}-\d{2}$/.test(q.date)) {
179
+ return { ok: false, unresolved: unresolved.length > 0 ? unresolved : [q.date] }
180
+ }
181
+ const fs = encodeURIComponent(`${q.from.trim()},${fromTc}`)
182
+ const ts = encodeURIComponent(`${q.to.trim()},${toTc}`)
183
+ return { ok: true, url: `https://kyfw.12306.cn/otn/leftTicket/init?linktypeid=dc&fs=${fs}&ts=${ts}&date=${q.date}&flag=N,N,Y` }
184
+ }
185
+
186
+ /** networkHints:余票查询 XHR(queryG/Z/A/U 等负载均衡变体全命中) */
187
+ export const TRAIN_NETWORK_HINTS = [/leftTicket\/query/i]
188
+
189
+ /** 12306 页域(content_scripts 注入面与 background 白名单的对账源) */
190
+ export const TRAIN_SITE_HOST = 'kyfw.12306.cn'
191
+
192
+ export interface SessionTrainOption {
193
+ /** 车次(G1375 / K79 等) */
194
+ trainCode: string
195
+ fromStation: string
196
+ toStation: string
197
+ /** 出发/到达时刻(HH:MM;跨日由到达时刻本身表达,官方 DTO 无隔日字段) */
198
+ depTime: string
199
+ arrTime: string
200
+ /** 历时(分钟) */
201
+ durationMin: number
202
+ /** Y=可预订 / N=不可 / 其他上游原话 */
203
+ canWebBuy: string
204
+ /** 余票分桶(第一方校准索引,见 SEAT_BUCKETS;值原样:数字 / 有 / 无 / --) */
205
+ seats: Record<string, string>
206
+ /** 查询落地页(由人选车完成预订;gotry 不碰) */
207
+ jumpUrl?: string
208
+ }
209
+
210
+ /** 座位桶索引表(**第一方校准 2026-09-03**:逐字段取自 12306 官方前端
211
+ * queryLeftTicket_end_js.js 的 cN(result,map) 转换函数——旧版公开常识索引
212
+ * (19-28 段)与官方现行映射不符,首查前即纠);label=页面席别口径,未知席别
213
+ * 保留上游字段 id 原样 */
214
+ const SEAT_BUCKETS: Array<{ index: number; field: string; label: string }> = [
215
+ { index: 20, field: 'gg_num', label: '其他(通勤)' },
216
+ { index: 21, field: 'gr_num', label: '高级软卧' },
217
+ { index: 22, field: 'qt_num', label: '其他' },
218
+ { index: 23, field: 'rw_num', label: '软卧(一等卧)' },
219
+ { index: 24, field: 'rz_num', label: '软座' },
220
+ { index: 25, field: 'tz_num', label: '特等座' },
221
+ { index: 26, field: 'wz_num', label: '无座' },
222
+ { index: 27, field: 'yb_num', label: 'yp_b(上游席别)' },
223
+ { index: 28, field: 'yw_num', label: '硬卧(二等卧)' },
224
+ { index: 29, field: 'yz_num', label: '硬座' },
225
+ { index: 30, field: 'ze_num', label: '二等座' },
226
+ { index: 31, field: 'zy_num', label: '一等座' },
227
+ { index: 32, field: 'swz_num', label: '商务座' },
228
+ { index: 33, field: 'srrb_num', label: '动卧' },
229
+ ]
230
+
231
+ const TRAIN_CODE_RE = /^[GDCZTKYLSF]\d{1,5}[A-Z]?$/i
232
+ const HHMM_RE = /^\d{2}:\d{2}$/
233
+
234
+ /** 历时 "07:52" → 分钟 */
235
+ function lishiToMin(s: string | undefined): number {
236
+ const m = /^(\d{1,3}):(\d{2})$/.exec((s ?? '').trim())
237
+ if (!m) return 0
238
+ return Number(m[1]) * 60 + Number(m[2])
239
+ }
240
+
241
+ /** 行级签名:车次码形态 + 时刻形态不符即整行跳过(索引漂移 fail-visible,不造数) */
242
+ function rowToOption(row: string, stationMap: Record<string, string> | undefined, entryUrl: string): SessionTrainOption | null {
243
+ const f = row.split('|')
244
+ if (f.length < 34) return null
245
+ const trainCode = (f[3] ?? '').trim()
246
+ const depTime = (f[8] ?? '').trim()
247
+ const arrTime = (f[9] ?? '').trim()
248
+ if (!TRAIN_CODE_RE.test(trainCode) || !HHMM_RE.test(depTime) || !HHMM_RE.test(arrTime)) return null
249
+ // 官方 cN 口径:站名 = data.map[电报码](map 缺失/缺键时退电报码原样,不猜名)
250
+ const fromStation = stationMap?.[f[6] ?? ''] ?? (f[6] ?? '')
251
+ const toStation = stationMap?.[f[7] ?? ''] ?? (f[7] ?? '')
252
+ const seats: Record<string, string> = {}
253
+ for (const b of SEAT_BUCKETS) {
254
+ const v = (f[b.index] ?? '').trim()
255
+ if (v && v !== '--') seats[b.label] = v
256
+ }
257
+ return {
258
+ trainCode,
259
+ fromStation,
260
+ toStation,
261
+ depTime,
262
+ arrTime,
263
+ durationMin: lishiToMin(f[10]),
264
+ canWebBuy: (f[11] ?? '').trim(),
265
+ seats,
266
+ jumpUrl: entryUrl,
267
+ }
268
+ }
269
+
270
+ /** 解析 leftTicket/query 响应(纯函数,fixture 测试锚点;字段索引与站名映射
271
+ * 全部对齐 12306 官方前端 cN(result,map)——见 SEAT_BUCKETS 注释);
272
+ * malformed 一律返空,不抛错 */
273
+ export function parseLeftTicketQuery(body: string, entryUrl: string, opts: { maxItems?: number } = {}): SessionTrainOption[] {
274
+ let raw: { data?: { result?: unknown; map?: Record<string, string> } }
275
+ try {
276
+ raw = JSON.parse(body) as typeof raw
277
+ } catch {
278
+ return []
279
+ }
280
+ const rows = raw.data?.result
281
+ if (!Array.isArray(rows)) return []
282
+ const map = (raw.data?.map && typeof raw.data.map === 'object') ? raw.data.map : undefined
283
+ const out: SessionTrainOption[] = []
284
+ for (const r of rows) {
285
+ if (typeof r !== 'string') continue
286
+ const opt = rowToOption(r, map, entryUrl)
287
+ if (opt) {
288
+ out.push(opt)
289
+ if (out.length >= (opts.maxItems ?? 30)) break
290
+ }
291
+ }
292
+ return out
293
+ }