db-vendo-client/index.js

554 lines
17 KiB
JavaScript
Raw Normal View History

2016-06-22 01:39:04 +02:00
'use strict'
2017-11-20 15:43:13 +01:00
const minBy = require('lodash/minBy')
const maxBy = require('lodash/maxBy')
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')
2017-11-20 15:43:13 +01:00
const defaultProfile = require('./lib/default-profile')
const createParseBitmask = require('./parse/products-bitmask')
const createFormatProductsFilter = require('./format/products-filter')
const validateProfile = require('./lib/validate-profile')
2018-01-23 00:37:09 +01:00
const _request = require('./lib/request')
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)) {
throw new Error(name + ' must be an object.')
} else if (loc.type !== 'location') {
throw new Error('invalid location object.')
} else if ('number' !== typeof loc.latitude) {
throw new Error(name + '.latitude must be a number.')
} else if ('number' !== typeof loc.longitude) {
throw new Error(name + '.longitude must be a number.')
}
}
2018-07-19 21:50:20 +02:00
const createClient = (profile, userAgent, request = _request) => {
2017-11-11 22:49:04 +01:00
profile = Object.assign({}, defaultProfile, profile)
if (!profile.parseProducts) {
profile.parseProducts = createParseBitmask(profile)
}
if (!profile.formatProductsFilter) {
profile.formatProductsFilter = createFormatProductsFilter(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) {
throw new Error('userAgent must be a string');
}
2018-06-26 17:11:25 +02:00
const _stationBoard = (station, type, parser, 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)
else throw new Error('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 Error('type must be a non-empty string.')
}
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({
direction: null, // only show departures heading to this station
duration: 10, // show departures for the next n minutes
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')
const products = profile.formatProductsFilter(opt.products || {})
2017-11-12 18:06:16 +01:00
const dir = opt.direction ? profile.formatStation(opt.direction) : null
const req = {
type,
date: profile.formatDate(profile, opt.when),
time: profile.formatTime(profile, opt.when),
stbLoc: station,
dirLoc: dir,
jnyFltrL: [products],
dur: opt.duration
}
if (profile.departuresGetPasslist) req.getPasslist = !!opt.stopovers
if (profile.departuresStbFltrEquiv) req.stbFltrEquiv = !opt.includeRelatedStations
2018-07-19 21:50:20 +02:00
return request(profile, userAgent, opt, {
2017-11-12 18:06:16 +01:00
meth: 'StationBoard',
req
2017-11-12 18:06:16 +01:00
})
.then((d) => {
2018-06-26 17:11:25 +02:00
if (!Array.isArray(d.jnyL)) return []
const parse = parser(profile, opt, {
locations: d.locations,
lines: d.lines,
2018-06-26 12:52:33 +02:00
hints: d.hints,
warnings: d.warnings
})
2017-11-12 18:06:16 +01:00
return d.jnyL.map(parse)
2017-11-18 10:14:17 +01:00
.sort((a, b) => new Date(a.when) - new Date(b.when))
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)
}
2017-11-12 20:02:32 +01:00
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)) {
throw new Error('opt.earlierThan and opt.laterThan are mutually exclusive.')
}
if (('departure' in opt) && ('arrival' in opt)) {
throw new Error('opt.departure and opt.arrival are mutually exclusive.')
}
let journeysRef = null
if ('earlierThan' in opt) {
if (!isNonEmptyString(opt.earlierThan)) {
throw new Error('opt.earlierThan must be a non-empty string.')
}
if (('departure' in opt) || ('arrival' in opt)) {
throw new Error('opt.earlierThan and opt.departure/opt.arrival are mutually exclusive.')
}
journeysRef = opt.earlierThan
}
if ('laterThan' in opt) {
if (!isNonEmptyString(opt.laterThan)) {
throw new Error('opt.laterThan must be a non-empty string.')
}
if (('departure' in opt) || ('arrival' in opt)) {
throw new Error('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: 5, // how many journeys?
via: null, // let journeys pass this station?
stopovers: false, // return stations on the way?
transfers: -1, // maximum of 5 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
tickets: false, // return tickets?
polylines: false, // return leg shapes?
2018-06-28 13:45:56 +02:00
remarks: true, // parse & expose hints & warnings?
// Consider walking to nearby stations at the beginning of a journey?
2018-07-23 20:42:22 +02:00
startWithWalking: true,
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)
if (Number.isNaN(+when)) throw new Error('opt.departure is invalid')
} else if (opt.arrival !== undefined && opt.arrival !== null) {
when = new Date(opt.arrival)
if (Number.isNaN(+when)) throw new Error('opt.arrival is invalid')
outFrwd = false
}
const filters = [
profile.formatProductsFilter(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-09-25 20:19:06 +02:00
// With protocol version `1.16`, the VBB endpoint *used to* fail with
// `CGI_READ_FAILED` if you pass `numF`, the parameter for the number
// of results. To circumvent this, we loop here, collecting journeys
// until we have enough.
2018-09-25 20:19:06 +02:00
// todo: revert this change, see https://github.com/public-transport/hafas-client/issues/76#issuecomment-424448449
const journeys = []
let earlierRef, laterRef
const more = (when, journeysRef) => {
const query = {
2018-03-05 00:51:11 +01:00
outDate: profile.formatDate(profile, when),
outTime: profile.formatTime(profile, when),
ctxScr: journeysRef,
getPasslist: !!opt.stopovers,
maxChg: opt.transfers,
minChgTime: opt.transferTime,
depLocL: [from],
viaLocL: opt.via ? [{loc: opt.via}] : null,
arrLocL: [to],
jnyFltrL: filters,
getTariff: !!opt.tickets,
outFrwd,
ushrp: !!opt.startWithWalking,
// todo: what is req.gisFltrL?
getPT: true, // todo: what is this?
getIV: false, // todo: walk & bike as alternatives?
2018-04-30 12:49:33 +02:00
getPolyline: !!opt.polylines
}
2018-03-07 13:32:40 +01:00
if (profile.journeysNumF) query.numF = opt.results
2018-07-19 21:50:20 +02:00
return request(profile, userAgent, opt, {
cfg: {polyEnc: 'GPA'},
meth: 'TripSearch',
req: profile.transformJourneysQuery(query, opt)
})
.then((d) => {
if (!Array.isArray(d.outConL)) return []
2018-04-30 12:49:58 +02:00
const parse = profile.parseJourney(profile, opt, {
locations: d.locations,
lines: d.lines,
2018-06-26 12:52:33 +02:00
hints: d.hints,
warnings: d.warnings,
polylines: opt.polylines && d.common.polyL || []
})
2018-04-30 12:49:58 +02:00
if (!earlierRef) earlierRef = d.outCtxScrB
2018-03-05 00:51:11 +01:00
let latestDep = -Infinity
for (let j of d.outConL) {
2018-03-05 00:51:11 +01:00
j = parse(j)
journeys.push(j)
if (journeys.length >= opt.results) { // collected enough
laterRef = d.outCtxScrF
return {
earlierRef,
laterRef,
journeys
}
}
const dep = +new Date(j.legs[0].departure)
2018-03-05 00:51:11 +01:00
if (dep > latestDep) latestDep = dep
}
2018-03-05 00:51:11 +01:00
const when = new Date(latestDep)
return more(when, d.outCtxScrF) // otherwise continue
})
}
return more(when, journeysRef)
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) {
new Error('refreshToken must be a non-empty string.')
}
opt = Object.assign({
stopovers: false, // return stations on the way?
tickets: false, // return tickets?
polylines: false, // return leg shapes?
remarks: true // parse & expose hints & warnings?
}, opt)
return request(profile, userAgent, opt, {
2018-07-24 17:37:36 +02:00
meth: 'Reconstruction',
req: {
ctxRecon: refreshToken,
getIST: true, // todo: make an option
getPasslist: !!opt.stopovers,
getPolyline: !!opt.polylines,
getTariff: !!opt.tickets
}
})
.then((d) => {
if (!Array.isArray(d.outConL) || !d.outConL[0]) {
throw new Error('invalid response')
}
const parse = profile.parseJourney(profile, opt, {
locations: d.locations,
lines: d.lines,
hints: d.hints,
warnings: d.warnings,
polylines: opt.polylines && d.common.polyL || []
})
return parse(d.outConL[0])
})
2017-11-12 20:02:32 +01:00
}
2017-11-12 20:19:33 +01:00
const locations = (query, opt = {}) => {
if (!isNonEmptyString(query)) {
2018-02-28 16:09:23 +01:00
throw new Error('query must be a non-empty string.')
}
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
linesOfStops: false // parse & expose lines at each stop/station?
2017-11-12 20:19:33 +01:00
}, opt)
const f = profile.formatLocationFilter(opt.stops, opt.addresses, opt.poi)
2018-07-19 21:50:20 +02:00
return request(profile, userAgent, opt, {
2017-11-12 20:19:33 +01:00
cfg: {polyEnc: 'GPA'},
meth: 'LocMatch',
req: {input: {
loc: {
type: f,
name: opt.fuzzy ? query + '?' : query
},
maxLoc: opt.results,
field: 'S' // todo: what is this?
}}
})
.then((d) => {
if (!d.match || !Array.isArray(d.match.locL)) return []
const parse = profile.parseLocation
return d.match.locL.map(loc => parse(profile, opt, {lines: d.lines}, 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)
else throw new Error('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?
}, opt)
2018-07-19 21:50:20 +02:00
return request(profile, userAgent, opt, {
2018-01-26 17:08:07 +01:00
meth: 'LocDetails',
req: {
2018-11-21 19:54:40 +01:00
locL: [stop]
2018-01-26 17:08:07 +01:00
}
})
.then((d) => {
if (!d || !Array.isArray(d.locL) || !d.locL[0]) {
// todo: proper stack trace?
throw new Error('invalid response')
}
return profile.parseLocation(profile, opt, {lines: d.lines}, d.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?
linesOfStops: false // parse & expose lines at each stop/station?
2017-11-12 20:29:57 +01:00
}, opt)
2018-07-19 21:50:20 +02:00
return request(profile, userAgent, opt, {
2017-11-12 20:29:57 +01:00
cfg: {polyEnc: 'GPA'},
meth: 'LocGeoPos',
req: {
ring: {
cCrd: {
2018-01-05 14:53:03 +01:00
x: profile.formatCoord(location.longitude),
y: profile.formatCoord(location.latitude)
2017-11-12 20:29:57 +01:00
},
maxDist: opt.distance || -1,
minDist: 0
},
getPOIs: !!opt.poi,
getStops: !!opt.stops,
2017-11-12 20:29:57 +01:00
maxLoc: opt.results
}
})
.then((d) => {
if (!Array.isArray(d.locL)) return []
const parse = profile.parseNearby
2018-06-26 11:48:30 +02:00
return d.locL.map(loc => parse(profile, opt, d, loc))
2017-11-12 20:29:57 +01:00
})
}
const trip = (id, lineName, opt = {}) => {
if (!isNonEmptyString(id)) {
throw new Error('id must be a non-empty string.')
2018-02-28 16:09:23 +01:00
}
if (!isNonEmptyString(lineName)) {
2018-02-28 16:09:23 +01:00
throw new Error('lineName must be a non-empty string.')
}
2017-12-17 20:33:04 +01:00
opt = Object.assign({
stopovers: true, // return stations on the way?
polyline: false, // return a track shape?
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
2018-07-19 21:50:20 +02:00
return request(profile, userAgent, opt, {
2017-11-20 15:43:13 +01:00
cfg: {polyEnc: 'GPA'},
meth: 'JourneyDetails',
req: {
2018-04-30 13:14:19 +02:00
// todo: getTrainComposition
jid: id,
2017-11-20 15:43:13 +01:00
name: lineName,
// HAFAS apparently ignores the date in the trip ID and uses the `date` field.
// Thus, it will find a different trip if you pass the wrong date via `opt.when`.
// date: profile.formatDate(profile, opt.when),
2018-04-30 13:12:05 +02:00
getPolyline: !!opt.polyline
2017-11-20 15:43:13 +01:00
}
})
.then((d) => {
const parse = profile.parseJourneyLeg(profile, opt, {
locations: d.locations,
lines: d.lines,
2018-06-26 12:52:33 +02:00
hints: d.hints,
warnings: d.warnings,
polylines: opt.polyline && d.common.polyL || []
})
2017-11-20 15:43:13 +01:00
2018-11-21 23:42:13 +01:00
const rawLeg = { // pretend the leg is contained in a journey
2017-11-20 15:43:13 +01:00
type: 'JNY',
dep: minBy(d.journey.stopL, 'idx'),
arr: maxBy(d.journey.stopL, 'idx'),
jny: d.journey
}
2018-11-21 23:42:13 +01:00
const trip = parse(d.journey, rawLeg, !!opt.stopovers)
trip.id = trip.tripId
delete trip.tripId
return trip
2017-11-20 15:43:13 +01:00
})
}
2018-03-17 20:58:03 +01:00
const radar = ({north, west, south, east}, opt) => {
2017-11-20 17:37:08 +01:00
if ('number' !== typeof north) throw new Error('north must be a number.')
if ('number' !== typeof west) throw new Error('west must be a number.')
if ('number' !== typeof south) throw new Error('south must be a number.')
if ('number' !== typeof east) throw new Error('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
polylines: true // return a track shape for each vehicle?
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())
if (Number.isNaN(+opt.when)) throw new Error('opt.when is invalid')
2017-11-20 17:37:08 +01:00
const durationPerStep = opt.duration / Math.max(opt.frames, 1) * 1000
2018-07-19 21:50:20 +02:00
return request(profile, userAgent, opt, {
2017-11-20 17:37:08 +01:00
meth: 'JourneyGeoPos',
req: {
maxJny: opt.results,
onlyRT: false, // todo: does this mean "only realtime"?
date: profile.formatDate(profile, opt.when),
time: profile.formatTime(profile, opt.when),
// todo: would a ring work here as well?
rect: profile.formatRectangle(profile, north, west, south, east),
perSize: opt.duration * 1000,
perStep: Math.round(durationPerStep),
ageOfReport: true, // todo: what is this?
jnyFltrL: [
profile.formatProductsFilter(opt.products || {})
2017-11-20 17:37:08 +01:00
],
trainPosMode: 'CALC' // todo: what is this? what about realtime?
}
})
.then((d) => {
2018-09-03 15:29:06 +02:00
if (!Array.isArray(d.jnyL) || d.jnyL.length === 0) return []
2017-11-20 17:37:08 +01:00
const parse = profile.parseMovement(profile, opt, {
locations: d.locations,
lines: d.lines,
2018-06-26 12:52:33 +02:00
hints: d.hints,
warnings: d.warnings,
polylines: opt.polylines && d.common.polyL || []
})
2017-11-20 17:37:08 +01:00
return d.jnyL.map(parse)
})
}
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
2018-08-25 01:52:46 +02:00
products: {}
}, opt)
if (Number.isNaN(+opt.when)) throw new Error('opt.when is invalid')
2018-09-03 20:38:56 +02:00
const refetch = () => {
return request(profile, userAgent, opt, {
meth: 'LocGeoReach',
req: {
loc: profile.formatLocation(profile, address, 'address'),
maxDur: opt.maxDuration === null ? -1 : opt.maxDuration,
2018-09-03 20:38:56 +02:00
maxChg: opt.maxTransfers,
date: profile.formatDate(profile, opt.when),
time: profile.formatTime(profile, opt.when),
period: 120, // todo: what is this?
jnyFltrL: [
profile.formatProductsFilter(opt.products || {})
]
2018-08-25 01:52:46 +02:00
}
2018-09-03 20:38:56 +02:00
})
.then((d) => {
if (!Array.isArray(d.posL)) {
const err = new Error('invalid response')
err.shouldRetry = true
throw err
}
const byDuration = []
let i = 0, lastDuration = NaN
for (const pos of sortBy(d.posL, 'dur')) {
const loc = d.locations[pos.locX]
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
})
}
2018-11-21 19:54:40 +01:00
const client = {departures, arrivals, journeys, locations, stop, nearby}
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
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