@danceiny/gotry 0.0.1-rc.21 → 0.0.1-rc.24

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 (327) hide show
  1. package/README.md +102 -194
  2. package/README.zh-CN.md +115 -210
  3. package/bin/gotry-backend.js +53 -0
  4. package/bin/gotry-bootstrap.js +466 -35
  5. package/bin/gotry-inner.js +255 -36
  6. package/bin/gotry-process-liveness.d.ts +27 -0
  7. package/bin/gotry-process-liveness.js +97 -0
  8. package/bin/gotry-runtime-resolution.d.ts +1 -1
  9. package/bin/gotry-runtime-resolution.js +2 -2
  10. package/client/client.js +174 -0
  11. package/cordis.gotry-patch.yml +70 -93
  12. package/dist/capabilities/agent-reach-deep.js +45 -122
  13. package/dist/capabilities/agent-reach.js +63 -148
  14. package/dist/capabilities/anything.js +89 -75
  15. package/dist/capabilities/artifacts.js +123 -104
  16. package/dist/capabilities/channel-health.js +65 -45
  17. package/dist/capabilities/channel-registry.js +108 -87
  18. package/dist/capabilities/doctor.js +357 -235
  19. package/dist/capabilities/effect.js +223 -309
  20. package/dist/capabilities/fact-log.js +28 -14
  21. package/dist/capabilities/flyai.js +245 -156
  22. package/dist/capabilities/ground-transfer.js +509 -0
  23. package/dist/capabilities/hbcli.js +144 -148
  24. package/dist/capabilities/hotelbyte-transaction.js +194 -0
  25. package/dist/capabilities/incident-log.js +131 -61
  26. package/dist/capabilities/model-override.js +30 -10
  27. package/dist/capabilities/opensky.js +54 -43
  28. package/dist/capabilities/resilience.js +50 -32
  29. package/dist/capabilities/session/action-cache.js +50 -44
  30. package/dist/capabilities/session/adapters/ctrip-flight.js +119 -62
  31. package/dist/capabilities/session/adapters/ctrip-hotel.js +125 -110
  32. package/dist/capabilities/session/adapters/dida-portal.js +206 -0
  33. package/dist/capabilities/session/adapters/meituan-local.js +35 -54
  34. package/dist/capabilities/session/adapters/rail-12306.js +169 -112
  35. package/dist/capabilities/session/benchmark.js +85 -149
  36. package/dist/capabilities/session/extension-bridge.js +320 -259
  37. package/dist/capabilities/session/extension-channel.js +57 -76
  38. package/dist/capabilities/session/extension-distribution.js +115 -119
  39. package/dist/capabilities/session/extract.js +31 -21
  40. package/dist/capabilities/session/golden-score.js +55 -33
  41. package/dist/capabilities/session/health-watch.js +146 -154
  42. package/dist/capabilities/session/read-guard.js +55 -47
  43. package/dist/capabilities/session/static-flight-golden.js +49 -36
  44. package/dist/capabilities/session/transport.js +54 -61
  45. package/dist/capabilities/session/wizard.js +63 -93
  46. package/dist/capabilities/session-consent.js +143 -47
  47. package/dist/capabilities/session-login.js +95 -126
  48. package/dist/capabilities/session-search.js +391 -253
  49. package/dist/capabilities/sponsor-plugin.js +341 -0
  50. package/dist/capabilities/visa-policy.js +87 -73
  51. package/dist/capabilities/weather.js +163 -235
  52. package/dist/scripts/action-cache-tests.js +24 -59
  53. package/dist/scripts/agent-planning-turn-deadline-e2e.js +91 -142
  54. package/dist/scripts/agent-planning-turn-deadline-tests.js +80 -176
  55. package/dist/scripts/agent-reach-deep-tests.js +30 -40
  56. package/dist/scripts/agent-reach-tests.js +26 -18
  57. package/dist/scripts/agent-reach-wrapper-tests.js +46 -50
  58. package/dist/scripts/anything-tests.js +58 -62
  59. package/dist/scripts/artifact-client-contract-tests.js +69 -0
  60. package/dist/scripts/artifacts-capability-tests.js +235 -0
  61. package/dist/scripts/async-collect.js +26 -15
  62. package/dist/scripts/benchmark-environment-bridge-e2e.js +287 -684
  63. package/dist/scripts/benchmark-environment-bridge-tests.js +818 -3123
  64. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +66 -279
  65. package/dist/scripts/booking-copilot-availability-policy-tests.js +153 -1257
  66. package/dist/scripts/booking-copilot-bin-proof-tests.js +22 -45
  67. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +64 -76
  68. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +69 -199
  69. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +393 -759
  70. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +33 -75
  71. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +60 -110
  72. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +28 -66
  73. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +72 -271
  74. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +73 -275
  75. package/dist/scripts/booking-copilot-runtime-proof-tests.js +784 -3398
  76. package/dist/scripts/booking-copilot-server-proof-tests.js +63 -168
  77. package/dist/scripts/booking-copilot-startup-proof-tests.js +38 -124
  78. package/dist/scripts/booking-executor-tests.js +80 -0
  79. package/dist/scripts/booking-recovery-chain-tests.js +182 -0
  80. package/dist/scripts/booking-saga-tests.js +61 -123
  81. package/dist/scripts/booking-surface-contract-proof-tests.js +152 -1167
  82. package/dist/scripts/bootstrap-tests.js +1383 -128
  83. package/dist/scripts/build-changelog.js +89 -130
  84. package/dist/scripts/build-metrics-report.js +135 -143
  85. package/dist/scripts/changelog-tests.js +35 -82
  86. package/dist/scripts/channel-probe-tests.js +44 -116
  87. package/dist/scripts/channel-probe.js +84 -167
  88. package/dist/scripts/channel-registry-tests.js +97 -185
  89. package/dist/scripts/companion-tests.js +25 -70
  90. package/dist/scripts/dida-cookie-probe.js +87 -0
  91. package/dist/scripts/dida-sw-probe.js +114 -0
  92. package/dist/scripts/diff-test.js +17 -12
  93. package/dist/scripts/doctor-tests.js +428 -120
  94. package/dist/scripts/dsh-artifact-e2e.js +150 -0
  95. package/dist/scripts/dsh-artifact-web-e2e.js +443 -0
  96. package/dist/scripts/dsh-http-proxy-sse-proof.js +90 -0
  97. package/dist/scripts/dsh-runtime-closure-tests.js +110 -153
  98. package/dist/scripts/dsh-runtime-closure.js +75 -63
  99. package/dist/scripts/dsh-session-v3-migration-proof.js +106 -0
  100. package/dist/scripts/dsh-subprocess-local-proof.js +149 -0
  101. package/dist/scripts/dsh-target-closure-proof.js +178 -0
  102. package/dist/scripts/effect-tests.js +165 -552
  103. package/dist/scripts/engine-run.js +1 -3
  104. package/dist/scripts/engine-tests.js +16 -10
  105. package/dist/scripts/evaluation-cadence-tests.js +69 -242
  106. package/dist/scripts/evaluation-contract-tests.js +193 -499
  107. package/dist/scripts/extension-distribution-cli.js +19 -15
  108. package/dist/scripts/extension-distribution-tests.js +98 -211
  109. package/dist/scripts/extension-tests.js +366 -389
  110. package/dist/scripts/fact-gate-tests.js +725 -263
  111. package/dist/scripts/fixtures/extension-bridge-unref-child.mjs +21 -0
  112. package/dist/scripts/flight-hotel-anchor-363-registered-e2e.js +169 -0
  113. package/dist/scripts/flight-malformed-tests.js +159 -0
  114. package/dist/scripts/flyai-tests.js +325 -68
  115. package/dist/scripts/g5-guard-tests.js +131 -0
  116. package/dist/scripts/g5-guard.js +189 -0
  117. package/dist/scripts/gen-lunar-anchors.js +106 -0
  118. package/dist/scripts/gotry-backend-kernel-tests.js +79 -0
  119. package/dist/scripts/ground-transfer-tests.js +956 -0
  120. package/dist/scripts/hbcli-e2e-tests.js +71 -90
  121. package/dist/scripts/hbcli-live-optin-tests.js +96 -0
  122. package/dist/scripts/hbcli-release-contract-tests.js +122 -0
  123. package/dist/scripts/hbcli-tests.js +41 -115
  124. package/dist/scripts/health-watch-cli.js +42 -28
  125. package/dist/scripts/hotel-date-gate-tests.js +496 -0
  126. package/dist/scripts/hotelbyte-reconcile-tests.js +141 -0
  127. package/dist/scripts/hotelbyte-spawn-e2e-tests.js +373 -0
  128. package/dist/scripts/i18n-tests.js +16 -9
  129. package/dist/scripts/incident-tests.js +281 -87
  130. package/dist/scripts/issue-194-job-id-guard-tests.js +106 -0
  131. package/dist/scripts/issue-233-cancel-refund-commission-tests.js +392 -0
  132. package/dist/scripts/issue-271-liveness-tests.js +547 -0
  133. package/dist/scripts/issue-289-model-retry-real-tests.js +296 -0
  134. package/dist/scripts/issue-289-model-retry-tests.js +441 -0
  135. package/dist/scripts/issue-289-web-retry-e2e.js +630 -0
  136. package/dist/scripts/issue-338-home-city-e2e.js +577 -0
  137. package/dist/scripts/issue343-real-entry-e2e.js +160 -0
  138. package/dist/scripts/journey-tests.js +22 -27
  139. package/dist/scripts/kernel-manifest-evidence.js +109 -0
  140. package/dist/scripts/kernel-manifest-gate.js +126 -0
  141. package/dist/scripts/kernel-manifest-tests.js +212 -0
  142. package/dist/scripts/kernel-manifest-trace-entry.js +7 -0
  143. package/dist/scripts/kernel-manifest-trace-hook.mjs +28 -0
  144. package/dist/scripts/kernel-manifest.js +369 -0
  145. package/dist/scripts/ledger-repair-apply-tests.js +316 -0
  146. package/dist/scripts/ledger-repair-plan-tests.js +375 -0
  147. package/dist/scripts/ledger-tests.js +325 -340
  148. package/dist/scripts/ledger-workflow-crash.js +10 -20
  149. package/dist/scripts/map-tools-vendor-package-proof.js +206 -134
  150. package/dist/scripts/memory-capture-tests.js +242 -61
  151. package/dist/scripts/memory-decay-tests.js +18 -52
  152. package/dist/scripts/memory-lifecycle-tests.js +657 -0
  153. package/dist/scripts/memory-lifecycle.js +196 -0
  154. package/dist/scripts/memory-metrics.js +24 -12
  155. package/dist/scripts/memory-value-report.js +474 -222
  156. package/dist/scripts/memory-value-tests.js +328 -0
  157. package/dist/scripts/metrics-report-tests.js +62 -199
  158. package/dist/scripts/model-override-e2e.js +66 -105
  159. package/dist/scripts/nightly-evidence-tests.js +64 -89
  160. package/dist/scripts/nightly-evidence.js +108 -111
  161. package/dist/scripts/nudge-digest.js +39 -28
  162. package/dist/scripts/onboarding-tests.js +63 -88
  163. package/dist/scripts/opensky-check.js +18 -13
  164. package/dist/scripts/opensky-tests.js +28 -28
  165. package/dist/scripts/persona-surface-guard-tests.js +37 -12
  166. package/dist/scripts/pnpm-dsh-closure-proof.js +2 -5
  167. package/dist/scripts/policy-anchor-359-registered-e2e.js +214 -0
  168. package/dist/scripts/price-drift-tests.js +126 -227
  169. package/dist/scripts/price-drift-watch.js +209 -253
  170. package/dist/scripts/probe-poi-tests.js +52 -45
  171. package/dist/scripts/product-metrics.js +187 -273
  172. package/dist/scripts/publish-preverify.js +47 -41
  173. package/dist/scripts/realtime-pricing-tests.js +37 -76
  174. package/dist/scripts/replay-async.js +20 -18
  175. package/dist/scripts/replay-real.js +16 -18
  176. package/dist/scripts/replay.js +52 -82
  177. package/dist/scripts/session-attach-diagnose.js +16 -12
  178. package/dist/scripts/session-attach-poc.js +34 -37
  179. package/dist/scripts/session-benchmark.js +66 -147
  180. package/dist/scripts/session-dida-live-e2e.js +151 -0
  181. package/dist/scripts/session-extract-tests.js +14 -35
  182. package/dist/scripts/session-login.js +14 -13
  183. package/dist/scripts/session-tests.js +545 -583
  184. package/dist/scripts/sf-live-benchmark.js +92 -110
  185. package/dist/scripts/sf-live-cli-tests.js +2 -9
  186. package/dist/scripts/sf-soft-score-tests.js +19 -60
  187. package/dist/scripts/sf-summary-tests.js +149 -0
  188. package/dist/scripts/sf-summary.js +466 -84
  189. package/dist/scripts/skeleton-check.js +16 -14
  190. package/dist/scripts/skeleton-integration-test.js +11 -10
  191. package/dist/scripts/skills-contract-tests.js +51 -46
  192. package/dist/scripts/smoke-session-gate-tests.js +12 -20
  193. package/dist/scripts/smoke.js +541 -422
  194. package/dist/scripts/sponsor-reuse-tests.js +262 -0
  195. package/dist/scripts/state-cli-tests.js +226 -101
  196. package/dist/scripts/state-cli.js +549 -212
  197. package/dist/scripts/static-golden-tests.js +46 -180
  198. package/dist/scripts/time-eval-tests.js +210 -192
  199. package/dist/scripts/travel-timeline-tests.js +29 -93
  200. package/dist/scripts/turn-handoff-collect-tests.js +46 -93
  201. package/dist/scripts/turn-handoff-collect.js +71 -99
  202. package/dist/scripts/turn-policy-tests.js +28 -29
  203. package/dist/scripts/typed-contract-canary.js +77 -162
  204. package/dist/scripts/tz-resolver-sanity.js +50 -0
  205. package/dist/scripts/tz-resolver-tests.js +584 -0
  206. package/dist/scripts/unified-tests.js +23 -19
  207. package/dist/scripts/visa-policy-tests.js +31 -44
  208. package/dist/scripts/weather-tests.js +121 -543
  209. package/dist/scripts/wish-channel-gate-tests.js +29 -86
  210. package/dist/scripts/write-gate-tests.js +522 -0
  211. package/dist/scripts/z3-lifecycle-fault-tests.js +188 -0
  212. package/dist/scripts/z3-lifecycle-tests.js +170 -0
  213. package/dist/scripts/z3-race-repeat-tests.js +39 -0
  214. package/dist/scripts/z3-race-tests.js +18 -12
  215. package/dist/src/artifact-gate.js +652 -221
  216. package/dist/src/backend/kernel.js +91 -0
  217. package/dist/src/backend/modules/booking-copilot.js +55 -0
  218. package/dist/src/backend/modules/booking-executor.js +124 -0
  219. package/dist/src/backend/modules/session-search.js +194 -0
  220. package/dist/src/benchmark-agent-conformance.js +296 -262
  221. package/dist/src/benchmark-environment-bridge.js +298 -413
  222. package/dist/src/benchmark-headless-child-diagnostics.js +93 -100
  223. package/dist/src/benchmark-tool-isolation.js +94 -67
  224. package/dist/src/bookable-facts.js +299 -174
  225. package/dist/src/booking-saga.js +64 -120
  226. package/dist/src/booking-surface/availability-policy.js +443 -576
  227. package/dist/src/booking-surface/cancel-refund-commission.js +379 -0
  228. package/dist/src/booking-surface/contracts.js +24 -80
  229. package/dist/src/booking-surface/dsh-planner.js +510 -281
  230. package/dist/src/booking-surface/dsh-plugin.js +17 -1
  231. package/dist/src/booking-surface/error-codes.js +82 -10
  232. package/dist/src/booking-surface/index.js +0 -3
  233. package/dist/src/booking-surface/profile.js +10 -54
  234. package/dist/src/booking-surface/recovery-chain.js +367 -0
  235. package/dist/src/booking-surface/runtime.js +765 -891
  236. package/dist/src/booking-surface/server.js +200 -251
  237. package/dist/src/booking-surface/startup.js +58 -54
  238. package/dist/src/booking-surface/validation.js +150 -218
  239. package/dist/src/bridge.js +20 -13
  240. package/dist/src/companions.js +48 -74
  241. package/dist/src/contracts.js +21 -43
  242. package/dist/src/dsh-llm.js +82 -120
  243. package/dist/src/engine.js +253 -239
  244. package/dist/src/evaluation-cadence.js +72 -209
  245. package/dist/src/evaluation-contracts.js +220 -843
  246. package/dist/src/flight-pack-adapter.js +54 -0
  247. package/dist/src/flight-pack-contract.js +2 -0
  248. package/dist/src/gotry-backend.js +70 -0
  249. package/dist/src/hotel-date-gate.js +118 -0
  250. package/dist/src/i18n.js +18 -7
  251. package/dist/src/index.js +1425 -1629
  252. package/dist/src/journey.js +132 -81
  253. package/dist/src/ledger-repair-apply.js +538 -0
  254. package/dist/src/ledger-repair-plan.js +463 -0
  255. package/dist/src/loop.js +257 -166
  256. package/dist/src/memory-capture.js +180 -32
  257. package/dist/src/memory-decay.js +30 -14
  258. package/dist/src/memory-lifecycle.js +1310 -0
  259. package/dist/src/memory-utility.js +27 -40
  260. package/dist/src/mock-llm.js +36 -61
  261. package/dist/src/model.js +81 -36
  262. package/dist/src/realtime-pricing.js +63 -72
  263. package/dist/src/slot-spec.js +84 -106
  264. package/dist/src/state-ledger.js +310 -471
  265. package/dist/src/subagent-job-id-guard.js +58 -0
  266. package/dist/src/time-anchor.js +231 -33
  267. package/dist/src/tool-packet.js +20 -8
  268. package/dist/src/travel-slots.js +58 -35
  269. package/dist/src/travel-timeline.js +44 -63
  270. package/dist/src/turn-deadline.js +121 -127
  271. package/dist/src/turn-policy.js +61 -89
  272. package/dist/src/tz-resolver.js +143 -0
  273. package/dist/src/unified.js +504 -381
  274. package/dist/src/wish-pool.js +39 -14
  275. package/dist/src/write-gate.js +495 -0
  276. package/dist/src/z3-shared.js +273 -7
  277. package/extension/README.md +4 -3
  278. package/extension/background.js +125 -14
  279. package/extension/content-bridge.js +23 -1
  280. package/extension/content-main.js +15 -4
  281. package/extension/manifest.json +16 -8
  282. package/package.json +251 -220
  283. package/ts/capabilities/agent-reach.ts +4 -4
  284. package/ts/capabilities/anything.ts +1 -1
  285. package/ts/capabilities/artifacts.ts +46 -9
  286. package/ts/capabilities/flyai.ts +156 -37
  287. package/ts/capabilities/hbcli.ts +1 -1
  288. package/ts/capabilities/incident-log.ts +88 -59
  289. package/ts/capabilities/session/adapters/ctrip-flight.ts +99 -36
  290. package/ts/capabilities/session/adapters/dida-portal.ts +250 -0
  291. package/ts/capabilities/session/adapters/rail-12306.ts +123 -24
  292. package/ts/capabilities/session/extension-bridge.ts +226 -101
  293. package/ts/capabilities/session/extension-channel.ts +22 -9
  294. package/ts/capabilities/session/wizard.ts +2 -2
  295. package/ts/capabilities/session-consent.ts +98 -14
  296. package/ts/capabilities/session-login.ts +1 -0
  297. package/ts/capabilities/session-search.ts +315 -31
  298. package/ts/dsh-runtime/vendor/README.md +8 -4
  299. package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +7 -1
  300. package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +5 -3
  301. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +1 -1
  302. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +1 -1
  303. package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +6 -6
  304. package/ts/package.json +236 -16
  305. package/ts/scripts/map-tools-vendor-package-proof.ts +152 -31
  306. package/ts/scripts/state-cli.ts +418 -51
  307. package/ts/src/artifact-gate.ts +561 -15
  308. package/ts/src/benchmark-environment-bridge.ts +37 -20
  309. package/ts/src/bookable-facts.ts +183 -7
  310. package/ts/src/booking-surface/cancel-refund-commission.ts +488 -0
  311. package/ts/src/booking-surface/dsh-planner.ts +240 -32
  312. package/ts/src/booking-surface/dsh-plugin.js +17 -1
  313. package/ts/src/booking-surface/error-codes.ts +75 -0
  314. package/ts/src/booking-surface/recovery-chain.ts +409 -0
  315. package/ts/src/booking-surface/server.ts +84 -51
  316. package/ts/src/booking-surface/validation.ts +2 -0
  317. package/ts/src/contracts.ts +12 -2
  318. package/ts/src/dsh-llm.ts +4 -8
  319. package/ts/src/index.ts +609 -90
  320. package/ts/src/loop.ts +116 -7
  321. package/ts/src/memory-capture.ts +227 -3
  322. package/ts/src/mock-llm.ts +4 -8
  323. package/ts/src/model.ts +88 -2
  324. package/ts/src/state-ledger.ts +47 -29
  325. package/ts/src/subagent-job-id-guard.ts +72 -0
  326. package/ts/src/time-anchor.ts +217 -13
  327. package/ts/src/unified.ts +316 -54
@@ -56,52 +56,115 @@ export interface SessionFlightOption {
56
56
  aircraft?: string
57
57
  }
58
58
 
59
- interface RawBatch {
60
- status?: number
61
- data?: {
62
- flightItineraryList?: Array<{
63
- flightSegments?: Array<{
64
- airlineName?: string
65
- duration?: number
66
- flightList?: Array<{
67
- flightNo?: string
68
- departureAirportName?: string
69
- arrivalAirportName?: string
70
- departureDateTime?: string
71
- arrivalDateTime?: string
72
- aircraftName?: string
73
- }>
74
- }>
75
- priceList?: Array<{ adultPrice?: number }>
76
- }>
77
- }
59
+ /** 解析结果的三态判语(issue #279):空响应不再一律 miss——已识别合法空 = miss,
60
+ * 形状未识别(malformed/unknown)= error,选项命中 = hit。误把任意 [] 判 miss 会把未知
61
+ * 响应错误收敛为「这条线路没有航班」,这是上游接口改版/协议变动期的常见陷阱 */
62
+ export type BatchSearchVerdict = 'hit' | 'miss' | 'error'
63
+
64
+ export interface BatchSearchResult {
65
+ options: SessionFlightOption[]
66
+ verdict: BatchSearchVerdict
67
+ }
68
+
69
+ /** 安全取字符串字段(任意来源字段都可能是 null/undefined/数字/对象) */
70
+ function asString(v: unknown): string {
71
+ return typeof v === 'string' ? v : ''
72
+ }
73
+
74
+ /** 安全取正数(无法解析按 0 返,绝不伪造价格) */
75
+ function asPositiveNumber(v: unknown): number {
76
+ return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0
78
77
  }
79
78
 
80
79
  /** 解析 batchSearch 响应(纯函数,fixture 测试锚点);malformed 一律返空,不抛错 */
81
80
  export function parseBatchSearch(body: string): SessionFlightOption[] {
82
- let raw: RawBatch
81
+ return parseBatchSearchResult(body).options
82
+ }
83
+
84
+ /**
85
+ * 结构化解析(issue #279):返回选项 + 三态判语。
86
+ * - `hit` = 已识别合法结构 + 至少一个有效行程
87
+ * - `miss` = 已识别合法空(data.flightItineraryList 是空数组)
88
+ * - `error`= 形状未识别(JSON 解析失败 / 非对象根 / data 缺失或非对象 /
89
+ * flightItineraryList 不是数组 / 非空数组没有可用行程)——未知响应
90
+ * 不可静默收敛为 miss
91
+ * 绝不发明价格、航班号、机场、时刻;混合数组保留有效项,全畸形数组报 error。
92
+ */
93
+ export function parseBatchSearchResult(body: string): BatchSearchResult {
94
+ let raw: unknown
83
95
  try {
84
- raw = JSON.parse(body) as RawBatch
96
+ raw = JSON.parse(body)
85
97
  } catch {
86
- return []
98
+ return { options: [], verdict: 'error' }
99
+ }
100
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
101
+ return { options: [], verdict: 'error' }
102
+ }
103
+ const data = (raw as { data?: unknown }).data
104
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
105
+ return { options: [], verdict: 'error' }
106
+ }
107
+ const list = (raw as { data: { flightItineraryList?: unknown } }).data.flightItineraryList
108
+ if (!Array.isArray(list)) {
109
+ return { options: [], verdict: 'error' }
87
110
  }
88
111
  const out: SessionFlightOption[] = []
89
- for (const it of raw.data?.flightItineraryList ?? []) {
90
- const seg = it.flightSegments?.[0]
91
- const fl = seg?.flightList?.[0]
92
- if (!fl?.flightNo || !fl.departureDateTime) continue
93
- const prices = (it.priceList ?? []).map((p) => Number(p.adultPrice ?? 0)).filter((n) => n > 0)
112
+ for (const it of list) {
113
+ if (it === null || typeof it !== 'object' || Array.isArray(it)) continue
114
+ const item = it as { flightSegments?: unknown; priceList?: unknown }
115
+ const segs = item.flightSegments
116
+ if (!Array.isArray(segs) || segs.length === 0) continue
117
+ const seg = segs[0]
118
+ if (seg === null || typeof seg !== 'object' || Array.isArray(seg)) continue
119
+ const segObj = seg as { airlineName?: unknown; duration?: unknown; flightList?: unknown }
120
+ const fl = segObj.flightList
121
+ if (!Array.isArray(fl) || fl.length === 0) continue
122
+ const flight = fl[0]
123
+ if (flight === null || typeof flight !== 'object' || Array.isArray(flight)) continue
124
+ const f = flight as {
125
+ flightNo?: unknown
126
+ departureAirportName?: unknown
127
+ arrivalAirportName?: unknown
128
+ departureDateTime?: unknown
129
+ arrivalDateTime?: unknown
130
+ aircraftName?: unknown
131
+ }
132
+ if (typeof f.flightNo !== 'string' || !f.flightNo) continue
133
+ if (typeof f.departureDateTime !== 'string' || !f.departureDateTime) continue
134
+ // 缺 priceList 延续旧合同为「有航班、价格未知」;显式非数组与畸形价项
135
+ // 属未知响应,整项跳过。若响应里没有其他有效项,最终判 error 而非 miss。
136
+ if (item.priceList !== undefined && !Array.isArray(item.priceList)) continue
137
+ const priceList = item.priceList ?? []
138
+ const prices: number[] = []
139
+ let malformedPrice = false
140
+ for (const p of priceList) {
141
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) {
142
+ malformedPrice = true
143
+ break
144
+ }
145
+ const adultPrice = (p as { adultPrice?: unknown }).adultPrice
146
+ if (adultPrice === undefined) continue
147
+ if (typeof adultPrice !== 'number' || !Number.isFinite(adultPrice)) {
148
+ malformedPrice = true
149
+ break
150
+ }
151
+ if (adultPrice > 0) prices.push(adultPrice)
152
+ }
153
+ if (malformedPrice) continue
94
154
  out.push({
95
- flightNo: fl.flightNo,
96
- airline: seg?.airlineName ?? '',
97
- depDateTime: fl.departureDateTime ?? '',
98
- arrDateTime: fl.arrivalDateTime ?? '',
99
- depAirport: fl.departureAirportName ?? '',
100
- arrAirport: fl.arrivalAirportName ?? '',
101
- durationMin: Number(seg?.duration ?? 0) || 0,
155
+ flightNo: f.flightNo,
156
+ airline: asString(segObj.airlineName),
157
+ depDateTime: asString(f.departureDateTime),
158
+ arrDateTime: asString(f.arrivalDateTime),
159
+ depAirport: asString(f.departureAirportName),
160
+ arrAirport: asString(f.arrivalAirportName),
161
+ durationMin: asPositiveNumber(segObj.duration),
102
162
  price: prices.length > 0 ? Math.min(...prices) : 0,
103
- aircraft: fl.aircraftName,
163
+ aircraft: typeof f.aircraftName === 'string' ? f.aircraftName : undefined,
104
164
  })
105
165
  }
106
- return out
166
+ return {
167
+ options: out,
168
+ verdict: out.length > 0 ? 'hit' : list.length === 0 ? 'miss' : 'error',
169
+ }
107
170
  }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Dida 供应商门户站点适配器(2026-09-09 实装;hotel-be portal integration 迁移线:
3
+ * 员工子账号免知凭据访问供应商 portal——登录经 hotel-be portal 自动填充跳板在
4
+ * dida 官网由人完成,gotry 只在用户本人 Chrome 登录态里被动嗅探检索回包)。
5
+ *
6
+ * - entry:https://portal.dida.com/hotel/find(SPA,页内交互触发检索;深链参数
7
+ * 形态未知——D-13 同款边界,首个真会话后校准)
8
+ * - networkHints:portal-webapi.dida.com 实时价/价格监控 XHR(接口名取自
9
+ * hotel-be supplier/integration/dida/portal 已捕获端点,2026-07 抓包校准);
10
+ * URL hint 未命中时由 content-main 的形状嗅探兜底(响应 JSON 含报价签名即
11
+ * 转发),对接口改名免疫
12
+ * - 解析:SearchRealTime 信封(Envelope{Data.HotelPriceList[].RoomTypeList[]
13
+ * .RatePlanList[]})逐报价展平;SearchMonitor 响应形状未捕获前不猜测——
14
+ * 只按 URL 命中转发、解析留给首个真会话校准(D-13)
15
+ *
16
+ * 只读:适配器不含任何提交/预订语义;预订面(PriceConfirm 等)URL 属 hotel-be
17
+ * 适配器边界,gotry 侧不存在对应原语;登录语义红线同全局:gotry 永不读取/存储/
18
+ * 回传 cookie 值,只读票据 cookie 名(CN_M_DidaTravel)存在性。
19
+ */
20
+
21
+ /** 票据 cookie 名(hotel-be portal 会话同款 HttpOnly cookie;只读名字,永不读值) */
22
+ export const DIDA_LOGIN_COOKIE_NAMES = ['CN_M_DidaTravel']
23
+
24
+ /** page 域(content_scripts 注入面与 background 白名单的对账源) */
25
+ export const DIDA_SITE_HOST = 'portal.dida.com'
26
+ /** cookie 域(chrome.cookies 过滤器与登录票据检查;did a 站点 cookie 挂 .dida.com 域 cookie) */
27
+ export const DIDA_SITE_DOMAIN = 'dida.com'
28
+
29
+ /** 默认检索入口(酒店 find 页;SPA 加载后由页面自身代码发起价格检索) */
30
+ export const DIDA_ENTRY_URL = 'https://portal.dida.com/hotel/find'
31
+
32
+ export interface DidaEntry {
33
+ ok: boolean
34
+ url?: string
35
+ }
36
+
37
+ export interface DidaEntryQuery {
38
+ /** 显式覆盖入口 URL(深链参数校准后使用;必须落在 portal.dida.com 域内) */
39
+ entryUrl?: string
40
+ }
41
+
42
+ /** entry 构造(v1:固定 find 页 + 显式覆盖守白名单域;URL 直开即零 DOM 交互) */
43
+ export function buildDidaEntryUrl(q: DidaEntryQuery = {}): DidaEntry {
44
+ const url = (q.entryUrl ?? '').trim() || DIDA_ENTRY_URL
45
+ if (!/^https:\/\/portal\.dida\.com\//.test(url)) return { ok: false }
46
+ return { ok: true, url }
47
+ }
48
+
49
+ /** networkHints:portal-webapi.dida.com 实时价/价格监控 XHR(2026-07 抓包口径;
50
+ * 首个真会话后按现行接口校准——D-13 同款边界) */
51
+ export const DIDA_NETWORK_HINTS = [
52
+ /portal-webapi\.dida\.com\/HotelPriceAPI\/SearchRealTime/i,
53
+ /portal-webapi\.dida\.com\/HotelPriceAPI\/SearchMonitor/i,
54
+ // 2026-09-10 UAT 实测校准:find 页加载态自发的是推荐流(非 SearchRealTime——
55
+ // 那是用户点「预订」后的实时价接口)。推荐酒店 + 推荐价格两个接口成对出现。
56
+ /portal-webapi\.dida\.com\/HotelRecommendAPI\/SearchHomepageRecommendHotels/i,
57
+ /portal-webapi\.dida\.com\/HotelRecommendAPI\/SearchHomepageRecommendPrices/i,
58
+ /portal-webapi\.dida\.com\/PopularDestinationAPI\/SearchHotels/i,
59
+ /portal-webapi\.dida\.com\/PopularDestinationAPI\/SearchHotelPrices/i,
60
+ ]
61
+
62
+ /** 形状嗅探签名:URL hint 未命中时的兜底(实时价信封报价签名,对接口改名免疫);
63
+ * 大小上限由调用方先把关 */
64
+ export function looksLikeDidaRatesBody(body: string): boolean {
65
+ if (!body || body.length > 2_000_000) return false
66
+ return /"HotelPriceList"|"RatePlanList"/.test(body)
67
+ }
68
+
69
+ // ── 现行加载态接口(2026-09-10 校准):推荐酒店 + 推荐价格 ──────────────────
70
+
71
+ export interface DidaRecommendHotel {
72
+ hotelId: string
73
+ name: string
74
+ nameEN?: string
75
+ }
76
+
77
+ /** 解析推荐酒店列表(Data.Hotels[]:HotelID/Name/Name_CN/Name_EN;Current 页加载态无价) */
78
+ export function parseDidaRecommendHotels(body: string, opts: { maxItems?: number } = {}): DidaRecommendHotel[] {
79
+ let raw: unknown
80
+ try { raw = JSON.parse(body) } catch { return [] }
81
+ const hotels = (raw as { Data?: { Hotels?: unknown } })?.Data?.Hotels
82
+ if (!Array.isArray(hotels)) return []
83
+ const out: DidaRecommendHotel[] = []
84
+ for (const item of hotels.slice(0, opts.maxItems ?? 60)) {
85
+ if (item == null || typeof item !== 'object') continue
86
+ const h = item as Record<string, unknown>
87
+ const hotelId = h.HotelID != null ? String(h.HotelID) : ''
88
+ const name = typeof h.Name_CN === 'string' && h.Name_CN.trim() ? h.Name_CN : (typeof h.Name === 'string' ? h.Name : '')
89
+ if (!hotelId || !name) continue
90
+ out.push({ hotelId, name, nameEN: typeof h.Name_EN === 'string' ? h.Name_EN : undefined })
91
+ }
92
+ return out
93
+ }
94
+
95
+ export interface DidaPriceEntry {
96
+ hotelId: string
97
+ price: number
98
+ originalPrice?: number
99
+ currency?: string
100
+ /** 单晚起始价对应日期(YYYY-MM-DD) */
101
+ priceDate?: string
102
+ checkInDate?: string
103
+ checkOutDate?: string
104
+ }
105
+
106
+ // (parser types continue below)
107
+
108
+ /** 解析价格列表:兼容 Recommend Prices(HotelId/Date)与 PopularDestination(HotelID/CheckInDate)两种键形 */
109
+ export function parseDidaPrices(body: string): DidaPriceEntry[] {
110
+ let raw: unknown
111
+ try { raw = JSON.parse(body) } catch { return [] }
112
+ const prices = (raw as { Data?: { Prices?: unknown } })?.Data?.Prices
113
+ if (!Array.isArray(prices)) return []
114
+ const out: DidaPriceEntry[] = []
115
+ for (const item of prices) {
116
+ if (item == null || typeof item !== 'object') continue
117
+ const p = item as Record<string, unknown>
118
+ const hotelId = p.HotelId != null ? String(p.HotelId) : (p.HotelID != null ? String(p.HotelID) : '')
119
+ const price = typeof p.Price === 'number' ? p.Price : NaN
120
+ if (!hotelId || !Number.isFinite(price) || price <= 0) continue
121
+ const dateOf = (v: unknown): string | undefined => (typeof v === 'string' && v.length >= 10 ? v.slice(0, 10) : undefined)
122
+ out.push({
123
+ hotelId,
124
+ price,
125
+ originalPrice: typeof p.OriginalPrice === 'number' ? p.OriginalPrice : undefined,
126
+ currency: typeof p.Currency === 'string' ? p.Currency : undefined,
127
+ priceDate: dateOf(p.Date),
128
+ checkInDate: dateOf(p.CheckInDate),
129
+ checkOutDate: dateOf(p.CheckOutDate),
130
+ })
131
+ }
132
+ return out
133
+ }
134
+
135
+ /** 单条报价(一房一价一计划;语义判定在工具层,本层只归形状——页面 JSON 是不可信输入) */
136
+ export interface SessionDidaRateOption {
137
+ hotelId?: string
138
+ hotelName?: string
139
+ roomTypeId?: string
140
+ roomName?: string
141
+ ratePlanId?: string
142
+ /** 单价(数值不可得时为 0) */
143
+ price: number
144
+ /** 总价(含税费口径以 dida 页面为准) */
145
+ totalPrice?: number
146
+ currency?: string
147
+ mealType?: string
148
+ breakfastType?: string
149
+ bedType?: string
150
+ paymentType?: string
151
+ /** 库存/可订间数(0=未给) */
152
+ inventory?: number
153
+ /** 检索续期令牌(dida 报价引用号;服务端预订链凭此续操作) */
154
+ referenceNo?: string
155
+ /** 酒店详情落地页(由人完成预订;gotry 不碰) */
156
+ jumpUrl?: string
157
+ /** 推荐价对应日期(YYYY-MM-DD;推荐流单晚起始价) */
158
+ priceDate?: string
159
+ }
160
+
161
+ interface RateCandidate {
162
+ hotelId?: string
163
+ hotelName?: string
164
+ roomTypeId?: string
165
+ roomName?: string
166
+ plan: Record<string, unknown>
167
+ }
168
+
169
+ /** 从 RoomTypePrice 展平一条报价候选(RatePlanList 元素为 dida 已知形态);
170
+ * 酒店字段取 HotelPriceItem.Hotel 嵌套(HotelStatic),容忍旧式扁平形态 */
171
+ function rateCandidate(hotel: Record<string, unknown>, room: Record<string, unknown>, plan: Record<string, unknown>): RateCandidate | null {
172
+ if (plan == null || typeof plan !== 'object') return null
173
+ const ratePlanId = plan.RatePlanID
174
+ const price = plan.Price
175
+ const totalPrice = plan.TotalPrice
176
+ if (typeof ratePlanId !== 'string' || !ratePlanId.trim()) return null
177
+ if (typeof price !== 'number' && typeof totalPrice !== 'number') return null
178
+ const hotelStatic = (hotel.Hotel != null && typeof hotel.Hotel === 'object' ? hotel.Hotel : hotel) as Record<string, unknown>
179
+ const hotelId = hotelStatic.HotelID
180
+ const roomTypeId = room.DidaRoomTypeID
181
+ return {
182
+ hotelId: hotelId != null ? String(hotelId) : undefined,
183
+ hotelName: typeof hotelStatic.Name === 'string' ? hotelStatic.Name : undefined,
184
+ roomTypeId: roomTypeId != null ? String(roomTypeId) : undefined,
185
+ roomName: typeof room.DidaRoomTypeName_CN === 'string' && room.DidaRoomTypeName_CN.trim()
186
+ ? room.DidaRoomTypeName_CN
187
+ : (typeof room.DidaRoomTypeName_EN === 'string' ? room.DidaRoomTypeName_EN : undefined),
188
+ plan,
189
+ }
190
+ }
191
+
192
+ /** 走形:SearchRealTime 信封 → 展平报价列表(纯函数,fixture 测试锚点);
193
+ * malformed 一律返空,不抛错——页面/接口 JSON 是不可信输入(RFC §3.5) */
194
+ export function parseDidaRates(body: string, opts: { maxItems?: number } = {}): SessionDidaRateOption[] {
195
+ let raw: unknown
196
+ try {
197
+ raw = JSON.parse(body)
198
+ } catch {
199
+ return []
200
+ }
201
+ const maxItems = opts.maxItems ?? 40
202
+ const out: SessionDidaRateOption[] = []
203
+ // 定位 HotelPriceList:信封顶层 Data 下;无信封时容忍裸数组形态
204
+ let hotelList: unknown
205
+ if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
206
+ const data = (raw as Record<string, unknown>).Data
207
+ if (data != null && typeof data === 'object') hotelList = (data as Record<string, unknown>).HotelPriceList
208
+ } else if (Array.isArray(raw)) {
209
+ hotelList = raw
210
+ }
211
+ if (!Array.isArray(hotelList)) return out
212
+ for (const item of hotelList) {
213
+ if (out.length >= maxItems || item == null || typeof item !== 'object') continue
214
+ const hotel = item as Record<string, unknown>
215
+ // RoomTypeList 与 RoomList 是合并关系(hotel-be converter 两表都吃),不是二选一
216
+ const rooms = [
217
+ ...(Array.isArray(hotel.RoomTypeList) ? hotel.RoomTypeList : []),
218
+ ...(Array.isArray(hotel.RoomList) ? hotel.RoomList : []),
219
+ ]
220
+ for (const room of rooms) {
221
+ if (out.length >= maxItems || room == null || typeof room !== 'object') continue
222
+ const plans = (room as Record<string, unknown>).RatePlanList
223
+ if (!Array.isArray(plans)) continue
224
+ for (const plan of plans) {
225
+ if (out.length >= maxItems) return out
226
+ const cand = rateCandidate(hotel, room as Record<string, unknown>, plan as Record<string, unknown>)
227
+ if (!cand) continue
228
+ const p = cand.plan
229
+ out.push({
230
+ hotelId: cand.hotelId,
231
+ hotelName: cand.hotelName,
232
+ roomTypeId: cand.roomTypeId,
233
+ roomName: cand.roomName,
234
+ ratePlanId: typeof p.RatePlanID === 'string' ? p.RatePlanID : undefined,
235
+ price: typeof p.Price === 'number' ? p.Price : 0,
236
+ totalPrice: typeof p.TotalPrice === 'number' ? p.TotalPrice : undefined,
237
+ currency: typeof p.Currency === 'string' ? p.Currency : undefined,
238
+ mealType: typeof p.MealType === 'string' ? p.MealType : undefined,
239
+ breakfastType: typeof p.BreakfastType === 'string' ? p.BreakfastType : undefined,
240
+ bedType: typeof p.BedType === 'string' ? p.BedType : undefined,
241
+ paymentType: typeof p.PaymentType === 'string' ? p.PaymentType : undefined,
242
+ inventory: typeof p.Inventory === 'number' ? p.Inventory : (typeof p.RoomCount === 'number' ? p.RoomCount : undefined),
243
+ referenceNo: typeof p.ReferenceNo === 'string' ? p.ReferenceNo : undefined,
244
+ jumpUrl: cand.hotelId ? `https://portal.dida.com/hotel/find` : undefined,
245
+ })
246
+ }
247
+ }
248
+ }
249
+ return out
250
+ }
@@ -169,15 +169,63 @@ export interface AdapterEntry {
169
169
  unresolved?: string[]
170
170
  }
171
171
 
172
- export function buildTrainEntryUrl(q: TrainEntryQuery): AdapterEntry {
172
+ export interface TrainQueryTelecodes {
173
+ fromStationTelecode: string
174
+ toStationTelecode: string
175
+ date: string
176
+ }
177
+
178
+ export interface TrainResponseBinding extends TrainQueryTelecodes {
179
+ /** Exact URL observed on the response whose body was parsed. */
180
+ url: string
181
+ }
182
+
183
+ export type TrainResponseUrlValidation =
184
+ | { ok: true; binding: TrainResponseBinding }
185
+ | { ok: false; reason: string }
186
+
187
+ export function resolveTrainQueryTelecodes(q: TrainEntryQuery): { ok: true; telecodes: TrainQueryTelecodes } | { ok: false; unresolved: string[] } {
173
188
  const unresolved: string[] = []
174
189
  const fromTc = (q.fromStationTelecode ?? '').trim().toUpperCase() || STATION_TELECODES[q.from.trim()]
175
190
  const toTc = (q.toStationTelecode ?? '').trim().toUpperCase() || STATION_TELECODES[q.to.trim()]
176
191
  if (!fromTc) unresolved.push(q.from)
177
192
  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] }
193
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(q.date)) unresolved.push(q.date)
194
+ if (unresolved.length > 0 || !fromTc || !toTc) return { ok: false, unresolved }
195
+ return { ok: true, telecodes: { fromStationTelecode: fromTc, toStationTelecode: toTc, date: q.date } }
196
+ }
197
+
198
+ /** Bind a parsed body to the exact 12306 query request that this invocation built. */
199
+ export function validateTrainQueryResponseUrl(observedUrl: string, expected: TrainQueryTelecodes): TrainResponseUrlValidation {
200
+ let parsed: URL
201
+ try {
202
+ parsed = new URL(observedUrl)
203
+ } catch {
204
+ return { ok: false, reason: 'response URL malformed' }
205
+ }
206
+ if (parsed.protocol !== 'https:' || parsed.hostname.toLowerCase() !== TRAIN_SITE_HOST || parsed.port) {
207
+ return { ok: false, reason: 'response URL host/protocol is not the 12306 HTTPS host' }
208
+ }
209
+ if (!/^\/otn\/leftTicket\/query[A-Za-z0-9_-]*$/i.test(parsed.pathname)
210
+ || !TRAIN_NETWORK_HINTS.some((hint) => hint.test(parsed.pathname))) {
211
+ return { ok: false, reason: 'response URL is not an allowed leftTicket query endpoint' }
180
212
  }
213
+ const exactParam = (name: string): string | undefined => {
214
+ const values = parsed.searchParams.getAll(name)
215
+ return values.length === 1 ? values[0] : undefined
216
+ }
217
+ if (exactParam('leftTicketDTO.train_date') !== expected.date
218
+ || exactParam('leftTicketDTO.from_station') !== expected.fromStationTelecode
219
+ || exactParam('leftTicketDTO.to_station') !== expected.toStationTelecode) {
220
+ return { ok: false, reason: 'response URL query route/date does not match the invocation' }
221
+ }
222
+ return { ok: true, binding: { url: observedUrl, ...expected } }
223
+ }
224
+
225
+ export function buildTrainEntryUrl(q: TrainEntryQuery): AdapterEntry {
226
+ const resolved = resolveTrainQueryTelecodes(q)
227
+ if (!resolved.ok) return { ok: false, unresolved: resolved.unresolved }
228
+ const { fromStationTelecode: fromTc, toStationTelecode: toTc } = resolved.telecodes
181
229
  const fs = encodeURIComponent(`${q.from.trim()},${fromTc}`)
182
230
  const ts = encodeURIComponent(`${q.to.trim()},${toTc}`)
183
231
  return { ok: true, url: `https://kyfw.12306.cn/otn/leftTicket/init?linktypeid=dc&fs=${fs}&ts=${ts}&date=${q.date}&flag=N,N,Y` }
@@ -199,6 +247,8 @@ export interface SessionTrainOption {
199
247
  arrTime: string
200
248
  /** 历时(分钟) */
201
249
  durationMin: number
250
+ /** 12306 train-origin date(YYYY-MM-DD);validated and preserved, not passenger query date */
251
+ startTrainDate?: string
202
252
  /** Y=可预订 / N=不可 / 其他上游原话 */
203
253
  canWebBuy: string
204
254
  /** 余票分桶(第一方校准索引,见 SEAT_BUCKETS;值原样:数字 / 有 / 无 / --) */
@@ -231,24 +281,56 @@ const SEAT_BUCKETS: Array<{ index: number; field: string; label: string }> = [
231
281
  const TRAIN_CODE_RE = /^[GDCZTKYLSF]\d{1,5}[A-Z]?$/i
232
282
  const HHMM_RE = /^\d{2}:\d{2}$/
233
283
 
284
+ export const TRAIN_QUERY_PARSE_SCHEMA = 'gotry_session_train_parse.v1' as const
285
+
286
+ export type TrainQueryParseOutcome =
287
+ | { kind: 'recognized-nonempty'; trains: SessionTrainOption[] }
288
+ | { kind: 'recognized-empty'; trains: [] }
289
+ | { kind: 'malformed'; reason: string }
290
+
291
+ function isRecord(value: unknown): value is Record<string, unknown> {
292
+ return !!value && typeof value === 'object' && !Array.isArray(value)
293
+ }
294
+
234
295
  /** 历时 "07:52" → 分钟 */
235
296
  function lishiToMin(s: string | undefined): number {
236
297
  const m = /^(\d{1,3}):(\d{2})$/.exec((s ?? '').trim())
237
298
  if (!m) return 0
299
+ if (Number(m[2]) > 59) return 0
238
300
  return Number(m[1]) * 60 + Number(m[2])
239
301
  }
240
302
 
303
+ function normalizeStartTrainDate(value: string): string | undefined {
304
+ if (!/^\d{8}$/.test(value)) return undefined
305
+ const normalized = `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`
306
+ const parsed = new Date(`${normalized}T00:00:00.000Z`)
307
+ if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== normalized) return undefined
308
+ return normalized
309
+ }
310
+
241
311
  /** 行级签名:车次码形态 + 时刻形态不符即整行跳过(索引漂移 fail-visible,不造数) */
242
- function rowToOption(row: string, stationMap: Record<string, string> | undefined, entryUrl: string): SessionTrainOption | null {
312
+ function rowToOption(row: string, stationMap: Record<string, unknown> | undefined, entryUrl: string): SessionTrainOption | null {
243
313
  const f = row.split('|')
244
314
  if (f.length < 34) return null
245
315
  const trainCode = (f[3] ?? '').trim()
246
316
  const depTime = (f[8] ?? '').trim()
247
317
  const arrTime = (f[9] ?? '').trim()
248
- if (!TRAIN_CODE_RE.test(trainCode) || !HHMM_RE.test(depTime) || !HHMM_RE.test(arrTime)) return null
318
+ const durationMin = lishiToMin(f[10])
319
+ const startTrainDateRaw = (f[13] ?? '').trim()
320
+ const startTrainDate = normalizeStartTrainDate(startTrainDateRaw)
321
+ const validHm = (value: string): boolean => HHMM_RE.test(value)
322
+ && Number(value.slice(0, 2)) <= 23 && Number(value.slice(3, 5)) <= 59
323
+ if (!TRAIN_CODE_RE.test(trainCode) || !validHm(depTime) || !validHm(arrTime)
324
+ || durationMin <= 0 || !startTrainDate || !(f[6] ?? '').trim() || !(f[7] ?? '').trim()) return null
249
325
  // 官方 cN 口径:站名 = data.map[电报码](map 缺失/缺键时退电报码原样,不猜名)
250
- const fromStation = stationMap?.[f[6] ?? ''] ?? (f[6] ?? '')
251
- const toStation = stationMap?.[f[7] ?? ''] ?? (f[7] ?? '')
326
+ const stationName = (code: string): string | null => {
327
+ if (!stationMap || !Object.prototype.hasOwnProperty.call(stationMap, code)) return code
328
+ const mapped = stationMap[code]
329
+ return typeof mapped === 'string' && mapped.trim() ? mapped.trim() : null
330
+ }
331
+ const fromStation = stationName((f[6] ?? '').trim())
332
+ const toStation = stationName((f[7] ?? '').trim())
333
+ if (!fromStation || !toStation) return null
252
334
  const seats: Record<string, string> = {}
253
335
  for (const b of SEAT_BUCKETS) {
254
336
  const v = (f[b.index] ?? '').trim()
@@ -260,34 +342,51 @@ function rowToOption(row: string, stationMap: Record<string, string> | undefined
260
342
  toStation,
261
343
  depTime,
262
344
  arrTime,
263
- durationMin: lishiToMin(f[10]),
345
+ durationMin,
346
+ startTrainDate,
264
347
  canWebBuy: (f[11] ?? '').trim(),
265
348
  seats,
266
349
  jumpUrl: entryUrl,
267
350
  }
268
351
  }
269
352
 
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> } }
353
+ /** 解析 leftTicket/query 响应的 typed outcome。
354
+ * recognized-empty 只代表明确存在的 data.result=[];
355
+ * recognized-nonempty 要求批次每一行都通过签名校验,任一畸形行即整个批次
356
+ * malformed,不暴露部分候选。transport/runtime failure 不在此纯解析函数伪造。
357
+ */
358
+ export function parseLeftTicketQueryResult(body: string, entryUrl: string, opts: { maxItems?: number } = {}): TrainQueryParseOutcome {
359
+ let raw: unknown
275
360
  try {
276
- raw = JSON.parse(body) as typeof raw
361
+ raw = JSON.parse(body) as unknown
277
362
  } catch {
278
- return []
363
+ return { kind: 'malformed', reason: 'response body is not valid JSON' }
279
364
  }
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
365
+ if (!isRecord(raw) || !isRecord(raw.data)) return { kind: 'malformed', reason: 'response root/data is not an object' }
366
+ const rows = raw.data.result
367
+ if (!Array.isArray(rows)) return { kind: 'malformed', reason: 'data.result is not an array' }
368
+ if (raw.data.map !== undefined && !isRecord(raw.data.map)) {
369
+ return { kind: 'malformed', reason: 'data.map is not an object' }
370
+ }
371
+ if (rows.length === 0) return { kind: 'recognized-empty', trains: [] }
372
+ const map = raw.data.map as Record<string, unknown> | undefined
283
373
  const out: SessionTrainOption[] = []
284
374
  for (const r of rows) {
285
- if (typeof r !== 'string') continue
375
+ if (typeof r !== 'string') return { kind: 'malformed', reason: 'data.result contains a non-string row' }
286
376
  const opt = rowToOption(r, map, entryUrl)
287
- if (opt) {
288
- out.push(opt)
289
- if (out.length >= (opts.maxItems ?? 30)) break
290
- }
377
+ if (!opt) return { kind: 'malformed', reason: 'data.result contains a malformed or unrecognized row' }
378
+ if (out.length < (opts.maxItems ?? 30)) out.push(opt)
291
379
  }
292
- return out
380
+ return { kind: 'recognized-nonempty', trains: out }
381
+ }
382
+
383
+ /** 兼容旧调用方:typed outcome 之外仍保留旧的数组投影,错误与 genuine empty 均为 []。 */
384
+ export function parseLeftTicketQuery(body: string, entryUrl: string, opts: { maxItems?: number } = {}): SessionTrainOption[] {
385
+ const outcome = parseLeftTicketQueryResult(body, entryUrl, opts)
386
+ return outcome.kind === 'recognized-nonempty' ? outcome.trains : []
387
+ }
388
+
389
+ /** Positive availability is deliberately a small closed set, not canWebBuy alone. */
390
+ export function hasRecognizedAvailableSeat(option: SessionTrainOption): boolean {
391
+ return Object.values(option.seats).some(value => value === '有' || /^[1-9]\d*$/.test(value))
293
392
  }