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