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