db-vendo-client/index.js

488 lines
17 KiB
JavaScript
Raw Normal View History

import isObj from 'lodash/isObject.js';
2024-12-17 19:41:00 +00:00
import distance from 'gps-distance';
2024-12-18 00:21:10 +00:00
import readStations from 'db-hafas-stations';
2016-06-22 01:39:04 +02:00
import {defaultProfile} from './lib/default-profile.js';
import {validateProfile} from './lib/validate-profile.js';
import {INVALID_REQUEST} from './lib/errors.js';
import {HafasError} from './lib/errors.js';
2016-06-22 02:09:02 +02:00
// background info: https://github.com/public-transport/hafas-client/issues/286
const FORBIDDEN_USER_AGENTS = [
'my-awesome-program', // previously used in readme.md, p/*/readme.md & docs/*.md
'hafas-client-example', // previously used in p/*/example.js
'link-to-your-project-or-email', // now used throughout
];
const isNonEmptyString = str => 'string' === typeof str && str.length > 0;
2018-02-28 16:09:23 +01:00
2018-08-25 01:52:46 +02:00
const validateLocation = (loc, name = 'location') => {
if (!isObj(loc)) {
throw new TypeError(name + ' must be an object.');
2018-08-25 01:52:46 +02:00
} else if (loc.type !== 'location') {
throw new TypeError('invalid location object.');
2018-08-25 01:52:46 +02:00
} else if ('number' !== typeof loc.latitude) {
throw new TypeError(name + '.latitude must be a number.');
2018-08-25 01:52:46 +02:00
} else if ('number' !== typeof loc.longitude) {
throw new TypeError(name + '.longitude must be a number.');
2018-08-25 01:52:46 +02:00
}
};
2018-08-25 01:52:46 +02:00
2024-12-18 00:08:52 +00:00
const loadEnrichedStationData = () => new Promise((resolve, reject) => {
2024-12-18 00:21:10 +00:00
const items = {};
2024-12-18 00:08:52 +00:00
readStations.full()
2024-12-18 00:21:10 +00:00
.on('data', (station) => {
items[station.id] = station;
})
.once('end', () => {
console.info('Loaded station index.');
resolve(items);
})
.once('error', (err) => {
reject(err);
});
2024-12-18 00:08:52 +00:00
});
const createClient = (profile, userAgent, opt = {}) => {
profile = Object.assign({}, defaultProfile, profile);
validateProfile(profile);
2024-12-18 00:08:52 +00:00
const common = {};
if (opt.enrichStations !== false) {
2024-12-18 00:21:10 +00:00
loadEnrichedStationData()
.then(locations => {
common.locations = locations;
});
2024-12-18 00:08:52 +00:00
}
2017-10-03 17:36:42 +02:00
2018-07-19 21:50:20 +02:00
if ('string' !== typeof userAgent) {
2019-02-13 17:55:57 +01:00
throw new TypeError('userAgent must be a string');
2018-07-19 21:50:20 +02:00
}
if (FORBIDDEN_USER_AGENTS.includes(userAgent.toLowerCase())) {
throw new TypeError(`userAgent should tell the API operators how to contact you. If you have copied "${userAgent}" value from the documentation, please adapt it.`);
}
2018-07-19 21:50:20 +02:00
const _stationBoard = async (station, type, resultsField, parse, opt = {}) => {
2024-12-07 22:46:04 +00:00
if (isObj(station) && station.id) {
station = station.id;
} else if ('string' !== typeof station) {
throw new TypeError('station must be an object or a string.');
}
2017-11-12 18:06:16 +01:00
2018-06-26 15:49:50 +02:00
if ('string' !== typeof type || !type) {
throw new TypeError('type must be a non-empty string.');
2018-06-26 15:49:50 +02:00
}
if (!profile.departuresGetPasslist && 'stopovers' in opt) {
throw new Error('opt.stopovers is not supported by this endpoint');
}
if (!profile.departuresStbFltrEquiv && 'includeRelatedStations' in opt) {
throw new Error('opt.includeRelatedStations is not supported by this endpoint');
}
2017-11-12 18:06:16 +01:00
opt = Object.assign({
2019-10-28 17:42:33 +01:00
// todo: for arrivals(), this is actually a station it *has already* stopped by
direction: null, // only show departures stopping by this station
2020-03-09 21:59:17 +01:00
line: null, // filter by line ID
duration: 10, // show departures for the next n minutes
results: null, // max. number of results; `null` means "whatever HAFAS wants"
2020-03-18 20:04:39 +01:00
subStops: true, // parse & expose sub-stops of stations?
entrances: true, // parse & expose entrances of stops/stations?
linesOfStops: false, // parse & expose lines at the stop/station?
remarks: true, // parse & expose hints & warnings?
stopovers: false, // fetch & parse previous/next stopovers?
// departures at related stations
// e.g. those that belong together on the metro map.
includeRelatedStations: true,
}, opt);
opt.when = new Date(opt.when || Date.now());
if (Number.isNaN(Number(opt.when))) {
throw new Error('opt.when is invalid');
}
2024-12-07 22:46:04 +00:00
const req = profile.formatStationBoardReq({profile, opt}, station, resultsField);
2019-10-31 20:08:56 +01:00
2024-12-18 00:21:10 +00:00
const {res} = await profile.request({profile, opt}, userAgent, req);
const ctx = {profile, opt, common, res};
2024-12-10 17:51:20 +00:00
const results = (res[resultsField] || res.items).map(res => parse(ctx, res)); // todo sort?
return {
[resultsField]: results,
2024-12-07 22:46:04 +00:00
realtimeDataUpdatedAt: null, // TODO
};
};
2017-11-12 18:06:16 +01:00
2021-12-29 18:53:50 +01:00
const departures = async (station, opt = {}) => {
return await _stationBoard(station, 'DEP', 'departures', profile.parseDeparture, opt);
};
2021-12-29 18:53:50 +01:00
const arrivals = async (station, opt = {}) => {
return await _stationBoard(station, 'ARR', 'arrivals', profile.parseArrival, opt);
};
2018-06-26 17:11:25 +02:00
2021-12-29 18:53:50 +01:00
const journeys = async (from, to, opt = {}) => {
from = profile.formatLocation(profile, from, 'from');
to = profile.formatLocation(profile, to, 'to');
2017-11-12 20:02:32 +01:00
if ('earlierThan' in opt && 'laterThan' in opt) {
throw new TypeError('opt.earlierThan and opt.laterThan are mutually exclusive.');
}
if ('departure' in opt && 'arrival' in opt) {
throw new TypeError('opt.departure and opt.arrival are mutually exclusive.');
}
let journeysRef = null;
if ('earlierThan' in opt) {
if (!isNonEmptyString(opt.earlierThan)) {
throw new TypeError('opt.earlierThan must be a non-empty string.');
}
if ('departure' in opt || 'arrival' in opt) {
throw new TypeError('opt.earlierThan and opt.departure/opt.arrival are mutually exclusive.');
}
journeysRef = opt.earlierThan;
}
if ('laterThan' in opt) {
if (!isNonEmptyString(opt.laterThan)) {
throw new TypeError('opt.laterThan must be a non-empty string.');
}
if ('departure' in opt || 'arrival' in opt) {
throw new TypeError('opt.laterThan and opt.departure/opt.arrival are mutually exclusive.');
}
journeysRef = opt.laterThan;
}
2017-11-12 20:02:32 +01:00
opt = Object.assign({
results: null, // number of journeys `null` means "whatever HAFAS returns"
2017-11-12 20:02:32 +01:00
via: null, // let journeys pass this station?
stopovers: false, // return stations on the way?
2024-12-07 18:29:16 +00:00
transfers: null, // maximum nr of transfers
2017-11-12 20:02:32 +01:00
transferTime: 0, // minimum time for a single transfer in minutes
// todo: does this work with every endpoint?
accessibility: 'none', // 'none', 'partial' or 'complete'
bike: false, // only bike-friendly journeys
2018-10-25 16:11:19 +02:00
walkingSpeed: 'normal', // 'slow', 'normal', 'fast'
// Consider walking to nearby stations at the beginning of a journey?
2018-07-23 20:42:22 +02:00
startWithWalking: true,
tickets: false, // return tickets?
polylines: false, // return leg shapes?
2020-03-18 20:04:39 +01:00
subStops: true, // parse & expose sub-stops of stations?
entrances: true, // parse & expose entrances of stops/stations?
remarks: true, // parse & expose hints & warnings?
scheduledDays: false, // parse & expose dates each journey is valid on?
}, opt);
if (opt.via) {
opt.via = profile.formatLocation(profile, opt.via, 'opt.via');
}
if (opt.when !== undefined) {
throw new Error('opt.when is not supported anymore. Use opt.departure/opt.arrival.');
}
let when = new Date(), outFrwd = true;
if (opt.departure !== undefined && opt.departure !== null) {
when = new Date(opt.departure);
if (Number.isNaN(Number(when))) {
throw new TypeError('opt.departure is invalid');
}
} else if (opt.arrival !== undefined && opt.arrival !== null) {
2019-12-29 22:16:45 +01:00
if (!profile.journeysOutFrwd) {
throw new Error('opt.arrival is unsupported');
}
when = new Date(opt.arrival);
if (Number.isNaN(Number(when))) {
throw new TypeError('opt.arrival is invalid');
2019-12-29 22:16:45 +01:00
}
outFrwd = false;
}
2024-12-07 16:16:31 +00:00
const filters = profile.formatProductsFilter({profile}, opt.products || {});
// TODO opt.accessibility
2018-10-25 16:11:19 +02:00
const query = {
2024-12-07 18:29:16 +00:00
maxUmstiege: opt.transfers,
minUmstiegszeit: opt.transferTime,
2024-12-07 16:16:31 +00:00
deutschlandTicketVorhanden: false,
nurDeutschlandTicketVerbindungen: false,
reservierungsKontingenteVorhanden: false,
schnelleVerbindungen: true,
sitzplatzOnly: false,
abfahrtsHalt: from.lid,
2024-12-07 18:29:16 +00:00
zwischenhalte: opt.via
2024-12-17 19:41:00 +00:00
? [{id: opt.via.lid}]
2024-12-07 18:29:16 +00:00
: null,
2024-12-07 16:16:31 +00:00
ankunftsHalt: to.lid,
produktgattungen: filters,
bikeCarriage: opt.bike,
// TODO
// todo: this is actually "take additional stations nearby the given start and destination station into account"
// see rest.exe docs
2024-12-08 21:42:57 +00:00
// ushrp: Boolean(opt.startWithWalking),
};
2024-12-17 20:26:48 +00:00
query.anfrageZeitpunkt = profile.formatTime(profile, when);
2024-12-08 21:42:57 +00:00
if (journeysRef) {
2024-12-07 18:29:16 +00:00
query.pagingReference = journeysRef;
}
2024-12-07 16:16:31 +00:00
query.ankunftSuche = outFrwd ? 'ABFAHRT' : 'ANKUNFT';
if (opt.results !== null) {
2024-12-07 16:16:31 +00:00
// TODO query.numF = opt.results;
}
2024-12-07 22:46:04 +00:00
const req = profile.transformJourneysQuery({profile, opt}, query);
2024-12-18 00:21:10 +00:00
const {res} = await profile.request({profile, opt}, userAgent, req);
const ctx = {profile, opt, common, res};
const verbindungen = opt.results ? res.verbindungen.slice(0, opt.results) : res.verbindungen;
const journeys = verbindungen
.map(j => profile.parseJourney(ctx, j));
2021-12-29 18:53:50 +01:00
return {
2024-12-07 16:16:31 +00:00
earlierRef: res.verbindungReference?.earlier || null,
laterRef: res.verbindungReference?.later || null,
2021-12-29 18:53:50 +01:00
journeys,
2024-12-08 21:42:57 +00:00
realtimeDataUpdatedAt: null, // TODO
};
};
2017-11-12 20:02:32 +01:00
2021-12-29 18:53:50 +01:00
const refreshJourney = async (refreshToken, opt = {}) => {
2018-07-24 17:37:36 +02:00
if ('string' !== typeof refreshToken || !refreshToken) {
throw new TypeError('refreshToken must be a non-empty string.');
2018-07-24 17:37:36 +02:00
}
opt = Object.assign({
stopovers: false, // return stations on the way?
tickets: false, // return tickets?
polylines: false, // return leg shapes? (not supported by all endpoints)
2020-03-18 20:04:39 +01:00
subStops: true, // parse & expose sub-stops of stations?
entrances: true, // parse & expose entrances of stops/stations?
remarks: true, // parse & expose hints & warnings?
scheduledDays: false, // parse & expose dates the journey is valid on?
}, opt);
2018-07-24 17:37:36 +02:00
const req = profile.formatRefreshJourneyReq({profile, opt}, refreshToken);
2024-12-18 00:21:10 +00:00
const {res} = await profile.request({profile, opt}, userAgent, req);
const ctx = {profile, opt, common, res};
2021-12-29 18:53:50 +01:00
return {
journey: profile.parseJourney(ctx, res.verbindungen[0]),
realtimeDataUpdatedAt: null, // TODO
};
};
2017-11-12 20:02:32 +01:00
2021-12-29 18:53:50 +01:00
const locations = async (query, opt = {}) => {
if (!isNonEmptyString(query)) {
throw new TypeError('query must be a non-empty string.');
2018-02-28 16:09:23 +01:00
}
2017-11-12 20:19:33 +01:00
opt = Object.assign({
fuzzy: true, // find only exact matches?
results: 5, // how many search results?
stops: true, // return stops/stations?
2017-11-12 20:19:33 +01:00
addresses: true,
poi: true, // points of interest
2020-03-18 20:04:39 +01:00
subStops: true, // parse & expose sub-stops of stations?
entrances: true, // parse & expose entrances of stops/stations?
linesOfStops: false, // parse & expose lines at each stop/station?
}, opt);
const req = profile.formatLocationsReq({profile, opt}, query);
2019-10-31 20:08:56 +01:00
2024-12-18 00:21:10 +00:00
const {res} = await profile.request({profile, opt}, userAgent, req);
const ctx = {profile, opt, common, res};
2024-12-07 18:29:16 +00:00
return res.map(loc => profile.parseLocation(ctx, loc));
};
2017-11-12 20:19:33 +01:00
2024-12-18 00:21:10 +00:00
const stop = async (stop, opt = {}) => { // TODO
if ('object' === typeof stop) {
stop = profile.formatStation(stop.id);
} else if ('string' === typeof stop) {
stop = profile.formatStation(stop);
} else {
throw new TypeError('stop must be an object or a string.');
}
2018-01-26 17:08:07 +01:00
opt = Object.assign({
linesOfStops: false, // parse & expose lines at the stop/station?
2020-03-18 20:04:39 +01:00
subStops: true, // parse & expose sub-stops of stations?
entrances: true, // parse & expose entrances of stops/stations?
remarks: true, // parse & expose hints & warnings?
}, opt);
2019-10-31 20:08:56 +01:00
const req = profile.formatStopReq({profile, opt}, stop);
2019-10-31 20:08:56 +01:00
2024-12-18 00:21:10 +00:00
const {res} = await profile.request({profile, opt}, userAgent, req);
2021-12-29 18:53:50 +01:00
if (!res || !Array.isArray(res.locL) || !res.locL[0]) {
2022-05-03 23:21:44 +02:00
throw new HafasError('invalid response, expected locL[0]', null, {
// This problem occurs on invalid input. 🙄
code: INVALID_REQUEST,
});
2021-12-29 18:53:50 +01:00
}
const ctx = {profile, opt, res, common};
return profile.parseLocation(ctx, res.locL[0]);
};
2018-01-26 17:08:07 +01:00
2021-12-29 18:53:50 +01:00
const nearby = async (location, opt = {}) => {
validateLocation(location, 'location');
2018-01-05 14:53:03 +01:00
2017-11-12 20:29:57 +01:00
opt = Object.assign({
results: 8, // maximum number of results
distance: null, // maximum walking distance in meters
poi: false, // return points of interest?
stops: true, // return stops/stations?
2020-03-18 20:04:39 +01:00
subStops: true, // parse & expose sub-stops of stations?
entrances: true, // parse & expose entrances of stops/stations?
linesOfStops: false, // parse & expose lines at each stop/station?
}, opt);
2017-11-12 20:29:57 +01:00
const req = profile.formatNearbyReq({profile, opt}, location);
2024-12-18 00:21:10 +00:00
const {res} = await profile.request({profile, opt}, userAgent, req);
2019-10-31 20:08:56 +01:00
const ctx = {profile, opt, common, res};
2024-12-17 19:41:00 +00:00
const results = res.map(loc => {
const res = profile.parseLocation(ctx, loc);
if (res.latitude || res.location?.latitude) {
res.distance = Math.round(distance(location.latitude, location.longitude, res.latitude || res.location?.latitude, res.longitude || res.location?.longitude) * 1000);
}
return res;
});
2021-12-29 18:53:50 +01:00
return Number.isInteger(opt.results)
? results.slice(0, opt.results)
: results;
};
2017-11-12 20:29:57 +01:00
const trip = async (id, opt = {}) => {
if (!isNonEmptyString(id)) {
throw new TypeError('id must be a non-empty string.');
2018-02-28 16:09:23 +01:00
}
2017-12-17 20:33:04 +01:00
opt = Object.assign({
stopovers: true, // return stations on the way?
polyline: false, // return a track shape?
2020-03-18 20:04:39 +01:00
subStops: true, // parse & expose sub-stops of stations?
entrances: true, // parse & expose entrances of stops/stations?
2022-11-16 15:18:12 +01:00
remarks: true, // parse & expose hints & warnings?
scheduledDays: false, // parse & expose dates trip is valid on?
}, opt);
2017-11-20 15:43:13 +01:00
const req = profile.formatTripReq({profile, opt}, id);
2019-10-31 20:08:56 +01:00
2024-12-18 00:21:10 +00:00
const {res} = await profile.request({profile, opt}, userAgent, req);
const ctx = {profile, opt, common, res};
2021-12-29 18:53:50 +01:00
const trip = profile.parseTrip(ctx, res.journey);
return {
trip,
realtimeDataUpdatedAt: res.planrtTS && res.planrtTS !== '0'
? parseInt(res.planrtTS)
: null,
};
};
2017-11-20 15:43:13 +01:00
// todo [breaking]: rename to trips()?
2021-12-29 18:53:50 +01:00
const tripsByName = async (lineNameOrFahrtNr = '*', opt = {}) => {
if (!isNonEmptyString(lineNameOrFahrtNr)) {
throw new TypeError('lineNameOrFahrtNr must be a non-empty string.');
}
opt = Object.assign({
when: null,
fromWhen: null, untilWhen: null,
onlyCurrentlyRunning: true,
products: {},
currentlyStoppingAt: null,
lineName: null,
operatorNames: null,
additionalFilters: [], // undocumented
}, opt);
const req = {
// fields: https://github.com/marudor/BahnhofsAbfahrten/blob/f619e754f212980261eb7e2b151cd73ba0213da8/packages/types/HAFAS/JourneyMatch.ts#L4-L23
input: lineNameOrFahrtNr,
onlyCR: opt.onlyCurrentlyRunning,
jnyFltrL: [
profile.formatProductsFilter({profile}, opt.products),
],
// todo: passing `tripId` yields a `CGI_READ_FAILED` error
// todo: passing a stop ID as `extId` yields a `PARAMETER` error
// todo: `onlyRT: true` reduces the number of results, but filters recent trips 🤔
// todo: `onlyTN: true` yields a `NO_MATCH` error
// todo: useAeqi
};
if (opt.when !== null) {
req.date = profile.formatDate(profile, new Date(opt.when));
req.time = profile.formatTime(profile, new Date(opt.when));
}
// todo: fromWhen doesn't work yet, but untilWhen does
if (opt.fromWhen !== null) {
req.dateB = profile.formatDate(profile, new Date(opt.fromWhen));
req.timeB = profile.formatTime(profile, new Date(opt.fromWhen));
}
if (opt.untilWhen !== null) {
req.dateE = profile.formatDate(profile, new Date(opt.untilWhen));
req.timeE = profile.formatTime(profile, new Date(opt.untilWhen));
}
const filter = (mode, type, value) => ({mode, type, value});
if (opt.currentlyStoppingAt !== null) {
if (!isNonEmptyString(opt.currentlyStoppingAt)) {
throw new TypeError('opt.currentlyStoppingAt must be a non-empty string.');
}
req.jnyFltrL.push(filter('INC', 'STATIONS', opt.currentlyStoppingAt));
}
if (opt.lineName !== null) {
if (!isNonEmptyString(opt.lineName)) {
throw new TypeError('opt.lineName must be a non-empty string.');
}
// todo: does this target `line` or `lineId`?
req.jnyFltrL.push(filter('INC', 'LINE', opt.lineName));
}
if (opt.operatorNames !== null) {
if (
!Array.isArray(opt.operatorNames)
|| opt.operatorNames.length === 0
|| !opt.operatorNames.every(isNonEmptyString)
) {
throw new TypeError('opt.operatorNames must be an array of non-empty strings.');
}
// todo: is the an escaping mechanism for ","
req.jnyFltrL.push(filter('INC', 'OP', opt.operatorNames.join(',')));
}
req.jnyFltrL = [...req.jnyFltrL, ...opt.additionalFilters];
2024-12-18 00:21:10 +00:00
const {res} = await profile.request({profile, opt}, userAgent, {
cfg: {polyEnc: 'GPA'},
meth: 'JourneyMatch',
req,
});
2021-10-26 14:15:17 +02:00
// todo [breaking]: catch `NO_MATCH` errors, return []
const ctx = {profile, opt, common, res};
2021-12-29 18:53:50 +01:00
const trips = res.jnyL.map(t => profile.parseTrip(ctx, t));
return {
trips,
realtimeDataUpdatedAt: res.planrtTS && res.planrtTS !== '0'
? parseInt(res.planrtTS)
: null,
};
};
const client = {
departures,
arrivals,
journeys,
locations,
stop,
nearby,
};
if (profile.trip) {
client.trip = trip;
}
if (profile.refreshJourney) {
client.refreshJourney = refreshJourney;
}
if (profile.tripsByName) {
client.tripsByName = tripsByName;
}
Object.defineProperty(client, 'profile', {value: profile});
return client;
};
2016-06-22 01:39:59 +02:00
2022-05-07 16:17:37 +02:00
export {
createClient,
};