import { loadTimetable } from './timetable.mjs'; export class ApiError extends Error { constructor(status = 502, code = 'data_unavailable') { super(code); this.status = status; this.code = code; } } const clean = value => typeof value === 'string' ? value.trim() : ''; const ids = value => Array.isArray(value) ? value.map(Number).filter(id => Number.isSafeInteger(id) && id > 0) : []; const strings = value => Array.isArray(value) ? value.map(clean).filter(Boolean) : []; export const localDate = now => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now); export function scheduledInstant(time, now) { const match = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/.exec(clean(time)); if (!match || Number(match[1]) > 23) return null; // ETA is a same-day timetable in Minsk. Never move an expired trip to tomorrow. return `${localDate(now)}T${match[1].padStart(2, '0')}:${match[2]}:${match[3] || '00'}+03:00`; } function routeInfo(route) { return { id: Number(route.id), number: clean(route.route_number), name: clean(route.name), type: route.type === 'minibus' ? 'minibus' : 'bus' }; } export function normalizeDepartures(raw, now, stopNames = new Map()) { if (!raw || !Array.isArray(raw.routes)) throw new ApiError(); let partial = false; const departures = []; const routes = []; for (const item of raw.routes) { if (!item || !Number.isSafeInteger(Number(item.id)) || !clean(item.route_number) || !clean(item.name)) { partial = true; continue; } const route = routeInfo(item); routes.push(route); if (item.error || !Array.isArray(item.arrivals)) { partial = true; continue; } for (const arrival of item.arrivals) { const scheduledAt = scheduledInstant(arrival?.scheduledTime, now); if (!scheduledAt) { partial = true; continue; } if (Date.parse(scheduledAt) + 60000 <= now) continue; const note = clean(arrival.trip?.note); const terminal = stopNames.get(Number(arrival.trip?.endStopId)); const destination = terminal || route.name.split(/\s+[-–—]\s+/).at(-1) || route.name; const identity = [route.id, scheduledAt, arrival.trip?.departureTime || '', arrival.trip?.startStopId || '', arrival.trip?.endStopId || '', note].join(':'); departures.push({ id: identity, routeId: route.id, routeNumber: route.number, routeName: route.name, destination, type: route.type, scheduledAt, scheduledTime: clean(arrival.scheduledTime).slice(0, 5), note, source: 'schedule' }); } } return { departures: [...new Map(departures.map(item => [item.id, item])).values()].sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt)), routes: routes.sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true })), partial }; } export function normalizeAlerts(raw, routes, cityId, now) { if (!Array.isArray(raw)) throw new ApiError(); const routeIds = new Set(routes.map(route => route.id)); const routeNumbers = new Set(routes.map(route => route.number)); return [...new Map(raw.filter(alert => { if (!alert || alert.is_active === false || alert.city_id != null && Number(alert.city_id) !== cityId) return false; const start = alert.start_time == null ? null : Date.parse(alert.start_time); const end = alert.end_time == null ? null : Date.parse(alert.end_time); if (start !== null && (!Number.isFinite(start) || start > now) || end !== null && (!Number.isFinite(end) || end <= now)) return false; const targets = ids(alert.route_ids); if (!targets.length && Number(alert.route_id) > 0) targets.push(Number(alert.route_id)); const numbers = strings(alert.route_numbers); if (!numbers.length && clean(alert.route_number)) numbers.push(clean(alert.route_number)); return targets.length === 0 && numbers.length === 0 || targets.some(id => routeIds.has(id)) || numbers.some(number => routeNumbers.has(number)); }).map(alert => { const numbers = strings(alert.route_numbers); if (!numbers.length && clean(alert.route_number)) numbers.push(clean(alert.route_number)); const targets = ids(alert.route_ids); if (!targets.length && Number(alert.route_id) > 0) targets.push(Number(alert.route_id)); const value = { id: String(alert.id), title: clean(alert.title) || 'Изменения движения', message: clean(alert.message), type: ['cancellation', 'delay', 'detour'].includes(alert.alert_type) ? alert.alert_type : 'info', routeNumbers: [...new Set(numbers)], routeIds: [...new Set(targets)], startAt: alert.start_time || null, endAt: alert.end_time || null }; return [value.id, value]; })).values()]; } export function createBoardClient({ baseUrl = process.env.RIDEBUS_API_BASE_URL || 'https://api.ridebus.by/', apiKey = process.env.RIDEBUS_API_KEY, cityId = Number(process.env.BOARD_CITY_ID || 1), cityName = process.env.BOARD_CITY_NAME || 'Полоцк', stopName = process.env.BOARD_STOP_NAME || 'Швейная фабрика', defaultStopId = Number(process.env.BOARD_STOP_ID || 1), passengerBaseUrl = process.env.BOARD_PWA_URL || 'https://pwa.ridebus.by/', fetchData = fetch, now = Date.now, } = {}) { const cache = new Map(); const pending = new Map(); const url = new URL(baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'); if (!['https:', 'http:'].includes(url.protocol) || !Number.isSafeInteger(cityId) || cityId < 1) throw new Error('Invalid RideBus configuration'); async function upstream(path) { if (!apiKey?.trim()) throw new ApiError(503, 'api_not_configured'); try { const response = await fetchData(new URL(path, url), { headers: { Accept: 'application/json', 'X-API-Key': apiKey, 'X-City-Id': String(cityId) }, redirect: 'error', signal: AbortSignal.timeout(10000) }); if (!response.ok) throw new ApiError(response.status === 404 ? 404 : 502); return await response.json(); } catch (error) { throw error instanceof ApiError ? error : new ApiError(); } } function cached(key, ttl, load) { const entry = cache.get(key); if (entry?.expires > now()) return Promise.resolve(entry.data); if (pending.has(key)) return pending.get(key); const request = load().then(data => { cache.set(key, { expires: now() + ttl, data }); return data; }).finally(() => pending.delete(key)); pending.set(key, request); return request; } function catalog() { return cached('catalog', 300000, async () => { const raw = await upstream('api/v1/stops'); if (!Array.isArray(raw)) throw new ApiError(); return raw.filter(stop => Number(stop.id) > 0 && clean(stop.name)); }); } async function stopDetails(stopId) { if (!Number.isSafeInteger(stopId) || stopId < 1) throw new ApiError(400, 'invalid_stop'); if (!(await catalog()).some(stop => Number(stop.id) === stopId)) throw new ApiError(404, 'stop_not_found'); const stop = await cached(`stop:${stopId}`, 300000, () => upstream(`api/v1/stops/${stopId}`)); if (Number(stop.id) !== stopId || stop.city_id != null && Number(stop.city_id) !== cityId) throw new ApiError(); return stop; } const config = () => ({ cityId, cityName, stopName, defaultStopId, configured: Boolean(apiKey?.trim()), timeZone: 'Europe/Minsk', refreshSeconds: 20, passengerBaseUrl }); return { config, async coordinates(stopId) { const stop = await stopDetails(stopId); return { latitude: stop.latitude, longitude: stop.longitude }; }, async timetable(stopId) { const stop = await stopDetails(stopId); return cached(`timetable:${stopId}`, 1800000, () => loadTimetable({ upstream, cityId, cityName, stop, now: now() })); }, async stops() { return (await catalog()).map(stop => ({ id: Number(stop.id), name: clean(stop.name), direction: clean(stop.description) })); }, async board(stopId) { if (!Number.isSafeInteger(stopId) || stopId < 1) throw new ApiError(400, 'invalid_stop'); const stops = await catalog(); const catalogStop = stops.find(stop => Number(stop.id) === stopId); if (!catalogStop) throw new ApiError(404, 'stop_not_found'); return cached(`board:${stopId}`, 12000, async () => { const [stop, eta] = await Promise.all([ cached(`stop:${stopId}`, 300000, () => upstream(`api/v1/stops/${stopId}`)), upstream(`api/v1/eta/stop/${stopId}`), ]); if (Number(stop.id) !== stopId || stop.city_id != null && Number(stop.city_id) !== cityId) throw new ApiError(); const generatedAt = now(); const data = normalizeDepartures(eta, generatedAt, new Map(stops.map(stop => [Number(stop.id), clean(stop.name)]))); const stopRoutes = Array.isArray(stop.routes) ? stop.routes.filter(route => route?.is_active !== false).map(routeInfo) : data.routes; let alerts = [], alertsAvailable = true; try { alerts = normalizeAlerts(await cached('alerts', 20000, () => upstream('api/v1/alerts/active')), stopRoutes, cityId, now()); } catch { alertsAvailable = false; } return { cityId, cityName, stop: { id: stopId, name: clean(stop.name), direction: clean(stop.description) }, ...data, alerts, alertsAvailable, updatedAt: new Date(generatedAt).toISOString(), serviceDate: localDate(generatedAt), mode: 'live' }; }); }, }; }