@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
@@ -1,25 +1,43 @@
1
- import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
1
+ /**
2
+ * 会话数据面 P1 测试(RFC §4 P1 exit):
3
+ * A-E 纯函数(确定性,进 CI):ReadGuard 分类器/批搜解析(buildEntryUrl/fixture)/提交件过滤/节律闸;
4
+ * F live FlyAI 官方通道(天气回归为 deterministic fixture;live 天气观测不进 merge gate);
5
+ * G live 会话检索(Chrome + 携程;**默认 SKIP**——测试永不自动开用户浏览器窗口,GOTRY_SESSION_LIVE=1 显式开启);
6
+ * H 酒店通道(flyai search-hotel;纯 CLI,无浏览器窗口);
7
+ * I 账号会话授权闸(纯函数:每会话一次/拒绝=会话内吊销/allow/off/无审批通道,确定性);
8
+ * J 登录引导(无凭证语义/表格完备/票据名级检查;live opt-in 同 G)。
9
+ * K 酒店会话面(2026-09-03 实装:entry URL/走形解析/形状嗅探/城市码指引/闸面;纯函数,transport 前短路)。
10
+ * L 火车会话面(2026-09-03 实装:12306 管道行解析/行级签名防索引漂移/电报码指引/闸面;公开查询面)。
11
+ * #21 字段 scorer/双源 gate 的无网络验收独立放在 scripts/session-benchmark.ts。
12
+ * 隔离纪律:live 用 mktemp profile 与 stateRoot,绝不动共享状态与日常浏览器 profile;
13
+ * 任何测试不自动弹浏览器窗口(用户 Chrome 只经 CDP attach 或用户手动运行 session-login)。
14
+ */
15
+ import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from 'node:fs';
2
16
  import { tmpdir } from 'node:os';
3
17
  import { join } from 'node:path';
4
18
  import { classifyRequest, isSubmitText } from '../capabilities/session/read-guard.js';
5
- import { buildEntryUrl, parseBatchSearch } from '../capabilities/session/adapters/ctrip-flight.js';
19
+ import { buildEntryUrl, parseBatchSearch, parseBatchSearchResult } from '../capabilities/session/adapters/ctrip-flight.js';
6
20
  import { buildHotelEntryUrl, parseCtripHotelList, looksLikeHotelListBody } from '../capabilities/session/adapters/ctrip-hotel.js';
7
- import { buildTrainEntryUrl, parseLeftTicketQuery, STATION_TELECODES } from '../capabilities/session/adapters/rail-12306.js';
8
- import { sessionFlightSearch, sessionHotelSearch, sessionTrainSearch, trainStationUnresolvedHint, hotelCityUnresolvedHint, __resetRateLimiterForTest, classifyTransportFailure } from '../capabilities/session-search.js';
21
+ import { buildTrainEntryUrl, hasRecognizedAvailableSeat, parseLeftTicketQuery, parseLeftTicketQueryResult, resolveTrainQueryTelecodes, STATION_TELECODES, validateTrainQueryResponseUrl } from '../capabilities/session/adapters/rail-12306.js';
22
+ import { buildDidaEntryUrl, parseDidaRates, looksLikeDidaRatesBody } from '../capabilities/session/adapters/dida-portal.js';
23
+ import { sessionFlightSearch, sessionHotelSearch, sessionTrainSearch, sessionDidaSearch, trainStationUnresolvedHint, hotelCityUnresolvedHint, didaLoginHint, __resetRateLimiterForTest, __setTrainSessionTransportForTest, classifyTransportFailure } from '../capabilities/session-search.js';
9
24
  import { flyaiSearch } from '../capabilities/flyai.js';
10
25
  import { createConsentGate } from '../capabilities/session-consent.js';
11
26
  import { sessionLogin, pollTicketNames, LOGIN_TARGETS } from '../capabilities/session-login.js';
27
+ import { factsFromHotel, factsFromSessionTrain, TRAIN_FACT_FRESHNESS_RULE, TRAIN_FACT_MAX_AGE_MS } from '../src/bookable-facts.js';
12
28
  let pass = 0;
13
29
  let fail = 0;
14
30
  function assert(cond, label, detail) {
15
31
  if (cond) {
16
32
  pass += 1;
17
33
  console.log(` ok - ${label}`);
18
- } else {
34
+ }
35
+ else {
19
36
  fail += 1;
20
37
  console.log(` FAIL - ${label}${detail !== undefined ? ' :: ' + JSON.stringify(detail)?.slice(0, 300) : ''}`);
21
38
  }
22
39
  }
40
+ // A. ReadGuard 网络层分类器:方法 × URL 双因子(搜索 POST 不误伤,写请求必拦)
23
41
  console.log('A. classifyRequest(方法×URL 双因子)');
24
42
  assert(classifyRequest('POST', 'https://flights.ctrip.com/international/search/api/search/batchSearch?v=1') === 'allow', '搜索 POST(batchSearch)放行');
25
43
  assert(classifyRequest('POST', 'https://m.ctrip.com/restapi/soa2/12345/SubmitOrder') === 'block', 'POST /SubmitOrder 拦截(驼峰复合)');
@@ -28,74 +46,111 @@ assert(classifyRequest('GET', 'https://pay.ctrip.com/pay/payment?x=1') === 'bloc
28
46
  assert(classifyRequest('GET', 'https://m.ctrip.com/restapi/soa2/15095/SearchBoxRecommend') === 'allow', 'GET 搜索建议放行');
29
47
  assert(classifyRequest('PUT', 'https://api.example.com/trade/create') === 'block', 'PUT /trade/create 拦截');
30
48
  assert(classifyRequest('OPTIONS', 'https://flights.ctrip.com/api/preflight') === 'allow', 'OPTIONS 放行');
49
+ // B. batchSearch 解析(实测 schema fixture,2026-08-28)
31
50
  console.log('B. parseBatchSearch(fixture)');
32
51
  const fixture = JSON.stringify({
33
52
  status: 0,
34
53
  data: {
35
54
  flightItineraryList: [
36
55
  {
37
- flightSegments: [
38
- {
39
- airlineName: '吉祥航空',
40
- duration: 200,
41
- flightList: [
42
- {
43
- flightNo: 'HO5577',
44
- departureAirportName: '虹桥国际机场',
45
- arrivalAirportName: '三义机场',
46
- departureDateTime: '2026-10-01 07:35:00',
47
- arrivalDateTime: '2026-10-01 10:55:00',
48
- aircraftName: '波音737(中)'
49
- }
50
- ]
51
- }
52
- ],
53
- priceList: [
54
- {
55
- adultPrice: 3240
56
- },
57
- {
58
- adultPrice: 2980
59
- }
60
- ]
56
+ flightSegments: [{ airlineName: '吉祥航空', duration: 200, flightList: [{ flightNo: 'HO5577', departureAirportName: '虹桥国际机场', arrivalAirportName: '三义机场', departureDateTime: '2026-10-01 07:35:00', arrivalDateTime: '2026-10-01 10:55:00', aircraftName: '波音737(中)' }] }],
57
+ priceList: [{ adultPrice: 3240 }, { adultPrice: 2980 }],
61
58
  },
62
- {
63
- flightSegments: [
64
- {}
65
- ]
66
- }
67
- ]
68
- }
59
+ { flightSegments: [{}] }, // malformed 项:跳过不抛
60
+ ],
61
+ },
69
62
  });
70
63
  const parsed = parseBatchSearch(fixture);
71
64
  assert(parsed.length === 1, 'malformed 项跳过,1 个有效行程');
72
65
  assert(parsed[0]?.flightNo === 'HO5577' && parsed[0]?.price === 2980, 'flightNo + priceList 最小 adultPrice');
73
66
  assert(parsed[0]?.depDateTime === '2026-10-01 07:35:00' && parsed[0]?.durationMin === 200, '时刻与时长字段');
74
67
  assert(parseBatchSearch('not-json').length === 0, '非 JSON 返空不抛错');
68
+ // B2. issue #279 结构化解析(malformed 不抛 + 形状异常归 error,合法空归 miss,非空归 hit)
69
+ console.log('B2. parseBatchSearchResult(issue #279 形状异常归 error,合法空归 miss,命中归 hit)');
70
+ {
71
+ const fixtureHit = JSON.stringify({ data: { flightItineraryList: [{
72
+ flightSegments: [{ airlineName: '吉祥航空', duration: 200, flightList: [{ flightNo: 'HO5577', departureAirportName: '虹桥', arrivalAirportName: '三义', departureDateTime: '2026-10-01 07:35:00', arrivalDateTime: '2026-10-01 10:55:00' }] }],
73
+ priceList: [{ adultPrice: 3240 }, { adultPrice: 2980 }],
74
+ }] } });
75
+ const rHit = parseBatchSearchResult(fixtureHit);
76
+ assert(rHit.verdict === 'hit' && rHit.options.length === 1 && rHit.options[0]?.flightNo === 'HO5577' && rHit.options[0]?.price === 2980, '命中 fixture → hit + 1 option + 最低价', rHit);
77
+ const rMiss = parseBatchSearchResult(JSON.stringify({ data: { flightItineraryList: [] } }));
78
+ assert(rMiss.verdict === 'miss' && rMiss.options.length === 0, '已识别合法空(数组存在且空) → miss(不为 hit 也不为 error)', rMiss);
79
+ // 不抛错 + 形状未识别 → error(关键:不可静默收敛为 miss,把未知响应误判为「这条线路没航班」)
80
+ const malformed = [
81
+ ['JSON null body', 'null'],
82
+ ['非对象根(数字)', '123'],
83
+ ['非对象根(数组)', '[1,2,3]'],
84
+ ['空对象', '{}'],
85
+ ['缺 data 字段', JSON.stringify({ foo: 1 })],
86
+ ['data 非对象(null)', JSON.stringify({ data: null })],
87
+ ['data 是数组', JSON.stringify({ data: [] })],
88
+ ['flightItineraryList 非数组(对象)', JSON.stringify({ data: { flightItineraryList: { foo: 1 } } })],
89
+ ['flightItineraryList 非数组(字符串)', JSON.stringify({ data: { flightItineraryList: 'abc' } })],
90
+ ['flightItineraryList 非数组(数字)', JSON.stringify({ data: { flightItineraryList: 42 } })],
91
+ ['非 JSON 文本', 'not-json{'],
92
+ ['空字符串', ''],
93
+ ];
94
+ for (const [label, body] of malformed) {
95
+ let verdict = 'error';
96
+ let optionsLen = -1;
97
+ let threw = false;
98
+ try {
99
+ const r = parseBatchSearchResult(body);
100
+ verdict = r.verdict;
101
+ optionsLen = r.options.length;
102
+ }
103
+ catch {
104
+ threw = true;
105
+ }
106
+ assert(!threw && verdict === 'error' && optionsLen === 0, `${label} → verdict=error(不抛,不为 miss)`, { verdict, optionsLen, threw });
107
+ }
108
+ // priceList 非数组:item 行字段虽合法,但整行结构不可信 → error,不暴露 options
109
+ const priceMalformed = JSON.stringify({ data: { flightItineraryList: [{
110
+ flightSegments: [{ flightList: [{ flightNo: 'HO1', departureDateTime: '2026-10-01 07:35:00' }] }],
111
+ priceList: 'not-an-array',
112
+ }] } });
113
+ const rPm = parseBatchSearchResult(priceMalformed);
114
+ assert(rPm.verdict === 'error' && rPm.options.length === 0, 'priceList 显式非数组 → error 且不暴露 options', rPm);
115
+ // 已识别列表非空 + 行全部畸形 → error(没有可呈现的航段,不伪装成 miss)
116
+ const allBadRows = JSON.stringify({ data: { flightItineraryList: [
117
+ { flightSegments: [{}] },
118
+ 'not-an-object-row',
119
+ null,
120
+ ] } });
121
+ const rAb = parseBatchSearchResult(allBadRows);
122
+ assert(rAb.verdict === 'error' && rAb.options.length === 0, '非空列表行全畸形 → error(不伪装成 miss)', rAb);
123
+ // 混合列表保留有效项,忽略畸形兄弟行
124
+ const mixed = JSON.stringify({ data: { flightItineraryList: [
125
+ { flightSegments: [{}], priceList: [{ adultPrice: 'bad' }] },
126
+ { flightSegments: [{ flightList: [{ flightNo: 'HO2', departureDateTime: '2026-10-01 08:00:00' }] }], priceList: [] },
127
+ ] } });
128
+ const rMixed = parseBatchSearchResult(mixed);
129
+ assert(rMixed.verdict === 'hit' && rMixed.options.length === 1 && rMixed.options[0]?.flightNo === 'HO2' && rMixed.options[0]?.price === 0, '混合列表 → 保留有效项并忽略畸形兄弟行', rMixed);
130
+ // 兼容性:旧 API parseBatchSearch 与新 API options 字段逐项一致
131
+ const compatParsed = parseBatchSearch(fixtureHit);
132
+ assert(compatParsed.length === rHit.options.length && compatParsed[0]?.flightNo === rHit.options[0]?.flightNo && compatParsed[0]?.price === rHit.options[0]?.price, '旧 API parseBatchSearch 返回的 options 字段与新 API 一致(兼容性)', { compat: compatParsed[0], new: rHit.options[0] });
133
+ }
134
+ // C. adapter entry URL
75
135
  console.log('C. buildEntryUrl(城市码表)');
76
136
  const e1 = buildEntryUrl('上海', '丽江', '2026-10-01');
77
137
  assert(e1.ok === true && e1.url === 'https://flights.ctrip.com/online/list/oneway-sha-ljg?depdate=2026-10-01', '上海→丽江 URL 精确');
78
138
  const e2 = buildEntryUrl('乌兰巴托', '丽江', '2026-10-01');
79
139
  assert(e2.ok === false && e2.unresolved?.[0] === '乌兰巴托', '词表外城市 unresolved 逐字保留(不猜)');
80
140
  assert(buildEntryUrl('上海', '丽江', '十月一号').ok === false, '非法日期拒绝');
141
+ // D. DOM 提交件过滤
81
142
  console.log('D. isSubmitText(DOM 提交件黑名单)');
82
143
  assert(isSubmitText('立即下单') && isSubmitText('去支付') && isSubmitText('提交订单'), '下单/支付/提交 命中');
83
144
  assert(!isSubmitText('搜索') && !isSubmitText('筛选'), '搜索/筛选 不误伤');
145
+ // E. 节律闸(无需 Chrome:闸在 entry 构建前触发)
84
146
  console.log('E. 节律闸(≥30s 间隔)');
85
147
  __resetRateLimiterForTest();
86
- const r1 = await sessionFlightSearch({
87
- from: '乌兰巴托',
88
- to: '丽江',
89
- date: '2026-10-01'
90
- });
91
- const r2 = await sessionFlightSearch({
92
- from: '上海',
93
- to: '丽江',
94
- date: '2026-10-01'
95
- });
148
+ const r1 = await sessionFlightSearch({ from: '乌兰巴托', to: '丽江', date: '2026-10-01' });
149
+ const r2 = await sessionFlightSearch({ from: '上海', to: '丽江', date: '2026-10-01' });
96
150
  assert(r1.verdict === 'error' && /unresolved/.test(r1.error ?? ''), '首次调用:词表外 → error(unresolved)');
97
151
  assert(r2.verdict === 'cooldown', '30s 内二次调用 → cooldown,不发起导航');
98
152
  __resetRateLimiterForTest();
153
+ // F. transport 失败分类 + live FlyAI 官方通道
99
154
  console.log('F. transport verdict + flyaiSearch(live,飞猪官方,无 key;GOTRY_SESSION_LIVE=0 跳过)');
100
155
  assert(classifyTransportFailure('日常 Chrome 未开调试端口', true) === 'needs-attach', '调试端口未开稳定投影 needs-attach');
101
156
  assert(classifyTransportFailure('cdp attach 失败:socket closed', true) === 'needs-attach', 'CDP 握手失败稳定投影 needs-attach');
@@ -103,43 +158,34 @@ assert(classifyTransportFailure('chrome launch failed', false) === 'error', '隔
103
158
  let fr = null;
104
159
  if (process.env.GOTRY_SESSION_LIVE === '0') {
105
160
  console.log(' SKIP - GOTRY_SESSION_LIVE=0(离线门禁不调用 FlyAI 外部实时端点)');
106
- } else {
107
- fr = await flyaiSearch({
108
- kind: 'flight',
109
- origin: '上海',
110
- destination: '丽江',
111
- depDate: '2026-10-01'
112
- });
161
+ }
162
+ else {
163
+ fr = await flyaiSearch({ kind: 'flight', origin: '上海', destination: '丽江', depDate: '2026-10-01' });
113
164
  const sentinelBlocked = fr.verdict === 'error' && /sentinel|block|trial limit/i.test(fr.error ?? '');
114
165
  if (sentinelBlocked) {
115
166
  console.log(' WARN - 飞猪上游限流(Sentinel 2026-08-28 / trial-limit 429 2026-08-31 实测,配额未文档化)——降级合同验证通过,跳过 hit 断言');
116
167
  assert(fr.ok === false && /\[实时API:flyai@error@/.test(fr.evidence), '限流降级:结构化 error + 证据链错误形');
117
- } else {
168
+ }
169
+ else {
118
170
  assert(fr.ok === true && fr.verdict === 'hit', '上海→丽江 hit', fr);
119
- assert((fr.options?.length ?? 0) >= 1 && (fr.options?.every((o)=>o.price > 0 && /^\d+[A-Z]\d+|^[A-Z]{2}\d+/.test(o.no)) ?? false), '结构化字段齐(price>0,航班号形)', fr.options?.[0]);
171
+ assert((fr.options?.length ?? 0) >= 1 && (fr.options?.every((o) => o.price > 0 && /^\d+[A-Z]\d+|^[A-Z]{2}\d+/.test(o.no)) ?? false), '结构化字段齐(price>0,航班号形)', fr.options?.[0]);
120
172
  }
121
173
  assert(/\[实时API:flyai/.test(fr.evidence), '证据链 [实时API:flyai@*]');
122
174
  }
175
+ // F2. 离线回归(issue #24):上游语义失败——CLI exit=0 且 {"data":null,"message":"出发日期非法"}
176
+ // 必须带上游原话走结构化 error,不得吞成 miss(miss 会误导模型「这条线路没有航班」)
123
177
  {
124
178
  const fakeDir = mkdtempSync(join(tmpdir(), 'flyai-fake-'));
125
179
  const fakeCli = join(fakeDir, 'flyai-cli-fake');
126
- writeFileSync(fakeCli, '#!/bin/sh\necho \'{"data":null,"message":"出发日期非法","status":1,"systemMessage":null}\'\nexit 0\n', {
127
- mode: 0o755
128
- });
129
- const fr2 = await flyaiSearch({
130
- kind: 'flight',
131
- origin: '深圳',
132
- destination: '普吉',
133
- depDate: '2026-07-18',
134
- cliBin: fakeCli
135
- });
180
+ writeFileSync(fakeCli, '#!/bin/sh\necho \'{"data":null,"message":"出发日期非法","status":1,"systemMessage":null}\'\nexit 0\n', { mode: 0o755 });
181
+ const fr2 = await flyaiSearch({ kind: 'flight', origin: '深圳', destination: '普吉', depDate: '2026-07-18', cliBin: fakeCli });
136
182
  assert(fr2.ok === false && fr2.verdict === 'error', 'data:null 语义失败 → error 终态(非 miss)', fr2);
137
183
  assert(/出发日期非法/.test(fr2.error ?? '') && /flyai@error@/.test(fr2.evidence), '上游原话透传 + 证据链错误形', fr2);
138
- rmSync(fakeDir, {
139
- recursive: true,
140
- force: true
141
- });
142
- }{
184
+ rmSync(fakeDir, { recursive: true, force: true });
185
+ }
186
+ // F3. stdout 截断回归(issue #84):CLI 分片异步写 >64KB 后立即 exit——
187
+ // 管道消费丢未 flush 尾部(实测截 ~7.6KB,静默 exit=0);文件重定向同步写不丢。
188
+ {
143
189
  const bigDir = mkdtempSync(join(tmpdir(), 'flyai-trunc-'));
144
190
  const payloadJs = join(bigDir, 'payload.js');
145
191
  const itemCount = 100;
@@ -162,521 +208,353 @@ for (let i = 0; i < 100; i++) process.stdout.write(payload.slice(i * step, (i +
162
208
  process.exit(0)
163
209
  `);
164
210
  const bigBin = join(bigDir, 'flyai-cli-big');
165
- writeFileSync(bigBin, `#!/bin/sh\nexec node "${payloadJs}"\n`, {
166
- mode: 0o755
167
- });
168
- const tr = await flyaiSearch({
169
- kind: 'flight',
170
- origin: '上海',
171
- destination: '昆明',
172
- depDate: '2026-09-15',
173
- cliBin: bigBin
174
- });
175
- assert(tr.ok === true && tr.verdict === 'hit' && tr.options?.length === itemCount, `大载荷 ${itemCount} 条(>64KB)完整解析无截断(#84)`, {
176
- verdict: tr.verdict,
177
- options: tr.options?.length,
178
- error: tr.error
179
- });
180
- rmSync(bigDir, {
181
- recursive: true,
182
- force: true
183
- });
184
- }console.log('G. sessionFlightSearch(live,Chrome+携程;默认跳过,GOTRY_SESSION_LIVE=1 显式开启)');
211
+ writeFileSync(bigBin, `#!/bin/sh\nexec node "${payloadJs}"\n`, { mode: 0o755 });
212
+ const tr = await flyaiSearch({ kind: 'flight', origin: '上海', destination: '昆明', depDate: '2026-09-15', cliBin: bigBin });
213
+ assert(tr.ok === true && tr.verdict === 'hit' && tr.options?.length === itemCount, `大载荷 ${itemCount} 条(>64KB)完整解析无截断(#84)`, { verdict: tr.verdict, options: tr.options?.length, error: tr.error });
214
+ rmSync(bigDir, { recursive: true, force: true });
215
+ }
216
+ // G. live 会话检索(默认 SKIP:例行动回归**永不**自启浏览器窗口—— founder 2026-08-29 反馈
217
+ // 「匿名窗口反复打开携程/浏览器闪退」= 测试骚扰,live 须显式 GOTRY_SESSION_LIVE=1 请求)
218
+ console.log('G. sessionFlightSearch(live,Chrome+携程;默认跳过,GOTRY_SESSION_LIVE=1 显式开启)');
185
219
  if (process.env.GOTRY_SESSION_LIVE !== '1') {
186
220
  console.log(' SKIP - 会话 live 探针默认关(测试不再自动开浏览器窗口;GOTRY_SESSION_LIVE=1 显式开启)');
187
- } else {
221
+ }
222
+ else {
188
223
  __resetRateLimiterForTest();
189
224
  const iso = mkdtempSync(join(tmpdir(), 'gotry-session-test-'));
190
- const nl = await sessionFlightSearch({
191
- from: '上海',
192
- to: '丽江',
193
- date: '2026-10-01',
194
- profileDir: join(iso, 'anon-profile'),
195
- headless: true
196
- });
225
+ // G1 登录闸合同:匿名实例默认拒绝(headless 本地启 Chrome,不触网)
226
+ const nl = await sessionFlightSearch({ from: '上海', to: '丽江', date: '2026-10-01', profileDir: join(iso, 'anon-profile'), headless: true });
197
227
  assert(nl.verdict === 'needs-login' && nl.ok === false, '匿名实例 → needs-login(不导航不发请求)', nl);
198
228
  __resetRateLimiterForTest();
199
- const sr = await sessionFlightSearch({
200
- from: '上海',
201
- to: '丽江',
202
- date: '2026-10-01',
203
- profileDir: join(iso, 'profile'),
204
- headless: false,
205
- allowAnonymous: true,
206
- auditPath: join(iso, 'audit', 'session-incidents.jsonl')
207
- });
229
+ // G2 链路自检:allowAnonymous 显式开闸后,嗅探/解析/守卫全链验证(真实登录态走 scripts/session-login.ts 后的默认 profile)
230
+ const sr = await sessionFlightSearch({ from: '上海', to: '丽江', date: '2026-10-01', profileDir: join(iso, 'profile'), headless: false, allowAnonymous: true, auditPath: join(iso, 'audit', 'session-incidents.jsonl') });
208
231
  if (sr.verdict === 'challenged') {
209
232
  console.log(` WARN - 风控命中(平台方差,合同路径验证通过):${sr.error}`);
210
233
  assert(sr.ok === false && sr.verdict === 'challenged', 'challenged = degraded 语义正确(不重试不绕过)');
211
- } else if (sr.verdict === 'error' && /chrome launch failed/.test(sr.error ?? '')) {
234
+ }
235
+ else if (sr.verdict === 'error' && /chrome launch failed/.test(sr.error ?? '')) {
212
236
  console.log(' SKIP - 本机无 Chrome(channel:chrome 不可用)');
213
- } else if (sr.verdict === 'error' && /chrome launch failed/i.test(sr.error ?? '')) {
237
+ }
238
+ else if (sr.verdict === 'error' && /chrome launch failed/i.test(sr.error ?? '')) {
214
239
  console.log(' SKIP - 本机无 Chrome(channel:chrome 不可用)');
215
- } else if ((sr.verdict === 'miss' || sr.verdict === 'error') && /blocked=0/.test(sr.evidence)) {
240
+ }
241
+ else if ((sr.verdict === 'miss' || sr.verdict === 'error') && /blocked=0/.test(sr.evidence)) {
242
+ // 匿名自检态 0 options = 外部会话数据面未取回(站点方差/匿名态;三值语义与 Sentinel 限流同构:
243
+ // miss ≠ 链路断,守卫/证据/审计合同仍在 miss 证据里验证)。真 hit 收敛挂 founder 登录态(D-13)。
216
244
  console.log(` SKIP - 会话数据面 miss(外部方差,合同降级语义):${sr.evidence.slice(0, 90)}`);
217
- } else if (sr.verdict === 'error' && /timeout/i.test(sr.error ?? '')) {
245
+ }
246
+ else if (sr.verdict === 'error' && /timeout/i.test(sr.error ?? '')) {
247
+ // 页面 30s 未完成加载(外部网络方差;与 flyai 端点不可达降级同构:可达性不进合并闸)
218
248
  console.log(` SKIP - 携程页面加载超时(外部方差,合同降级语义):${String(sr.error).slice(0, 80)}`);
219
249
  assert(sr.ok === false && /\[会话:ctrip-flight@error@/.test(sr.evidence), '超时降级仍带证据链错误形');
220
- } else {
250
+ }
251
+ else {
221
252
  assert(sr.ok === true && sr.verdict === 'hit' && (sr.options?.length ?? 0) >= 1, '上海→丽江 会话嗅探 hit', sr);
222
- assert(sr.options?.every((o)=>o.price > 0 && o.depDateTime.includes('2026-10-01')) ?? false, '班期=查询日,价格>0', sr.options?.[0]);
253
+ assert(sr.options?.every((o) => o.price > 0 && o.depDateTime.includes('2026-10-01')) ?? false, '班期=查询日,价格>0', sr.options?.[0]);
223
254
  assert(/\[会话:ctrip-flight@/.test(sr.evidence) && /blocked=0/.test(sr.evidence), '证据链 [会话:*] + ReadGuard 零拦截(纯只读)');
224
255
  assert(!existsSync(join(iso, 'audit', 'session-incidents.jsonl')), '审计文件不出现(零写请求)');
225
- const flyaiMin = Math.min(...fr?.options?.map((o)=>o.price).filter((p)=>p > 0) ?? [
226
- 0
227
- ]);
228
- const sessionMin = Math.min(...sr.options?.map((o)=>o.price).filter((p)=>p > 0) ?? [
229
- 0
230
- ]);
256
+ // 双源对照(记录式):FlyAI vs 携程会话 同查询最低价
257
+ const flyaiMin = Math.min(...(fr?.options?.map((o) => o.price).filter((p) => p > 0) ?? [0]));
258
+ const sessionMin = Math.min(...(sr.options?.map((o) => o.price).filter((p) => p > 0) ?? [0]));
231
259
  console.log(` 双源对照:FlyAI 最低 ¥${flyaiMin} vs 会话(携程)最低 ¥${sessionMin}(同日同线路,记录不判等)`);
232
260
  }
233
- rmSync(iso, {
234
- recursive: true,
235
- force: true
236
- });
261
+ rmSync(iso, { recursive: true, force: true });
237
262
  }
263
+ // H. 酒店通道(2026-08-29 平铺接入):live 双合法终态 + 离线解析 + 参数闸
238
264
  console.log('H. flyaiSearch hotel(飞猪官方 search-hotel)');
239
265
  {
266
+ // H1 live:带日期两形态(成对可选;未定档期可不带日期)
240
267
  if (process.env.GOTRY_SESSION_LIVE === '0') {
241
268
  console.log(' SKIP - GOTRY_SESSION_LIVE=0(离线门禁不调用 FlyAI 酒店实时端点)');
242
- } else {
243
- const hr = await flyaiSearch({
244
- kind: 'hotel',
245
- destName: '大理',
246
- checkInDate: '2026-10-01',
247
- checkOutDate: '2026-10-03'
248
- });
269
+ }
270
+ else {
271
+ const hr = await flyaiSearch({ kind: 'hotel', destName: '大理', checkInDate: '2026-10-01', checkOutDate: '2026-10-03' });
249
272
  const hSentinel = hr.verdict === 'error' && /sentinel|block/i.test(hr.error ?? '');
250
273
  if (hSentinel) {
251
274
  console.log(' WARN - 飞猪 Sentinel 限流——降级合同验证通过,跳过 hit 断言');
252
275
  assert(hr.ok === false && /\[实时API:flyai@error@/.test(hr.evidence), '酒店限流降级:结构化 error + 证据链错误形');
253
- } else if (hr.ok === false) {
276
+ }
277
+ else if (hr.ok === false) {
254
278
  console.log(` WARN - flyai hotel 端点降级(${String(hr.error).slice(0, 60)})——证据链合同通过,hit 断言跳过`);
255
279
  assert(/\[实时API:flyai@error@/.test(hr.evidence), '端点降级仍带证据链错误形');
256
- } else {
280
+ }
281
+ else {
257
282
  assert(hr.verdict === 'hit' && (hr.hotels?.length ?? 0) >= 1, '大理酒店 hit', hr.hotels?.[0]);
258
- assert(hr.hotels?.every((h)=>!!h.name && !!h.jumpUrl) ?? false, '条目结构化(name + jumpUrl 透传)', hr.hotels?.[0]);
283
+ assert(hr.hotels?.every(h => !!h.name && !!h.jumpUrl) ?? false, '条目结构化(name + jumpUrl 透传)', hr.hotels?.[0]);
259
284
  }
260
285
  assert(/\[实时API:flyai/.test(hr.evidence), '证据链 [实时API:flyai@*]');
261
286
  }
287
+ // H2 离线 malformed-batch 合同:有效酒店 + malformed sibling → 整体 error,zero facts
262
288
  const fakeDir = mkdtempSync(join(tmpdir(), 'flyai-hotel-fake-'));
263
289
  const fakeCliH = join(fakeDir, 'flyai-hotel-fake');
264
- writeFileSync(fakeCliH, '#!/bin/sh\necho \'{"data":{"itemList":[{"name":"大理A 酒店","shId":"1","star":"高档型","rate":null,"price":"\\u00a57xx","address":"addr","interestsPoi":"近洱海","detailUrl":"https://router.feizhu.com/x"},{"star":"舒适型"}]}}\'\nexit 0\n', {
265
- mode: 0o755
266
- });
267
- const h2 = await flyaiSearch({
268
- kind: 'hotel',
269
- destName: '大理',
270
- cliBin: fakeCliH
271
- });
272
- assert(h2.verdict === 'hit' && h2.hotels?.length === 1, '酒店解析:缺名条目跳过,1 条有效', h2);
273
- const h0 = h2.hotels?.[0];
290
+ writeFileSync(fakeCliH, '#!/bin/sh\necho \'{"data":{"itemList":[{"name":"大理A 酒店","shId":"1","star":"高档型","rate":null,"price":"\\u00a57xx","address":"addr","interestsPoi":"近洱海","detailUrl":"https://router.feizhu.com/x"},{"star":"舒适型"}]}}\'\nexit 0\n', { mode: 0o755 });
291
+ const h2 = await flyaiSearch({ kind: 'hotel', destName: '大理', cliBin: fakeCliH });
292
+ assert(h2.verdict === 'error' && h2.hotels === undefined, '酒店有效+malformed sibling → 整体 structured error', h2);
293
+ assert(/hotel itemList malformed.*1\/2/i.test(h2.evidence), '酒店 mixed error 暴露 malformed/总条目比例', h2);
294
+ const h2Facts = factsFromHotel({ source: 'flyai-hotel', destination: '大理', checkIn: '2026-10-01', checkOut: '2026-10-03', verdict: h2.verdict, options: 0, evidence: h2.evidence, fetchedAt: new Date().toISOString() });
295
+ assert(h2Facts.length === 0, '酒店 mixed error 不落酒店事实', h2Facts);
296
+ // H3 单一完整合法酒店 → hit/1,字段保真(priceRaw/star/jumpUrl)
297
+ writeFileSync(fakeCliH, '#!/bin/sh\necho \'{"data":{"itemList":[{"name":"大理A 酒店","shId":"1","star":"高档型","rate":null,"price":"\\u00a57xx","address":"addr","interestsPoi":"近洱海","detailUrl":"https://router.feizhu.com/x"}]}}\'\nexit 0\n', { mode: 0o755 });
298
+ const h3 = await flyaiSearch({ kind: 'hotel', destName: '大理', cliBin: fakeCliH });
299
+ assert(h3.verdict === 'hit' && h3.hotels?.length === 1, '单一完整酒店 → hit/1', h3);
300
+ const h0 = h3.hotels?.[0];
274
301
  assert(h0?.name === '大理A 酒店' && h0?.priceRaw === '¥7xx' && h0?.price === 0, '打码价保 priceRaw 原值(数字价 0)', h0);
275
302
  assert(h0?.star === '高档型' && h0?.hotelId === '1' && h0?.jumpUrl === 'https://router.feizhu.com/x', 'star/jumpUrl(shId/detailUrl)透传', h0);
276
- const hb1 = await flyaiSearch({
277
- kind: 'hotel'
278
- });
303
+ // H4 参数闸:无目的地 / 日期不成对 / 非规整日期 都走结构化 error,不发上游
304
+ const hb1 = await flyaiSearch({ kind: 'hotel' });
279
305
  assert(hb1.verdict === 'error' && /destName|目的地/.test(hb1.error ?? ''), '缺目的地 → bad args error', hb1);
280
- const hb2 = await flyaiSearch({
281
- kind: 'hotel',
282
- destName: '大理',
283
- checkInDate: '2026-10-01'
284
- });
306
+ const hb2 = await flyaiSearch({ kind: 'hotel', destName: '大理', checkInDate: '2026-10-01' });
285
307
  assert(hb2.verdict === 'error' && /成对/.test(hb2.error ?? ''), '入住/退房不成对 → error(不静默)', hb2);
286
- assert((await flyaiSearch({
287
- kind: 'hotel',
288
- destName: '大理',
289
- checkInDate: '10月1号',
290
- checkOutDate: '2026-10-03'
291
- })).verdict === 'error', '非法日期格式 → error');
292
- rmSync(fakeDir, {
293
- recursive: true,
294
- force: true
295
- });
296
- }console.log('I. createConsentGate(账号会话授权:每会话一次/拒绝吊销/allow/off/无通道)');
308
+ assert((await flyaiSearch({ kind: 'hotel', destName: '大理', checkInDate: '10月1号', checkOutDate: '2026-10-03' })).verdict === 'error', '非法日期格式 → error');
309
+ rmSync(fakeDir, { recursive: true, force: true });
310
+ }
311
+ // I. 账号会话授权闸(纯函数;v2 语义:每会话每站点一次,拒绝=会话内吊销,不再逐次弹卡)
312
+ console.log('I. createConsentGate(账号会话授权:每会话一次/拒绝吊销/allow/off/无通道)');
297
313
  {
298
- const next = async ()=>({
299
- kind: 'allow'
300
- });
301
- const agentA = {
302
- id: 'agent-A'
303
- };
304
- const agentB = {
305
- id: 'agent-B'
306
- };
307
- const sess = (a = agentA)=>({
308
- name: 'gotry_session_search',
309
- agent: a,
310
- callId: 'c1'
311
- });
312
- const other = ()=>({
313
- name: 'gotry_anything_search',
314
- agent: agentA
315
- });
316
- const mkStore = ()=>new WeakMap();
317
- const mkGate = (access, seam)=>createConsentGate({
318
- access: ()=>access,
319
- approval: seam ? ()=>seam : undefined,
320
- store: mkStore()
321
- });
322
- const gateBare = createConsentGate({
323
- access: ()=>'ask'
324
- });
325
- const d1 = await gateBare({
326
- name: 'gotry_session_search',
327
- agent: undefined
328
- }, next);
329
- assert(d1.kind === 'ask', '无审批通道 → ask(交运行时 fail-closed;denies 责任在 registry)', d1);
330
- assert((await gateBare({
331
- name: 'gotry_anything_search',
332
- agent: undefined
333
- }, next)).kind === 'allow', '非账号工具不过闸,原样放行');
314
+ const next = async () => ({ kind: 'allow' });
315
+ const agentA = { id: 'agent-A' };
316
+ const agentB = { id: 'agent-B' };
317
+ const sess = (a = agentA) => ({ name: 'gotry_session_search', agent: a, callId: 'c1', arguments: { kind: 'flight' } });
318
+ const other = () => ({ name: 'gotry_anything_search', agent: agentA });
319
+ const mkStore = () => new WeakMap();
320
+ const mkGate = (access, seam) => createConsentGate({ access: () => access, approval: seam ? () => seam : undefined, store: mkStore() });
321
+ // I1 无审批通道(headless/极简宿主):账号工具 → ask(交运行时 fail-closed);非账号工具放行
322
+ const gateBare = createConsentGate({ access: () => 'ask' });
323
+ const d1 = await gateBare({ name: 'gotry_session_search', agent: undefined, arguments: { from: '上海' } }, next);
324
+ assert(d1.kind === 'ask' && /携程机票/.test(String(d1.reason ?? '')), '缺省 kind → flight 默认并交运行时 fail-closed ask', d1);
325
+ assert((await gateBare({ name: 'gotry_anything_search', agent: undefined }, next)).kind === 'allow', '非账号工具不过闸,原样放行');
326
+ // I2 批准一次 → 会话内记住:第二次免弹卡直接放行(审批请求计数恒 1)
334
327
  {
335
328
  let requests = 0;
336
- const seam = {
337
- request: async ()=>{
338
- requests += 1;
339
- return 'allowed-once';
340
- }
341
- };
342
- const gate = createConsentGate({
343
- access: ()=>'ask',
344
- approval: ()=>seam
345
- });
329
+ const seam = { request: async () => { requests += 1; return 'allowed-once'; } };
330
+ const gate = createConsentGate({ access: () => 'ask', approval: () => seam });
346
331
  const r1 = await gate(sess(), next);
347
- assert(r1.kind === 'allow' && requests === 1, '首次调用:弹卡一次,批准后放行', {
348
- r1,
349
- requests
350
- });
332
+ assert(r1.kind === 'allow' && requests === 1, '首次调用:弹卡一次,批准后放行', { r1, requests });
351
333
  const r2 = await gate(sess(), next);
352
- assert(r2.kind === 'allow' && requests === 1, '会话内第二次调用免弹卡直接放行(不重复骚扰)', {
353
- r2: r1,
354
- requests
355
- });
334
+ assert(r2.kind === 'allow' && requests === 1, '会话内第二次调用免弹卡直接放行(不重复骚扰)', { r2: r1, requests });
356
335
  }
336
+ // I3 拒绝 = 本会话吊销:deny 且不再弹卡;另一会话不受影响
357
337
  {
358
338
  let requests = 0;
359
- const seam = {
360
- request: async ()=>{
361
- requests += 1;
362
- return 'rejected';
363
- }
364
- };
339
+ const seam = { request: async () => { requests += 1; return 'rejected'; } };
365
340
  const store = mkStore();
366
- const gate = createConsentGate({
367
- access: ()=>'ask',
368
- approval: ()=>seam,
369
- store
370
- });
341
+ const gate = createConsentGate({ access: () => 'ask', approval: () => seam, store });
371
342
  const d1 = await gate(sess(), next);
372
343
  assert(d1.kind === 'deny' && /拒绝/.test(String(d1.kind === 'deny' ? d1.reason : '')), '拒绝 → deny + 明示「本会话内生效」', d1);
373
344
  const d2 = await gate(sess(), next);
374
- assert(d2.kind === 'deny' && requests === 1, '拒绝后再次调用 → 直接 deny,不再弹卡(拒绝=吊销)', {
375
- d2
376
- });
377
- const dB = await gate({
378
- name: 'gotry_session_search',
379
- agent: agentB
380
- }, next);
381
- assert(dB.kind === 'deny' && requests === 2, '另一会话不受此前拒绝影响——会重新发起一次审批请求(seam 本例仍拒)', {
382
- dB,
383
- requests
384
- });
345
+ assert(d2.kind === 'deny' && requests === 1, '拒绝后再次调用 → 直接 deny,不再弹卡(拒绝=吊销)', { d2 });
346
+ const dB = await gate({ name: 'gotry_session_search', agent: agentB, arguments: { kind: 'flight' } }, next);
347
+ assert(dB.kind === 'deny' && requests === 2, '另一会话不受此前拒绝影响——会重新发起一次审批请求(seam 本例仍拒)', { dB, requests });
385
348
  }
349
+ // I4 off 总闸:不弹卡直接 deny;非账号工具不受影响
386
350
  {
387
351
  const gate = mkGate('off');
388
352
  const d = await gate(sess(), next);
389
353
  assert(d.kind === 'deny' && /sessionAccess=off/.test(String(d.kind === 'deny' ? d.reason : '')), 'off → 不弹卡直接 deny', d);
354
+ const train = await gate({ name: 'gotry_session_search', agent: agentA, arguments: { kind: 'train' } }, next);
355
+ assert(train.kind === 'deny' && /sessionAccess=off/.test(String(train.kind === 'deny' ? train.reason : '')), 'off → train 公开查询面同样 deny(不绕过总闸)', train);
390
356
  assert((await gate(other(), next)).kind === 'allow', 'off 只关账号面工具,其余放行');
391
357
  }
358
+ // I5 allow(配置级预授权):直接放行,不弹卡
392
359
  {
393
360
  const gate = mkGate('allow');
394
361
  assert((await gate(sess(), next)).kind === 'allow', 'sessionAccess=allow → 配置明示预授权,直接放行');
362
+ assert((await gate({ name: 'gotry_session_search', agent: agentA, arguments: { kind: 'train' } }, next)).kind === 'allow', 'sessionAccess=allow → train 也按总闸预授权放行');
395
363
  }
396
- }console.log('J. sessionLogin(登录引导:无凭证语义/表格完备/pending 语义)');
364
+ // I5b train 的 ask/拒绝隔离:公开查询面仍必须走同一站点授权桶。
365
+ {
366
+ let requests = 0;
367
+ const seam = { request: async () => { requests += 1; return requests === 1 ? 'rejected' : 'allowed-once'; } };
368
+ const trainAgent = { id: 'agent-train' };
369
+ const gate = createConsentGate({ access: () => 'ask', approval: () => seam });
370
+ const rejected = await gate({ name: 'gotry_session_search', agent: trainAgent, arguments: { kind: 'flight' } }, next);
371
+ assert(rejected.kind === 'deny' && requests === 1, 'ask 首次 flight 拒绝 → deny + 请求数恰为 1', rejected);
372
+ const allowed = await gate({ name: 'gotry_session_search', agent: trainAgent, arguments: { kind: 'train' } }, next);
373
+ assert(allowed.kind === 'allow' && requests === 2, '拒绝 flight 不污染 train:另一站点恰再请求一次并允许', allowed);
374
+ const cached = await gate({ name: 'gotry_session_search', agent: trainAgent, arguments: { kind: 'train' } }, next);
375
+ assert(cached.kind === 'allow' && requests === 2, 'train 同站批准缓存:后续调用不再请求', cached);
376
+ }
377
+ // I6 site-bound(#308):授权与拒绝均按 site(kind)分桶,跨站不得互授;同站复用放行;
378
+ // unknown/malformed kind 失败关闭(不扩权);query-first 冲突形态与 flat args 一致。
379
+ {
380
+ let requests = 0;
381
+ const seam = { request: async () => { requests += 1; return 'allowed-once'; } };
382
+ const store = mkStore();
383
+ const gate = createConsentGate({ access: () => 'ask', approval: () => seam, store });
384
+ // 6a flat args:首次 dida 弹卡 + 放行;同会话再调 ctrip-flight 必须再弹卡(跨站不互授)
385
+ const r1 = await gate({ name: 'gotry_session_search', agent: agentA, callId: 'c-dida', arguments: { kind: 'dida' } }, next);
386
+ assert(r1.kind === 'allow' && requests === 1, 'flat args:首次 dida 弹卡 + 放行', { r1, requests });
387
+ const r2 = await gate({ name: 'gotry_session_search', agent: agentA, callId: 'c-ctrip', arguments: { kind: 'flight' } }, next);
388
+ assert(r2.kind === 'allow' && requests === 2, 'flat args:ctrip-flight 跨站必须再弹卡', { r2, requests });
389
+ // 6b 同站(flight)二次调用:免弹卡直接放行
390
+ const r3 = await gate({ name: 'gotry_session_search', agent: agentA, callId: 'c-ctrip-2', arguments: { kind: 'flight' } }, next);
391
+ assert(r3.kind === 'allow' && requests === 2, 'flat args:ctrip-flight 同站复用,免弹卡', { r3, requests });
392
+ // 6c wrapped args:{ query: { kind: 'hotel' } } 形态必须同样识别为 ctrip-hotel
393
+ const r4 = await gate({ name: 'gotry_session_search', agent: agentA, callId: 'c-hotel', arguments: { query: { kind: 'hotel' }, kind: 'flight' } }, next);
394
+ assert(r4.kind === 'allow' && requests === 3, 'kind=hotel → ctrip-hotel 站点弹卡(独立分桶)', { r4, requests });
395
+ // 6d 拒绝隔离:拒绝 ctrip-flight 后,同会话调 dida 仍须弹卡(deny 不跨站污染)
396
+ let rejReq = 0;
397
+ const seamRej = { request: async () => { rejReq += 1; return 'rejected'; } };
398
+ const gateRej = createConsentGate({ access: () => 'ask', approval: () => seamRej, store });
399
+ const agentRej = { id: 'agent-rej' };
400
+ const d1 = await gateRej({ name: 'gotry_session_search', agent: agentRej, callId: 'r1', arguments: { kind: 'flight' } }, next);
401
+ assert(d1.kind === 'deny' && rejReq === 1, '拒绝 ctrip-flight → deny(弹卡一次)', { d1, rejReq });
402
+ const d2 = await gateRej({ name: 'gotry_session_search', agent: agentRej, callId: 'r2', arguments: { kind: 'dida' } }, next);
403
+ assert(d2.kind === 'deny' && rejReq === 2, '拒绝不跨站污染:同会话 dida 仍须弹卡(被拒后)', { d2, rejReq });
404
+ // 6e unknown kind 失败关闭(不弹卡,不让过;无审批通道语义但带 agent 时更稳)
405
+ const rU = await gate({ name: 'gotry_session_search', agent: agentA, callId: 'c-xx', arguments: { kind: 'not-a-kind' } }, next);
406
+ assert(rU.kind === 'deny', 'unknown kind → fail-closed deny(不扩权)', rU);
407
+ const rMalformed = await gate({ name: 'gotry_session_search', agent: agentA, callId: 'c-malformed', arguments: { kind: 42 } }, next);
408
+ assert(rMalformed.kind === 'deny', 'malformed kind → fail-closed deny(不把异常降成 flight)', rMalformed);
409
+ }
410
+ }
411
+ // J. 登录引导(产品工具 gotry_session_login 的能力层;纯函数/确定性部分)
412
+ // 语义红线(founder):登录永远发生在外部网站——gotry 永不经手密码/验证码/cookie 值,
413
+ // 只读「票据 cookie 名」这个存在性事实(名称级,0 个值过手)。
414
+ console.log('J. sessionLogin(登录引导:无凭证语义/表格完备/pending 语义)');
397
415
  {
398
- for (const [site, t] of Object.entries(LOGIN_TARGETS)){
416
+ // J1 表格完备:每个站点必须有 domain/names/label/entryUrl,票据名表非空
417
+ for (const [site, t] of Object.entries(LOGIN_TARGETS)) {
399
418
  assert(!!t.domain && t.names.length > 0 && !!t.label && t.entryUrl.startsWith('https://'), `${site} 登录目标表完备`, t);
400
419
  }
401
- const unknownSite = await sessionLogin({
402
- site: 'not-a-site',
403
- waitMs: 0
404
- });
420
+ // J2 未知站点 结构化 error,不抛错
421
+ const unknownSite = await sessionLogin({ site: 'not-a-site', waitMs: 0 });
405
422
  assert(unknownSite.ok === false && unknownSite.verdict === 'error' && /未知站点/.test(unknownSite.error ?? ''), '未知站点 → 结构化 error(降级不抛)', unknownSite);
423
+ // J3 pollTicketNames 名称级:只回名字,绝不携带任何 cookie 值(值即便在 fixture 里也到不了 results)
406
424
  {
407
- const fakeBrowser = {
408
- cookies: async ()=>[
409
- {
410
- domain: '.ctrip.com',
411
- name: 'cticket',
412
- value: 'SECRET-TICKET'
413
- },
414
- {
415
- domain: 'ctrip.com',
416
- name: 'uid',
417
- value: 'SECRET-UID'
418
- },
419
- {
420
- domain: 'ctrip.com',
421
- name: 'irrelevant',
422
- value: 'x'
423
- }
424
- ]
425
- };
425
+ const fakeBrowser = { cookies: async () => [
426
+ { domain: '.ctrip.com', name: 'cticket', value: 'SECRET-TICKET' },
427
+ { domain: 'ctrip.com', name: 'uid', value: 'SECRET-UID' },
428
+ { domain: 'ctrip.com', name: 'irrelevant', value: 'x' },
429
+ ] };
426
430
  const names = await pollTicketNames(fakeBrowser, LOGIN_TARGETS['ctrip-flight']);
427
431
  assert(JSON.stringify(names) === '["cticket","uid"]', '票据名级检查:只回名字;值(SOCRET)永不进入结果', names);
428
432
  const joined = JSON.stringify(names);
429
433
  assert(!joined.includes('SECRET'), '存在性检查零值过手(fixture 值不泄露)', joined);
430
434
  }
435
+ // J4 live(opt-in,同 G 节纪律):不交互的短等待 → needs-attach(Chrome 未开调试)或 pending(入口已开)
431
436
  if (process.env.GOTRY_SESSION_LIVE === '1') {
432
- const lr = await sessionLogin({
433
- waitMs: 2500,
434
- pollMs: 500
435
- });
437
+ const lr = await sessionLogin({ waitMs: 2500, pollMs: 500 });
436
438
  if (lr.verdict === 'needs-attach') {
437
439
  assert(lr.ok === false && /chrome:\/\/inspect/.test(lr.error ?? ''), 'live:Chrome 未开调试 → needs-attach + 一次性指引', lr);
438
- } else if (lr.verdict === 'logged-in') {
440
+ }
441
+ else if (lr.verdict === 'logged-in') {
442
+ // 自动检测快路径:票据已在 → 零弹窗直接确认(不打开任何页面)
439
443
  assert(lr.ok === true && (lr.tickets?.length ?? 0) > 0, 'live:已登录自动检测 → logged-in(零弹窗,票据名级)', lr);
440
- } else {
444
+ }
445
+ else {
441
446
  assert(lr.ok === true && lr.verdict === 'pending', 'live:attach 成功不交互 → pending(入口已开,等人登录)', lr);
442
447
  const evidenceTagRe = /\[会话:ctrip-flight-login@/;
443
- if (!evidenceTagRe.test(lr.evidence ?? '')) throw new Error(`FAIL: 登录证据链缺失,实际 ${lr.evidence}`);
448
+ if (!evidenceTagRe.test(lr.evidence ?? ''))
449
+ throw new Error(`FAIL: 登录证据链缺失,实际 ${lr.evidence}`);
444
450
  pass += 1;
445
451
  console.log(' ok - 登录证据链 [会话:ctrip-flight-login@*] 形态');
446
452
  }
447
- } else {
453
+ }
454
+ else {
448
455
  console.log(' SKIP - 登录 live 探针默认关(GOTRY_SESSION_LIVE=1 opt-in;工具面/桌面入口才真调)');
449
456
  }
450
- }console.log('K. 酒店适配器(buildHotelEntryUrl/走形解析/形状嗅探/城市码指引/节律与日期闸)');
457
+ }
458
+ // K. 酒店会话面(2026-09-03 实装,迪拜 session 复盘:会话面此前只有机票)
459
+ console.log('K. 酒店适配器(buildHotelEntryUrl/走形解析/形状嗅探/城市码指引/节律与日期闸)');
451
460
  {
452
- const h1 = buildHotelEntryUrl({
453
- to: '迪拜',
454
- cityId: 220,
455
- checkIn: '2026-12-01',
456
- checkOut: '2026-12-03',
457
- adults: 2
458
- });
461
+ // K1 entry URL:cityId 覆盖 / 码表 / 未收录
462
+ const h1 = buildHotelEntryUrl({ to: '迪拜', cityId: 220, checkIn: '2026-12-01', checkOut: '2026-12-03', adults: 2 });
459
463
  assert(h1.ok && h1.url === 'https://hotels.ctrip.com/hotels/list?city=220&checkin=2026-12-01&checkout=2026-12-03&adult=2', 'cityId 显式覆盖 + 参数序', h1);
460
- const h2 = buildHotelEntryUrl({
461
- to: '上海'
462
- });
464
+ const h2 = buildHotelEntryUrl({ to: '上海' });
463
465
  assert(h2.ok && h2.url === 'https://hotels.ctrip.com/hotels/list?city=2', '码表内城市(上海=2,实测校准)', h2);
464
- const h3 = buildHotelEntryUrl({
465
- to: '不在码表的城市'
466
- });
466
+ const h3 = buildHotelEntryUrl({ to: '不在码表的城市' });
467
467
  assert(!h3.ok && h3.unresolved?.includes('不在码表的城市') === true, '未收录城市 unresolved(不猜 id,不造数)', h3);
468
- assert(/city=/.test(hotelCityUnresolvedHint([
469
- '迪拜'
470
- ])), '未收录指引带 cityId 发现路径(web list 页 URL)');
471
- const hDubai = buildHotelEntryUrl({
472
- to: '迪拜'
473
- });
468
+ assert(/city=/.test(hotelCityUnresolvedHint(['迪拜'])), '未收录指引带 cityId 发现路径(web 搜 list 页 URL)');
469
+ // K1b 实测校准抽查:迪拜=220/丽江=37/大理=36(2026-09-03 页面 title 验证)
470
+ const hDubai = buildHotelEntryUrl({ to: '迪拜' });
474
471
  assert(hDubai.ok && hDubai.url.includes('city=220'), '迪拜=220(实测,轨迹里 agent 构造的 id 正确)', hDubai);
475
- const hLijiang = buildHotelEntryUrl({
476
- to: '丽江'
477
- });
472
+ const hLijiang = buildHotelEntryUrl({ to: '丽江' });
478
473
  assert(hLijiang.ok && hLijiang.url.includes('city=37'), '丽江=37(实测)', hLijiang);
474
+ // K2 走形解析:domestic 形态(priceInfo 对象)/扁平形态/打码价/malformed
475
+ // K2b 携程现行真实形态(一方校准 2026-09-03:hotelInfo 包裹 + roomInfo[].priceInfo.price 官方价格路径)
479
476
  const realShape = JSON.stringify({
480
- initListData: {
481
- hotelList: [
482
- {
477
+ initListData: { hotelList: [{
483
478
  hotelInfo: {
484
- summary: {
485
- hotelId: '3732301',
486
- nameInfo: {
487
- name: 'Ibis Styles Dubai Jumeira',
488
- names: [
489
- 'Ibis Styles Dubai Jumeira'
490
- ]
491
- },
492
- hotelStar: {
493
- star: 3
494
- },
495
- positionInfo: {
496
- address: 'Al Mina Road - Jumeirah 1'
497
- }
498
- },
499
- commentInfo: {
500
- commentScore: '4.4'
501
- },
479
+ summary: { hotelId: '3732301', nameInfo: { name: 'Ibis Styles Dubai Jumeira', names: ['Ibis Styles Dubai Jumeira'] }, hotelStar: { star: 3 }, positionInfo: { address: 'Al Mina Road - Jumeirah 1' } },
480
+ commentInfo: { commentScore: '4.4' },
502
481
  roomInfo: [
503
- {
504
- priceInfo: {
505
- price: 520
506
- }
507
- },
508
- {
509
- priceInfo: {
510
- price: 468,
511
- displayPrice: '¥468起'
512
- }
513
- }
514
- ]
515
- }
516
- }
517
- ]
518
- }
482
+ { priceInfo: { price: 520 } },
483
+ { priceInfo: { price: 468, displayPrice: '¥468起' } },
484
+ ],
485
+ },
486
+ }] },
519
487
  });
520
488
  const realHotels = parseCtripHotelList(realShape);
521
489
  assert(realHotels.length === 1, '真实形态(hotelInfo 包裹)解析命中', realHotels);
522
490
  assert(realHotels[0].name === 'Ibis Styles Dubai Jumeira' && realHotels[0].hotelId === '3732301' && realHotels[0].star === 3 && realHotels[0].score === '4.4', '结构化字段(名/id/星/评分)', realHotels[0]);
523
491
  assert(realHotels[0].price === 468, '多房型取最低价(roomInfo[].priceInfo.price 官方路径)', realHotels[0]);
524
492
  assert(realHotels[0].jumpUrl === 'https://hotels.ctrip.com/hotel/3732301', '真实形态 jumpUrl 构造', realHotels[0]);
493
+ // K2c 加密价形态(priceToken 在而无 price):不伪造任何价,条目保留(名/星/评分仍可用)
525
494
  const encryptedShape = JSON.stringify({
526
- hotelList: [
527
- {
495
+ hotelList: [{
528
496
  hotelInfo: {
529
- summary: {
530
- hotelId: '999',
531
- nameInfo: {
532
- names: [
533
- 'Atlantis The Palm'
534
- ]
535
- },
536
- hotelStar: {
537
- star: 5
538
- }
539
- },
540
- commentInfo: {
541
- commentScore: '4.8'
542
- },
543
- roomInfo: [
544
- {
545
- priceInfo: {
546
- priceToken: 'ENCRYPTED_TOKEN',
547
- isHiddenPrice: false
548
- }
549
- }
550
- ]
551
- }
552
- }
553
- ]
497
+ summary: { hotelId: '999', nameInfo: { names: ['Atlantis The Palm'] }, hotelStar: { star: 5 } },
498
+ commentInfo: { commentScore: '4.8' },
499
+ roomInfo: [{ priceInfo: { priceToken: 'ENCRYPTED_TOKEN', isHiddenPrice: false } }],
500
+ },
501
+ }],
554
502
  });
555
503
  const encHotels = parseCtripHotelList(encryptedShape);
556
504
  assert(encHotels.length === 1, '加密价条目不丢弃(名/星/评分仍可呈现)', encHotels);
557
505
  assert(encHotels[0].price === 0 && !encHotels[0].priceRaw, '加密价不伪造价格(价以 jumpUrl 落地页为准)', encHotels[0]);
558
- const displayShape = JSON.stringify({
559
- hotelList: [
560
- {
561
- hotelInfo: {
562
- summary: {
563
- hotelId: '888',
564
- nameInfo: {
565
- names: [
566
- 'Rove Downtown'
567
- ]
568
- }
569
- },
570
- roomInfo: [
571
- {
572
- priceInfo: {
573
- displayPrice: '¥7xx'
574
- }
575
- }
576
- ]
577
- }
578
- }
579
- ]
580
- });
506
+ // K2d displayPrice 字符串兜底(无数值 price 时)
507
+ const displayShape = JSON.stringify({ hotelList: [{ hotelInfo: { summary: { hotelId: '888', nameInfo: { names: ['Rove Downtown'] } }, roomInfo: [{ priceInfo: { displayPrice: '¥7xx' } }] } }] });
581
508
  const dispHotels = parseCtripHotelList(displayShape);
582
509
  assert(dispHotels.length === 1 && dispHotels[0].priceRaw === '¥7xx' && dispHotels[0].price === 0, 'displayPrice 字符串价原样保留(打码口径)', dispHotels[0]);
583
510
  const fixture = JSON.stringify({
584
- data: {
585
- hotelList: [
586
- {
587
- hotelId: 442516,
588
- hotelName: 'Dubai Marriott',
589
- star: 5,
590
- commentScore: 4.7,
591
- position: {
592
- address: 'Sheikh Zayed Rd'
593
- },
594
- priceInfo: {
595
- avgPrice: 680,
596
- total: 1360
597
- }
598
- },
599
- {
600
- hotelId: '999',
601
- hotelName: 'Rove Downtown',
602
- priceInfo: {
603
- priceDisplay: '¥7xx'
604
- }
605
- }
606
- ]
607
- }
511
+ data: { hotelList: [
512
+ { hotelId: 442516, hotelName: 'Dubai Marriott', star: 5, commentScore: 4.7, position: { address: 'Sheikh Zayed Rd' }, priceInfo: { avgPrice: 680, total: 1360 } },
513
+ { hotelId: '999', hotelName: 'Rove Downtown', priceInfo: { priceDisplay: '¥7xx' } },
514
+ ] },
608
515
  });
609
516
  const hotels1 = parseCtripHotelList(fixture);
610
517
  assert(hotels1.length === 2, '走形解析命中 hotelList 数组', hotels1);
611
518
  assert(hotels1[0].name === 'Dubai Marriott' && hotels1[0].price === 680 && hotels1[0].star === 5 && hotels1[0].score === '4.7', '归一化字段(名/价/星/评分)', hotels1[0]);
612
519
  assert(hotels1[0].jumpUrl === 'https://hotels.ctrip.com/hotel/442516', 'jumpUrl 由 hotelId 构造(预订由人完成)', hotels1[0]);
613
520
  assert(hotels1[1].price === 0 && hotels1[1].priceRaw === '¥7xx', '打码价原样保留不数值化(不伪装真价)', hotels1[1]);
614
- const hotels2 = parseCtripHotelList(JSON.stringify({
615
- hotelMatchInfos: [
616
- {
617
- name: 'Atlantis',
618
- price: 2100,
619
- score: '4.8',
620
- hotelId: '123'
621
- }
622
- ]
623
- }));
521
+ const hotels2 = parseCtripHotelList(JSON.stringify({ hotelMatchInfos: [{ name: 'Atlantis', price: 2100, score: '4.8', hotelId: '123' }] }));
624
522
  assert(hotels2.length === 1 && hotels2[0].price === 2100, '扁平形态(顶层 price 数字)同样命中', hotels2);
625
523
  assert(parseCtripHotelList('not json{').length === 0 && parseCtripHotelList('{"a":1}').length === 0, 'malformed/无签名 一律返空(不抛错)');
524
+ // K3 形状嗅探签名
626
525
  assert(looksLikeHotelListBody('{"data":{"hotelList":[]}}') === true, '签名命中 hotelList');
627
526
  assert(looksLikeHotelListBody('{"data":{"userList":[]}}') === false, '无签名不转发(防无关响应误投)');
628
527
  assert(looksLikeHotelListBody('x'.repeat(2_000_001)) === false, '超大响应体不嗅探(上限把关)');
629
- const dPast = await sessionHotelSearch({
630
- to: '上海',
631
- checkIn: '2026-01-01',
632
- checkOut: '2026-01-03'
633
- });
528
+ // K4 闸面:日期对闸(不发上游)/城市未收录 error/节律闸 cooldown(全在 transport 之前,零桥零浏览器)
529
+ const dPast = await sessionHotelSearch({ to: '上海', checkIn: '2026-01-01', checkOut: '2026-01-03' });
634
530
  assert(dPast.ok === false && dPast.verdict === 'error' && /不是未来合法区间/.test(dPast.error ?? ''), '过去入住日代码层拒绝,不发上游', dPast);
635
- const dPair = await sessionHotelSearch({
636
- to: '上海',
637
- checkIn: '2026-12-01'
638
- });
531
+ const dPair = await sessionHotelSearch({ to: '上海', checkIn: '2026-12-01' });
639
532
  assert(dPair.ok === false && /成对/.test(dPair.error ?? ''), 'checkIn/checkOut 须成对', dPair);
640
533
  __resetRateLimiterForTest();
641
- const e1 = await sessionHotelSearch({
642
- to: '不在码表的城市'
643
- });
534
+ const e1 = await sessionHotelSearch({ to: '不在码表的城市' });
644
535
  assert(e1.ok === false && e1.verdict === 'error' && /city=/.test(e1.error ?? ''), '未收录城市 → error + cityId 指引', e1);
645
- const e2 = await sessionHotelSearch({
646
- to: '上海'
647
- });
536
+ const e2 = await sessionHotelSearch({ to: '上海' });
648
537
  assert(e2.ok === false && e2.verdict === 'cooldown', '节律闸:同站点 30s 内第二调 → cooldown(不发起导航)', e2);
649
- }console.log('L. 火车适配器(buildTrainEntryUrl/parseLeftTicketQuery/电报码指引/节律闸)');
538
+ }
539
+ // L. 火车会话面(2026-09-03 实装,12306 公开查询面)纯函数:entry URL/管道行解析/电报码指引/闸面
540
+ console.log('L. 火车适配器(buildTrainEntryUrl/parseLeftTicketQuery/电报码指引/节律闸)');
650
541
  {
651
- const t1 = buildTrainEntryUrl({
652
- from: '上海',
653
- to: '昆明',
654
- date: '2026-12-01'
655
- });
542
+ // L1 entry URL:码表 / 显式电报码 / 未收录
543
+ const t1 = buildTrainEntryUrl({ from: '上海', to: '昆明', date: '2026-12-01' });
656
544
  assert(t1.ok && t1.url.includes('fs=%E4%B8%8A%E6%B5%B7%2CSHH') && t1.url.includes('ts=%E6%98%86%E6%98%8E%2CKMM') && t1.url.includes('date=2026-12-01'), '城市电报码(fs=城市名,SHH 形态)', t1);
657
- const t2 = buildTrainEntryUrl({
658
- from: '丽江',
659
- to: '大理',
660
- date: '2026-12-01',
661
- fromStationTelecode: 'EHM',
662
- toStationTelecode: 'KDM'
663
- });
545
+ const t2 = buildTrainEntryUrl({ from: '丽江', to: '大理', date: '2026-12-01', fromStationTelecode: 'EHM', toStationTelecode: 'KDM' });
664
546
  assert(t2.ok && t2.url.includes('%2CEHM') && t2.url.includes('%2CKDM'), '显式电报码覆盖(码表外城市)', t2);
665
- const t3 = buildTrainEntryUrl({
666
- from: '敦煌',
667
- to: '西双版纳',
668
- date: '2026-12-01'
669
- });
547
+ const t3 = buildTrainEntryUrl({ from: '敦煌', to: '西双版纳', date: '2026-12-01' });
670
548
  assert(!t3.ok && t3.unresolved?.includes('敦煌') === true, '码表外城市 unresolved(官方站表亦无城市组,不猜码)', t3);
671
- assert(/fromStationTelecode/.test(trainStationUnresolvedHint([
672
- '敦煌'
673
- ])), '未收录指引带电报码发现路径(web kyfw 查询页 URL)');
549
+ assert(/fromStationTelecode/.test(trainStationUnresolvedHint(['敦煌'])), '未收录指引带电报码发现路径(web 搜 kyfw 查询页 URL)');
550
+ // 电报码表防漂移:逐条锁定官方站表快照(data/stations-12306-verify.json,
551
+ // 2026-09-03 kyfw station_name.js 校准;曾纠出南宁 NIZ→NNZ)
674
552
  {
675
553
  const snap = JSON.parse(readFileSync(join(import.meta.dirname, '..', '..', 'data', 'stations-12306-verify.json'), 'utf-8'));
676
554
  const names = Object.keys(snap.cities);
677
555
  assert(names.length >= 129, `快照覆盖 ${names.length} 城`);
678
556
  let drift = 0;
679
- for (const [city, v] of Object.entries(snap.cities)){
557
+ for (const [city, v] of Object.entries(snap.cities)) {
680
558
  if (STATION_TELECODES[city] !== v.telecode) {
681
559
  drift += 1;
682
560
  console.log(` FAIL-detail - 电报码漂移: ${city} 表=${STATION_TELECODES[city]} 快照=${v.telecode}`);
@@ -685,13 +563,24 @@ console.log('H. flyaiSearch hotel(飞猪官方 search-hotel)');
685
563
  assert(drift === 0, `电报码表 129 城与官方快照零漂移(漂移 ${drift} 处)`);
686
564
  assert(Object.keys(STATION_TELECODES).length === names.length, '表与快照城市集合一致(无表外多城)');
687
565
  }
688
- const t4 = buildTrainEntryUrl({
689
- from: '南宁',
690
- to: '丽江',
691
- date: '2026-12-01'
692
- });
566
+ const t4 = buildTrainEntryUrl({ from: '南宁', to: '丽江', date: '2026-12-01' });
693
567
  assert(t4.ok && t4.url.includes('%2CNNZ'), '南宁=NNZ(官方站表校准,曾错 NIZ)且丽江已入表=LHM', t4);
694
- const makeRow = (seats)=>{
568
+ const exactResponseUrl = 'https://kyfw.12306.cn/otn/leftTicket/queryG?leftTicketDTO.train_date=2026-12-01&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=KMM';
569
+ const resolvedQuery = resolveTrainQueryTelecodes({ from: '丽江', to: '大理', date: '2026-12-01', fromStationTelecode: 'EHM', toStationTelecode: 'KDM' });
570
+ assert(resolvedQuery.ok && resolvedQuery.telecodes.fromStationTelecode === 'EHM' && resolvedQuery.telecodes.toStationTelecode === 'KDM', '请求电报码解析保留显式覆盖');
571
+ const exactBinding = validateTrainQueryResponseUrl(exactResponseUrl, { fromStationTelecode: 'SHH', toStationTelecode: 'KMM', date: '2026-12-01' });
572
+ assert(exactBinding.ok && exactBinding.binding.url === exactResponseUrl, 'response URL exact host/path/telecodes/date → bound');
573
+ for (const [label, url] of [
574
+ ['wrong host', exactResponseUrl.replace('kyfw.12306.cn', 'evil.example')],
575
+ ['wrong path', exactResponseUrl.replace('/otn/leftTicket/queryG', '/otn/leftTicket/init')],
576
+ ['missing date', exactResponseUrl.replace('leftTicketDTO.train_date=2026-12-01&', '')],
577
+ ['wrong route', exactResponseUrl.replace('from_station=SHH', 'from_station=BJP')],
578
+ ]) {
579
+ assert(!validateTrainQueryResponseUrl(url, { fromStationTelecode: 'SHH', toStationTelecode: 'KMM', date: '2026-12-01' }).ok, `${label} response URL → fail closed`);
580
+ }
581
+ // L2 管道行解析(官方 cN 口径:站名走 data.map,座位桶 20-33 第一方校准;
582
+ // 行按官方索引程序化构造,杜绝手数偏移)
583
+ const makeRow = (seats, canWebBuy = 'Y', startTrainDate = '20261201') => {
695
584
  const c = new Array(56).fill('');
696
585
  c[2] = '24000000G1375';
697
586
  c[3] = 'G1375';
@@ -702,154 +591,227 @@ console.log('H. flyaiSearch hotel(飞猪官方 search-hotel)');
702
591
  c[8] = '07:35';
703
592
  c[9] = '15:27';
704
593
  c[10] = '07:52';
705
- c[11] = 'Y';
594
+ c[11] = canWebBuy;
706
595
  c[12] = 'yp';
707
- c[13] = '20261201';
596
+ c[13] = startTrainDate;
708
597
  c[14] = 'x';
709
598
  c[15] = 'loc';
710
599
  c[16] = '01';
711
600
  c[17] = '02';
712
601
  c[18] = 'Y';
713
602
  c[19] = '0';
714
- for (const [k, v] of Object.entries(seats))c[Number(k)] = v;
603
+ for (const [k, v] of Object.entries(seats))
604
+ c[Number(k)] = v;
715
605
  return c.join('|');
716
606
  };
717
- const entry = buildTrainEntryUrl({
718
- from: '上海',
719
- to: '昆明',
720
- date: '2026-12-01'
721
- });
607
+ const entry = buildTrainEntryUrl({ from: '上海', to: '昆明', date: '2026-12-01' });
722
608
  assert(entry.ok, 'entry 构造成功');
609
+ // 官方 cN 索引探针:第 i 位座位桶写入其下标字串,解析结果必须 label→下标逐一相等
723
610
  const probeSeats = {};
724
- for(let i = 20; i <= 33; i++)probeSeats[i] = String(i);
611
+ for (let i = 20; i <= 33; i++)
612
+ probeSeats[i] = String(i);
725
613
  const probeRow = makeRow(probeSeats);
726
- const probe = parseLeftTicketQuery(JSON.stringify({
727
- data: {
728
- result: [
729
- probeRow
730
- ],
731
- map: {
732
- SHH: '上海南',
733
- KMM: '昆明'
734
- }
735
- }
736
- }), entry.url ?? '');
614
+ const probe = parseLeftTicketQuery(JSON.stringify({ data: { result: [probeRow], map: { SHH: '上海南', KMM: '昆明' } } }), entry.url ?? '');
737
615
  assert(probe.length === 1, '探针行解析', probe);
738
- const expectSeats = [
739
- [
740
- '其他(通勤)',
741
- '20'
742
- ],
743
- [
744
- '高级软卧',
745
- '21'
746
- ],
747
- [
748
- '其他',
749
- '22'
750
- ],
751
- [
752
- '软卧(一等卧)',
753
- '23'
754
- ],
755
- [
756
- '软座',
757
- '24'
758
- ],
759
- [
760
- '特等座',
761
- '25'
762
- ],
763
- [
764
- '无座',
765
- '26'
766
- ],
767
- [
768
- 'yp_b(上游席别)',
769
- '27'
770
- ],
771
- [
772
- '硬卧(二等卧)',
773
- '28'
774
- ],
775
- [
776
- '硬座',
777
- '29'
778
- ],
779
- [
780
- '二等座',
781
- '30'
782
- ],
783
- [
784
- '一等座',
785
- '31'
786
- ],
787
- [
788
- '商务座',
789
- '32'
790
- ],
791
- [
792
- '动卧',
793
- '33'
794
- ]
795
- ];
796
- for (const [label, idxVal] of expectSeats){
616
+ const expectSeats = [['其他(通勤)', '20'], ['高级软卧', '21'], ['其他', '22'], ['软卧(一等卧)', '23'], ['软座', '24'], ['特等座', '25'], ['无座', '26'], ['yp_b(上游席别)', '27'], ['硬卧(二等卧)', '28'], ['硬座', '29'], ['二等座', '30'], ['一等座', '31'], ['商务座', '32'], ['动卧', '33']];
617
+ for (const [label, idxVal] of expectSeats) {
797
618
  assert(probe[0].seats[label] === idxVal, `座位桶 ${label} 下标=${idxVal}(官方 cN)`, probe[0].seats);
798
619
  }
799
620
  assert(probe[0].fromStation === '上海南' && probe[0].toStation === '昆明', '站名= data.map[电报码](官方 cN 口径,非行内索引)', probe[0]);
800
- const trains1 = parseLeftTicketQuery(JSON.stringify({
801
- data: {
802
- result: [
803
- makeRow({
804
- 25: '有',
805
- 26: '有',
806
- 28: '有',
807
- 29: '有',
808
- 30: '有',
809
- 31: '有',
810
- 32: '有'
811
- })
812
- ],
813
- map: {
814
- SHH: '上海南',
815
- KMM: '昆明'
816
- }
817
- }
818
- }), entry.url ?? '');
621
+ assert(parseLeftTicketQueryResult(JSON.stringify({ data: { result: [probeRow], map: { SHH: '上海南', KMM: '昆明' } } }), entry.url ?? '').kind === 'recognized-nonempty', 'typed parser:有效非空批次 → recognized-nonempty');
622
+ const trains1 = parseLeftTicketQuery(JSON.stringify({ data: { result: [makeRow({ 25: '有', 26: '有', 28: '有', 29: '有', 30: '有', 31: '有', 32: '有' })], map: { SHH: '上海南', KMM: '昆明' } } }), entry.url ?? '');
819
623
  assert(trains1.length === 1, '管道行解析命中', trains1);
820
624
  assert(trains1[0].trainCode === 'G1375' && trains1[0].depTime === '07:35' && trains1[0].arrTime === '15:27' && trains1[0].durationMin === 472, '车次/时刻/历时归一化', trains1[0]);
821
625
  assert(trains1[0].seats['商务座'] === '有' && trains1[0].seats['二等座'] === '有' && trains1[0].seats['特等座'] === '有' && trains1[0].seats['硬卧(二等卧)'] === '有', '余票分桶(官方索引)', trains1[0].seats);
822
626
  assert(!('price' in trains1[0]), '列表接口无票价——不伪装价格(诚实面)');
823
- const shifted = makeRow({
824
- 25: ''
825
- }).split('|').slice(1).join('|');
826
- assert(parseLeftTicketQuery(JSON.stringify({
827
- data: {
828
- result: [
829
- shifted
830
- ],
831
- map: {}
832
- }
833
- }), entry.url ?? '').length === 0, '索引漂移行(签名失配)整行跳过,fail-visible');
834
- assert(parseLeftTicketQuery('not json', entry.url ?? '').length === 0 && parseLeftTicketQuery('{"data":{"result":[]}}', entry.url ?? '').length === 0, 'malformed/空 一律返空(不抛错)');
627
+ const shifted = makeRow({ 25: '有' }).split('|').slice(1).join('|');
628
+ assert(parseLeftTicketQuery(JSON.stringify({ data: { result: [shifted], map: {} } }), entry.url ?? '').length === 0, '索引漂移行(签名失配)整行跳过,fail-visible');
629
+ assert(parseLeftTicketQuery('not json', entry.url ?? '').length === 0 && parseLeftTicketQuery('{"data":{"result":[]}}', entry.url ?? '').length === 0, '兼容数组投影:malformed/空 一律返空(不抛错)');
630
+ const malformedLimitedFields = [
631
+ ['invalid departure minute', makeRow({ 30: '有' }).replace('07:35', '07:99'), { SHH: '上海', KMM: '昆明' }],
632
+ ['invalid arrival minute', makeRow({ 30: '有' }).replace('15:27', '15:99'), { SHH: '上海', KMM: '昆明' }],
633
+ ['invalid calendar start train date', makeRow({ 30: '有' }, 'Y', '20261399'), { SHH: '上海', KMM: '昆明' }],
634
+ ['non-string station map value', makeRow({ 30: '有' }), { SHH: 123, KMM: '昆明' }],
635
+ ];
636
+ for (const [label, row, map] of malformedLimitedFields) {
637
+ const outcome = parseLeftTicketQueryResult(JSON.stringify({ data: { result: [row], map } }), entry.url ?? '');
638
+ assert(outcome.kind === 'malformed', `${label} malformed typed outcome`, outcome);
639
+ }
640
+ // L2b. #355 typed outcome + train fact counterexamples. The converter
641
+ // consumes invocation binding only; no caller timestamp/query metadata.
642
+ const requested = { from: '上海', to: '昆明', date: '2026-12-01' };
643
+ const now = new Date('2026-09-10T12:00:00.000Z');
644
+ const binding = {
645
+ requested,
646
+ request: { fromStationTelecode: 'SHH', toStationTelecode: 'KMM', date: requested.date },
647
+ response: {
648
+ url: 'https://kyfw.12306.cn/otn/leftTicket/queryG?leftTicketDTO.train_date=2026-12-01&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=KMM',
649
+ fromStationTelecode: 'SHH', toStationTelecode: 'KMM', date: requested.date,
650
+ },
651
+ batchId: 'fixture-hit-batch', queryId: 'session:12306-train:fixture-hit-batch', fetchedAt: '2026-09-10T11:59:00.000Z',
652
+ };
653
+ for (const [label, row, map] of malformedLimitedFields) {
654
+ const outcome = parseLeftTicketQueryResult(JSON.stringify({ data: { result: [row], map } }), entry.url ?? '');
655
+ assert(outcome.kind === 'malformed' && factsFromSessionTrain(requested, { outcome, collection: binding }, now).length === 0, `${label} → malformed + zero facts`, outcome);
656
+ }
657
+ const available = parseLeftTicketQueryResult(JSON.stringify({ data: { result: [makeRow({ 30: '有' })], map: { SHH: '上海', KMM: '昆明' } } }), entry.url ?? '');
658
+ assert(available.kind === 'recognized-nonempty' && available.trains.length === 1 && hasRecognizedAvailableSeat(available.trains[0]), 'counterexample:valid available row → recognized row + closed-set seat');
659
+ const hitFacts = factsFromSessionTrain(requested, { outcome: available, collection: binding }, now);
660
+ assert(hitFacts.length === 1 && hitFacts[0].kind === 'train' && hitFacts[0].bookability === 'bookable_exact_date' && !('price' in hitFacts[0]), 'valid available row → one bookable train fact, price absent');
661
+ assert(factsFromSessionTrain(requested, { outcome: available, collection: { ...binding, response: undefined } }, now).length === 0, 'hit-shaped outcome without captured response URL → zero facts');
662
+ assert(factsFromSessionTrain(requested, { outcome: available, collection: { ...binding, response: { ...binding.response, url: binding.response.url.replace('SHH', 'SHA') } } }, now).length === 0, 'hit-shaped outcome with mismatched response URL → zero facts');
663
+ const empty = parseLeftTicketQueryResult('{"data":{"result":[]}}', entry.url ?? '');
664
+ assert(empty.kind === 'recognized-empty', 'counterexample:explicit data.result=[] → recognized-empty');
665
+ assert(factsFromSessionTrain(requested, { outcome: empty, collection: { ...binding, batchId: 'fixture-empty-batch', queryId: 'session:12306-train:fixture-empty-batch' } }, now).length === 1, 'genuine empty alone → one negative fact');
666
+ assert(factsFromSessionTrain(requested, { outcome: empty, collection: { ...binding, response: { ...binding.response, url: binding.response.url.replace('2026-12-01', '2026-12-02') } } }, now).length === 0, 'recognized-empty with mismatched response date → zero facts');
667
+ const malformedBodies = [
668
+ ['malformed JSON', 'not json'],
669
+ ['malformed root', '123'],
670
+ ['malformed result', '{"data":{"result":{}}}'],
671
+ ];
672
+ for (const [label, body] of malformedBodies) {
673
+ const outcome = parseLeftTicketQueryResult(body, entry.url ?? '');
674
+ assert(outcome.kind === 'malformed' && factsFromSessionTrain(requested, { outcome, collection: binding }, now).length === 0, `${label} → typed malformed + zero facts`, outcome);
675
+ }
676
+ const badRow = makeRow({ 30: '有' }).split('|').slice(1).join('|');
677
+ const shortRow = makeRow({ 30: '有' }).split('|').slice(0, 20).join('|');
678
+ const mixedOutcome = parseLeftTicketQueryResult(JSON.stringify({ data: { result: [makeRow({ 30: '有' }), badRow], map: { SHH: '上海', KMM: '昆明' } } }), entry.url ?? '');
679
+ assert(mixedOutcome.kind === 'malformed' && factsFromSessionTrain(requested, { outcome: mixedOutcome, collection: binding }, now).length === 0, 'mixed valid/malformed batch → fail closed, zero candidates');
680
+ for (const [label, row] of [['nonobject row', null], ['short row', shortRow]]) {
681
+ const body = JSON.stringify({ data: { result: [row], map: { SHH: '上海', KMM: '昆明' } } });
682
+ const outcome = parseLeftTicketQueryResult(body, entry.url ?? '');
683
+ assert(outcome.kind === 'malformed' && factsFromSessionTrain(requested, { outcome, collection: binding }, now).length === 0, `${label} → malformed + zero facts`);
684
+ }
685
+ const transport = { outcome: { kind: 'transport-runtime-error', reason: 'fixture transport error' }, collection: undefined };
686
+ assert(factsFromSessionTrain(requested, transport, now).length === 0, 'transport/runtime error → zero facts');
687
+ const yNoSeat = parseLeftTicketQueryResult(JSON.stringify({ data: { result: [makeRow({ 30: '0', 31: '无', 32: '--' })], map: { SHH: '上海', KMM: '昆明' } } }), entry.url ?? '');
688
+ assert(yNoSeat.kind === 'recognized-nonempty' && factsFromSessionTrain(requested, { outcome: yNoSeat, collection: binding }, now).length === 0, 'canWebBuy=Y but no available seat → zero positive/negative facts');
689
+ const incoherentBuy = parseLeftTicketQueryResult(JSON.stringify({ data: { result: [makeRow({ 30: '有' }, 'N')], map: { SHH: '上海', KMM: '昆明' } } }), entry.url ?? '');
690
+ assert(incoherentBuy.kind === 'recognized-nonempty' && factsFromSessionTrain(requested, { outcome: incoherentBuy, collection: binding }, now).length === 0, 'seat available but incoherent buy flag → zero facts');
691
+ assert(factsFromSessionTrain(requested, { outcome: available, collection: { ...binding, requested: { ...requested, from: '南京' } } }, now).length === 0, 'route mismatch binding → zero facts');
692
+ assert(factsFromSessionTrain(requested, { outcome: available, collection: { ...binding, requested: { ...requested, date: '2026-12-02' } } }, now).length === 0, 'date mismatch binding → zero facts');
693
+ assert(factsFromSessionTrain(requested, { outcome: available, collection: { ...binding, queryId: 'session:12306-train:other-batch' } }, now).length === 0, 'mismatched caller query identity → zero facts');
694
+ const priorOriginDate = parseLeftTicketQueryResult(JSON.stringify({ data: { result: [makeRow({ 30: '有' }, 'Y', '20261130')], map: { SHH: '上海', KMM: '昆明' } } }), entry.url ?? '');
695
+ assert(priorOriginDate.kind === 'recognized-nonempty' && priorOriginDate.trains[0].startTrainDate === '2026-11-30'
696
+ && factsFromSessionTrain(requested, { outcome: priorOriginDate, collection: binding }, now).length === 1, 'startTrainDate 前一日且 passenger query date 命中 → 保留并产出事实');
697
+ const staleBinding = { ...binding, fetchedAt: new Date(now.getTime() - TRAIN_FACT_MAX_AGE_MS - 1).toISOString() };
698
+ const futureBinding = { ...binding, fetchedAt: new Date(now.getTime() + 1).toISOString() };
699
+ assert(factsFromSessionTrain(requested, { outcome: available, collection: staleBinding }, now).length === 0 && factsFromSessionTrain(requested, { outcome: available, collection: futureBinding }, now).length === 0, `stale/future fetchedAt → zero facts (${TRAIN_FACT_FRESHNESS_RULE})`);
700
+ // L2c. Actual CDP collector seam:body and the URL come from the same response.
701
+ const savedTrainTransport = process.env.GOTRY_SESSION_TRANSPORT;
702
+ const cdpResponse = (body, url) => async () => {
703
+ let onResponse;
704
+ const page = {
705
+ on: (event, handler) => { if (event === 'response')
706
+ onResponse = handler; },
707
+ goto: async () => { await onResponse?.({ url: () => url, text: async () => body }); },
708
+ title: async () => '',
709
+ content: async () => '',
710
+ };
711
+ return {
712
+ ok: true,
713
+ browser: {},
714
+ page: page,
715
+ guard: { blockedCount: () => 0, requestCount: () => 1 },
716
+ close: async () => { },
717
+ };
718
+ };
719
+ try {
720
+ process.env.GOTRY_SESSION_TRANSPORT = 'cdp';
721
+ __resetRateLimiterForTest();
722
+ __setTrainSessionTransportForTest(cdpResponse(JSON.stringify({ data: { result: [makeRow({ 30: '有' }, 'Y', '20261130')], map: { SHH: '上海', KMM: '昆明' } } }), 'https://kyfw.12306.cn/otn/leftTicket/queryG?leftTicketDTO.train_date=2026-12-01&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=KMM'));
723
+ const cdpControl = await sessionTrainSearch({ from: '上海', to: '昆明', date: '2026-12-01', timeoutMs: 10 });
724
+ const cdpNow = new Date();
725
+ assert(cdpControl.collection?.response.url.includes('from_station=SHH') === true
726
+ && factsFromSessionTrain(requested, cdpControl, cdpNow).length === 1, 'CDP collector exact response URL + previous origin date → one fact');
727
+ __resetRateLimiterForTest();
728
+ __setTrainSessionTransportForTest(cdpResponse(JSON.stringify({ data: { result: [makeRow({ 30: '有' })], map: { SHH: '上海', KMM: '昆明' } } }), 'https://kyfw.12306.cn/otn/leftTicket/queryG?leftTicketDTO.train_date=2026-12-03&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=KMM'));
729
+ const cdpWrongRoute = await sessionTrainSearch({ from: '北京', to: '上海', date: '2026-12-03', timeoutMs: 10 });
730
+ assert(cdpWrongRoute.collection === undefined && factsFromSessionTrain({ from: '北京', to: '上海', date: '2026-12-03' }, cdpWrongRoute, now).length === 0, 'CDP wrong-route response URL → no collection/facts');
731
+ __resetRateLimiterForTest();
732
+ __setTrainSessionTransportForTest(cdpResponse('{"data":{"result":[]}}', 'https://kyfw.12306.cn/otn/leftTicket/queryG?leftTicketDTO.train_date=2026-12-01&leftTicketDTO.from_station=SHH&leftTicketDTO.to_station=KMM'));
733
+ const cdpWrongDateEmpty = await sessionTrainSearch({ from: '上海', to: '昆明', date: '2026-12-02', timeoutMs: 10 });
734
+ assert(cdpWrongDateEmpty.outcome.kind === 'recognized-empty' && cdpWrongDateEmpty.collection === undefined
735
+ && factsFromSessionTrain({ from: '上海', to: '昆明', date: '2026-12-02' }, cdpWrongDateEmpty, now).length === 0, 'CDP wrong-date recognized-empty → no negative fact');
736
+ }
737
+ finally {
738
+ __setTrainSessionTransportForTest(null);
739
+ if (savedTrainTransport === undefined)
740
+ delete process.env.GOTRY_SESSION_TRANSPORT;
741
+ else
742
+ process.env.GOTRY_SESSION_TRANSPORT = savedTrainTransport;
743
+ }
744
+ // L3 闸面:过去日期在工具层拦截(cooldown 链路前)
835
745
  __resetRateLimiterForTest();
836
- const e1 = await sessionTrainSearch({
837
- from: '不在码表',
838
- to: '也不在',
839
- date: '2026-12-01'
840
- });
746
+ const e1 = await sessionTrainSearch({ from: '不在码表', to: '也不在', date: '2026-12-01' });
841
747
  assert(e1.ok === false && e1.verdict === 'error' && /fromStationTelecode/.test(e1.error ?? ''), '未收录电报码 → error + 发现路径指引', e1);
842
- const e2 = await sessionTrainSearch({
843
- from: '上海',
844
- to: '昆明',
845
- date: '2026-12-01'
846
- });
748
+ const e2 = await sessionTrainSearch({ from: '上海', to: '昆明', date: '2026-12-01' });
847
749
  assert(e2.ok === false && e2.verdict === 'cooldown', '节律闸:同站点 30s 内第二调 → cooldown(不发起导航)', e2);
848
- }if (process.env.GOTRY_SESSION_TEST_FORCE_FAILURE === '1') {
750
+ }
751
+ // ---------------------------------------------------------------------------
752
+ // M. Dida 供应商门户会话面(2026-09-09 实装;entry 守域/信封走形/形状签名/闸面;
753
+ // 纯函数 + transport 前短路,离线确定性——扩展桥不在测试里拉起)
754
+ // ---------------------------------------------------------------------------
755
+ console.log('M. Dida 门户(entry 守域 + 信封走形 + 闸面)');
756
+ {
757
+ // M1 entry:默认 find 页;覆盖 URL 必须落在 portal.dida.com 域内
758
+ const d1 = buildDidaEntryUrl();
759
+ assert(d1.ok && d1.url === 'https://portal.dida.com/hotel/find', 'entry 默认=find 页', d1);
760
+ const d2 = buildDidaEntryUrl({ entryUrl: 'https://portal.dida.com/hotel/find?city=Bangkok' });
761
+ assert(d2.ok && d2.url === 'https://portal.dida.com/hotel/find?city=Bangkok', 'entry 覆盖(域内)放行', d2);
762
+ const d3 = buildDidaEntryUrl({ entryUrl: 'https://evil.example.com/hotel/find' });
763
+ assert(!d3.ok, 'entry 覆盖(域外)拒绝——fail-closed', d3);
764
+ const d4 = buildDidaEntryUrl({ entryUrl: 'http://portal.dida.com/hotel/find' });
765
+ assert(!d4.ok, 'entry 覆盖(http 明文)拒绝', d4);
766
+ // M2 走形:SearchRealTime 信封(hotel-be models.go 口径)→ 展平报价
767
+ const envelope = JSON.stringify({
768
+ Message: 'ok', Success: true, MessageCode: 20000,
769
+ Data: {
770
+ ReferenceNo: 'REF-ROOT',
771
+ HotelPriceList: [
772
+ {
773
+ Hotel: { HotelID: 24110, Name: 'Atlantis The Palm' },
774
+ RoomTypeList: [
775
+ { DidaRoomTypeID: 701, DidaRoomTypeName_CN: '海景大床房', DidaRoomTypeName_EN: 'Sea View King', RatePlanList: [
776
+ { RatePlanID: 'RP-1', Price: 1580.5, TotalPrice: 4741.5, Currency: 'CNY', MealType: 'Breakfast', BreakfastType: 'ABF', BedType: 'King', PaymentType: 'Prepay', Inventory: 3, RoomCount: 5, ReferenceNo: 'REF-A' },
777
+ { RatePlanID: 'RP-2', Price: 1710, TotalPrice: 5130, Currency: 'CNY', PaymentType: 'PayAtHotel', Inventory: 0, ReferenceNo: 'REF-B' },
778
+ ] },
779
+ ],
780
+ RoomList: [
781
+ { DidaRoomTypeID: 702, DidaRoomTypeName_EN: 'Suite', RatePlanList: [
782
+ { RatePlanID: 'RP-3', Price: 3200, TotalPrice: 9600, Currency: 'USD', ReferenceNo: 'REF-C' },
783
+ ] },
784
+ ],
785
+ },
786
+ { Hotel: { HotelID: 24111, Name: 'Burj Al Arab' }, RoomTypeList: [] },
787
+ ],
788
+ },
789
+ });
790
+ const rates = parseDidaRates(envelope);
791
+ assert(rates.length === 3, '信封走形:RoomTypeList+RoomList 逐计划展平', rates);
792
+ assert(rates[0].hotelId === '24110' && rates[0].hotelName === 'Atlantis The Palm' && rates[0].roomName === '海景大床房', '酒店/房型字段映射(中文名优先)', rates[0]);
793
+ assert(rates[0].price === 1580.5 && rates[0].totalPrice === 4741.5 && rates[0].currency === 'CNY', '价/总价/币种', rates[0]);
794
+ assert(rates[0].inventory === 3 && rates[0].referenceNo === 'REF-A' && rates[0].paymentType === 'Prepay', '库存/引用号/支付类型', rates[0]);
795
+ assert(rates[1].ratePlanId === 'RP-2' && rates[1].inventory === 0, 'inventory=0 如实保留(不伪装可订)', rates[1]);
796
+ assert(rates[2].roomName === 'Suite' && rates[2].currency === 'USD', 'RoomList 同构展平', rates[2]);
797
+ assert(parseDidaRates('not json').length === 0 && parseDidaRates('{"a":1}').length === 0 && parseDidaRates('[]').length === 0, 'malformed/无信封/裸空数组 一律返空(不抛错)');
798
+ assert(parseDidaRates(envelope, { maxItems: 2 }).length === 2, 'maxItems 截断');
799
+ // M3 形状签名(与 content-main DIDA_BODY_SIG_RE 逐字对账)
800
+ assert(looksLikeDidaRatesBody('"HotelPriceList":[]'), '签名命中 HotelPriceList');
801
+ assert(looksLikeDidaRatesBody('"RatePlanList":[]'), '签名命中 RatePlanList');
802
+ assert(!looksLikeDidaRatesBody(''), '空体不命中');
803
+ assert(!looksLikeDidaRatesBody('x'.repeat(2_000_001)), '超上限不命中');
804
+ // M4 闸面(transport 前短路):entry 域外 → error;同站点二调 → cooldown
805
+ __resetRateLimiterForTest();
806
+ const q1 = await sessionDidaSearch({ entryUrl: 'https://evil.example.com/x' });
807
+ assert(q1.ok === false && q1.verdict === 'error', 'entry 域外 → error(不发起导航)', q1);
808
+ const q2 = await sessionDidaSearch({});
809
+ assert(q2.ok === false && q2.verdict === 'cooldown', '节律闸:30s 内二调 → cooldown', q2);
810
+ assert(typeof didaLoginHint() === 'string' && /自动登录/.test(didaLoginHint()), '登录指引指向 hotel-be portal 跳板(账密永不经 gotry)');
811
+ }
812
+ if (process.env.GOTRY_SESSION_TEST_FORCE_FAILURE === '1') {
849
813
  assert(false, 'forced session failure propagation proof');
850
814
  }
851
815
  console.log(`\nSESSION P1: ${pass} pass, ${fail} fail`);
852
- if (fail > 0) process.exitCode = 1;
853
-
854
-
855
- //# sourceURL=ts/scripts/session-tests.ts
816
+ if (fail > 0)
817
+ process.exitCode = 1;