@danceiny/gotry 0.0.1-rc.18 → 0.0.1-rc.19
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 +42 -16
- package/README.zh-CN.md +37 -15
- package/bin/gotry-bootstrap.js +236 -3
- package/bin/gotry-inner.js +66 -20
- package/bin/gotry-runtime-resolution.d.ts +1 -1
- package/bin/gotry-runtime-resolution.js +7 -8
- package/cordis.gotry-patch.yml +15 -5
- package/data/stations-12306-verify.json +524 -0
- package/dist/capabilities/agent-reach.js +4 -4
- package/dist/capabilities/channel-health.js +103 -0
- package/dist/capabilities/channel-registry.js +213 -0
- package/dist/capabilities/doctor.js +285 -0
- package/dist/capabilities/effect.js +178 -5
- package/dist/capabilities/flyai.js +13 -0
- package/dist/capabilities/session/adapters/ctrip-hotel.js +207 -0
- package/dist/capabilities/session/adapters/rail-12306.js +285 -0
- package/dist/capabilities/session/extension-bridge.js +14 -1
- package/dist/capabilities/session/extension-channel.js +4 -2
- package/dist/capabilities/session/extension-distribution.js +1 -1
- package/dist/capabilities/session-search.js +311 -0
- package/dist/capabilities/visa-policy.js +126 -0
- package/dist/scripts/agent-planning-turn-deadline-e2e.js +304 -0
- package/dist/scripts/agent-planning-turn-deadline-tests.js +281 -0
- package/dist/scripts/benchmark-environment-bridge-e2e.js +5 -10
- package/dist/scripts/benchmark-environment-bridge-tests.js +148 -142
- package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +26 -26
- package/dist/scripts/{booking-copilot-availability-policy-v2-tests.js → booking-copilot-availability-policy-tests.js} +115 -115
- package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +41 -1
- package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +75 -120
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +370 -61
- package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +2 -2
- package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +7 -3
- package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +15 -9
- package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +19 -11
- package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +16 -52
- package/dist/scripts/booking-copilot-runtime-proof-tests.js +3953 -155
- package/dist/scripts/booking-copilot-server-proof-tests.js +58 -23
- package/dist/scripts/booking-copilot-startup-proof-tests.js +10 -119
- package/dist/scripts/booking-surface-contract-proof-tests.js +1154 -326
- package/dist/scripts/bootstrap-tests.js +87 -2
- package/dist/scripts/build-metrics-report.js +339 -0
- package/dist/scripts/channel-probe-tests.js +148 -0
- package/dist/scripts/channel-probe.js +252 -0
- package/dist/scripts/channel-registry-tests.js +262 -0
- package/dist/scripts/doctor-tests.js +121 -0
- package/dist/scripts/effect-tests.js +274 -1
- package/dist/scripts/extension-tests.js +129 -11
- package/dist/scripts/fact-gate-tests.js +91 -2
- package/dist/scripts/flyai-tests.js +17 -1
- package/dist/scripts/hbcli-e2e-tests.js +1 -3
- package/dist/scripts/metrics-report-tests.js +288 -0
- package/dist/scripts/persona-surface-guard-tests.js +19 -0
- package/dist/scripts/session-tests.js +401 -1
- package/dist/scripts/smoke.js +100 -83
- package/dist/scripts/turn-handoff-collect-tests.js +216 -0
- package/dist/scripts/turn-handoff-collect.js +189 -0
- package/dist/scripts/turn-policy-tests.js +56 -0
- package/dist/scripts/typed-contract-canary.js +261 -0
- package/dist/scripts/visa-policy-tests.js +75 -0
- package/dist/scripts/wish-channel-gate-tests.js +111 -0
- package/dist/src/artifact-gate.js +91 -3
- package/dist/src/benchmark-agent-conformance.js +7 -4
- package/dist/src/benchmark-environment-bridge.js +68 -7
- package/dist/src/bookable-facts.js +62 -3
- package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
- package/dist/src/booking-surface/canonical-schema.js +8 -22
- package/dist/src/booking-surface/contracts.js +70 -19
- package/dist/src/booking-surface/dsh-planner.js +310 -177
- package/dist/src/booking-surface/dsh-plugin.js +3 -3
- package/dist/src/booking-surface/index.js +1 -5
- package/dist/src/booking-surface/profile.js +1 -1
- package/dist/src/booking-surface/runtime.js +1682 -247
- package/dist/src/booking-surface/server.js +371 -185
- package/dist/src/booking-surface/startup.js +17 -27
- package/dist/src/booking-surface/validation.js +270 -760
- package/dist/src/index.js +735 -149
- package/dist/src/turn-deadline.js +292 -0
- package/dist/src/turn-policy.js +146 -0
- package/dist/src/wish-pool.js +5 -0
- package/extension/background.js +23 -4
- package/extension/content-main.js +47 -9
- package/extension/manifest.json +27 -9
- package/package.json +6 -26
- package/schemas/booking.surface.schema.json +2593 -0
- package/ts/capabilities/agent-reach.ts +4 -4
- package/ts/capabilities/flyai.ts +21 -3
- package/ts/capabilities/session/action-cache.ts +1 -1
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
- package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
- package/ts/capabilities/session/extension-bridge.ts +32 -2
- package/ts/capabilities/session/extension-channel.ts +9 -8
- package/ts/capabilities/session/extension-distribution.ts +3 -1
- package/ts/capabilities/session/health-watch.ts +1 -1
- package/ts/capabilities/session/read-guard.ts +1 -1
- package/ts/capabilities/session/wizard.ts +1 -1
- package/ts/capabilities/session-search.ts +323 -1
- package/ts/package.json +1 -0
- package/ts/scripts/turn-handoff-collect.ts +178 -0
- package/ts/src/artifact-gate.ts +82 -2
- package/ts/src/benchmark-agent-conformance.ts +14 -6
- package/ts/src/benchmark-environment-bridge.ts +29 -10
- package/ts/src/bookable-facts.ts +109 -5
- package/ts/src/booking-saga.ts +1 -1
- package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
- package/ts/src/booking-surface/canonical-schema.js +8 -22
- package/ts/src/booking-surface/contracts.ts +278 -239
- package/ts/src/booking-surface/dsh-planner.ts +333 -194
- package/ts/src/booking-surface/dsh-plugin.js +3 -3
- package/ts/src/booking-surface/index.ts +1 -5
- package/ts/src/booking-surface/profile.ts +11 -11
- package/ts/src/booking-surface/runtime.ts +1351 -351
- package/ts/src/booking-surface/server.ts +282 -175
- package/ts/src/booking-surface/startup.ts +36 -48
- package/ts/src/booking-surface/validation.ts +194 -442
- package/ts/src/contracts.ts +1 -1
- package/ts/src/index.ts +489 -171
- package/ts/src/loop.ts +1 -1
- package/ts/src/state-ledger.ts +1 -1
- package/ts/src/turn-deadline.ts +361 -0
- package/ts/src/turn-policy.ts +154 -0
- package/ts/src/wish-pool.ts +17 -4
- package/dist/scripts/agent-planning-budget-e2e.js +0 -227
- package/dist/scripts/agent-planning-budget-tests.js +0 -173
- package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
- package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
- package/dist/src/booking-surface/contracts-v2.js +0 -89
- package/dist/src/booking-surface/runtime-v2.js +0 -1771
- package/dist/src/booking-surface/server-v2.js +0 -334
- package/dist/src/booking-surface/validation-v2.js +0 -319
- package/dist/src/tool-budget.js +0 -136
- package/schemas/booking.surface.v1.schema.json +0 -927
- package/schemas/booking.surface.v2.schema.json +0 -61
- package/ts/src/booking-surface/contracts-v2.ts +0 -118
- package/ts/src/booking-surface/runtime-v2.ts +0 -1466
- package/ts/src/booking-surface/server-v2.ts +0 -247
- package/ts/src/booking-surface/validation-v2.ts +0 -205
- package/ts/src/tool-budget.ts +0 -165
|
@@ -77,7 +77,7 @@ export async function reach(q: { channel: string; method: string; args?: string[
|
|
|
77
77
|
}
|
|
78
78
|
const py = venvPython()
|
|
79
79
|
if (!existsSync(py)) {
|
|
80
|
-
return { ...base, ok: false, verdict: 'not-installed', evidence: evidence('not-installed'), latencyMs: 0, setup: 'gotry .venv 缺 python
|
|
80
|
+
return { ...base, ok: false, verdict: 'not-installed', evidence: evidence('not-installed'), latencyMs: 0, setup: 'gotry .venv 缺 python——可选依赖未装配。补装:终端跑 npx gotry doctor --fix,或让用户看体检报告 npx gotry doctor' }
|
|
81
81
|
}
|
|
82
82
|
const args = Array.isArray(q.args) ? q.args : (q.args ? q.args.split(/\s+/).filter(Boolean) : [])
|
|
83
83
|
const r = await run(py, [bridgeScript(), q.channel, q.method, ...args], q.timeoutMs ?? 30_000)
|
|
@@ -89,14 +89,14 @@ export async function reach(q: { channel: string; method: string; args?: string[
|
|
|
89
89
|
} catch { /* 走下面的兜底 */ }
|
|
90
90
|
|
|
91
91
|
if (r.err?.includes('ENOENT')) {
|
|
92
|
-
return { ...base, ok: false, verdict: 'not-installed', evidence: evidence('not-installed'), latencyMs, setup: '.venv/bin/python
|
|
92
|
+
return { ...base, ok: false, verdict: 'not-installed', evidence: evidence('not-installed'), latencyMs, setup: '.venv/bin/python 不可执行——补装:终端跑 npx gotry doctor --fix' }
|
|
93
93
|
}
|
|
94
94
|
if (parsed.ok === true) {
|
|
95
95
|
return { ...base, ok: true, verdict: 'found', evidence: evidence('found'), latencyMs, data: parsed.data }
|
|
96
96
|
}
|
|
97
97
|
// 上游 agent_reach 包未装(bridge 自己的 exit 3 输出)
|
|
98
98
|
if (parsed.error?.includes('agent_reach 未安装')) {
|
|
99
|
-
return { ...base, ok: false, verdict: 'not-installed', evidence: evidence('not-installed'), latencyMs, setup: '.venv 缺 agent-reach
|
|
99
|
+
return { ...base, ok: false, verdict: 'not-installed', evidence: evidence('not-installed'), latencyMs, setup: '.venv 缺 agent-reach 包——补装:终端跑 npx gotry doctor --fix' }
|
|
100
100
|
}
|
|
101
101
|
// 未知渠道/方法:自描述,附上游清单(不拦 LLM,让它看清单自纠)
|
|
102
102
|
if (r.code === 2) {
|
|
@@ -144,7 +144,7 @@ export async function reachStatus(timeoutMs = 90_000): Promise<ReachStatus> {
|
|
|
144
144
|
const started = Date.now()
|
|
145
145
|
const bin = resolve(repoRoot(), '.venv/bin/agent-reach')
|
|
146
146
|
if (!existsSync(bin)) {
|
|
147
|
-
return { ok: false, via: 'not-installed', evidence: `[agent-reach:doctor@not-installed@${ts()}]`, latencyMs: 0, output: '
|
|
147
|
+
return { ok: false, via: 'not-installed', evidence: `[agent-reach:doctor@not-installed@${ts()}]`, latencyMs: 0, output: 'Agent Reach 未装配(.venv 缺失)——可选依赖,补装:终端跑 npx gotry doctor --fix;或让助手调 gotry_doctor 看整体体检报告' }
|
|
148
148
|
}
|
|
149
149
|
const r = await run(bin, ['doctor'], timeoutMs)
|
|
150
150
|
const output = (r.stdout || r.stderr).trim()
|
package/ts/capabilities/flyai.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* FlyAI 官方通道能力层(飞猪开放平台,data-sources.md §8 / RFC user-session-data-rfc.md P0):
|
|
2
|
+
* FlyAI 官方通道能力层(飞猪开放平台,data-sources.md §8 / RFC docs/rfc/user-session-data-rfc.md P0):
|
|
3
3
|
*
|
|
4
4
|
* 链路(同构 anything.ts 的 CLI spawn 模式):
|
|
5
5
|
* gotry capabilities/flyai.ts
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
* - 只读:8 工具全只读,交易经 jumpUrl/detailUrl 由人完成(与 WriteGate 哲学同构);
|
|
12
12
|
* - 永不抛错:网络/超时/解析失败一律降级返回 verdict='error';
|
|
13
13
|
* - 证据链:成功 [实时API:flyai@ts];失败 [实时API:flyai@error@ts];
|
|
14
|
-
* -
|
|
14
|
+
* - 匿名试用额度可用但共享易达限(2026-09-02 迪拜 session:429 Trial limit reached
|
|
15
|
+
* 被当通用 error,LLM 跨轮盲重试 4 次)——达限归类 verdict='needs-setup' +
|
|
16
|
+
* setup 指引(申请正式 key 配 FLYAI_API_KEY),工具层据此阻断盲目重试。
|
|
17
|
+
* FLYAI_API_KEY 已配时 env 透传,不再走试用额度。
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
import { spawn } from 'node:child_process'
|
|
@@ -86,11 +89,14 @@ export interface FlyaiResult {
|
|
|
86
89
|
via: 'flyai' | 'flyai-error'
|
|
87
90
|
evidence: string
|
|
88
91
|
latencyMs: number
|
|
89
|
-
|
|
92
|
+
/** needs-setup = 配置问题(试用额度达限/需正式 key),不是检索失败——工具层应阻断盲目重试 */
|
|
93
|
+
verdict: 'hit' | 'miss' | 'error' | 'needs-setup'
|
|
90
94
|
kind: FlyaiKind
|
|
91
95
|
options?: FlyaiOption[]
|
|
92
96
|
hotels?: FlyaiHotelOption[]
|
|
93
97
|
error?: string
|
|
98
|
+
/** 上游指引原话/补配指引(仅 needs-setup 时) */
|
|
99
|
+
setup?: string
|
|
94
100
|
}
|
|
95
101
|
|
|
96
102
|
interface RawItem {
|
|
@@ -245,6 +251,18 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
|
|
|
245
251
|
})
|
|
246
252
|
const latencyMs = Date.now() - started
|
|
247
253
|
if (r.error || r.code !== 0) {
|
|
254
|
+
// 试用额度达限(2026-09-02 迪拜 session 实况:exit 1 + MCP HTTP 429 "Trial limit
|
|
255
|
+
// reached")是配置问题不是瞬时故障——归 needs-setup 带补配指引,让工具层阻断
|
|
256
|
+
// LLM 拿同一把 429 跨轮盲重试;正式 key 经 FLYAI_API_KEY 配入后此路径不再触发。
|
|
257
|
+
const upstream = `${r.stderr}\n${r.stdout}`
|
|
258
|
+
if (/Trial limit reached|HTTP\s*429|\b429\b/.test(upstream)) {
|
|
259
|
+
return {
|
|
260
|
+
...base, latencyMs, ok: false, via: 'flyai-error', verdict: 'needs-setup',
|
|
261
|
+
evidence: `[实时API:flyai@error@${ts}] trial quota exhausted`,
|
|
262
|
+
error: `FlyAI 匿名试用额度已用尽(上游 429):${upstream.replace(/\s+/g, ' ').slice(0, 160)}`,
|
|
263
|
+
setup: 'FlyAI 试用额度达限——到 flyai.open.fliggy.com 控制台申请正式 API Key,配入环境变量 FLYAI_API_KEY 即恢复;本会话请勿重试 gotry_flyai_search,机/火/酒改走 gotry_session_search(账号会话)或 web 检索推进。',
|
|
264
|
+
}
|
|
265
|
+
}
|
|
248
266
|
return { ...base, latencyMs, ok: false, via: 'flyai-error', verdict: 'error', evidence: `[实时API:flyai@error@${ts}] ${r.error ?? `exit ${r.code}`}`, error: r.error ?? r.stderr.replace(/\s+/g, ' ').slice(0, 200) }
|
|
249
267
|
}
|
|
250
268
|
let items: RawItem[]
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* action-cache 本地自愈层(RFC user-session-data-rfc.md §3.2 / §2.3;Stagehand 云端缓存的本地化):
|
|
2
|
+
* action-cache 本地自愈层(RFC docs/rfc/user-session-data-rfc.md §3.2 / §2.3;Stagehand 云端缓存的本地化):
|
|
3
3
|
*
|
|
4
4
|
* 目的:会话检索的查询模式高度重复(同站点同类型搜索),首跑用 LLM/规则定位,后续走
|
|
5
5
|
* 缓存的确定性载荷零成本重放;站点改版 → DOM 指纹失配 → miss → 重新定位并回写。
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 携程酒店站点适配器(2026-09-03 迪拜 session 复盘:账号会话面此前只有机票,
|
|
3
|
+
* 用户要「携程找酒店」时被迫撞 flyai 429 与打码 web 读页;会话面酒店从
|
|
4
|
+
* 「备份」实装为通道之一,rfc §2.2 同款只读纪律)。
|
|
5
|
+
*
|
|
6
|
+
* - entry:https://hotels.ctrip.com/hotels/list?city=<id>&checkin=&checkout=&adult=N
|
|
7
|
+
* - networkHints:酒店检索 XHR 的 URL 形态(接口名公开资料多版并存,**首个真会话
|
|
8
|
+
* 后校准**——D-13 同款边界);URL hint 未命中时由 content-main 的**形状嗅探**
|
|
9
|
+
* 兜底(响应 JSON 含酒店清单签名即转发),对接口改名免疫
|
|
10
|
+
* - 解析:走形(walk)JSON 找「名字 + 价格」签名的条目数组,归一化最小字段;
|
|
11
|
+
* 页面/接口 JSON 是不可信输入(RFC §3.5)——本层只归形状,语义判定在工具层
|
|
12
|
+
*
|
|
13
|
+
* 只读:适配器不含任何提交/预订语义;URL 直开即零 DOM 交互;ReadGuard 对酒店
|
|
14
|
+
* 页面照常生效(下单/支付面 URL 仍被物理拦截)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** 城市码表(**实测校准 2026-09-03**:每条 id 均经 hotels.ctrip.com list 页
|
|
18
|
+
* <title> 验证——探测法曾纠出 104=平遥县(传猜成都)等 5 处错猜,表内只收
|
|
19
|
+
* 验证过的 id;词表外 unresolved 逐字保留,调用方可显式传 cityId 覆盖) */
|
|
20
|
+
const HOTEL_CITY_CODES: Record<string, string> = {
|
|
21
|
+
上海: '2', 北京: '1', 广州: '32', 深圳: '30', 成都: '28',
|
|
22
|
+
杭州: '17', 南京: '12', 苏州: '14', 无锡: '13', 扬州: '15', 镇江: '16',
|
|
23
|
+
青岛: '7', 厦门: '25', 珠海: '31', 三亚: '43', 海口: '42',
|
|
24
|
+
桂林: '33', 昆明: '34', 大理: '36', 丽江: '37', 西双版纳: '35',
|
|
25
|
+
贵阳: '38', 拉萨: '41', 乌鲁木齐: '39', 太原: '105', 长春: '158', 南通: '82',
|
|
26
|
+
敦煌: '11', 舟山: '19', 张家界: '27', 黄山市: '23', 九江: '24', 武夷山: '26',
|
|
27
|
+
迪拜: '220',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AdapterEntry {
|
|
31
|
+
ok: boolean
|
|
32
|
+
url?: string
|
|
33
|
+
/** 词表外城市逐字保留(调用方提示:web 搜携程酒店 list 页取 city id 后带 cityId 重试) */
|
|
34
|
+
unresolved?: string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface HotelEntryQuery {
|
|
38
|
+
/** 目的地(中文);词表外须配 cityId */
|
|
39
|
+
to: string
|
|
40
|
+
/** 显式城市 id(携程/trip.com 酒店 list 页 URL 的 city= 数字;覆盖码表) */
|
|
41
|
+
cityId?: number | string
|
|
42
|
+
/** YYYY-MM-DD */
|
|
43
|
+
checkIn?: string
|
|
44
|
+
/** YYYY-MM-DD(与 checkIn 成对) */
|
|
45
|
+
checkOut?: string
|
|
46
|
+
adults?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildHotelEntryUrl(q: HotelEntryQuery): AdapterEntry {
|
|
50
|
+
const unresolved: string[] = []
|
|
51
|
+
const cityId = String(q.cityId ?? '').trim() || HOTEL_CITY_CODES[q.to.trim()]
|
|
52
|
+
if (!cityId) unresolved.push(q.to)
|
|
53
|
+
if (unresolved.length > 0) {
|
|
54
|
+
return { ok: false, unresolved }
|
|
55
|
+
}
|
|
56
|
+
const params = new URLSearchParams({ city: cityId })
|
|
57
|
+
if (q.checkIn) params.set('checkin', q.checkIn)
|
|
58
|
+
if (q.checkOut) params.set('checkout', q.checkOut)
|
|
59
|
+
if (q.adults && q.adults > 0) params.set('adult', String(q.adults))
|
|
60
|
+
return { ok: true, url: `https://hotels.ctrip.com/hotels/list?${params.toString()}` }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** networkHints:酒店检索 XHR 的 URL 形态(restapi/soa2/34951/fetchHotelList
|
|
64
|
+
* 为 2026-09-03 自 list 页一方确认的现行接口;其余为多版并存口径;命中即读) */
|
|
65
|
+
export const HOTEL_NETWORK_HINTS = [
|
|
66
|
+
/hotels\.ctrip\.com\/(hotels\/api|domestic\/pc\/api)/i,
|
|
67
|
+
/GetHotelListBySOA|GetHotelListByCity|HotelSearch|hotelsearch/i,
|
|
68
|
+
/restapi\/soa2\/\d+\/fetchHotelList/i,
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
/** 形状嗅探签名:URL hint 未命中时的兜底(对接口改名免疫);大小上限由调用方先把关 */
|
|
72
|
+
export function looksLikeHotelListBody(body: string): boolean {
|
|
73
|
+
if (!body || body.length > 2_000_000) return false
|
|
74
|
+
return /"hotelList"|"hotelMatchInfos"|"hotelName"/.test(body)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 酒店 page 域(content_scripts 注入面与 background 白名单的对账源) */
|
|
78
|
+
export const HOTEL_SITE_HOST = 'hotels.ctrip.com'
|
|
79
|
+
|
|
80
|
+
export interface SessionHotelOption {
|
|
81
|
+
/** 酒店名 */
|
|
82
|
+
name: string
|
|
83
|
+
/** 数字价(数值不可得时为 0;真实价以 jumpUrl 落地页为准) */
|
|
84
|
+
price: number
|
|
85
|
+
/** 价格原串(打码/区间等不可数值化的形态原样保留) */
|
|
86
|
+
priceRaw?: string
|
|
87
|
+
star?: number
|
|
88
|
+
/** 点评分(上游形态有数字有字符串,原样归一为字符串缺省) */
|
|
89
|
+
score?: string
|
|
90
|
+
address?: string
|
|
91
|
+
hotelId?: string
|
|
92
|
+
/** 酒店详情页跳转(由人完成预订;gotry 不碰) */
|
|
93
|
+
jumpUrl?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface WalkCandidate {
|
|
97
|
+
name: string
|
|
98
|
+
price?: number
|
|
99
|
+
priceRaw?: string
|
|
100
|
+
star?: number
|
|
101
|
+
score?: string
|
|
102
|
+
address?: string
|
|
103
|
+
hotelId?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 条目签名:像不像一家酒店(名字类字段必有 + 价格类字段至少其一) */
|
|
107
|
+
function hotelSignature(o: Record<string, unknown>): WalkCandidate | null {
|
|
108
|
+
const name = (o.hotelName ?? o.name ?? o.hotelNameEn) as unknown
|
|
109
|
+
if (typeof name !== 'string' || !name.trim()) return null
|
|
110
|
+
let price: number | undefined
|
|
111
|
+
let priceRaw: string | undefined
|
|
112
|
+
// 顶层扁平价(数字/字符串直给;字符串价是打码/区间形态,原样保留不数值化)
|
|
113
|
+
if (typeof o.price === 'number' && o.price > 0) price = o.price
|
|
114
|
+
else if (typeof o.price === 'string' && o.price.trim()) priceRaw = o.price.trim()
|
|
115
|
+
const probe = (v: unknown, seen = new Set<unknown>()): void => {
|
|
116
|
+
if (v == null || typeof v !== 'object') return
|
|
117
|
+
if (seen.has(v)) return
|
|
118
|
+
seen.add(v)
|
|
119
|
+
for (const [k, child] of Object.entries(v as Record<string, unknown>)) {
|
|
120
|
+
if (/^(avgPrice|totalPrice|price|amount|total|startPrice)$/i.test(k) && typeof child === 'number' && child > 0 && price === undefined) price = child
|
|
121
|
+
else if (/^(price|priceDisplay|amountText)$/i.test(k) && typeof child === 'string' && child.trim() && priceRaw === undefined) priceRaw = child.trim()
|
|
122
|
+
else probe(child, seen)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
probe(o.priceInfo)
|
|
126
|
+
const flatPrice = o.totalPrice ?? o.avgPrice ?? o.minPrice
|
|
127
|
+
if (typeof flatPrice === 'number' && flatPrice > 0 && price === undefined) price = flatPrice
|
|
128
|
+
const flatRaw = o.priceText ?? o.priceDisplay
|
|
129
|
+
if (typeof flatRaw === 'string' && flatRaw.trim() && priceRaw === undefined) priceRaw = flatRaw.trim()
|
|
130
|
+
if (price === undefined && priceRaw === undefined) return null
|
|
131
|
+
const starRaw = o.star ?? o.level ?? o.hotelStar
|
|
132
|
+
const star = typeof starRaw === 'number' && starRaw > 0 ? starRaw : Number.isFinite(Number(starRaw)) && Number(starRaw) > 0 ? Number(starRaw) : undefined
|
|
133
|
+
const scoreRaw = o.score ?? o.commentScore ?? o.reviewScore
|
|
134
|
+
const score = scoreRaw == null ? undefined : String(scoreRaw)
|
|
135
|
+
const pos = o.position as Record<string, unknown> | undefined
|
|
136
|
+
const address = (typeof pos?.address === 'string' ? pos.address : undefined) ?? (typeof o.address === 'string' ? o.address : undefined)
|
|
137
|
+
const hotelId = (typeof o.hotelId === 'number' || typeof o.hotelId === 'string' ? String(o.hotelId) : undefined)
|
|
138
|
+
?? (typeof o.hotelIdStr === 'string' ? o.hotelIdStr : undefined)
|
|
139
|
+
return { name: name.trim(), price, priceRaw, star, score, address, hotelId }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 结构化候选:携程现行 list 形态(**一方校准 2026-09-03**,取自 list 页官方
|
|
143
|
+
* 前端代码反查 + SSR initListData 实测):
|
|
144
|
+
* - 条目 = hotelList[].hotelInfo{summary{hotelId,nameInfo{name,names[]},hotelStar{star},positionInfo},commentInfo{commentScore}}
|
|
145
|
+
* - 价格 = roomInfo[].priceInfo.price(多房型取最低;页面 room_price 即读此);
|
|
146
|
+
* priceToken 在而无 price = 加密价(fetchHotelListPrices 页面内存解密),
|
|
147
|
+
* 嗅探面拿不到明文——如实不报价,summary 指引到 jumpUrl 落地页
|
|
148
|
+
* - displayPrice(如 "¥468")作 priceRaw 兜底;通用 price/amount 子树探测殿后
|
|
149
|
+
* (直连复现 fetchHotelList 被 hotel-spider-defence 反爬 404——「让站点自己
|
|
150
|
+
* 发请求」正是会话桥的设计原因;官方前端为公开静态资产,路径自其中反查) */
|
|
151
|
+
function ctripStructuredCandidate(item: Record<string, unknown>): WalkCandidate | null {
|
|
152
|
+
const info = item.hotelInfo as Record<string, unknown> | undefined
|
|
153
|
+
const summary = (info?.summary ?? item) as Record<string, unknown>
|
|
154
|
+
const nameInfo = summary.nameInfo as Record<string, unknown> | undefined
|
|
155
|
+
const names = nameInfo?.names
|
|
156
|
+
const nameRaw = nameInfo?.name ?? (Array.isArray(names) && names.length > 0 ? names[0] : undefined) ?? summary.hotelName ?? summary.name
|
|
157
|
+
if (typeof nameRaw !== 'string' || !nameRaw.trim()) return null
|
|
158
|
+
// 价格:roomInfo[].priceInfo 官方路径优先(数值 price 取 min;displayPrice/字符串价作 priceRaw)
|
|
159
|
+
let price: number | undefined
|
|
160
|
+
let priceRaw: string | undefined
|
|
161
|
+
let encrypted = false
|
|
162
|
+
const rooms = info?.roomInfo ?? item.roomInfo
|
|
163
|
+
if (Array.isArray(rooms)) {
|
|
164
|
+
for (const room of rooms) {
|
|
165
|
+
if (room == null || typeof room !== 'object') continue
|
|
166
|
+
const pi = (room as Record<string, unknown>).priceInfo as Record<string, unknown> | undefined
|
|
167
|
+
if (!pi) continue
|
|
168
|
+
if (typeof pi.price === 'number' && pi.price > 0) price = price === undefined ? pi.price : Math.min(price, pi.price)
|
|
169
|
+
if (typeof pi.price === 'string' && pi.price.trim() && !priceRaw) priceRaw = pi.price.trim()
|
|
170
|
+
if (typeof pi.displayPrice === 'string' && pi.displayPrice.trim() && !priceRaw) priceRaw = pi.displayPrice.trim()
|
|
171
|
+
if (pi.priceToken != null && pi.price === undefined) encrypted = true
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// 兜底:条目子树 price/amount 探测(SSR 形态 productInfo.priceInfo.price 等)
|
|
175
|
+
if (price === undefined && !priceRaw) {
|
|
176
|
+
const priceProbe = (v: unknown, depth: number, seen: Set<unknown>): void => {
|
|
177
|
+
if (price !== undefined || depth > 6 || v == null || typeof v !== 'object' || seen.has(v)) return
|
|
178
|
+
seen.add(v)
|
|
179
|
+
for (const [k, child] of Object.entries(v as Record<string, unknown>)) {
|
|
180
|
+
if (/^(price|avgPrice|totalPrice|minPrice|amount)$/i.test(k) && typeof child === 'number' && child > 0) { price = child; return }
|
|
181
|
+
if (/^(price|displayPrice|amountText)$/i.test(k) && typeof child === 'string' && child.trim()) { priceRaw = priceRaw ?? child.trim(); continue }
|
|
182
|
+
priceProbe(child, depth + 1, seen)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
priceProbe(item, 0, new Set())
|
|
186
|
+
}
|
|
187
|
+
// 加密价(priceToken 在而无明文 price):条目保留,价如实为 0——不伪造任何价
|
|
188
|
+
if (price === undefined && !priceRaw && !encrypted) return null
|
|
189
|
+
const starOf = summary.hotelStar as Record<string, unknown> | undefined
|
|
190
|
+
const starRaw = starOf?.star ?? summary.star
|
|
191
|
+
const star = typeof starRaw === 'number' && starRaw > 0 ? starRaw : undefined
|
|
192
|
+
const scoreRaw = ((info?.commentInfo ?? summary.commentInfo) as Record<string, unknown> | undefined)?.commentScore
|
|
193
|
+
const pos = summary.positionInfo as Record<string, unknown> | undefined
|
|
194
|
+
const address = (typeof pos?.address === 'string' ? pos.address : undefined) ?? (typeof summary.address === 'string' ? summary.address : undefined)
|
|
195
|
+
const hotelId = summary.hotelId != null ? String(summary.hotelId) : undefined
|
|
196
|
+
return { name: nameRaw.trim(), price, priceRaw, star, score: scoreRaw == null ? undefined : String(scoreRaw), address, hotelId }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 走形:深找第一个酒店签名数组(纯函数,fixture 测试锚点);malformed 一律返空,不抛错 */
|
|
200
|
+
export function parseCtripHotelList(body: string, opts: { maxItems?: number } = {}): SessionHotelOption[] {
|
|
201
|
+
let raw: unknown
|
|
202
|
+
try {
|
|
203
|
+
raw = JSON.parse(body)
|
|
204
|
+
} catch {
|
|
205
|
+
return []
|
|
206
|
+
}
|
|
207
|
+
const out: SessionHotelOption[] = []
|
|
208
|
+
const maxItems = opts.maxItems ?? 20
|
|
209
|
+
const walk = (node: unknown, depth: number, seen: Set<unknown>): void => {
|
|
210
|
+
if (out.length >= maxItems || depth > 12 || node == null || typeof node !== 'object' || seen.has(node)) return
|
|
211
|
+
if (Array.isArray(node)) {
|
|
212
|
+
for (const item of node) {
|
|
213
|
+
if (out.length >= maxItems) return
|
|
214
|
+
if (item == null || typeof item !== 'object' || Array.isArray(item)) continue
|
|
215
|
+
const cand = hotelSignature(item as Record<string, unknown>) ?? ctripStructuredCandidate(item as Record<string, unknown>)
|
|
216
|
+
if (!cand) continue
|
|
217
|
+
out.push({
|
|
218
|
+
name: cand.name,
|
|
219
|
+
price: cand.price ?? 0,
|
|
220
|
+
priceRaw: cand.priceRaw,
|
|
221
|
+
star: cand.star,
|
|
222
|
+
score: cand.score,
|
|
223
|
+
address: cand.address,
|
|
224
|
+
hotelId: cand.hotelId,
|
|
225
|
+
jumpUrl: cand.hotelId ? `https://hotels.ctrip.com/hotel/${cand.hotelId}` : undefined,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
seen.add(node)
|
|
231
|
+
for (const child of Object.values(node as Record<string, unknown>)) {
|
|
232
|
+
walk(child, depth + 1, seen)
|
|
233
|
+
if (out.length >= maxItems) return
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
walk(raw, 0, new Set())
|
|
237
|
+
return out
|
|
238
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 12306 火车站点适配器(2026-09-03,继 ctrip-hotel 后会话面第三通道):
|
|
3
|
+
* - entry:https://kyfw.12306.cn/otn/leftTicket/init?linktypeid=dc&fs=<城市名,电报码>&ts=<...>&date=YYYY-MM-DD&flag=N,N,Y
|
|
4
|
+
* (电报码表官方站表全量校准 2026-09-03,129 城;曾纠出南宁 NIZ→NNZ 错码)
|
|
5
|
+
* - networkHints:kyfw leftTicket/query(负载均衡变体 queryG/queryZ/queryA 等全部命中)
|
|
6
|
+
* - 解析:leftTicket/query 返回 data.result[] 管道行(12306 经典 | 拼接格式)。
|
|
7
|
+
* **第一方校准(2026-09-03)**:字段索引与站名映射(data.map[电报码])逐一
|
|
8
|
+
* 取自官方前端 queryLeftTicket_end_js.js 的 cN(result,map) 转换函数——旧版
|
|
9
|
+
* 公开常识索引与官方现行映射不符(站名/座位桶全错),真会话前即纠;行级
|
|
10
|
+
* 签名校验(车次码/时刻形态)继续兜住未来漂移:失配整行跳过,fail-visible
|
|
11
|
+
*
|
|
12
|
+
* 与机/酒通道的两点刻意差异:
|
|
13
|
+
* - 无登录闸:12306 余票查询是公开面(登录只关系下单),无账号数据过手;
|
|
14
|
+
* 扩展照样只读被动嗅探(零写行为),证据链标注「公开查询面」
|
|
15
|
+
* - 列表接口不含票价:如实只呈现 车次/时刻/历时/余票,票价以 12306 落地页为准
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** 城市电报码表(**官方口径校准 2026-09-03**:全部条目逐一核对自
|
|
19
|
+
* kyfw.12306.cn/otn/resources/js/framework/station_name.js 官方全量站表
|
|
20
|
+
* ——曾借此抓出南宁 NIZ→NNZ 错码;代表站规则=城市同名站,fs/ts 用城市码
|
|
21
|
+
* 服务端扩到市内全部站。词表外 unresolved 逐字保留,调用方可显式传电报码
|
|
22
|
+
* 覆盖——kyfw 查询页 URL 里的 fs=城市,XXX 三位码;核对快照见
|
|
23
|
+
* data/stations-12306-verify.json) */
|
|
24
|
+
export const STATION_TELECODES: Record<string, string> = {
|
|
25
|
+
"三亚": 'SEQ',
|
|
26
|
+
"上海": 'SHH',
|
|
27
|
+
"上饶": 'SRG',
|
|
28
|
+
"东莞": 'RTQ',
|
|
29
|
+
"中卫": 'ZWJ',
|
|
30
|
+
"中山": 'ZSQ',
|
|
31
|
+
"丽江": 'LHM',
|
|
32
|
+
"乌鲁木齐": 'WAR',
|
|
33
|
+
"乐山": 'IVW',
|
|
34
|
+
"九江": 'JJG',
|
|
35
|
+
"伊宁": 'YMR',
|
|
36
|
+
"佛山": 'FSQ',
|
|
37
|
+
"保山": 'BAM',
|
|
38
|
+
"六盘水": 'UMW',
|
|
39
|
+
"兰州": 'LZJ',
|
|
40
|
+
"包头": 'BTC',
|
|
41
|
+
"北京": 'BJP',
|
|
42
|
+
"北海": 'BHZ',
|
|
43
|
+
"十堰": 'SNN',
|
|
44
|
+
"南京": 'NJH',
|
|
45
|
+
"南充": 'NCW',
|
|
46
|
+
"南宁": 'NNZ',
|
|
47
|
+
"南昌": 'NCG',
|
|
48
|
+
"南通": 'NUH',
|
|
49
|
+
"南阳": 'NFF',
|
|
50
|
+
"厦门": 'XMS',
|
|
51
|
+
"台州": 'TEU',
|
|
52
|
+
"合肥": 'HFH',
|
|
53
|
+
"吉林": 'JLL',
|
|
54
|
+
"吐鲁番": 'TFR',
|
|
55
|
+
"呼和浩特": 'HHC',
|
|
56
|
+
"咸阳": 'XYY',
|
|
57
|
+
"哈密": 'HMR',
|
|
58
|
+
"哈尔滨": 'HBB',
|
|
59
|
+
"喀什": 'KSR',
|
|
60
|
+
"嘉兴": 'JXH',
|
|
61
|
+
"嘉峪关": 'JGJ',
|
|
62
|
+
"大同": 'DTV',
|
|
63
|
+
"大庆": 'DZX',
|
|
64
|
+
"大理": 'DKM',
|
|
65
|
+
"大连": 'DLT',
|
|
66
|
+
"天水": 'TSJ',
|
|
67
|
+
"天津": 'TJP',
|
|
68
|
+
"太原": 'TYV',
|
|
69
|
+
"宁波": 'NGH',
|
|
70
|
+
"安顺": 'ASW',
|
|
71
|
+
"宜宾": 'YKE',
|
|
72
|
+
"宜昌": 'YCN',
|
|
73
|
+
"宝鸡": 'BJY',
|
|
74
|
+
"岳阳": 'YYQ',
|
|
75
|
+
"常州": 'CZH',
|
|
76
|
+
"常德": 'VGQ',
|
|
77
|
+
"广州": 'GZQ',
|
|
78
|
+
"库尔勒": 'KLR',
|
|
79
|
+
"延安": 'YWY',
|
|
80
|
+
"开封": 'KFF',
|
|
81
|
+
"张掖": 'ZYJ',
|
|
82
|
+
"徐州": 'XCH',
|
|
83
|
+
"德阳": 'DYW',
|
|
84
|
+
"惠州": 'HCQ',
|
|
85
|
+
"成都": 'CDW',
|
|
86
|
+
"扬州": 'YLH',
|
|
87
|
+
"承德": 'CDP',
|
|
88
|
+
"抚顺": 'FET',
|
|
89
|
+
"拉萨": 'LSO',
|
|
90
|
+
"揭阳": 'JYA',
|
|
91
|
+
"新乡": 'XXF',
|
|
92
|
+
"无锡": 'WGH',
|
|
93
|
+
"日喀则": 'RKO',
|
|
94
|
+
"昆明": 'KMM',
|
|
95
|
+
"普洱": 'PEM',
|
|
96
|
+
"景德镇": 'JCG',
|
|
97
|
+
"曲靖": 'QJM',
|
|
98
|
+
"杭州": 'HZH',
|
|
99
|
+
"林芝": 'LZO',
|
|
100
|
+
"柳州": 'LZZ',
|
|
101
|
+
"株洲": 'ZZQ',
|
|
102
|
+
"格尔木": 'GRO',
|
|
103
|
+
"桂林": 'GLZ',
|
|
104
|
+
"梧州": 'WZZ',
|
|
105
|
+
"武汉": 'WHN',
|
|
106
|
+
"汉中": 'HOY',
|
|
107
|
+
"汕头": 'OTQ',
|
|
108
|
+
"沈阳": 'SYT',
|
|
109
|
+
"泉州": 'QYS',
|
|
110
|
+
"泸州": 'LUE',
|
|
111
|
+
"洛阳": 'LYF',
|
|
112
|
+
"济南": 'JNK',
|
|
113
|
+
"深圳": 'SZQ',
|
|
114
|
+
"温州": 'RZH',
|
|
115
|
+
"湛江": 'ZJZ',
|
|
116
|
+
"漳州": 'ZUS',
|
|
117
|
+
"潮州": 'CKQ',
|
|
118
|
+
"牡丹江": 'MDB',
|
|
119
|
+
"珠海": 'ZHQ',
|
|
120
|
+
"盐城": 'AFH',
|
|
121
|
+
"石家庄": 'SJP',
|
|
122
|
+
"福州": 'FZS',
|
|
123
|
+
"秦皇岛": 'QTP',
|
|
124
|
+
"绍兴": 'SOH',
|
|
125
|
+
"绵阳": 'MYW',
|
|
126
|
+
"芜湖": 'WHH',
|
|
127
|
+
"苏州": 'SZH',
|
|
128
|
+
"茂名": 'MDQ',
|
|
129
|
+
"莆田": 'PTS',
|
|
130
|
+
"衡阳": 'HYQ',
|
|
131
|
+
"襄阳": 'XFN',
|
|
132
|
+
"西宁": 'XNO',
|
|
133
|
+
"西安": 'XAY',
|
|
134
|
+
"西昌": 'ECW',
|
|
135
|
+
"贵阳": 'GIW',
|
|
136
|
+
"赣州": 'GZG',
|
|
137
|
+
"达州": 'RXW',
|
|
138
|
+
"遵义": 'ZYE',
|
|
139
|
+
"郑州": 'ZZF',
|
|
140
|
+
"重庆": 'CUW',
|
|
141
|
+
"金华": 'JBH',
|
|
142
|
+
"银川": 'YIJ',
|
|
143
|
+
"镇江": 'ZJH',
|
|
144
|
+
"长春": 'CCT',
|
|
145
|
+
"长沙": 'CSQ',
|
|
146
|
+
"阜阳": 'FYH',
|
|
147
|
+
"青岛": 'QDK',
|
|
148
|
+
"鞍山": 'AST',
|
|
149
|
+
"韶关": 'SNQ',
|
|
150
|
+
"香格里拉": 'EUM',
|
|
151
|
+
"香港": 'XJA',
|
|
152
|
+
"齐齐哈尔": 'QHX',
|
|
153
|
+
"龙岩": 'LYS',
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface TrainEntryQuery {
|
|
157
|
+
from: string
|
|
158
|
+
to: string
|
|
159
|
+
/** YYYY-MM-DD */
|
|
160
|
+
date: string
|
|
161
|
+
/** 显式城市电报码(覆盖码表;kyfw 查询页 URL fs=城市,XXX 的三位码) */
|
|
162
|
+
fromStationTelecode?: string
|
|
163
|
+
toStationTelecode?: string
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface AdapterEntry {
|
|
167
|
+
ok: boolean
|
|
168
|
+
url?: string
|
|
169
|
+
unresolved?: string[]
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function buildTrainEntryUrl(q: TrainEntryQuery): AdapterEntry {
|
|
173
|
+
const unresolved: string[] = []
|
|
174
|
+
const fromTc = (q.fromStationTelecode ?? '').trim().toUpperCase() || STATION_TELECODES[q.from.trim()]
|
|
175
|
+
const toTc = (q.toStationTelecode ?? '').trim().toUpperCase() || STATION_TELECODES[q.to.trim()]
|
|
176
|
+
if (!fromTc) unresolved.push(q.from)
|
|
177
|
+
if (!toTc) unresolved.push(q.to)
|
|
178
|
+
if (unresolved.length > 0 || !/^\d{4}-\d{2}-\d{2}$/.test(q.date)) {
|
|
179
|
+
return { ok: false, unresolved: unresolved.length > 0 ? unresolved : [q.date] }
|
|
180
|
+
}
|
|
181
|
+
const fs = encodeURIComponent(`${q.from.trim()},${fromTc}`)
|
|
182
|
+
const ts = encodeURIComponent(`${q.to.trim()},${toTc}`)
|
|
183
|
+
return { ok: true, url: `https://kyfw.12306.cn/otn/leftTicket/init?linktypeid=dc&fs=${fs}&ts=${ts}&date=${q.date}&flag=N,N,Y` }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** networkHints:余票查询 XHR(queryG/Z/A/U 等负载均衡变体全命中) */
|
|
187
|
+
export const TRAIN_NETWORK_HINTS = [/leftTicket\/query/i]
|
|
188
|
+
|
|
189
|
+
/** 12306 页域(content_scripts 注入面与 background 白名单的对账源) */
|
|
190
|
+
export const TRAIN_SITE_HOST = 'kyfw.12306.cn'
|
|
191
|
+
|
|
192
|
+
export interface SessionTrainOption {
|
|
193
|
+
/** 车次(G1375 / K79 等) */
|
|
194
|
+
trainCode: string
|
|
195
|
+
fromStation: string
|
|
196
|
+
toStation: string
|
|
197
|
+
/** 出发/到达时刻(HH:MM;跨日由到达时刻本身表达,官方 DTO 无隔日字段) */
|
|
198
|
+
depTime: string
|
|
199
|
+
arrTime: string
|
|
200
|
+
/** 历时(分钟) */
|
|
201
|
+
durationMin: number
|
|
202
|
+
/** Y=可预订 / N=不可 / 其他上游原话 */
|
|
203
|
+
canWebBuy: string
|
|
204
|
+
/** 余票分桶(第一方校准索引,见 SEAT_BUCKETS;值原样:数字 / 有 / 无 / --) */
|
|
205
|
+
seats: Record<string, string>
|
|
206
|
+
/** 查询落地页(由人选车完成预订;gotry 不碰) */
|
|
207
|
+
jumpUrl?: string
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 座位桶索引表(**第一方校准 2026-09-03**:逐字段取自 12306 官方前端
|
|
211
|
+
* queryLeftTicket_end_js.js 的 cN(result,map) 转换函数——旧版公开常识索引
|
|
212
|
+
* (19-28 段)与官方现行映射不符,首查前即纠);label=页面席别口径,未知席别
|
|
213
|
+
* 保留上游字段 id 原样 */
|
|
214
|
+
const SEAT_BUCKETS: Array<{ index: number; field: string; label: string }> = [
|
|
215
|
+
{ index: 20, field: 'gg_num', label: '其他(通勤)' },
|
|
216
|
+
{ index: 21, field: 'gr_num', label: '高级软卧' },
|
|
217
|
+
{ index: 22, field: 'qt_num', label: '其他' },
|
|
218
|
+
{ index: 23, field: 'rw_num', label: '软卧(一等卧)' },
|
|
219
|
+
{ index: 24, field: 'rz_num', label: '软座' },
|
|
220
|
+
{ index: 25, field: 'tz_num', label: '特等座' },
|
|
221
|
+
{ index: 26, field: 'wz_num', label: '无座' },
|
|
222
|
+
{ index: 27, field: 'yb_num', label: 'yp_b(上游席别)' },
|
|
223
|
+
{ index: 28, field: 'yw_num', label: '硬卧(二等卧)' },
|
|
224
|
+
{ index: 29, field: 'yz_num', label: '硬座' },
|
|
225
|
+
{ index: 30, field: 'ze_num', label: '二等座' },
|
|
226
|
+
{ index: 31, field: 'zy_num', label: '一等座' },
|
|
227
|
+
{ index: 32, field: 'swz_num', label: '商务座' },
|
|
228
|
+
{ index: 33, field: 'srrb_num', label: '动卧' },
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
const TRAIN_CODE_RE = /^[GDCZTKYLSF]\d{1,5}[A-Z]?$/i
|
|
232
|
+
const HHMM_RE = /^\d{2}:\d{2}$/
|
|
233
|
+
|
|
234
|
+
/** 历时 "07:52" → 分钟 */
|
|
235
|
+
function lishiToMin(s: string | undefined): number {
|
|
236
|
+
const m = /^(\d{1,3}):(\d{2})$/.exec((s ?? '').trim())
|
|
237
|
+
if (!m) return 0
|
|
238
|
+
return Number(m[1]) * 60 + Number(m[2])
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 行级签名:车次码形态 + 时刻形态不符即整行跳过(索引漂移 fail-visible,不造数) */
|
|
242
|
+
function rowToOption(row: string, stationMap: Record<string, string> | undefined, entryUrl: string): SessionTrainOption | null {
|
|
243
|
+
const f = row.split('|')
|
|
244
|
+
if (f.length < 34) return null
|
|
245
|
+
const trainCode = (f[3] ?? '').trim()
|
|
246
|
+
const depTime = (f[8] ?? '').trim()
|
|
247
|
+
const arrTime = (f[9] ?? '').trim()
|
|
248
|
+
if (!TRAIN_CODE_RE.test(trainCode) || !HHMM_RE.test(depTime) || !HHMM_RE.test(arrTime)) return null
|
|
249
|
+
// 官方 cN 口径:站名 = data.map[电报码](map 缺失/缺键时退电报码原样,不猜名)
|
|
250
|
+
const fromStation = stationMap?.[f[6] ?? ''] ?? (f[6] ?? '')
|
|
251
|
+
const toStation = stationMap?.[f[7] ?? ''] ?? (f[7] ?? '')
|
|
252
|
+
const seats: Record<string, string> = {}
|
|
253
|
+
for (const b of SEAT_BUCKETS) {
|
|
254
|
+
const v = (f[b.index] ?? '').trim()
|
|
255
|
+
if (v && v !== '--') seats[b.label] = v
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
trainCode,
|
|
259
|
+
fromStation,
|
|
260
|
+
toStation,
|
|
261
|
+
depTime,
|
|
262
|
+
arrTime,
|
|
263
|
+
durationMin: lishiToMin(f[10]),
|
|
264
|
+
canWebBuy: (f[11] ?? '').trim(),
|
|
265
|
+
seats,
|
|
266
|
+
jumpUrl: entryUrl,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** 解析 leftTicket/query 响应(纯函数,fixture 测试锚点;字段索引与站名映射
|
|
271
|
+
* 全部对齐 12306 官方前端 cN(result,map)——见 SEAT_BUCKETS 注释);
|
|
272
|
+
* malformed 一律返空,不抛错 */
|
|
273
|
+
export function parseLeftTicketQuery(body: string, entryUrl: string, opts: { maxItems?: number } = {}): SessionTrainOption[] {
|
|
274
|
+
let raw: { data?: { result?: unknown; map?: Record<string, string> } }
|
|
275
|
+
try {
|
|
276
|
+
raw = JSON.parse(body) as typeof raw
|
|
277
|
+
} catch {
|
|
278
|
+
return []
|
|
279
|
+
}
|
|
280
|
+
const rows = raw.data?.result
|
|
281
|
+
if (!Array.isArray(rows)) return []
|
|
282
|
+
const map = (raw.data?.map && typeof raw.data.map === 'object') ? raw.data.map : undefined
|
|
283
|
+
const out: SessionTrainOption[] = []
|
|
284
|
+
for (const r of rows) {
|
|
285
|
+
if (typeof r !== 'string') continue
|
|
286
|
+
const opt = rowToOption(r, map, entryUrl)
|
|
287
|
+
if (opt) {
|
|
288
|
+
out.push(opt)
|
|
289
|
+
if (out.length >= (opts.maxItems ?? 30)) break
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return out
|
|
293
|
+
}
|