158 lines
9.7 KiB
JavaScript
158 lines
9.7 KiB
JavaScript
import { loadTimetable } from './timetable.mjs';
|
|
import { parseCancellationNotice } from '../src/lib/cancellations.mjs';
|
|
import { localDate, shiftDate } from '../src/lib/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 { localDate };
|
|
|
|
export function scheduledInstant(time, now, today = localDate(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 `${today}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 = [];
|
|
const today = localDate(now), yesterday = shiftDate(today, -1);
|
|
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, today);
|
|
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(':');
|
|
const originTime = scheduledInstant(arrival.trip?.departureTime, now, today)?.slice(11, 16);
|
|
departures.push({ id: identity, routeId: route.id, routeNumber: route.number, routeName: route.name,
|
|
destination, type: route.type, scheduledAt, scheduledTime: scheduledAt.slice(11, 16), note,
|
|
serviceDate: originTime && originTime > scheduledAt.slice(11, 16) ? yesterday : today,
|
|
originStopId: Number(arrival.trip?.startStopId) || undefined, originTime,
|
|
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, stops = []) {
|
|
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));
|
|
if (!numbers.length) numbers.push(...routes.filter(route => targets.includes(route.id)).map(route => route.number));
|
|
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 };
|
|
if (value.type === 'cancellation') value.cancellations = parseCancellationNotice(value.message, value.routeNumbers, stops);
|
|
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(), stops); }
|
|
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' };
|
|
});
|
|
},
|
|
};
|
|
}
|