├── .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://img.shields.io/badge/Joooook-blue.svg?logo=github&lable=python&labelColor=497568&color=497568&style=flat-square)](https://github.com/Joooook) 6 | [![](https://img.shields.io/badge/Joooook-blue.svg?logo=bilibili&logoColor=white&lable=python&labelColor=af7a82&color=af7a82&style=flat-square)](https://space.bilibili.com/3546386788255839) 7 | ![](https://img.shields.io/badge/typescript-blue.svg?logo=typescript&lable=typescript&logoColor=white&labelColor=192c3b&color=192c3b&style=flat-square) 8 | ![](https://img.shields.io/github/stars/Joooook/12306-mcp?logo=reverbnation&lable=python&logoColor=white&labelColor=ffc773&color=ffc773&style=flat-square) 9 | ![](https://img.shields.io/github/last-commit/Joooook/12306-mcp.svg?style=flat-square) 10 | ![](https://img.shields.io/github/license/Joooook/12306-mcp.svg?style=flat-square&color=000000) 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 | ![12306-MCP 服务架构图](./docs/architecture.png) 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 | 12306-mcp MCP server 88 | 89 | 90 | [![MseeP.ai Security Assessment Badge](https://mseep.net/pr/joooook-12306-mcp-badge.png)](https://mseep.ai/app/joooook-12306-mcp) 91 | 92 |
93 | 94 | ##
☕️Donate
95 | 请我喝杯奶茶吧。 96 |
97 | 98 | 99 | 100 |
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 | ![12306-MCP 服务架构图](./architecture.png) 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 | } --------------------------------------------------------------------------------