@danceiny/gotry 0.2.0-rc.31 → 0.2.0-rc.32
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 +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +63 -53
- package/bin/gotry-hbcli-release.d.ts +3 -0
- package/bin/gotry-hbcli-release.js +4 -0
- package/client/client.js +66 -21
- package/dist/capabilities/doctor.js +7 -8
- package/dist/capabilities/effect.js +45 -12
- package/dist/capabilities/flyai-config.js +12 -10
- package/dist/capabilities/flyai.js +87 -56
- package/dist/capabilities/hbcli-config.js +156 -0
- package/dist/capabilities/hbcli.js +29 -2
- package/dist/capabilities/resilience.js +6 -6
- package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
- package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
- package/dist/capabilities/session/ctrip-city.js +186 -0
- package/dist/capabilities/session/extension-bridge.js +8 -1
- package/dist/capabilities/session/gui-flight.js +737 -0
- package/dist/capabilities/session/gui-hotel.js +413 -0
- package/dist/capabilities/session-search.js +98 -11
- package/dist/scripts/bootstrap-tests.js +23 -6
- package/dist/scripts/ctrip-city-tests.js +80 -0
- package/dist/scripts/effect-tests.js +602 -504
- package/dist/scripts/extension-tests.js +14 -8
- package/dist/scripts/flight-malformed-tests.js +1 -1
- package/dist/scripts/flyai-setup-tests.js +19 -4
- package/dist/scripts/flyai-tests.js +768 -653
- package/dist/scripts/gotry-web-api-tests.js +5 -0
- package/dist/scripts/gotry-web-client-tests.js +20 -1
- package/dist/scripts/gui-flight-live.js +27 -0
- package/dist/scripts/hbcli-tests.js +1 -1
- package/dist/scripts/hbcli-upgrade-tests.js +107 -0
- package/dist/scripts/hbcli-web-setup-tests.js +114 -0
- package/dist/scripts/hotel-date-gate-tests.js +1 -1
- package/dist/scripts/hotel-result-tests.js +169 -0
- package/dist/scripts/issue-271-liveness-tests.js +1 -1
- package/dist/scripts/session-tests.js +177 -6
- package/dist/src/gotry-web-api.js +64 -2
- package/dist/src/index.js +40 -18
- package/extension/manifest.json +2 -2
- package/package.json +2 -1
- package/ts/capabilities/flyai-config.ts +12 -10
- package/ts/capabilities/flyai.ts +91 -50
- package/ts/capabilities/hbcli-config.ts +128 -0
- package/ts/capabilities/hbcli.ts +30 -3
- package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
- package/ts/capabilities/session/ctrip-city.ts +158 -0
- package/ts/capabilities/session/extension-bridge.ts +9 -2
- package/ts/capabilities/session/gui-flight.ts +792 -0
- package/ts/capabilities/session/gui-hotel.ts +451 -0
- package/ts/capabilities/session-search.ts +115 -15
- package/ts/src/gotry-web-api.ts +51 -2
- package/ts/src/index.ts +41 -19
package/ts/capabilities/flyai.ts
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
import { closeSync, openSync, readFileSync, unlinkSync } from 'node:fs'
|
|
33
33
|
import { tmpdir } from 'node:os'
|
|
34
34
|
import { join } from 'node:path'
|
|
35
|
-
import { resolveFlyaiEndpoint, resolveFlyaiKey, type FlyaiKeySource } from './flyai-config.ts'
|
|
35
|
+
import { normalizeEndpoint, resolveFlyaiEndpoint, resolveFlyaiKey, sha256Hex, type FlyaiKeySource } from './flyai-config.ts'
|
|
36
36
|
import { spawnBounded } from './spawn-bounded.ts'
|
|
37
37
|
|
|
38
38
|
export type FlyaiKind =
|
|
@@ -239,6 +239,8 @@ export interface FlyaiResult {
|
|
|
239
239
|
verdict: FlyaiVerdict
|
|
240
240
|
/** true = 已识别的上游普通网络/HTTP 5xx/429 瞬时失败,可交给 effect 层退避重试 */
|
|
241
241
|
retryable?: boolean
|
|
242
|
+
/** 独立于重试:只在明确上游故障时计熔断;unknown 不增失败,也不宣称恢复。 */
|
|
243
|
+
upstreamHealth?: 'healthy' | 'unhealthy' | 'unknown'
|
|
242
244
|
/** 存在时表示请求未得到可判定的上游响应,应 fail-closed 且只执行一次。 */
|
|
243
245
|
localTermination?: FlyaiLocalTermination
|
|
244
246
|
kind: FlyaiKind
|
|
@@ -411,7 +413,7 @@ export function parseFlyaiItemList(stdout: string): unknown[] {
|
|
|
411
413
|
}
|
|
412
414
|
|
|
413
415
|
/** 解析 stdout 中首个完整 JSON 对象(不限 itemList;ai-search 用) */
|
|
414
|
-
export function parseFlyaiJsonEnvelope(stdout: string): Record<string, unknown> {
|
|
416
|
+
export function parseFlyaiJsonEnvelope(stdout: string, accepts: (envelope: Record<string, unknown>) => boolean = () => true): Record<string, unknown> {
|
|
415
417
|
let start = -1
|
|
416
418
|
let depth = 0
|
|
417
419
|
let inString = false
|
|
@@ -435,7 +437,12 @@ export function parseFlyaiJsonEnvelope(stdout: string): Record<string, unknown>
|
|
|
435
437
|
depth -= 1
|
|
436
438
|
if (depth === 0) {
|
|
437
439
|
const candidate = stdout.slice(start, index + 1)
|
|
438
|
-
try {
|
|
440
|
+
try {
|
|
441
|
+
const parsed = JSON.parse(candidate) as Record<string, unknown>
|
|
442
|
+
if (accepts(parsed)) return parsed
|
|
443
|
+
} catch { /* prefix logs may contain balanced non-JSON braces */ }
|
|
444
|
+
start = -1
|
|
445
|
+
depth = 0
|
|
439
446
|
}
|
|
440
447
|
}
|
|
441
448
|
}
|
|
@@ -749,6 +756,15 @@ const KIND_LABEL: Record<FlyaiKind, string> = {
|
|
|
749
756
|
keyword: 'keyword', ai: 'ai', 'marriott-hotel': 'marriott hotel', 'marriott-package': 'marriott package',
|
|
750
757
|
}
|
|
751
758
|
|
|
759
|
+
/** 每种检索 × 生效 endpoint/key 隔离;Map 中只留下不可逆指纹。 */
|
|
760
|
+
export function flyaiBreakerScope(kind: unknown): string {
|
|
761
|
+
const key = resolveFlyaiKey()
|
|
762
|
+
const endpoint = resolveFlyaiEndpoint().url
|
|
763
|
+
const identity = sha256Hex(JSON.stringify([normalizeEndpoint(endpoint) ?? endpoint, key.key ?? null]))
|
|
764
|
+
const safeKind = typeof kind === 'string' && Object.hasOwn(KIND_LABEL, kind) ? kind : 'invalid-kind'
|
|
765
|
+
return `${safeKind}:${identity}`
|
|
766
|
+
}
|
|
767
|
+
|
|
752
768
|
/**
|
|
753
769
|
* FlyAI 官方只读检索(8 命令)— 任何失败走降级;不抛错。
|
|
754
770
|
*/
|
|
@@ -758,9 +774,9 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
|
|
|
758
774
|
const envContext = await runtimeEnvNote()
|
|
759
775
|
const envNote = envContext.note
|
|
760
776
|
const sensitive = envContext.sensitive
|
|
761
|
-
const safe =
|
|
777
|
+
const safe = (result: FlyaiResult): FlyaiResult => safeFlyaiResult({ upstreamHealth: 'unknown', retryable: false, ...result }, sensitive)
|
|
762
778
|
const built = buildCliArgs(q)
|
|
763
|
-
if (built.error) return { ...badArg(q.kind, built.error, ts), ...envNote }
|
|
779
|
+
if (built.error) return safe({ ...badArg(q.kind, built.error, ts), ...envNote })
|
|
764
780
|
|
|
765
781
|
const prefix = q.cliPrefixArgs ?? ['-y', '@fly-ai/flyai-cli@1.0.16']
|
|
766
782
|
const r = await sh(q.cliBin ?? 'npx', [...prefix, ...built.args!], {
|
|
@@ -769,7 +785,6 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
|
|
|
769
785
|
signal: q.signal,
|
|
770
786
|
})
|
|
771
787
|
const latencyMs = Date.now() - started
|
|
772
|
-
const combined = `${r.stderr}\n${r.stdout}`
|
|
773
788
|
|
|
774
789
|
if (r.cancelled) {
|
|
775
790
|
return safe({
|
|
@@ -797,37 +812,55 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
|
|
|
797
812
|
return safe(localTerminationResult(q.kind, r.localTermination, r, latencyMs, ts, envNote))
|
|
798
813
|
}
|
|
799
814
|
|
|
800
|
-
if (r.error || r.code !== 0 ||
|
|
815
|
+
if (r.error || r.code !== 0 || explicitUpstreamFailure(r.stderr)) {
|
|
801
816
|
return safe(classifyUpstreamFailure(q.kind, r, latencyMs, ts, envNote))
|
|
802
817
|
}
|
|
803
818
|
|
|
819
|
+
// Validate the response envelope before accepting inventory for any kind.
|
|
820
|
+
// A business status is not an HTTP status; successful data is never scanned
|
|
821
|
+
// for fault words (for example an AI answer quoting an HTTP error).
|
|
822
|
+
let envelope: Record<string, unknown>
|
|
823
|
+
try {
|
|
824
|
+
envelope = parseFlyaiJsonEnvelope(r.stdout, candidate => ['data', 'status', 'error'].some(key => Object.hasOwn(candidate, key))
|
|
825
|
+
|| explicitUpstreamFailure(nonEmptyText(candidate.message) ?? ''))
|
|
826
|
+
} catch (e) {
|
|
827
|
+
if (explicitUpstreamFailure(r.stdout)) return safe(classifyUpstreamFailure(q.kind, r, latencyMs, ts, envNote))
|
|
828
|
+
const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160)
|
|
829
|
+
return safe({
|
|
830
|
+
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
831
|
+
evidence: `[实时API:flyai@error@${ts}] parse failed: ${raw}`,
|
|
832
|
+
error: `failed to parse flyai output (${(e as Error).message}): ${raw}`,
|
|
833
|
+
})
|
|
834
|
+
}
|
|
835
|
+
const systemMessage = nonEmptyText(envelope.systemMessage)
|
|
836
|
+
const envelopeFailure = `${nonEmptyText(envelope.message) ?? ''}\n${envelope.error == null ? '' : JSON.stringify(envelope.error)}`
|
|
837
|
+
if (explicitUpstreamFailure(envelopeFailure)) {
|
|
838
|
+
return safe(classifyUpstreamFailure(q.kind, { ...r, stdout: envelopeFailure }, latencyMs, ts, envNote))
|
|
839
|
+
}
|
|
840
|
+
if ((envelope.status !== undefined && envelope.status !== 0) || envelope.error != null) {
|
|
841
|
+
return safe({
|
|
842
|
+
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
843
|
+
evidence: `[实时API:flyai@error@${ts}] upstream response status=${String(envelope.status ?? 'unknown')}`,
|
|
844
|
+
error: nonEmptyText(envelope.message) ?? 'FlyAI upstream response rejected the query',
|
|
845
|
+
...(systemMessage ? { systemMessage } : {}),
|
|
846
|
+
})
|
|
847
|
+
}
|
|
848
|
+
|
|
804
849
|
// ai-search:data 是自由形状,不走 itemList。
|
|
805
850
|
if (q.kind === 'ai') {
|
|
806
|
-
|
|
807
|
-
try {
|
|
808
|
-
envelope = parseFlyaiJsonEnvelope(r.stdout)
|
|
809
|
-
} catch (e) {
|
|
810
|
-
const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160)
|
|
811
|
-
return safe({
|
|
812
|
-
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
813
|
-
evidence: `[实时API:flyai@error@${ts}] parse failed: ${raw}`,
|
|
814
|
-
error: `failed to parse flyai ai output (${(e as Error).message}): ${raw}`,
|
|
815
|
-
})
|
|
816
|
-
}
|
|
817
|
-
const systemMessage = nonEmptyText(envelope.systemMessage)
|
|
818
|
-
if (envelope.status !== undefined && envelope.status !== 0) {
|
|
851
|
+
if (!Object.hasOwn(envelope, 'data')) {
|
|
819
852
|
return safe({
|
|
820
853
|
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
821
|
-
evidence: `[实时API:flyai@error@${ts}] ai
|
|
822
|
-
error:
|
|
854
|
+
evidence: `[实时API:flyai@error@${ts}] malformed ai response: missing data`,
|
|
855
|
+
error: 'FlyAI ai response malformed: missing data member',
|
|
823
856
|
...(systemMessage ? { systemMessage } : {}),
|
|
824
857
|
})
|
|
825
858
|
}
|
|
826
859
|
const aiData = envelope.data
|
|
827
|
-
const empty = aiData ===
|
|
860
|
+
const empty = aiData === null || aiData === ''
|
|
828
861
|
|| (Array.isArray(aiData) && aiData.length === 0)
|
|
829
862
|
return safe(JSON.parse(JSON.stringify({
|
|
830
|
-
kind: q.kind, ...envNote, latencyMs, ok: true, via: 'flyai',
|
|
863
|
+
kind: q.kind, ...envNote, latencyMs, ok: true, via: 'flyai', upstreamHealth: 'healthy',
|
|
831
864
|
verdict: empty ? 'miss' : 'hit',
|
|
832
865
|
...(empty ? {} : { aiData }),
|
|
833
866
|
...(systemMessage ? { systemMessage } : {}),
|
|
@@ -835,22 +868,10 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
|
|
|
835
868
|
})) as FlyaiResult)
|
|
836
869
|
}
|
|
837
870
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
items = parseFlyaiItemList(r.stdout) as RawItem[]
|
|
841
|
-
} catch (e) {
|
|
871
|
+
const data = envelope.data as { itemList?: unknown } | null
|
|
872
|
+
if (!Array.isArray(data?.itemList)) {
|
|
842
873
|
const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160)
|
|
843
|
-
|
|
844
|
-
if (/Sentinel/i.test(raw)) {
|
|
845
|
-
return safe({
|
|
846
|
-
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
847
|
-
retryable: false,
|
|
848
|
-
evidence: `[实时API:flyai@error@${ts}] sentinel: ${raw}`,
|
|
849
|
-
error: `FlyAI Sentinel 限流:${raw}`,
|
|
850
|
-
setup: '平台 Sentinel 限流(恢复窗口未公开)——稍后重试,或改走 gotry_session_search(账号会话)。',
|
|
851
|
-
})
|
|
852
|
-
}
|
|
853
|
-
const reason = (e as Error).message
|
|
874
|
+
const reason = 'no complete FlyAI itemList JSON object'
|
|
854
875
|
return safe({
|
|
855
876
|
kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
|
|
856
877
|
retryable: false,
|
|
@@ -859,7 +880,17 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
|
|
|
859
880
|
})
|
|
860
881
|
}
|
|
861
882
|
|
|
862
|
-
return safe(parseItemListResult(q.kind,
|
|
883
|
+
return safe(parseItemListResult(q.kind, data.itemList, latencyMs, ts, envNote, r.stdout))
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// Explicit protocol/transport evidence only. Bare business codes and generic
|
|
887
|
+
// words such as "network" or "timeout" do not diagnose an upstream outage.
|
|
888
|
+
const HTTP_FAILURE = /\bHTTP(?:\/[\d.]+)?\s*[:=]?\s*([45]\d\d)\b/i
|
|
889
|
+
const TRANSPORT_FAILURE = /\b(?:ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN)\b|socket hang up|fetch failed|network (?:error|failure|unreachable)|(?:request|connection) (?:timed out|timeout|reset|refused)/i
|
|
890
|
+
|
|
891
|
+
function explicitUpstreamFailure(text: string): boolean {
|
|
892
|
+
return HTTP_FAILURE.test(text) || TRANSPORT_FAILURE.test(text)
|
|
893
|
+
|| /Invalid API key|Trial limit reached|SentinelBlockException/i.test(text)
|
|
863
894
|
}
|
|
864
895
|
|
|
865
896
|
/** 当前进程 key 来源/endpoint 覆盖(仅展示,与 spawn 实际生效同源) */
|
|
@@ -886,39 +917,47 @@ function classifyUpstreamFailure(
|
|
|
886
917
|
): FlyaiResult {
|
|
887
918
|
const upstream = `${r.stderr}\n${r.stdout}`.replace(/\s+/g, ' ').trim()
|
|
888
919
|
const tag = (verdict: FlyaiVerdict, evidence: string, extra: Partial<FlyaiResult>): FlyaiResult =>
|
|
889
|
-
({ kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict, evidence, ...extra })
|
|
920
|
+
({ kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict, upstreamHealth: 'unknown', evidence, ...extra })
|
|
921
|
+
const httpStatus = Number(HTTP_FAILURE.exec(upstream)?.[1])
|
|
890
922
|
|
|
891
|
-
if (/Invalid API key
|
|
923
|
+
if (/Invalid API key/i.test(upstream) || httpStatus === 401) {
|
|
892
924
|
return tag('auth-error', `[实时API:flyai@error@${ts}] 401 invalid api key`, {
|
|
893
925
|
retryable: false,
|
|
894
926
|
error: `FlyAI 鉴权失败(401):${upstream.slice(0, 160)}`,
|
|
895
927
|
setup: '当前 key 无效或已吊销——在本机终端运行 `gotry setup flyai` 重新设置并验证(模型无法替你输入 key);`gotry setup flyai --clear` 可清除错误 key,清除后仍可匿名试用。',
|
|
896
928
|
})
|
|
897
929
|
}
|
|
898
|
-
if (
|
|
930
|
+
if (httpStatus === 403) {
|
|
899
931
|
return tag('forbidden', `[实时API:flyai@error@${ts}] 403 forbidden`, {
|
|
900
932
|
retryable: false,
|
|
901
933
|
error: `FlyAI 拒绝访问(403):${upstream.slice(0, 160)}`,
|
|
902
934
|
setup: '上游 403:核对该 key 的权限范围与控制台状态;如 endpoint 被 DEBUG_FLYAI_MCP_URL 覆盖,请确认指向。',
|
|
903
935
|
})
|
|
904
936
|
}
|
|
905
|
-
if (/Trial limit reached
|
|
937
|
+
if (/Trial limit reached/i.test(upstream)) {
|
|
906
938
|
return tag('needs-setup', `[实时API:flyai@error@${ts}] trial quota exhausted`, {
|
|
907
939
|
retryable: false,
|
|
908
940
|
error: `FlyAI 匿名试用额度已用尽:${upstream.slice(0, 160)}`,
|
|
909
941
|
setup: '打开 https://flyai.open.fliggy.com/console,登录后复制 API Key,本机运行 `gotry setup flyai` 配置并验证;本会话请勿盲重试 gotry_flyai_search,机/火/酒改走 gotry_session_search。',
|
|
910
942
|
})
|
|
911
943
|
}
|
|
912
|
-
if (/
|
|
944
|
+
if (/SentinelBlockException/i.test(upstream)) {
|
|
945
|
+
return tag('error', `[实时API:flyai@error@${ts}] sentinel`, {
|
|
946
|
+
retryable: false, upstreamHealth: 'unhealthy',
|
|
947
|
+
error: `FlyAI Sentinel 限流:${upstream.slice(0, 160)}`,
|
|
948
|
+
setup: '平台 Sentinel 限流(恢复窗口未公开);稍后重试,或改走 gotry_session_search(账号会话)。',
|
|
949
|
+
})
|
|
950
|
+
}
|
|
951
|
+
if (httpStatus === 429) {
|
|
913
952
|
return tag('rate-limited', `[实时API:flyai@error@${ts}] 429 rate limited`, {
|
|
914
|
-
retryable: true,
|
|
953
|
+
retryable: true, upstreamHealth: 'unhealthy',
|
|
915
954
|
error: `FlyAI 普通限流(非试用达限):${upstream.slice(0, 160)}`,
|
|
916
955
|
setup: '上游普通限流:稍候再查,勿高频连发;仍 429 请稍后或换 gotry_session_search。',
|
|
917
956
|
})
|
|
918
957
|
}
|
|
919
|
-
if (
|
|
958
|
+
if (TRANSPORT_FAILURE.test(upstream) || httpStatus >= 500) {
|
|
920
959
|
return tag('error', `[实时API:flyai@error@${ts}] transient upstream`, {
|
|
921
|
-
retryable: true,
|
|
960
|
+
retryable: true, upstreamHealth: 'unhealthy',
|
|
922
961
|
error: `FlyAI 瞬时网络/上游错误:${upstream.slice(0, 160)}`,
|
|
923
962
|
setup: '网络抖动或上游 5xx:稍后重试;持续失败请检查本机网络或改走 gotry_session_search。',
|
|
924
963
|
})
|
|
@@ -982,7 +1021,7 @@ function parseItemListResult(
|
|
|
982
1021
|
const count = parsed.options.length
|
|
983
1022
|
const verdict: FlyaiVerdict = count > 0 ? 'hit' : 'miss'
|
|
984
1023
|
const evidence = `[实时API:flyai@${ts}] ${count}/${items.length} ${KIND_LABEL[kind]} options`
|
|
985
|
-
const common = { kind, ...envNote, latencyMs, ok: true, via: 'flyai' as const, verdict, evidence, ...(systemMessage ? { systemMessage } : {}) }
|
|
1024
|
+
const common = { kind, ...envNote, latencyMs, ok: true, via: 'flyai' as const, upstreamHealth: 'healthy' as const, verdict, evidence, ...(systemMessage ? { systemMessage } : {}) }
|
|
986
1025
|
|
|
987
1026
|
switch (kind) {
|
|
988
1027
|
case 'flight':
|
|
@@ -1088,7 +1127,9 @@ function parseHotelItems(items: unknown[]): ParsedItems<FlyaiHotelOption> {
|
|
|
1088
1127
|
}
|
|
1089
1128
|
const it = raw as RawItem
|
|
1090
1129
|
const name = nonEmptyText(it.name)
|
|
1091
|
-
|
|
1130
|
+
// Unrated homestays may return whitespace (live Harbin response).
|
|
1131
|
+
// Only this optional descriptive field permits blank text; types stay strict.
|
|
1132
|
+
const star = typeof it.star === 'string' && !it.star.trim() ? undefined : stringField(it, 'star')
|
|
1092
1133
|
const rawPrice = stringField(it, 'price')
|
|
1093
1134
|
const rate = it.rate === undefined ? undefined : it.rate === null ? null : nonEmptyText(it.rate) ?? 'bad'
|
|
1094
1135
|
const address = stringField(it, 'address')
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/** Official hbcli credential-file contract; no credentials enter tool results. */
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
3
|
+
import { homedir, tmpdir } from 'node:os'
|
|
4
|
+
import { isAbsolute, join } from 'node:path'
|
|
5
|
+
import { atomicWriteJson, sha256Hex } from './flyai-config.ts'
|
|
6
|
+
import { hbcliBinCandidates } from './hbcli.ts'
|
|
7
|
+
import { spawnBounded } from './spawn-bounded.ts'
|
|
8
|
+
|
|
9
|
+
export const HBCLI_REGISTRATION_URL = 'https://hotelbyte.com/zh/guides/sandbox-verification'
|
|
10
|
+
const ENVIRONMENT = 'uat'
|
|
11
|
+
const ENTRY = `openapi:${ENVIRONMENT}`
|
|
12
|
+
export interface HbcliConfigOptions { homeDir?: string; env?: NodeJS.ProcessEnv; hbcliBin?: string }
|
|
13
|
+
export type HbcliSetupVerdict = 'hit' | 'miss' | 'no-suppliers'
|
|
14
|
+
interface Verification { schema: 'gotry.hbcli-verification.v1'; fingerprint: string; verdict: HbcliSetupVerdict; at: string }
|
|
15
|
+
|
|
16
|
+
export function hbcliCredentialRoot(options: HbcliConfigOptions): string {
|
|
17
|
+
return options.env?.STAICLI_HOME?.trim() || join(options.homeDir ?? homedir(), '.staicli')
|
|
18
|
+
}
|
|
19
|
+
export function hbcliCredentialPath(options: HbcliConfigOptions): string { return join(hbcliCredentialRoot(options), 'credentials.json') }
|
|
20
|
+
export function hbcliVerificationPath(homeDir: string): string { return join(homeDir, '.gotry', 'hbcli-verification.json') }
|
|
21
|
+
|
|
22
|
+
function safeEntry(path: string, directory: boolean): boolean {
|
|
23
|
+
try {
|
|
24
|
+
const entry = lstatSync(path)
|
|
25
|
+
return !entry.isSymbolicLink() && (directory ? entry.isDirectory() : entry.isFile())
|
|
26
|
+
&& (typeof process.getuid !== 'function' || entry.uid === process.getuid())
|
|
27
|
+
} catch (e) { return (e as NodeJS.ErrnoException).code === 'ENOENT' }
|
|
28
|
+
}
|
|
29
|
+
export function readHbcliCredentials(options: HbcliConfigOptions): { ok: true; data: Record<string, unknown>; bytes?: string } | { ok: false } {
|
|
30
|
+
const root = hbcliCredentialRoot(options), file = hbcliCredentialPath(options)
|
|
31
|
+
try {
|
|
32
|
+
if (!isAbsolute(root) || !safeEntry(root, true) || !safeEntry(file, false)) return { ok: false }
|
|
33
|
+
if (!existsSync(file)) return { ok: true, data: {} }
|
|
34
|
+
const bytes = readFileSync(file, 'utf8'), data: unknown = JSON.parse(bytes)
|
|
35
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? { ok: true, data: data as Record<string, unknown>, bytes } : { ok: false }
|
|
36
|
+
} catch { return { ok: false } }
|
|
37
|
+
}
|
|
38
|
+
function fingerprint(data: Record<string, unknown>): string | undefined {
|
|
39
|
+
const entry = data[ENTRY] as { appKey?: unknown; appSecret?: unknown } | undefined
|
|
40
|
+
return typeof entry?.appKey === 'string' && entry.appKey.trim() && typeof entry.appSecret === 'string' && entry.appSecret.trim()
|
|
41
|
+
? sha256Hex(JSON.stringify([ENVIRONMENT, entry.appKey, entry.appSecret])) : undefined
|
|
42
|
+
}
|
|
43
|
+
function readReceipt(home: string): Verification | undefined {
|
|
44
|
+
try {
|
|
45
|
+
const path = hbcliVerificationPath(home)
|
|
46
|
+
if (!safeEntry(join(home, '.gotry'), true) || !safeEntry(path, false)) return undefined
|
|
47
|
+
const v = JSON.parse(readFileSync(path, 'utf8')) as Verification
|
|
48
|
+
return v.schema === 'gotry.hbcli-verification.v1' && typeof v.fingerprint === 'string'
|
|
49
|
+
&& ['hit', 'miss', 'no-suppliers'].includes(v.verdict) && Number.isFinite(Date.parse(v.at)) ? v : undefined
|
|
50
|
+
} catch { return undefined }
|
|
51
|
+
}
|
|
52
|
+
export function hbcliSetupStatus(options: HbcliConfigOptions) {
|
|
53
|
+
const read = readHbcliCredentials(options), hash = read.ok ? fingerprint(read.data) : undefined
|
|
54
|
+
const receipt = readReceipt(options.homeDir ?? homedir())
|
|
55
|
+
const envToken = Boolean(options.env?.HOTELBYTE_TOKEN?.trim())
|
|
56
|
+
const matches = !envToken && hash && receipt?.fingerprint === hash
|
|
57
|
+
return { ok: true, configured: envToken || Boolean(hash), writable: read.ok && !envToken && process.platform !== 'win32',
|
|
58
|
+
source: envToken ? 'env' : hash ? 'config' : 'none', environment: ENVIRONMENT,
|
|
59
|
+
verified: Boolean(matches && receipt?.verdict !== 'no-suppliers'),
|
|
60
|
+
...(matches && receipt ? { verdict: receipt.verdict, checkedAt: receipt.at } : {}), registrationUrl: HBCLI_REGISTRATION_URL }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function prepareDirectory(dir: string): boolean {
|
|
64
|
+
try {
|
|
65
|
+
if (process.platform === 'win32' || !isAbsolute(dir) || !safeEntry(dir, true)) return false
|
|
66
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
67
|
+
if (!safeEntry(dir, true)) return false
|
|
68
|
+
chmodSync(dir, 0o700)
|
|
69
|
+
return true
|
|
70
|
+
} catch { return false }
|
|
71
|
+
}
|
|
72
|
+
export function writeHbcliCredentials(data: Record<string, unknown>, options: HbcliConfigOptions): boolean {
|
|
73
|
+
const path = hbcliCredentialPath(options)
|
|
74
|
+
return readHbcliCredentials(options).ok && prepareDirectory(hbcliCredentialRoot(options)) && atomicWriteJson(path, data).ok
|
|
75
|
+
}
|
|
76
|
+
export function writeHbcliVerification(data: Record<string, unknown>, verdict: HbcliSetupVerdict, options: HbcliConfigOptions): boolean {
|
|
77
|
+
const hash = fingerprint(data), home = options.homeDir ?? homedir()
|
|
78
|
+
if (!hash || !prepareDirectory(join(home, '.gotry'))) return false
|
|
79
|
+
const entry: Verification = { schema: 'gotry.hbcli-verification.v1', fingerprint: hash, verdict, at: new Date().toISOString() }
|
|
80
|
+
return safeEntry(hbcliVerificationPath(home), false) && atomicWriteJson(hbcliVerificationPath(home), entry).ok
|
|
81
|
+
}
|
|
82
|
+
export function removeHbcliVerification(homeDir: string): void {
|
|
83
|
+
if (safeEntry(join(homeDir, '.gotry'), true) && safeEntry(hbcliVerificationPath(homeDir), false)) {
|
|
84
|
+
try { rmSync(hbcliVerificationPath(homeDir), { force: true }) } catch { /* best effort */ }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function withHbcliCredentials(data: Record<string, unknown>, appKey: string, appSecret: string): Record<string, unknown> {
|
|
88
|
+
return { ...data, [ENTRY]: { appKey, appSecret } }
|
|
89
|
+
}
|
|
90
|
+
export function withoutHbcliCredentials(data: Record<string, unknown>): Record<string, unknown> {
|
|
91
|
+
const next = { ...data }; delete next[ENTRY]; return next
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Verify through the existing CLI bridge, with a private disposable credential root.
|
|
95
|
+
* An upstream no-suppliers verdict is retained separately from a completed query.
|
|
96
|
+
*/
|
|
97
|
+
export async function verifyHbcliCandidate(appKey: string, appSecret: string, options: HbcliConfigOptions, signal: AbortSignal): Promise<{ verdict: HbcliSetupVerdict | 'error' | 'cancelled'; error?: string }> {
|
|
98
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-hbcli-verify-'))
|
|
99
|
+
try {
|
|
100
|
+
if (!atomicWriteJson(join(root, 'credentials.json'), withHbcliCredentials({}, appKey, appSecret)).ok) {
|
|
101
|
+
return { verdict: 'error', error: '无法创建隔离的验证环境。' }
|
|
102
|
+
}
|
|
103
|
+
const checkIn = new Date(); checkIn.setUTCDate(checkIn.getUTCDate() + 1)
|
|
104
|
+
const checkOut = new Date(checkIn); checkOut.setUTCDate(checkOut.getUTCDate() + 1)
|
|
105
|
+
const args = ['--json', '--env', ENVIRONMENT, 'search', 'hotel-list', '--destination-name', '上海',
|
|
106
|
+
'--check-in', checkIn.toISOString().slice(0, 10), '--check-out', checkOut.toISOString().slice(0, 10),
|
|
107
|
+
'--room-occupancies', '[{"adultCount":2,"childrenAges":[]}]', '--page-size', '1']
|
|
108
|
+
const env: NodeJS.ProcessEnv = { ...(options.env ?? process.env), STAICLI_HOME: root, HOTELBYTE_ENV: ENVIRONMENT }
|
|
109
|
+
delete env.HOTELBYTE_TOKEN
|
|
110
|
+
let result: Awaited<ReturnType<typeof spawnBounded>> | undefined
|
|
111
|
+
for (const bin of hbcliBinCandidates(options.hbcliBin ?? options.env?.GOTRY_HBCLI_BIN ?? 'hbcli', options.homeDir)) {
|
|
112
|
+
result = await spawnBounded(bin, args, { env, signal, timeoutMs: 20_000 })
|
|
113
|
+
if (!/ENOENT/.test(result.error ?? '') || signal.aborted) break
|
|
114
|
+
}
|
|
115
|
+
if (signal.aborted || result?.aborted) return { verdict: 'cancelled', error: '验证已取消,旧配置保留。' }
|
|
116
|
+
if (!result || result.error || result.timedOut || result.drainTimedOut || result.groupReaped === false || result.code !== 0) {
|
|
117
|
+
return { verdict: 'error', error: 'HotelByte 验证未完成,请检查 hbcli 安装、网络和凭证;旧配置保留。' }
|
|
118
|
+
}
|
|
119
|
+
let value: { code?: unknown; list?: unknown }
|
|
120
|
+
try { value = JSON.parse(result.stdout) } catch { return { verdict: 'error', error: 'HotelByte 回包无法识别,旧配置保留。' } }
|
|
121
|
+
if (value?.code === 300010002 || value?.code === '300010002') return { verdict: 'no-suppliers' }
|
|
122
|
+
if (!value || (value.code !== undefined && value.code !== 0 && value.code !== '0') || !Array.isArray(value.list)
|
|
123
|
+
|| value.list.some(item => !item || typeof item !== 'object' || Array.isArray(item))) {
|
|
124
|
+
return { verdict: 'error', error: 'HotelByte 未接受凭证或返回了无效的查询结果;旧配置保留。' }
|
|
125
|
+
}
|
|
126
|
+
return { verdict: value.list.length ? 'hit' : 'miss' }
|
|
127
|
+
} finally { rmSync(root, { recursive: true, force: true }) }
|
|
128
|
+
}
|
package/ts/capabilities/hbcli.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface HbcliCallOptions {
|
|
|
32
32
|
export interface HbcliCallResult {
|
|
33
33
|
/** 是否走了实时 hbcli */
|
|
34
34
|
via: 'hbcli-realtime' | 'hbcli-cache' | 'hbcli-error'
|
|
35
|
-
/** hbcli
|
|
35
|
+
/** hbcli 进程退出码;零不代表 API 业务成功 */
|
|
36
36
|
exitCode: number
|
|
37
37
|
/** 解析后的 JSON(若 --json 模式输出可解析;否则为空) */
|
|
38
38
|
result: unknown
|
|
@@ -99,6 +99,24 @@ async function attemptHbcli(
|
|
|
99
99
|
if (jsonStr) {
|
|
100
100
|
try { result = JSON.parse(jsonStr) } catch { /* 非 JSON 输出,留给调用方处理 */ }
|
|
101
101
|
}
|
|
102
|
+
// CLI exit status describes the process, not the API verdict. hbcli can exit
|
|
103
|
+
// zero with an API error envelope (for example an unconfigured supplier pool).
|
|
104
|
+
const code = (result as { code?: unknown } | null)?.code
|
|
105
|
+
const invalid = result === null
|
|
106
|
+
const upstreamFailure = code !== undefined && code !== 0 && code !== '0'
|
|
107
|
+
if (invalid || upstreamFailure) {
|
|
108
|
+
const msg = (result as { msg?: unknown; message?: unknown } | null)?.msg
|
|
109
|
+
?? (result as { message?: unknown } | null)?.message
|
|
110
|
+
const detail = code === 300010002 || code === '300010002'
|
|
111
|
+
? '当前账号没有可用供应商;库存查询未完成'
|
|
112
|
+
: typeof msg === 'string' ? msg.slice(0, 200) : 'API request failed'
|
|
113
|
+
return {
|
|
114
|
+
via: 'hbcli-error', exitCode: 0, result: null,
|
|
115
|
+
evidence: `[实时API:hbcli@error@${ts}]`,
|
|
116
|
+
stdout: r.stdout.slice(0, 2000), latencyMs,
|
|
117
|
+
error: invalid ? 'failed to parse hbcli output as JSON' : `hbcli upstream code ${String(code)}: ${detail}`,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
102
120
|
return {
|
|
103
121
|
via: 'hbcli-realtime', exitCode: 0, result,
|
|
104
122
|
evidence: `[实时API:hbcli@${ts}]`,
|
|
@@ -177,7 +195,7 @@ export async function searchHotels(
|
|
|
177
195
|
if (query.checkIn) hbArgs.push('--check-in', query.checkIn)
|
|
178
196
|
if (query.checkOut) hbArgs.push('--check-out', query.checkOut)
|
|
179
197
|
if (query.adults) hbArgs.push('--room-occupancies', JSON.stringify([{ adultCount: query.adults, childrenAges: [] }]))
|
|
180
|
-
|
|
198
|
+
let live = await callHbcliJson(hbArgs, opts)
|
|
181
199
|
// 取消立即结束:不读静态包、不产出命中/无结果/估算,也不计上游故障
|
|
182
200
|
if (opts.signal?.aborted || /abort/i.test(live.error ?? '')) {
|
|
183
201
|
const ts = new Date().toISOString()
|
|
@@ -188,6 +206,15 @@ export async function searchHotels(
|
|
|
188
206
|
latencyMs: live.latencyMs, error: `aborted by host signal${cleanupError}`, summary: `酒店「${query.destination}」检索已取消,未产生事实`,
|
|
189
207
|
}
|
|
190
208
|
}
|
|
209
|
+
const list = (live.result as { list?: unknown } | null)?.list
|
|
210
|
+
if (live.via === 'hbcli-realtime' && (!Array.isArray(list)
|
|
211
|
+
|| list.some(item => item === null || typeof item !== 'object' || Array.isArray(item)))) {
|
|
212
|
+
live = {
|
|
213
|
+
...live, via: 'hbcli-error', result: null,
|
|
214
|
+
evidence: `[实时API:hbcli@error@${new Date().toISOString()}]`,
|
|
215
|
+
error: 'invalid hotel-list response: expected list array of hotels; inventory not observed',
|
|
216
|
+
}
|
|
217
|
+
}
|
|
191
218
|
if (live.via === 'hbcli-realtime') {
|
|
192
219
|
// summary 即模型面(render 只透 summary):必须自带紧凑数据行 + 证据链,
|
|
193
220
|
// 否则模型只看到「实时返回」占位一句话(issue #195 实锤)。
|
|
@@ -297,7 +324,7 @@ export async function checkAvail(
|
|
|
297
324
|
* ~/.staicli/credentials.json)——hbcli 侧 customer 档是最低优先级凭据(无
|
|
298
325
|
* portal/API-key 凭据时才生效),登录不劫持既有凭据。本层不透出 token 值
|
|
299
326
|
* (CLI 只回 token_saved,凭据留在 CLI 仓内,不进 gotry 会话/日志)。
|
|
300
|
-
* auth 命令族要求 hbcli 带 register 支持(
|
|
327
|
+
* auth 命令族要求 hbcli 带 register 支持(官方原生 0.0.4 起;0.0.3 报 unknown
|
|
301
328
|
* command,经 hbcli-error 面优雅失败)。
|
|
302
329
|
*/
|
|
303
330
|
export async function customerSendCode(
|
|
@@ -7,12 +7,21 @@
|
|
|
7
7
|
* 只读:适配器不含任何提交/预订语义;URL 直开即零 DOM 交互。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
import { resolveCtripFlightCity, cityResolutionHint, type CityResolution } from '../ctrip-city.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 离线起步缓存;未命中由编排层查询供应商城市数据(ctrip-city.ts),不推断航线覆盖。
|
|
14
|
+
* 码表只做性能优化,不在主链路上(显式码/供应商解析是正路)。
|
|
15
|
+
* 实测校准(2026-10-14):香港=hkg/迪拜=dxb 经 list 页实证——合法组合 HTTP 200 且
|
|
16
|
+
* title 含航线名(「中国香港(HKG)到迪拜(阿联酋)(DXB)机票预订」),非法组合 302 跳
|
|
17
|
+
* international/search(该 200/302 判别式即真数据校准手段)。
|
|
18
|
+
*/
|
|
19
|
+
export const CITY_CODES: Record<string, string> = {
|
|
12
20
|
上海: 'sha', 北京: 'bjs', 广州: 'can', 深圳: 'szx', 成都: 'ctu', 昆明: 'kmg',
|
|
13
21
|
大理: 'dlu', 丽江: 'ljg', 西安: 'sia', 杭州: 'hgh', 三亚: 'syx', 厦门: 'xmn',
|
|
14
22
|
重庆: 'cqg', 青岛: 'taa', 长沙: 'csx', 武汉: 'wuh', 南京: 'nkg', 郑州: 'cgo',
|
|
15
23
|
贵阳: 'kwe', 桂林: 'kwl', 西双版纳: 'jhg', 香格里拉: 'dig',
|
|
24
|
+
香港: 'hkg', 迪拜: 'dxb',
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
export interface AdapterEntry {
|
|
@@ -20,16 +29,43 @@ export interface AdapterEntry {
|
|
|
20
29
|
url?: string
|
|
21
30
|
/** 词表外城市逐字保留(调用方降级无日期/无线路搜索) */
|
|
22
31
|
unresolved?: string[]
|
|
32
|
+
error?: string
|
|
23
33
|
}
|
|
24
34
|
|
|
25
|
-
|
|
35
|
+
/** Resolve unknown names against current supplier metadata before inventory navigation. */
|
|
36
|
+
export async function resolveFlightEntryUrl(from: string, to: string, depDate: string,
|
|
37
|
+
codes: { fromCode?: string; toCode?: string } = {},
|
|
38
|
+
resolve: (query: string) => Promise<CityResolution> = resolveCtripFlightCity,
|
|
39
|
+
backDate?: string): Promise<AdapterEntry> {
|
|
40
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(depDate)) return { ok: false, unresolved: [depDate], error: '出发日期需要 YYYY-MM-DD' }
|
|
41
|
+
for (const c of [codes.fromCode, codes.toCode]) if (c !== undefined && !/^[a-z]{3}$/i.test(c)) return { ok: false, error: '城市/机场码必须是三个英文字母' }
|
|
42
|
+
const resolveCode = async (name: string, explicit?: string) => {
|
|
43
|
+
const known = explicit ?? (/^[a-z]{3}$/i.test(name.trim()) ? name.trim() : undefined)
|
|
44
|
+
if (known) return { code: known }
|
|
45
|
+
const result = await resolve(name)
|
|
46
|
+
return result.ok ? { code: result.city.code } : { error: cityResolutionHint(name, result) }
|
|
47
|
+
}
|
|
48
|
+
const [origin, destination] = await Promise.all([resolveCode(from, codes.fromCode), resolveCode(to, codes.toCode)])
|
|
49
|
+
if (!origin.code || !destination.code) return { ok: false, unresolved: [!origin.code ? from : '', !destination.code ? to : ''].filter(Boolean), error: [origin.error, destination.error].filter(Boolean).join(';') }
|
|
50
|
+
return buildEntryUrl(from, to, depDate, { fromCode: origin.code, toCode: destination.code }, backDate)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildEntryUrl(from: string, to: string, depDate: string, codes: { fromCode?: string; toCode?: string } = {}, backDate?: string): AdapterEntry {
|
|
26
54
|
const unresolved: string[] = []
|
|
27
|
-
|
|
28
|
-
const
|
|
55
|
+
// 显式码优先(形状合格才采纳),词表只是缓存——任何城市都可经显式码成行。
|
|
56
|
+
const fromCode = codes.fromCode === undefined ? CITY_CODES[from.trim()] : /^[a-z]{3}$/i.test(codes.fromCode) ? codes.fromCode.toLowerCase() : undefined
|
|
57
|
+
const toCode = codes.toCode === undefined ? CITY_CODES[to.trim()] : /^[a-z]{3}$/i.test(codes.toCode) ? codes.toCode.toLowerCase() : undefined
|
|
29
58
|
if (!fromCode) unresolved.push(from)
|
|
30
59
|
if (!toCode) unresolved.push(to)
|
|
31
|
-
|
|
32
|
-
|
|
60
|
+
const badDate = !/^\d{4}-\d{2}-\d{2}$/.test(depDate)
|
|
61
|
+
|| (backDate !== undefined && (!/^\d{4}-\d{2}-\d{2}$/.test(backDate) || backDate <= depDate))
|
|
62
|
+
if (unresolved.length > 0 || badDate) {
|
|
63
|
+
return { ok: false, unresolved: unresolved.length > 0 ? unresolved : [badDate ? (backDate !== undefined && backDate <= depDate ? backDate : depDate) : depDate], ...(backDate !== undefined && backDate <= depDate ? { error: `回程(${backDate})须晚于去程(${depDate})` } : {}) }
|
|
64
|
+
}
|
|
65
|
+
// 往返形状(2026-10-14 GUI URL 教师实测定稿):round-{code}0-{code}0?depdate=去_回&cabin=y_s
|
|
66
|
+
// ——此前 roundtrip-{3字码}?depdate&retdate 的猜测形状实测 302,已废弃。
|
|
67
|
+
if (backDate) {
|
|
68
|
+
return { ok: true, url: `https://flights.ctrip.com/online/list/round-${fromCode}0-${toCode}0?depdate=${depDate}_${backDate}&cabin=y_s&adult=1&child=0&infant=0` }
|
|
33
69
|
}
|
|
34
70
|
return { ok: true, url: `https://flights.ctrip.com/online/list/oneway-${fromCode}-${toCode}?depdate=${depDate}` }
|
|
35
71
|
}
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
* 页面照常生效(下单/支付面 URL 仍被物理拦截)。
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
import { resolveCtripHotelCity, hotelResolutionHint, type HotelCityResolution } from '../ctrip-city.ts'
|
|
18
|
+
|
|
19
|
+
/** 离线兼容缓存(**实测校准 2026-09-03**:每条 id 均经 hotels.ctrip.com list 页
|
|
18
20
|
* <title> 验证——探测法曾纠出 104=平遥县(传猜成都)等 5 处错猜,表内只收
|
|
19
21
|
* 验证过的 id;词表外 unresolved 逐字保留,调用方可显式传 cityId 覆盖) */
|
|
20
|
-
const HOTEL_CITY_CODES: Record<string, string> = {
|
|
22
|
+
export const HOTEL_CITY_CODES: Record<string, string> = {
|
|
21
23
|
上海: '2', 北京: '1', 广州: '32', 深圳: '30', 成都: '28',
|
|
22
24
|
杭州: '17', 南京: '12', 苏州: '14', 无锡: '13', 扬州: '15', 镇江: '16',
|
|
23
25
|
青岛: '7', 厦门: '25', 珠海: '31', 三亚: '43', 海口: '42',
|
|
@@ -25,6 +27,9 @@ const HOTEL_CITY_CODES: Record<string, string> = {
|
|
|
25
27
|
贵阳: '38', 拉萨: '41', 乌鲁木齐: '39', 太原: '105', 长春: '158', 南通: '82',
|
|
26
28
|
敦煌: '11', 舟山: '19', 张家界: '27', 黄山市: '23', 九江: '24', 武夷山: '26',
|
|
27
29
|
迪拜: '220',
|
|
30
|
+
// Verified against the official list page and its displayed destination/date
|
|
31
|
+
// controls on 2026-10-07; https://hotels.ctrip.com/hotels/list?city=5
|
|
32
|
+
哈尔滨: '5',
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
export interface AdapterEntry {
|
|
@@ -32,6 +37,7 @@ export interface AdapterEntry {
|
|
|
32
37
|
url?: string
|
|
33
38
|
/** 词表外城市逐字保留(调用方提示:web 搜携程酒店 list 页取 city id 后带 cityId 重试) */
|
|
34
39
|
unresolved?: string[]
|
|
40
|
+
error?: string
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
export interface HotelEntryQuery {
|
|
@@ -39,6 +45,7 @@ export interface HotelEntryQuery {
|
|
|
39
45
|
to: string
|
|
40
46
|
/** 显式城市 id(携程/trip.com 酒店 list 页 URL 的 city= 数字;覆盖码表) */
|
|
41
47
|
cityId?: number | string
|
|
48
|
+
country?: string
|
|
42
49
|
/** YYYY-MM-DD */
|
|
43
50
|
checkIn?: string
|
|
44
51
|
/** YYYY-MM-DD(与 checkIn 成对) */
|
|
@@ -46,14 +53,23 @@ export interface HotelEntryQuery {
|
|
|
46
53
|
adults?: number
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
export async function resolveHotelEntryUrl(q: HotelEntryQuery,
|
|
57
|
+
resolve: (query: string, country?: string) => Promise<HotelCityResolution> = resolveCtripHotelCity): Promise<AdapterEntry> {
|
|
58
|
+
if (q.cityId !== undefined) return buildHotelEntryUrl(q)
|
|
59
|
+
const result = await resolve(q.to, q.country)
|
|
60
|
+
if (!result.ok) return { ok: false, unresolved: [q.to], error: hotelResolutionHint(q.to, result) }
|
|
61
|
+
return buildHotelEntryUrl({ ...q, cityId: result.city.id })
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
export function buildHotelEntryUrl(q: HotelEntryQuery): AdapterEntry {
|
|
50
65
|
const unresolved: string[] = []
|
|
51
|
-
const
|
|
66
|
+
const candidate = q.cityId === undefined ? HOTEL_CITY_CODES[q.to.trim()] : String(q.cityId).trim()
|
|
67
|
+
const cityId = candidate && /^[1-9]\d{0,9}$/.test(candidate) ? candidate : undefined
|
|
52
68
|
if (!cityId) unresolved.push(q.to)
|
|
53
69
|
if (unresolved.length > 0) {
|
|
54
70
|
return { ok: false, unresolved }
|
|
55
71
|
}
|
|
56
|
-
const params = new URLSearchParams({ city: cityId })
|
|
72
|
+
const params = new URLSearchParams({ city: cityId! })
|
|
57
73
|
if (q.checkIn) params.set('checkin', q.checkIn)
|
|
58
74
|
if (q.checkOut) params.set('checkout', q.checkOut)
|
|
59
75
|
if (q.adults && q.adults > 0) params.set('adult', String(q.adults))
|
|
@@ -236,3 +252,34 @@ export function parseCtripHotelList(body: string, opts: { maxItems?: number } =
|
|
|
236
252
|
walk(raw, 0, new Set())
|
|
237
253
|
return out
|
|
238
254
|
}
|
|
255
|
+
|
|
256
|
+
/** Only an explicit empty hotel array proves a miss; parser failure proves nothing. */
|
|
257
|
+
export function classifyCtripHotelList(body: string): {
|
|
258
|
+
shape: 'parsed' | 'recognized-empty' | 'malformed' | 'unrecognized'
|
|
259
|
+
hotels: SessionHotelOption[]
|
|
260
|
+
} {
|
|
261
|
+
let raw: unknown
|
|
262
|
+
try { raw = JSON.parse(body) } catch { return { shape: 'malformed', hotels: [] } }
|
|
263
|
+
const envelope = raw as { code?: unknown; success?: unknown; ok?: unknown; ResponseStatus?: { Ack?: unknown } } | null
|
|
264
|
+
if (envelope?.success === false || envelope?.ok === false
|
|
265
|
+
|| (envelope?.code !== undefined && envelope.code !== 0 && envelope.code !== '0')
|
|
266
|
+
|| /^(failure|error)$/i.test(String(envelope?.ResponseStatus?.Ack ?? ''))) {
|
|
267
|
+
return { shape: 'unrecognized', hotels: [] }
|
|
268
|
+
}
|
|
269
|
+
const hotels = parseCtripHotelList(body)
|
|
270
|
+
if (hotels.length > 0) return { shape: 'parsed', hotels }
|
|
271
|
+
let emptyList = false
|
|
272
|
+
let unparsedItems = false
|
|
273
|
+
const inspectHotelLists = (node: unknown, depth: number): void => {
|
|
274
|
+
if (depth > 12 || node === null || typeof node !== 'object') return
|
|
275
|
+
for (const [key, value] of Object.entries(node)) {
|
|
276
|
+
if (['hotelList', 'hotelMatchInfos'].includes(key) && Array.isArray(value)) {
|
|
277
|
+
if (value.length === 0) emptyList = true
|
|
278
|
+
else unparsedItems = true
|
|
279
|
+
}
|
|
280
|
+
inspectHotelLists(value, depth + 1)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
inspectHotelLists(raw, 0)
|
|
284
|
+
return { shape: emptyList && !unparsedItems ? 'recognized-empty' : 'unrecognized', hotels: [] }
|
|
285
|
+
}
|