12306-mcp

#!/usr/bin/env node // Data一般用于表示从服务器上请求到的数据,Info一般表示解析并筛选过的要传输给大模型的数据。变量使用驼峰命名,常量使用全大写下划线命名。 import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import axios from 'axios'; import { z } from 'zod'; import { Price, RouteStationData, RouteStationInfo, StationData, StationDataKeys, TicketData, TicketDataKeys, TicketInfo, } from './types.js'; const API_BASE = 'https://kyfw.12306.cn'; const WEB_URL = 'https://www.12306.cn/index/'; const MISSING_STATIONS: StationData[] = [ { station_id: '@cdd', station_name: '成 都东', station_code: 'WEI', station_pinyin: 'chengdudong', station_short: 'cdd', station_index: '', code: '1707', city: '成都', r1: '', r2: '', }, ]; const STATIONS: Record<string, StationData> = await getStations(); //以Code为键 const CITY_STATIONS: Record< string, { station_code: string; station_name: string }[] > = (() => { const result: Record< string, { station_code: string; station_name: string }[] > = {}; for (const station of Object.values(STATIONS)) { const city = station.city; if (!result[city]) { result[city] = []; } result[city].push({ station_code: station.station_code, station_name: station.station_name, }); } return result; })(); //以城市名名为键,位于该城市的的所有Station列表的记录 const CITY_CODES: Record< string, { station_code: string; station_name: string } > = (() => { const result: Record<string, { station_code: string; station_name: string }> = {}; for (const [city, stations] of Object.entries(CITY_STATIONS)) { for (const station of stations) { if (station.station_name == city) { result[city] = station; break; } } } return result; })(); //以城市名名为键的Station记录 const NAME_STATIONS: Record< string, { station_code: string; station_name: string } > = (() => { const result: Record<string, { station_code: string; station_name: string }> = {}; for (const station of Object.values(STATIONS)) { const station_name = station.station_name; result[station_name] = { station_code: station.station_code, station_name: station.station_name, }; } return result; })(); //以车站名为键的Station记录 const SEAT_SHORT_TYPES = { swz: '商务座', tz: '特等座', zy: '一等座', ze: '二等座', gr: '高软卧', srrb: '动卧', rw: '软卧', yw: '硬卧', rz: '软座', yz: '硬座', wz: '无座', qt: '其他', gg: '', yb: '', }; const SEAT_TYPES = { '9': { name: '商务座', short: 'swz' }, P: { name: '特等座', short: 'tz' }, M: { name: '一等座', short: 'zy' }, D: { name: '优选一等座', short: 'zy' }, O: { name: '二等座', short: 'ze' }, S: { name: '二等包座', short: 'ze' }, '6': { name: '高级软卧', short: 'gr' }, A: { name: '高级动卧', short: 'gr' }, '4': { name: '软卧', short: 'rw' }, I: { name: '一等卧', short: 'rw' }, F: { name: '动卧', short: 'rw' }, '3': { name: '硬卧', short: 'yw' }, J: { name: '二等卧', short: 'yw' }, '2': { name: '软座', short: 'rz' }, '1': { name: '硬座', short: 'yz' }, W: { name: '无座', short: 'wz' }, WZ: { name: '无座', short: 'wz' }, H: { name: '其他', short: 'qt' }, }; const DW_FLAGS = [ '智能动车组', '复兴号', '静音车厢', '温馨动卧', '动感号', '支持选铺', '老年优惠', ]; const TRAIN_FILTERS = { //G(高铁/城际),D(动车),Z(直达特快),T(特快),K(快速),O(其他),F(复兴号),S(智能动车组) G: (ticketInfo: TicketInfo) => { return ticketInfo.train_no.startsWith('G') || ticketInfo.train_no.startsWith('C') ? true : false; }, D: (ticketInfo: TicketInfo) => { return ticketInfo.train_no.startsWith('D') ? true : false; }, Z: (ticketInfo: TicketInfo) => { return ticketInfo.train_no.startsWith('Z') ? true : false; }, T: (ticketInfo: TicketInfo) => { return ticketInfo.train_no.startsWith('T') ? true : false; }, K: (ticketInfo: TicketInfo) => { return ticketInfo.train_no.startsWith('K') ? true : false; }, O: (ticketInfo: TicketInfo) => { return TRAIN_FILTERS.G(ticketInfo) || TRAIN_FILTERS.D(ticketInfo) || TRAIN_FILTERS.Z(ticketInfo) || TRAIN_FILTERS.T(ticketInfo) || TRAIN_FILTERS.K(ticketInfo) ? false : true; }, F: (ticketInfo: TicketInfo) => { return ticketInfo.dw_flag.includes('复兴号') ? true : false; }, S: (ticketInfo: TicketInfo) => { return ticketInfo.dw_flag.includes('智能动车组') ? true : false; }, }; function parseCookies(cookies: Array<string>): Record<string, string> { const cookieRecord: Record<string, string> = {}; cookies.forEach((cookie) => { // 提取键值对部分(去掉 Path、HttpOnly 等属性) const keyValuePart = cookie.split(';')[0]; // 分割键和值 const [key, value] = keyValuePart.split('='); // 存入对象 if (key && value) { cookieRecord[key.trim()] = value.trim(); } }); return cookieRecord; } function formatCookies(cookies: Record<string, string>): string { return Object.entries(cookies) .map(([key, value]) => `${key}=${value}`) .join('; '); } async function getCookie(url: string) { try { const response = await axios.get(url); const setCookieHeader = response.headers['set-cookie']; if (setCookieHeader) { return parseCookies(setCookieHeader); } return null; } catch (error) { console.error('Error making 12306 request:', error); return null; } } function parseRouteStationsData(rawData: Object[]): RouteStationData[] { const result: RouteStationData[] = []; for (const item of rawData) { result.push(item as RouteStationData); } return result; } function parseRouteStationsInfo( routeStationsData: RouteStationData[] ): RouteStationInfo[] { const result: RouteStationInfo[] = []; routeStationsData.forEach((routeStationData, index) => { if (index == 0) { result.push({ arrive_time: routeStationData.start_time, station_name: routeStationData.station_name, stopover_time: routeStationData.stopover_time, station_no: parseInt(routeStationData.station_no), }); } else { result.push({ arrive_time: routeStationData.arrive_time, station_name: routeStationData.station_name, stopover_time: routeStationData.stopover_time, station_no: parseInt(routeStationData.station_no), }); } }); return result; } function parseTicketsData(rawData: string[]): TicketData[] { const result: TicketData[] = []; for (const item of rawData) { const values = item.split('|'); const entry: Partial<TicketData> = {}; TicketDataKeys.forEach((key, index) => { entry[key] = values[index]; }); result.push(entry as TicketData); } return result; } function parseTicketsInfo(ticketsData: TicketData[]): TicketInfo[] { const result: TicketInfo[] = []; for (const ticket of ticketsData) { const prices = extractPrices(ticket); const dw_flag = extractDWFlags(ticket); result.push({ train_no: ticket.train_no, start_train_code: ticket.station_train_code, start_time: ticket.start_time, arrive_time: ticket.arrive_time, lishi: ticket.lishi, from_station: STATIONS[ticket.from_station_telecode].station_name, to_station: STATIONS[ticket.to_station_telecode].station_name, from_station_telecode: ticket.from_station_telecode, to_station_telecode: ticket.to_station_telecode, prices: prices, dw_flag: dw_flag, }); } return result; } function formatTicketsInfo(ticketsInfo: TicketInfo[]): string { if (ticketsInfo.length === 0) { return '没有查询到相关车次信息'; } let result = '车次 | 出发站 -> 到达站 | 出发时间 -> 到达时间 | 历时 |'; ticketsInfo.forEach((ticketInfo) => { let infoStr = ''; 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}`; ticketInfo.prices.forEach((price) => { infoStr += `\n- ${price.seat_name}: ${ price.num.match(/^\d+$/) ? price.num + '张' : price.num }剩余 ${price.price}元`; }); result += `${infoStr}\n`; }); return result; } function filterTicketsInfo( ticketsInfo: TicketInfo[], filters: string ): TicketInfo[] { if (filters.length === 0) { return ticketsInfo; } const result: TicketInfo[] = []; for (const ticketInfo of ticketsInfo) { for (const filter of filters) { if (TRAIN_FILTERS[filter as keyof typeof TRAIN_FILTERS](ticketInfo)) { result.push(ticketInfo); break; } } } return result; } function parseStationsData(rawData: string): Record<string, StationData> { const result: Record<string, StationData> = {}; const dataArray = rawData.split('|'); const dataList: string[][] = []; for (let i = 0; i < Math.floor(dataArray.length / 10); i++) { dataList.push(dataArray.slice(i * 10, i * 10 + 10)); } for (const group of dataList) { let station: Partial<StationData> = {}; StationDataKeys.forEach((key, index) => { station[key] = group[index]; }); if (!station.station_code) { continue; } result[station.station_code!] = station as StationData; } return result; } function extractPrices(ticketData: TicketData): Price[] { const PRICE_STR_LENGTH = 10; const DISCOUNT_STR_LENGTH = 5; const yp_ex = ticketData.yp_ex; const yp_info_new = ticketData.yp_info_new; const seat_discount_info = ticketData.seat_discount_info; const prices: { [key: string]: Price } = {}; const discounts: { [key: string]: number } = {}; for (let i = 0; i < seat_discount_info.length / DISCOUNT_STR_LENGTH; i++) { const discount_str = seat_discount_info.slice( i * DISCOUNT_STR_LENGTH, (i + 1) * DISCOUNT_STR_LENGTH ); discounts[discount_str[0]] = parseInt(discount_str.slice(1), 10); } const exList = yp_ex.split(/[01]/).filter(Boolean); // Remove empty strings exList.forEach((ex, index) => { const seat_type = SEAT_TYPES[ex as keyof typeof SEAT_TYPES]; const price_str = yp_info_new.slice( index * PRICE_STR_LENGTH, (index + 1) * PRICE_STR_LENGTH ); const price = parseInt(price_str.slice(1, -5), 10); const discount = ex in discounts ? discounts[ex] : null; prices[ex] = { seat_name: seat_type.name, short: seat_type.short, seat_type_code: ex, num: ticketData[`${seat_type.short}_num` as keyof TicketData], price, discount, }; }); return Object.values(prices); } function extractDWFlags(ticketData: TicketData): string[] { const dwFlagList = ticketData.dw_flag.split('#'); let result = []; if ('5' == dwFlagList[0]) { result.push(DW_FLAGS[0]); } if (dwFlagList.length > 1 && '1' == dwFlagList[1]) { result.push(DW_FLAGS[1]); } if (dwFlagList.length > 2) { if ('Q' == dwFlagList[2].substring(0, 1)) { result.push(DW_FLAGS[2]); } else if ('R' == dwFlagList[2].substring(0, 1)) { result.push(DW_FLAGS[3]); } } if (dwFlagList.length > 5 && 'D' == dwFlagList[5]) { result.push(DW_FLAGS[4]); } if (dwFlagList.length > 6 && 'z' != dwFlagList[6]) { result.push(DW_FLAGS[5]); } if (dwFlagList.length > 7 && 'z' != dwFlagList[7]) { result.push(DW_FLAGS[6]); } return result; } async function make12306Request<T>( url: string | URL, scheme: URLSearchParams = new URLSearchParams(), headers: Record<string, string> = {} ): Promise<T | null> { try { const response = await axios.get(url + '?' + scheme.toString(), { headers: headers, }); return (await response.data) as T; } catch (error) { console.error('Error making 12306 request:', error); return null; } } // Create server instance const server = new McpServer({ name: '12306-mcp', version: '1.0.0', capabilities: { resources: {}, tools: {}, }, instructions: 'This server provides information about 12306.You can use this server to query train tickets on 12306.', }); interface QueryResponse { [key: string]: any; httpstatus: string; data: { [key: string]: any; }; messages: string; status: boolean; } server.resource('stations', 'data://all-stations', async (uri) => ({ contents: [{ uri: uri.href, text: JSON.stringify(STATIONS) }], })); server.tool( 'get-stations-code-in-city', '通过城市名查询该城市所有车站的station_code,结果为列表。', { city: z.string().describe('中文城市名称'), }, async ({ city }) => { if (!(city in CITY_STATIONS)) { return { content: [{ type: 'text', text: 'Error: City not found. ' }], }; } return { content: [{ type: 'text', text: JSON.stringify(CITY_STATIONS[city]) }], }; } ); server.tool( 'get-station-code-of-city', '通过城市名查询该城市对应的station_code,结果是唯一的。', { city: z.string().describe('中文城市名称'), }, async ({ city }) => { if (!(city in CITY_CODES)) { return { content: [{ type: 'text', text: 'Error: City not found. ' }], }; } return { content: [{ type: 'text', text: JSON.stringify(CITY_CODES[city]) }], }; } ); server.tool( 'get-station-code-by-name', '通过车站名查询station_code,结果是唯一的。', { stationName: z.string().describe('中文车站名称'), }, async ({ stationName }) => { stationName = stationName.endsWith('站') ? stationName.substring(0, -1) : stationName; if (!(stationName in NAME_STATIONS)) { return { content: [{ type: 'text', text: 'Error: Station not found. ' }], }; } return { content: [ { type: 'text', text: JSON.stringify(NAME_STATIONS[stationName]) }, ], }; } ); server.tool( 'get-station-by-telecode', '通过station_telecode查询车站信息,结果是唯一的。', { stationTelecode: z.string().describe('车站的station_telecode'), }, async ({ stationTelecode }) => { if (!STATIONS[stationTelecode]) { return { content: [{ type: 'text', text: 'Error: Station not found. ' }], }; } return { content: [ { type: 'text', text: JSON.stringify(STATIONS[stationTelecode]) }, ], }; } ); server.tool( 'get-tickets', '查询12306余票信息。', { date: z.string().length(10).describe('日期( 格式: yyyy-mm-dd )'), fromStation: z .string() .describe('出发车站的station_code 或 出发城市的station_code'), toStation: z .string() .describe('到达车站的station_code 或 出发城市的station_code'), trainFilterFlags: z .string() .regex(/^[GDZTKOFS]*$/) .max(8) .optional() .default('') .describe( '车次筛选条件,默认为空。从以下标志中选取多个条件组合[G(高铁/城际),D(动车),Z(直达特快),T(特快),K(快速),O(其他),F(复兴号),S(智能动车组)]' ), }, async ({ date, fromStation, toStation, trainFilterFlags }) => { // 检查日期是否早于当前日期 if (new Date(date).setHours(0, 0, 0, 0) < new Date().setHours(0, 0, 0, 0)) { return { content: [ { type: 'text', text: 'Error: The date cannot be earlier than today.', }, ], }; } if ( !Object.keys(STATIONS).includes(fromStation) || !Object.keys(STATIONS).includes(toStation) ) { return { content: [{ type: 'text', text: 'Error: Station not found. ' }], }; } const queryParams = new URLSearchParams({ 'leftTicketDTO.train_date': date, 'leftTicketDTO.from_station': fromStation, 'leftTicketDTO.to_station': toStation, purpose_codes: 'ADULT', }); const queryUrl = `${API_BASE}/otn/leftTicket/query`; const cookies = await getCookie(API_BASE); if (cookies == null) { return { content: [ { type: 'text', text: 'Error: get cookie failed. Check your network.', }, ], }; } const queryResponse = await make12306Request<QueryResponse>( queryUrl, queryParams, { Cookie: formatCookies(cookies) } ); if (queryResponse === null || queryResponse === undefined) { return { content: [{ type: 'text', text: 'Error: get tickets data failed. ' }], }; } const ticketsData = parseTicketsData(queryResponse.data.result); let ticketsInfo: TicketInfo[]; try { ticketsInfo = parseTicketsInfo(ticketsData); } catch (error) { return { content: [{ type: 'text', text: 'Error: parse tickets info failed. ' }], }; } const filteredTicketsInfo = filterTicketsInfo( ticketsInfo, trainFilterFlags ); return { content: [{ type: 'text', text: formatTicketsInfo(filteredTicketsInfo) }], }; } ); server.tool( 'get-train-route-stations', '查询列车途径车站信息。', { trainNo: z.string().describe('实际车次编号train_no,例如240000G10336.'), fromStationTelecode: z .string() .describe('出发车站的station_telecode_code,而非城市的station_code.'), toStationTelecode: z .string() .describe('到达车站的station_telecode_code,而非城市的station_code.'), departDate: z .string() .length(10) .describe('列车出发日期( 格式: yyyy-mm-dd )'), }, async ({ trainNo: trainNo, fromStationTelecode, toStationTelecode, departDate, }) => { const queryParams = new URLSearchParams({ train_no: trainNo, from_station_telecode: fromStationTelecode, to_station_telecode: toStationTelecode, depart_date: departDate, }); const queryUrl = `${API_BASE}/otn/czxx/queryByTrainNo`; const cookies = await getCookie(API_BASE); if (cookies == null) { return { content: [{ type: 'text', text: 'Error: get cookie failed. ' }], }; } const queryResponse = await make12306Request<QueryResponse>( queryUrl, queryParams, { Cookie: formatCookies(cookies) } ); if (queryResponse == null) { return { content: [ { type: 'text', text: 'Error: get train route stations failed. ' }, ], }; } const routeStationsData = parseRouteStationsData(queryResponse.data.data); const routeStationsInfo = parseRouteStationsInfo(routeStationsData); return { content: [{ type: 'text', text: JSON.stringify(routeStationsInfo) }], }; } ); async function getStations(): Promise<Record<string, StationData>> { const html = await make12306Request<string>(WEB_URL); if (html == null) { throw new Error('Error: get 12306 web page failed.'); } const match = html.match('.(/script/core/common/station_name.+?.js)'); if (match == null) { throw new Error('Error: get station name js file failed.'); } const stationNameJSFilePath = match[0]; const stationNameJS = await make12306Request<string>( new URL(stationNameJSFilePath, WEB_URL) ); if (stationNameJS == null) { throw new Error('Error: get station name js file failed.'); } const rawData = eval(stationNameJS.replace('var station_names =', '')); const stationsData = parseStationsData(rawData); // 加上缺失的车站信息 for (const station of MISSING_STATIONS) { if (!stationsData[station.station_code]) { stationsData[station.station_code] = station; } } return stationsData; } async function init() {} async function main() { const transport = new StdioServerTransport(); await init(); await server.connect(transport); console.error('12306 MCP Server running on stdio @Joooook'); } main().catch((error) => { console.error('Fatal error in main():', error); process.exit(1); });
ID: 24yfio62k1