db-vendo-client/index.js

576 lines
19 KiB
JavaScript
Raw Normal View History

2016-06-22 01:39:04 +02:00
'use strict'
2018-03-18 00:13:44 +01:00
const isObj = require('lodash/isObject')
2018-08-25 01:52:46 +02:00
const sortBy = require('lodash/sortBy')
2018-09-03 20:38:56 +02:00
const pRetry = require('p-retry')
2020-03-09 21:44:35 +01:00
const omit = require('lodash/omit')
2017-11-20 15:43:13 +01:00
const defaultProfile = require('./lib/default-profile')
const validateProfile = require('./lib/validate-profile')
const {INVALID_REQUEST} = require('./lib/errors')
2016-06-22 02:09:02 +02:00
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)) {
2019-02-13 17:55:57 +01:00
throw new TypeError(name + ' must be an object.')
2018-08-25 01:52:46 +02:00
} else if (loc.type !== 'location') {
2019-02-13 17:55:57 +01:00
throw new TypeError('invalid location object.')
2018-08-25 01:52:46 +02:00
} else if ('number' !== typeof loc.latitude) {
2019-02-13 17:55:57 +01:00
throw new TypeError(name + '.latitude must be a number.')
2018-08-25 01:52:46 +02:00
} else if ('number' !== typeof loc.longitude) {
2019-02-13 17:55:57 +01:00
throw new TypeError(name + '.longitude must be a number.')
2018-08-25 01:52:46 +02:00
}
}
2020-03-09 20:54:43 +01:00
const validateWhen = (when, name = 'when') => {
if (Number.isNaN(+when)) {
throw new TypeError(name + ' is invalid')
}
}
const createClient = (profile, userAgent, opt = {}) => {
2017-11-11 22:49:04 +01:00
profile = Object.assign({}, defaultProfile, profile)
2017-12-12 03:21:12 +01:00
validateProfile(profile)
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
}
const _stationBoard = (station, type, parse, opt = {}) => {
2018-02-28 16:09:23 +01:00
if (isObj(station)) station = profile.formatStation(station.id)
else if ('string' === typeof station) station = profile.formatStation(station)
2019-02-13 17:55:57 +01:00
else 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) {
2019-02-13 17:55:57 +01:00
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
2017-11-12 18:06:16 +01:00
}, opt)
2018-05-21 17:10:42 +02:00
opt.when = new Date(opt.when || Date.now())
if (Number.isNaN(+opt.when)) throw new Error('opt.when is invalid')
2019-10-31 20:08:56 +01:00
const req = profile.formatStationBoardReq({profile, opt}, station, type)
return profile.request({profile, opt}, userAgent, req)
.then(({res, common}) => {
if (!Array.isArray(res.jnyL)) return []
const ctx = {profile, opt, common, res}
return res.jnyL.map(res => parse(ctx, res))
.sort((a, b) => new Date(a.when) - new Date(b.when)) // todo
2017-11-12 18:06:16 +01:00
})
}
2018-06-26 17:11:25 +02:00
const departures = (station, opt = {}) => {
return _stationBoard(station, 'DEP', profile.parseDeparture, opt)
}
const arrivals = (station, opt = {}) => {
return _stationBoard(station, 'ARR', profile.parseArrival, opt)
}
const journeys = (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)) {
2019-02-13 17:55:57 +01:00
throw new TypeError('opt.earlierThan and opt.laterThan are mutually exclusive.')
}
if (('departure' in opt) && ('arrival' in opt)) {
2019-02-13 17:55:57 +01:00
throw new TypeError('opt.departure and opt.arrival are mutually exclusive.')
}
let journeysRef = null
if ('earlierThan' in opt) {
if (!isNonEmptyString(opt.earlierThan)) {
2019-02-13 17:55:57 +01:00
throw new TypeError('opt.earlierThan must be a non-empty string.')
}
if (('departure' in opt) || ('arrival' in opt)) {
2019-02-13 17:55:57 +01:00
throw new TypeError('opt.earlierThan and opt.departure/opt.arrival are mutually exclusive.')
}
journeysRef = opt.earlierThan
}
if ('laterThan' in opt) {
if (!isNonEmptyString(opt.laterThan)) {
2019-02-13 17:55:57 +01:00
throw new TypeError('opt.laterThan must be a non-empty string.')
}
if (('departure' in opt) || ('arrival' in opt)) {
2019-02-13 17:55:57 +01:00
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?
transfers: -1, // 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?
2018-07-23 20:42:22 +02:00
scheduledDays: false
2017-11-12 20:02:32 +01:00
}, 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)
2019-02-13 17:55:57 +01:00
if (Number.isNaN(+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)
2019-02-13 17:55:57 +01:00
if (Number.isNaN(+when)) throw new TypeError('opt.arrival is invalid')
outFrwd = false
}
const filters = [
profile.formatProductsFilter({profile}, opt.products || {})
]
if (
opt.accessibility &&
profile.filters &&
profile.filters.accessibility &&
profile.filters.accessibility[opt.accessibility]
) {
filters.push(profile.filters.accessibility[opt.accessibility])
}
2017-11-12 20:02:32 +01:00
2018-10-25 16:11:19 +02:00
if (!['slow','normal','fast'].includes(opt.walkingSpeed)) {
throw new Error('opt.walkingSpeed must be one of these values: "slow", "normal", "fast".')
}
const gisFltrL = []
if (profile.journeysWalkingSpeed) {
gisFltrL.push({
meta: 'foot_speed_' + opt.walkingSpeed,
mode: 'FB',
type: 'M'
})
}
2018-10-25 16:11:19 +02:00
const query = {
getPasslist: !!opt.stopovers,
maxChg: opt.transfers,
minChgTime: opt.transferTime,
depLocL: [from],
viaLocL: opt.via ? [{loc: opt.via}] : null,
arrLocL: [to],
jnyFltrL: filters,
gisFltrL,
getTariff: !!opt.tickets,
// todo: this is actually "take additional stations nearby the given start and destination station into account"
// see rest.exe docs
ushrp: !!opt.startWithWalking,
getPT: true, // todo: what is this?
getIV: false, // todo: walk & bike as alternatives?
getPolyline: !!opt.polylines
// todo: `getConGroups: false` what is this?
// todo: what is getEco, fwrd?
}
if (journeysRef) query.ctxScr = journeysRef
else {
query.outDate = profile.formatDate(profile, when)
query.outTime = profile.formatTime(profile, when)
}
if (opt.results !== null) query.numF = opt.results
if (profile.journeysOutFrwd) query.outFrwd = outFrwd
return profile.request({profile, opt}, userAgent, {
cfg: {polyEnc: 'GPA'},
meth: 'TripSearch',
req: profile.transformJourneysQuery({profile, opt}, query)
})
.then(({res, common}) => {
if (!Array.isArray(res.outConL)) return []
// todo: outConGrpL
const ctx = {profile, opt, common, res}
const journeys = res.outConL
.map(j => profile.parseJourney(ctx, j))
return {
earlierRef: res.outCtxScrB,
laterRef: res.outCtxScrF,
journeys,
realtimeDataFrom: res.planrtTS ? parseInt(res.planrtTS) : null,
}
})
2017-11-12 20:02:32 +01:00
}
2018-07-24 17:37:36 +02:00
const refreshJourney = (refreshToken, opt = {}) => {
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?
2020-03-18 20:04:39 +01:00
subStops: true, // parse & expose sub-stops of stations?
entrances: true, // parse & expose entrances of stops/stations?
2018-07-24 17:37:36 +02:00
remarks: true // parse & expose hints & warnings?
}, opt)
const req = profile.formatRefreshJourneyReq({profile, opt}, refreshToken)
return profile.request({profile, opt}, userAgent, req)
.then(({res, common}) => {
if (!Array.isArray(res.outConL) || !res.outConL[0]) {
const err = new Error('invalid response')
// technically this is not a HAFAS error
// todo: find a different flag with decent DX
err.isHafasError = true
throw err
2018-07-24 17:37:36 +02:00
}
const ctx = {profile, opt, common, res}
return profile.parseJourney(ctx, res.outConL[0])
2018-07-24 17:37:36 +02:00
})
2017-11-12 20:02:32 +01:00
}
2017-11-12 20:19:33 +01:00
const locations = (query, opt = {}) => {
if (!isNonEmptyString(query)) {
2019-02-13 17:55:57 +01:00
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?
2017-11-12 20:19:33 +01:00
}, opt)
2019-10-31 20:08:56 +01:00
const req = profile.formatLocationsReq({profile, opt}, query)
return profile.request({profile, opt}, userAgent, req)
.then(({res, common}) => {
if (!res.match || !Array.isArray(res.match.locL)) return []
const ctx = {profile, opt, common, res}
return res.match.locL.map(loc => profile.parseLocation(ctx, loc))
2017-11-12 20:19:33 +01:00
})
}
2018-11-21 19:54:40 +01:00
const stop = (stop, opt = {}) => {
if ('object' === typeof stop) stop = profile.formatStation(stop.id)
else if ('string' === typeof stop) stop = profile.formatStation(stop)
2019-02-13 17:55:57 +01:00
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)
return profile.request({profile, opt}, userAgent, req)
.then(({res, common}) => {
if (!res || !Array.isArray(res.locL) || !res.locL[0]) {
2018-01-26 17:08:07 +01:00
// todo: proper stack trace?
// todo: DRY with lib/request.js
const err = new Error('response has no stop')
// technically this is not a HAFAS error
// todo: find a different flag with decent DX
err.isHafasError = true
err.code = INVALID_REQUEST
throw err
2018-01-26 17:08:07 +01:00
}
const ctx = {profile, opt, res, common}
return profile.parseLocation(ctx, res.locL[0])
2018-01-26 17:08:07 +01:00
})
}
2018-01-05 14:53:03 +01:00
const nearby = (location, opt = {}) => {
2018-08-25 01:52:46 +02:00
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?
2017-11-12 20:29:57 +01:00
}, opt)
2019-10-31 20:08:56 +01:00
const req = profile.formatNearbyReq({profile, opt}, location)
return profile.request({profile, opt}, userAgent, req)
.then(({common, res}) => {
if (!Array.isArray(res.locL)) return []
const ctx = {profile, opt, common, res}
const results = res.locL.map(loc => profile.parseNearby(ctx, loc))
return Number.isInteger(opt.results) ? results.slice(0, opt.results) : results
2017-11-12 20:29:57 +01:00
})
}
const trip = (id, lineName, opt = {}) => {
if (!isNonEmptyString(id)) {
2019-02-13 17:55:57 +01:00
throw new TypeError('id must be a non-empty string.')
2018-02-28 16:09:23 +01:00
}
if (!isNonEmptyString(lineName)) {
2019-02-13 17:55:57 +01:00
throw new TypeError('lineName 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?
2018-06-28 13:45:56 +02:00
remarks: true // parse & expose hints & warnings?
2017-12-17 20:33:04 +01:00
}, opt)
2017-11-20 15:43:13 +01:00
2019-10-31 20:08:56 +01:00
const req = profile.formatTripReq({profile, opt}, id, lineName)
return profile.request({profile, opt}, userAgent, req)
.then(({common, res}) => {
const ctx = {profile, opt, common, res}
2020-06-11 15:33:45 +02:00
return profile.parseTrip(ctx, res.journey)
2017-11-20 15:43:13 +01:00
})
}
const tripsByName = (lineNameOrFahrtNr, opt = {}) => {
if (!isNonEmptyString(lineNameOrFahrtNr)) {
throw new TypeError('lineNameOrFahrtNr must be a non-empty string.')
}
opt = Object.assign({
}, opt)
opt.when = new Date(opt.when || Date.now())
return profile.request({profile, opt}, userAgent, {
cfg: {polyEnc: 'GPA'},
meth: 'JourneyMatch',
req: {
input: lineNameOrFahrtNr,
2020-11-15 13:43:26 +01:00
// todo: date seems to be ignored
date: profile.formatDate(profile, opt.when),
// todo: there are probably more options
}
})
.then(({res, common}) => {
const ctx = {profile, opt, common, res}
return res.jnyL.map(t => profile.parseTrip(ctx, t))
})
}
2018-03-17 20:58:03 +01:00
const radar = ({north, west, south, east}, opt) => {
2019-02-13 17:55:57 +01:00
if ('number' !== typeof north) throw new TypeError('north must be a number.')
if ('number' !== typeof west) throw new TypeError('west must be a number.')
if ('number' !== typeof south) throw new TypeError('south must be a number.')
if ('number' !== typeof east) throw new TypeError('east must be a number.')
if (north <= south) throw new Error('north must be larger than south.')
if (east <= west) throw new Error('east must be larger than west.')
2017-11-20 17:37:08 +01:00
opt = Object.assign({
results: 256, // maximum number of vehicles
duration: 30, // compute frames for the next n seconds
// todo: what happens with `frames: 0`?
2017-12-12 03:28:54 +01:00
frames: 3, // nr of frames to compute
products: null, // optionally an object of booleans
2020-03-18 20:04:39 +01:00
polylines: true, // return a track shape for each vehicle?
subStops: true, // parse & expose sub-stops of stations?
entrances: true, // parse & expose entrances of stops/stations?
2017-11-20 17:37:08 +01:00
}, opt || {})
2018-05-21 17:10:42 +02:00
opt.when = new Date(opt.when || Date.now())
2019-02-13 17:55:57 +01:00
if (Number.isNaN(+opt.when)) throw new TypeError('opt.when is invalid')
2017-11-20 17:37:08 +01:00
2019-10-31 20:08:56 +01:00
const req = profile.formatRadarReq({profile, opt}, north, west, south, east)
return profile.request({profile, opt}, userAgent, req)
.then(({res, common}) => {
if (!Array.isArray(res.jnyL)) return []
2017-11-20 17:37:08 +01:00
const ctx = {profile, opt, common, res}
return res.jnyL.map(m => profile.parseMovement(ctx, m))
2017-11-20 17:37:08 +01:00
})
}
const reachableFrom = (address, opt = {}) => {
validateLocation(address, 'address')
2018-08-25 01:52:46 +02:00
opt = Object.assign({
when: Date.now(),
maxTransfers: 5, // maximum of 5 transfers
maxDuration: 20, // maximum travel duration in minutes, pass `null` for infinite
2020-03-18 20:04:39 +01:00
products: {},
subStops: true, // parse & expose sub-stops of stations?
entrances: true, // parse & expose entrances of stops/stations?
2018-08-25 01:52:46 +02:00
}, opt)
2019-02-13 17:55:57 +01:00
if (Number.isNaN(+opt.when)) throw new TypeError('opt.when is invalid')
2018-08-25 01:52:46 +02:00
2019-10-31 20:08:56 +01:00
const req = profile.formatReachableFromReq({profile, opt}, address)
2018-09-03 20:38:56 +02:00
const refetch = () => {
2019-10-31 20:08:56 +01:00
return profile.request({profile, opt}, userAgent, req)
.then(({res, common}) => {
if (!Array.isArray(res.posL)) {
2018-09-03 20:38:56 +02:00
const err = new Error('invalid response')
err.shouldRetry = true
throw err
}
const byDuration = []
let i = 0, lastDuration = NaN
for (const pos of sortBy(res.posL, 'dur')) {
const loc = common.locations[pos.locX]
2018-09-03 20:38:56 +02:00
if (!loc) continue
if (pos.dur !== lastDuration) {
lastDuration = pos.dur
i = byDuration.length
byDuration.push({
duration: pos.dur,
stations: [loc]
})
} else {
byDuration[i].stations.push(loc)
}
}
return byDuration
})
}
return pRetry(refetch, {
retries: 3,
factor: 2,
minTimeout: 2 * 1000
2018-08-25 01:52:46 +02:00
})
}
2020-03-09 20:54:43 +01:00
const remarks = async (opt = {}) => {
opt = {
results: 100, // maximum number of remarks
// filter by time
from: Date.now(),
to: null,
products: null, // filter by affected products
...opt
}
if (opt.from !== null) {
opt.from = new Date(opt.from)
validateWhen(opt.from, 'opt.from')
}
if (opt.to !== null) {
opt.to = new Date(opt.to)
validateWhen(opt.to, 'opt.to')
}
const req = profile.formatRemarksReq({profile, opt})
const {
res, common,
} = await profile.request({profile, opt}, userAgent, req)
const ctx = {profile, opt, common, res}
return (res.msgL || [])
.map(w => profile.parseWarning(ctx, w))
2020-03-09 20:54:43 +01:00
}
2020-03-09 21:44:35 +01:00
const lines = async (query, opt = {}) => {
if (!isNonEmptyString(query)) {
throw new TypeError('query must be a non-empty string.')
}
const req = profile.formatLinesReq({profile, opt}, query)
const {
res, common,
} = await profile.request({profile, opt}, userAgent, req)
if (!Array.isArray(res.lineL)) return []
const ctx = {profile, opt, common, res}
return res.lineL.map(l => {
const parseDirRef = i => (res.common.dirL[i] || {}).txt || null
return {
...omit(l.line, ['id', 'fahrtNr']),
id: l.lineId,
// todo: what is locX?
directions: Array.isArray(l.dirRefL)
? l.dirRefL.map(parseDirRef)
: null,
trips: Array.isArray(l.jnyL)
? l.jnyL.map(t => profile.parseTrip(ctx, t))
: null,
}
})
}
const serverInfo = async (opt = {}) => {
const {res, common} = await profile.request({profile, opt}, userAgent, {
meth: 'ServerInfo',
req: {}
})
const ctx = {profile, opt, common, res}
return {
timetableStart: res.fpB || null,
timetableEnd: res.fpE || null,
serverTime: res.sD && res.sT
? profile.parseDateTime(ctx, res.sD, res.sT)
: null,
realtimeDataUpdatedAt: res.planrtTS
? parseInt(res.planrtTS)
: null,
}
}
const client = {
departures,
arrivals,
journeys,
locations,
stop,
nearby,
serverInfo,
}
if (profile.trip) client.trip = trip
if (profile.radar) client.radar = radar
if (profile.refreshJourney) client.refreshJourney = refreshJourney
2018-08-25 01:52:46 +02:00
if (profile.reachableFrom) client.reachableFrom = reachableFrom
if (profile.tripsByName) client.tripsByName = tripsByName
2020-09-16 17:21:59 +02:00
if (profile.remarks !== false) client.remarks = remarks
2020-09-16 17:26:59 +02:00
if (profile.lines !== false) client.lines = lines
2017-12-16 07:54:39 +01:00
Object.defineProperty(client, 'profile', {value: profile})
return client
2016-06-22 01:39:59 +02:00
}
module.exports = createClient