@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
|
@@ -1,357 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open-Meteo 天气能力层(免费无 key):预报 + 历史气候回退。
|
|
3
|
+
*
|
|
4
|
+
* 产品定位:替代 LLM 的「雨季常识」——季节性建议、窗口内降雨概率、
|
|
5
|
+
* 体感温度范围,全部走真实数据并带证据标注 [实时API:open-meteo@ts]。
|
|
6
|
+
*
|
|
7
|
+
* 契约(与 hbcli.ts 同构,能力层不变量):
|
|
8
|
+
* - 永不抛错:网络失败/超时/解析失败一律降级返回,不穿透到调用方;
|
|
9
|
+
* - 证据链标注:成功带 [实时API:open-meteo@ts],失败带 [实时API:open-meteo@error@ts];
|
|
10
|
+
* - 免费源无配额概念,但仍记录 latency(供 L5 成本工程度量)。
|
|
11
|
+
*
|
|
12
|
+
* 端点(全部免费,无 key):
|
|
13
|
+
* 预报: https://api.open-meteo.com/v1/forecast (≤16 天)
|
|
14
|
+
* 历史气候: https://archive-api.open-meteo.com/v1/archive (1940→,做季节性)
|
|
15
|
+
* 地理编码: https://geocoding-api.open-meteo.com/v1/search (地名→坐标)
|
|
16
|
+
*/
|
|
1
17
|
const FORECAST_BASE = 'https://api.open-meteo.com/v1/forecast';
|
|
2
18
|
const ARCHIVE_BASE = 'https://archive-api.open-meteo.com/v1/archive';
|
|
3
19
|
const GEOCODE_BASE = 'https://geocoding-api.open-meteo.com/v1/search';
|
|
20
|
+
/** WMO weather code → 中文描述(节选最常用的码) */
|
|
4
21
|
const WMO_ZH = {
|
|
5
|
-
0: '晴',
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
61: '小雨',
|
|
15
|
-
63: '中雨',
|
|
16
|
-
65: '大雨',
|
|
17
|
-
66: '冻雨',
|
|
18
|
-
67: '强冻雨',
|
|
19
|
-
71: '小雪',
|
|
20
|
-
73: '中雪',
|
|
21
|
-
75: '大雪',
|
|
22
|
-
77: '雪粒',
|
|
23
|
-
80: '阵雨',
|
|
24
|
-
81: '强阵雨',
|
|
25
|
-
82: '暴雨',
|
|
26
|
-
85: '阵雪',
|
|
27
|
-
86: '强阵雪',
|
|
28
|
-
95: '雷暴',
|
|
29
|
-
96: '雷暴伴小冰雹',
|
|
30
|
-
99: '雷暴伴大冰雹'
|
|
22
|
+
0: '晴', 1: '大致晴', 2: '多云', 3: '阴',
|
|
23
|
+
45: '雾', 48: '冻雾',
|
|
24
|
+
51: '毛毛雨', 53: '毛毛雨', 55: '密集毛毛雨',
|
|
25
|
+
61: '小雨', 63: '中雨', 65: '大雨',
|
|
26
|
+
66: '冻雨', 67: '强冻雨',
|
|
27
|
+
71: '小雪', 73: '中雪', 75: '大雪', 77: '雪粒',
|
|
28
|
+
80: '阵雨', 81: '强阵雨', 82: '暴雨',
|
|
29
|
+
85: '阵雪', 86: '强阵雪',
|
|
30
|
+
95: '雷暴', 96: '雷暴伴小冰雹', 99: '雷暴伴大冰雹',
|
|
31
31
|
};
|
|
32
32
|
export function wmoLabel(code) {
|
|
33
33
|
return WMO_ZH[code] ?? `天气码${code}`;
|
|
34
34
|
}
|
|
35
|
-
const monotonicNow = ()=>performance.now();
|
|
35
|
+
const monotonicNow = () => performance.now();
|
|
36
36
|
const MIN_TIMER_MS = 1;
|
|
37
37
|
const TIMEOUT_ERROR = 'timeoutMs must be a finite positive integer';
|
|
38
38
|
const BUDGET_EXHAUSTED_ERROR = 'total timeout budget exhausted';
|
|
39
39
|
const REQUEST_ERROR = 'request failed';
|
|
40
|
-
const normalizeTimeout = (value)=>Number.isInteger(value) && value > 0 ? value : null;
|
|
41
|
-
|
|
40
|
+
const normalizeTimeout = (value) => Number.isInteger(value) && value > 0 ? value : null;
|
|
41
|
+
/** Rejection values are untrusted: only a real string message is safe to expose. */
|
|
42
|
+
const normalizeError = (value) => {
|
|
42
43
|
try {
|
|
43
44
|
if (value instanceof Error && typeof value.message === 'string' && value.message.length > 0) {
|
|
44
45
|
return value.message.slice(0, 200);
|
|
45
46
|
}
|
|
46
|
-
if (typeof value === 'string' && value.length > 0)
|
|
47
|
-
|
|
47
|
+
if (typeof value === 'string' && value.length > 0)
|
|
48
|
+
return value.slice(0, 200);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
48
51
|
return REQUEST_ERROR;
|
|
49
52
|
}
|
|
50
53
|
return REQUEST_ERROR;
|
|
51
54
|
};
|
|
52
55
|
async function fetchJson(url, timeoutMs, headers = {}, fetchImpl = globalThis.fetch) {
|
|
53
|
-
if (!Number.isFinite(timeoutMs) || timeoutMs < MIN_TIMER_MS)
|
|
54
|
-
ok: false,
|
|
55
|
-
error: BUDGET_EXHAUSTED_ERROR
|
|
56
|
-
};
|
|
56
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < MIN_TIMER_MS)
|
|
57
|
+
return { ok: false, error: BUDGET_EXHAUSTED_ERROR };
|
|
57
58
|
const ctrl = new AbortController();
|
|
58
59
|
const timerDelay = Math.floor(timeoutMs);
|
|
59
|
-
if (timerDelay < MIN_TIMER_MS)
|
|
60
|
-
ok: false,
|
|
61
|
-
error: BUDGET_EXHAUSTED_ERROR
|
|
62
|
-
};
|
|
60
|
+
if (timerDelay < MIN_TIMER_MS)
|
|
61
|
+
return { ok: false, error: BUDGET_EXHAUSTED_ERROR };
|
|
63
62
|
let timer;
|
|
64
63
|
let timedOut = false;
|
|
65
|
-
const timeout = new Promise((_, reject)=>{
|
|
66
|
-
timer = setTimeout(()=>{
|
|
64
|
+
const timeout = new Promise((_, reject) => {
|
|
65
|
+
timer = setTimeout(() => {
|
|
67
66
|
timedOut = true;
|
|
68
67
|
ctrl.abort();
|
|
69
68
|
reject(new Error(`timeout after ${timeoutMs}ms`));
|
|
70
69
|
}, timerDelay);
|
|
71
70
|
});
|
|
72
|
-
const operation = (async ()=>{
|
|
73
|
-
const res = await fetchImpl(url, {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
...headers
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
if (!res.ok) return {
|
|
81
|
-
ok: false,
|
|
82
|
-
error: `HTTP ${res.status}`
|
|
83
|
-
};
|
|
84
|
-
return {
|
|
85
|
-
ok: true,
|
|
86
|
-
data: await res.json()
|
|
87
|
-
};
|
|
71
|
+
const operation = (async () => {
|
|
72
|
+
const res = await fetchImpl(url, { signal: ctrl.signal, headers: { Accept: 'application/json', ...headers } });
|
|
73
|
+
if (!res.ok)
|
|
74
|
+
return { ok: false, error: `HTTP ${res.status}` };
|
|
75
|
+
return { ok: true, data: await res.json() };
|
|
88
76
|
})();
|
|
89
77
|
try {
|
|
90
|
-
return await Promise.race([
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
timedOut: true
|
|
100
|
-
} : {}
|
|
101
|
-
};
|
|
102
|
-
} finally{
|
|
103
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
104
|
-
void operation.catch(()=>undefined);
|
|
78
|
+
return await Promise.race([operation, timeout]);
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
return { ok: false, error: normalizeError(e), ...(timedOut ? { timedOut: true } : {}) };
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
if (timer !== undefined)
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
void operation.catch(() => undefined);
|
|
105
87
|
}
|
|
106
88
|
}
|
|
107
89
|
const NOMINATIM_BASE = 'https://nominatim.openstreetmap.org/search';
|
|
90
|
+
/** 行政级/人口重镇 feature_code(open-meteo 命名惯例):PPLC=首都 PPLA*=首府 PPLB=城镇 PPLX=区片 */
|
|
108
91
|
const MAJOR_FEATURE = /^(PPLC|PPLA|PPLB|PPLX)/;
|
|
109
|
-
const isRecord = (value)=>value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
92
|
+
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
110
93
|
function stripHits(hits) {
|
|
111
|
-
return hits.map(({ name, latitude, longitude, country, admin1 })=>({
|
|
112
|
-
...country ? {
|
|
113
|
-
country
|
|
114
|
-
} : {},
|
|
115
|
-
...admin1 ? {
|
|
116
|
-
admin1
|
|
117
|
-
} : {},
|
|
118
|
-
name,
|
|
119
|
-
latitude,
|
|
120
|
-
longitude
|
|
121
|
-
}));
|
|
94
|
+
return hits.map(({ name, latitude, longitude, country, admin1 }) => ({ ...(country ? { country } : {}), ...(admin1 ? { admin1 } : {}), name, latitude, longitude }));
|
|
122
95
|
}
|
|
96
|
+
/** 地名 → 坐标(双源,供 dsh 工具把「大理」转成经纬度):
|
|
97
|
+
* 1. Open-Meteo(主源):命中按人口/行政级降序——「大理」会同时命中云南州府与四川同名乡,不能盲信次序;
|
|
98
|
+
* 2. Nominatim(兜底):open-meteo 的中文名覆盖有洞(实测(issue #24)「普吉岛」0 结果、
|
|
99
|
+
* 裸词「普吉」错配西藏林芝同名村),弱命中/零结果时走 Nominatim 拿正确目标,
|
|
100
|
+
* 兜底仍空则回退 open-meteo 弱结果(不比此前更差)。 */
|
|
123
101
|
export async function geocodePlace(name, opts = {}) {
|
|
124
102
|
const ts = new Date().toISOString();
|
|
125
103
|
const count = opts.count ?? 5;
|
|
126
104
|
const query = name.trim();
|
|
127
105
|
const timeoutMs = normalizeTimeout(opts.timeoutMs ?? 15_000);
|
|
128
|
-
if (timeoutMs === null)
|
|
129
|
-
ok: false,
|
|
130
|
-
evidence: `[实时API:open-meteo-geo@error@${ts}]`,
|
|
131
|
-
results: [],
|
|
132
|
-
error: TIMEOUT_ERROR
|
|
133
|
-
};
|
|
106
|
+
if (timeoutMs === null)
|
|
107
|
+
return { ok: false, evidence: `[实时API:open-meteo-geo@error@${ts}]`, results: [], error: TIMEOUT_ERROR };
|
|
134
108
|
const omUrl = `${GEOCODE_BASE}?name=${encodeURIComponent(query)}&count=${count}&language=zh&format=json`;
|
|
109
|
+
// timeoutMs is a budget for the complete primary→fallback chain, not per request.
|
|
135
110
|
const deadline = monotonicNow() + timeoutMs;
|
|
136
|
-
const remaining = ()=>{
|
|
111
|
+
const remaining = () => {
|
|
137
112
|
const value = deadline - monotonicNow();
|
|
138
113
|
return Number.isFinite(value) && value >= MIN_TIMER_MS ? value : null;
|
|
139
114
|
};
|
|
140
115
|
const primaryRemaining = remaining();
|
|
141
|
-
const r = primaryRemaining === null
|
|
142
|
-
ok: false,
|
|
143
|
-
|
|
144
|
-
} : await fetchJson(omUrl, primaryRemaining, {}, opts.fetchImpl);
|
|
116
|
+
const r = primaryRemaining === null
|
|
117
|
+
? { ok: false, error: BUDGET_EXHAUSTED_ERROR }
|
|
118
|
+
: await fetchJson(omUrl, primaryRemaining, {}, opts.fetchImpl);
|
|
145
119
|
const primaryPayload = r.ok && isRecord(r.data) && Array.isArray(r.data['results']) ? r.data['results'] : null;
|
|
146
|
-
const omResults = primaryPayload
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
120
|
+
const omResults = primaryPayload
|
|
121
|
+
? primaryPayload.map((item) => {
|
|
122
|
+
if (!isRecord(item))
|
|
123
|
+
return null;
|
|
124
|
+
const it = item;
|
|
125
|
+
return {
|
|
126
|
+
name: String(it['name'] ?? ''),
|
|
127
|
+
latitude: Number(it['latitude']),
|
|
128
|
+
longitude: Number(it['longitude']),
|
|
129
|
+
country: it['country'] ? String(it['country']) : undefined,
|
|
130
|
+
admin1: it['admin1'] ? String(it['admin1']) : undefined,
|
|
131
|
+
population: Number(it['population'] ?? 0) || 0,
|
|
132
|
+
featureCode: String(it['feature_code'] ?? ''),
|
|
133
|
+
};
|
|
134
|
+
}).filter((h) => !!h && !!h.name && Number.isFinite(h.latitude) && Number.isFinite(h.longitude))
|
|
135
|
+
: [];
|
|
136
|
+
omResults.sort((a, b) => (b.population - a.population) || (Number(MAJOR_FEATURE.test(b.featureCode)) - Number(MAJOR_FEATURE.test(a.featureCode))));
|
|
160
137
|
const top = omResults[0];
|
|
138
|
+
// 强命中:有人口规模或主要行政级,直接采信(「深圳市」「曼谷」路径不变)
|
|
161
139
|
if (top && (top.population > 0 || MAJOR_FEATURE.test(top.featureCode))) {
|
|
162
|
-
return {
|
|
163
|
-
ok: true,
|
|
164
|
-
evidence: `[实时API:open-meteo-geo@${ts}]`,
|
|
165
|
-
via: 'open-meteo',
|
|
166
|
-
results: stripHits(omResults)
|
|
167
|
-
};
|
|
140
|
+
return { ok: true, evidence: `[实时API:open-meteo-geo@${ts}]`, via: 'open-meteo', results: stripHits(omResults) };
|
|
168
141
|
}
|
|
142
|
+
// 弱命中/零结果/主源请求失败:Nominatim 兜底(免费无 key;中文 accept-language)
|
|
169
143
|
const nomTs = new Date().toISOString();
|
|
170
144
|
const nomUrl = `${NOMINATIM_BASE}?q=${encodeURIComponent(query)}&format=jsonv2&limit=${count}&accept-language=zh&addressdetails=1`;
|
|
171
145
|
const fallbackRemaining = primaryRemaining === null || r.timedOut ? null : remaining();
|
|
172
|
-
const nom = fallbackRemaining === null
|
|
173
|
-
ok: false,
|
|
174
|
-
|
|
175
|
-
} : await fetchJson(nomUrl, fallbackRemaining, {
|
|
176
|
-
'User-Agent': 'gotry-travel-agent/0.1 (+https://github.com/Danceiny/gotry)'
|
|
177
|
-
}, opts.fetchImpl);
|
|
146
|
+
const nom = fallbackRemaining === null
|
|
147
|
+
? { ok: false, error: BUDGET_EXHAUSTED_ERROR }
|
|
148
|
+
: await fetchJson(nomUrl, fallbackRemaining, { 'User-Agent': 'gotry-travel-agent/0.1 (+https://github.com/Danceiny/gotry)' }, opts.fetchImpl);
|
|
178
149
|
const nomResults = [];
|
|
179
150
|
if (nom.ok && Array.isArray(nom.data)) {
|
|
180
|
-
const parsedNom = nom.data.map((item)=>{
|
|
181
|
-
if (!isRecord(item))
|
|
151
|
+
const parsedNom = nom.data.map((item) => {
|
|
152
|
+
if (!isRecord(item))
|
|
153
|
+
return null;
|
|
182
154
|
const it = item;
|
|
183
155
|
const addr = isRecord(it['address']) ? it['address'] : {};
|
|
184
156
|
const country = typeof addr['country'] === 'string' ? addr['country'] : undefined;
|
|
185
|
-
const admin1 = [
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
addr['county']
|
|
189
|
-
].find((v)=>typeof v === 'string');
|
|
190
|
-
return {
|
|
191
|
-
name: String(it['name'] ?? ''),
|
|
192
|
-
latitude: Number(it['lat']),
|
|
193
|
-
longitude: Number(it['lon']),
|
|
194
|
-
...country ? {
|
|
195
|
-
country
|
|
196
|
-
} : {},
|
|
197
|
-
...admin1 ? {
|
|
198
|
-
admin1
|
|
199
|
-
} : {}
|
|
200
|
-
};
|
|
201
|
-
}).filter((h)=>!!h && !!h.name && Number.isFinite(h.latitude) && Number.isFinite(h.longitude));
|
|
157
|
+
const admin1 = [addr['province'], addr['state'], addr['county']].find(v => typeof v === 'string');
|
|
158
|
+
return { name: String(it['name'] ?? ''), latitude: Number(it['lat']), longitude: Number(it['lon']), ...(country ? { country } : {}), ...(admin1 ? { admin1 } : {}) };
|
|
159
|
+
}).filter((h) => !!h && !!h.name && Number.isFinite(h.latitude) && Number.isFinite(h.longitude));
|
|
202
160
|
nomResults.push(...parsedNom);
|
|
203
161
|
}
|
|
204
162
|
if (nomResults.length) {
|
|
205
|
-
const omNote = r.ok
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
via: 'nominatim',
|
|
210
|
-
results: nomResults
|
|
211
|
-
};
|
|
163
|
+
const omNote = r.ok
|
|
164
|
+
? `open-meteo ${omResults.length} 条弱命中(无人口/行政级,不足采信)`
|
|
165
|
+
: `open-meteo 失败:${r.error ?? 'HTTP error'}`;
|
|
166
|
+
return { ok: true, evidence: `[实时API:nominatim@${nomTs}](兜底层: ${omNote})`, via: 'nominatim', results: nomResults };
|
|
212
167
|
}
|
|
168
|
+
// 双源皆空/兜底失败:open-meteo 有弱结果仍返回(同构「降级不阻塞」契约),否则维持失败终态
|
|
213
169
|
if (omResults.length) {
|
|
214
|
-
return {
|
|
215
|
-
ok: true,
|
|
216
|
-
evidence: `[实时API:open-meteo-geo@${ts}]`,
|
|
217
|
-
via: 'open-meteo',
|
|
218
|
-
results: stripHits(omResults)
|
|
219
|
-
};
|
|
170
|
+
return { ok: true, evidence: `[实时API:open-meteo-geo@${ts}]`, via: 'open-meteo', results: stripHits(omResults) };
|
|
220
171
|
}
|
|
221
172
|
return {
|
|
222
173
|
ok: false,
|
|
223
174
|
evidence: `[实时API:open-meteo-geo@error@${ts}];[实时API:nominatim@error@${nomTs}]`,
|
|
224
175
|
results: [],
|
|
225
|
-
error: r.ok ? `双源无结果:${nom.error ?? 'nominatim empty'}` : r.error
|
|
176
|
+
error: r.ok ? `双源无结果:${nom.error ?? 'nominatim empty'}` : (r.error),
|
|
226
177
|
};
|
|
227
178
|
}
|
|
179
|
+
/** 未来天气预报(≤16 天) */
|
|
228
180
|
export async function getForecast(point, opts = {}) {
|
|
229
181
|
const started = monotonicNow();
|
|
230
182
|
const ts = new Date().toISOString();
|
|
231
183
|
const requestedDays = opts.days ?? 7;
|
|
232
184
|
const days = Number.isInteger(requestedDays) && requestedDays > 0 ? Math.min(requestedDays, 16) : 0;
|
|
233
185
|
const timeoutMs = normalizeTimeout(opts.timeoutMs ?? 15_000);
|
|
234
|
-
if (days < 1 || timeoutMs === null)
|
|
235
|
-
ok: false,
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
error: days < 1 ? 'days must be valid positive values' : TIMEOUT_ERROR
|
|
240
|
-
};
|
|
241
|
-
const url = `${FORECAST_BASE}?latitude=${point.latitude}&longitude=${point.longitude}` + `&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max,weathercode` + `&forecast_days=${days}&timezone=auto`;
|
|
186
|
+
if (days < 1 || timeoutMs === null)
|
|
187
|
+
return { ok: false, via: 'open-meteo-error', evidence: `[实时API:open-meteo@error@${ts}]`, latencyMs: monotonicNow() - started, error: days < 1 ? 'days must be valid positive values' : TIMEOUT_ERROR };
|
|
188
|
+
const url = `${FORECAST_BASE}?latitude=${point.latitude}&longitude=${point.longitude}`
|
|
189
|
+
+ `&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max,weathercode`
|
|
190
|
+
+ `&forecast_days=${days}&timezone=auto`;
|
|
242
191
|
const r = await fetchJson(url, timeoutMs, {}, opts.fetchImpl);
|
|
243
192
|
const latencyMs = monotonicNow() - started;
|
|
244
193
|
if (!r.ok) {
|
|
245
|
-
return {
|
|
246
|
-
ok: false,
|
|
247
|
-
via: 'open-meteo-error',
|
|
248
|
-
evidence: `[实时API:open-meteo@error@${ts}]`,
|
|
249
|
-
latencyMs,
|
|
250
|
-
error: r.error
|
|
251
|
-
};
|
|
194
|
+
return { ok: false, via: 'open-meteo-error', evidence: `[实时API:open-meteo@error@${ts}]`, latencyMs, error: r.error };
|
|
252
195
|
}
|
|
253
196
|
const payload = isRecord(r.data) ? r.data : null;
|
|
254
197
|
const daily = isRecord(payload?.['daily']) ? payload['daily'] : null;
|
|
255
|
-
if (!daily || !Array.isArray(daily.time) || !Array.isArray(daily.temperature_2m_max)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
198
|
+
if (!daily || !Array.isArray(daily.time) || !Array.isArray(daily.temperature_2m_max)
|
|
199
|
+
|| !Array.isArray(daily.temperature_2m_min) || !Array.isArray(daily.weathercode)
|
|
200
|
+
|| daily.time.length !== days || daily.temperature_2m_max.length !== days
|
|
201
|
+
|| daily.temperature_2m_min.length !== days || daily.weathercode.length !== days
|
|
202
|
+
|| daily.time.some(v => typeof v !== 'string')
|
|
203
|
+
|| daily.temperature_2m_max.some(v => typeof v !== 'number' || !Number.isFinite(v))
|
|
204
|
+
|| daily.temperature_2m_min.some(v => typeof v !== 'number' || !Number.isFinite(v))
|
|
205
|
+
|| daily.weathercode.some(v => typeof v !== 'number' || !Number.isFinite(v))
|
|
206
|
+
|| (daily.precipitation_probability_max !== undefined && daily.precipitation_probability_max !== null
|
|
207
|
+
&& !Array.isArray(daily.precipitation_probability_max))
|
|
208
|
+
|| (Array.isArray(daily.precipitation_probability_max)
|
|
209
|
+
&& (daily.precipitation_probability_max.length !== days
|
|
210
|
+
|| daily.precipitation_probability_max.some(v => v !== null && (typeof v !== 'number' || !Number.isFinite(v)))))) {
|
|
211
|
+
return { ok: false, via: 'open-meteo-error', evidence: `[实时API:open-meteo@error@${ts}]`, latencyMs, error: 'empty daily payload' };
|
|
263
212
|
}
|
|
264
213
|
const times = daily.time;
|
|
265
214
|
const maxes = daily.temperature_2m_max;
|
|
266
215
|
const mins = daily.temperature_2m_min;
|
|
267
216
|
const codes = daily.weathercode;
|
|
268
|
-
const precip = Array.isArray(daily.precipitation_probability_max)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
weatherCode: codes[i]
|
|
275
|
-
}));
|
|
217
|
+
const precip = Array.isArray(daily.precipitation_probability_max)
|
|
218
|
+
? daily.precipitation_probability_max
|
|
219
|
+
: undefined;
|
|
220
|
+
const rows = times.map((date, i) => ({
|
|
221
|
+
date, tempMaxC: maxes[i], tempMinC: mins[i], precipProbMaxPct: precip?.[i] ?? null, weatherCode: codes[i],
|
|
222
|
+
}));
|
|
276
223
|
return {
|
|
277
|
-
ok: true,
|
|
278
|
-
via: 'open-meteo',
|
|
224
|
+
ok: true, via: 'open-meteo',
|
|
279
225
|
evidence: `[实时API:open-meteo@${ts}]`,
|
|
280
226
|
latencyMs,
|
|
281
227
|
timezone: typeof payload?.['timezone'] === 'string' ? payload['timezone'] : undefined,
|
|
282
|
-
daily: rows
|
|
228
|
+
daily: rows,
|
|
283
229
|
};
|
|
284
230
|
}
|
|
231
|
+
/** 历史气候(季节性推荐底座:去年同期日均温/降雨概率) */
|
|
285
232
|
export async function getClimate(point, month, opts = {}) {
|
|
286
233
|
const started = monotonicNow();
|
|
287
234
|
const ts = new Date().toISOString();
|
|
288
235
|
const timeoutMs = normalizeTimeout(opts.timeoutMs ?? 15_000);
|
|
289
|
-
if (timeoutMs === null)
|
|
290
|
-
ok: false,
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
latencyMs: monotonicNow() - started,
|
|
294
|
-
error: TIMEOUT_ERROR
|
|
295
|
-
};
|
|
296
|
-
const year = opts.year ?? new Date().getUTCFullYear() - 1;
|
|
236
|
+
if (timeoutMs === null)
|
|
237
|
+
return { ok: false, via: 'open-meteo-error', evidence: `[实时API:open-meteo-climate@error@${ts}]`, latencyMs: monotonicNow() - started, error: TIMEOUT_ERROR };
|
|
238
|
+
// 默认取去年同月做气候基线
|
|
239
|
+
const year = opts.year ?? (new Date().getUTCFullYear() - 1);
|
|
297
240
|
if (!Number.isInteger(month) || month < 1 || month > 12 || !Number.isInteger(year) || year < 1940 || year > new Date().getUTCFullYear()) {
|
|
298
|
-
return {
|
|
299
|
-
ok: false,
|
|
300
|
-
via: 'open-meteo-error',
|
|
301
|
-
evidence: `[实时API:open-meteo-climate@error@${ts}]`,
|
|
302
|
-
latencyMs: monotonicNow() - started,
|
|
303
|
-
error: 'month/year must be valid historical calendar values'
|
|
304
|
-
};
|
|
241
|
+
return { ok: false, via: 'open-meteo-error', evidence: `[实时API:open-meteo-climate@error@${ts}]`, latencyMs: monotonicNow() - started, error: 'month/year must be valid historical calendar values' };
|
|
305
242
|
}
|
|
306
243
|
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
307
|
-
const expectedDates = Array.from({
|
|
308
|
-
length: lastDay
|
|
309
|
-
}, (_, i)=>`${year}-${String(month).padStart(2, '0')}-${String(i + 1).padStart(2, '0')}`);
|
|
244
|
+
const expectedDates = Array.from({ length: lastDay }, (_, i) => `${year}-${String(month).padStart(2, '0')}-${String(i + 1).padStart(2, '0')}`);
|
|
310
245
|
const start = `${year}-${String(month).padStart(2, '0')}-01`;
|
|
311
246
|
const end = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
|
312
|
-
const url = `${ARCHIVE_BASE}?latitude=${point.latitude}&longitude=${point.longitude}`
|
|
247
|
+
const url = `${ARCHIVE_BASE}?latitude=${point.latitude}&longitude=${point.longitude}`
|
|
248
|
+
+ `&start_date=${start}&end_date=${end}`
|
|
249
|
+
+ `&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,weathercode&timezone=auto`;
|
|
313
250
|
const r = await fetchJson(url, timeoutMs, {}, opts.fetchImpl);
|
|
314
251
|
const latencyMs = monotonicNow() - started;
|
|
315
252
|
if (!r.ok) {
|
|
316
|
-
return {
|
|
317
|
-
ok: false,
|
|
318
|
-
via: 'open-meteo-error',
|
|
319
|
-
evidence: `[实时API:open-meteo-climate@error@${ts}]`,
|
|
320
|
-
latencyMs,
|
|
321
|
-
error: r.error
|
|
322
|
-
};
|
|
253
|
+
return { ok: false, via: 'open-meteo-error', evidence: `[实时API:open-meteo-climate@error@${ts}]`, latencyMs, error: r.error };
|
|
323
254
|
}
|
|
324
255
|
const payload = isRecord(r.data) ? r.data : null;
|
|
325
256
|
const daily = isRecord(payload?.['daily']) ? payload['daily'] : null;
|
|
326
|
-
if (!daily || !Array.isArray(daily.time) || !Array.isArray(daily.temperature_2m_max)
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
257
|
+
if (!daily || !Array.isArray(daily.time) || !Array.isArray(daily.temperature_2m_max)
|
|
258
|
+
|| !Array.isArray(daily.temperature_2m_min) || !Array.isArray(daily.weathercode)
|
|
259
|
+
|| daily.time.length !== lastDay
|
|
260
|
+
|| daily.time.length !== daily.temperature_2m_max.length
|
|
261
|
+
|| daily.time.length !== daily.temperature_2m_min.length
|
|
262
|
+
|| daily.time.length !== daily.weathercode.length
|
|
263
|
+
|| daily.time.some(v => typeof v !== 'string')
|
|
264
|
+
|| daily.time.some((v, i) => v !== expectedDates[i])
|
|
265
|
+
|| daily.temperature_2m_max.some(v => typeof v !== 'number' || !Number.isFinite(v))
|
|
266
|
+
|| daily.temperature_2m_min.some(v => typeof v !== 'number' || !Number.isFinite(v))
|
|
267
|
+
|| daily.weathercode.some(v => typeof v !== 'number' || !Number.isFinite(v))) {
|
|
268
|
+
return { ok: false, via: 'open-meteo-error', evidence: `[实时API:open-meteo-climate@error@${ts}]`, latencyMs, error: 'empty daily payload' };
|
|
334
269
|
}
|
|
335
270
|
const times = daily.time;
|
|
336
271
|
const maxes = daily.temperature_2m_max;
|
|
337
272
|
const mins = daily.temperature_2m_min;
|
|
338
273
|
const codes = daily.weathercode;
|
|
339
|
-
const rows = times.map((date, i)=>({
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
precipProbMaxPct: null,
|
|
344
|
-
weatherCode: codes[i]
|
|
345
|
-
}));
|
|
274
|
+
const rows = times.map((date, i) => ({
|
|
275
|
+
date, tempMaxC: maxes[i], tempMinC: mins[i], precipProbMaxPct: null, // archive 端点没有概率,用降水量另行估算
|
|
276
|
+
weatherCode: codes[i],
|
|
277
|
+
}));
|
|
346
278
|
return {
|
|
347
|
-
ok: true,
|
|
348
|
-
via: 'open-meteo',
|
|
279
|
+
ok: true, via: 'open-meteo',
|
|
349
280
|
evidence: `[实时API:open-meteo-climate@${ts}]`,
|
|
350
281
|
latencyMs,
|
|
351
282
|
timezone: typeof payload?.['timezone'] === 'string' ? payload['timezone'] : undefined,
|
|
352
|
-
daily: rows
|
|
283
|
+
daily: rows,
|
|
353
284
|
};
|
|
354
285
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
//# sourceURL=ts/capabilities/weather.ts
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* action-cache 自愈层确定性测试(RFC P2;run-all §25):
|
|
3
|
+
* 命中/指纹失配/TTL 过期/变量化 key/LRU 淘汰/损坏文件容错/持久化跨实例/幂等回写。
|
|
4
|
+
* 全纯函数 + 临时目录,零浏览器零网络。
|
|
5
|
+
*/
|
|
1
6
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
7
|
import { tmpdir } from 'node:os';
|
|
3
8
|
import { join } from 'node:path';
|
|
@@ -8,7 +13,8 @@ function assert(cond, label, detail) {
|
|
|
8
13
|
if (cond) {
|
|
9
14
|
pass += 1;
|
|
10
15
|
console.log(` ok - ${label}`);
|
|
11
|
-
}
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
12
18
|
fail += 1;
|
|
13
19
|
console.log(` FAIL - ${label}${detail !== undefined ? ' :: ' + String(detail) : ''}`);
|
|
14
20
|
}
|
|
@@ -17,91 +23,50 @@ const iso = mkdtempSync(join(tmpdir(), 'gotry-actioncache-'));
|
|
|
17
23
|
const path = actionCachePath(iso);
|
|
18
24
|
const T0 = new Date('2026-08-28T10:00:00Z');
|
|
19
25
|
let clock = T0.getTime();
|
|
20
|
-
const now = ()=>new Date(clock);
|
|
26
|
+
const now = () => new Date(clock);
|
|
21
27
|
const site = 'ctrip-flight';
|
|
22
28
|
console.log('A. templateKey(参数变量化)');
|
|
23
|
-
const k1 = templateKey(site, 'search', {
|
|
24
|
-
|
|
25
|
-
to: '丽江',
|
|
26
|
-
date: '2026-10-01'
|
|
27
|
-
});
|
|
28
|
-
const k2 = templateKey(site, 'search', {
|
|
29
|
-
from: '上海',
|
|
30
|
-
to: '丽江',
|
|
31
|
-
date: '2026-11-11'
|
|
32
|
-
});
|
|
29
|
+
const k1 = templateKey(site, 'search', { from: '上海', to: '丽江', date: '2026-10-01' });
|
|
30
|
+
const k2 = templateKey(site, 'search', { from: '上海', to: '丽江', date: '2026-11-11' });
|
|
33
31
|
assert(k1 === k2 && k1 === 'ctrip-flight:search:上海|丽江|%date%', '同型查询共享模板 key(日期变量化),跨日期复用');
|
|
34
32
|
console.log('B. 命中与指纹失配(被动失效)');
|
|
35
|
-
const c = new ActionCache(path, {
|
|
36
|
-
now
|
|
37
|
-
});
|
|
33
|
+
const c = new ActionCache(path, { now });
|
|
38
34
|
const fpA = fingerprint('batchSearch v1 shape {"flightItineraryList":[]}');
|
|
39
|
-
const fpB = fingerprint('batchSearch v2 shape {"itineraries":[]}');
|
|
40
|
-
c.record(k1, fpA, {
|
|
41
|
-
entryUrl: 'https://flights.ctrip.com/online/list/oneway-sha-ljg?depdate=%date%',
|
|
42
|
-
parser: 'batchSearch.v1'
|
|
43
|
-
}, site);
|
|
35
|
+
const fpB = fingerprint('batchSearch v2 shape {"itineraries":[]}'); // 接口改版
|
|
36
|
+
c.record(k1, fpA, { entryUrl: 'https://flights.ctrip.com/online/list/oneway-sha-ljg?depdate=%date%', parser: 'batchSearch.v1' }, site);
|
|
44
37
|
clock += 60_000;
|
|
45
38
|
assert(c.lookup(k1, fpA, site)?.locator.parser === 'batchSearch.v1', '指纹一致 → 命中并回放确定性载荷');
|
|
46
39
|
assert(c.lookup(k1, fpA, site)?.hits === 2, '命中计 hits');
|
|
47
40
|
assert(c.lookup(k1, fpB, site) === null, '接口改版(指纹失配)→ miss 不误放(错误的缓存点击比慢更糟)');
|
|
48
|
-
assert(c.lookup(templateKey(site, 'search', {
|
|
49
|
-
from: '北京',
|
|
50
|
-
to: '大理',
|
|
51
|
-
date: '2026-10-01'
|
|
52
|
-
}), fpA, site) === null, 'key 不同 → miss');
|
|
41
|
+
assert(c.lookup(templateKey(site, 'search', { from: '北京', to: '大理', date: '2026-10-01' }), fpA, site) === null, 'key 不同 → miss');
|
|
53
42
|
console.log('C. miss 回写与幂等');
|
|
54
|
-
c.record(k1, fpB, {
|
|
55
|
-
entryUrl: 'oneway-bjs-dlu?depdate=%date%',
|
|
56
|
-
parser: 'batchSearch.v2'
|
|
57
|
-
}, site);
|
|
43
|
+
c.record(k1, fpB, { entryUrl: 'oneway-bjs-dlu?depdate=%date%', parser: 'batchSearch.v2' }, site);
|
|
58
44
|
assert(c.lookup(k1, fpB, site)?.locator.parser === 'batchSearch.v2', '改版后回写新载荷,下次命中');
|
|
59
45
|
const hitsBefore = c.lookup(k1, fpB, site).hits;
|
|
60
|
-
c.record(k1, fpB, {
|
|
61
|
-
parser: 'batchSearch.v2'
|
|
62
|
-
}, site);
|
|
46
|
+
c.record(k1, fpB, { parser: 'batchSearch.v2' }, site);
|
|
63
47
|
assert(c.lookup(k1, fpB, site).hits === hitsBefore + 1, '重复 record 幂等(hits/createdAt 保留,不重置)');
|
|
64
48
|
console.log('D. TTL 过期(48h 默认;注入时钟)');
|
|
65
49
|
clock += 49 * 3600_000;
|
|
66
50
|
assert(c.lookup(k1, fpB, site) === null && c.size(site) === 0, '超 48h → 过期删除条目');
|
|
67
51
|
console.log('E. LRU 淘汰(每站点上限)');
|
|
68
|
-
const c2 = new ActionCache(path + '.lru', {
|
|
69
|
-
|
|
70
|
-
maxEntriesPerSite: 3
|
|
71
|
-
});
|
|
72
|
-
for(let i = 0; i < 5; i++){
|
|
52
|
+
const c2 = new ActionCache(path + '.lru', { now, maxEntriesPerSite: 3 });
|
|
53
|
+
for (let i = 0; i < 5; i++) {
|
|
73
54
|
const key = `ctrip-flight:search:城${i}|丽江|%date%`;
|
|
74
|
-
c2.record(key, fingerprint(`shape-${i}`), {
|
|
75
|
-
i: String(i)
|
|
76
|
-
}, site);
|
|
55
|
+
c2.record(key, fingerprint(`shape-${i}`), { i: String(i) }, site);
|
|
77
56
|
clock += 1_000;
|
|
78
57
|
}
|
|
79
58
|
assert(c2.size(site) === 3, '超容 LRU 淘汰至 3 条');
|
|
80
59
|
console.log('F. 持久化跨实例 + 损坏文件容错');
|
|
81
|
-
const c3 = new ActionCache(path + '.p', {
|
|
82
|
-
|
|
83
|
-
});
|
|
84
|
-
c3.record('meituan:search:大理|民宿|%date%', fingerprint('mt-shape'), {
|
|
85
|
-
url: 'https://www.meituan.com/...'
|
|
86
|
-
}, 'meituan');
|
|
60
|
+
const c3 = new ActionCache(path + '.p', { now });
|
|
61
|
+
c3.record('meituan:search:大理|民宿|%date%', fingerprint('mt-shape'), { url: 'https://www.meituan.com/...' }, 'meituan');
|
|
87
62
|
clock += 5_000;
|
|
88
|
-
const c4 = new ActionCache(path + '.p', {
|
|
89
|
-
now
|
|
90
|
-
});
|
|
63
|
+
const c4 = new ActionCache(path + '.p', { now });
|
|
91
64
|
assert(c4.lookup('meituan:search:大理|民宿|%date%', fingerprint('mt-shape'), 'meituan') !== null, '跨实例:落盘后新实例可命中');
|
|
92
65
|
writeFileSync(path + '.broken', '{not json', 'utf8');
|
|
93
|
-
const c5 = new ActionCache(path + '.broken', {
|
|
94
|
-
now
|
|
95
|
-
});
|
|
66
|
+
const c5 = new ActionCache(path + '.broken', { now });
|
|
96
67
|
assert(c5.size() === 0 && c5.lookup('any', 'any', site) === null, '损坏 JSON 按空缓存,不抛错');
|
|
97
68
|
console.log('G. fingerprint 敏感度');
|
|
98
69
|
assert(fingerprint('abc') !== fingerprint('abd') && fingerprint('abc') === fingerprint('abc'), '微改即变,同文稳定');
|
|
99
|
-
rmSync(iso, {
|
|
100
|
-
recursive: true,
|
|
101
|
-
force: true
|
|
102
|
-
});
|
|
70
|
+
rmSync(iso, { recursive: true, force: true });
|
|
103
71
|
console.log(`\nACTION-CACHE: ${pass} pass, ${fail} fail`);
|
|
104
72
|
process.exit(fail > 0 ? 1 : 0);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
//# sourceURL=ts/scripts/action-cache-tests.ts
|