diff --git a/.travis.yml b/.travis.yml index d707c8bf..35211681 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,5 +2,5 @@ sudo: false language: node_js node_js: - 'stable' - - '7' + - '8' - '6' diff --git a/docs/departures.md b/docs/departures.md new file mode 100644 index 00000000..6438b986 --- /dev/null +++ b/docs/departures.md @@ -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' +} ] +``` diff --git a/docs/journey-leg.md b/docs/journey-leg.md new file mode 100644 index 00000000..95de8c14 --- /dev/null +++ b/docs/journey-leg.md @@ -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: [ /* … */ ] +} +``` diff --git a/docs/journeys.md b/docs/journeys.md new file mode 100644 index 00000000..86446da8 --- /dev/null +++ b/docs/journeys.md @@ -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`. diff --git a/docs/locations.md b/docs/locations.md new file mode 100644 index 00000000..8b9d17ab --- /dev/null +++ b/docs/locations.md @@ -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 +} ] +``` diff --git a/docs/nearby.md b/docs/nearby.md new file mode 100644 index 00000000..397162f0 --- /dev/null +++ b/docs/nearby.md @@ -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 +} ] +``` diff --git a/docs/radar.md b/docs/radar.md new file mode 100644 index 00000000..281b2f77 --- /dev/null +++ b/docs/radar.md @@ -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 + } ] +}, /* … */ ] +``` diff --git a/format/address.js b/format/address.js new file mode 100644 index 00000000..2774c380 --- /dev/null +++ b/format/address.js @@ -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 diff --git a/format/coord.js b/format/coord.js new file mode 100644 index 00000000..7d1ff695 --- /dev/null +++ b/format/coord.js @@ -0,0 +1,5 @@ +'use strict' + +const formatCoord = x => Math.round(x * 1000000) + +module.exports = formatCoord diff --git a/format/date.js b/format/date.js new file mode 100644 index 00000000..45091a34 --- /dev/null +++ b/format/date.js @@ -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 diff --git a/format/filters.js b/format/filters.js new file mode 100644 index 00000000..45a17f7c --- /dev/null +++ b/format/filters.js @@ -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} diff --git a/format/index.js b/format/index.js new file mode 100644 index 00000000..a119dd37 --- /dev/null +++ b/format/index.js @@ -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') +} diff --git a/format/location-filter.js b/format/location-filter.js new file mode 100644 index 00000000..79a79706 --- /dev/null +++ b/format/location-filter.js @@ -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 diff --git a/format/location.js b/format/location.js new file mode 100644 index 00000000..121cd13f --- /dev/null +++ b/format/location.js @@ -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 diff --git a/format/poi.js b/format/poi.js new file mode 100644 index 00000000..3991e4c6 --- /dev/null +++ b/format/poi.js @@ -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 diff --git a/format/products-bitmask.js b/format/products-bitmask.js new file mode 100644 index 00000000..9ec158e0 --- /dev/null +++ b/format/products-bitmask.js @@ -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 diff --git a/format/rectangle.js b/format/rectangle.js new file mode 100644 index 00000000..51f83e9e --- /dev/null +++ b/format/rectangle.js @@ -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 diff --git a/format/station.js b/format/station.js new file mode 100644 index 00000000..418e5181 --- /dev/null +++ b/format/station.js @@ -0,0 +1,5 @@ +'use strict' + +const formatStation = id => ({type: 'S', lid: 'L=' + id}) + +module.exports = formatStation diff --git a/format/time.js b/format/time.js new file mode 100644 index 00000000..d16b2e8c --- /dev/null +++ b/format/time.js @@ -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 diff --git a/index.js b/index.js index 51b58929..5c5f3a6a 100644 --- a/index.js +++ b/index.js @@ -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 hafasError = (err) => { - err.isHafasError = true - return err -} - -const createRequest = (opt) => { - opt = Object.assign({}, defaults, opt) - - 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' - }, - query: null - }) - 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) + 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 } - 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 (!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)) }) } - return request + const journeys = (from, to, opt = {}) => { + from = profile.formatLocation(profile, from) + to = profile.formatLocation(profile, to) + + 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 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 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 + }, + maxLoc: opt.results, + field: 'S' // todo: what is this? + }} + }) + .then((d) => { + if (!d.match || !Array.isArray(d.match.locL)) return [] + const parse = profile.parseLocation + return d.match.locL.map(loc => parse(profile, loc)) + }) + } + + 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 diff --git a/lib/default-profile.js b/lib/default-profile.js new file mode 100644 index 00000000..ec2a09fe --- /dev/null +++ b/lib/default-profile.js @@ -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 diff --git a/lib/request.js b/lib/request.js new file mode 100644 index 00000000..0eccbb52 --- /dev/null +++ b/lib/request.js @@ -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 diff --git a/lib/validate-profile.js b/lib/validate-profile.js new file mode 100644 index 00000000..cfa8a840 --- /dev/null +++ b/lib/validate-profile.js @@ -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 diff --git a/p/db/example.js b/p/db/example.js new file mode 100644 index 00000000..72742f8c --- /dev/null +++ b/p/db/example.js @@ -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) diff --git a/p/db/index.js b/p/db/index.js new file mode 100644 index 00000000..4b6dd41c --- /dev/null +++ b/p/db/index.js @@ -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 diff --git a/p/db/loyalty-cards.js b/p/db/loyalty-cards.js new file mode 100644 index 00000000..dfb583e9 --- /dev/null +++ b/p/db/loyalty-cards.js @@ -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 +} diff --git a/p/db/modes.js b/p/db/modes.js new file mode 100644 index 00000000..6996b768 --- /dev/null +++ b/p/db/modes.js @@ -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 diff --git a/p/db/readme.md b/p/db/readme.md new file mode 100644 index 00000000..0d566c74 --- /dev/null +++ b/p/db/readme.md @@ -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` diff --git a/p/readme.md b/p/readme.md new file mode 100644 index 00000000..dea7cc16 --- /dev/null +++ b/p/readme.md @@ -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… +``` diff --git a/p/vbb/example.js b/p/vbb/example.js new file mode 100644 index 00000000..089e5af2 --- /dev/null +++ b/p/vbb/example.js @@ -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) diff --git a/p/vbb/index.js b/p/vbb/index.js new file mode 100644 index 00000000..2c9325c6 --- /dev/null +++ b/p/vbb/index.js @@ -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 diff --git a/p/vbb/modes.js b/p/vbb/modes.js new file mode 100644 index 00000000..3e771793 --- /dev/null +++ b/p/vbb/modes.js @@ -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 diff --git a/p/vbb/readme.md b/p/vbb/readme.md new file mode 100644 index 00000000..35b4b89a --- /dev/null +++ b/p/vbb/readme.md @@ -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 `⟲` diff --git a/package.json b/package.json index b05a85ba..482de464 100644 --- a/package.json +++ b/package.json @@ -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 ", "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" } } diff --git a/parse.js b/parse.js deleted file mode 100644 index d832bb1a..00000000 --- a/parse.js +++ /dev/null @@ -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 -} diff --git a/parse/date-time.js b/parse/date-time.js new file mode 100644 index 00000000..78635354 --- /dev/null +++ b/parse/date-time.js @@ -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 diff --git a/parse/departure.js b/parse/departure.js new file mode 100644 index 00000000..a4fce0dd --- /dev/null +++ b/parse/departure.js @@ -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 diff --git a/parse/index.js b/parse/index.js new file mode 100644 index 00000000..11a5bd0f --- /dev/null +++ b/parse/index.js @@ -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') +} diff --git a/parse/journey-leg.js b/parse/journey-leg.js new file mode 100644 index 00000000..cf8a4f7a --- /dev/null +++ b/parse/journey-leg.js @@ -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 diff --git a/parse/journey.js b/parse/journey.js new file mode 100644 index 00000000..2463c114 --- /dev/null +++ b/parse/journey.js @@ -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 diff --git a/parse/line.js b/parse/line.js new file mode 100644 index 00000000..47333a14 --- /dev/null +++ b/parse/line.js @@ -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 diff --git a/parse/location.js b/parse/location.js new file mode 100644 index 00000000..b776b920 --- /dev/null +++ b/parse/location.js @@ -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 diff --git a/parse/movement.js b/parse/movement.js new file mode 100644 index 00000000..e03a3821 --- /dev/null +++ b/parse/movement.js @@ -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 diff --git a/parse/nearby.js b/parse/nearby.js new file mode 100644 index 00000000..e009b405 --- /dev/null +++ b/parse/nearby.js @@ -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 diff --git a/parse/operator.js b/parse/operator.js new file mode 100644 index 00000000..b3682199 --- /dev/null +++ b/parse/operator.js @@ -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 diff --git a/parse/products-bitmask.js b/parse/products-bitmask.js new file mode 100644 index 00000000..4880da7d --- /dev/null +++ b/parse/products-bitmask.js @@ -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 diff --git a/parse/remark.js b/parse/remark.js new file mode 100644 index 00000000..0b8c7a52 --- /dev/null +++ b/parse/remark.js @@ -0,0 +1,8 @@ +'use strict' + +// todo: is passing in profile necessary? +const parseRemark = (profile, r) => { + return null // todo +} + +module.exports = parseRemark diff --git a/parse/stopover.js b/parse/stopover.js new file mode 100644 index 00000000..216d9c1b --- /dev/null +++ b/parse/stopover.js @@ -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 diff --git a/readme.md b/readme.md index 36460fde..16fc85f5 100644 --- a/readme.md +++ b/readme.md @@ -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 diff --git a/stringify.js b/stringify.js deleted file mode 100644 index e7063b0c..00000000 --- a/stringify.js +++ /dev/null @@ -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 -} diff --git a/test/db.js b/test/db.js new file mode 100644 index 00000000..9f9801d6 --- /dev/null +++ b/test/db.js @@ -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() +})) diff --git a/test/index.js b/test/index.js new file mode 100644 index 00000000..85c39e86 --- /dev/null +++ b/test/index.js @@ -0,0 +1,4 @@ +'use strict' + +require('./db') +require('./vbb') diff --git a/test/util.js b/test/util.js new file mode 100644 index 00000000..6a374bac --- /dev/null +++ b/test/util.js @@ -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 +} diff --git a/test/vbb.js b/test/vbb.js new file mode 100644 index 00000000..71371a57 --- /dev/null +++ b/test/vbb.js @@ -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() +}))