UNPKG

@siva-sub/mcp-public-transport

Version:

A Model Context Protocol server for Singapore transport data with real-time information and routing

219 lines (218 loc) 8.54 kB
import axios from 'axios'; import { APIError, RateLimitError } from '../utils/errors.js'; import { logger } from '../utils/logger.js'; export class LTAService { accountKey; cache; timeout; client; // Changed to protected baseUrl = 'https://datamall2.mytransport.sg/ltaodataservice'; constructor(accountKey, cache, timeout = 30000) { this.accountKey = accountKey; this.cache = cache; this.timeout = timeout; this.client = axios.create({ baseURL: this.baseUrl, timeout: this.timeout, headers: { 'AccountKey': this.accountKey, 'Accept': 'application/json', }, }); this.setupInterceptors(); } setupInterceptors() { // Request interceptor for logging this.client.interceptors.request.use((config) => { logger.debug(`LTA API Request: ${config.method?.toUpperCase()} ${config.url}`, { params: config.params, }); return config; }, (error) => Promise.reject(error)); // Response interceptor for error handling this.client.interceptors.response.use((response) => { logger.debug(`LTA API Response: ${response.status} ${response.config.url}`, { dataLength: JSON.stringify(response.data).length, }); return response; }, (error) => { const status = error.response?.status; const message = error.response?.data || error.message; logger.error(`LTA API error: ${status}`, { url: error.config?.url, status, message, }); if (status === 429) { throw new RateLimitError(`LTA API rate limit exceeded: ${message}`); } if (status === 401) { throw new APIError('Invalid LTA API key', 'LTA_AUTH_ERROR', status); } if (status && status >= 500) { throw new APIError(`LTA API server error: ${message}`, 'LTA_SERVER_ERROR', status); } throw new APIError(`LTA API error: ${message}`, 'LTA_API_ERROR', status || 500); }); } async getBusArrival(busStopCode, serviceNo) { const cacheKey = `bus_arrival_${busStopCode}_${serviceNo || 'all'}`; return this.cache.getOrSet(cacheKey, async () => { const params = { BusStopCode: busStopCode }; if (serviceNo) { params.ServiceNo = serviceNo; } const response = await this.client.get('/v3/BusArrival', { params }); return this.formatBusArrival(response.data); }, 30); // 30 second cache for real-time data } async getBusStops(skip = 0, limit = 500) { const cacheKey = `bus_stops_${skip}_${limit}`; return this.cache.getOrSet(cacheKey, async () => { const response = await this.client.get('/BusStops', { params: { $skip: skip, $top: limit } }); return response.data.value.map((stop) => ({ busStopCode: stop.BusStopCode, roadName: stop.RoadName, description: stop.Description, latitude: stop.Latitude, longitude: stop.Longitude, })); }, 3600); // 1 hour cache for static data } async getAllBusStops() { const cacheKey = 'all_bus_stops'; return this.cache.getOrSet(cacheKey, async () => { const allStops = []; let skip = 0; const limit = 500; while (true) { const stops = await this.getBusStops(skip, limit); allStops.push(...stops); if (stops.length < limit) { break; } skip += limit; } logger.info(`Loaded ${allStops.length} bus stops`); return allStops; }, 86400); // 24 hour cache } async getTrainServiceAlerts() { const cacheKey = 'train_service_alerts'; return this.cache.getOrSet(cacheKey, async () => { const response = await this.client.get('/TrainServiceAlerts'); return this.formatTrainAlerts(response.data); }, 60); // 1 minute cache } async getTaxiAvailability() { const cacheKey = 'taxi_availability'; return this.cache.getOrSet(cacheKey, async () => { const response = await this.client.get('/Taxi-Availability'); return response.data.value.map((taxi) => ({ coordinates: [taxi.Latitude, taxi.Longitude], timestamp: taxi.Timestamp, taxiCount: taxi.NoOfTaxis, // Map NoOfTaxis to taxiCount })); }, 30); // 30 second cache } async getTrafficIncidents() { const cacheKey = 'traffic_incidents'; return this.cache.getOrSet(cacheKey, async () => { const response = await this.client.get('/TrafficIncidents'); return response.data.value.map((incident) => ({ type: incident.Type, message: incident.Message, coordinates: incident.Latitude && incident.Longitude ? [incident.Latitude, incident.Longitude] : undefined, })); }, 120); // 2 minute cache } formatBusArrival(data) { if (!data.Services) { return []; } return data.Services.map((service) => ({ serviceNo: service.ServiceNo, operator: this.getOperatorName(service.Operator), nextBuses: [service.NextBus, service.NextBus2, service.NextBus3] .filter(bus => bus?.EstimatedArrival) .map(bus => ({ estimatedArrival: bus.EstimatedArrival, minutesAway: this.calculateMinutesAway(bus.EstimatedArrival), loadStatus: bus.Load, loadDescription: this.formatLoadStatus(bus.Load), wheelchairAccessible: bus.Feature === 'WAB', busType: bus.Type, busTypeDescription: this.formatBusType(bus.Type), latitude: bus.Latitude, longitude: bus.Longitude, visitNumber: bus.VisitNumber, })), })).filter(service => service.nextBuses.length > 0); } formatTrainAlerts(data) { if (data.Status === 1) { return []; // No alerts } const alerts = []; if (data.AffectedSegments) { alerts.push(...data.AffectedSegments.map((segment) => ({ line: segment.Line, lineName: this.getLineName(segment.Line), status: 'Disrupted', direction: segment.Direction, affectedStations: segment.Stations?.split(',').map(s => s.trim()) || [], alternativeTransport: segment.FreePublicBus === 'Y', shuttleService: segment.FreeMRTShuttle === 'Y', }))); } return alerts; } getOperatorName(code) { const operators = { 'SBST': 'SBS Transit', 'SMRT': 'SMRT Buses', 'TTS': 'Tower Transit Singapore', 'GAS': 'Go-Ahead Singapore', }; return operators[code] || code; } getLineName(code) { const lines = { 'EWL': 'East West Line', 'NSL': 'North South Line', 'CCL': 'Circle Line', 'DTL': 'Downtown Line', 'NEL': 'North East Line', 'BPL': 'Bukit Panjang LRT', 'SLRT': 'Sengkang LRT', 'PLRT': 'Punggol LRT', 'TEL': 'Thomson-East Coast Line', }; return lines[code] || code; } formatLoadStatus(load) { const loads = { 'SEA': 'Seats Available', 'SDA': 'Standing Available', 'LSD': 'Limited Standing', }; return loads[load] || load || 'Unknown'; } formatBusType(busType) { const types = { 'SD': 'Single Deck', 'DD': 'Double Deck', 'BD': 'Bendy Bus', }; return types[busType] || busType || 'Unknown'; } calculateMinutesAway(arrivalTime) { const arrival = new Date(arrivalTime); const now = new Date(); return Math.max(0, Math.floor((arrival.getTime() - now.getTime()) / 60000)); } }