merge any-endpoint into master

This commit is contained in:
Jannis R 2018-01-05 21:36:07 +01:00
commit 079964ee40
No known key found for this signature in database
GPG key ID: 0FE83946296A88A5
54 changed files with 3545 additions and 356 deletions

View file

@ -2,5 +2,5 @@ sudo: false
language: node_js
node_js:
- 'stable'
- '7'
- '8'
- '6'

163
docs/departures.md Normal file
View file

@ -0,0 +1,163 @@
# `departures(station, [opt])`
`station` must be in one of these formats:
```js
// a station ID, in a format compatible to the profile you use
'900000013102'
// an FPTF `station` object
{
type: 'station',
id: '900000013102',
name: 'foo station',
location: {
type: 'location',
latitude: 1.23,
longitude: 3.21
}
}
```
With `opt`, you can override the default options, which look like this:
```js
{
when: new Date(),
direction: null, // only show departures heading to this station
duration: 10 // show departures for the next n minutes
}
```
## Response
*Note:* As stated in the [*Friendly Public Transport Format* `1.0.1`](https://github.com/public-transport/friendly-public-transport-format/tree/1.0.1), the `when` field includes the current delay. The `delay` field, if present, expresses how much the former differs from the schedule.
You may pass the `journeyId` field into [`journeyLeg(ref, lineName, [opt])`](journey-leg.md) to get details on the vehicle's journey.
As an example, we're going to use the [VBB profile](../p/vbb):
```js
const createClient = require('hafas-client')
const vbbProfile = require('hafas-client/p/vbb')
const client = createClient(vbbProfile)
// S Charlottenburg
client.journeys('900000024101', {duration: 3})
.then(console.log)
.catch(console.error)
```
The response may look like this:
```js
[ {
journeyId: '1|31431|28|86|17122017',
trip: 31431,
station: {
type: 'station',
id: '900000024101',
name: 'S Charlottenburg',
location: {
type: 'location',
latitude: 52.504806,
longitude: 13.303846
},
products: {
suburban: true,
subway: false,
tram: false,
bus: true,
ferry: false,
express: false,
regional: true
}
},
when: '2017-12-17T19:32:00.000+01:00',
delay: null
line: {
type: 'line',
id: '18299',
name: 'S9',
public: true,
mode: 'train',
product: 'suburban',
symbol: 'S',
nr: 9,
metro: false,
express: false,
night: false,
productCode: 0,
operator: {
type: 'operator',
id: 's-bahn-berlin-gmbh',
name: 'S-Bahn Berlin GmbH'
}
},
direction: 'S Spandau'
}, {
journeyId: '1|30977|8|86|17122017',
trip: 30977,
station: { /* … */ },
when: null,
delay: null,
cancelled: true,
line: {
type: 'line',
id: '16441',
name: 'S5',
public: true,
mode: 'train',
product: 'suburban',
symbol: 'S',
nr: 5,
metro: false,
express: false,
night: false,
productCode: 0,
operator: { /* … */ }
},
direction: 'S Westkreuz'
}, {
journeyId: '1|28671|4|86|17122017',
trip: 28671,
station: {
type: 'station',
id: '900000024202',
name: 'U Wilmersdorfer Str.',
location: {
type: 'location',
latitude: 52.506415,
longitude: 13.306777
},
products: {
suburban: false,
subway: true,
tram: false,
bus: false,
ferry: false,
express: false,
regional: false
}
},
when: '2017-12-17T19:35:00.000+01:00',
delay: 0,
line: {
type: 'line',
id: '19494',
name: 'U7',
public: true,
mode: 'train',
product: 'subway',
symbol: 'U',
nr: 7,
metro: false,
express: false,
night: false,
productCode: 1,
operator: { /* … */ }
},
direction: 'U Rudow'
} ]
```

118
docs/journey-leg.md Normal file
View file

@ -0,0 +1,118 @@
# `journeyLeg(ref, lineName, [opt])`
This method can be used to refetch information about a leg of a journey. Note that it is not supported by every profile/endpoint.
Let's say you used [`journeys`](journeys.md) and now want to get more up-to-date data about the arrival/departure of a leg. You'd pass in a journey leg `id` like `'1|24983|22|86|18062017'`. `lineName` must be the name of the journey leg's `line.name`. You can get them like this:
```js
const createClient = require('hafas-client')
const vbbProfile = require('hafas-client/p/vbb')
const client = createClient(vbbProfile)
// Hauptbahnhof to Heinrich-Heine-Str.
client.journeys('900000003201', '900000100008', {results: 1})
.then(([journey]) => {
const leg = journey.legs[0]
return client.journeyLeg(leg.id, leg.line.name)
})
.then(console.log)
.catch(console.error)
```
With `opt`, you can override the default options, which look like this:
```js
{
when: new Date(),
passedStations: true // return stations on the way?
}
```
## Response
*Note:* As stated in the [*Friendly Public Transport Format* `1.0.1`](https://github.com/public-transport/friendly-public-transport-format/tree/1.0.1), the returned `departure` and `arrival` times include the current delay. The `departureDelay`/`arrivalDelay` fields express how much they differ from the schedule.
As an example, we're going to use the [VBB profile](../p/vbb):
```js
const createClient = require('hafas-client')
const vbbProfile = require('hafas-client/p/vbb')
const client = createClient(vbbProfile)
client.journeyLeg('1|31431|28|86|17122017', 'S9', {when: 1513534689273})
.then(console.log)
.catch(console.error)
```
The response looked like this:
```js
{
id: '1|31431|28|86|17122017',
origin: {
type: 'station',
id: '900000260005',
name: 'S Flughafen Berlin-Schönefeld',
location: {
type: 'location',
latitude: 52.390796,
longitude: 13.51352
},
products: {
suburban: true,
subway: false,
tram: false,
bus: true,
ferry: false,
express: false,
regional: true
}
},
departure: '2017-12-17T18:37:00.000+01:00',
departurePlatform: '13',
destination: {
type: 'station',
id: '900000029101',
name: 'S Spandau',
location: {
type: 'location',
latitude: 52.534794,
longitude: 13.197477
},
products: {
suburban: true,
subway: false,
tram: false,
bus: true,
ferry: false,
express: true,
regional: true
}
},
arrival: '2017-12-17T19:49:00.000+01:00',
arrivalPlatform: '2',
line: {
type: 'line',
id: '18299',
name: 'S9',
public: true,
mode: 'train',
product: 'suburban',
symbol: 'S',
nr: 9,
metro: false,
express: false,
night: false,
productCode: 0,
operator: {
type: 'operator',
id: 's-bahn-berlin-gmbh',
name: 'S-Bahn Berlin GmbH'
}
},
direction: 'S Spandau',
passed: [ /* … */ ]
}
```

244
docs/journeys.md Normal file
View file

@ -0,0 +1,244 @@
# `journeys(from, to, [opt])`
`from` and `to` each must be in one of these formats:
```js
// a station ID, in a format compatible to the profile you use
'900000013102'
// an FPTF `station` object
{
type: 'station',
id: '900000013102',
name: 'foo station',
location: {
type: 'location',
latitude: 1.23,
longitude: 3.21
}
}
// a point of interest, which is an FPTF `location` object
{
type: 'location',
id: '123',
name: 'foo restaurant',
latitude: 1.23,
longitude: 3.21
}
// an address, which is an FTPF `location` object
{
type: 'location',
address: 'foo street 1',
latitude: 1.23,
longitude: 3.21
}
```
With `opt`, you can override the default options, which look like this:
```js
{
when: new Date(),
results: 5, // how many journeys?
via: null, // let journeys pass this station
passedStations: false, // return stations on the way?
transfers: 5, // maximum of 5 transfers
transferTime: 0, // minimum time for a single transfer in minutes
accessibility: 'none', // 'none', 'partial' or 'complete'
bike: false, // only bike-friendly journeys
products: {
suburban: true,
subway: true,
tram: true,
bus: true,
ferry: true,
express: true,
regional: true
},
tickets: false // return tickets? only available with some profiles
}
```
## Response
*Note:* As stated in the [*Friendly Public Transport Format* `1.0.1`](https://github.com/public-transport/friendly-public-transport-format/tree/1.0.1), the returned `departure` and `arrival` times include the current delay. The `departureDelay`/`arrivalDelay` fields express how much they differ from the schedule.
As an example, we're going to use the [VBB profile](../p/vbb):
```js
const createClient = require('hafas-client')
const vbbProfile = require('hafas-client/p/vbb')
const client = createClient(vbbProfile)
// Hauptbahnhof to Heinrich-Heine-Str.
client.journeys('900000003201', '900000100008', {
results: 1,
passedStations: true
})
.then(console.log)
.catch(console.error)
```
The response may look like this:
```js
[ {
legs: [ {
id: '1|31041|35|86|17122017',
origin: {
type: 'station',
id: '900000003201',
name: 'S+U Berlin Hauptbahnhof',
location: {
type: 'location',
latitude: 52.52585,
longitude: 13.368928
},
products: {
suburban: true,
subway: true,
tram: true,
bus: true,
ferry: false,
express: true,
regional: true
}
},
departure: '2017-12-17T19:07:00.000+01:00',
departurePlatform: '16',
destination: {
type: 'station',
id: '900000024101',
name: 'S Charlottenburg',
location: {
type: 'location',
latitude: 52.504806,
longitude: 13.303846
},
products: {
suburban: true,
subway: false,
tram: false,
bus: true,
ferry: false,
express: false,
regional: true
}
},
arrival: '2017-12-17T19:47:00.000+01:00',
arrivalPlatform: '8',
arrivalDelay: 30,
line: {
type: 'line',
id: '16845',
name: 'S7',
public: true,
mode: 'train',
product: 'suburban',
symbol: 'S',
nr: 7,
metro: false,
express: false,
night: false,
productCode: 0,
operator: {
type: 'operator',
id: 's-bahn-berlin-gmbh',
name: 'S-Bahn Berlin GmbH'
}
},
direction: 'S Potsdam Hauptbahnhof',
passed: [ {
station: {
type: 'station',
id: '900000003201',
name: 'S+U Berlin Hauptbahnhof',
location: { /* … */ },
products: { /* … */ }
},
arrival: null,
departure: null,
cancelled: true
}, {
station: {
type: 'station',
id: '900000003102',
name: 'S Bellevue',
location: { /* … */ },
products: { /* … */ }
},
arrival: '2017-12-17T19:09:00.000+01:00',
departure: '2017-12-17T19:09:00.000+01:00'
}, /* … */ {
station: {
type: 'station',
id: '900000024101',
name: 'S Charlottenburg',
location: { /* … */ },
products: { /* … */ }
},
arrival: '2017-12-17T19:17:00.000+01:00',
departure: '2017-12-17T19:17:00.000+01:00'
} ]
} ],
origin: {
type: 'station',
id: '900000003201',
name: 'S+U Berlin Hauptbahnhof',
location: { /* … */ },
products: { /* … */ }
},
departure: '2017-12-17T19:07:00.000+01:00',
destination: {
type: 'station',
id: '900000024101',
name: 'S Charlottenburg',
location: { /* … */ },
products: { /* … */ }
},
arrival: '2017-12-17T19:47:00.000+01:00',
arrivalDelay: 30
} ]
```
Some [profiles](../p) are able to parse the ticket information, if returned by the API. For example, if you pass `tickets: true` with the [VBB profile](../p/vbb), each `journey` will have a tickets array that looks like this:
```js
[ {
name: 'Berlin Tarifgebiet A-B: Einzelfahrausweis Regeltarif',
price: 2.8,
tariff: 'Berlin',
coverage: 'AB',
variant: 'adult',
amount: 1
}, {
name: 'Berlin Tarifgebiet A-B: Einzelfahrausweis Ermäßigungstarif',
price: 1.7,
tariff: 'Berlin',
coverage: 'AB',
variant: 'reduced',
amount: 1,
reduced: true
}, /* … */ {
name: 'Berlin Tarifgebiet A-B: Tageskarte Ermäßigungstarif',
price: 4.7,
tariff: 'Berlin',
coverage: 'AB',
variant: '1 day, reduced',
amount: 1,
reduced: true,
fullDay: true
}, /* … */ {
name: 'Berlin Tarifgebiet A-B: 4-Fahrten-Karte Regeltarif',
price: 9,
tariff: 'Berlin',
coverage: 'AB',
variant: '4x adult',
amount: 4
} ]
```
If a journey leg has been cancelled, a `cancelled: true` will be added. Also, `departure`/`departureDelay`/`departurePlatform` and `arrival`/`arrivalDelay`/`arrivalPlatform` will be `null`.

66
docs/locations.md Normal file
View file

@ -0,0 +1,66 @@
# `locations(query, [opt])`
`query` must be an string (e.g. `'Alexanderplatz'`).
With `opt`, you can override the default options, which look like this:
```js
{
fuzzy: true // find only exact matches?
, results: 10 // how many search results?
, stations: true
, addresses: true
, poi: true // points of interest
}
```
## Response
As an example, we're going to use the [VBB profile](../p/vbb):
```js
const createClient = require('hafas-client')
const vbbProfile = require('hafas-client/p/vbb')
const client = createClient(vbbProfile)
client.locations('Alexanderplatz', {results: 3})
.then(console.log)
.catch(console.error)
```
The response may look like this:
```js
[ {
type: 'station',
id: '900000100003',
name: 'S+U Alexanderplatz',
location: {
type: 'location',
latitude: 52.521508,
longitude: 13.411267
},
products: {
suburban: true,
subway: true,
tram: true,
bus: true,
ferry: false,
express: false,
regional: true
}
}, { // point of interest
type: 'location',
name: 'Berlin, Holiday Inn Centre Alexanderplatz****',
id: '900980709',
latitude: 52.523549,
longitude: 13.418441
}, { // point of interest
type: 'location',
name: 'Berlin, Hotel Agon am Alexanderplatz',
id: '900980176',
latitude: 52.524556,
longitude: 13.420266
} ]
```

81
docs/nearby.md Normal file
View file

@ -0,0 +1,81 @@
# `nearby(location, [opt])`
This method can be used to find stations close to a location. Note that it is not supported by every profile/endpoint.
`location` must be an [*FPTF* `location` object](https://github.com/public-transport/friendly-public-transport-format/blob/1.0.1/spec/readme.md#location-objects).
With `opt`, you can override the default options, which look like this:
```js
{
distance: null, // maximum walking distance in meters
poi: false, // return points of interest?
stations: true, // return stations?
}
```
## Response
As an example, we're going to use the [VBB profile](../p/vbb):
```js
const createClient = require('hafas-client')
const vbbProfile = require('hafas-client/p/vbb')
const client = createClient(vbbProfile)
client.nearby({
type: 'location',
latitude: 52.5137344,
longitude: 13.4744798
}, {distance: 400})
.then(console.log)
.catch(console.error)
```
The response may look like this:
```js
[ {
type: 'station',
id: '900000120001',
name: 'S+U Frankfurter Allee',
location: {
type: 'location',
latitude: 52.513616,
longitude: 13.475298
},
products: {
suburban: true,
subway: true,
tram: true,
bus: true,
ferry: false,
express: false,
regional: false
},
distance: 56
}, {
type: 'station',
id: '900000120540',
name: 'Scharnweberstr./Weichselstr.',
location: {
type: 'location',
latitude: 52.512339,
longitude: 13.470174
},
products: { /* … */ },
distance: 330
}, {
type: 'station',
id: '900000160544',
name: 'Rathaus Lichtenberg',
location: {
type: 'location',
latitude: 52.515908,
longitude: 13.479073
},
products: { /* … */ },
distance: 394
} ]
```

158
docs/radar.md Normal file
View file

@ -0,0 +1,158 @@
# `radar(north, west, south, east, [opt])`
Use this method to find all vehicles currently in an area. Note that it is not supported by every profile/endpoint.
`north`, `west`, `south` and `eath` must be numbers (e.g. `52.52411`). Together, they form a [bounding box](https://en.wikipedia.org/wiki/Minimum_bounding_box).
With `opt`, you can override the default options, which look like this:
```js
{
results: 256, // maximum number of vehicles
duration: 30, // compute frames for the next n seconds
frames: 3, // nr of frames to compute
}
```
## Response
*Note:* As stated in the [*Friendly Public Transport Format* `1.0.1`](https://github.com/public-transport/friendly-public-transport-format/tree/1.0.1), the returned `departure` and `arrival` times include the current delay. The `departureDelay`/`arrivalDelay` fields express how much they differ from the schedule.
As an example, we're going to use the [VBB profile](../p/vbb):
```js
const createClient = require('hafas-client')
const vbbProfile = require('hafas-client/p/vbb')
const client = createClient(vbbProfile)
client.radar(52.52411, 13.41002, 52.51942, 13.41709, {results: 5})
.then(console.log)
.catch(console.error)
```
The response may look like this:
```js
[ {
location: {
type: 'location',
latitude: 52.521508,
longitude: 13.411267
},
line: {
type: 'line',
id: 's9',
name: 'S9',
public: true,
mode: 'train',
product: 'suburban',
symbol: 'S',
nr: 9,
metro: false,
express: false,
night: false,
operator: {
type: 'operator',
id: 's-bahn-berlin-gmbh',
name: 'S-Bahn Berlin GmbH'
}
},
direction: 'S Flughafen Berlin-Schönefeld',
trip: 31463,
nextStops: [ {
station: {
type: 'station',
id: '900000029101',
name: 'S Spandau',
location: {
type: 'location',
latitude: 52.534794,
longitude: 13.197477
},
products: {
suburban: true,
subway: false,
tram: false,
bus: true,
ferry: false,
express: true,
regional: true
}
},
arrival: null,
arrivalDelay: null,
departure: '2017-12-17T19:16:00.000+01:00',
departureDelay: null
} /* … */ ],
frames: [ {
origin: {
type: 'station',
id: '900000100003',
name: 'S+U Alexanderplatz',
location: { /* … */ },
products: { /* … */ }
},
destination: {
type: 'station',
id: '900000100004',
name: 'S+U Jannowitzbrücke',
location: { /* … */ },
products: { /* … */ }
},
t: 0
}, /* … */ {
origin: { /* Alexanderplatz */ },
destination: { /* Jannowitzbrücke */ },
t: 30000
} ]
}, {
location: {
type: 'location',
latitude: 52.523297,
longitude: 13.411151
},
line: {
type: 'line',
id: 'm2',
name: 'M2',
public: true,
mode: 'train',
product: 'tram',
symbol: 'M',
nr: 2,
metro: true,
express: false,
night: false,
operator: {
type: 'operator',
id: 'berliner-verkehrsbetriebe',
name: 'Berliner Verkehrsbetriebe'
}
},
direction: 'Heinersdorf',
trip: 26321,
nextStops: [ {
station: { /* S+U Alexanderplatz/Dircksenstr. */ },
arrival: null,
arrivalDelay: null,
departure: '2017-12-17T19:52:00.000+01:00',
departureDelay: null
}, {
station: { /* Memhardstr. */ },
arrival: '2017-12-17T19:54:00.000+01:00',
arrivalDelay: null,
departure: '2017-12-17T19:54:00.000+01:00',
departureDelay: null
}, /* … */ ],
frames: [ {
origin: { /* S+U Alexanderplatz/Dircksenstr. */ },
destination: { /* Memhardstr. */ },
t: 0
}, /* … */ {
origin: { /* Memhardstr. */ },
destination: { /* Mollstr./Prenzlauer Allee */ },
t: 30000
} ]
}, /* … */ ]
```

20
format/address.js Normal file
View file

@ -0,0 +1,20 @@
'use strict'
const formatCoord = require('./coord')
const formatAddress = (a) => {
if (a.type !== 'location' || !a.latitude || !a.longitude || !a.address) {
throw new Error('invalid address')
}
return {
type: 'A',
name: a.address,
crd: {
x: formatCoord(a.longitude),
y: formatCoord(a.latitude)
}
}
}
module.exports = formatAddress

5
format/coord.js Normal file
View file

@ -0,0 +1,5 @@
'use strict'
const formatCoord = x => Math.round(x * 1000000)
module.exports = formatCoord

12
format/date.js Normal file
View file

@ -0,0 +1,12 @@
'use strict'
const {DateTime} = require('luxon')
const formatDate = (profile, when) => {
return DateTime.fromMillis(+when, {
locale: profile.locale,
zone: profile.timezone
}).toFormat('yyyyMMdd')
}
module.exports = formatDate

11
format/filters.js Normal file
View file

@ -0,0 +1,11 @@
'use strict'
const bike = {type: 'BC', mode: 'INC'}
const accessibility = {
none: {type: 'META', mode: 'INC', meta: 'notBarrierfree'},
partial: {type: 'META', mode: 'INC', meta: 'limitedBarrierfree'},
complete: {type: 'META', mode: 'INC', meta: 'completeBarrierfree'}
}
module.exports = {bike, accessibility}

13
format/index.js Normal file
View file

@ -0,0 +1,13 @@
'use strict'
module.exports = {
date: require('./date'),
time: require('./time'),
filters: require('./filters'),
station: require('./station'),
address: require('./address'),
poi: require('./poi'),
location: require('./location'),
locationFilter: require('./location-filter'),
rectangle: require('./rectangle')
}

View file

@ -0,0 +1,8 @@
'use strict'
const formatLocationFilter = (stations, addresses, poi) => {
if (stations && addresses && poi) return 'ALL'
return (stations ? 'S' : '') + (addresses ? 'A' : '') + (poi ? 'P' : '')
}
module.exports = formatLocationFilter

14
format/location.js Normal file
View file

@ -0,0 +1,14 @@
'use strict'
const formatLocation = (profile, l) => {
if ('string' === typeof l) return profile.formatStation(l)
if ('object' === typeof l && !Array.isArray(l)) {
if (l.type === 'station') return profile.formatStation(l.id)
if ('string' === typeof l.id) return profile.formatPoi(l)
if ('string' === typeof l.address) return profile.formatAddress(l)
throw new Error('invalid location type: ' + l.type)
}
throw new Error('valid station, address or poi required.')
}
module.exports = formatLocation

21
format/poi.js Normal file
View file

@ -0,0 +1,21 @@
'use strict'
const formatCoord = require('./coord')
const formatPoi = (p) => {
if (p.type !== 'location' || !p.latitude || !p.longitude || !p.id || !p.name) {
throw new Error('invalid POI')
}
return {
type: 'P',
name: p.name,
lid: 'L=' + p.id,
crd: {
x: formatCoord(p.longitude),
y: formatCoord(p.latitude)
}
}
}
module.exports = formatPoi

View file

@ -0,0 +1,14 @@
'use strict'
const createFormatBitmask = (modes) => {
const formatBitmask = (products) => {
let bitmask = 0
for (let product in products) {
if (products[product] === true) bitmask += modes[product].bitmask
}
return bitmask
}
return formatBitmask
}
module.exports = createFormatBitmask

16
format/rectangle.js Normal file
View file

@ -0,0 +1,16 @@
'use strict'
const formatRectangle = (profile, north, west, south, east) => {
return {
llCrd: {
x: profile.formatCoord(west),
y: profile.formatCoord(south)
},
urCrd: {
x: profile.formatCoord(east),
y: profile.formatCoord(north)
}
}
}
module.exports = formatRectangle

5
format/station.js Normal file
View file

@ -0,0 +1,5 @@
'use strict'
const formatStation = id => ({type: 'S', lid: 'L=' + id})
module.exports = formatStation

12
format/time.js Normal file
View file

@ -0,0 +1,12 @@
'use strict'
const {DateTime} = require('luxon')
const formatTime = (profile, when) => {
return DateTime.fromMillis(+when, {
locale: profile.locale,
zone: profile.timezone
}).toFormat('HHmmss')
}
module.exports = formatTime

296
index.js
View file

@ -1,74 +1,258 @@
'use strict'
const Promise = require('pinkie-promise')
const {fetch} = require('fetch-ponyfill')({Promise})
const {stringify} = require('query-string')
const minBy = require('lodash/minBy')
const maxBy = require('lodash/maxBy')
const parse = require('./parse')
const validateProfile = require('./lib/validate-profile')
const defaultProfile = require('./lib/default-profile')
const request = require('./lib/request')
const createClient = (profile) => {
profile = Object.assign({}, defaultProfile, profile)
validateProfile(profile)
const departures = (station, opt = {}) => {
if ('object' === typeof 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.')
const id = (x) => x
const defaults = {
onBody: id,
onReq: id,
onLocation: parse.location,
onLine: parse.line,
onRemark: parse.remark,
onOperator: parse.operator
}
opt = Object.assign({
direction: null, // only show departures heading to this station
duration: 10 // show departures for the next n minutes
}, opt)
opt.when = opt.when || new Date()
const products = profile.formatProducts(opt.products || {})
const dir = opt.direction ? profile.formatStation(opt.direction) : null
return request(profile, {
meth: 'StationBoard',
req: {
type: 'DEP',
date: profile.formatDate(profile, opt.when),
time: profile.formatTime(profile, opt.when),
stbLoc: station,
dirLoc: dir,
jnyFltrL: [products],
dur: opt.duration,
getPasslist: false
}
})
.then((d) => {
if (!Array.isArray(d.jnyL)) return [] // todo: throw err?
const parse = profile.parseDeparture(profile, d.locations, d.lines, d.remarks)
return d.jnyL.map(parse)
.sort((a, b) => new Date(a.when) - new Date(b.when))
})
}
const journeys = (from, to, opt = {}) => {
from = profile.formatLocation(profile, from)
to = profile.formatLocation(profile, to)
const hafasError = (err) => {
err.isHafasError = true
return err
}
opt = Object.assign({
results: 5, // how many journeys?
via: null, // let journeys pass this station?
passedStations: false, // return stations on the way?
transfers: 5, // maximum of 5 transfers
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?
}, opt)
if (opt.via) opt.via = profile.formatLocation(profile, opt.via)
opt.when = opt.when || new Date()
const createRequest = (opt) => {
opt = Object.assign({}, defaults, opt)
const filters = [
profile.formatProducts(opt.products || {})
]
if (
opt.accessibility &&
profile.filters &&
profile.filters.accessibility &&
profile.filters.accessibility[opt.accessibility]
) {
filters.push(profile.filters.accessibility[opt.accessibility])
}
const request = (data) => {
const body = opt.onBody({lang: 'en', svcReqL: [data]})
const req = opt.onReq({
method: 'post',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
'Accept-Encoding': 'gzip, deflate',
'user-agent': 'https://github.com/derhuerst/hafas-client'
const query = profile.transformJourneysQuery({
outDate: profile.formatDate(profile, opt.when),
outTime: profile.formatTime(profile, opt.when),
numF: opt.results,
getPasslist: !!opt.passedStations,
maxChg: opt.transfers,
minChgTime: opt.transferTime,
depLocL: [from],
viaLocL: opt.via ? [opt.via] : null,
arrLocL: [to],
jnyFltrL: filters,
getTariff: !!opt.tickets,
// todo: what is req.gisFltrL?
getPT: true, // todo: what is this?
outFrwd: true, // todo: what is this?
getIV: false, // todo: walk & bike as alternatives?
getPolyline: false // todo: shape for displaying on a map?
}, opt)
return request(profile, {
cfg: {polyEnc: 'GPA'},
meth: 'TripSearch',
req: query
})
.then((d) => {
if (!Array.isArray(d.outConL)) return []
const parse = profile.parseJourney(profile, d.locations, d.lines, d.remarks)
return d.outConL.map(parse)
})
}
const locations = (query, opt = {}) => {
if ('string' !== typeof query) throw new Error('query must be a string.')
opt = Object.assign({
fuzzy: true, // find only exact matches?
results: 10, // how many search results?
stations: true,
addresses: true,
poi: true // points of interest
}, opt)
const f = profile.formatLocationFilter(opt.stations, opt.addresses, opt.poi)
return request(profile, {
cfg: {polyEnc: 'GPA'},
meth: 'LocMatch',
req: {input: {
loc: {
type: f,
name: opt.fuzzy ? query + '?' : query
},
query: null
maxLoc: opt.results,
field: 'S' // todo: what is this?
}}
})
const url = opt.endpoint + (req.query ? '?' + stringify(req.query) : '')
return fetch(url, req)
.then((res) => {
if (!res.ok) {
const err = new Error(res.statusText)
err.statusCode = res.status
throw hafasError(err)
}
return res.json()
})
.then((b) => {
if (b.err) throw hafasError(new Error(b.err))
if (!b.svcResL || !b.svcResL[0]) throw new Error('invalid response')
if (b.svcResL[0].err !== 'OK') {
throw hafasError(new Error(b.svcResL[0].errTxt))
}
const d = b.svcResL[0].res
const c = d.common || {}
if (Array.isArray(c.locL)) d.locations = c.locL.map(opt.onLocation)
if (Array.isArray(c.prodL)) d.lines = c.prodL.map(opt.onLine)
if (Array.isArray(c.remL)) d.remarks = c.remL.map(opt.onRemark)
if (Array.isArray(c.opL)) d.operators = c.opL.map(opt.onOperator)
return d
.then((d) => {
if (!d.match || !Array.isArray(d.match.locL)) return []
const parse = profile.parseLocation
return d.match.locL.map(loc => parse(profile, loc))
})
}
return request
const nearby = (location, opt = {}) => {
if ('object' !== typeof location || Array.isArray(location)) {
throw new Error('location must be an object.')
} else if (location.type !== 'location') {
throw new Error('invalid location object.')
} else if ('number' !== typeof location.latitude) {
throw new Error('location.latitude must be a number.')
} else if ('number' !== typeof location.longitude) {
throw new Error('location.longitude must be a number.')
}
opt = Object.assign({
results: 8, // maximum number of results
distance: null, // maximum walking distance in meters
poi: false, // return points of interest?
stations: true, // return stations?
}, opt)
return request(profile, {
cfg: {polyEnc: 'GPA'},
meth: 'LocGeoPos',
req: {
ring: {
cCrd: {
x: profile.formatCoord(location.longitude),
y: profile.formatCoord(location.latitude)
},
maxDist: opt.distance || -1,
minDist: 0
},
getPOIs: !!opt.poi,
getStops: !!opt.stations,
maxLoc: opt.results
}
})
.then((d) => {
if (!Array.isArray(d.locL)) return []
const parse = profile.parseNearby
return d.locL.map(loc => parse(profile, loc))
})
}
const journeyLeg = (ref, lineName, opt = {}) => {
opt = Object.assign({
passedStations: true // return stations on the way?
}, opt)
opt.when = opt.when || new Date()
return request(profile, {
cfg: {polyEnc: 'GPA'},
meth: 'JourneyDetails',
req: {
jid: ref,
name: lineName,
date: profile.formatDate(profile, opt.when)
}
})
.then((d) => {
const parse = profile.parseJourneyLeg(profile, d.locations, d.lines, d.remarks)
const leg = { // pretend the leg is contained in a journey
type: 'JNY',
dep: minBy(d.journey.stopL, 'idx'),
arr: maxBy(d.journey.stopL, 'idx'),
jny: d.journey
}
return parse(d.journey, leg, !!opt.passedStations)
})
}
const radar = (north, west, south, east, opt) => {
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.')
opt = Object.assign({
results: 256, // maximum number of vehicles
duration: 30, // compute frames for the next n seconds
frames: 3, // nr of frames to compute
products: null // optionally an object of booleans
}, opt || {})
opt.when = opt.when || new Date()
const durationPerStep = opt.duration / Math.max(opt.frames, 1) * 1000
return request(profile, {
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.formatProducts(opt.products || {})
],
trainPosMode: 'CALC' // todo: what is this? what about realtime?
}
})
.then((d) => {
if (!Array.isArray(d.jnyL)) return []
const parse = profile.parseMovement(profile, d.locations, d.lines, d.remarks)
return d.jnyL.map(parse)
})
}
const client = {departures, journeys, locations, nearby}
if (profile.journeyLeg) client.journeyLeg = journeyLeg
if (profile.radar) client.radar = radar
Object.defineProperty(client, 'profile', {value: profile})
return client
}
module.exports = createRequest
module.exports = createClient

62
lib/default-profile.js Normal file
View file

@ -0,0 +1,62 @@
'use strict'
const parseDateTime = require('../parse/date-time')
const parseDeparture = require('../parse/departure')
const parseJourneyLeg = require('../parse/journey-leg')
const parseJourney = require('../parse/journey')
const parseLine = require('../parse/line')
const parseLocation = require('../parse/location')
const parseMovement = require('../parse/movement')
const parseNearby = require('../parse/nearby')
const parseOperator = require('../parse/operator')
const parseRemark = require('../parse/remark')
const parseStopover = require('../parse/stopover')
const formatAddress = require('../format/address')
const formatCoord = require('../format/coord')
const formatDate = require('../format/date')
const formatLocationFilter = require('../format/location-filter')
const formatPoi = require('../format/poi')
const formatStation = require('../format/station')
const formatTime = require('../format/time')
const formatLocation = require('../format/location')
const formatRectangle = require('../format/rectangle')
const filters = require('../format/filters')
const id = x => x
const defaultProfile = {
transformReqBody: id,
transformReq: id,
transformJourneysQuery: id,
parseDateTime,
parseDeparture,
parseJourneyLeg,
parseJourney,
parseLine,
parseStationName: id,
parseLocation,
parseMovement,
parseNearby,
parseOperator,
parseRemark,
parseStopover,
formatAddress,
formatCoord,
formatDate,
formatLocationFilter,
formatPoi,
formatStation,
formatTime,
formatLocation,
formatRectangle,
filters,
journeyLeg: false,
radar: false
}
module.exports = defaultProfile

62
lib/request.js Normal file
View file

@ -0,0 +1,62 @@
'use strict'
const Promise = require('pinkie-promise')
const {fetch} = require('fetch-ponyfill')({Promise})
const {stringify} = require('query-string')
const hafasError = (err) => {
err.isHafasError = true
return err
}
const request = (profile, data) => {
const body = profile.transformReqBody({lang: 'en', svcReqL: [data]})
const req = profile.transformReq({
method: 'post',
// todo: CORS? referrer policy?
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
'Accept-Encoding': 'gzip, deflate',
'user-agent': 'https://github.com/derhuerst/hafas-client'
},
query: null
})
const url = profile.endpoint + (req.query ? '?' + stringify(req.query) : '')
return fetch(url, req)
.then((res) => {
if (!res.ok) {
const err = new Error(res.statusText)
err.statusCode = res.status
throw hafasError(err)
}
return res.json()
})
.then((b) => {
if (b.err) throw hafasError(new Error(b.err))
if (!b.svcResL || !b.svcResL[0]) throw new Error('invalid response')
if (b.svcResL[0].err !== 'OK') {
throw hafasError(new Error(b.svcResL[0].errTxt))
}
const d = b.svcResL[0].res
const c = d.common || {}
if (Array.isArray(c.locL)) {
d.locations = c.locL.map(loc => profile.parseLocation(profile, loc))
}
if (Array.isArray(c.remL)) {
d.remarks = c.remL.map(rem => profile.parseRemark(profile, rem))
}
if (Array.isArray(c.opL)) {
d.operators = c.opL.map(op => profile.parseOperator(profile, op))
}
if (Array.isArray(c.prodL)) {
const parse = profile.parseLine(profile, d.operators)
d.lines = c.prodL.map(parse)
}
return d
})
}
module.exports = request

48
lib/validate-profile.js Normal file
View file

@ -0,0 +1,48 @@
'use strict'
const types = {
locale: 'string',
timezone: 'string',
transformReq: 'function',
transformReqBody: 'function',
transformJourneysQuery: 'function',
products: 'object',
parseDateTime: 'function',
parseDeparture: 'function',
parseJourneyLeg: 'function',
parseJourney: 'function',
parseLine: 'function',
parseStationName: 'function',
parseLocation: 'function',
parseMovement: 'function',
parseNearby: 'function',
parseOperator: 'function',
parseRemark: 'function',
parseStopover: 'function',
formatAddress: 'function',
formatCoord: 'function',
formatDate: 'function',
formatLocationFilter: 'function',
formatPoi: 'function',
formatStation: 'function',
formatTime: 'function',
formatLocation: 'function',
formatRectangle: 'function'
}
const validateProfile = (profile) => {
for (let key of Object.keys(types)) {
const type = types[key]
if (type !== typeof profile[key]) {
throw new Error(`profile.${key} must be a ${type}.`)
}
if (type === 'object' && profile[key] === null) {
throw new Error(`profile.${key} must not be null.`)
}
}
}
module.exports = validateProfile

17
p/db/example.js Normal file
View file

@ -0,0 +1,17 @@
'use strict'
const createClient = require('../../')
const dbProfile = require('.')
const client = createClient(dbProfile)
// Berlin Jungfernheide to München Hbf
client.journeys('8011167', '8000261', {results: 1, tickets: true})
// client.departures('8011167', {duration: 1})
// client.locations('Berlin Jungfernheide')
// client.locations('Atze Musiktheater', {poi: true, addressses: false, fuzzy: false})
// client.nearby(52.4751309, 13.3656537, {results: 1})
.then((data) => {
console.log(require('util').inspect(data, {depth: null}))
}, console.error)

160
p/db/index.js Normal file
View file

@ -0,0 +1,160 @@
'use strict'
const crypto = require('crypto')
const _createParseLine = require('../../parse/line')
const _createParseJourney = require('../../parse/journey')
const _formatStation = require('../../format/station')
const createParseBitmask = require('../../parse/products-bitmask')
const createFormatBitmask = require('../../format/products-bitmask')
const {bike} = require('../../format/filters')
const modes = require('./modes')
const formatLoyaltyCard = require('./loyalty-cards').format
const formatBitmask = createFormatBitmask(modes)
const transformReqBody = (body) => {
body.client = {id: 'DB', v: '16040000', type: 'IPH', name: 'DB Navigator'}
body.ext = 'DB.R15.12.a'
body.ver = '1.15'
body.auth = {type: 'AID', aid: 'n91dB8Z77MLdoR0K'}
return body
}
const salt = 'bdI8UVj40K5fvxwf'
const transformReq = (req) => {
const hash = crypto.createHash('md5')
hash.update(req.body + salt)
if (!req.query) req.query = {}
req.query.checksum = hash.digest('hex')
return req
}
const transformJourneysQuery = (query, opt) => {
const filters = query.jnyFltrL
if (opt.bike) filters.push(bike)
query.trfReq = {
jnyCl: opt.firstClass === true ? 1 : 2,
tvlrProf: [{
type: 'E',
redtnCard: opt.loyaltyCard
? formatLoyaltyCard(opt.loyaltyCard)
: null
}],
cType: 'PK'
}
return query
}
const createParseLine = (profile, operators) => {
const parseLine = _createParseLine(profile, operators)
const parseLineWithMode = (l) => {
const res = parseLine(l)
res.mode = res.product = null
if ('class' in res) {
const data = modes.bitmasks[parseInt(res.class)]
if (data) {
res.mode = data.mode
res.product = data.product
}
}
return res
}
return parseLineWithMode
}
const createParseJourney = (profile, stations, lines, remarks) => {
const parseJourney = _createParseJourney(profile, stations, lines, remarks)
// todo: j.sotRating, j.conSubscr, j.isSotCon, j.showARSLink, k.sotCtxt
// todo: j.conSubscr, j.showARSLink, j.useableTime
const parseJourneyWithPrice = (j) => {
const res = parseJourney(j)
// todo: find cheapest, find discounts
// todo: write a parser like vbb-parse-ticket
// [ {
// prc: 15000,
// isFromPrice: true,
// isBookable: true,
// isUpsell: false,
// targetCtx: 'D',
// buttonText: 'To offer selection'
// } ]
res.price = {amount: null, hint: 'No pricing information available.'}
if (
j.trfRes &&
Array.isArray(j.trfRes.fareSetL) &&
j.trfRes.fareSetL[0] &&
Array.isArray(j.trfRes.fareSetL[0].fareL) &&
j.trfRes.fareSetL[0].fareL[0]
) {
const tariff = j.trfRes.fareSetL[0].fareL[0]
if (tariff.prc >= 0) { // wat
res.price = {amount: tariff.prc / 100, hint: null}
}
}
return res
}
return parseJourneyWithPrice
}
const isIBNR = /^\d{6,}$/
const formatStation = (id) => {
if (!isIBNR.test(id)) throw new Error('station ID must be an IBNR.')
return _formatStation(id)
}
const defaultProducts = {
suburban: true,
subway: true,
tram: true,
bus: true,
ferry: true,
national: true,
nationalExp: true,
regional: true,
regionalExp: true
}
const formatProducts = (products) => {
products = Object.assign(Object.create(null), defaultProducts, products)
return {
type: 'PROD',
mode: 'INC',
value: formatBitmask(products) + ''
}
}
// todo: find option for absolute number of results
const dbProfile = {
locale: 'de-DE',
timezone: 'Europe/Berlin',
endpoint: 'https://reiseauskunft.bahn.de/bin/mgate.exe',
transformReqBody,
transformReq,
transformJourneysQuery,
products: modes.allProducts,
// todo: parseLocation
parseLine: createParseLine,
parseProducts: createParseBitmask(modes.bitmasks),
parseJourney: createParseJourney,
formatStation,
formatProducts
}
module.exports = dbProfile

30
p/db/loyalty-cards.js Normal file
View file

@ -0,0 +1,30 @@
'use strict'
const c = {
NONE: Symbol('no loyaly card'),
BAHNCARD: Symbol('Bahncard'),
VORTEILSCARD: Symbol('VorteilsCard'),
HALBTAXABO: Symbol('HalbtaxAbo'),
VOORDEELURENABO: Symbol('Voordeelurenabo'),
SHCARD: Symbol('SH-Card'),
GENERALABONNEMENT: Symbol('General-Abonnement')
}
// see https://gist.github.com/juliuste/202bb04f450a79f8fa12a2ec3abcd72d
const formatLoyaltyCard = (data) => {
if (data.type === c.BAHNCARD) {
if (data.discount === 25) return c.class === 1 ? 1 : 2
if (data.discount === 50) return c.class === 1 ? 3 : 4
}
if (data.type === c.VORTEILSCARD) return 9
if (data.type === c.HALBTAXABO) return data.railplus ? 10 : 11
if (data.type === c.VOORDEELURENABO) return data.railplus ? 12 : 13
if (data.type === c.SHCARD) return 14
if (data.type === c.GENERALABONNEMENT) return 15
return 0
}
module.exports = {
data: c,
format: formatLoyaltyCard
}

108
p/db/modes.js Normal file
View file

@ -0,0 +1,108 @@
'use strict'
// todo: https://gist.github.com/anonymous/d3323a5d2d6e159ed42b12afd0380434#file-haf_products-properties-L1-L95
const m = {
nationalExp: {
bitmask: 1,
name: 'InterCityExpress',
short: 'ICE',
mode: 'train',
product: 'nationalExp'
},
national: {
bitmask: 2,
name: 'InterCity & EuroCity',
short: 'IC/EC',
mode: 'train',
product: 'national'
},
regionalExp: {
bitmask: 4,
name: 'RegionalExpress & InterRegio',
short: 'RE/IR',
mode: 'train',
product: 'regionalExp'
},
regional: {
bitmask: 8,
name: 'Regio',
short: 'RB',
mode: 'train',
product: 'regional'
},
suburban: {
bitmask: 16,
name: 'S-Bahn',
short: 'S',
mode: 'train',
product: 'suburban'
},
bus: {
bitmask: 32,
name: 'Bus',
short: 'B',
mode: 'bus',
product: 'bus'
},
ferry: {
bitmask: 64,
name: 'Ferry',
short: 'F',
mode: 'watercraft',
product: 'ferry'
},
subway: {
bitmask: 128,
name: 'U-Bahn',
short: 'U',
mode: 'train',
product: 'subway'
},
tram: {
bitmask: 256,
name: 'Tram',
short: 'T',
mode: 'tram',
product: 'tram'
},
taxi: {
bitmask: 512,
name: 'Group Taxi',
short: 'Taxi',
mode: null, // todo
product: 'taxi'
},
unknown: {
bitmask: 0,
name: 'unknown',
short: '?',
product: 'unknown'
}
}
m.bitmasks = []
m.bitmasks[1] = m.nationalExp
m.bitmasks[2] = m.national
m.bitmasks[4] = m.regionalExp
m.bitmasks[8] = m.regional
m.bitmasks[16] = m.suburban
m.bitmasks[32] = m.bus
m.bitmasks[64] = m.ferry
m.bitmasks[128] = m.subway
m.bitmasks[256] = m.tram
m.bitmasks[512] = m.taxi
m.allProducts = [
m.nationalExp,
m.national,
m.regionalExp,
m.regional,
m.suburban,
m.bus,
m.ferry,
m.subway,
m.tram,
m.taxi
]
module.exports = m

21
p/db/readme.md Normal file
View file

@ -0,0 +1,21 @@
# DB profile for `hafas-client`
[*Deutsche Bahn (DB)*](https://en.wikipedia.org/wiki/Deutsche_Bahn) is the largest German long-distance public transport company. This profile adds *DB*-specific customizations to `hafas-client`. Consider using [`db-hafas`](https://github.com/derhuerst/db-hafas#db-hafas), to always get the customized client right away.
## Usage
```js
const createClient = require('hafas-client')
const dbProfile = require('hafas-client/p/db')
// create a client with DB profile
const client = createClient(dbProfile)
```
## Customisations
- supports 1st and 2nd class with `journey()`
- supports [their loyalty cards](https://en.wikipedia.org/wiki/Deutsche_Bahn#Tickets) with `journey()`
- parses *DB*-specific products (such as *InterCity-Express*)
- exposes the cheapest ticket price for a `journey`

15
p/readme.md Normal file
View file

@ -0,0 +1,15 @@
# Profiles
This directory contains specific customisations for each endpoint, called *profiles*. They **parse data from the API differently, add additional information, or enable non-default methods** (such as [`journeyLeg`](../docs/journey-leg.md)) if they are supported.
Each profile has it's own directory. It will be passed into `hafas-client` and is expected to be in a certain structure:
```js
const createClient = require('hafas-client')
const someProfile = require('hafas-client/p/some-profile')
// create a client with the profile
const client = createClient(dbProfile)
// use it to query data…
```

18
p/vbb/example.js Normal file
View file

@ -0,0 +1,18 @@
'use strict'
const createClient = require('../..')
const vbbProfile = require('.')
const client = createClient(vbbProfile)
// Hauptbahnhof to Charlottenburg
client.journeys('900000003201', '900000024101', {results: 1})
// client.departures('900000013102', {duration: 1})
// client.locations('Alexanderplatz', {results: 2})
// client.nearby(52.5137344, 13.4744798, {distance: 60})
// client.radar(52.52411, 13.41002, 52.51942, 13.41709, {results: 10})
.then((data) => {
console.log(require('util').inspect(data, {depth: null}))
})
.catch(console.error)

190
p/vbb/index.js Normal file
View file

@ -0,0 +1,190 @@
'use strict'
const shorten = require('vbb-short-station-name')
const {to12Digit, to9Digit} = require('vbb-translate-ids')
const parseLineName = require('vbb-parse-line')
const parseTicket = require('vbb-parse-ticket')
const getStations = require('vbb-stations')
const _createParseLine = require('../../parse/line')
const _parseLocation = require('../../parse/location')
const _createParseJourney = require('../../parse/journey')
const _createParseStopover = require('../../parse/stopover')
const _createParseDeparture = require('../../parse/departure')
const _formatStation = require('../../format/station')
const createParseBitmask = require('../../parse/products-bitmask')
const createFormatBitmask = require('../../format/products-bitmask')
const modes = require('./modes')
const formatBitmask = createFormatBitmask(modes)
const transformReqBody = (body) => {
body.client = {type: 'IPA', id: 'VBB', name: 'vbbPROD', v: '4010300'}
body.ext = 'VBB.1'
body.ver = '1.11' // todo: 1.16 with `mic` and `mac` query params
body.auth = {type: 'AID', aid: 'hafas-vbb-apps'}
return body
}
const createParseLine = (profile, operators) => {
const parseLine = _createParseLine(profile, operators)
const parseLineWithMode = (l) => {
const res = parseLine(l)
res.mode = res.product = null
if ('class' in res) {
const data = modes.bitmasks[parseInt(res.class)]
if (data) {
res.mode = data.mode
res.product = data.product
}
}
const details = parseLineName(l.name)
res.symbol = details.symbol
res.nr = details.nr
res.metro = details.metro
res.express = details.express
res.night = details.night
return res
}
return parseLineWithMode
}
const parseLocation = (profile, l) => {
const res = _parseLocation(profile, l)
if (res.type === 'station') {
res.name = shorten(res.name)
res.id = to12Digit(res.id)
if (!res.location.latitude || !res.location.longitude) {
const [s] = getStations(res.id)
if (s) Object.assign(res.location, s.coordinates)
}
}
return res
}
const createParseJourney = (profile, stations, lines, remarks) => {
const parseJourney = _createParseJourney(profile, stations, lines, remarks)
const parseJourneyWithTickets = (j) => {
const res = parseJourney(j)
if (
j.trfRes &&
Array.isArray(j.trfRes.fareSetL) &&
j.trfRes.fareSetL[0] &&
Array.isArray(j.trfRes.fareSetL[0].fareL)
) {
res.tickets = []
const sets = j.trfRes.fareSetL[0].fareL
for (let s of sets) {
if (!Array.isArray(s.ticketL) || s.ticketL.length === 0) continue
for (let t of s.ticketL) {
const ticket = parseTicket(t)
ticket.name = s.name + ' ' + ticket.name
res.tickets.push(ticket)
}
}
}
return res
}
return parseJourneyWithTickets
}
const createParseStopover = (profile, stations, lines, remarks, connection) => {
const parseStopover = _createParseStopover(profile, stations, lines, remarks, connection)
const parseStopoverWithShorten = (st) => {
const res = parseStopover(st)
if (res.station && res.station.name) {
res.station.name = shorten(res.station.name)
}
return res
}
return parseStopoverWithShorten
}
const createParseDeparture = (profile, stations, lines, remarks) => {
const parseDeparture = _createParseDeparture(profile, stations, lines, remarks)
const ringbahnClockwise = /^ringbahn s\s?41$/i
const ringbahnAnticlockwise = /^ringbahn s\s?42$/i
const parseDepartureRenameRingbahn = (j) => {
const res = parseDeparture(j)
if (res.line && res.line.product === 'suburban') {
const d = res.direction && res.direction.trim()
if (ringbahnClockwise.test(d)) res.direction = 'Ringbahn S41 ⟳'
else if (ringbahnAnticlockwise.test(d)) res.direction = 'Ringbahn S42 ⟲'
}
return res
}
return parseDepartureRenameRingbahn
}
const validIBNR = /^\d+$/
const formatStation = (id) => {
if ('string' !== typeof id) throw new Error('station ID must be a string.')
const l = id.length
if ((l !== 7 && l !== 9 && l !== 12) || !validIBNR.test(id)) {
throw new Error('station ID must be a valid IBNR.')
}
// The VBB has some 7-digit stations. We don't convert them to 12 digits,
// because it only recognizes in the 7-digit format. see derhuerst/vbb-hafas#22
if (l !== 7) id = to9Digit(id)
return _formatStation(id)
}
const defaultProducts = {
suburban: true,
subway: true,
tram: true,
bus: true,
ferry: true,
express: true,
regional: true
}
const formatProducts = (products) => {
products = Object.assign(Object.create(null), defaultProducts, products)
return {
type: 'PROD',
mode: 'INC',
value: formatBitmask(products) + ''
}
}
const vbbProfile = {
locale: 'de-DE',
timezone: 'Europe/Berlin',
endpoint: 'https://fahrinfo.vbb.de/bin/mgate.exe',
transformReqBody,
products: modes.allProducts,
parseStationName: shorten,
parseLocation,
parseLine: createParseLine,
parseProducts: createParseBitmask(modes.bitmasks),
parseJourney: createParseJourney,
parseDeparture: createParseDeparture,
parseStopover: createParseStopover,
formatStation,
formatProducts,
journeyLeg: true,
radar: true
}
module.exports = vbbProfile

112
p/vbb/modes.js Normal file
View file

@ -0,0 +1,112 @@
'use strict'
// todo: remove useless keys
const m = {
suburban: {
category: 0,
bitmask: 1,
name: 'S-Bahn',
mode: 'train',
short: 'S',
product: 'suburban'
},
subway: {
category: 1,
bitmask: 2,
name: 'U-Bahn',
mode: 'train',
short: 'U',
product: 'subway'
},
tram: {
category: 2,
bitmask: 4,
name: 'Tram',
mode: 'train',
short: 'T',
product: 'tram'
},
bus: {
category: 3,
bitmask: 8,
name: 'Bus',
mode: 'bus',
short: 'B',
product: 'bus'
},
ferry: {
category: 4,
bitmask: 16,
name: 'Fähre',
mode: 'watercraft',
short: 'F',
product: 'ferry'
},
express: {
category: 5,
bitmask: 32,
name: 'IC/ICE',
mode: 'train',
short: 'E',
product: 'express'
},
regional: {
category: 6,
bitmask: 64,
name: 'RB/RE',
mode: 'train',
short: 'R',
product: 'regional'
},
unknown: {
category: null,
bitmask: 0,
name: 'unknown',
mode: null,
short: '?',
product: 'unknown'
}
}
m.bitmasks = []
m.bitmasks[1] = m.suburban
m.bitmasks[2] = m.subway
m.bitmasks[4] = m.tram
m.bitmasks[8] = m.bus
m.bitmasks[16] = m.ferry
m.bitmasks[32] = m.express
m.bitmasks[64] = m.regional
m.categories = [
m.suburban,
m.subway,
m.tram,
m.bus,
m.ferry,
m.express,
m.regional,
m.unknown
]
m.allProducts = [
m.suburban,
m.subway,
m.tram,
m.bus,
m.ferry,
m.express,
m.regional
]
// m.parseCategory = (category) => {
// return m.categories[parseInt(category)] || m.unknown
// }
module.exports = m

22
p/vbb/readme.md Normal file
View file

@ -0,0 +1,22 @@
# VBB profile for `hafas-client`
[*Verkehrsverbund Berlin-Brandenburg (VBB)*](https://en.wikipedia.org/wiki/Verkehrsverbund_Berlin-Brandenburg) is a group of public transport companies, running the public transport network in [Berlin](https://en.wikipedia.org/wiki/Berlin). This profile adds *VBB*-specific customizations to `hafas-client`. Consider using [`vbb-hafas`](https://github.com/derhuerst/vbb-hafas#vbb-hafas), to always get the customized client right away.
## Usage
```js
const createClient = require('hafas-client')
const vbbProfile = require('hafas-client/p/vbb')
// create a client with VBB profile
const client = createClient(vbbProfile)
```
## Customisations
- parses *VBB*-specific products (such as *X-Bus*)
- strips parts from station names that are unnecessary in the Berlin context
- parses line names to give more information (e.g. "Is it an express bus?")
- parses *VBB*-specific tickets
- renames *Ringbahn* line names to contain `⟳` and `⟲`

View file

@ -1,12 +1,16 @@
{
"name": "hafas-client",
"description": "JavaScript client for HAFAS mobile APIs.",
"version": "1.3.1",
"description": "JavaScript client for HAFAS public transport APIs.",
"version": "2.0.0",
"main": "index.js",
"files": [
"index.js",
"parse.js",
"stringify.js"
"lib",
"parse",
"format",
"p",
"docs"
],
"author": "Jannis R <mail@jannisr.de>",
"homepage": "https://github.com/derhuerst/hafas-client",
@ -17,21 +21,39 @@
"hafas",
"public",
"transport",
"transit",
"api",
"mgate"
"http"
],
"engines": {
"node": ">=6"
},
"dependencies": {
"fetch-ponyfill": "^4.1.0",
"moment-timezone": "^0.5.13",
"lodash": "^4.17.4",
"luxon": "^0.2.7",
"pinkie-promise": "^2.0.1",
"query-string": "^5.0.0",
"slugg": "^1.2.0"
"slugg": "^1.2.0",
"vbb-parse-line": "^0.2.5",
"vbb-parse-ticket": "^0.2.1",
"vbb-short-station-name": "^0.4.0",
"vbb-stations": "^5.9.0",
"vbb-translate-ids": "^3.1.0"
},
"devDependencies": {
"co": "^4.6.0",
"db-stations": "^1.25.0",
"is-coordinates": "^2.0.2",
"is-roughly-equal": "^0.1.0",
"tap-spec": "^4.1.1",
"tape": "^4.8.0",
"tape-promise": "^2.0.1",
"validate-fptf": "^1.0.2",
"vbb-stations-autocomplete": "^2.11.0"
},
"scripts": {
"test": "node -e \"require('.')\"",
"prepublishOnly": "npm test"
"test": "node test/index.js",
"prepublishOnly": "npm test | tap-spec"
}
}

237
parse.js
View file

@ -1,237 +0,0 @@
'use strict'
const moment = require('moment-timezone')
const slugg = require('slugg')
const dateTime = (tz, date, time) => {
let offset = 0 // in days
if (time.length > 6) {
offset = +time.slice(0, -6)
time = time.slice(-6)
}
return moment.tz(date + 'T' + time, tz)
.add(offset, 'days')
}
const types = {P: 'poi', S: 'station', A: 'address'}
// todo: what is s.rRefL?
const location = (l) => {
const type = types[l.type] || 'unknown'
const result = {
type,
name: l.name,
coordinates: l.crd ? {
latitude: l.crd.y / 1000000,
longitude: l.crd.x / 1000000
} : null
}
if (type === 'poi' || type === 'station') result.id = l.extId
if ('pCls' in l) result.products = l.pCls
return result
}
// todo: what is p.number vs p.line?
// todo: what is p.icoX?
// todo: what is p.oprX?
const line = (p) => {
if (!p) return null
const result = {type: 'line', name: p.line || p.name}
if (p.cls) result.class = p.cls
if (p.prodCtx) {
result.productCode = +p.prodCtx.catCode
result.productName = p.prodCtx.catOutS
}
return result
}
const remark = (r) => null // todo
const operator = (a) => ({
type: 'operator',
id: slugg(a.name),
name: a.name
})
// s = stations, ln = lines, r = remarks, c = connection
const stopover = (tz, s, ln, r, c) => (st) => {
const result = {station: s[parseInt(st.locX)]}
if (st.aTimeR || st.aTimeS) {
result.arrival = dateTime(tz, c.date, st.aTimeR || st.aTimeS).format()
}
if (st.dTimeR || st.dTimeS) {
result.departure = dateTime(tz, c.date, st.dTimeR || st.dTimeS).format()
}
if (st.aCncl && st.dCncl) {
result.cancelled = true
}
return result
}
// todo: finish parseRemark first
// s = stations, ln = lines, r = remarks, c = connection
const applyRemark = (s, ln, r, c) => (rm) => null
// todo: pt.sDays
// todo: pt.dep.dProgType, pt.arr.dProgType
// todo: what is pt.jny.dirFlg?
// todo: how does pt.freq work?
// tz = timezone, s = stations, ln = lines, r = remarks, c = connection
const part = (tz, s, ln, r, c) => (pt) => {
const result = {
origin: Object.assign({}, s[parseInt(pt.dep.locX)])
, destination: Object.assign({}, s[parseInt(pt.arr.locX)])
, departure: dateTime(tz, c.date, pt.dep.dTimeR || pt.dep.dTimeS).format()
, arrival: dateTime(tz, c.date, pt.arr.aTimeR || pt.arr.aTimeS).format()
}
if (pt.dep.dTimeR && pt.dep.dTimeS) {
const realtime = dateTime(tz, c.date, pt.dep.dTimeR)
const planned = dateTime(tz, c.date, pt.dep.dTimeS)
result.delay = Math.round((realtime - planned) / 1000)
}
if (pt.type === 'WALK') result.mode = 'walking'
else if (pt.type === 'JNY') {
result.id = pt.jny.jid
result.line = ln[parseInt(pt.jny.prodX)]
result.direction = pt.jny.dirTxt // todo: parse this
if (pt.dep.dPlatfS) result.departurePlatform = pt.dep.dPlatfS
if (pt.arr.aPlatfS) result.arrivalPlatform = pt.arr.aPlatfS
if (pt.jny.stopL) result.passed = pt.jny.stopL.map(stopover(tz, s, ln, r, c))
if (Array.isArray(pt.jny.remL))
pt.jny.remL.forEach(applyRemark(s, ln, r, c))
if (pt.jny.freq && pt.jny.freq.jnyL)
result.alternatives = pt.jny.freq.jnyL
.filter((a) => a.stopL[0].locX === pt.dep.locX)
.map((a) => ({
line: ln[parseInt(a.prodX)],
when: dateTime(tz, c.date, a.stopL[0].dTimeS).format()
}))
}
// todo: follow public-transport/friendly-public-transport-format#27 here
if (pt.dep.dCncl && pt.arr.aCncl) {
result.cancelled = true
}
return result
}
// todo: c.sDays
// todo: c.dep.dProgType, c.arr.dProgType
// todo: c.conSubscr
// todo: c.trfRes x vbb-parse-ticket
// todo: use computed information from part
// s = stations, ln = lines, r = remarks, p = parsePart
const journey = (tz, s, ln, r, p = part) => (c) => {
const parts = c.secL.map(p(tz, s, ln, r, c))
return {
parts
, origin: parts[0].origin
, destination: parts[parts.length - 1].destination
, departure: parts[0].departure
, arrival: parts[parts.length - 1].arrival
}
}
// todos from derhuerst/hafas-client#2
// - stdStop.dPlatfS, stdStop.dPlatfR
// todo: what is d.jny.dirFlg?
// todo: d.stbStop.dProgType
// tz = timezone, s = stations, ln = lines, r = remarks
const departure = (tz, s, ln, r) => (d) => {
const result = {
ref: d.jid
, station: s[parseInt(d.stbStop.locX)]
, when: dateTime(tz, d.date, d.stbStop.dTimeR || d.stbStop.dTimeS).format()
, direction: d.dirTxt
, line: ln[parseInt(d.prodX)]
, remarks: d.remL ? d.remL.map((rm) => r[parseInt(rm.remX)]) : null
, trip: +d.jid.split('|')[1]
}
if (d.stbStop.dTimeR && d.stbStop.dTimeS) {
const realtime = dateTime(tz, d.date, d.stbStop.dTimeR)
const planned = dateTime(tz, d.date, d.stbStop.dTimeS)
result.delay = Math.round((realtime - planned) / 1000)
} else result.delay = null
// todo: follow public-transport/friendly-public-transport-format#27 here
if (d.stbStop.dCncl) {
result.cancelled = true
}
return result
}
// todo: remarks
// todo: lines
// todo: what is s.pCls?
// todo: what is s.wt?
// todo: what is s.dur?
const nearby = (n) => {
const result = location(n)
result.distance = n.dist
return result
}
// todo: what is m.dirGeo? maybe the speed?
// todo: what is m.stopL?
// todo: what is m.proc? wut?
// todo: what is m.pos?
// todo: what is m.ani.dirGeo[n]? maybe the speed?
// todo: what is m.ani.proc[n]? wut?
// todo: how does m.ani.poly work?
// tz = timezone, l = locations, ln = lines, r = remarks
const movement = (tz, l, ln, r) => (m) => {
const result = {
direction: m.dirTxt
, line: ln[m.prodX]
, coordinates: m.pos ? {
latitude: m.pos.y / 1000000,
longitude: m.pos.x / 1000000
} : null
, nextStops: m.stopL.map((s) => ({
station: l[s.locX]
, departure: s.dTimeR || s.dTimeS
? dateTime(tz, m.date, s.dTimeR || s.dTimeS).format()
: null
, arrival: s.aTimeR || s.aTimeS
? dateTime(tz, m.date, s.aTimeR || s.aTimeS).format()
: null
}))
, frames: []
}
if (m.ani && Array.isArray(m.ani.mSec))
for (let i = 0; i < m.ani.mSec.length; i++)
result.frames.push({
origin: l[m.ani.fLocX[i]],
destination: l[m.ani.tLocX[i]],
t: m.ani.mSec[i]
})
return result
}
module.exports = {
dateTime,
location, line, remark, operator,
stopover, applyRemark, part, journey,
departure,
nearby,
movement
}

27
parse/date-time.js Normal file
View file

@ -0,0 +1,27 @@
'use strict'
const {DateTime} = require('luxon')
const validDate = /^(\d{4})-(\d{2})-(\d{2})$/
const parseDateTime = (profile, date, time) => {
const pDate = [date.substr(-8, 4), date.substr(-4, 2), date.substr(-2, 2)]
if (!pDate[0] || !pDate[1] || !pDate[2]) {
throw new Error('invalid date format: ' + date)
}
const pTime = [time.substr(-6, 2), time.substr(-4, 2), time.substr(-2, 2)]
if (!pTime[0] || !pTime[1] || !pTime[2]) {
throw new Error('invalid time format: ' + time)
}
const offset = time.length > 6 ? parseInt(time.slice(0, -6)) : 0
const dt = DateTime.fromISO(pDate.join('-') + 'T' + pTime.join(':'), {
locale: profile.locale,
zone: profile.timezone
})
return offset > 0 ? dt.plus({days: offset}) : dt
}
module.exports = parseDateTime

44
parse/departure.js Normal file
View file

@ -0,0 +1,44 @@
'use strict'
// todos from derhuerst/hafas-client#2
// - stdStop.dPlatfS, stdStop.dPlatfR
// todo: what is d.jny.dirFlg?
// todo: d.stbStop.dProgType
// todo: d.freq, d.freq.jnyL, see https://github.com/derhuerst/hafas-client/blob/9203ed1481f08baacca41ac5e3c19bf022f01b0b/parse.js#L115
const createParseDeparture = (profile, stations, lines, remarks) => {
const findRemark = rm => remarks[parseInt(rm.remX)] || null
const parseDeparture = (d) => {
const when = profile.parseDateTime(profile, d.date, d.stbStop.dTimeR || d.stbStop.dTimeS)
const res = {
journeyId: d.jid,
station: stations[parseInt(d.stbStop.locX)] || null,
when: when.toISO(),
direction: profile.parseStationName(d.dirTxt),
line: lines[parseInt(d.prodX)] || null,
remarks: d.remL ? d.remL.map(findRemark) : [],
trip: +d.jid.split('|')[1] // todo: this seems brittle
}
// todo: res.trip from rawLine.prodCtx.num
if (d.stbStop.dTimeR && d.stbStop.dTimeS) {
const realtime = profile.parseDateTime(profile, d.date, d.stbStop.dTimeR)
const planned = profile.parseDateTime(profile, d.date, d.stbStop.dTimeS)
res.delay = Math.round((realtime - planned) / 1000)
} else res.delay = null
// todo: follow public-transport/friendly-public-transport-format#27 here
// see also derhuerst/vbb-rest#19
if (d.stbStop.aCncl || d.stbStop.dCncl) {
res.cancelled = true
res.when = res.delay = null
}
return res
}
return parseDeparture
}
module.exports = createParseDeparture

14
parse/index.js Normal file
View file

@ -0,0 +1,14 @@
'use strict'
module.exports = {
dateTime: require('./date-time'),
location: require('./location'),
line: require('./line'),
remark: require('./remark'),
operator: require('./operator'),
stopover: require('./stopover'),
journeyLeg: require('./journey-leg'),
journey: require('./journey'),
nearby: require('./nearby'),
movement: require('./movement')
}

85
parse/journey-leg.js Normal file
View file

@ -0,0 +1,85 @@
'use strict'
const parseDateTime = require('./date-time')
const clone = obj => Object.assign({}, obj)
const createParseJourneyLeg = (profile, stations, lines, remarks) => {
// todo: finish parse/remark.js first
const applyRemark = (j, rm) => {}
// todo: pt.sDays
// todo: pt.dep.dProgType, pt.arr.dProgType
// todo: what is pt.jny.dirFlg?
// todo: how does pt.freq work?
// todo: what is pt.himL?
const parseJourneyLeg = (j, pt, passed = true) => { // j = journey, pt = part
const dep = profile.parseDateTime(profile, j.date, pt.dep.dTimeR || pt.dep.dTimeS)
const arr = profile.parseDateTime(profile, j.date, pt.arr.aTimeR || pt.arr.aTimeS)
const res = {
origin: clone(stations[parseInt(pt.dep.locX)]) || null,
destination: clone(stations[parseInt(pt.arr.locX)]),
departure: dep.toISO(),
arrival: arr.toISO()
}
if (pt.dep.dTimeR && pt.dep.dTimeS) {
const realtime = profile.parseDateTime(profile, j.date, pt.dep.dTimeR)
const planned = profile.parseDateTime(profile, j.date, pt.dep.dTimeS)
res.delay = Math.round((realtime - planned) / 1000)
}
if (pt.type === 'WALK') {
res.mode = 'walking'
res.public = true
} else if (pt.type === 'JNY') {
// todo: pull `public` value from `profile.products`
res.id = pt.jny.jid
res.line = lines[parseInt(pt.jny.prodX)] || null
res.direction = profile.parseStationName(pt.jny.dirTxt)
if (pt.dep.dPlatfS) res.departurePlatform = pt.dep.dPlatfS
if (pt.arr.aPlatfS) res.arrivalPlatform = pt.arr.aPlatfS
if (passed && pt.jny.stopL) {
const parse = profile.parseStopover(profile, stations, lines, remarks, j)
res.passed = pt.jny.stopL.map(parse)
}
if (Array.isArray(pt.jny.remL)) {
for (let remark of pt.jny.remL) applyRemark(j, remark)
}
if (pt.jny.freq && pt.jny.freq.jnyL) {
const parseAlternative = (a) => {
const t = a.stopL[0].dTimeS || a.stopL[0].dTimeR
const when = profile.parseDateTime(profile, j.date, t)
return {
line: lines[parseInt(a.prodX)] || null,
when: when.toISO()
}
}
res.alternatives = pt.jny.freq.jnyL
.filter(a => a.stopL[0].locX === pt.dep.locX)
.map(parseAlternative)
}
}
// todo: follow public-transport/friendly-public-transport-format#27 here
// see also derhuerst/vbb-rest#19
if (pt.arr.aCncl) {
res.cancelled = true
res.arrival = res.arrivalPlatform = null
}
if (pt.dep.dCncl) {
res.cancelled = true
res.departure = res.departurePlatform = null
res.delay = null
}
return res
}
return parseJourneyLeg
}
module.exports = createParseJourneyLeg

34
parse/journey.js Normal file
View file

@ -0,0 +1,34 @@
'use strict'
const createParseJourneyLeg = require('./journey-leg')
const clone = obj => Object.assign({}, obj)
const createParseJourney = (profile, stations, lines, remarks) => {
const parseLeg = createParseJourneyLeg(profile, stations, lines, remarks)
// todo: c.sDays
// todo: c.dep.dProgType, c.arr.dProgType
// todo: c.conSubscr
// todo: c.trfRes x vbb-parse-ticket
const parseJourney = (j) => {
const legs = j.secL.map(leg => parseLeg(j, leg))
const res = {
legs,
origin: legs[0].origin,
destination: legs[legs.length - 1].destination,
departure: legs[0].departure,
arrival: legs[legs.length - 1].arrival
}
if (legs.some(p => p.cancelled)) {
res.cancelled = true
res.departure = res.arrival = null
}
return res
}
return parseJourney
}
module.exports = createParseJourney

39
parse/line.js Normal file
View file

@ -0,0 +1,39 @@
'use strict'
const slugg = require('slugg')
// todo: are p.number and p.line ever different?
const createParseLine = (profile, operators) => {
const parseLine = (p) => {
if (!p) return null // todo: handle this upstream
const res = {
type: 'line',
id: null,
name: p.line || p.name,
public: true
}
// We don't get a proper line id from the API, so we use the trip nr here.
// todo: find a better way
if (p.prodCtx && p.prodCtx.num) res.id = p.prodCtx.num
// This is terrible, but FPTF demands an ID. Let's pray for VBB to expose an ID.
else if (p.line) res.id = slugg(p.line.trim())
else if (p.name) res.id = slugg(p.name.trim())
if (p.cls) res.class = p.cls
if (p.prodCtx && p.prodCtx.catCode !== undefined) {
res.productCode = +p.prodCtx.catCode
}
// todo: parse mode, remove from profiles
if ('number' === typeof p.oprX) {
res.operator = operators[p.oprX] || null
}
return res
}
return parseLine
}
module.exports = createParseLine

34
parse/location.js Normal file
View file

@ -0,0 +1,34 @@
'use strict'
const POI = 'P'
const STATION = 'S'
const ADDRESS = 'A'
// todo: what is s.rRefL?
// todo: is passing in profile necessary?
const parseLocation = (profile, l) => {
const res = {type: 'location'}
if (l.crd) {
res.latitude = l.crd.y / 1000000
res.longitude = l.crd.x / 1000000
}
if (l.type === STATION) {
const station = {
type: 'station',
id: l.extId,
name: l.name,
location: res
}
if ('pCls' in l) station.products = profile.parseProducts(l.pCls)
return station
}
if (l.type === ADDRESS) res.address = l.name
else res.name = l.name
if (l.type === POI) res.id = l.extId
return res
}
module.exports = parseLocation

67
parse/movement.js Normal file
View file

@ -0,0 +1,67 @@
'use strict'
const createParseMovement = (profile, locations, lines, remarks) => {
// todo: what is m.dirGeo? maybe the speed?
// todo: what is m.stopL?
// todo: what is m.proc? wut?
// todo: what is m.pos?
// todo: what is m.ani.dirGeo[n]? maybe the speed?
// todo: what is m.ani.proc[n]? wut?
// todo: how does m.ani.poly work?
const parseMovement = (m) => {
const parseNextStop = (s) => {
const dep = s.dTimeR || s.dTimeS
? profile.parseDateTime(profile, m.date, s.dTimeR || s.dTimeS)
: null
const arr = s.aTimeR || s.aTimeS
? profile.parseDateTime(profile, m.date, s.aTimeR || s.aTimeS)
: null
const res = {
station: locations[s.locX],
departure: dep ? dep.toISO() : null,
arrival: arr ? arr.toISO() : null
}
if (m.dTimeR && m.dTimeS) {
const plannedDep = profile.parseDateTime(profile, m.date, s.dTimeS)
res.departureDelay = Math.round((dep - plannedDep) / 1000)
} else res.departureDelay = null
if (m.aTimeR && m.aTimeS) {
const plannedArr = profile.parseDateTime(profile, m.date, s.aTimeS)
res.arrivalDelay = Math.round((arr - plannedArr) / 1000)
} else res.arrivalDelay = null
return res
}
const res = {
direction: profile.parseStationName(m.dirTxt),
trip: m.jid && +m.jid.split('|')[1] || null, // todo: this seems brittle
line: lines[m.prodX] || null,
location: m.pos ? {
type: 'location',
latitude: m.pos.y / 1000000,
longitude: m.pos.x / 1000000
} : null,
nextStops: m.stopL.map(parseNextStop),
frames: []
}
if (m.ani && Array.isArray(m.ani.mSec)) {
for (let i = 0; i < m.ani.mSec.length; i++) {
res.frames.push({
origin: locations[m.ani.fLocX[i]] || null,
destination: locations[m.ani.tLocX[i]] || null,
t: m.ani.mSec[i]
})
}
}
return res
}
return parseMovement
}
module.exports = createParseMovement

14
parse/nearby.js Normal file
View file

@ -0,0 +1,14 @@
'use strict'
// todo: remarks
// todo: lines
// todo: what is s.pCls?
// todo: what is s.wt?
// todo: what is s.dur?
const parseNearby = (profile, n) => {
const res = profile.parseLocation(profile, n)
res.distance = n.dist
return res
}
module.exports = parseNearby

14
parse/operator.js Normal file
View file

@ -0,0 +1,14 @@
'use strict'
const slugg = require('slugg')
// todo: is passing in profile necessary?
const parseOperator = (profile, a) => {
return {
type: 'operator',
id: slugg(a.name), // todo: find a more reliable way
name: a.name
}
}
module.exports = parseOperator

16
parse/products-bitmask.js Normal file
View file

@ -0,0 +1,16 @@
'use strict'
const createParseBitmask = (bitmasks) => {
const parseBitmask = (bitmask) => {
const products = {}
let i = 1
do {
products[bitmasks[i].product] = !!(bitmask & i)
i *= 2
} while (bitmasks[i] && bitmasks[i].product)
return products
}
return parseBitmask
}
module.exports = createParseBitmask

8
parse/remark.js Normal file
View file

@ -0,0 +1,8 @@
'use strict'
// todo: is passing in profile necessary?
const parseRemark = (profile, r) => {
return null // todo
}
module.exports = parseRemark

36
parse/stopover.js Normal file
View file

@ -0,0 +1,36 @@
'use strict'
// todo: arrivalDelay, departureDelay or only delay ?
// todo: arrivalPlatform, departurePlatform
const createParseStopover = (profile, stations, lines, remarks, connection) => {
const parseStopover = (st) => {
const res = {
station: stations[parseInt(st.locX)] || null
}
if (st.aTimeR || st.aTimeS) {
const arr = profile.parseDateTime(profile, connection.date, st.aTimeR || st.aTimeS)
res.arrival = arr.toISO()
}
if (st.dTimeR || st.dTimeS) {
const dep = profile.parseDateTime(profile, connection.date, st.dTimeR || st.dTimeS)
res.departure = dep.toISO()
}
// todo: follow public-transport/friendly-public-transport-format#27 here
// see also derhuerst/vbb-rest#19
if (st.aCncl) {
res.cancelled = true
res.arrival = null
}
if (st.dCncl) {
res.cancelled = true
res.departure = null
}
return res
}
return parseStopover
}
module.exports = createParseStopover

164
readme.md
View file

@ -1,15 +1,25 @@
# hafas-client
**A client for HAFAS mobile APIs**, providing the base for [vbb-hafas](https://github.com/derhuerst/vbb-hafas) and [db-hafas](https://github.com/derhuerst/db-hafas).
**A client for HAFAS public transport APIs**. Sort of like [public-transport-enabler](https://github.com/schildbach/public-transport-enabler), but with a smaller scope. It also [contains customisations](p) for the following transport networks:
HAFAS endpoint | wrapper library? | docs | example code | source code
---------------|------------------|------|---------|------------
[Deutsche Bahn](https://en.wikipedia.org/wiki/Deutsche_Bahn) | [`vbb-hafas`](https://github.com/derhuerst/vbb-hafas), which has additional features | [docs](p/db/readme.md) | [example code](p/db/example.js) | [src](p/db/index.js)
[Berlin & Brandenburg public transport](https://en.wikipedia.org/wiki/Verkehrsverbund_Berlin-Brandenburg) | [`db-hafas`](https://github.com/derhuerst/db-hafas), which has additional features | [docs](p/vbb/readme.md) | [example code](p/vbb/example.js) | [src](p/vbb/index.js)
[![npm version](https://img.shields.io/npm/v/hafas-client.svg)](https://www.npmjs.com/package/hafas-client)
[![build status](https://img.shields.io/travis/derhuerst/hafas-client.svg)](https://travis-ci.org/derhuerst/hafas-client)
[![dependency status](https://img.shields.io/david/derhuerst/hafas-client.svg)](https://david-dm.org/derhuerst/hafas-client)
[![dev dependency status](https://img.shields.io/david/dev/derhuerst/hafas-client.svg)](https://david-dm.org/derhuerst/hafas-client#info=devDependencies)
![ISC-licensed](https://img.shields.io/github/license/derhuerst/hafas-client.svg)
[![chat on gitter](https://badges.gitter.im/derhuerst.svg)](https://gitter.im/derhuerst)
## Background
There's [a company called HaCon](http://hacon.de) that sells [a public transport management system called HAFAS](https://de.wikipedia.org/wiki/HAFAS). It is [used by companies all over Europe](https://gist.github.com/derhuerst/2b7ed83bfa5f115125a5) to serve routing and departure information for apps. All those endpoints are similar, with the same terms and API routes, but have slightly different options, filters and sets of enabled features.
`hafas-client` contains all logic for communicating with these, as well as serialising from and parsing to [*Friendly Public Transport Format (FPTF)* `1.0.1`](https://github.com/public-transport/friendly-public-transport-format/blob/1.0.1/spec/readme.md). Endpoint-specific customisations (called *profiles* here) increase the quality of the returned data.
## Installing
```shell
@ -17,9 +27,155 @@ npm install hafas-client
```
## API
- [`journeys(from, to, [opt])`](docs/journeys.md) get journeys between locations
- [`journeyLeg(ref, name, [opt])`](docs/journey-leg.md) get details for a leg of a journey
- [`departures(station, [opt])`](docs/departures.md) query the next departures at a station
- [`locations(query, [opt])`](docs/locations.md) find stations, POIs and addresses
- [`nearby(location, [opt])`](docs/nearby.md) show stations & POIs around
- [`radar(query, [opt])`](docs/radar.md) find all vehicles currently in a certain area
## Usage
See [vbb-hafas](https://github.com/derhuerst/vbb-hafas/blob/master/lib/request.js).
```js
const createClient = require('hafas-client')
const dbProfile = require('hafas-client/p/db')
// create a client with Deutsche Bahn profile
const client = createClient(dbProfile)
// Berlin Jungfernheide to München Hbf
client.journeys('8011167', '8000261', {results: 1})
.then(console.log)
.catch(console.error)
```
The returned [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/promise) will resolve with an array of one [*FPTF* `journey`](https://github.com/public-transport/friendly-public-transport-format/blob/1.0.1/spec/readme.md#journey).
```js
[ {
legs: [ {
id: '1|100067|48|81|17122017',
origin: {
type: 'station',
id: '8089100',
name: 'Berlin Jungfernheide (S)',
location: {
type: 'location',
latitude: 52.530291,
longitude: 13.299451
},
products: { /* … */ }
},
departure: '2017-12-17T17:05:00.000+01:00',
departurePlatform: '5',
destination: {
type: 'station',
id: '8089118',
name: 'Berlin Beusselstraße',
location: { /* … */ },
products: { /* … */ }
},
arrival: '2017-12-17T17:08:00.000+01:00',
arrivalPlatform: '1',
line: {
type: 'line',
id: '41172',
name: 'S 41',
public: true,
mode: 'train',
product: 'suburban',
class: 16,
productCode: 4,
operator: {
type: 'operator',
id: 's-bahn-berlin-gmbh',
name: 'S-Bahn Berlin GmbH'
}
},
direction: 'Ringbahn ->'
}, /* … */ {
origin: {
type: 'station',
id: '730749',
name: 'Berlin Hauptbahnhof (S+U), Berlin',
location: {
type: 'location',
latitude: 52.526461,
longitude: 13.369378
},
products: { /* … */ }
},
departure: '2017-12-17T17:25:00.000+01:00',
destination: {
type: 'station',
id: '8098160',
name: 'Berlin Hbf (tief)',
location: { /* … */ },
products: { /* … */ }
},
arrival: '2017-12-17T17:33:00.000+01:00',
mode: 'walking',
public: true
}, {
id: '1|70906|0|81|17122017',
origin: {
type: 'station',
id: '8098160',
name: 'Berlin Hbf (tief)',
location: { /* … */ },
products: { /* … */ }
},
departure: '2017-12-17T17:37:00.000+01:00',
departurePlatform: '1',
destination: {
type: 'station',
id: '8000261',
name: 'München Hbf',
location: { /* … */ },
products: { /* … */ }
},
arrival: '2017-12-17T22:45:00.000+01:00',
arrivalPlatform: '13',
line: { /* … */ },
direction: 'München Hbf'
} ],
origin: {
type: 'station',
id: '8089100',
name: 'Berlin Jungfernheide (S)',
location: { /* … */ },
products: { /* … */ }
},
departure: '2017-12-17T17:05:00.000+01:00',
destination: {
type: 'station',
id: '8000261',
name: 'München Hbf',
location: { /* … */ },
products: { /* … */ }
},
arrival: '2017-12-17T22:45:00.000+01:00',
price: {
amount: null,
hint: 'No pricing information available.'
}
} ]
```
## Related
- [*Friendly Public Transport Format*](https://github.com/public-transport/friendly-public-transport-format#friendly-public-transport-format-fptf) A format for APIs, libraries and datasets containing and working with public transport data.
- [`db-hafas`](https://github.com/derhuerst/db-hafas#db-hafas) JavaScript client for the DB HAFAS API.
- [`vbb-hafas`](https://github.com/derhuerst/vbb-hafas#vbb-hafas) JavaScript client for Berlin & Brandenburg public transport HAFAS API.
- [`hafas-departures-in-direction`](https://github.com/derhuerst/hafas-departures-in-direction#hafas-departures-in-direction)  Pass in a HAFAS client, get departures in a certain direction.
- [`hafas-collect-departures-at`](https://github.com/derhuerst/hafas-collect-departures-at#hafas-collect-departures-at)  Utility to collect departures, using any HAFAS client.
- [`hafas-rest-api`](https://github.com/derhuerst/hafas-rest-api#hafas-rest-api) Expose a HAFAS client via an HTTP REST API.
- [List of european long-distance transport operators, available API endpoints, GTFS feeds and client modules.](https://github.com/public-transport/european-transport-operators)
- [Collection of european transport JavaScript modules.](https://github.com/public-transport/european-transport-modules)
## Contributing

View file

@ -1,46 +0,0 @@
'use strict'
const moment = require('moment-timezone')
const date = (tz, when) => moment(when).tz(tz).format('YYYYMMDD')
const time = (tz, when) => moment(when).tz(tz).format('HHmmss')
// filters
const bike = {type: 'BC', mode: 'INC'}
const accessibility = {
none: {type: 'META', mode: 'INC', meta: 'notBarrierfree'}
, partial: {type: 'META', mode: 'INC', meta: 'limitedBarrierfree'}
, complete: {type: 'META', mode: 'INC', meta: 'completeBarrierfree'}
}
const coord = (x) => Math.round(x * 1000000)
const station = (id) => ({type: 'S', lid: 'L=' + id})
const address = (latitude, longitude, name) => {
if (!latitude || !longitude || !name) throw new Error('invalid address.')
return {type: 'A', name, crd: {x: coord(longitude), y: coord(latitude)}}
}
const poi = (latitude, longitude, id, name) => {
if (!latitude || !longitude || !id || !name) throw new Error('invalid poi.')
return {type: 'P', name, lid: 'L=' + id, crd: {x: coord(longitude), y: coord(latitude)}}
}
const locationFilter = (stations, addresses, poi) => {
if (stations && addresses && poi) return 'ALL'
return (stations ? 'S' : '')
+ (addresses ? 'A' : '')
+ (poi ? 'P' : '')
}
module.exports = {
date, time,
bike, accessibility,
coord, station, address, poi, locationFilter
}

285
test/db.js Normal file
View file

@ -0,0 +1,285 @@
'use strict'
const getStations = require('db-stations').full
const tapePromise = require('tape-promise').default
const tape = require('tape')
const co = require('co')
const isRoughlyEqual = require('is-roughly-equal')
const createClient = require('..')
const dbProfile = require('../p/db')
const modes = require('../p/db/modes')
const {
assertValidStation,
assertValidPoi,
assertValidAddress,
assertValidLocation,
assertValidLine,
assertValidStopover,
when, isValidWhen
} = require('./util.js')
const assertValidStationProducts = (t, p) => {
t.ok(p)
t.equal(typeof p.nationalExp, 'boolean')
t.equal(typeof p.national, 'boolean')
t.equal(typeof p.regionalExp, 'boolean')
t.equal(typeof p.regional, 'boolean')
t.equal(typeof p.suburban, 'boolean')
t.equal(typeof p.bus, 'boolean')
t.equal(typeof p.ferry, 'boolean')
t.equal(typeof p.subway, 'boolean')
t.equal(typeof p.tram, 'boolean')
t.equal(typeof p.taxi, 'boolean')
}
const findStation = (id) => new Promise((yay, nay) => {
const stations = getStations()
stations
.once('error', nay)
.on('data', (s) => {
if (
s.id === id ||
(s.additionalIds && s.additionalIds.includes(id))
) {
yay(s)
stations.destroy()
}
})
.once('end', yay)
})
const isJungfernheide = (s) => {
return s.type === 'station' &&
(s.id === '008011167' || s.id === '8011167') &&
s.name === 'Berlin Jungfernheide' &&
s.location &&
isRoughlyEqual(s.location.latitude, 52.530408, .0005) &&
isRoughlyEqual(s.location.longitude, 13.299424, .0005)
}
const assertIsJungfernheide = (t, s) => {
t.equal(s.type, 'station')
t.ok(s.id === '008011167' || s.id === '8011167', 'id should be 8011167')
t.equal(s.name, 'Berlin Jungfernheide')
t.ok(s.location)
t.ok(isRoughlyEqual(s.location.latitude, 52.530408, .0005))
t.ok(isRoughlyEqual(s.location.longitude, 13.299424, .0005))
}
// todo: this doesnt seem to work
// todo: DRY with assertValidStationProducts
const assertValidProducts = (t, p) => {
for (let k of Object.keys(modes)) {
t.ok('boolean', typeof modes[k], 'mode ' + k + ' must be a boolean')
}
}
const assertValidPrice = (t, p) => {
t.ok(p)
if (p.amount !== null) {
t.equal(typeof p.amount, 'number')
t.ok(p.amount > 0)
}
if (p.hint !== null) {
t.equal(typeof p.hint, 'string')
t.ok(p.hint)
}
}
const test = tapePromise(tape)
const client = createClient(dbProfile)
test('Berlin Jungfernheide to München Hbf', co.wrap(function* (t) {
const journeys = yield client.journeys('8011167', '8000261', {
when, passedStations: true
})
t.ok(Array.isArray(journeys))
t.ok(journeys.length > 0, 'no journeys')
for (let journey of journeys) {
assertValidStation(t, journey.origin)
assertValidStationProducts(t, journey.origin.products)
if (!(yield findStation(journey.origin.id))) {
console.error('unknown station', journey.origin.id, journey.origin.name)
}
if (journey.origin.products) {
assertValidProducts(t, journey.origin.products)
}
t.ok(isValidWhen(journey.departure))
assertValidStation(t, journey.destination)
assertValidStationProducts(t, journey.origin.products)
if (!(yield findStation(journey.origin.id))) {
console.error('unknown station', journey.destination.id, journey.destination.name)
}
if (journey.destination.products) {
assertValidProducts(t, journey.destination.products)
}
t.ok(isValidWhen(journey.arrival))
t.ok(Array.isArray(journey.legs))
t.ok(journey.legs.length > 0, 'no legs')
const leg = journey.legs[0]
assertValidStation(t, leg.origin)
assertValidStationProducts(t, leg.origin.products)
if (!(yield findStation(leg.origin.id))) {
console.error('unknown station', leg.origin.id, leg.origin.name)
}
t.ok(isValidWhen(leg.departure))
t.equal(typeof leg.departurePlatform, 'string')
assertValidStation(t, leg.destination)
assertValidStationProducts(t, leg.origin.products)
if (!(yield findStation(leg.destination.id))) {
console.error('unknown station', leg.destination.id, leg.destination.name)
}
t.ok(isValidWhen(leg.arrival))
t.equal(typeof leg.arrivalPlatform, 'string')
assertValidLine(t, leg.line)
t.ok(Array.isArray(leg.passed))
for (let stopover of leg.passed) assertValidStopover(t, stopover)
if (journey.price) assertValidPrice(t, journey.price)
}
t.end()
}))
test('Berlin Jungfernheide to Torfstraße 17', co.wrap(function* (t) {
const journeys = yield client.journeys('8011167', {
type: 'location', address: 'Torfstraße 17',
latitude: 52.5416823, longitude: 13.3491223
}, {when})
t.ok(Array.isArray(journeys))
t.ok(journeys.length >= 1, 'no journeys')
const journey = journeys[0]
const leg = journey.legs[journey.legs.length - 1]
assertValidStation(t, leg.origin)
assertValidStationProducts(t, leg.origin.products)
if (!(yield findStation(leg.origin.id))) {
console.error('unknown station', leg.origin.id, leg.origin.name)
}
if (leg.origin.products) assertValidProducts(t, leg.origin.products)
t.ok(isValidWhen(leg.departure))
t.ok(isValidWhen(leg.arrival))
const d = leg.destination
assertValidAddress(t, d)
t.equal(d.address, 'Torfstraße 17')
t.ok(isRoughlyEqual(.0001, d.latitude, 52.5416823))
t.ok(isRoughlyEqual(.0001, d.longitude, 13.3491223))
t.end()
}))
test('Berlin Jungfernheide to ATZE Musiktheater', co.wrap(function* (t) {
const journeys = yield client.journeys('8011167', {
type: 'location', id: '991598902', name: 'ATZE Musiktheater',
latitude: 52.542417, longitude: 13.350437
}, {when})
t.ok(Array.isArray(journeys))
t.ok(journeys.length >= 1, 'no journeys')
const journey = journeys[0]
const leg = journey.legs[journey.legs.length - 1]
assertValidStation(t, leg.origin)
assertValidStationProducts(t, leg.origin.products)
if (!(yield findStation(leg.origin.id))) {
console.error('unknown station', leg.origin.id, leg.origin.name)
}
if (leg.origin.products) assertValidProducts(t, leg.origin.products)
t.ok(isValidWhen(leg.departure))
t.ok(isValidWhen(leg.arrival))
const d = leg.destination
assertValidPoi(t, d)
t.equal(d.name, 'ATZE Musiktheater')
t.ok(isRoughlyEqual(.0001, d.latitude, 52.542399))
t.ok(isRoughlyEqual(.0001, d.longitude, 13.350402))
t.end()
}))
test('departures at Berlin Jungfernheide', co.wrap(function* (t) {
const deps = yield client.departures('8011167', {
duration: 5, when
})
t.ok(Array.isArray(deps))
for (let dep of deps) {
assertValidStation(t, dep.station)
assertValidStationProducts(t, dep.station.products)
if (!(yield findStation(dep.station.id))) {
console.error('unknown station', dep.station.id, dep.station.name)
}
if (dep.station.products) assertValidProducts(t, dep.station.products)
t.ok(isValidWhen(dep.when))
}
t.end()
}))
test('departures with station object', co.wrap(function* (t) {
yield client.departures({
type: 'station',
id: '8011167',
name: 'Berlin Jungfernheide',
location: {
type: 'location',
latitude: 1.23,
longitude: 2.34
}
}, {when})
t.ok('did not fail')
t.end()
}))
test('nearby Berlin Jungfernheide', co.wrap(function* (t) {
const nearby = yield client.nearby({
type: 'location',
latitude: 52.530273,
longitude: 13.299433
}, {
results: 2, distance: 400
})
t.ok(Array.isArray(nearby))
t.equal(nearby.length, 2)
assertIsJungfernheide(t, nearby[0])
t.ok(nearby[0].distance >= 0)
t.ok(nearby[0].distance <= 100)
for (let n of nearby) {
if (n.type === 'station') assertValidStation(t, n)
else assertValidLocation(t, n)
}
t.end()
}))
test('locations named Jungfernheide', co.wrap(function* (t) {
const locations = yield client.locations('Jungfernheide', {
results: 10
})
t.ok(Array.isArray(locations))
t.ok(locations.length > 0)
t.ok(locations.length <= 10)
for (let l of locations) {
if (l.type === 'station') assertValidStation(t, l)
else assertValidLocation(t, l)
}
t.ok(locations.some(isJungfernheide))
t.end()
}))

4
test/index.js Normal file
View file

@ -0,0 +1,4 @@
'use strict'
require('./db')
require('./vbb')

156
test/util.js Normal file
View file

@ -0,0 +1,156 @@
'use strict'
const validateFptf = require('validate-fptf')
const isRoughlyEqual = require('is-roughly-equal')
const {DateTime} = require('luxon')
const isValidWGS84 = require('is-coordinates')
const validateFptfWith = (t, item, allowedTypes, name) => {
try {
validateFptf.recurse(allowedTypes, item, name)
} catch (err) {
t.ifError(err)
}
}
const assertValidStation = (t, s, coordsOptional = false) => {
validateFptfWith(t, s, ['station'], 'station')
if (!coordsOptional || (s.location !== null && s.location !== undefined)) {
t.ok(s.location)
assertValidLocation(t, s.location, coordsOptional)
}
}
const assertValidPoi = (t, p) => {
assertValidLocation(t, p, true)
t.equal(typeof p.id, 'string')
t.equal(typeof p.name, 'string')
if (p.address !== null && p.address !== undefined) {
t.equal(typeof p.address, 'string')
t.ok(p.address)
}
}
const assertValidAddress = (t, a) => {
assertValidLocation(t, a, true)
t.equal(typeof a.address, 'string')
}
const assertValidLocation = (t, l, coordsOptional = false) => {
t.equal(l.type, 'location')
if (l.name !== null && l.name !== undefined) {
t.equal(typeof l.name, 'string')
t.ok(l.name)
}
if (l.address !== null && l.address !== undefined) {
t.equal(typeof l.address, 'string')
t.ok(l.address)
}
const hasLatitude = l.latitude !== null && l.latitude !== undefined
const hasLongitude = l.longitude !== null && l.longitude !== undefined
if (!coordsOptional && hasLatitude) t.equal(typeof l.latitude, 'number')
if (!coordsOptional && hasLongitude) t.equal(typeof l.longitude, 'number')
if ((hasLongitude && !hasLatitude) || (hasLatitude && !hasLongitude)) {
t.fail('should have both .latitude and .longitude')
}
if (hasLatitude && hasLongitude) isValidWGS84([l.longitude, l.latitude])
if (!coordsOptional && l.altitude !== null && l.altitude !== undefined) {
t.equal(typeof l.altitude, 'number')
}
}
const validLineModes = [
'train', 'bus', 'watercraft', 'taxi', 'gondola', 'aircraft',
'car', 'bicycle', 'walking'
]
const assertValidLine = (t, l) => {
validateFptfWith(t, l, ['line'], 'line')
}
const isValidDateTime = (w) => {
return !Number.isNaN(+new Date(w))
}
const assertValidStopover = (t, s, coordsOptional = false) => {
if ('arrival' in s) t.ok(isValidDateTime(s.arrival))
if ('departure' in s) t.ok(isValidDateTime(s.departure))
if (s.arrivalDelay !== null && s.arrivalDelay !== undefined) {
t.equal(typeof s.arrivalDelay, 'number')
}
if (s.departureDelay !== null && s.departureDelay !== undefined) {
t.equal(typeof s.departureDelay, 'number')
}
if (!('arrival' in s) && !('departure' in s)) {
t.fail('stopover doesn\'t contain arrival or departure')
}
t.ok(s.station)
assertValidStation(t, s.station, coordsOptional)
}
const hour = 60 * 60 * 1000
const week = 7 * 24 * hour
// next Monday 10 am
const when = DateTime.fromMillis(Date.now(), {
zone: 'Europe/Berlin',
locale: 'de-DE'
}).startOf('week').plus({weeks: 1, hours: 10}).toJSDate()
const isValidWhen = (w) => {
const ts = +new Date(w)
if (Number.isNaN(ts)) return false
return isRoughlyEqual(12 * hour, +when, ts)
}
const assertValidWhen = (t, w) => {
t.ok(isValidWhen(w), 'invalid when')
}
const assertValidTicket = (t, ti) => {
t.strictEqual(typeof ti.name, 'string')
t.ok(ti.name.length > 0)
if (ti.price !== null) {
t.strictEqual(typeof ti.price, 'number')
t.ok(ti.price > 0)
}
if (ti.amount !== null) {
t.strictEqual(typeof ti.amount, 'number')
t.ok(ti.amount > 0)
}
if ('bike' in ti) t.strictEqual(typeof ti.bike, 'boolean')
if ('shortTrip' in ti) t.strictEqual(typeof ti.shortTrip, 'boolean')
if ('group' in ti) t.strictEqual(typeof ti.group, 'boolean')
if ('fullDay' in ti) t.strictEqual(typeof ti.fullDay, 'boolean')
if (ti.tariff !== null) {
t.strictEqual(typeof ti.tariff, 'string')
t.ok(ti.tariff.length > 0)
}
if (ti.coverage !== null) {
t.strictEqual(typeof ti.coverage, 'string')
t.ok(ti.coverage.length > 0)
}
if (ti.variant !== null) {
t.strictEqual(typeof ti.variant, 'string')
t.ok(ti.variant.length > 0)
}
}
module.exports = {
assertValidStation,
assertValidPoi,
assertValidAddress,
assertValidLocation,
assertValidLine,
isValidDateTime,
assertValidStopover,
hour, when, isValidWhen, assertValidWhen,
assertValidTicket
}

387
test/vbb.js Normal file
View file

@ -0,0 +1,387 @@
'use strict'
const a = require('assert')
const isRoughlyEqual = require('is-roughly-equal')
const stations = require('vbb-stations-autocomplete')
const tapePromise = require('tape-promise').default
const tape = require('tape')
const co = require('co')
const shorten = require('vbb-short-station-name')
const createClient = require('..')
const vbbProfile = require('../p/vbb')
const {
assertValidStation: _assertValidStation,
assertValidPoi,
assertValidAddress,
assertValidLocation,
assertValidLine: _assertValidLine,
assertValidStopover,
hour, when,
assertValidWhen,
assertValidTicket
} = require('./util')
const assertValidStation = (t, s, coordsOptional = false) => {
_assertValidStation(t, s, coordsOptional)
t.equal(s.name, shorten(s.name))
}
const assertValidStationProducts = (t, p) => {
t.ok(p)
t.equal(typeof p.suburban, 'boolean')
t.equal(typeof p.subway, 'boolean')
t.equal(typeof p.tram, 'boolean')
t.equal(typeof p.bus, 'boolean')
t.equal(typeof p.ferry, 'boolean')
t.equal(typeof p.express, 'boolean')
t.equal(typeof p.regional, 'boolean')
}
const assertValidLine = (t, l) => {
_assertValidLine(t, l)
if (l.symbol !== null) t.equal(typeof l.symbol, 'string')
if (l.nr !== null) t.equal(typeof l.nr, 'number')
if (l.metro !== null) t.equal(typeof l.metro, 'boolean')
if (l.express !== null) t.equal(typeof l.express, 'boolean')
if (l.night !== null) t.equal(typeof l.night, 'boolean')
}
// todo
const findStation = (query) => stations(query, true, false)
const test = tapePromise(tape)
const client = createClient(vbbProfile)
const amrumerStr = '900000009101'
const spichernstr = '900000042101'
const bismarckstr = '900000024201'
test('journeys  station to station', co.wrap(function* (t) {
const journeys = yield client.journeys(spichernstr, amrumerStr, {
results: 3, when, passedStations: true
})
t.ok(Array.isArray(journeys))
t.strictEqual(journeys.length, 3)
for (let journey of journeys) {
assertValidStation(t, journey.origin)
assertValidStationProducts(t, journey.origin.products)
t.ok(journey.origin.name.indexOf('(Berlin)') === -1)
t.strictEqual(journey.origin.id, spichernstr)
assertValidWhen(t, journey.departure)
assertValidStation(t, journey.destination)
assertValidStationProducts(t, journey.destination.products)
t.strictEqual(journey.destination.id, amrumerStr)
assertValidWhen(t, journey.arrival)
t.ok(Array.isArray(journey.legs))
t.strictEqual(journey.legs.length, 1)
const leg = journey.legs[0]
t.equal(typeof leg.id, 'string')
t.ok(leg.id)
assertValidStation(t, leg.origin)
assertValidStationProducts(t, leg.origin.products)
t.ok(leg.origin.name.indexOf('(Berlin)') === -1)
t.strictEqual(leg.origin.id, spichernstr)
assertValidWhen(t, leg.departure)
assertValidStation(t, leg.destination)
assertValidStationProducts(t, leg.destination.products)
t.strictEqual(leg.destination.id, amrumerStr)
assertValidWhen(t, leg.arrival)
assertValidLine(t, leg.line)
t.ok(findStation(leg.direction))
t.ok(leg.direction.indexOf('(Berlin)') === -1)
t.ok(Array.isArray(leg.passed))
for (let passed of leg.passed) assertValidStopover(t, passed)
// todo: find a journey where there ticket info is always available
if (journey.tickets) {
t.ok(Array.isArray(journey.tickets))
for (let ticket of journey.tickets) assertValidTicket(t, ticket)
}
}
t.end()
}))
test('journeys  only subway', co.wrap(function* (t) {
const journeys = yield client.journeys(spichernstr, bismarckstr, {
results: 20, when,
products: {
suburban: false,
subway: true,
tram: false,
bus: false,
ferry: false,
express: false,
regional: false
}
})
t.ok(Array.isArray(journeys))
t.ok(journeys.length > 1)
for (let journey of journeys) {
for (let leg of journey.legs) {
if (leg.line) {
assertValidLine(t, leg.line)
t.equal(leg.line.mode, 'train')
t.equal(leg.line.product, 'subway')
}
}
}
t.end()
}))
test('journeys  fails with no product', co.wrap(function* (t) {
try {
yield client.journeys(spichernstr, bismarckstr, {
when,
products: {
suburban: false,
subway: false,
tram: false,
bus: false,
ferry: false,
express: false,
regional: false
}
})
} catch (err) {
t.ok(err, 'error thrown')
t.end()
}
}))
test('journey leg details', co.wrap(function* (t) {
const journeys = yield client.journeys(spichernstr, amrumerStr, {
results: 1, when
})
const p = journeys[0].legs[0]
t.ok(p.id, 'precondition failed')
t.ok(p.line.name, 'precondition failed')
const leg = yield client.journeyLeg(p.id, p.line.name, {when})
t.equal(typeof leg.id, 'string')
t.ok(leg.id)
assertValidLine(t, leg.line)
t.equal(typeof leg.direction, 'string')
t.ok(leg.direction)
t.ok(Array.isArray(leg.passed))
for (let passed of leg.passed) assertValidStopover(t, passed)
t.end()
}))
test('journeys  station to address', co.wrap(function* (t) {
const journeys = yield client.journeys(spichernstr, {
type: 'location', address: 'Torfstraße 17',
latitude: 52.5416823, longitude: 13.3491223
}, {results: 1, when})
t.ok(Array.isArray(journeys))
t.strictEqual(journeys.length, 1)
const journey = journeys[0]
const leg = journey.legs[journey.legs.length - 1]
assertValidStation(t, leg.origin)
assertValidStationProducts(t, leg.origin.products)
assertValidWhen(t, leg.departure)
const dest = leg.destination
assertValidAddress(t, dest)
t.strictEqual(dest.address, 'Torfstraße 17')
t.ok(isRoughlyEqual(.0001, dest.latitude, 52.5416823))
t.ok(isRoughlyEqual(.0001, dest.longitude, 13.3491223))
assertValidWhen(t, leg.arrival)
t.end()
}))
test('journeys  station to POI', co.wrap(function* (t) {
const journeys = yield client.journeys(spichernstr, {
type: 'location', id: '9980720', name: 'ATZE Musiktheater',
latitude: 52.543333, longitude: 13.351686
}, {results: 1, when})
t.ok(Array.isArray(journeys))
t.strictEqual(journeys.length, 1)
const journey = journeys[0]
const leg = journey.legs[journey.legs.length - 1]
assertValidStation(t, leg.origin)
assertValidStationProducts(t, leg.origin.products)
assertValidWhen(t, leg.departure)
const dest = leg.destination
assertValidPoi(t, dest)
t.strictEqual(dest.name, 'ATZE Musiktheater')
t.ok(isRoughlyEqual(.0001, dest.latitude, 52.543333))
t.ok(isRoughlyEqual(.0001, dest.longitude, 13.351686))
assertValidWhen(t, leg.arrival)
t.end()
}))
test('departures', co.wrap(function* (t) {
const deps = yield client.departures(spichernstr, {duration: 5, when})
t.ok(Array.isArray(deps))
t.deepEqual(deps, deps.sort((a, b) => t.when > b.when))
for (let dep of deps) {
t.equal(typeof dep.journeyId, 'string')
t.ok(dep.journeyId)
t.equal(dep.station.name, 'U Spichernstr.')
assertValidStation(t, dep.station)
assertValidStationProducts(t, dep.station.products)
t.strictEqual(dep.station.id, spichernstr)
assertValidWhen(t, dep.when)
t.ok(findStation(dep.direction))
assertValidLine(t, dep.line)
}
t.end()
}))
test('departures with station object', co.wrap(function* (t) {
yield client.departures({
type: 'station',
id: spichernstr,
name: 'U Spichernstr',
location: {
type: 'location',
latitude: 1.23,
longitude: 2.34
}
}, {when})
t.ok('did not fail')
t.end()
}))
test('departures at 7-digit station', co.wrap(function* (t) {
const eisenach = '8010097' // see derhuerst/vbb-hafas#22
yield client.departures(eisenach, {when})
t.pass('did not fail')
t.end()
}))
test('nearby', co.wrap(function* (t) {
// Berliner Str./Bundesallee
const nearby = yield client.nearby({
type: 'location',
latitude: 52.4873452,
longitude: 13.3310411
}, {distance: 200})
t.ok(Array.isArray(nearby))
for (let n of nearby) {
if (n.type === 'station') assertValidStation(t, n)
else assertValidLocation(t, n, false)
}
t.equal(nearby[0].id, '900000044201')
t.equal(nearby[0].name, 'U Berliner Str.')
t.ok(nearby[0].distance > 0)
t.ok(nearby[0].distance < 100)
t.equal(nearby[1].id, '900000043252')
t.equal(nearby[1].name, 'Landhausstr.')
t.ok(nearby[1].distance > 100)
t.ok(nearby[1].distance < 200)
t.end()
}))
test('locations', co.wrap(function* (t) {
const locations = yield client.locations('Alexanderplatz', {results: 10})
t.ok(Array.isArray(locations))
t.ok(locations.length > 0)
t.ok(locations.length <= 10)
for (let l of locations) {
if (l.type === 'station') assertValidStation(t, l)
else assertValidLocation(t, l)
}
t.ok(locations.find(s => s.type === 'station'))
t.ok(locations.find(s => s.id && s.name)) // POIs
t.ok(locations.find(s => !s.name && s.address)) // addresses
t.end()
}))
test('radar', co.wrap(function* (t) {
const vehicles = yield client.radar(52.52411, 13.41002, 52.51942, 13.41709, {
duration: 5 * 60, when
})
t.ok(Array.isArray(vehicles))
t.ok(vehicles.length > 0)
for (let v of vehicles) {
t.ok(findStation(v.direction))
assertValidLine(t, v.line)
t.equal(typeof v.location.latitude, 'number')
t.ok(v.location.latitude <= 55, 'vehicle is too far away')
t.ok(v.location.latitude >= 45, 'vehicle is too far away')
t.equal(typeof v.location.longitude, 'number')
t.ok(v.location.longitude >= 9, 'vehicle is too far away')
t.ok(v.location.longitude <= 15, 'vehicle is too far away')
t.ok(Array.isArray(v.nextStops))
for (let st of v.nextStops) {
assertValidStopover(t, st, true)
t.strictEqual(st.station.name.indexOf('(Berlin)'), -1)
if (st.arrival) {
t.equal(typeof st.arrival, 'string')
const arr = +new Date(st.arrival)
// note that this can be an ICE train
t.ok(isRoughlyEqual(14 * hour, +when, arr))
}
if (st.departure) {
t.equal(typeof st.departure, 'string')
const dep = +new Date(st.departure)
// note that this can be an ICE train
t.ok(isRoughlyEqual(14 * hour, +when, dep))
}
}
t.ok(Array.isArray(v.frames))
for (let f of v.frames) {
assertValidStation(t, f.origin, true)
assertValidStationProducts(t, f.origin.products)
t.strictEqual(f.origin.name.indexOf('(Berlin)'), -1)
assertValidStation(t, f.destination, true)
assertValidStationProducts(t, f.destination.products)
t.strictEqual(f.destination.name.indexOf('(Berlin)'), -1)
t.equal(typeof f.t, 'number')
}
}
t.end()
}))