diff --git a/format/location-identifier.js b/format/location-identifier.js index b7529aec..87cfcde6 100644 --- a/format/location-identifier.js +++ b/format/location-identifier.js @@ -7,7 +7,7 @@ const formatLocationIdentifier = (data) => { for (let key in data) { if (!Object.prototype.hasOwnProperty.call(data, key)) continue - str += key + '=' + data[key] + sep // todo: escape + str += key + '=' + data[key] + sep // todo: escape, but how? } return str diff --git a/format/poi.js b/format/poi.js index b1577a01..22abe0f5 100644 --- a/format/poi.js +++ b/format/poi.js @@ -13,9 +13,9 @@ const formatPoi = (p) => { lid: formatLocationIdentifier({ A: '4', // POI O: p.name, + L: p.id, X: formatCoord(p.longitude), - Y: formatCoord(p.latitude), - L: p.id + Y: formatCoord(p.latitude) }) } } diff --git a/format/station.js b/format/station.js index 418e5181..a2440c47 100644 --- a/format/station.js +++ b/format/station.js @@ -1,5 +1,15 @@ 'use strict' -const formatStation = id => ({type: 'S', lid: 'L=' + id}) +const formatLocationIdentifier = require('./location-identifier') + +const formatStation = (id) => { + return { + // todo: name necessary? + lid: formatLocationIdentifier({ + A: '1', // station + L: id + }) + } +} module.exports = formatStation diff --git a/index.js b/index.js index 5e38e696..4861c6b4 100644 --- a/index.js +++ b/index.js @@ -101,41 +101,64 @@ const createClient = (profile, request = _request) => { filters.push(profile.filters.accessibility[opt.accessibility]) } - const query = profile.transformJourneysQuery({ - outDate: profile.formatDate(profile, opt.when), - outTime: profile.formatTime(profile, opt.when), - ctxScr: journeysRef, - numF: opt.results, - getPasslist: !!opt.passedStations, - maxChg: opt.transfers, - minChgTime: opt.transferTime, - depLocL: [from], - viaLocL: opt.via ? [{loc: opt.via}] : null, - arrLocL: [to], - jnyFltrL: filters, - getTariff: !!opt.tickets, + // With protocol version `1.16`, the VBB endpoint fails with + // `CGI_READ_FAILED` if you pass `numF`, the parameter for the number + // of results. To circumvent this, we loop here, collecting journeys + // until we have enough. + // see https://github.com/derhuerst/hafas-client/pull/23#issuecomment-370246163 + // todo: check if `numF` is supported again, revert this change + const journeys = [] + const more = (when, journeysRef) => { + const query = { + outDate: profile.formatDate(profile, when), + outTime: profile.formatTime(profile, when), + ctxScr: journeysRef, + // numF: opt.results, + getPasslist: !!opt.passedStations, + maxChg: opt.transfers, + minChgTime: opt.transferTime, + depLocL: [from], + viaLocL: opt.via ? [{loc: 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) + // 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? + } - 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) - const res = d.outConL.map(parse) + return request(profile, { + cfg: {polyEnc: 'GPA'}, + meth: 'TripSearch', + req: profile.transformJourneysQuery(query, opt) + }) + .then((d) => { + if (!Array.isArray(d.outConL)) return [] + const parse = profile.parseJourney(profile, d.locations, d.lines, d.remarks) + if (!journeys.earlierRef) journeys.earlierRef = d.outCtxScrB - if (d.outCtxScrB) res.earlierRef = d.outCtxScrB - if (d.outCtxScrF) res.laterRef = d.outCtxScrF - return res - }) + let latestDep = -Infinity + for (let j of d.outConL) { + j = parse(j) + journeys.push(j) + + if (journeys.length === opt.results) { // collected enough + journeys.laterRef = d.outCtxScrF + return journeys + } + const dep = +new Date(j.departure) + if (dep > latestDep) latestDep = dep + } + + const when = new Date(latestDep) + return more(when, d.outCtxScrF) // otherwise continue + }) + } + + return more(opt.when, journeysRef) } const locations = (query, opt = {}) => { diff --git a/lib/default-profile.js b/lib/default-profile.js index ec2a09fe..7abcf60d 100644 --- a/lib/default-profile.js +++ b/lib/default-profile.js @@ -26,6 +26,10 @@ const filters = require('../format/filters') const id = x => x const defaultProfile = { + salt: null, + addChecksum: false, + addMicMac: false, + transformReqBody: id, transformReq: id, diff --git a/lib/request.js b/lib/request.js index 52600212..96d5da5f 100644 --- a/lib/request.js +++ b/lib/request.js @@ -1,5 +1,6 @@ 'use strict' +const crypto = require('crypto') let captureStackTrace = () => {} if (process.env.NODE_ENV === 'dev') { captureStackTrace = require('capture-stack-trace') @@ -8,6 +9,8 @@ const {stringify} = require('query-string') const Promise = require('pinkie-promise') const {fetch} = require('fetch-ponyfill')({Promise}) +const md5 = input => crypto.createHash('md5').update(input).digest() + const request = (profile, data) => { const body = profile.transformReqBody({lang: 'en', svcReqL: [data]}) const req = profile.transformReq({ @@ -19,14 +22,37 @@ const request = (profile, data) => { 'Accept-Encoding': 'gzip, deflate', 'user-agent': 'https://github.com/derhuerst/hafas-client' }, - query: null + query: {} }) - const url = profile.endpoint + (req.query ? '?' + stringify(req.query) : '') + + if (profile.addChecksum || profile.addMicMac) { + if (!Buffer.isBuffer(profile.salt)) { + throw new Error('profile.salt must be a Buffer.') + } + if (profile.addChecksum) { + const checksum = md5(Buffer.concat([ + Buffer.from(req.body, 'utf8'), + profile.salt + ])) + req.query.checksum = checksum.toString('hex') + } + if (profile.addMicMac) { + const mic = md5(Buffer.from(req.body, 'utf8')) + req.query.mic = mic.toString('hex') + + const micAsHex = Buffer.from(mic.toString('hex'), 'utf8') + const mac = md5(Buffer.concat([micAsHex, profile.salt])) + req.query.mac = mac.toString('hex') + } + } + + const url = profile.endpoint + '?' + stringify(req.query) // Async stack traces are not supported everywhere yet, so we create our own. const err = new Error() err.isHafasError = true err.request = body + err.url = url captureStackTrace(err) return fetch(url, req) diff --git a/p/db/index.js b/p/db/index.js index 4b6dd41c..a5973581 100644 --- a/p/db/index.js +++ b/p/db/index.js @@ -1,7 +1,5 @@ 'use strict' -const crypto = require('crypto') - const _createParseLine = require('../../parse/line') const _createParseJourney = require('../../parse/journey') const _formatStation = require('../../format/station') @@ -23,17 +21,6 @@ const transformReqBody = (body) => { 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) @@ -142,8 +129,11 @@ const dbProfile = { locale: 'de-DE', timezone: 'Europe/Berlin', endpoint: 'https://reiseauskunft.bahn.de/bin/mgate.exe', + + salt: Buffer.from('bdI8UVj40K5fvxwf', 'utf8'), + addChecksum: true, + transformReqBody, - transformReq, transformJourneysQuery, products: modes.allProducts, diff --git a/p/vbb/index.js b/p/vbb/index.js index 6af748f0..8ec996a2 100644 --- a/p/vbb/index.js +++ b/p/vbb/index.js @@ -20,10 +20,10 @@ const modes = require('./modes') const formatBitmask = createFormatBitmask(modes) const transformReqBody = (body) => { - body.client = {type: 'IPA', id: 'BVG', name: 'FahrInfo', v: '4070700'} - body.ext = 'BVG.1' - body.ver = '1.15' // todo: 1.16 with `mic` and `mac` query params - body.auth = {type: 'AID', aid: '1Rxs112shyHLatUX4fofnmdxK'} + body.client = {type: 'IPA', id: 'VBB', name: 'vbbPROD', v: '4010300'} + body.ext = 'VBB.1' + body.ver = '1.16' + body.auth = {type: 'AID', aid: 'hafas-vbb-apps'} return body } @@ -167,7 +167,13 @@ const formatProducts = (products) => { const vbbProfile = { locale: 'de-DE', timezone: 'Europe/Berlin', - endpoint: 'https://bvg-apps.hafas.de/bin/mgate.exe', + endpoint: 'https://fahrinfo.vbb.de/bin/mgate.exe', + + // https://gist.github.com/derhuerst/a8d94a433358abc015ff77df4481070c#file-haf_config_base-properties-L39 + // https://runkit.com/derhuerst/hafas-decrypt-encrypted-mac-salt + salt: Buffer.from('5243544a4d3266467846667878516649', 'hex'), + addMicMac: true, + transformReqBody, products: modes.allProducts, diff --git a/readme.md b/readme.md index 91aa8a52..a0f71162 100644 --- a/readme.md +++ b/readme.md @@ -175,6 +175,7 @@ The returned [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript - [`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-discover-stations`](https://github.com/derhuerst/hafas-discover-stations#hafas-discover-stations) – Pass in a HAFAS client, discover stations by querying departures. - [`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) diff --git a/test/vbb.js b/test/vbb.js index 50a86e5f..29b41d63 100644 --- a/test/vbb.js +++ b/test/vbb.js @@ -246,8 +246,9 @@ test('journey leg details', co(function* (t) { test('journeys – station to address', co(function* (t) { const journeys = yield client.journeys(spichernstr, { - type: 'location', address: 'Torfstraße 17', - latitude: 52.5416823, longitude: 13.3491223 + type: 'location', + address: 'Torfstr. 17, Berlin', + latitude: 52.541797, longitude: 13.350042 }, {results: 1, when}) t.ok(Array.isArray(journeys)) @@ -261,9 +262,9 @@ test('journeys – station to address', co(function* (t) { 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)) + t.strictEqual(dest.address, '13353 Berlin-Wedding, Torfstr. 17') + t.ok(isRoughlyEqual(.0001, dest.latitude, 52.541797)) + t.ok(isRoughlyEqual(.0001, dest.longitude, 13.350042)) assertValidWhen(t, leg.arrival, when) t.end() @@ -273,7 +274,9 @@ test('journeys – station to address', co(function* (t) { test('journeys – station to POI', co(function* (t) { const journeys = yield client.journeys(spichernstr, { - type: 'location', id: '9980720', name: 'ATZE Musiktheater', + type: 'location', + id: '900980720', + name: 'Berlin, Atze Musiktheater für Kinder', latitude: 52.543333, longitude: 13.351686 }, {results: 1, when}) @@ -288,7 +291,8 @@ test('journeys – station to POI', co(function* (t) { const dest = leg.destination assertValidPoi(t, dest) - t.strictEqual(dest.name, 'ATZE Musiktheater') + t.strictEqual(dest.id, '900980720') + t.strictEqual(dest.name, 'Berlin, Atze Musiktheater für Kinder') t.ok(isRoughlyEqual(.0001, dest.latitude, 52.543333)) t.ok(isRoughlyEqual(.0001, dest.longitude, 13.351686)) assertValidWhen(t, leg.arrival, when)