├── .github
└── ISSUE_TEMPLATE
│ └── bug-report.yaml
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── build
├── index.js
└── types.js
├── docs
├── architecture.md
├── architecture.png
└── principle.md
├── glama.json
├── package-lock.json
├── package.json
├── src
├── index.ts
└── types.ts
└── tsconfig.json
/.github/ISSUE_TEMPLATE/bug-report.yaml:
--------------------------------------------------------------------------------
1 | name: Bug反馈
2 | description: 反馈使用遇到的问题。
3 | title: "[BUG] 请在标题中简短描述问题,便于查看。"
4 | labels: bug
5 |
6 | body:
7 | - type: textarea
8 | id: bug-description
9 | attributes:
10 | label: BUG描述
11 | description: 尽量详细描述BUG问题。A clear and concise description of what the bug is.
12 | placeholder: 尽量详细描述BUG问题。A clear and concise description of what the bug is.
13 | validations:
14 | required: true
15 | - type: textarea
16 | id: screenshots
17 | attributes:
18 | label: Screenshot截图
19 | description: 尽量提供运行时报错或者结果错误的参数截图,包括调用参数。Please try to provide screenshots of the parameters that cause runtime errors or incorrect results, including the invocation parameters.
20 | placeholder: 尽量提供运行时报错或者结果错误的参数截图,包括调用参数。Please try to provide screenshots of the parameters that cause runtime errors or incorrect results, including the invocation parameters.
21 | validations:
22 | required: true
23 | - type: textarea
24 | id: addtional-context
25 | attributes:
26 | label: Additional context其他信息
27 | description: 其他信息。Add any other context about the problem here.
28 | placeholder: 其他信息。Add any other context about the problem here.
29 | validations:
30 | required: true
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Optional stylelint cache
58 | .stylelintcache
59 |
60 | # Microbundle cache
61 | .rpt2_cache/
62 | .rts2_cache_cjs/
63 | .rts2_cache_es/
64 | .rts2_cache_umd/
65 |
66 | # Optional REPL history
67 | .node_repl_history
68 |
69 | # Output of 'npm pack'
70 | *.tgz
71 |
72 | # Yarn Integrity file
73 | .yarn-integrity
74 |
75 | # dotenv environment variable files
76 | .env
77 | .env.development.local
78 | .env.test.local
79 | .env.production.local
80 | .env.local
81 |
82 | # parcel-bundler cache (https://parceljs.org/)
83 | .cache
84 | .parcel-cache
85 |
86 | # Next.js build output
87 | .next
88 | out
89 |
90 | # Nuxt.js build / generate output
91 | .nuxt
92 | dist
93 |
94 | # Gatsby files
95 | .cache/
96 | # Comment in the public line in if your project uses Gatsby and not Next.js
97 | # https://nextjs.org/blog/next-9-1#public-directory-support
98 | # public
99 |
100 | # vuepress build output
101 | .vuepress/dist
102 |
103 | # vuepress v2.x temp and cache directory
104 | .temp
105 | .cache
106 |
107 | # vitepress build output
108 | **/.vitepress/dist
109 |
110 | # vitepress cache directory
111 | **/.vitepress/cache
112 |
113 | # Docusaurus cache and generated files
114 | .docusaurus
115 |
116 | # Serverless directories
117 | .serverless/
118 |
119 | # FuseBox cache
120 | .fusebox/
121 |
122 | # DynamoDB Local files
123 | .dynamodb/
124 |
125 | # TernJS port file
126 | .tern-port
127 |
128 | # Stores VSCode versions used for testing VSCode extensions
129 | .vscode-test
130 |
131 | # yarn v2
132 | .yarn/cache
133 | .yarn/unplugged
134 | .yarn/build-state.yml
135 | .yarn/install-state.gz
136 | .pnp.*
137 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": true,
4 | "tabWidth": 2,
5 | "useTabs": false,
6 | "trailingComma": "es5",
7 | "printWidth": 80,
8 | "bracketSpacing": true,
9 | "arrowParens": "always",
10 | "endOfLine": "auto"
11 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Jok
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | #
12306-mcp
2 |
3 |
4 |
5 | [](https://github.com/Joooook)
6 | [](https://space.bilibili.com/3546386788255839)
7 | 
8 | 
9 | 
10 | 
11 |
12 |
13 | A 12306 ticket search server based on the Model Context Protocol (MCP). The server provides a simple API interface that allows users to search for 12306 tickets.
14 |
15 | 基于 Model Context Protocol (MCP) 的12306购票搜索服务器。提供了简单的API接口,允许大模型利用接口搜索12306购票信息。
16 |
17 | ## 🚩Features
18 |
19 |
20 | | 功能描述 | 状态 |
21 | |------------------------------|--------|
22 | | 查询12306购票信息 | ✅ 已完成 |
23 | | 过滤列车信息 | ✅ 已完成 |
24 | | 过站查询 | ✅ 已完成 |
25 | | 中转查询 | ✅ 已完成 |
26 | | 其余接口,欢迎提feature | 🚧 计划内 |
27 |
28 |
29 |
30 |

31 |
32 |
33 |

34 |
35 |
36 | ## ⚙️Installation
37 |
38 | ~~~bash
39 | git clone https://github.com/Joooook/12306-mcp.git
40 | npm i
41 | ~~~
42 |
43 |
44 | ## ▶️Quick Start
45 |
46 | ### CLI
47 | ~~~bash
48 | npm run build
49 | node ./build/index.js
50 | ~~~
51 |
52 | ### MCP sever configuration
53 |
54 | ~~~json
55 | {
56 | "mcpServers": {
57 | "12306-mcp": {
58 | "command": "npx",
59 | "args": [
60 | "-y",
61 | "12306-mcp"
62 | ]
63 | }
64 | }
65 | }
66 | ~~~
67 |
68 |
69 |
70 |
71 | ## 📚Documentation
72 |
73 | - [服务原理详解](./docs/principle.md) 12306-MCP服务的工作原理
74 | - [架构图](./docs/architecture.md) 12306-MCP服务的架构图
75 | 
76 |
77 | ## 👉️Reference
78 | - [modelcontextprotocol/modelcontextprotocol](https://github.com/modelcontextprotocol/modelcontextprotocol)
79 | - [modelcontextprotocol/typescript-sdk](https://github.com/modelcontextprotocol/typescript-sdk)
80 |
81 | ## 💭Murmurs
82 | 本项目仅用于学习,欢迎催更。
83 |
84 | ## 🎫Badges
85 |
86 |
87 |
88 |
89 |
90 | [](https://mseep.ai/app/joooook-12306-mcp)
91 |
92 |
93 |
94 | ## ☕️Donate
95 | 请我喝杯奶茶吧。
96 |
101 |
--------------------------------------------------------------------------------
/build/index.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | // Data一般用于表示从服务器上请求到的数据,Info一般表示解析并筛选过的要传输给大模型的数据。变量使用驼峰命名,常量使用全大写下划线命名。
3 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5 | import axios from 'axios';
6 | import { z } from 'zod';
7 | import { format, parse } from 'date-fns';
8 | import { toZonedTime } from 'date-fns-tz';
9 | import { StationDataKeys, TicketDataKeys, } from './types.js';
10 | const VERSION = "0.3.2";
11 | const API_BASE = 'https://kyfw.12306.cn';
12 | const WEB_URL = 'https://www.12306.cn/index/';
13 | const LCQUERY_INIT_URL = "https://kyfw.12306.cn/otn/lcQuery/init";
14 | const LCQUERY_PATH = await getLCQueryPath();
15 | const MISSING_STATIONS = [
16 | {
17 | station_id: '@cdd',
18 | station_name: '成 都东',
19 | station_code: 'WEI',
20 | station_pinyin: 'chengdudong',
21 | station_short: 'cdd',
22 | station_index: '',
23 | code: '1707',
24 | city: '成都',
25 | r1: '',
26 | r2: '',
27 | },
28 | ];
29 | const STATIONS = await getStations(); //以Code为键
30 | const CITY_STATIONS = (() => {
31 | const result = {};
32 | for (const station of Object.values(STATIONS)) {
33 | const city = station.city;
34 | if (!result[city]) {
35 | result[city] = [];
36 | }
37 | result[city].push({
38 | station_code: station.station_code,
39 | station_name: station.station_name,
40 | });
41 | }
42 | return result;
43 | })(); //以城市名名为键,位于该城市的的所有Station列表的记录
44 | const CITY_CODES = (() => {
45 | const result = {};
46 | for (const [city, stations] of Object.entries(CITY_STATIONS)) {
47 | for (const station of stations) {
48 | if (station.station_name == city) {
49 | result[city] = station;
50 | break;
51 | }
52 | }
53 | }
54 | return result;
55 | })(); //以城市名名为键的Station记录
56 | const NAME_STATIONS = (() => {
57 | const result = {};
58 | for (const station of Object.values(STATIONS)) {
59 | const station_name = station.station_name;
60 | result[station_name] = {
61 | station_code: station.station_code,
62 | station_name: station.station_name,
63 | };
64 | }
65 | return result;
66 | })(); //以车站名为键的Station记录
67 | const SEAT_SHORT_TYPES = {
68 | swz: '商务座',
69 | tz: '特等座',
70 | zy: '一等座',
71 | ze: '二等座',
72 | gr: '高软卧',
73 | srrb: '动卧',
74 | rw: '软卧',
75 | yw: '硬卧',
76 | rz: '软座',
77 | yz: '硬座',
78 | wz: '无座',
79 | qt: '其他',
80 | gg: '',
81 | yb: '',
82 | };
83 | const SEAT_TYPES = {
84 | '9': { name: '商务座', short: 'swz' },
85 | P: { name: '特等座', short: 'tz' },
86 | M: { name: '一等座', short: 'zy' },
87 | D: { name: '优选一等座', short: 'zy' },
88 | O: { name: '二等座', short: 'ze' },
89 | S: { name: '二等包座', short: 'ze' },
90 | '6': { name: '高级软卧', short: 'gr' },
91 | A: { name: '高级动卧', short: 'gr' },
92 | '4': { name: '软卧', short: 'rw' },
93 | I: { name: '一等卧', short: 'rw' },
94 | F: { name: '动卧', short: 'rw' },
95 | '3': { name: '硬卧', short: 'yw' },
96 | J: { name: '二等卧', short: 'yw' },
97 | '2': { name: '软座', short: 'rz' },
98 | '1': { name: '硬座', short: 'yz' },
99 | W: { name: '无座', short: 'wz' },
100 | WZ: { name: '无座', short: 'wz' },
101 | H: { name: '其他', short: 'qt' },
102 | };
103 | const DW_FLAGS = [
104 | '智能动车组',
105 | '复兴号',
106 | '静音车厢',
107 | '温馨动卧',
108 | '动感号',
109 | '支持选铺',
110 | '老年优惠',
111 | ];
112 | const TRAIN_FILTERS = {
113 | //G(高铁/城际),D(动车),Z(直达特快),T(特快),K(快速),O(其他),F(复兴号),S(智能动车组)
114 | G: (ticketInfo) => {
115 | return ticketInfo.start_train_code.startsWith('G') ||
116 | ticketInfo.start_train_code.startsWith('C')
117 | ? true
118 | : false;
119 | },
120 | D: (ticketInfo) => {
121 | return ticketInfo.start_train_code.startsWith('D') ? true : false;
122 | },
123 | Z: (ticketInfo) => {
124 | return ticketInfo.start_train_code.startsWith('Z') ? true : false;
125 | },
126 | T: (ticketInfo) => {
127 | return ticketInfo.start_train_code.startsWith('T') ? true : false;
128 | },
129 | K: (ticketInfo) => {
130 | return ticketInfo.start_train_code.startsWith('K') ? true : false;
131 | },
132 | O: (ticketInfo) => {
133 | return TRAIN_FILTERS.G(ticketInfo) ||
134 | TRAIN_FILTERS.D(ticketInfo) ||
135 | TRAIN_FILTERS.Z(ticketInfo) ||
136 | TRAIN_FILTERS.T(ticketInfo) ||
137 | TRAIN_FILTERS.K(ticketInfo)
138 | ? false
139 | : true;
140 | },
141 | F: (ticketInfo) => {
142 | if ('dw_flag' in ticketInfo) {
143 | return ticketInfo.dw_flag.includes('复兴号') ? true : false;
144 | }
145 | return ticketInfo.ticketList[0].dw_flag.includes('复兴号') ? true : false;
146 | },
147 | S: (ticketInfo) => {
148 | if ('dw_flag' in ticketInfo) {
149 | return ticketInfo.dw_flag.includes('智能动车组') ? true : false;
150 | }
151 | return ticketInfo.ticketList[0].dw_flag.includes('智能动车组')
152 | ? true
153 | : false;
154 | },
155 | };
156 | const TIME_COMPARETOR = {
157 | startTime: (ticketInfoA, ticketInfoB) => {
158 | const timeA = new Date(ticketInfoA.start_date);
159 | const timeB = new Date(ticketInfoB.start_date);
160 | if (timeA.getTime() != timeB.getTime()) {
161 | return timeA.getTime() - timeB.getTime();
162 | }
163 | const startTimeA = ticketInfoA.start_time.split(':');
164 | const startTimeB = ticketInfoB.start_time.split(':');
165 | const hourA = parseInt(startTimeA[0]);
166 | const hourB = parseInt(startTimeB[0]);
167 | if (hourA != hourB) {
168 | return hourA - hourB;
169 | }
170 | const minuteA = parseInt(startTimeA[1]);
171 | const minuteB = parseInt(startTimeB[1]);
172 | return minuteA - minuteB;
173 | },
174 | arriveTime: (ticketInfoA, ticketInfoB) => {
175 | const timeA = new Date(ticketInfoA.arrive_date);
176 | const timeB = new Date(ticketInfoB.arrive_date);
177 | if (timeA.getTime() != timeB.getTime()) {
178 | return timeA.getTime() - timeB.getTime();
179 | }
180 | const arriveTimeA = ticketInfoA.arrive_time.split(':');
181 | const arriveTimeB = ticketInfoB.arrive_time.split(':');
182 | const hourA = parseInt(arriveTimeA[0]);
183 | const hourB = parseInt(arriveTimeB[0]);
184 | if (hourA != hourB) {
185 | return hourA - hourB;
186 | }
187 | const minuteA = parseInt(arriveTimeA[1]);
188 | const minuteB = parseInt(arriveTimeB[1]);
189 | return minuteA - minuteB;
190 | },
191 | duration: (ticketInfoA, ticketInfoB) => {
192 | const lishiTimeA = ticketInfoA.lishi.split(':');
193 | const lishiTimeB = ticketInfoB.lishi.split(':');
194 | const hourA = parseInt(lishiTimeA[0]);
195 | const hourB = parseInt(lishiTimeB[0]);
196 | if (hourA != hourB) {
197 | return hourA - hourB;
198 | }
199 | const minuteA = parseInt(lishiTimeA[1]);
200 | const minuteB = parseInt(lishiTimeB[1]);
201 | return minuteA - minuteB;
202 | },
203 | };
204 | function parseCookies(cookies) {
205 | const cookieRecord = {};
206 | cookies.forEach((cookie) => {
207 | // 提取键值对部分(去掉 Path、HttpOnly 等属性)
208 | const keyValuePart = cookie.split(';')[0];
209 | // 分割键和值
210 | const [key, value] = keyValuePart.split('=');
211 | // 存入对象
212 | if (key && value) {
213 | cookieRecord[key.trim()] = value.trim();
214 | }
215 | });
216 | return cookieRecord;
217 | }
218 | function formatCookies(cookies) {
219 | return Object.entries(cookies)
220 | .map(([key, value]) => `${key}=${value}`)
221 | .join('; ');
222 | }
223 | async function getCookie(url) {
224 | try {
225 | const response = await axios.get(url);
226 | const setCookieHeader = response.headers['set-cookie'];
227 | if (setCookieHeader) {
228 | return parseCookies(setCookieHeader);
229 | }
230 | return null;
231 | }
232 | catch (error) {
233 | console.error('Error making 12306 request:', error);
234 | return null;
235 | }
236 | }
237 | function parseRouteStationsData(rawData) {
238 | const result = [];
239 | for (const item of rawData) {
240 | result.push(item);
241 | }
242 | return result;
243 | }
244 | function parseRouteStationsInfo(routeStationsData) {
245 | const result = [];
246 | routeStationsData.forEach((routeStationData, index) => {
247 | if (index == 0) {
248 | result.push({
249 | arrive_time: routeStationData.start_time,
250 | station_name: routeStationData.station_name,
251 | stopover_time: routeStationData.stopover_time,
252 | station_no: parseInt(routeStationData.station_no),
253 | });
254 | }
255 | else {
256 | result.push({
257 | arrive_time: routeStationData.arrive_time,
258 | station_name: routeStationData.station_name,
259 | stopover_time: routeStationData.stopover_time,
260 | station_no: parseInt(routeStationData.station_no),
261 | });
262 | }
263 | });
264 | return result;
265 | }
266 | function parseTicketsData(rawData) {
267 | const result = [];
268 | for (const item of rawData) {
269 | const values = item.split('|');
270 | const entry = {};
271 | TicketDataKeys.forEach((key, index) => {
272 | entry[key] = values[index];
273 | });
274 | result.push(entry);
275 | }
276 | return result;
277 | }
278 | function parseTicketsInfo(ticketsData, map) {
279 | const result = [];
280 | for (const ticket of ticketsData) {
281 | const prices = extractPrices(ticket.yp_info_new, ticket.seat_discount_info, ticket);
282 | const dw_flag = extractDWFlags(ticket.dw_flag);
283 | const startHours = parseInt(ticket.start_time.split(':')[0]);
284 | const startMinutes = parseInt(ticket.start_time.split(':')[1]);
285 | const durationHours = parseInt(ticket.lishi.split(':')[0]);
286 | const durationMinutes = parseInt(ticket.lishi.split(':')[1]);
287 | const startDate = parse(ticket.start_train_date, 'yyyyMMdd', new Date());
288 | startDate.setHours(startHours, startMinutes);
289 | const arriveDate = startDate;
290 | arriveDate.setHours(startHours + durationHours, startMinutes + durationMinutes);
291 | result.push({
292 | train_no: ticket.train_no,
293 | start_date: format(startDate, "yyyy-MM-dd"),
294 | arrive_date: format(arriveDate, "yyyy-MM-dd"),
295 | start_train_code: ticket.station_train_code,
296 | start_time: ticket.start_time,
297 | arrive_time: ticket.arrive_time,
298 | lishi: ticket.lishi,
299 | from_station: map[ticket.from_station_telecode],
300 | to_station: map[ticket.to_station_telecode],
301 | from_station_telecode: ticket.from_station_telecode,
302 | to_station_telecode: ticket.to_station_telecode,
303 | prices: prices,
304 | dw_flag: dw_flag,
305 | });
306 | }
307 | return result;
308 | }
309 | /**
310 | * 格式化票量信息,提供语义化描述
311 | * @param num 票量数字或状态字符串
312 | * @returns 格式化后的票量描述
313 | */
314 | function formatTicketStatus(num) {
315 | // 检查是否为纯数字
316 | if (num.match(/^\d+$/)) {
317 | const count = parseInt(num);
318 | if (count === 0) {
319 | return '无票';
320 | }
321 | else {
322 | return `剩余${count}张票`;
323 | }
324 | }
325 | // 处理特殊状态字符串
326 | switch (num) {
327 | case '有':
328 | case '充足':
329 | return '有票';
330 | case '无':
331 | case '--':
332 | case '':
333 | return '无票';
334 | case '候补':
335 | return '无票需候补';
336 | default:
337 | return `${num}票`;
338 | }
339 | }
340 | function formatTicketsInfo(ticketsInfo) {
341 | if (ticketsInfo.length === 0) {
342 | return '没有查询到相关车次信息';
343 | }
344 | let result = '车次 | 出发站 -> 到达站 | 出发时间 -> 到达时间 | 历时\n';
345 | ticketsInfo.forEach((ticketInfo) => {
346 | let infoStr = '';
347 | infoStr += `${ticketInfo.start_train_code}(实际车次train_no: ${ticketInfo.train_no}) ${ticketInfo.from_station}(telecode: ${ticketInfo.from_station_telecode}) -> ${ticketInfo.to_station}(telecode: ${ticketInfo.to_station_telecode}) ${ticketInfo.start_time} -> ${ticketInfo.arrive_time} 历时:${ticketInfo.lishi}`;
348 | ticketInfo.prices.forEach((price) => {
349 | const ticketStatus = formatTicketStatus(price.num);
350 | infoStr += `\n- ${price.seat_name}: ${ticketStatus} ${price.price}元`;
351 | });
352 | result += `${infoStr}\n`;
353 | });
354 | return result;
355 | }
356 | function filterTicketsInfo(ticketsInfo, trainFilterFlags, sortFlag = '', sortReverse = false, limitedNum = 0) {
357 | let result;
358 | if (trainFilterFlags.length === 0) {
359 | result = ticketsInfo;
360 | }
361 | else {
362 | result = [];
363 | for (const ticketInfo of ticketsInfo) {
364 | for (const filter of trainFilterFlags) {
365 | if (TRAIN_FILTERS[filter](ticketInfo)) {
366 | result.push(ticketInfo);
367 | break;
368 | }
369 | }
370 | }
371 | }
372 | if (Object.keys(TIME_COMPARETOR).includes(sortFlag)) {
373 | result.sort(TIME_COMPARETOR[sortFlag]);
374 | if (sortReverse) {
375 | result.reverse();
376 | }
377 | }
378 | if (limitedNum == 0) {
379 | return result;
380 | }
381 | return result.slice(0, limitedNum);
382 | }
383 | function parseInterlinesTicketInfo(interlineTicketsData) {
384 | const result = [];
385 | for (const interlineTicketData of interlineTicketsData) {
386 | const prices = extractPrices(interlineTicketData.yp_info, interlineTicketData.seat_discount_info, interlineTicketData);
387 | const startHours = parseInt(interlineTicketData.start_time.split(':')[0]);
388 | const startMinutes = parseInt(interlineTicketData.start_time.split(':')[1]);
389 | const durationHours = parseInt(interlineTicketData.lishi.split(':')[0]);
390 | const durationMinutes = parseInt(interlineTicketData.lishi.split(':')[1]);
391 | const startDate = parse(interlineTicketData.start_train_date, 'yyyyMMdd', new Date());
392 | startDate.setHours(startHours, startMinutes);
393 | const arriveDate = startDate;
394 | arriveDate.setHours(startHours + durationHours, startMinutes + durationMinutes);
395 | result.push({
396 | train_no: interlineTicketData.train_no,
397 | start_train_code: interlineTicketData.station_train_code,
398 | start_date: format(startDate, "yyyy-MM-dd"),
399 | arrive_date: format(arriveDate, "yyyy-MM-dd"),
400 | start_time: interlineTicketData.start_time,
401 | arrive_time: interlineTicketData.arrive_time,
402 | lishi: interlineTicketData.lishi,
403 | from_station: interlineTicketData.from_station_name,
404 | to_station: interlineTicketData.to_station_name,
405 | from_station_telecode: interlineTicketData.from_station_telecode,
406 | to_station_telecode: interlineTicketData.to_station_telecode,
407 | prices: prices,
408 | dw_flag: extractDWFlags(interlineTicketData.dw_flag),
409 | });
410 | }
411 | return result;
412 | }
413 | function parseInterlinesInfo(interlineData) {
414 | const result = [];
415 | for (const ticket of interlineData) {
416 | const interlineTickets = parseInterlinesTicketInfo(ticket.fullList);
417 | const lishi = extractLishi(ticket.all_lishi);
418 | result.push({
419 | lishi: lishi,
420 | start_time: ticket.start_time,
421 | start_date: ticket.train_date,
422 | middle_date: ticket.middle_date,
423 | arrive_date: ticket.arrive_date,
424 | arrive_time: ticket.arrive_time,
425 | from_station_code: ticket.from_station_code,
426 | from_station_name: ticket.from_station_name,
427 | middle_station_code: ticket.middle_station_code,
428 | middle_station_name: ticket.middle_station_name,
429 | end_station_code: ticket.end_station_code,
430 | end_station_name: ticket.end_station_name,
431 | start_train_code: interlineTickets[0].start_train_code,
432 | first_train_no: ticket.first_train_no,
433 | second_train_no: ticket.second_train_no,
434 | train_count: ticket.train_count,
435 | ticketList: interlineTickets,
436 | same_station: ticket.same_station == '0' ? true : false,
437 | same_train: ticket.same_train == 'Y' ? true : false,
438 | wait_time: ticket.wait_time,
439 | });
440 | }
441 | return result;
442 | }
443 | function formatInterlinesInfo(interlinesInfo) {
444 | let result = '出发时间 -> 到达时间 | 出发车站 -> 中转车站 -> 到达车站 | 换乘标志 |换乘等待时间| 总历时\n\n';
445 | interlinesInfo.forEach((interlineInfo) => {
446 | result += `${interlineInfo.start_date} ${interlineInfo.start_time} -> ${interlineInfo.arrive_date} ${interlineInfo.arrive_time} | `;
447 | result += `${interlineInfo.from_station_name} -> ${interlineInfo.middle_station_name} -> ${interlineInfo.end_station_name} | `;
448 | result += `${interlineInfo.same_train
449 | ? '同车换乘'
450 | : interlineInfo.same_station
451 | ? '同站换乘'
452 | : '换站换乘'} | ${interlineInfo.wait_time} | ${interlineInfo.lishi}\n\n`;
453 | result +=
454 | '\t' + formatTicketsInfo(interlineInfo.ticketList).replace(/\n/g, '\n\t');
455 | result += '\n';
456 | });
457 | return result;
458 | }
459 | function parseStationsData(rawData) {
460 | const result = {};
461 | const dataArray = rawData.split('|');
462 | const dataList = [];
463 | for (let i = 0; i < Math.floor(dataArray.length / 10); i++) {
464 | dataList.push(dataArray.slice(i * 10, i * 10 + 10));
465 | }
466 | for (const group of dataList) {
467 | let station = {};
468 | StationDataKeys.forEach((key, index) => {
469 | station[key] = group[index];
470 | });
471 | if (!station.station_code) {
472 | continue;
473 | }
474 | result[station.station_code] = station;
475 | }
476 | return result;
477 | }
478 | /**
479 | * 格式化历时数据为hh:mm,为比较历时做准备。
480 | * @param all_lishi interlineTicket中的历时数据, 形如:H小时M分钟或M分钟
481 | * @returns 和普通余票数据中的lishi字段一样的hh:mm格式的历时
482 | */
483 | function extractLishi(all_lishi) {
484 | const match = all_lishi.match(/(?:(\d+)小时)?(\d+?)分钟/);
485 | if (!match) {
486 | throw new Error('extractLishi失败,没有匹配到关键词');
487 | }
488 | if (!match[1]) {
489 | return `00:${match[2]}`;
490 | }
491 | return `${match[1].padStart(2, '0')}:${match[2]}}`;
492 | }
493 | function extractPrices(yp_info, seat_discount_info, ticketData) {
494 | const PRICE_STR_LENGTH = 10;
495 | const DISCOUNT_STR_LENGTH = 5;
496 | const prices = [];
497 | const discounts = {};
498 | for (let i = 0; i < seat_discount_info.length / DISCOUNT_STR_LENGTH; i++) {
499 | const discount_str = seat_discount_info.slice(i * DISCOUNT_STR_LENGTH, (i + 1) * DISCOUNT_STR_LENGTH);
500 | discounts[discount_str[0]] = parseInt(discount_str.slice(1), 10);
501 | }
502 | for (let i = 0; i < yp_info.length / PRICE_STR_LENGTH; i++) {
503 | const price_str = yp_info.slice(i * PRICE_STR_LENGTH, (i + 1) * PRICE_STR_LENGTH);
504 | var seat_type_code;
505 | if (parseInt(price_str.slice(6, 10), 10) >= 3000) { // 根据12306的js逆向出来的,不懂。
506 | seat_type_code = 'W'; // 为无座
507 | }
508 | else if (!Object.keys(SEAT_TYPES).includes(price_str[0])) {
509 | seat_type_code = 'H'; // 其他坐席
510 | }
511 | else {
512 | seat_type_code = price_str[0];
513 | }
514 | const seat_type = SEAT_TYPES[seat_type_code];
515 | const price = parseInt(price_str.slice(1, 6), 10) / 10;
516 | const discount = seat_type_code in discounts ? discounts[seat_type_code] : null;
517 | prices.push({
518 | seat_name: seat_type.name,
519 | short: seat_type.short,
520 | seat_type_code,
521 | num: ticketData[`${seat_type.short}_num`],
522 | price,
523 | discount,
524 | });
525 | }
526 | return prices;
527 | }
528 | function extractDWFlags(dw_flag_str) {
529 | const dwFlagList = dw_flag_str.split('#');
530 | let result = [];
531 | if ('5' == dwFlagList[0]) {
532 | result.push(DW_FLAGS[0]);
533 | }
534 | if (dwFlagList.length > 1 && '1' == dwFlagList[1]) {
535 | result.push(DW_FLAGS[1]);
536 | }
537 | if (dwFlagList.length > 2) {
538 | if ('Q' == dwFlagList[2].substring(0, 1)) {
539 | result.push(DW_FLAGS[2]);
540 | }
541 | else if ('R' == dwFlagList[2].substring(0, 1)) {
542 | result.push(DW_FLAGS[3]);
543 | }
544 | }
545 | if (dwFlagList.length > 5 && 'D' == dwFlagList[5]) {
546 | result.push(DW_FLAGS[4]);
547 | }
548 | if (dwFlagList.length > 6 && 'z' != dwFlagList[6]) {
549 | result.push(DW_FLAGS[5]);
550 | }
551 | if (dwFlagList.length > 7 && 'z' != dwFlagList[7]) {
552 | result.push(DW_FLAGS[6]);
553 | }
554 | return result;
555 | }
556 | function checkDate(date) {
557 | const timeZone = 'Asia/Shanghai';
558 | const nowInShanghai = toZonedTime(new Date(), timeZone);
559 | nowInShanghai.setHours(0, 0, 0, 0);
560 | const inputInShanghai = toZonedTime(new Date(date), timeZone);
561 | inputInShanghai.setHours(0, 0, 0, 0);
562 | return inputInShanghai >= nowInShanghai;
563 | }
564 | async function make12306Request(url, scheme = new URLSearchParams(), headers = {}) {
565 | try {
566 | const response = await axios.get(url + '?' + scheme.toString(), {
567 | headers: headers,
568 | });
569 | return (await response.data);
570 | }
571 | catch (error) {
572 | console.error('Error making 12306 request:', error);
573 | return null;
574 | }
575 | }
576 | // Create server instance
577 | const server = new McpServer({
578 | name: '12306-mcp',
579 | version: VERSION,
580 | capabilities: {
581 | resources: {},
582 | tools: {},
583 | },
584 | instructions: '该服务主要用于帮助用户查询火车票信息、特定列车的经停站信息以及相关的车站信息。请仔细理解用户的意图,并按以下指引选择合适的接口:\n\n' +
585 | '**原则:**\n' +
586 | '* **优先理解意图**:判断用户的真实需求,是查票、查经停站还是查车站信息。\n' +
587 | '* **参数准确性**:确保传递给每个的参数格式和类型都正确,特别是日期格式和地点编码。\n' +
588 | '* **必要时追问**:如果用户信息不足以调用接口,请向用户追问缺失的信息。\n' +
589 | '* **清晰呈现结果**:将接口返回的信息以用户易于理解的方式进行呈现。\n\n' +
590 | '请根据上述指引选择接口。',
591 | });
592 | server.resource('stations', 'data://all-stations', async (uri) => ({
593 | contents: [{ uri: uri.href, text: JSON.stringify(STATIONS) }],
594 | }));
595 | server.tool('get-current-date', '获取当前日期,以上海时区(Asia/Shanghai, UTC+8)为准,返回格式为 "yyyy-MM-dd"。主要用于解析用户提到的相对日期(如“明天”、“下周三”),为其他需要日期的接口提供准确的日期输入。', {}, async () => {
596 | try {
597 | const timeZone = 'Asia/Shanghai';
598 | const nowInShanghai = toZonedTime(new Date(), timeZone);
599 | const formattedDate = format(nowInShanghai, 'yyyy-MM-dd');
600 | return {
601 | content: [{ type: 'text', text: formattedDate }],
602 | };
603 | }
604 | catch (error) {
605 | console.error('Error getting current date:', error);
606 | return {
607 | content: [{ type: 'text', text: 'Error: Failed to get current date.' }],
608 | };
609 | }
610 | });
611 | server.tool('get-stations-code-in-city', '通过中文城市名查询该城市 **所有** 火车站的名称及其对应的 `station_code`,结果是一个包含多个车站信息的列表。', {
612 | city: z.string().describe('中文城市名称,例如:"北京", "上海"'),
613 | }, async ({ city }) => {
614 | if (!(city in CITY_STATIONS)) {
615 | return {
616 | content: [{ type: 'text', text: 'Error: City not found. ' }],
617 | };
618 | }
619 | return {
620 | content: [{ type: 'text', text: JSON.stringify(CITY_STATIONS[city]) }],
621 | };
622 | });
623 | server.tool('get-station-code-of-citys', '通过中文城市名查询代表该城市的 `station_code`。此接口主要用于在用户提供**城市名**作为出发地或到达地时,为接口准备 `station_code` 参数。', {
624 | citys: z
625 | .string()
626 | .describe('要查询的城市,比如"北京"。若要查询多个城市,请用|分割,比如"北京|上海"。'),
627 | }, async ({ citys }) => {
628 | let result = {};
629 | for (const city of citys.split('|')) {
630 | if (!(city in CITY_CODES)) {
631 | result[city] = { error: '未检索到城市。' };
632 | }
633 | else {
634 | result[city] = CITY_CODES[city];
635 | }
636 | }
637 | return {
638 | content: [{ type: 'text', text: JSON.stringify(result) }],
639 | };
640 | });
641 | server.tool('get-station-code-by-names', '通过具体的中文车站名查询其 `station_code` 和车站名。此接口主要用于在用户提供**具体车站名**作为出发地或到达地时,为接口准备 `station_code` 参数。', {
642 | stationNames: z
643 | .string()
644 | .describe('具体的中文车站名称,例如:"北京南", "上海虹桥"。若要查询多个站点,请用|分割,比如"北京南|上海虹桥"。'),
645 | }, async ({ stationNames }) => {
646 | let result = {};
647 | for (let stationName of stationNames.split('|')) {
648 | stationName = stationName.endsWith('站')
649 | ? stationName.substring(0, -1)
650 | : stationName;
651 | if (!(stationName in NAME_STATIONS)) {
652 | result[stationName] = { error: '未检索到城市。' };
653 | }
654 | else {
655 | result[stationName] = NAME_STATIONS[stationName];
656 | }
657 | }
658 | return {
659 | content: [{ type: 'text', text: JSON.stringify(result) }],
660 | };
661 | });
662 | server.tool('get-station-by-telecode', '通过车站的 `station_telecode` 查询车站的详细信息,包括名称、拼音、所属城市等。此接口主要用于在已知 `telecode` 的情况下获取更完整的车站数据,或用于特殊查询及调试目的。一般用户对话流程中较少直接触发。', {
663 | stationTelecode: z
664 | .string()
665 | .describe('车站的 `station_telecode` (3位字母编码)'),
666 | }, async ({ stationTelecode }) => {
667 | if (!STATIONS[stationTelecode]) {
668 | return {
669 | content: [{ type: 'text', text: 'Error: Station not found. ' }],
670 | };
671 | }
672 | return {
673 | content: [
674 | { type: 'text', text: JSON.stringify(STATIONS[stationTelecode]) },
675 | ],
676 | };
677 | });
678 | server.tool('get-tickets', '查询12306余票信息。', {
679 | date: z
680 | .string()
681 | .length(10)
682 | .describe('查询日期,格式为 "yyyy-MM-dd"。如果用户提供的是相对日期(如“明天”),请务必先调用 `get-current-date` 接口获取当前日期,并计算出目标日期。'),
683 | fromStation: z
684 | .string()
685 | .describe('出发地的 `station_code` 。必须是通过 `get-station-code-by-names` 或 `get-station-code-of-citys` 接口查询得到的编码,严禁直接使用中文地名。'),
686 | toStation: z
687 | .string()
688 | .describe('到达地的 `station_code` 。必须是通过 `get-station-code-by-names` 或 `get-station-code-of-citys` 接口查询得到的编码,严禁直接使用中文地名。'),
689 | trainFilterFlags: z
690 | .string()
691 | .regex(/^[GDZTKOFS]*$/)
692 | .max(8)
693 | .optional()
694 | .default('')
695 | .describe('车次筛选条件,默认为空,即不筛选。支持多个标志同时筛选。例如用户说“高铁票”,则应使用 "G"。可选标志:[G(高铁/城际),D(动车),Z(直达特快),T(特快),K(快速),O(其他),F(复兴号),S(智能动车组)]'),
696 | sortFlag: z
697 | .string()
698 | .optional()
699 | .default('')
700 | .describe('排序方式,默认为空,即不排序。仅支持单一标识。可选标志:[startTime(出发时间从早到晚), arriveTime(抵达时间从早到晚), duration(历时从短到长)]'),
701 | sortReverse: z
702 | .boolean()
703 | .optional()
704 | .default(false)
705 | .describe('是否逆向排序结果,默认为false。仅在设置了sortFlag时生效。'),
706 | limitedNum: z
707 | .number()
708 | .min(0)
709 | .optional()
710 | .default(0)
711 | .describe('返回的余票数量限制,默认为0,即不限制。'),
712 | }, async ({ date, fromStation, toStation, trainFilterFlags, sortFlag, sortReverse, limitedNum }) => {
713 | // 检查日期是否早于当前日期
714 | if (!checkDate(date)) {
715 | return {
716 | content: [
717 | {
718 | type: 'text',
719 | text: 'Error: The date cannot be earlier than today.',
720 | },
721 | ],
722 | };
723 | }
724 | if (!Object.keys(STATIONS).includes(fromStation) ||
725 | !Object.keys(STATIONS).includes(toStation)) {
726 | return {
727 | content: [{ type: 'text', text: 'Error: Station not found. ' }],
728 | };
729 | }
730 | const queryParams = new URLSearchParams({
731 | 'leftTicketDTO.train_date': date,
732 | 'leftTicketDTO.from_station': fromStation,
733 | 'leftTicketDTO.to_station': toStation,
734 | purpose_codes: 'ADULT',
735 | });
736 | const queryUrl = `${API_BASE}/otn/leftTicket/query`;
737 | const cookies = await getCookie(API_BASE);
738 | if (cookies == null) {
739 | return {
740 | content: [
741 | {
742 | type: 'text',
743 | text: 'Error: get cookie failed. Check your network.',
744 | },
745 | ],
746 | };
747 | }
748 | const queryResponse = await make12306Request(queryUrl, queryParams, { Cookie: formatCookies(cookies) });
749 | if (queryResponse === null || queryResponse === undefined) {
750 | return {
751 | content: [{ type: 'text', text: 'Error: get tickets data failed. ' }],
752 | };
753 | }
754 | const ticketsData = parseTicketsData(queryResponse.data.result);
755 | let ticketsInfo;
756 | try {
757 | ticketsInfo = parseTicketsInfo(ticketsData, queryResponse.data.map);
758 | }
759 | catch (error) {
760 | console.error('Error: parse tickets info failed. ', error);
761 | return {
762 | content: [{ type: 'text', text: 'Error: parse tickets info failed. ' }],
763 | };
764 | }
765 | const filteredTicketsInfo = filterTicketsInfo(ticketsInfo, trainFilterFlags, sortFlag, sortReverse, limitedNum);
766 | return {
767 | content: [{ type: 'text', text: formatTicketsInfo(filteredTicketsInfo) }],
768 | };
769 | });
770 | // https://kyfw.12306.cn/lcquery/queryG?
771 | // train_date=2025-05-10&
772 | // from_station_telecode=CDW&
773 | // to_station_telecode=ZGE&
774 | // middle_station=&
775 | // result_index=0&
776 | // can_query=Y&
777 | // isShowWZ=N&
778 | // purpose_codes=00&
779 | // channel=E ?channel是什么用的
780 | server.tool('get-interline-tickets', '查询12306中转余票信息。尚且只支持查询前十条。', {
781 | date: z
782 | .string()
783 | .length(10)
784 | .describe('查询日期,格式为 "yyyy-MM-dd"。如果用户提供的是相对日期(如“明天”),请务必先调用 `get-current-date` 接口获取当前日期,并计算出目标日期。'),
785 | fromStation: z
786 | .string()
787 | .describe('出发地的 `station_code` 。必须是通过 `get-station-code-by-names` 或 `get-station-code-of-citys` 接口查询得到的编码,严禁直接使用中文地名。'),
788 | toStation: z
789 | .string()
790 | .describe('出发地的 `station_code` 。必须是通过 `get-station-code-by-names` 或 `get-station-code-of-citys` 接口查询得到的编码,严禁直接使用中文地名。'),
791 | middleStation: z
792 | .string()
793 | .optional()
794 | .default('')
795 | .describe('中转地的 `station_code` ,可选。必须是通过 `get-station-code-by-names` 或 `get-station-code-of-citys` 接口查询得到的编码,严禁直接使用中文地名。'),
796 | showWZ: z
797 | .boolean()
798 | .optional()
799 | .default(false)
800 | .describe('是否显示无座车,默认不显示无座车。'),
801 | trainFilterFlags: z
802 | .string()
803 | .regex(/^[GDZTKOFS]*$/)
804 | .max(8)
805 | .optional()
806 | .default('')
807 | .describe('车次筛选条件,默认为空。从以下标志中选取多个条件组合[G(高铁/城际),D(动车),Z(直达特快),T(特快),K(快速),O(其他),F(复兴号),S(智能动车组)]'),
808 | sortFlag: z
809 | .string()
810 | .optional()
811 | .default('')
812 | .describe('排序方式,默认为空,即不排序。仅支持单一标识。可选标志:[startTime(出发时间从早到晚), arriveTime(抵达时间从早到晚), duration(历时从短到长)]'),
813 | sortReverse: z
814 | .boolean()
815 | .optional()
816 | .default(false)
817 | .describe('是否逆向排序结果,默认为false。仅在设置了sortFlag时生效。'),
818 | limitedNum: z
819 | .number()
820 | .min(0)
821 | .optional()
822 | .default(0)
823 | .describe('返回的余票数量限制,默认为0,即不限制。'),
824 | }, async ({ date, fromStation, toStation, middleStation, showWZ, trainFilterFlags, sortFlag, sortReverse, limitedNum }) => {
825 | // 检查日期是否早于当前日期
826 | if (!checkDate(date)) {
827 | return {
828 | content: [
829 | {
830 | type: 'text',
831 | text: 'Error: The date cannot be earlier than today.',
832 | },
833 | ],
834 | };
835 | }
836 | if (!Object.keys(STATIONS).includes(fromStation) ||
837 | !Object.keys(STATIONS).includes(toStation)) {
838 | return {
839 | content: [{ type: 'text', text: 'Error: Station not found. ' }],
840 | };
841 | }
842 | const queryUrl = `${API_BASE}${LCQUERY_PATH}`;
843 | const queryParams = new URLSearchParams({
844 | train_date: date,
845 | from_station_telecode: fromStation,
846 | to_station_telecode: toStation,
847 | middle_station: middleStation,
848 | result_index: '0',
849 | can_query: 'Y',
850 | isShowWZ: showWZ ? 'Y' : 'N',
851 | purpose_codes: '00', // 00: 成人票 0X: 学生票
852 | channel: 'E', // 没搞清楚什么用
853 | });
854 | const cookies = await getCookie(API_BASE);
855 | if (cookies == null) {
856 | return {
857 | content: [
858 | {
859 | type: 'text',
860 | text: 'Error: get cookie failed. Check your network.',
861 | },
862 | ],
863 | };
864 | }
865 | const queryResponse = await make12306Request(queryUrl, queryParams, { Cookie: formatCookies(cookies) });
866 | // 处理请求错误
867 | if (queryResponse === null || queryResponse === undefined) {
868 | return {
869 | content: [
870 | {
871 | type: 'text',
872 | text: 'Error: request interline tickets data failed. ',
873 | },
874 | ],
875 | };
876 | }
877 | // 请求成功,但查询有误
878 | if (typeof queryResponse.data == 'string') {
879 | return {
880 | content: [{ type: 'text', text: `很抱歉,未查到相关的列车余票。(${queryResponse.errorMsg})` }],
881 | };
882 | }
883 | // 请求和查询都没问题
884 | let interlineTicketsInfo;
885 | try {
886 | interlineTicketsInfo = parseInterlinesInfo(queryResponse.data.middleList);
887 | }
888 | catch (error) {
889 | return {
890 | content: [
891 | { type: 'text', text: `Error: parse tickets info failed. ${error}` },
892 | ],
893 | };
894 | }
895 | const filteredInterlineTicketsInfo = filterTicketsInfo(interlineTicketsInfo, trainFilterFlags, sortFlag, sortReverse, limitedNum);
896 | return {
897 | content: [
898 | {
899 | type: 'text',
900 | text: formatInterlinesInfo(filteredInterlineTicketsInfo),
901 | },
902 | ],
903 | };
904 | });
905 | server.tool('get-train-route-stations', '查询特定列车车次在指定区间内的途径车站、到站时间、出发时间及停留时间等详细经停信息。当用户询问某趟具体列车的经停站时使用此接口。', {
906 | trainNo: z
907 | .string()
908 | .describe('要查询的实际车次编号 `train_no`,例如 "240000G10336",而非"G1033"。此编号通常可以从 `get-tickets` 的查询结果中获取,或者由用户直接提供。'),
909 | fromStationTelecode: z
910 | .string()
911 | .describe('该列车行程的**出发站**的 `station_telecode` (3位字母编码`)。通常来自 `get-tickets` 结果中的 `telecode` 字段,或者通过 `get-station-code-by-names` 得到。'),
912 | toStationTelecode: z
913 | .string()
914 | .describe('该列车行程的**到达站**的 `station_telecode` (3位字母编码)。通常来自 `get-tickets` 结果中的 `telecode` 字段,或者通过 `get-station-code-by-names` 得到。'),
915 | departDate: z
916 | .string()
917 | .length(10)
918 | .describe('列车从 `fromStationTelecode` 指定的车站出发的日期 (格式: yyyy-MM-dd)。如果用户提供的是相对日期,请务必先调用 `get-current-date` 解析。'),
919 | }, async ({ trainNo: trainNo, fromStationTelecode, toStationTelecode, departDate, }) => {
920 | const queryParams = new URLSearchParams({
921 | train_no: trainNo,
922 | from_station_telecode: fromStationTelecode,
923 | to_station_telecode: toStationTelecode,
924 | depart_date: departDate,
925 | });
926 | const queryUrl = `${API_BASE}/otn/czxx/queryByTrainNo`;
927 | const cookies = await getCookie(API_BASE);
928 | if (cookies == null) {
929 | return {
930 | content: [{ type: 'text', text: 'Error: get cookie failed. ' }],
931 | };
932 | }
933 | const queryResponse = await make12306Request(queryUrl, queryParams, { Cookie: formatCookies(cookies) });
934 | if (queryResponse == null || queryResponse.data == undefined) {
935 | return {
936 | content: [
937 | { type: 'text', text: 'Error: get train route stations failed. ' },
938 | ],
939 | };
940 | }
941 | const routeStationsInfo = parseRouteStationsInfo(queryResponse.data.data);
942 | if (routeStationsInfo.length == 0) {
943 | return {
944 | content: [{ type: 'text', text: '未查询到相关车次信息。' }],
945 | };
946 | }
947 | return {
948 | content: [{ type: 'text', text: JSON.stringify(routeStationsInfo) }],
949 | };
950 | });
951 | async function getStations() {
952 | const html = await make12306Request(WEB_URL);
953 | if (html == null) {
954 | throw new Error('Error: get 12306 web page failed.');
955 | }
956 | const match = html.match('.(/script/core/common/station_name.+?\.js)');
957 | if (match == null) {
958 | throw new Error('Error: get station name js file failed.');
959 | }
960 | const stationNameJSFilePath = match[0];
961 | const stationNameJS = await make12306Request(new URL(stationNameJSFilePath, WEB_URL));
962 | if (stationNameJS == null) {
963 | throw new Error('Error: get station name js file failed.');
964 | }
965 | const rawData = eval(stationNameJS.replace('var station_names =', ''));
966 | const stationsData = parseStationsData(rawData);
967 | // 加上缺失的车站信息
968 | for (const station of MISSING_STATIONS) {
969 | if (!stationsData[station.station_code]) {
970 | stationsData[station.station_code] = station;
971 | }
972 | }
973 | return stationsData;
974 | }
975 | async function getLCQueryPath() {
976 | const html = await make12306Request(LCQUERY_INIT_URL);
977 | if (html == null) {
978 | throw new Error('Error: get 12306 web page failed.');
979 | }
980 | const match = html.match(/ var lc_search_url = '(.+?)'/);
981 | if (match == null) {
982 | throw new Error('Error: get station name js file failed.');
983 | }
984 | return match[1];
985 | }
986 | async function init() { }
987 | async function main() {
988 | const transport = new StdioServerTransport();
989 | await init();
990 | await server.connect(transport);
991 | console.error('12306 MCP Server running on stdio @Joooook');
992 | }
993 | main().catch((error) => {
994 | console.error('Fatal error in main():', error);
995 | process.exit(1);
996 | });
997 |
--------------------------------------------------------------------------------
/build/types.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | export const TicketDataKeys = [
3 | 'secret_Sstr',
4 | 'button_text_info',
5 | 'train_no',
6 | 'station_train_code',
7 | 'start_station_telecode',
8 | 'end_station_telecode',
9 | 'from_station_telecode',
10 | 'to_station_telecode',
11 | 'start_time',
12 | 'arrive_time',
13 | 'lishi',
14 | 'canWebBuy',
15 | 'yp_info',
16 | 'start_train_date',
17 | 'train_seat_feature',
18 | 'location_code',
19 | 'from_station_no',
20 | 'to_station_no',
21 | 'is_support_card',
22 | 'controlled_train_flag',
23 | 'gg_num',
24 | 'gr_num',
25 | 'qt_num',
26 | 'rw_num',
27 | 'rz_num',
28 | 'tz_num',
29 | 'wz_num',
30 | 'yb_num',
31 | 'yw_num',
32 | 'yz_num',
33 | 'ze_num',
34 | 'zy_num',
35 | 'swz_num',
36 | 'srrb_num',
37 | 'yp_ex',
38 | 'seat_types',
39 | 'exchange_train_flag',
40 | 'houbu_train_flag',
41 | 'houbu_seat_limit',
42 | 'yp_info_new',
43 | '40',
44 | '41',
45 | '42',
46 | '43',
47 | '44',
48 | '45',
49 | 'dw_flag',
50 | '47',
51 | 'stopcheckTime',
52 | 'country_flag',
53 | 'local_arrive_time',
54 | 'local_start_time',
55 | '52',
56 | 'bed_level_info',
57 | 'seat_discount_info',
58 | 'sale_time',
59 | '56',
60 | ];
61 | export const StationDataKeys = [
62 | 'station_id',
63 | 'station_name',
64 | 'station_code',
65 | 'station_pinyin',
66 | 'station_short',
67 | 'station_index',
68 | 'code',
69 | 'city',
70 | 'r1',
71 | 'r2',
72 | ];
73 |
--------------------------------------------------------------------------------
/docs/architecture.md:
--------------------------------------------------------------------------------
1 | # 12306-MCP 服务架构
2 |
3 | ## 整体架构
4 |
5 | 
6 |
7 |
8 | ```mermaid
9 | graph TB
10 | subgraph "用户层"
11 | User["用户"]
12 | LLM["大语言模型"]
13 | end
14 |
15 | subgraph "MCP 服务层"
16 | McpServer["MCP Server"]
17 |
18 | subgraph "基础工具层"
19 | GetDate["get-current-date"]
20 | GetCityStations["get-stations-code-in-city"]
21 | GetCityCodes["get-station-code-of-citys"]
22 | GetStationNames["get-station-code-by-names"]
23 | GetStationInfo["get-station-by-telecode"]
24 | end
25 |
26 | subgraph "核心工具层"
27 | GetTickets["get-tickets"]
28 | GetInterline["get-interline-tickets"]
29 | GetRoute["get-train-route-stations"]
30 | end
31 | end
32 |
33 | subgraph "数据层"
34 | Stations["STATIONS
(车站id→车站信息)"]
35 | CityStationsMap["CITY_STATIONS
(城市名→车站id(可能一个城市多个站))"]
36 | CityCodes["CITY_CODES
(车站名(与城市名相同,只会有一个) →车站id)"]
37 | NameStations["NAME_STATIONS
(车站名→车站id)"]
38 | end
39 |
40 | subgraph "外部服务"
41 | Api12306["12306 API"]
42 | end
43 |
44 | User --> LLM
45 | LLM <--> McpServer
46 |
47 | GetDate -.-> GetTickets
48 | GetDate -.-> GetInterline
49 | GetDate -.-> GetRoute
50 | GetCityCodes -.-> GetTickets
51 | GetCityCodes -.-> GetInterline
52 | GetStationNames -.-> GetTickets
53 | GetStationNames -.-> GetInterline
54 | GetStationNames -.-> GetRoute
55 | GetTickets -.-> GetRoute
56 |
57 |
58 | GetTickets --> Api12306
59 | GetInterline --> Api12306
60 | GetRoute --> Api12306
61 |
62 | GetCityStations --> CityStationsMap
63 | GetCityCodes --> CityCodes
64 | GetStationNames --> NameStations
65 | GetStationInfo --> Stations
66 | ```
67 |
68 |
--------------------------------------------------------------------------------
/docs/architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Joooook/12306-mcp/68f09e07ad0d75fd09b9b3f52c744e65b3df8c0e/docs/architecture.png
--------------------------------------------------------------------------------
/docs/principle.md:
--------------------------------------------------------------------------------
1 | # 12306-MCP 服务 原理
2 |
3 | ## 1. 启动初始化
4 |
5 | ### 1.1 车站数据加载
6 |
7 | 服务启动时通过 `getStations()` 函数从 12306 API 获取全国车站信息,构建四个核心索引:
8 |
9 | **具体流程:**
10 | 1. 访问 12306 首页 (https://www.12306.cn/index/)
11 | 2. 从 HTML 中提取车站名称 JS 文件路径
12 | 3. 下载并解析 JS 文件,获取原始车站数据
13 | 4. 补充缺失的车站信息 (MISSING_STATIONS)
14 | 5. 基于车站数据得到四个核心数据结构映射表
15 |
16 | ```typescript
17 | // 1. 车站id -> 车站信息
18 | STATIONS: Record
19 |
20 | // "AAA": {
21 | // "station_id": "@aaa",
22 | // "station_name": "北京北",
23 | // "station_code": "AAA",
24 | // "station_pinyin": "beijingbei",
25 | // "station_short": "aaa",
26 | // "station_index": "0",
27 | // "code": "1234",
28 | // "city": "北京",
29 | // "r1": "",
30 | // "r2": ""
31 | // }
32 |
33 |
34 | // 2. 城市名 -> 车站id 和 站名 (可能一个城市多个站)
35 | CITY_STATIONS: Record
36 |
37 | // "北京": [{"station_code": "AAA","station_name": "北京北"},{"station_code": "BBB","station_name": "京东"},...]
38 |
39 | // 3. 车站名(与城市名相同,只会有一个) -> 车站id 和 站名
40 | CITY_CODES: Record
41 |
42 | // "北京":{"station_code":"CCC","station_name":"北京"}
43 |
44 | // 4. 车站名 -> 车站id 和 站名
45 | NAME_STATIONS: Record
46 |
47 | // "北京北":{"station_code":"AAA","station_name":"北京北"}
48 |
49 | ```
50 |
51 | ## 2. MCP tools
52 |
53 | ### 2.1 基础tool
54 |
55 | - **`get-current-date`**: 获取上海时区当前日期
56 | - 返回当前上海时区的时间日期字符串("yyyy-MM-dd")
57 | - 为其他工具提供准确的查询日期基准
58 |
59 | - **`get-stations-code-in-city`**: 查询城市内所有车站(使用 `CITY_STATIONS`)
60 | - 输入:中文城市名
61 | - 查找:`CITY_STATIONS[city]` 获取该城市所有车站列表
62 | - 返回:包含 `station_code` 和 `station_name` 的数组
63 |
64 | - **`get-station-code-of-citys`**: 获取城市代表车站id(使用 `CITY_CODES`)
65 | - 输入:城市名(支持 "|" 分隔的多个城市)
66 | - 查找:`CITY_CODES[city]` 获取与城市同名的主要车站
67 | - 返回:每个城市对应的代表车站信息
68 |
69 | - **`get-station-code-by-names`**: 车站名转车站id(使用 `NAME_STATIONS`)
70 | - 输入:具体车站名(支持 "|" 分隔的多个车站)
71 | - 查找:`NAME_STATIONS[stationName]` 精确匹配车站名
72 | - 返回:车站id和车站名
73 |
74 | - **`get-station-by-telecode`**: 车站id查车站信息(使用 `STATIONS`)
75 | - 输入:车站id
76 | - 查找:`STATIONS[telecode]` 获取完整车站信息
77 | - 返回:包含拼音、城市等详细信息
78 |
79 | ### 2.2 核心tool (输入可通过基础tool获取)
80 |
81 | - **`get-tickets`**: 查询12306余票信息
82 | - 输入:出发日期、出发站id、到达站id、车次类型筛选
83 | - 参数处理:检查日期不早于当前日期,验证车站id存在性, 构造请求入参
84 | - Cookie 处理:先获取 12306 Cookie 用于身份验证
85 | - API 调用:访问 `/otn/leftTicket/query` 接口
86 | - 数据处理, 车次类型筛选
87 | - 返回格式化数据
88 |
89 | - **`get-interline-tickets`**: 中转换乘查询,支持指定中转站
90 | - 输入:出发站id、到达站id、中转站id、是否显示无座、车次类型筛选
91 | - 参数处理:检查日期不早于当前日期,验证车站id存在性, 构造请求入参
92 | - Cookie 处理:先获取 12306 Cookie 用于身份验证
93 | - API 调用:访问 `/lcquery/queryU` 接口
94 | - 数据处理, 车次类型筛选
95 | - 返回格式化数据
96 |
97 | - **`get-train-route-stations`**: 列车经停站查询
98 | - 输入:车次编码(可以调用get-tickets获取)、出发站id、到达站id、出发日期
99 | - 参数处理:检查日期不早于当前日期,验证车站id存在性, 构造请求入参
100 | - Cookie 处理:先获取 12306 Cookie 用于身份验证
101 | - API 调用:访问 `/otn/czxx/queryByTrainNo` 接口
102 | - 返回格式化数据
103 |
104 | ## 3. 数据流程与工具关系
105 |
106 | ### 3.1 车票查询流程
107 |
108 | ```
109 | 用户查询 "后天北京到上海的高铁" - 大模型调用流程:
110 | ↓
111 | 1. get-current-date() → "2024-01-15" (获取当前日期)
112 | 2. 大模型理解后天日期 → "2024-01-17"
113 | 3. get-station-code-of-citys("北京|上海") → {"北京": {"station_code": "BJP","station_name": "北京"}, "上海": {"station_code": "SHH","station_name": "上海"}}
114 | ↓
115 | 4. get-tickets(date: "2024-01-17", fromStation: "BJP", toStation: "SHH", trainFilterFlags: "G")
116 | ↓
117 | 5. 内部数据处理(参数验证, Cookie获取, API调用, 格式化输出文本)
118 | ↓
119 | 6. 返回格式化的高铁车次信息(车次、时间、价格、余票等)
120 | ```
121 |
122 | ### 3.2 中转查询流程
123 |
124 | ```
125 | 用户查询 "深圳到拉萨,经过西安中转"
126 | ↓
127 | 1. 获取三个城市的车站id
128 | 2. get-interline-tickets(from: "SZQ", to: "LSO", transfer: "XAY")
129 | ↓
130 | 3. 内部数据处理(参数验证, Cookie获取, API调用, 格式化输出文本)
131 | ↓
132 | 4. 返回中转方案(第一程 + 第二程)
133 | ```
134 |
135 | ### 3.3 经停站查询流程
136 |
137 | ```
138 | 用户查询 "G1次列车经停哪些站"
139 | ↓
140 | 1. get-train-route-stations(trainNo: "G1", from: "BJP", to: "SHH")
141 | ↓
142 | 2. 数据处理:parseRouteStationsData() → parseRouteStationsInfo()
143 | ↓
144 | 3. 返回经停站列表(站名、到达时间、出发时间、停留时间)
145 | ```
146 |
147 | ### 3.4 工具依赖关系
148 |
149 | ```
150 | 基础工具层:
151 | ├── get-current-date
152 | ├── get-stations-code-in-city
153 | └── get-station-code-of-citys
154 | └── get-station-code-by-names
155 | └── get-station-by-telecode
156 |
157 | ↓ 为核心工具层提供基础数据
158 |
159 | 核心工具层:
160 | ├── get-tickets (依赖车站id)
161 | ├── get-interline-tickets (依赖车站id)
162 | └── get-train-route-stations (依赖车站id和车次号)
163 | ```
164 |
165 |
--------------------------------------------------------------------------------
/glama.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://glama.ai/mcp/schemas/server.json",
3 | "maintainers": [
4 | "Joooook"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "12306-mcp",
3 | "version": "0.3.1",
4 | "lockfileVersion": 3,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "12306-mcp",
9 | "version": "0.3.1",
10 | "license": "MIT",
11 | "dependencies": {
12 | "@modelcontextprotocol/sdk": "^1.9.0",
13 | "axios": "^1.8.4",
14 | "date-fns": "^4.1.0",
15 | "date-fns-tz": "^3.2.0",
16 | "zod": "^3.24.2"
17 | },
18 | "bin": {
19 | "12306-mcp": "build/index.js"
20 | },
21 | "devDependencies": {
22 | "@types/node": "^22.14.1",
23 | "typescript": "^5.8.3"
24 | }
25 | },
26 | "node_modules/@modelcontextprotocol/sdk": {
27 | "version": "1.9.0",
28 | "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.9.0.tgz",
29 | "integrity": "sha512-Jq2EUCQpe0iyO5FGpzVYDNFR6oR53AIrwph9yWl7uSc7IWUMsrmpmSaTGra5hQNunXpM+9oit85p924jWuHzUA==",
30 | "dependencies": {
31 | "content-type": "^1.0.5",
32 | "cors": "^2.8.5",
33 | "cross-spawn": "^7.0.3",
34 | "eventsource": "^3.0.2",
35 | "express": "^5.0.1",
36 | "express-rate-limit": "^7.5.0",
37 | "pkce-challenge": "^5.0.0",
38 | "raw-body": "^3.0.0",
39 | "zod": "^3.23.8",
40 | "zod-to-json-schema": "^3.24.1"
41 | },
42 | "engines": {
43 | "node": ">=18"
44 | }
45 | },
46 | "node_modules/@types/node": {
47 | "version": "22.14.1",
48 | "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
49 | "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
50 | "dev": true,
51 | "dependencies": {
52 | "undici-types": "~6.21.0"
53 | }
54 | },
55 | "node_modules/accepts": {
56 | "version": "2.0.0",
57 | "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
58 | "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
59 | "dependencies": {
60 | "mime-types": "^3.0.0",
61 | "negotiator": "^1.0.0"
62 | },
63 | "engines": {
64 | "node": ">= 0.6"
65 | }
66 | },
67 | "node_modules/asynckit": {
68 | "version": "0.4.0",
69 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
70 | "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
71 | },
72 | "node_modules/axios": {
73 | "version": "1.8.4",
74 | "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
75 | "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
76 | "dependencies": {
77 | "follow-redirects": "^1.15.6",
78 | "form-data": "^4.0.0",
79 | "proxy-from-env": "^1.1.0"
80 | }
81 | },
82 | "node_modules/body-parser": {
83 | "version": "2.2.0",
84 | "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
85 | "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
86 | "dependencies": {
87 | "bytes": "^3.1.2",
88 | "content-type": "^1.0.5",
89 | "debug": "^4.4.0",
90 | "http-errors": "^2.0.0",
91 | "iconv-lite": "^0.6.3",
92 | "on-finished": "^2.4.1",
93 | "qs": "^6.14.0",
94 | "raw-body": "^3.0.0",
95 | "type-is": "^2.0.0"
96 | },
97 | "engines": {
98 | "node": ">=18"
99 | }
100 | },
101 | "node_modules/bytes": {
102 | "version": "3.1.2",
103 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
104 | "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
105 | "engines": {
106 | "node": ">= 0.8"
107 | }
108 | },
109 | "node_modules/call-bind-apply-helpers": {
110 | "version": "1.0.2",
111 | "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
112 | "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
113 | "dependencies": {
114 | "es-errors": "^1.3.0",
115 | "function-bind": "^1.1.2"
116 | },
117 | "engines": {
118 | "node": ">= 0.4"
119 | }
120 | },
121 | "node_modules/call-bound": {
122 | "version": "1.0.4",
123 | "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
124 | "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
125 | "dependencies": {
126 | "call-bind-apply-helpers": "^1.0.2",
127 | "get-intrinsic": "^1.3.0"
128 | },
129 | "engines": {
130 | "node": ">= 0.4"
131 | },
132 | "funding": {
133 | "url": "https://github.com/sponsors/ljharb"
134 | }
135 | },
136 | "node_modules/combined-stream": {
137 | "version": "1.0.8",
138 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
139 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
140 | "dependencies": {
141 | "delayed-stream": "~1.0.0"
142 | },
143 | "engines": {
144 | "node": ">= 0.8"
145 | }
146 | },
147 | "node_modules/content-disposition": {
148 | "version": "1.0.0",
149 | "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
150 | "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
151 | "dependencies": {
152 | "safe-buffer": "5.2.1"
153 | },
154 | "engines": {
155 | "node": ">= 0.6"
156 | }
157 | },
158 | "node_modules/content-type": {
159 | "version": "1.0.5",
160 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
161 | "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
162 | "engines": {
163 | "node": ">= 0.6"
164 | }
165 | },
166 | "node_modules/cookie": {
167 | "version": "0.7.2",
168 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
169 | "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
170 | "engines": {
171 | "node": ">= 0.6"
172 | }
173 | },
174 | "node_modules/cookie-signature": {
175 | "version": "1.2.2",
176 | "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
177 | "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
178 | "engines": {
179 | "node": ">=6.6.0"
180 | }
181 | },
182 | "node_modules/cors": {
183 | "version": "2.8.5",
184 | "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
185 | "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
186 | "dependencies": {
187 | "object-assign": "^4",
188 | "vary": "^1"
189 | },
190 | "engines": {
191 | "node": ">= 0.10"
192 | }
193 | },
194 | "node_modules/cross-spawn": {
195 | "version": "7.0.6",
196 | "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
197 | "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
198 | "dependencies": {
199 | "path-key": "^3.1.0",
200 | "shebang-command": "^2.0.0",
201 | "which": "^2.0.1"
202 | },
203 | "engines": {
204 | "node": ">= 8"
205 | }
206 | },
207 | "node_modules/date-fns": {
208 | "version": "4.1.0",
209 | "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
210 | "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
211 | "license": "MIT",
212 | "funding": {
213 | "type": "github",
214 | "url": "https://github.com/sponsors/kossnocorp"
215 | }
216 | },
217 | "node_modules/date-fns-tz": {
218 | "version": "3.2.0",
219 | "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
220 | "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
221 | "license": "MIT",
222 | "peerDependencies": {
223 | "date-fns": "^3.0.0 || ^4.0.0"
224 | }
225 | },
226 | "node_modules/debug": {
227 | "version": "4.4.0",
228 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
229 | "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
230 | "dependencies": {
231 | "ms": "^2.1.3"
232 | },
233 | "engines": {
234 | "node": ">=6.0"
235 | },
236 | "peerDependenciesMeta": {
237 | "supports-color": {
238 | "optional": true
239 | }
240 | }
241 | },
242 | "node_modules/delayed-stream": {
243 | "version": "1.0.0",
244 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
245 | "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
246 | "engines": {
247 | "node": ">=0.4.0"
248 | }
249 | },
250 | "node_modules/depd": {
251 | "version": "2.0.0",
252 | "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
253 | "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
254 | "engines": {
255 | "node": ">= 0.8"
256 | }
257 | },
258 | "node_modules/dunder-proto": {
259 | "version": "1.0.1",
260 | "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
261 | "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
262 | "dependencies": {
263 | "call-bind-apply-helpers": "^1.0.1",
264 | "es-errors": "^1.3.0",
265 | "gopd": "^1.2.0"
266 | },
267 | "engines": {
268 | "node": ">= 0.4"
269 | }
270 | },
271 | "node_modules/ee-first": {
272 | "version": "1.1.1",
273 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
274 | "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
275 | },
276 | "node_modules/encodeurl": {
277 | "version": "2.0.0",
278 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
279 | "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
280 | "engines": {
281 | "node": ">= 0.8"
282 | }
283 | },
284 | "node_modules/es-define-property": {
285 | "version": "1.0.1",
286 | "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
287 | "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
288 | "engines": {
289 | "node": ">= 0.4"
290 | }
291 | },
292 | "node_modules/es-errors": {
293 | "version": "1.3.0",
294 | "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
295 | "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
296 | "engines": {
297 | "node": ">= 0.4"
298 | }
299 | },
300 | "node_modules/es-object-atoms": {
301 | "version": "1.1.1",
302 | "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
303 | "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
304 | "dependencies": {
305 | "es-errors": "^1.3.0"
306 | },
307 | "engines": {
308 | "node": ">= 0.4"
309 | }
310 | },
311 | "node_modules/es-set-tostringtag": {
312 | "version": "2.1.0",
313 | "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
314 | "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
315 | "dependencies": {
316 | "es-errors": "^1.3.0",
317 | "get-intrinsic": "^1.2.6",
318 | "has-tostringtag": "^1.0.2",
319 | "hasown": "^2.0.2"
320 | },
321 | "engines": {
322 | "node": ">= 0.4"
323 | }
324 | },
325 | "node_modules/escape-html": {
326 | "version": "1.0.3",
327 | "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
328 | "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
329 | },
330 | "node_modules/etag": {
331 | "version": "1.8.1",
332 | "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
333 | "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
334 | "engines": {
335 | "node": ">= 0.6"
336 | }
337 | },
338 | "node_modules/eventsource": {
339 | "version": "3.0.6",
340 | "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz",
341 | "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==",
342 | "dependencies": {
343 | "eventsource-parser": "^3.0.1"
344 | },
345 | "engines": {
346 | "node": ">=18.0.0"
347 | }
348 | },
349 | "node_modules/eventsource-parser": {
350 | "version": "3.0.1",
351 | "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz",
352 | "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==",
353 | "engines": {
354 | "node": ">=18.0.0"
355 | }
356 | },
357 | "node_modules/express": {
358 | "version": "5.1.0",
359 | "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
360 | "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
361 | "dependencies": {
362 | "accepts": "^2.0.0",
363 | "body-parser": "^2.2.0",
364 | "content-disposition": "^1.0.0",
365 | "content-type": "^1.0.5",
366 | "cookie": "^0.7.1",
367 | "cookie-signature": "^1.2.1",
368 | "debug": "^4.4.0",
369 | "encodeurl": "^2.0.0",
370 | "escape-html": "^1.0.3",
371 | "etag": "^1.8.1",
372 | "finalhandler": "^2.1.0",
373 | "fresh": "^2.0.0",
374 | "http-errors": "^2.0.0",
375 | "merge-descriptors": "^2.0.0",
376 | "mime-types": "^3.0.0",
377 | "on-finished": "^2.4.1",
378 | "once": "^1.4.0",
379 | "parseurl": "^1.3.3",
380 | "proxy-addr": "^2.0.7",
381 | "qs": "^6.14.0",
382 | "range-parser": "^1.2.1",
383 | "router": "^2.2.0",
384 | "send": "^1.1.0",
385 | "serve-static": "^2.2.0",
386 | "statuses": "^2.0.1",
387 | "type-is": "^2.0.1",
388 | "vary": "^1.1.2"
389 | },
390 | "engines": {
391 | "node": ">= 18"
392 | },
393 | "funding": {
394 | "type": "opencollective",
395 | "url": "https://opencollective.com/express"
396 | }
397 | },
398 | "node_modules/express-rate-limit": {
399 | "version": "7.5.0",
400 | "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz",
401 | "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==",
402 | "engines": {
403 | "node": ">= 16"
404 | },
405 | "funding": {
406 | "url": "https://github.com/sponsors/express-rate-limit"
407 | },
408 | "peerDependencies": {
409 | "express": "^4.11 || 5 || ^5.0.0-beta.1"
410 | }
411 | },
412 | "node_modules/finalhandler": {
413 | "version": "2.1.0",
414 | "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
415 | "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
416 | "dependencies": {
417 | "debug": "^4.4.0",
418 | "encodeurl": "^2.0.0",
419 | "escape-html": "^1.0.3",
420 | "on-finished": "^2.4.1",
421 | "parseurl": "^1.3.3",
422 | "statuses": "^2.0.1"
423 | },
424 | "engines": {
425 | "node": ">= 0.8"
426 | }
427 | },
428 | "node_modules/follow-redirects": {
429 | "version": "1.15.9",
430 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
431 | "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
432 | "funding": [
433 | {
434 | "type": "individual",
435 | "url": "https://github.com/sponsors/RubenVerborgh"
436 | }
437 | ],
438 | "engines": {
439 | "node": ">=4.0"
440 | },
441 | "peerDependenciesMeta": {
442 | "debug": {
443 | "optional": true
444 | }
445 | }
446 | },
447 | "node_modules/form-data": {
448 | "version": "4.0.2",
449 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
450 | "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
451 | "dependencies": {
452 | "asynckit": "^0.4.0",
453 | "combined-stream": "^1.0.8",
454 | "es-set-tostringtag": "^2.1.0",
455 | "mime-types": "^2.1.12"
456 | },
457 | "engines": {
458 | "node": ">= 6"
459 | }
460 | },
461 | "node_modules/form-data/node_modules/mime-db": {
462 | "version": "1.52.0",
463 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
464 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
465 | "engines": {
466 | "node": ">= 0.6"
467 | }
468 | },
469 | "node_modules/form-data/node_modules/mime-types": {
470 | "version": "2.1.35",
471 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
472 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
473 | "dependencies": {
474 | "mime-db": "1.52.0"
475 | },
476 | "engines": {
477 | "node": ">= 0.6"
478 | }
479 | },
480 | "node_modules/forwarded": {
481 | "version": "0.2.0",
482 | "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
483 | "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
484 | "engines": {
485 | "node": ">= 0.6"
486 | }
487 | },
488 | "node_modules/fresh": {
489 | "version": "2.0.0",
490 | "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
491 | "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
492 | "engines": {
493 | "node": ">= 0.8"
494 | }
495 | },
496 | "node_modules/function-bind": {
497 | "version": "1.1.2",
498 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
499 | "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
500 | "funding": {
501 | "url": "https://github.com/sponsors/ljharb"
502 | }
503 | },
504 | "node_modules/get-intrinsic": {
505 | "version": "1.3.0",
506 | "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
507 | "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
508 | "dependencies": {
509 | "call-bind-apply-helpers": "^1.0.2",
510 | "es-define-property": "^1.0.1",
511 | "es-errors": "^1.3.0",
512 | "es-object-atoms": "^1.1.1",
513 | "function-bind": "^1.1.2",
514 | "get-proto": "^1.0.1",
515 | "gopd": "^1.2.0",
516 | "has-symbols": "^1.1.0",
517 | "hasown": "^2.0.2",
518 | "math-intrinsics": "^1.1.0"
519 | },
520 | "engines": {
521 | "node": ">= 0.4"
522 | },
523 | "funding": {
524 | "url": "https://github.com/sponsors/ljharb"
525 | }
526 | },
527 | "node_modules/get-proto": {
528 | "version": "1.0.1",
529 | "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
530 | "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
531 | "dependencies": {
532 | "dunder-proto": "^1.0.1",
533 | "es-object-atoms": "^1.0.0"
534 | },
535 | "engines": {
536 | "node": ">= 0.4"
537 | }
538 | },
539 | "node_modules/gopd": {
540 | "version": "1.2.0",
541 | "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
542 | "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
543 | "engines": {
544 | "node": ">= 0.4"
545 | },
546 | "funding": {
547 | "url": "https://github.com/sponsors/ljharb"
548 | }
549 | },
550 | "node_modules/has-symbols": {
551 | "version": "1.1.0",
552 | "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
553 | "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
554 | "engines": {
555 | "node": ">= 0.4"
556 | },
557 | "funding": {
558 | "url": "https://github.com/sponsors/ljharb"
559 | }
560 | },
561 | "node_modules/has-tostringtag": {
562 | "version": "1.0.2",
563 | "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
564 | "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
565 | "dependencies": {
566 | "has-symbols": "^1.0.3"
567 | },
568 | "engines": {
569 | "node": ">= 0.4"
570 | },
571 | "funding": {
572 | "url": "https://github.com/sponsors/ljharb"
573 | }
574 | },
575 | "node_modules/hasown": {
576 | "version": "2.0.2",
577 | "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
578 | "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
579 | "dependencies": {
580 | "function-bind": "^1.1.2"
581 | },
582 | "engines": {
583 | "node": ">= 0.4"
584 | }
585 | },
586 | "node_modules/http-errors": {
587 | "version": "2.0.0",
588 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
589 | "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
590 | "dependencies": {
591 | "depd": "2.0.0",
592 | "inherits": "2.0.4",
593 | "setprototypeof": "1.2.0",
594 | "statuses": "2.0.1",
595 | "toidentifier": "1.0.1"
596 | },
597 | "engines": {
598 | "node": ">= 0.8"
599 | }
600 | },
601 | "node_modules/iconv-lite": {
602 | "version": "0.6.3",
603 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
604 | "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
605 | "dependencies": {
606 | "safer-buffer": ">= 2.1.2 < 3.0.0"
607 | },
608 | "engines": {
609 | "node": ">=0.10.0"
610 | }
611 | },
612 | "node_modules/inherits": {
613 | "version": "2.0.4",
614 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
615 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
616 | },
617 | "node_modules/ipaddr.js": {
618 | "version": "1.9.1",
619 | "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
620 | "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
621 | "engines": {
622 | "node": ">= 0.10"
623 | }
624 | },
625 | "node_modules/is-promise": {
626 | "version": "4.0.0",
627 | "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
628 | "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="
629 | },
630 | "node_modules/isexe": {
631 | "version": "2.0.0",
632 | "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
633 | "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
634 | },
635 | "node_modules/math-intrinsics": {
636 | "version": "1.1.0",
637 | "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
638 | "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
639 | "engines": {
640 | "node": ">= 0.4"
641 | }
642 | },
643 | "node_modules/media-typer": {
644 | "version": "1.1.0",
645 | "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
646 | "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
647 | "engines": {
648 | "node": ">= 0.8"
649 | }
650 | },
651 | "node_modules/merge-descriptors": {
652 | "version": "2.0.0",
653 | "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
654 | "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
655 | "engines": {
656 | "node": ">=18"
657 | },
658 | "funding": {
659 | "url": "https://github.com/sponsors/sindresorhus"
660 | }
661 | },
662 | "node_modules/mime-db": {
663 | "version": "1.54.0",
664 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
665 | "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
666 | "engines": {
667 | "node": ">= 0.6"
668 | }
669 | },
670 | "node_modules/mime-types": {
671 | "version": "3.0.1",
672 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
673 | "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
674 | "dependencies": {
675 | "mime-db": "^1.54.0"
676 | },
677 | "engines": {
678 | "node": ">= 0.6"
679 | }
680 | },
681 | "node_modules/ms": {
682 | "version": "2.1.3",
683 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
684 | "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
685 | },
686 | "node_modules/negotiator": {
687 | "version": "1.0.0",
688 | "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
689 | "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
690 | "engines": {
691 | "node": ">= 0.6"
692 | }
693 | },
694 | "node_modules/object-assign": {
695 | "version": "4.1.1",
696 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
697 | "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
698 | "engines": {
699 | "node": ">=0.10.0"
700 | }
701 | },
702 | "node_modules/object-inspect": {
703 | "version": "1.13.4",
704 | "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
705 | "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
706 | "engines": {
707 | "node": ">= 0.4"
708 | },
709 | "funding": {
710 | "url": "https://github.com/sponsors/ljharb"
711 | }
712 | },
713 | "node_modules/on-finished": {
714 | "version": "2.4.1",
715 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
716 | "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
717 | "dependencies": {
718 | "ee-first": "1.1.1"
719 | },
720 | "engines": {
721 | "node": ">= 0.8"
722 | }
723 | },
724 | "node_modules/once": {
725 | "version": "1.4.0",
726 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
727 | "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
728 | "dependencies": {
729 | "wrappy": "1"
730 | }
731 | },
732 | "node_modules/parseurl": {
733 | "version": "1.3.3",
734 | "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
735 | "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
736 | "engines": {
737 | "node": ">= 0.8"
738 | }
739 | },
740 | "node_modules/path-key": {
741 | "version": "3.1.1",
742 | "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
743 | "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
744 | "engines": {
745 | "node": ">=8"
746 | }
747 | },
748 | "node_modules/path-to-regexp": {
749 | "version": "8.2.0",
750 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
751 | "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
752 | "engines": {
753 | "node": ">=16"
754 | }
755 | },
756 | "node_modules/pkce-challenge": {
757 | "version": "5.0.0",
758 | "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz",
759 | "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==",
760 | "engines": {
761 | "node": ">=16.20.0"
762 | }
763 | },
764 | "node_modules/proxy-addr": {
765 | "version": "2.0.7",
766 | "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
767 | "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
768 | "dependencies": {
769 | "forwarded": "0.2.0",
770 | "ipaddr.js": "1.9.1"
771 | },
772 | "engines": {
773 | "node": ">= 0.10"
774 | }
775 | },
776 | "node_modules/proxy-from-env": {
777 | "version": "1.1.0",
778 | "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
779 | "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
780 | },
781 | "node_modules/qs": {
782 | "version": "6.14.0",
783 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
784 | "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
785 | "dependencies": {
786 | "side-channel": "^1.1.0"
787 | },
788 | "engines": {
789 | "node": ">=0.6"
790 | },
791 | "funding": {
792 | "url": "https://github.com/sponsors/ljharb"
793 | }
794 | },
795 | "node_modules/range-parser": {
796 | "version": "1.2.1",
797 | "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
798 | "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
799 | "engines": {
800 | "node": ">= 0.6"
801 | }
802 | },
803 | "node_modules/raw-body": {
804 | "version": "3.0.0",
805 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
806 | "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
807 | "dependencies": {
808 | "bytes": "3.1.2",
809 | "http-errors": "2.0.0",
810 | "iconv-lite": "0.6.3",
811 | "unpipe": "1.0.0"
812 | },
813 | "engines": {
814 | "node": ">= 0.8"
815 | }
816 | },
817 | "node_modules/router": {
818 | "version": "2.2.0",
819 | "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
820 | "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
821 | "dependencies": {
822 | "debug": "^4.4.0",
823 | "depd": "^2.0.0",
824 | "is-promise": "^4.0.0",
825 | "parseurl": "^1.3.3",
826 | "path-to-regexp": "^8.0.0"
827 | },
828 | "engines": {
829 | "node": ">= 18"
830 | }
831 | },
832 | "node_modules/safe-buffer": {
833 | "version": "5.2.1",
834 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
835 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
836 | "funding": [
837 | {
838 | "type": "github",
839 | "url": "https://github.com/sponsors/feross"
840 | },
841 | {
842 | "type": "patreon",
843 | "url": "https://www.patreon.com/feross"
844 | },
845 | {
846 | "type": "consulting",
847 | "url": "https://feross.org/support"
848 | }
849 | ]
850 | },
851 | "node_modules/safer-buffer": {
852 | "version": "2.1.2",
853 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
854 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
855 | },
856 | "node_modules/send": {
857 | "version": "1.2.0",
858 | "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
859 | "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
860 | "dependencies": {
861 | "debug": "^4.3.5",
862 | "encodeurl": "^2.0.0",
863 | "escape-html": "^1.0.3",
864 | "etag": "^1.8.1",
865 | "fresh": "^2.0.0",
866 | "http-errors": "^2.0.0",
867 | "mime-types": "^3.0.1",
868 | "ms": "^2.1.3",
869 | "on-finished": "^2.4.1",
870 | "range-parser": "^1.2.1",
871 | "statuses": "^2.0.1"
872 | },
873 | "engines": {
874 | "node": ">= 18"
875 | }
876 | },
877 | "node_modules/serve-static": {
878 | "version": "2.2.0",
879 | "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
880 | "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
881 | "dependencies": {
882 | "encodeurl": "^2.0.0",
883 | "escape-html": "^1.0.3",
884 | "parseurl": "^1.3.3",
885 | "send": "^1.2.0"
886 | },
887 | "engines": {
888 | "node": ">= 18"
889 | }
890 | },
891 | "node_modules/setprototypeof": {
892 | "version": "1.2.0",
893 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
894 | "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
895 | },
896 | "node_modules/shebang-command": {
897 | "version": "2.0.0",
898 | "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
899 | "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
900 | "dependencies": {
901 | "shebang-regex": "^3.0.0"
902 | },
903 | "engines": {
904 | "node": ">=8"
905 | }
906 | },
907 | "node_modules/shebang-regex": {
908 | "version": "3.0.0",
909 | "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
910 | "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
911 | "engines": {
912 | "node": ">=8"
913 | }
914 | },
915 | "node_modules/side-channel": {
916 | "version": "1.1.0",
917 | "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
918 | "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
919 | "dependencies": {
920 | "es-errors": "^1.3.0",
921 | "object-inspect": "^1.13.3",
922 | "side-channel-list": "^1.0.0",
923 | "side-channel-map": "^1.0.1",
924 | "side-channel-weakmap": "^1.0.2"
925 | },
926 | "engines": {
927 | "node": ">= 0.4"
928 | },
929 | "funding": {
930 | "url": "https://github.com/sponsors/ljharb"
931 | }
932 | },
933 | "node_modules/side-channel-list": {
934 | "version": "1.0.0",
935 | "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
936 | "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
937 | "dependencies": {
938 | "es-errors": "^1.3.0",
939 | "object-inspect": "^1.13.3"
940 | },
941 | "engines": {
942 | "node": ">= 0.4"
943 | },
944 | "funding": {
945 | "url": "https://github.com/sponsors/ljharb"
946 | }
947 | },
948 | "node_modules/side-channel-map": {
949 | "version": "1.0.1",
950 | "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
951 | "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
952 | "dependencies": {
953 | "call-bound": "^1.0.2",
954 | "es-errors": "^1.3.0",
955 | "get-intrinsic": "^1.2.5",
956 | "object-inspect": "^1.13.3"
957 | },
958 | "engines": {
959 | "node": ">= 0.4"
960 | },
961 | "funding": {
962 | "url": "https://github.com/sponsors/ljharb"
963 | }
964 | },
965 | "node_modules/side-channel-weakmap": {
966 | "version": "1.0.2",
967 | "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
968 | "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
969 | "dependencies": {
970 | "call-bound": "^1.0.2",
971 | "es-errors": "^1.3.0",
972 | "get-intrinsic": "^1.2.5",
973 | "object-inspect": "^1.13.3",
974 | "side-channel-map": "^1.0.1"
975 | },
976 | "engines": {
977 | "node": ">= 0.4"
978 | },
979 | "funding": {
980 | "url": "https://github.com/sponsors/ljharb"
981 | }
982 | },
983 | "node_modules/statuses": {
984 | "version": "2.0.1",
985 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
986 | "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
987 | "engines": {
988 | "node": ">= 0.8"
989 | }
990 | },
991 | "node_modules/toidentifier": {
992 | "version": "1.0.1",
993 | "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
994 | "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
995 | "engines": {
996 | "node": ">=0.6"
997 | }
998 | },
999 | "node_modules/type-is": {
1000 | "version": "2.0.1",
1001 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
1002 | "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
1003 | "dependencies": {
1004 | "content-type": "^1.0.5",
1005 | "media-typer": "^1.1.0",
1006 | "mime-types": "^3.0.0"
1007 | },
1008 | "engines": {
1009 | "node": ">= 0.6"
1010 | }
1011 | },
1012 | "node_modules/typescript": {
1013 | "version": "5.8.3",
1014 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
1015 | "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
1016 | "dev": true,
1017 | "bin": {
1018 | "tsc": "bin/tsc",
1019 | "tsserver": "bin/tsserver"
1020 | },
1021 | "engines": {
1022 | "node": ">=14.17"
1023 | }
1024 | },
1025 | "node_modules/undici-types": {
1026 | "version": "6.21.0",
1027 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
1028 | "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
1029 | "dev": true
1030 | },
1031 | "node_modules/unpipe": {
1032 | "version": "1.0.0",
1033 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
1034 | "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
1035 | "engines": {
1036 | "node": ">= 0.8"
1037 | }
1038 | },
1039 | "node_modules/vary": {
1040 | "version": "1.1.2",
1041 | "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
1042 | "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
1043 | "engines": {
1044 | "node": ">= 0.8"
1045 | }
1046 | },
1047 | "node_modules/which": {
1048 | "version": "2.0.2",
1049 | "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
1050 | "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
1051 | "dependencies": {
1052 | "isexe": "^2.0.0"
1053 | },
1054 | "bin": {
1055 | "node-which": "bin/node-which"
1056 | },
1057 | "engines": {
1058 | "node": ">= 8"
1059 | }
1060 | },
1061 | "node_modules/wrappy": {
1062 | "version": "1.0.2",
1063 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
1064 | "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
1065 | },
1066 | "node_modules/zod": {
1067 | "version": "3.24.2",
1068 | "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
1069 | "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
1070 | "funding": {
1071 | "url": "https://github.com/sponsors/colinhacks"
1072 | }
1073 | },
1074 | "node_modules/zod-to-json-schema": {
1075 | "version": "3.24.5",
1076 | "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz",
1077 | "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==",
1078 | "peerDependencies": {
1079 | "zod": "^3.24.1"
1080 | }
1081 | }
1082 | }
1083 | }
1084 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "12306-mcp",
3 | "version": "0.3.2",
4 | "main": "build/index.js",
5 | "scripts": {
6 | "build": "tsc",
7 | "test": "tsc && node ./build/index.js",
8 | "debug": "tsc && npx @modelcontextprotocol/inspector node ./build/index.js"
9 | },
10 | "keywords": [
11 | "mcp",
12 | "12306",
13 | "mcp-server"
14 | ],
15 | "author": "joooook",
16 | "license": "MIT",
17 | "description": "This is a 12306 ticket search server based on the Model Context Protocol (MCP). ",
18 | "dependencies": {
19 | "@modelcontextprotocol/sdk": "^1.9.0",
20 | "axios": "^1.8.4",
21 | "date-fns": "^4.1.0",
22 | "date-fns-tz": "^3.2.0",
23 | "zod": "^3.24.2"
24 | },
25 | "devDependencies": {
26 | "@types/node": "^22.14.1",
27 | "typescript": "^5.8.3"
28 | },
29 | "type": "module",
30 | "bin": {
31 | "12306-mcp": "./build/index.js"
32 | },
33 | "files": [
34 | "build"
35 | ]
36 | }
37 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | // Data一般用于表示从服务器上请求到的数据,Info一般表示解析并筛选过的要传输给大模型的数据。变量使用驼峰命名,常量使用全大写下划线命名。
4 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6 | import axios from 'axios';
7 | import { z } from 'zod';
8 | import { format, parse } from 'date-fns';
9 | import { toZonedTime } from 'date-fns-tz';
10 | import {
11 | InterlineData,
12 | InterlineInfo,
13 | InterlineTicketData,
14 | Price,
15 | RouteStationData,
16 | RouteStationInfo,
17 | StationData,
18 | StationDataKeys,
19 | TicketData,
20 | TicketDataKeys,
21 | TicketInfo,
22 | } from './types.js';
23 |
24 | const VERSION = "0.3.2"
25 | const API_BASE = 'https://kyfw.12306.cn';
26 | const WEB_URL = 'https://www.12306.cn/index/';
27 | const LCQUERY_INIT_URL = "https://kyfw.12306.cn/otn/lcQuery/init"
28 | const LCQUERY_PATH = await getLCQueryPath();
29 | const MISSING_STATIONS: StationData[] = [
30 | {
31 | station_id: '@cdd',
32 | station_name: '成 都东',
33 | station_code: 'WEI',
34 | station_pinyin: 'chengdudong',
35 | station_short: 'cdd',
36 | station_index: '',
37 | code: '1707',
38 | city: '成都',
39 | r1: '',
40 | r2: '',
41 | },
42 | ];
43 | const STATIONS: Record = await getStations(); //以Code为键
44 | const CITY_STATIONS: Record<
45 | string,
46 | { station_code: string; station_name: string }[]
47 | > = (() => {
48 | const result: Record<
49 | string,
50 | { station_code: string; station_name: string }[]
51 | > = {};
52 | for (const station of Object.values(STATIONS)) {
53 | const city = station.city;
54 | if (!result[city]) {
55 | result[city] = [];
56 | }
57 | result[city].push({
58 | station_code: station.station_code,
59 | station_name: station.station_name,
60 | });
61 | }
62 | return result;
63 | })(); //以城市名名为键,位于该城市的的所有Station列表的记录
64 |
65 | const CITY_CODES: Record<
66 | string,
67 | { station_code: string; station_name: string }
68 | > = (() => {
69 | const result: Record =
70 | {};
71 | for (const [city, stations] of Object.entries(CITY_STATIONS)) {
72 | for (const station of stations) {
73 | if (station.station_name == city) {
74 | result[city] = station;
75 | break;
76 | }
77 | }
78 | }
79 | return result;
80 | })(); //以城市名名为键的Station记录
81 |
82 | const NAME_STATIONS: Record<
83 | string,
84 | { station_code: string; station_name: string }
85 | > = (() => {
86 | const result: Record =
87 | {};
88 | for (const station of Object.values(STATIONS)) {
89 | const station_name = station.station_name;
90 | result[station_name] = {
91 | station_code: station.station_code,
92 | station_name: station.station_name,
93 | };
94 | }
95 | return result;
96 | })(); //以车站名为键的Station记录
97 |
98 | const SEAT_SHORT_TYPES = {
99 | swz: '商务座',
100 | tz: '特等座',
101 | zy: '一等座',
102 | ze: '二等座',
103 | gr: '高软卧',
104 | srrb: '动卧',
105 | rw: '软卧',
106 | yw: '硬卧',
107 | rz: '软座',
108 | yz: '硬座',
109 | wz: '无座',
110 | qt: '其他',
111 | gg: '',
112 | yb: '',
113 | };
114 |
115 | const SEAT_TYPES = {
116 | '9': { name: '商务座', short: 'swz' },
117 | P: { name: '特等座', short: 'tz' },
118 | M: { name: '一等座', short: 'zy' },
119 | D: { name: '优选一等座', short: 'zy' },
120 | O: { name: '二等座', short: 'ze' },
121 | S: { name: '二等包座', short: 'ze' },
122 | '6': { name: '高级软卧', short: 'gr' },
123 | A: { name: '高级动卧', short: 'gr' },
124 | '4': { name: '软卧', short: 'rw' },
125 | I: { name: '一等卧', short: 'rw' },
126 | F: { name: '动卧', short: 'rw' },
127 | '3': { name: '硬卧', short: 'yw' },
128 | J: { name: '二等卧', short: 'yw' },
129 | '2': { name: '软座', short: 'rz' },
130 | '1': { name: '硬座', short: 'yz' },
131 | W: { name: '无座', short: 'wz' },
132 | WZ: { name: '无座', short: 'wz' },
133 | H: { name: '其他', short: 'qt' },
134 | };
135 |
136 | const DW_FLAGS = [
137 | '智能动车组',
138 | '复兴号',
139 | '静音车厢',
140 | '温馨动卧',
141 | '动感号',
142 | '支持选铺',
143 | '老年优惠',
144 | ];
145 |
146 | const TRAIN_FILTERS = {
147 | //G(高铁/城际),D(动车),Z(直达特快),T(特快),K(快速),O(其他),F(复兴号),S(智能动车组)
148 | G: (ticketInfo: TicketInfo | InterlineInfo) => {
149 | return ticketInfo.start_train_code.startsWith('G') ||
150 | ticketInfo.start_train_code.startsWith('C')
151 | ? true
152 | : false;
153 | },
154 | D: (ticketInfo: TicketInfo | InterlineInfo) => {
155 | return ticketInfo.start_train_code.startsWith('D') ? true : false;
156 | },
157 | Z: (ticketInfo: TicketInfo | InterlineInfo) => {
158 | return ticketInfo.start_train_code.startsWith('Z') ? true : false;
159 | },
160 | T: (ticketInfo: TicketInfo | InterlineInfo) => {
161 | return ticketInfo.start_train_code.startsWith('T') ? true : false;
162 | },
163 | K: (ticketInfo: TicketInfo | InterlineInfo) => {
164 | return ticketInfo.start_train_code.startsWith('K') ? true : false;
165 | },
166 | O: (ticketInfo: TicketInfo | InterlineInfo) => {
167 | return TRAIN_FILTERS.G(ticketInfo) ||
168 | TRAIN_FILTERS.D(ticketInfo) ||
169 | TRAIN_FILTERS.Z(ticketInfo) ||
170 | TRAIN_FILTERS.T(ticketInfo) ||
171 | TRAIN_FILTERS.K(ticketInfo)
172 | ? false
173 | : true;
174 | },
175 | F: (ticketInfo: TicketInfo | InterlineInfo) => {
176 | if ('dw_flag' in ticketInfo) {
177 | return ticketInfo.dw_flag.includes('复兴号') ? true : false;
178 | }
179 | return ticketInfo.ticketList[0].dw_flag.includes('复兴号') ? true : false;
180 | },
181 | S: (ticketInfo: TicketInfo | InterlineInfo) => {
182 | if ('dw_flag' in ticketInfo) {
183 | return ticketInfo.dw_flag.includes('智能动车组') ? true : false;
184 | }
185 | return ticketInfo.ticketList[0].dw_flag.includes('智能动车组')
186 | ? true
187 | : false;
188 | },
189 | };
190 |
191 | const TIME_COMPARETOR = {
192 | startTime: (ticketInfoA: TicketInfo | InterlineInfo, ticketInfoB: TicketInfo | InterlineInfo) => {
193 | const timeA = new Date(ticketInfoA.start_date);
194 | const timeB = new Date(ticketInfoB.start_date);
195 | if (timeA.getTime() != timeB.getTime()) {
196 | return timeA.getTime() - timeB.getTime();
197 | }
198 | const startTimeA = ticketInfoA.start_time.split(':');
199 | const startTimeB = ticketInfoB.start_time.split(':');
200 | const hourA = parseInt(startTimeA[0]);
201 | const hourB = parseInt(startTimeB[0]);
202 | if(hourA != hourB){
203 | return hourA - hourB;
204 | }
205 | const minuteA = parseInt(startTimeA[1]);
206 | const minuteB = parseInt(startTimeB[1]);
207 | return minuteA - minuteB;
208 | },
209 | arriveTime: (ticketInfoA: TicketInfo | InterlineInfo, ticketInfoB: TicketInfo | InterlineInfo) => {
210 | const timeA = new Date(ticketInfoA.arrive_date);
211 | const timeB = new Date(ticketInfoB.arrive_date);
212 | if (timeA.getTime() != timeB.getTime()) {
213 | return timeA.getTime() - timeB.getTime();
214 | }
215 | const arriveTimeA = ticketInfoA.arrive_time.split(':');
216 | const arriveTimeB = ticketInfoB.arrive_time.split(':');
217 | const hourA = parseInt(arriveTimeA[0]);
218 | const hourB = parseInt(arriveTimeB[0]);
219 | if(hourA != hourB){
220 | return hourA - hourB;
221 | }
222 | const minuteA = parseInt(arriveTimeA[1]);
223 | const minuteB = parseInt(arriveTimeB[1]);
224 | return minuteA - minuteB;
225 |
226 | },
227 | duration: (ticketInfoA: TicketInfo | InterlineInfo, ticketInfoB: TicketInfo | InterlineInfo) => {
228 | const lishiTimeA = ticketInfoA.lishi.split(':');
229 | const lishiTimeB = ticketInfoB.lishi.split(':');
230 | const hourA = parseInt(lishiTimeA[0]);
231 | const hourB = parseInt(lishiTimeB[0]);
232 | if (hourA != hourB) {
233 | return hourA - hourB;
234 |
235 | }
236 | const minuteA = parseInt(lishiTimeA[1]);
237 | const minuteB = parseInt(lishiTimeB[1]);
238 | return minuteA - minuteB;
239 | },
240 | };
241 |
242 | function parseCookies(cookies: Array): Record {
243 | const cookieRecord: Record = {};
244 | cookies.forEach((cookie) => {
245 | // 提取键值对部分(去掉 Path、HttpOnly 等属性)
246 | const keyValuePart = cookie.split(';')[0];
247 | // 分割键和值
248 | const [key, value] = keyValuePart.split('=');
249 | // 存入对象
250 | if (key && value) {
251 | cookieRecord[key.trim()] = value.trim();
252 | }
253 | });
254 | return cookieRecord;
255 | }
256 |
257 | function formatCookies(cookies: Record): string {
258 | return Object.entries(cookies)
259 | .map(([key, value]) => `${key}=${value}`)
260 | .join('; ');
261 | }
262 |
263 | async function getCookie(url: string) {
264 | try {
265 | const response = await axios.get(url);
266 | const setCookieHeader = response.headers['set-cookie'];
267 | if (setCookieHeader) {
268 | return parseCookies(setCookieHeader);
269 | }
270 | return null;
271 | } catch (error) {
272 | console.error('Error making 12306 request:', error);
273 | return null;
274 | }
275 | }
276 |
277 | function parseRouteStationsData(rawData: Object[]): RouteStationData[] {
278 | const result: RouteStationData[] = [];
279 | for (const item of rawData) {
280 | result.push(item as RouteStationData);
281 | }
282 | return result;
283 | }
284 |
285 | function parseRouteStationsInfo(
286 | routeStationsData: RouteStationData[]
287 | ): RouteStationInfo[] {
288 | const result: RouteStationInfo[] = [];
289 | routeStationsData.forEach((routeStationData, index) => {
290 | if (index == 0) {
291 | result.push({
292 | arrive_time: routeStationData.start_time,
293 | station_name: routeStationData.station_name,
294 | stopover_time: routeStationData.stopover_time,
295 | station_no: parseInt(routeStationData.station_no),
296 | });
297 | } else {
298 | result.push({
299 | arrive_time: routeStationData.arrive_time,
300 | station_name: routeStationData.station_name,
301 | stopover_time: routeStationData.stopover_time,
302 | station_no: parseInt(routeStationData.station_no),
303 | });
304 | }
305 | });
306 | return result;
307 | }
308 |
309 | function parseTicketsData(rawData: string[]): TicketData[] {
310 | const result: TicketData[] = [];
311 | for (const item of rawData) {
312 | const values = item.split('|');
313 | const entry: Partial = {};
314 | TicketDataKeys.forEach((key, index) => {
315 | entry[key] = values[index];
316 | });
317 | result.push(entry as TicketData);
318 | }
319 | return result;
320 | }
321 |
322 | function parseTicketsInfo(ticketsData: TicketData[], map:Record): TicketInfo[] {
323 | const result: TicketInfo[] = [];
324 | for (const ticket of ticketsData) {
325 | const prices = extractPrices(
326 | ticket.yp_info_new,
327 | ticket.seat_discount_info,
328 | ticket
329 | );
330 | const dw_flag = extractDWFlags(ticket.dw_flag);
331 | const startHours = parseInt(ticket.start_time.split(':')[0]);
332 | const startMinutes = parseInt(ticket.start_time.split(':')[1]);
333 | const durationHours = parseInt(ticket.lishi.split(':')[0]);
334 | const durationMinutes = parseInt(ticket.lishi.split(':')[1]);
335 | const startDate = parse(ticket.start_train_date, 'yyyyMMdd', new Date())
336 | startDate.setHours(startHours, startMinutes);
337 | const arriveDate = startDate;
338 | arriveDate.setHours(
339 | startHours + durationHours,
340 | startMinutes + durationMinutes
341 | );
342 | result.push({
343 | train_no: ticket.train_no,
344 | start_date: format(startDate,"yyyy-MM-dd"),
345 | arrive_date: format(arriveDate,"yyyy-MM-dd"),
346 | start_train_code: ticket.station_train_code,
347 | start_time: ticket.start_time,
348 | arrive_time: ticket.arrive_time,
349 | lishi: ticket.lishi,
350 | from_station: map[ticket.from_station_telecode],
351 | to_station: map[ticket.to_station_telecode],
352 | from_station_telecode: ticket.from_station_telecode,
353 | to_station_telecode: ticket.to_station_telecode,
354 | prices: prices,
355 | dw_flag: dw_flag,
356 | });
357 | }
358 | return result;
359 | }
360 |
361 | /**
362 | * 格式化票量信息,提供语义化描述
363 | * @param num 票量数字或状态字符串
364 | * @returns 格式化后的票量描述
365 | */
366 | function formatTicketStatus(num: string): string {
367 | // 检查是否为纯数字
368 | if (num.match(/^\d+$/)) {
369 | const count = parseInt(num);
370 | if (count === 0) {
371 | return '无票';
372 | } else {
373 | return `剩余${count}张票`;
374 | }
375 | }
376 |
377 | // 处理特殊状态字符串
378 | switch (num) {
379 | case '有':
380 | case '充足':
381 | return '有票';
382 | case '无':
383 | case '--':
384 | case '':
385 | return '无票';
386 | case '候补':
387 | return '无票需候补';
388 | default:
389 | return `${num}票`;
390 | }
391 | }
392 |
393 | function formatTicketsInfo(ticketsInfo: TicketInfo[]): string {
394 | if (ticketsInfo.length === 0) {
395 | return '没有查询到相关车次信息';
396 | }
397 | let result = '车次 | 出发站 -> 到达站 | 出发时间 -> 到达时间 | 历时\n';
398 | ticketsInfo.forEach((ticketInfo) => {
399 | let infoStr = '';
400 | infoStr += `${ticketInfo.start_train_code}(实际车次train_no: ${ticketInfo.train_no}) ${ticketInfo.from_station}(telecode: ${ticketInfo.from_station_telecode}) -> ${ticketInfo.to_station}(telecode: ${ticketInfo.to_station_telecode}) ${ticketInfo.start_time} -> ${ticketInfo.arrive_time} 历时:${ticketInfo.lishi}`;
401 | ticketInfo.prices.forEach((price) => {
402 | const ticketStatus = formatTicketStatus(price.num);
403 | infoStr += `\n- ${price.seat_name}: ${ticketStatus} ${price.price}元`;
404 | });
405 | result += `${infoStr}\n`;
406 | });
407 | return result;
408 | }
409 |
410 | function filterTicketsInfo(
411 | ticketsInfo: T[],
412 | trainFilterFlags: string,
413 | sortFlag: string = '',
414 | sortReverse: boolean = false,
415 | limitedNum: number = 0
416 | ): T[] {
417 | let result: T[] ;
418 | if (trainFilterFlags.length === 0) {
419 | result = ticketsInfo;
420 | }
421 | else {
422 | result = [];
423 | for (const ticketInfo of ticketsInfo) {
424 | for (const filter of trainFilterFlags) {
425 | if (TRAIN_FILTERS[filter as keyof typeof TRAIN_FILTERS](ticketInfo)) {
426 | result.push(ticketInfo);
427 | break;
428 | }
429 | }
430 | }
431 | }
432 | if(Object.keys(TIME_COMPARETOR).includes(sortFlag)){
433 | result.sort(TIME_COMPARETOR[sortFlag as keyof typeof TIME_COMPARETOR]);
434 | if(sortReverse){
435 | result.reverse()
436 | }
437 | }
438 | if(limitedNum == 0){
439 | return result;
440 | }
441 | return result.slice(0,limitedNum);
442 | }
443 |
444 | function parseInterlinesTicketInfo(
445 | interlineTicketsData: InterlineTicketData[]
446 | ) {
447 | const result: TicketInfo[] = [];
448 | for (const interlineTicketData of interlineTicketsData) {
449 | const prices = extractPrices(
450 | interlineTicketData.yp_info,
451 | interlineTicketData.seat_discount_info,
452 | interlineTicketData
453 | );
454 | const startHours = parseInt(interlineTicketData.start_time.split(':')[0]);
455 | const startMinutes = parseInt(interlineTicketData.start_time.split(':')[1]);
456 | const durationHours = parseInt(interlineTicketData.lishi.split(':')[0]);
457 | const durationMinutes = parseInt(interlineTicketData.lishi.split(':')[1]);
458 | const startDate = parse(interlineTicketData.start_train_date, 'yyyyMMdd', new Date())
459 | startDate.setHours(startHours, startMinutes);
460 | const arriveDate = startDate;
461 | arriveDate.setHours(
462 | startHours + durationHours,
463 | startMinutes + durationMinutes
464 | );
465 | result.push({
466 | train_no: interlineTicketData.train_no,
467 | start_train_code: interlineTicketData.station_train_code,
468 | start_date: format(startDate,"yyyy-MM-dd"),
469 | arrive_date: format(arriveDate,"yyyy-MM-dd"),
470 | start_time: interlineTicketData.start_time,
471 | arrive_time: interlineTicketData.arrive_time,
472 | lishi: interlineTicketData.lishi,
473 | from_station: interlineTicketData.from_station_name,
474 | to_station: interlineTicketData.to_station_name,
475 | from_station_telecode: interlineTicketData.from_station_telecode,
476 | to_station_telecode: interlineTicketData.to_station_telecode,
477 | prices: prices,
478 | dw_flag: extractDWFlags(interlineTicketData.dw_flag),
479 | });
480 | }
481 | return result;
482 | }
483 |
484 | function parseInterlinesInfo(interlineData: InterlineData[]): InterlineInfo[] {
485 | const result: InterlineInfo[] = [];
486 | for (const ticket of interlineData) {
487 | const interlineTickets = parseInterlinesTicketInfo(ticket.fullList);
488 | const lishi = extractLishi(ticket.all_lishi);
489 | result.push({
490 | lishi: lishi,
491 | start_time: ticket.start_time,
492 | start_date: ticket.train_date,
493 | middle_date: ticket.middle_date,
494 | arrive_date: ticket.arrive_date,
495 | arrive_time: ticket.arrive_time,
496 | from_station_code: ticket.from_station_code,
497 | from_station_name: ticket.from_station_name,
498 | middle_station_code: ticket.middle_station_code,
499 | middle_station_name: ticket.middle_station_name,
500 | end_station_code: ticket.end_station_code,
501 | end_station_name: ticket.end_station_name,
502 | start_train_code: interlineTickets[0].start_train_code,
503 | first_train_no: ticket.first_train_no,
504 | second_train_no: ticket.second_train_no,
505 | train_count: ticket.train_count,
506 | ticketList: interlineTickets,
507 | same_station: ticket.same_station == '0' ? true : false,
508 | same_train: ticket.same_train == 'Y' ? true : false,
509 | wait_time: ticket.wait_time,
510 | });
511 | }
512 | return result;
513 | }
514 |
515 | function formatInterlinesInfo(interlinesInfo: InterlineInfo[]): string {
516 | let result =
517 | '出发时间 -> 到达时间 | 出发车站 -> 中转车站 -> 到达车站 | 换乘标志 |换乘等待时间| 总历时\n\n';
518 | interlinesInfo.forEach((interlineInfo) => {
519 | result += `${interlineInfo.start_date} ${interlineInfo.start_time} -> ${interlineInfo.arrive_date} ${interlineInfo.arrive_time} | `;
520 | result += `${interlineInfo.from_station_name} -> ${interlineInfo.middle_station_name} -> ${interlineInfo.end_station_name} | `;
521 | result += `${
522 | interlineInfo.same_train
523 | ? '同车换乘'
524 | : interlineInfo.same_station
525 | ? '同站换乘'
526 | : '换站换乘'
527 | } | ${interlineInfo.wait_time} | ${interlineInfo.lishi}\n\n`;
528 | result +=
529 | '\t' + formatTicketsInfo(interlineInfo.ticketList).replace(/\n/g, '\n\t');
530 | result += '\n';
531 | });
532 | return result;
533 | }
534 |
535 | function parseStationsData(rawData: string): Record {
536 | const result: Record = {};
537 | const dataArray = rawData.split('|');
538 | const dataList: string[][] = [];
539 | for (let i = 0; i < Math.floor(dataArray.length / 10); i++) {
540 | dataList.push(dataArray.slice(i * 10, i * 10 + 10));
541 | }
542 | for (const group of dataList) {
543 | let station: Partial = {};
544 | StationDataKeys.forEach((key, index) => {
545 | station[key] = group[index];
546 | });
547 | if (!station.station_code) {
548 | continue;
549 | }
550 | result[station.station_code!] = station as StationData;
551 | }
552 | return result;
553 | }
554 | /**
555 | * 格式化历时数据为hh:mm,为比较历时做准备。
556 | * @param all_lishi interlineTicket中的历时数据, 形如:H小时M分钟或M分钟
557 | * @returns 和普通余票数据中的lishi字段一样的hh:mm格式的历时
558 | */
559 | function extractLishi(
560 | all_lishi:string
561 | ): string {
562 | const match = all_lishi.match(/(?:(\d+)小时)?(\d+?)分钟/);
563 | if(!match){
564 | throw new Error('extractLishi失败,没有匹配到关键词');
565 | }
566 | if(!match[1]){
567 | return `00:${match[2]}`;
568 | }
569 | return `${match[1].padStart(2,'0')}:${match[2]}}`
570 | }
571 |
572 | function extractPrices(
573 | yp_info: string,
574 | seat_discount_info: string,
575 | ticketData: TicketData | InterlineTicketData
576 | ): Price[] {
577 | const PRICE_STR_LENGTH = 10;
578 | const DISCOUNT_STR_LENGTH = 5;
579 | const prices: Price[] = [];
580 | const discounts: { [key: string]: number } = {};
581 | for (let i = 0; i < seat_discount_info.length / DISCOUNT_STR_LENGTH; i++) {
582 | const discount_str = seat_discount_info.slice(
583 | i * DISCOUNT_STR_LENGTH,
584 | (i + 1) * DISCOUNT_STR_LENGTH
585 | );
586 | discounts[discount_str[0]] = parseInt(discount_str.slice(1), 10);
587 | }
588 |
589 | for (let i = 0; i < yp_info.length / PRICE_STR_LENGTH; i++) {
590 | const price_str = yp_info.slice(
591 | i * PRICE_STR_LENGTH,
592 | (i + 1) * PRICE_STR_LENGTH
593 | );
594 | var seat_type_code;
595 | if (parseInt(price_str.slice(6, 10), 10) >= 3000){ // 根据12306的js逆向出来的,不懂。
596 | seat_type_code = 'W'; // 为无座
597 | }
598 | else if(!Object.keys(SEAT_TYPES).includes(price_str[0])){
599 | seat_type_code = 'H'; // 其他坐席
600 | }
601 | else{
602 | seat_type_code = price_str[0];
603 | }
604 | const seat_type = SEAT_TYPES[seat_type_code as keyof typeof SEAT_TYPES];
605 | const price = parseInt(price_str.slice(1, 6), 10) / 10;
606 | const discount = seat_type_code in discounts ? discounts[seat_type_code] : null;
607 | prices.push({
608 | seat_name: seat_type.name,
609 | short: seat_type.short,
610 | seat_type_code,
611 | num: ticketData[
612 | `${seat_type.short}_num` as keyof (TicketData | InterlineTicketData)
613 | ],
614 | price,
615 | discount,
616 | });
617 | }
618 | return prices;
619 | }
620 |
621 | function extractDWFlags(dw_flag_str: string): string[] {
622 | const dwFlagList = dw_flag_str.split('#');
623 | let result = [];
624 | if ('5' == dwFlagList[0]) {
625 | result.push(DW_FLAGS[0]);
626 | }
627 | if (dwFlagList.length > 1 && '1' == dwFlagList[1]) {
628 | result.push(DW_FLAGS[1]);
629 | }
630 | if (dwFlagList.length > 2) {
631 | if ('Q' == dwFlagList[2].substring(0, 1)) {
632 | result.push(DW_FLAGS[2]);
633 | } else if ('R' == dwFlagList[2].substring(0, 1)) {
634 | result.push(DW_FLAGS[3]);
635 | }
636 | }
637 | if (dwFlagList.length > 5 && 'D' == dwFlagList[5]) {
638 | result.push(DW_FLAGS[4]);
639 | }
640 | if (dwFlagList.length > 6 && 'z' != dwFlagList[6]) {
641 | result.push(DW_FLAGS[5]);
642 | }
643 | if (dwFlagList.length > 7 && 'z' != dwFlagList[7]) {
644 | result.push(DW_FLAGS[6]);
645 | }
646 | return result;
647 | }
648 |
649 | function checkDate(date: string): boolean {
650 | const timeZone = 'Asia/Shanghai';
651 | const nowInShanghai = toZonedTime(new Date(), timeZone);
652 | nowInShanghai.setHours(0, 0, 0, 0);
653 | const inputInShanghai = toZonedTime(new Date(date), timeZone);
654 | inputInShanghai.setHours(0, 0, 0, 0);
655 | return inputInShanghai >= nowInShanghai;
656 | }
657 |
658 | async function make12306Request(
659 | url: string | URL,
660 | scheme: URLSearchParams = new URLSearchParams(),
661 | headers: Record = {}
662 | ): Promise {
663 | try {
664 | const response = await axios.get(url + '?' + scheme.toString(), {
665 | headers: headers,
666 | });
667 | return (await response.data) as T;
668 | } catch (error) {
669 | console.error('Error making 12306 request:', error);
670 | return null;
671 | }
672 | }
673 |
674 | // Create server instance
675 | const server = new McpServer({
676 | name: '12306-mcp',
677 | version: VERSION,
678 | capabilities: {
679 | resources: {},
680 | tools: {},
681 | },
682 | instructions:
683 | '该服务主要用于帮助用户查询火车票信息、特定列车的经停站信息以及相关的车站信息。请仔细理解用户的意图,并按以下指引选择合适的接口:\n\n' +
684 | '**原则:**\n' +
685 | '* **优先理解意图**:判断用户的真实需求,是查票、查经停站还是查车站信息。\n' +
686 | '* **参数准确性**:确保传递给每个的参数格式和类型都正确,特别是日期格式和地点编码。\n' +
687 | '* **必要时追问**:如果用户信息不足以调用接口,请向用户追问缺失的信息。\n' +
688 | '* **清晰呈现结果**:将接口返回的信息以用户易于理解的方式进行呈现。\n\n' +
689 | '* **尽量精确需求**:尽量利用筛选功能筛选用户需要的车票信息,从而简短上下文长度。\n\n' +
690 | '请根据上述指引选择接口。',
691 | });
692 |
693 | interface QueryResponse {
694 | [key: string]: any;
695 | httpstatus?: string;
696 | data:
697 | | {
698 | [key: string]: any;
699 | }
700 | | string;
701 | status: boolean;
702 | }
703 |
704 | interface LeftTicketsQueryResponse extends QueryResponse {
705 | httpstatus: string;
706 | data: {
707 | [key: string]: any;
708 | };
709 | messages: string;
710 | }
711 |
712 | server.resource('stations', 'data://all-stations', async (uri) => ({
713 | contents: [{ uri: uri.href, text: JSON.stringify(STATIONS) }],
714 | }));
715 |
716 | server.tool(
717 | 'get-current-date',
718 | '获取当前日期,以上海时区(Asia/Shanghai, UTC+8)为准,返回格式为 "yyyy-MM-dd"。主要用于解析用户提到的相对日期(如“明天”、“下周三”),为其他需要日期的接口提供准确的日期输入。',
719 | {},
720 | async () => {
721 | try {
722 | const timeZone = 'Asia/Shanghai';
723 | const nowInShanghai = toZonedTime(new Date(), timeZone);
724 | const formattedDate = format(nowInShanghai, 'yyyy-MM-dd');
725 | return {
726 | content: [{ type: 'text', text: formattedDate }],
727 | };
728 | } catch (error) {
729 | console.error('Error getting current date:', error);
730 | return {
731 | content: [{ type: 'text', text: 'Error: Failed to get current date.' }],
732 | };
733 | }
734 | }
735 | );
736 |
737 | server.tool(
738 | 'get-stations-code-in-city',
739 | '通过中文城市名查询该城市 **所有** 火车站的名称及其对应的 `station_code`,结果是一个包含多个车站信息的列表。',
740 | {
741 | city: z.string().describe('中文城市名称,例如:"北京", "上海"'),
742 | },
743 | async ({ city }) => {
744 | if (!(city in CITY_STATIONS)) {
745 | return {
746 | content: [{ type: 'text', text: 'Error: City not found. ' }],
747 | };
748 | }
749 | return {
750 | content: [{ type: 'text', text: JSON.stringify(CITY_STATIONS[city]) }],
751 | };
752 | }
753 | );
754 |
755 | server.tool(
756 | 'get-station-code-of-citys',
757 | '通过中文城市名查询代表该城市的 `station_code`。此接口主要用于在用户提供**城市名**作为出发地或到达地时,为接口准备 `station_code` 参数。',
758 | {
759 | citys: z
760 | .string()
761 | .describe(
762 | '要查询的城市,比如"北京"。若要查询多个城市,请用|分割,比如"北京|上海"。'
763 | ),
764 | },
765 | async ({ citys }) => {
766 | let result: Record = {};
767 | for (const city of citys.split('|')) {
768 | if (!(city in CITY_CODES)) {
769 | result[city] = { error: '未检索到城市。' };
770 | } else {
771 | result[city] = CITY_CODES[city];
772 | }
773 | }
774 | return {
775 | content: [{ type: 'text', text: JSON.stringify(result) }],
776 | };
777 | }
778 | );
779 |
780 | server.tool(
781 | 'get-station-code-by-names',
782 | '通过具体的中文车站名查询其 `station_code` 和车站名。此接口主要用于在用户提供**具体车站名**作为出发地或到达地时,为接口准备 `station_code` 参数。',
783 | {
784 | stationNames: z
785 | .string()
786 | .describe(
787 | '具体的中文车站名称,例如:"北京南", "上海虹桥"。若要查询多个站点,请用|分割,比如"北京南|上海虹桥"。'
788 | ),
789 | },
790 | async ({ stationNames }) => {
791 | let result: Record = {};
792 | for (let stationName of stationNames.split('|')) {
793 | stationName = stationName.endsWith('站')
794 | ? stationName.substring(0, -1)
795 | : stationName;
796 | if (!(stationName in NAME_STATIONS)) {
797 | result[stationName] = { error: '未检索到城市。' };
798 | } else {
799 | result[stationName] = NAME_STATIONS[stationName];
800 | }
801 | }
802 | return {
803 | content: [{ type: 'text', text: JSON.stringify(result) }],
804 | };
805 | }
806 | );
807 |
808 | server.tool(
809 | 'get-station-by-telecode',
810 | '通过车站的 `station_telecode` 查询车站的详细信息,包括名称、拼音、所属城市等。此接口主要用于在已知 `telecode` 的情况下获取更完整的车站数据,或用于特殊查询及调试目的。一般用户对话流程中较少直接触发。',
811 | {
812 | stationTelecode: z
813 | .string()
814 | .describe('车站的 `station_telecode` (3位字母编码)'),
815 | },
816 | async ({ stationTelecode }) => {
817 | if (!STATIONS[stationTelecode]) {
818 | return {
819 | content: [{ type: 'text', text: 'Error: Station not found. ' }],
820 | };
821 | }
822 | return {
823 | content: [
824 | { type: 'text', text: JSON.stringify(STATIONS[stationTelecode]) },
825 | ],
826 | };
827 | }
828 | );
829 |
830 | server.tool(
831 | 'get-tickets',
832 | '查询12306余票信息。',
833 | {
834 | date: z
835 | .string()
836 | .length(10)
837 | .describe(
838 | '查询日期,格式为 "yyyy-MM-dd"。如果用户提供的是相对日期(如“明天”),请务必先调用 `get-current-date` 接口获取当前日期,并计算出目标日期。'
839 | ),
840 | fromStation: z
841 | .string()
842 | .describe(
843 | '出发地的 `station_code` 。必须是通过 `get-station-code-by-names` 或 `get-station-code-of-citys` 接口查询得到的编码,严禁直接使用中文地名。'
844 | ),
845 | toStation: z
846 | .string()
847 | .describe(
848 | '到达地的 `station_code` 。必须是通过 `get-station-code-by-names` 或 `get-station-code-of-citys` 接口查询得到的编码,严禁直接使用中文地名。'
849 | ),
850 | trainFilterFlags: z
851 | .string()
852 | .regex(/^[GDZTKOFS]*$/)
853 | .max(8)
854 | .optional()
855 | .default('')
856 | .describe(
857 | '车次筛选条件,默认为空,即不筛选。支持多个标志同时筛选。例如用户说“高铁票”,则应使用 "G"。可选标志:[G(高铁/城际),D(动车),Z(直达特快),T(特快),K(快速),O(其他),F(复兴号),S(智能动车组)]'
858 | ),
859 | sortFlag: z
860 | .string()
861 | .optional()
862 | .default('')
863 | .describe(
864 | '排序方式,默认为空,即不排序。仅支持单一标识。可选标志:[startTime(出发时间从早到晚), arriveTime(抵达时间从早到晚), duration(历时从短到长)]'
865 | ),
866 | sortReverse: z
867 | .boolean()
868 | .optional()
869 | .default(false)
870 | .describe('是否逆向排序结果,默认为false。仅在设置了sortFlag时生效。'),
871 | limitedNum: z
872 | .number()
873 | .min(0)
874 | .optional()
875 | .default(0)
876 | .describe('返回的余票数量限制,默认为0,即不限制。'),
877 | },
878 | async ({ date, fromStation, toStation, trainFilterFlags, sortFlag, sortReverse, limitedNum }) => {
879 | // 检查日期是否早于当前日期
880 | if (!checkDate(date)) {
881 | return {
882 | content: [
883 | {
884 | type: 'text',
885 | text: 'Error: The date cannot be earlier than today.',
886 | },
887 | ],
888 | };
889 | }
890 | if (
891 | !Object.keys(STATIONS).includes(fromStation) ||
892 | !Object.keys(STATIONS).includes(toStation)
893 | ) {
894 | return {
895 | content: [{ type: 'text', text: 'Error: Station not found. ' }],
896 | };
897 | }
898 | const queryParams = new URLSearchParams({
899 | 'leftTicketDTO.train_date': date,
900 | 'leftTicketDTO.from_station': fromStation,
901 | 'leftTicketDTO.to_station': toStation,
902 | purpose_codes: 'ADULT',
903 | });
904 | const queryUrl = `${API_BASE}/otn/leftTicket/query`;
905 | const cookies = await getCookie(API_BASE);
906 | if (cookies == null) {
907 | return {
908 | content: [
909 | {
910 | type: 'text',
911 | text: 'Error: get cookie failed. Check your network.',
912 | },
913 | ],
914 | };
915 | }
916 | const queryResponse = await make12306Request(
917 | queryUrl,
918 | queryParams,
919 | { Cookie: formatCookies(cookies) }
920 | );
921 | if (queryResponse === null || queryResponse === undefined) {
922 | return {
923 | content: [{ type: 'text', text: 'Error: get tickets data failed. ' }],
924 | };
925 | }
926 | const ticketsData = parseTicketsData(queryResponse.data.result);
927 | let ticketsInfo: TicketInfo[];
928 | try {
929 | ticketsInfo = parseTicketsInfo(ticketsData, queryResponse.data.map);
930 | } catch (error) {
931 | console.error('Error: parse tickets info failed. ',error);
932 | return {
933 | content: [{ type: 'text', text: 'Error: parse tickets info failed. ' }],
934 | };
935 | }
936 | const filteredTicketsInfo = filterTicketsInfo(
937 | ticketsInfo,
938 | trainFilterFlags,
939 | sortFlag,
940 | sortReverse,
941 | limitedNum
942 | );
943 | return {
944 | content: [{ type: 'text', text: formatTicketsInfo(filteredTicketsInfo) }],
945 | };
946 | }
947 | );
948 |
949 | interface InterlineQueryResponse extends QueryResponse {
950 | data:
951 | | {
952 | flag: boolean;
953 | result_index: number;
954 | middleStationList: string[];
955 | can_query: string;
956 | zd_yp_size: number;
957 | middleList: InterlineData[];
958 | zd_size: number;
959 | [key: string]: any;
960 | }
961 | | string;
962 | errorMsg: string;
963 | }
964 |
965 | // https://kyfw.12306.cn/lcquery/queryG?
966 | // train_date=2025-05-10&
967 | // from_station_telecode=CDW&
968 | // to_station_telecode=ZGE&
969 | // middle_station=&
970 | // result_index=0&
971 | // can_query=Y&
972 | // isShowWZ=N&
973 | // purpose_codes=00&
974 | // channel=E ?channel是什么用的
975 |
976 | server.tool(
977 | 'get-interline-tickets',
978 | '查询12306中转余票信息。尚且只支持查询前十条。',
979 | {
980 | date: z
981 | .string()
982 | .length(10)
983 | .describe(
984 | '查询日期,格式为 "yyyy-MM-dd"。如果用户提供的是相对日期(如“明天”),请务必先调用 `get-current-date` 接口获取当前日期,并计算出目标日期。'
985 | ),
986 | fromStation: z
987 | .string()
988 | .describe(
989 | '出发地的 `station_code` 。必须是通过 `get-station-code-by-names` 或 `get-station-code-of-citys` 接口查询得到的编码,严禁直接使用中文地名。'
990 | ),
991 | toStation: z
992 | .string()
993 | .describe(
994 | '出发地的 `station_code` 。必须是通过 `get-station-code-by-names` 或 `get-station-code-of-citys` 接口查询得到的编码,严禁直接使用中文地名。'
995 | ),
996 | middleStation: z
997 | .string()
998 | .optional()
999 | .default('')
1000 | .describe(
1001 | '中转地的 `station_code` ,可选。必须是通过 `get-station-code-by-names` 或 `get-station-code-of-citys` 接口查询得到的编码,严禁直接使用中文地名。'
1002 | ),
1003 | showWZ: z
1004 | .boolean()
1005 | .optional()
1006 | .default(false)
1007 | .describe('是否显示无座车,默认不显示无座车。'),
1008 | trainFilterFlags: z
1009 | .string()
1010 | .regex(/^[GDZTKOFS]*$/)
1011 | .max(8)
1012 | .optional()
1013 | .default('')
1014 | .describe(
1015 | '车次筛选条件,默认为空。从以下标志中选取多个条件组合[G(高铁/城际),D(动车),Z(直达特快),T(特快),K(快速),O(其他),F(复兴号),S(智能动车组)]'
1016 | ),
1017 | sortFlag: z
1018 | .string()
1019 | .optional()
1020 | .default('')
1021 | .describe(
1022 | '排序方式,默认为空,即不排序。仅支持单一标识。可选标志:[startTime(出发时间从早到晚), arriveTime(抵达时间从早到晚), duration(历时从短到长)]'
1023 | ),
1024 | sortReverse: z
1025 | .boolean()
1026 | .optional()
1027 | .default(false)
1028 | .describe('是否逆向排序结果,默认为false。仅在设置了sortFlag时生效。'),
1029 | limitedNum: z
1030 | .number()
1031 | .min(0)
1032 | .optional()
1033 | .default(0)
1034 | .describe('返回的余票数量限制,默认为0,即不限制。'),
1035 | },
1036 | async ({
1037 | date,
1038 | fromStation,
1039 | toStation,
1040 | middleStation,
1041 | showWZ,
1042 | trainFilterFlags,
1043 | sortFlag,
1044 | sortReverse,
1045 | limitedNum
1046 | }) => {
1047 | // 检查日期是否早于当前日期
1048 | if (!checkDate(date)) {
1049 | return {
1050 | content: [
1051 | {
1052 | type: 'text',
1053 | text: 'Error: The date cannot be earlier than today.',
1054 | },
1055 | ],
1056 | };
1057 | }
1058 | if (
1059 | !Object.keys(STATIONS).includes(fromStation) ||
1060 | !Object.keys(STATIONS).includes(toStation)
1061 | ) {
1062 | return {
1063 | content: [{ type: 'text', text: 'Error: Station not found. ' }],
1064 | };
1065 | }
1066 | const queryUrl = `${API_BASE}${LCQUERY_PATH}`;
1067 | const queryParams = new URLSearchParams({
1068 | train_date: date,
1069 | from_station_telecode: fromStation,
1070 | to_station_telecode: toStation,
1071 | middle_station: middleStation,
1072 | result_index: '0',
1073 | can_query: 'Y',
1074 | isShowWZ: showWZ ? 'Y' : 'N',
1075 | purpose_codes: '00', // 00: 成人票 0X: 学生票
1076 | channel: 'E', // 没搞清楚什么用
1077 | });
1078 | const cookies = await getCookie(API_BASE);
1079 | if (cookies == null) {
1080 | return {
1081 | content: [
1082 | {
1083 | type: 'text',
1084 | text: 'Error: get cookie failed. Check your network.',
1085 | },
1086 | ],
1087 | };
1088 | }
1089 | const queryResponse = await make12306Request(
1090 | queryUrl,
1091 | queryParams,
1092 | { Cookie: formatCookies(cookies) }
1093 | );
1094 | // 处理请求错误
1095 | if (queryResponse === null || queryResponse === undefined) {
1096 | return {
1097 | content: [
1098 | {
1099 | type: 'text',
1100 | text: 'Error: request interline tickets data failed. ',
1101 | },
1102 | ],
1103 | };
1104 | }
1105 | // 请求成功,但查询有误
1106 | if (typeof queryResponse.data == 'string') {
1107 | return {
1108 | content: [{ type: 'text', text: `很抱歉,未查到相关的列车余票。(${queryResponse.errorMsg})` }],
1109 | };
1110 | }
1111 | // 请求和查询都没问题
1112 | let interlineTicketsInfo: InterlineInfo[];
1113 | try {
1114 | interlineTicketsInfo = parseInterlinesInfo(queryResponse.data.middleList);
1115 | } catch (error) {
1116 | return {
1117 | content: [
1118 | { type: 'text', text: `Error: parse tickets info failed. ${error}` },
1119 | ],
1120 | };
1121 | }
1122 | const filteredInterlineTicketsInfo = filterTicketsInfo(
1123 | interlineTicketsInfo,
1124 | trainFilterFlags,
1125 | sortFlag,
1126 | sortReverse,
1127 | limitedNum
1128 | );
1129 | return {
1130 | content: [
1131 | {
1132 | type: 'text',
1133 | text: formatInterlinesInfo(filteredInterlineTicketsInfo),
1134 | },
1135 | ],
1136 | };
1137 | }
1138 | );
1139 |
1140 | interface RouteQueryResponse extends QueryResponse {
1141 | httpstatus: string;
1142 | data: {
1143 | data: RouteStationData[];
1144 | };
1145 | messages: [];
1146 | validateMessages: object;
1147 | validateMessagesShowId: string;
1148 | }
1149 |
1150 | server.tool(
1151 | 'get-train-route-stations',
1152 | '查询特定列车车次在指定区间内的途径车站、到站时间、出发时间及停留时间等详细经停信息。当用户询问某趟具体列车的经停站时使用此接口。',
1153 | {
1154 | trainNo: z
1155 | .string()
1156 | .describe(
1157 | '要查询的实际车次编号 `train_no`,例如 "240000G10336",而非"G1033"。此编号通常可以从 `get-tickets` 的查询结果中获取,或者由用户直接提供。'
1158 | ),
1159 | fromStationTelecode: z
1160 | .string()
1161 | .describe(
1162 | '该列车行程的**出发站**的 `station_telecode` (3位字母编码`)。通常来自 `get-tickets` 结果中的 `telecode` 字段,或者通过 `get-station-code-by-names` 得到。'
1163 | ),
1164 | toStationTelecode: z
1165 | .string()
1166 | .describe(
1167 | '该列车行程的**到达站**的 `station_telecode` (3位字母编码)。通常来自 `get-tickets` 结果中的 `telecode` 字段,或者通过 `get-station-code-by-names` 得到。'
1168 | ),
1169 | departDate: z
1170 | .string()
1171 | .length(10)
1172 | .describe(
1173 | '列车从 `fromStationTelecode` 指定的车站出发的日期 (格式: yyyy-MM-dd)。如果用户提供的是相对日期,请务必先调用 `get-current-date` 解析。'
1174 | ),
1175 | },
1176 | async ({
1177 | trainNo: trainNo,
1178 | fromStationTelecode,
1179 | toStationTelecode,
1180 | departDate,
1181 | }) => {
1182 | const queryParams = new URLSearchParams({
1183 | train_no: trainNo,
1184 | from_station_telecode: fromStationTelecode,
1185 | to_station_telecode: toStationTelecode,
1186 | depart_date: departDate,
1187 | });
1188 | const queryUrl = `${API_BASE}/otn/czxx/queryByTrainNo`;
1189 | const cookies = await getCookie(API_BASE);
1190 | if (cookies == null) {
1191 | return {
1192 | content: [{ type: 'text', text: 'Error: get cookie failed. ' }],
1193 | };
1194 | }
1195 | const queryResponse = await make12306Request(
1196 | queryUrl,
1197 | queryParams,
1198 | { Cookie: formatCookies(cookies) }
1199 | );
1200 | if (queryResponse == null || queryResponse.data == undefined) {
1201 | return {
1202 | content: [
1203 | { type: 'text', text: 'Error: get train route stations failed. ' },
1204 | ],
1205 | };
1206 | }
1207 | const routeStationsInfo = parseRouteStationsInfo(queryResponse.data.data);
1208 | if (routeStationsInfo.length == 0) {
1209 | return {
1210 | content: [{ type: 'text', text: '未查询到相关车次信息。' }],
1211 | };
1212 | }
1213 | return {
1214 | content: [{ type: 'text', text: JSON.stringify(routeStationsInfo) }],
1215 | };
1216 | }
1217 | );
1218 |
1219 | async function getStations(): Promise> {
1220 | const html = await make12306Request(WEB_URL);
1221 | if (html == null) {
1222 | throw new Error('Error: get 12306 web page failed.');
1223 | }
1224 | const match = html.match('.(/script/core/common/station_name.+?\.js)');
1225 | if (match == null) {
1226 | throw new Error('Error: get station name js file failed.');
1227 | }
1228 | const stationNameJSFilePath = match[0];
1229 | const stationNameJS = await make12306Request(
1230 | new URL(stationNameJSFilePath, WEB_URL)
1231 | );
1232 | if (stationNameJS == null) {
1233 | throw new Error('Error: get station name js file failed.');
1234 | }
1235 | const rawData = eval(stationNameJS.replace('var station_names =', ''));
1236 | const stationsData = parseStationsData(rawData);
1237 | // 加上缺失的车站信息
1238 | for (const station of MISSING_STATIONS) {
1239 | if (!stationsData[station.station_code]) {
1240 | stationsData[station.station_code] = station;
1241 | }
1242 | }
1243 | return stationsData;
1244 | }
1245 |
1246 | async function getLCQueryPath(): Promise {
1247 | const html = await make12306Request(LCQUERY_INIT_URL);
1248 | if (html == null) {
1249 | throw new Error('Error: get 12306 web page failed.');
1250 | }
1251 | const match = html.match(/ var lc_search_url = '(.+?)'/);
1252 | if (match == null) {
1253 | throw new Error('Error: get station name js file failed.');
1254 | }
1255 | return match[1];
1256 | }
1257 |
1258 | async function init() {}
1259 |
1260 | async function main() {
1261 | const transport = new StdioServerTransport();
1262 | await init();
1263 | await server.connect(transport);
1264 | console.error('12306 MCP Server running on stdio @Joooook');
1265 | }
1266 |
1267 | main().catch((error) => {
1268 | console.error('Fatal error in main():', error);
1269 | process.exit(1);
1270 | });
1271 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | export type TicketData = {
3 | secret_Sstr: string;
4 | button_text_info: string;
5 | train_no: string;
6 | station_train_code: string;
7 | start_station_telecode: string;
8 | end_station_telecode: string;
9 | from_station_telecode: string;
10 | to_station_telecode: string;
11 | start_time: string;
12 | arrive_time: string;
13 | lishi: string;
14 | canWebBuy: string;
15 | yp_info: string;
16 | start_train_date: string;
17 | train_seat_feature: string;
18 | location_code: string;
19 | from_station_no: string;
20 | to_station_no: string;
21 | is_support_card: string;
22 | controlled_train_flag: string;
23 | gg_num: string;
24 | gr_num: string;
25 | qt_num: string;
26 | rw_num: string;
27 | rz_num: string;
28 | tz_num: string;
29 | wz_num: string;
30 | yb_num: string;
31 | yw_num: string;
32 | yz_num: string;
33 | ze_num: string;
34 | zy_num: string;
35 | swz_num: string;
36 | srrb_num: string;
37 | yp_ex: string;
38 | seat_types: string;
39 | exchange_train_flag: string;
40 | houbu_train_flag: string;
41 | houbu_seat_limit: string;
42 | yp_info_new: string;
43 | '40': string;
44 | '41': string;
45 | '42': string;
46 | '43': string;
47 | '44': string;
48 | '45': string;
49 | dw_flag: string;
50 | '47': string;
51 | stopcheckTime: string;
52 | country_flag: string;
53 | local_arrive_time: string;
54 | local_start_time: string;
55 | '52': string;
56 | bed_level_info: string;
57 | seat_discount_info: string;
58 | sale_time: string;
59 | '56': string;
60 | };
61 |
62 | export const TicketDataKeys: (keyof TicketData)[] = [
63 | 'secret_Sstr',
64 | 'button_text_info',
65 | 'train_no',
66 | 'station_train_code',
67 | 'start_station_telecode',
68 | 'end_station_telecode',
69 | 'from_station_telecode',
70 | 'to_station_telecode',
71 | 'start_time',
72 | 'arrive_time',
73 | 'lishi',
74 | 'canWebBuy',
75 | 'yp_info',
76 | 'start_train_date',
77 | 'train_seat_feature',
78 | 'location_code',
79 | 'from_station_no',
80 | 'to_station_no',
81 | 'is_support_card',
82 | 'controlled_train_flag',
83 | 'gg_num',
84 | 'gr_num',
85 | 'qt_num',
86 | 'rw_num',
87 | 'rz_num',
88 | 'tz_num',
89 | 'wz_num',
90 | 'yb_num',
91 | 'yw_num',
92 | 'yz_num',
93 | 'ze_num',
94 | 'zy_num',
95 | 'swz_num',
96 | 'srrb_num',
97 | 'yp_ex',
98 | 'seat_types',
99 | 'exchange_train_flag',
100 | 'houbu_train_flag',
101 | 'houbu_seat_limit',
102 | 'yp_info_new',
103 | '40',
104 | '41',
105 | '42',
106 | '43',
107 | '44',
108 | '45',
109 | 'dw_flag',
110 | '47',
111 | 'stopcheckTime',
112 | 'country_flag',
113 | 'local_arrive_time',
114 | 'local_start_time',
115 | '52',
116 | 'bed_level_info',
117 | 'seat_discount_info',
118 | 'sale_time',
119 | '56',
120 | ];
121 |
122 | export type TicketInfo = {
123 | train_no: string;
124 | start_train_code: string;
125 | start_date: string;
126 | start_time: string;
127 | arrive_date: string;
128 | arrive_time: string;
129 | lishi: string;
130 | from_station: string;
131 | to_station: string;
132 | from_station_telecode: string;
133 | to_station_telecode: string;
134 | prices: Price[];
135 | dw_flag: string[];
136 | };
137 |
138 | export type StationData = {
139 | station_id: string;
140 | station_name: string;
141 | station_code: string;
142 | station_pinyin: string;
143 | station_short: string;
144 | station_index: string;
145 | code: string;
146 | city: string;
147 | r1: string;
148 | r2: string;
149 | };
150 |
151 | export const StationDataKeys: (keyof StationData)[] = [
152 | 'station_id',
153 | 'station_name',
154 | 'station_code',
155 | 'station_pinyin',
156 | 'station_short',
157 | 'station_index',
158 | 'code',
159 | 'city',
160 | 'r1',
161 | 'r2',
162 | ];
163 |
164 | export interface Price {
165 | seat_name: string;
166 | short: string;
167 | seat_type_code: string;
168 | num: string;
169 | price: number;
170 | discount: number | null;
171 | }
172 |
173 | export type RouteStationData = {
174 | arrive_time: string;
175 | station_name: string;
176 | isChina: string;
177 | start_time: string;
178 | stopover_time: string;
179 | station_no: string;
180 | country_code: string;
181 | country_name: string;
182 | isEnabled: boolean;
183 | train_class_name?: string;
184 | service_type?: string;
185 | end_station_name?: string;
186 | start_station_name?: string;
187 | station_train_code?: string;
188 | };
189 |
190 | export type RouteStationInfo = {
191 | arrive_time: string;
192 | station_name: string;
193 | stopover_time: string;
194 | station_no: number;
195 | };
196 |
197 | export type InterlineData = {
198 | all_lishi: string;
199 | all_lishi_minutes: number;
200 | arrive_date: string;
201 | arrive_time: string;
202 | end_station_code: string;
203 | end_station_name: string;
204 | first_train_no: string;
205 | from_station_code: string;
206 | from_station_name: string;
207 | fullList: InterlineTicketData[];
208 | isHeatTrain: string;
209 | isOutStation: string;
210 | lCWaitTime: string;
211 | lishi_flag: string;
212 | middle_date: string;
213 | middle_station_code: string;
214 | middle_station_name: string;
215 | same_station: string;
216 | same_train: string;
217 | score: number;
218 | score_str: string;
219 | scretstr: string;
220 | second_train_no: string;
221 | start_time: string;
222 | train_count: number;
223 | train_date: string; // 出发时间
224 | use_time: string;
225 | wait_time: string;
226 | wait_time_minutes: number;
227 | };
228 |
229 | export type InterlineInfo = {
230 | lishi: string;
231 | //all_lishi_minutes: number;
232 | start_time: string;
233 | start_date: string;
234 | middle_date: string;
235 | arrive_date: string;
236 | arrive_time: string;
237 | from_station_code: string;
238 | from_station_name: string;
239 | middle_station_code: string;
240 | middle_station_name: string;
241 | end_station_code: string;
242 | end_station_name: string;
243 | start_train_code: string; // 用于过滤
244 | first_train_no: string;
245 | second_train_no: string;
246 | train_count: number;
247 | ticketList: TicketInfo[];
248 | //isHeatTrain: string;
249 | //isOutStation: string;
250 | //lCWaitTime: string;
251 | //lishi_flag: string;
252 | same_station: boolean;
253 | same_train: boolean;
254 | wait_time: string;
255 | //wait_time_minutes: number;
256 | };
257 |
258 | export type InterlineTicketData = {
259 | arrive_time: string;
260 | bed_level_info: string;
261 | controlled_train_flag: string;
262 | country_flag: string;
263 | day_difference: string;
264 | dw_flag: string;
265 | end_station_name: string;
266 | end_station_telecode: string;
267 | from_station_name: string;
268 | from_station_no: string;
269 | from_station_telecode: string;
270 | gg_num: string;
271 | gr_num: string;
272 | is_support_card: string;
273 | lishi: string;
274 | local_arrive_time: string;
275 | local_start_time: string;
276 | qt_num: string;
277 | rw_num: string;
278 | rz_num: string;
279 | seat_discount_info: string;
280 | seat_types: string;
281 | srrb_num: string;
282 | start_station_name: string;
283 | start_station_telecode: string;
284 | start_time: string;
285 | start_train_date: string;
286 | station_train_code: string;
287 | swz_num: string;
288 | to_station_name: string;
289 | to_station_no: string;
290 | to_station_telecode: string;
291 | train_no: string;
292 | train_seat_feature: string;
293 | trms_train_flag: string;
294 | tz_num: string;
295 | wz_num: string;
296 | yb_num: string;
297 | yp_info: string;
298 | yw_num: string;
299 | yz_num: string;
300 | ze_num: string;
301 | zy_num: string;
302 | };
303 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "Node16",
5 | "moduleResolution": "Node16",
6 | "outDir": "./build",
7 | "rootDir": "./src",
8 | "strict": true,
9 | "esModuleInterop": true,
10 | "skipLibCheck": true,
11 | "forceConsistentCasingInFileNames": true
12 | },
13 | "include": ["src/**/*"],
14 | "exclude": ["node_modules"]
15 | }
--------------------------------------------------------------------------------