Software zum Installieren eines Smart-Mirror Frameworks , zum Nutzen von hochschulrelevanten Informationen, auf einem Raspberry-Pi.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

index.js 26KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772
  1. 'use strict'
  2. const isObj = require('lodash/isObject')
  3. const sortBy = require('lodash/sortBy')
  4. const pRetry = require('p-retry')
  5. const omit = require('lodash/omit')
  6. const defaultProfile = require('./lib/default-profile')
  7. const validateProfile = require('./lib/validate-profile')
  8. const {INVALID_REQUEST} = require('./lib/errors')
  9. const sliceLeg = require('./lib/slice-leg')
  10. const isNonEmptyString = str => 'string' === typeof str && str.length > 0
  11. const validateLocation = (loc, name = 'location') => {
  12. if (!isObj(loc)) {
  13. throw new TypeError(name + ' must be an object.')
  14. } else if (loc.type !== 'location') {
  15. throw new TypeError('invalid location object.')
  16. } else if ('number' !== typeof loc.latitude) {
  17. throw new TypeError(name + '.latitude must be a number.')
  18. } else if ('number' !== typeof loc.longitude) {
  19. throw new TypeError(name + '.longitude must be a number.')
  20. }
  21. }
  22. const validateWhen = (when, name = 'when') => {
  23. if (Number.isNaN(+when)) {
  24. throw new TypeError(name + ' is invalid')
  25. }
  26. }
  27. const createClient = (profile, userAgent, opt = {}) => {
  28. profile = Object.assign({}, defaultProfile, profile)
  29. validateProfile(profile)
  30. if ('string' !== typeof userAgent) {
  31. throw new TypeError('userAgent must be a string');
  32. }
  33. const _stationBoard = (station, type, parse, opt = {}) => {
  34. if (isObj(station)) station = profile.formatStation(station.id)
  35. else if ('string' === typeof station) station = profile.formatStation(station)
  36. else throw new TypeError('station must be an object or a string.')
  37. if ('string' !== typeof type || !type) {
  38. throw new TypeError('type must be a non-empty string.')
  39. }
  40. if (!profile.departuresGetPasslist && ('stopovers' in opt)) {
  41. throw new Error('opt.stopovers is not supported by this endpoint')
  42. }
  43. if (!profile.departuresStbFltrEquiv && ('includeRelatedStations' in opt)) {
  44. throw new Error('opt.includeRelatedStations is not supported by this endpoint')
  45. }
  46. opt = Object.assign({
  47. // todo: for arrivals(), this is actually a station it *has already* stopped by
  48. direction: null, // only show departures stopping by this station
  49. line: null, // filter by line ID
  50. duration: 10, // show departures for the next n minutes
  51. results: null, // max. number of results; `null` means "whatever HAFAS wants"
  52. subStops: true, // parse & expose sub-stops of stations?
  53. entrances: true, // parse & expose entrances of stops/stations?
  54. linesOfStops: false, // parse & expose lines at the stop/station?
  55. remarks: true, // parse & expose hints & warnings?
  56. stopovers: false, // fetch & parse previous/next stopovers?
  57. // departures at related stations
  58. // e.g. those that belong together on the metro map.
  59. includeRelatedStations: true
  60. }, opt)
  61. opt.when = new Date(opt.when || Date.now())
  62. if (Number.isNaN(+opt.when)) throw new Error('opt.when is invalid')
  63. const req = profile.formatStationBoardReq({profile, opt}, station, type)
  64. // todo [breaking]: return object with realtimeDataUpdatedAt
  65. return profile.request({profile, opt}, userAgent, req)
  66. .then(({res, common}) => {
  67. if (!Array.isArray(res.jnyL)) return []
  68. const ctx = {profile, opt, common, res}
  69. return res.jnyL.map(res => parse(ctx, res))
  70. .sort((a, b) => new Date(a.when) - new Date(b.when)) // todo
  71. })
  72. }
  73. const departures = (station, opt = {}) => {
  74. return _stationBoard(station, 'DEP', profile.parseDeparture, opt)
  75. }
  76. const arrivals = (station, opt = {}) => {
  77. return _stationBoard(station, 'ARR', profile.parseArrival, opt)
  78. }
  79. const journeys = (from, to, opt = {}) => {
  80. from = profile.formatLocation(profile, from, 'from')
  81. to = profile.formatLocation(profile, to, 'to')
  82. if (('earlierThan' in opt) && ('laterThan' in opt)) {
  83. throw new TypeError('opt.earlierThan and opt.laterThan are mutually exclusive.')
  84. }
  85. if (('departure' in opt) && ('arrival' in opt)) {
  86. throw new TypeError('opt.departure and opt.arrival are mutually exclusive.')
  87. }
  88. let journeysRef = null
  89. if ('earlierThan' in opt) {
  90. if (!isNonEmptyString(opt.earlierThan)) {
  91. throw new TypeError('opt.earlierThan must be a non-empty string.')
  92. }
  93. if (('departure' in opt) || ('arrival' in opt)) {
  94. throw new TypeError('opt.earlierThan and opt.departure/opt.arrival are mutually exclusive.')
  95. }
  96. journeysRef = opt.earlierThan
  97. }
  98. if ('laterThan' in opt) {
  99. if (!isNonEmptyString(opt.laterThan)) {
  100. throw new TypeError('opt.laterThan must be a non-empty string.')
  101. }
  102. if (('departure' in opt) || ('arrival' in opt)) {
  103. throw new TypeError('opt.laterThan and opt.departure/opt.arrival are mutually exclusive.')
  104. }
  105. journeysRef = opt.laterThan
  106. }
  107. opt = Object.assign({
  108. results: null, // number of journeys – `null` means "whatever HAFAS returns"
  109. via: null, // let journeys pass this station?
  110. stopovers: false, // return stations on the way?
  111. transfers: -1, // maximum nr of transfers
  112. transferTime: 0, // minimum time for a single transfer in minutes
  113. // todo: does this work with every endpoint?
  114. accessibility: 'none', // 'none', 'partial' or 'complete'
  115. bike: false, // only bike-friendly journeys
  116. walkingSpeed: 'normal', // 'slow', 'normal', 'fast'
  117. // Consider walking to nearby stations at the beginning of a journey?
  118. startWithWalking: true,
  119. tickets: false, // return tickets?
  120. polylines: false, // return leg shapes?
  121. subStops: true, // parse & expose sub-stops of stations?
  122. entrances: true, // parse & expose entrances of stops/stations?
  123. remarks: true, // parse & expose hints & warnings?
  124. scheduledDays: false
  125. }, opt)
  126. if (opt.via) opt.via = profile.formatLocation(profile, opt.via, 'opt.via')
  127. if (opt.when !== undefined) {
  128. throw new Error('opt.when is not supported anymore. Use opt.departure/opt.arrival.')
  129. }
  130. let when = new Date(), outFrwd = true
  131. if (opt.departure !== undefined && opt.departure !== null) {
  132. when = new Date(opt.departure)
  133. if (Number.isNaN(+when)) throw new TypeError('opt.departure is invalid')
  134. } else if (opt.arrival !== undefined && opt.arrival !== null) {
  135. if (!profile.journeysOutFrwd) {
  136. throw new Error('opt.arrival is unsupported')
  137. }
  138. when = new Date(opt.arrival)
  139. if (Number.isNaN(+when)) throw new TypeError('opt.arrival is invalid')
  140. outFrwd = false
  141. }
  142. const filters = [
  143. profile.formatProductsFilter({profile}, opt.products || {})
  144. ]
  145. if (
  146. opt.accessibility &&
  147. profile.filters &&
  148. profile.filters.accessibility &&
  149. profile.filters.accessibility[opt.accessibility]
  150. ) {
  151. filters.push(profile.filters.accessibility[opt.accessibility])
  152. }
  153. if (!['slow','normal','fast'].includes(opt.walkingSpeed)) {
  154. throw new Error('opt.walkingSpeed must be one of these values: "slow", "normal", "fast".')
  155. }
  156. const gisFltrL = []
  157. if (profile.journeysWalkingSpeed) {
  158. gisFltrL.push({
  159. meta: 'foot_speed_' + opt.walkingSpeed,
  160. mode: 'FB',
  161. type: 'M'
  162. })
  163. }
  164. const query = {
  165. getPasslist: !!opt.stopovers,
  166. maxChg: opt.transfers,
  167. minChgTime: opt.transferTime,
  168. depLocL: [from],
  169. viaLocL: opt.via ? [{loc: opt.via}] : [],
  170. arrLocL: [to],
  171. jnyFltrL: filters,
  172. gisFltrL,
  173. getTariff: !!opt.tickets,
  174. // todo: this is actually "take additional stations nearby the given start and destination station into account"
  175. // see rest.exe docs
  176. ushrp: !!opt.startWithWalking,
  177. getPT: true, // todo: what is this?
  178. getIV: false, // todo: walk & bike as alternatives?
  179. getPolyline: !!opt.polylines
  180. // todo: `getConGroups: false` what is this?
  181. // todo: what is getEco, fwrd?
  182. }
  183. if (journeysRef) query.ctxScr = journeysRef
  184. else {
  185. query.outDate = profile.formatDate(profile, when)
  186. query.outTime = profile.formatTime(profile, when)
  187. }
  188. if (opt.results !== null) query.numF = opt.results
  189. if (profile.journeysOutFrwd) query.outFrwd = outFrwd
  190. return profile.request({profile, opt}, userAgent, {
  191. cfg: {polyEnc: 'GPA'},
  192. meth: 'TripSearch',
  193. req: profile.transformJourneysQuery({profile, opt}, query)
  194. })
  195. .then(({res, common}) => {
  196. if (!Array.isArray(res.outConL)) return []
  197. // todo: outConGrpL
  198. const ctx = {profile, opt, common, res}
  199. const journeys = res.outConL
  200. .map(j => profile.parseJourney(ctx, j))
  201. return {
  202. earlierRef: res.outCtxScrB,
  203. laterRef: res.outCtxScrF,
  204. journeys,
  205. // todo [breaking]: rename to realtimeDataUpdatedAt
  206. realtimeDataFrom: res.planrtTS ? parseInt(res.planrtTS) : null,
  207. }
  208. })
  209. }
  210. const refreshJourney = (refreshToken, opt = {}) => {
  211. if ('string' !== typeof refreshToken || !refreshToken) {
  212. throw new TypeError('refreshToken must be a non-empty string.')
  213. }
  214. opt = Object.assign({
  215. stopovers: false, // return stations on the way?
  216. tickets: false, // return tickets?
  217. polylines: false, // return leg shapes? (not supported by all endpoints)
  218. subStops: true, // parse & expose sub-stops of stations?
  219. entrances: true, // parse & expose entrances of stops/stations?
  220. remarks: true // parse & expose hints & warnings?
  221. }, opt)
  222. const req = profile.formatRefreshJourneyReq({profile, opt}, refreshToken)
  223. return profile.request({profile, opt}, userAgent, req)
  224. .then(({res, common}) => {
  225. if (!Array.isArray(res.outConL) || !res.outConL[0]) {
  226. const err = new Error('invalid response')
  227. // technically this is not a HAFAS error
  228. // todo: find a different flag with decent DX
  229. err.isHafasError = true
  230. throw err
  231. }
  232. const ctx = {profile, opt, common, res}
  233. return {
  234. // todo [breaking]: rename to realtimeDataUpdatedAt
  235. realtimeDataFrom: res.planrtTS ? parseInt(res.planrtTS) : null,
  236. ...profile.parseJourney(ctx, res.outConL[0])
  237. }
  238. })
  239. }
  240. // Although the DB Navigator app passes the *first* stopover of the trip
  241. // (instead of the previous one), it seems to work with the previous one as well.
  242. const journeysFromTrip = async (fromTripId, previousStopover, to, opt = {}) => {
  243. if (!isNonEmptyString(fromTripId)) {
  244. throw new Error('fromTripId must be a non-empty string.')
  245. }
  246. if ('string' === typeof to) {
  247. to = profile.formatStation(to)
  248. } else if (isObj(to) && (to.type === 'station' || to.type === 'stop')) {
  249. to = profile.formatStation(to.id)
  250. } else throw new Error('to must be a valid stop or station.')
  251. if (!isObj(previousStopover)) throw new Error('previousStopover must be an object.')
  252. let prevStop = previousStopover.stop
  253. if (isObj(prevStop)) {
  254. prevStop = profile.formatStation(prevStop.id)
  255. } else if ('string' === typeof prevStop) {
  256. prevStop = profile.formatStation(prevStop)
  257. } else throw new Error('previousStopover.stop must be a valid stop or station.')
  258. let depAtPrevStop = previousStopover.departure || previousStopover.plannedDeparture
  259. if (!isNonEmptyString(depAtPrevStop)) {
  260. throw new Error('previousStopover.(planned)departure must be a string')
  261. }
  262. depAtPrevStop = Date.parse(depAtPrevStop)
  263. if (Number.isNaN(depAtPrevStop)) {
  264. throw new Error('previousStopover.(planned)departure is invalid')
  265. }
  266. if (depAtPrevStop > Date.now()) {
  267. throw new Error('previousStopover.(planned)departure must be in the past')
  268. }
  269. opt = Object.assign({
  270. stopovers: false, // return stations on the way?
  271. transferTime: 0, // minimum time for a single transfer in minutes
  272. accessibility: 'none', // 'none', 'partial' or 'complete'
  273. tickets: false, // return tickets?
  274. polylines: false, // return leg shapes?
  275. subStops: true, // parse & expose sub-stops of stations?
  276. entrances: true, // parse & expose entrances of stops/stations?
  277. remarks: true, // parse & expose hints & warnings?
  278. }, opt)
  279. // make clear that `departure`/`arrival`/`when` are not supported
  280. if (opt.departure) throw new Error('journeysFromTrip + opt.departure is not supported by HAFAS.')
  281. if (opt.arrival) throw new Error('journeysFromTrip + opt.arrival is not supported by HAFAS.')
  282. if (opt.when) throw new Error('journeysFromTrip + opt.when is not supported by HAFAS.')
  283. const filters = [
  284. profile.formatProductsFilter({profile}, opt.products || {})
  285. ]
  286. if (
  287. opt.accessibility &&
  288. profile.filters &&
  289. profile.filters.accessibility &&
  290. profile.filters.accessibility[opt.accessibility]
  291. ) {
  292. filters.push(profile.filters.accessibility[opt.accessibility])
  293. }
  294. // todo: support walking speed filter
  295. // todo: are these supported?
  296. // - getPT
  297. // - getIV
  298. // - trfReq
  299. // features from `journeys()` not supported here:
  300. // - `maxChg`: maximum nr of transfers
  301. // - `bike`: only bike-friendly journeys
  302. // - `numF`: how many journeys?
  303. // - `via`: let journeys pass this station
  304. // todo: find a way to support them
  305. const query = {
  306. // https://github.com/marudor/BahnhofsAbfahrten/blob/49ebf8b36576547112e61a6273bee770f0769660/packages/types/HAFAS/SearchOnTrip.ts#L16-L30
  307. // todo: support search by `journey.refreshToken` (a.k.a. `ctxRecon`) via `sotMode: RC`?
  308. sotMode: 'JI', // seach by trip ID (a.k.a. "JID")
  309. jid: fromTripId,
  310. locData: { // when & where the trip has been entered
  311. loc: prevStop,
  312. type: 'DEP', // todo: are there other values?
  313. date: profile.formatDate(profile, depAtPrevStop),
  314. time: profile.formatTime(profile, depAtPrevStop)
  315. },
  316. arrLocL: [to],
  317. jnyFltrL: filters,
  318. getPasslist: !!opt.stopovers,
  319. getPolyline: !!opt.polylines,
  320. minChgTime: opt.transferTime,
  321. getTariff: !!opt.tickets,
  322. }
  323. const {res, common} = await profile.request({profile, opt}, userAgent, {
  324. cfg: {polyEnc: 'GPA'},
  325. meth: 'SearchOnTrip',
  326. req: query,
  327. })
  328. if (!Array.isArray(res.outConL)) return []
  329. const ctx = {profile, opt, common, res}
  330. // todo [breaking]: return object with realtimeDataUpdatedAt
  331. return res.outConL
  332. .map(rawJourney => profile.parseJourney(ctx, rawJourney))
  333. .map((journey) => {
  334. // For the first (transit) leg, HAFAS sometimes returns *all* past
  335. // stopovers of the trip, even though it should only return stopovers
  336. // between the specified `depAtPrevStop` and the arrival at the
  337. // interchange station. We slice the leg accordingly.
  338. const fromLegI = journey.legs.findIndex(l => l.tripId === fromTripId)
  339. if (fromLegI < 0) return journey
  340. const fromLeg = journey.legs[fromLegI]
  341. return {
  342. ...journey,
  343. legs: [
  344. ...journey.legs.slice(0, fromLegI),
  345. sliceLeg(fromLeg, previousStopover.stop, fromLeg.destination),
  346. ...journey.legs.slice(fromLegI + 2),
  347. ],
  348. }
  349. })
  350. }
  351. const locations = (query, opt = {}) => {
  352. if (!isNonEmptyString(query)) {
  353. throw new TypeError('query must be a non-empty string.')
  354. }
  355. opt = Object.assign({
  356. fuzzy: true, // find only exact matches?
  357. results: 5, // how many search results?
  358. stops: true, // return stops/stations?
  359. addresses: true,
  360. poi: true, // points of interest
  361. subStops: true, // parse & expose sub-stops of stations?
  362. entrances: true, // parse & expose entrances of stops/stations?
  363. linesOfStops: false // parse & expose lines at each stop/station?
  364. }, opt)
  365. const req = profile.formatLocationsReq({profile, opt}, query)
  366. return profile.request({profile, opt}, userAgent, req)
  367. .then(({res, common}) => {
  368. if (!res.match || !Array.isArray(res.match.locL)) return []
  369. const ctx = {profile, opt, common, res}
  370. return res.match.locL.map(loc => profile.parseLocation(ctx, loc))
  371. })
  372. }
  373. const stop = (stop, opt = {}) => {
  374. if ('object' === typeof stop) stop = profile.formatStation(stop.id)
  375. else if ('string' === typeof stop) stop = profile.formatStation(stop)
  376. else throw new TypeError('stop must be an object or a string.')
  377. opt = Object.assign({
  378. linesOfStops: false, // parse & expose lines at the stop/station?
  379. subStops: true, // parse & expose sub-stops of stations?
  380. entrances: true, // parse & expose entrances of stops/stations?
  381. remarks: true, // parse & expose hints & warnings?
  382. }, opt)
  383. const req = profile.formatStopReq({profile, opt}, stop)
  384. return profile.request({profile, opt}, userAgent, req)
  385. .then(({res, common}) => {
  386. if (!res || !Array.isArray(res.locL) || !res.locL[0]) {
  387. // todo: proper stack trace?
  388. // todo: DRY with lib/request.js
  389. const err = new Error('response has no stop')
  390. // technically this is not a HAFAS error
  391. // todo: find a different flag with decent DX
  392. err.isHafasError = true
  393. err.code = INVALID_REQUEST
  394. throw err
  395. }
  396. const ctx = {profile, opt, res, common}
  397. return profile.parseLocation(ctx, res.locL[0])
  398. })
  399. }
  400. const nearby = (location, opt = {}) => {
  401. validateLocation(location, 'location')
  402. opt = Object.assign({
  403. results: 8, // maximum number of results
  404. distance: null, // maximum walking distance in meters
  405. poi: false, // return points of interest?
  406. stops: true, // return stops/stations?
  407. subStops: true, // parse & expose sub-stops of stations?
  408. entrances: true, // parse & expose entrances of stops/stations?
  409. linesOfStops: false // parse & expose lines at each stop/station?
  410. }, opt)
  411. const req = profile.formatNearbyReq({profile, opt}, location)
  412. return profile.request({profile, opt}, userAgent, req)
  413. .then(({common, res}) => {
  414. if (!Array.isArray(res.locL)) return []
  415. // todo: parse `.dur` – walking duration?
  416. const ctx = {profile, opt, common, res}
  417. const results = res.locL.map(loc => profile.parseNearby(ctx, loc))
  418. return Number.isInteger(opt.results) ? results.slice(0, opt.results) : results
  419. })
  420. }
  421. const trip = (id, lineName, opt = {}) => {
  422. if (!isNonEmptyString(id)) {
  423. throw new TypeError('id must be a non-empty string.')
  424. }
  425. if (!isNonEmptyString(lineName)) {
  426. throw new TypeError('lineName must be a non-empty string.')
  427. }
  428. opt = Object.assign({
  429. stopovers: true, // return stations on the way?
  430. polyline: false, // return a track shape?
  431. subStops: true, // parse & expose sub-stops of stations?
  432. entrances: true, // parse & expose entrances of stops/stations?
  433. remarks: true // parse & expose hints & warnings?
  434. }, opt)
  435. const req = profile.formatTripReq({profile, opt}, id, lineName)
  436. // todo [breaking]: return object with realtimeDataUpdatedAt
  437. return profile.request({profile, opt}, userAgent, req)
  438. .then(({common, res}) => {
  439. const ctx = {profile, opt, common, res}
  440. return profile.parseTrip(ctx, res.journey)
  441. })
  442. }
  443. // todo [breaking]: rename to trips()?
  444. const tripsByName = (lineNameOrFahrtNr = '*', opt = {}) => {
  445. if (!isNonEmptyString(lineNameOrFahrtNr)) {
  446. throw new TypeError('lineNameOrFahrtNr must be a non-empty string.')
  447. }
  448. opt = Object.assign({
  449. when: null,
  450. fromWhen: null, untilWhen: null,
  451. onlyCurrentlyRunning: true,
  452. products: {},
  453. currentlyStoppingAt: null,
  454. lineName: null,
  455. operatorNames: null,
  456. additionalFilters: [], // undocumented
  457. }, opt)
  458. const req = {
  459. // fields: https://github.com/marudor/BahnhofsAbfahrten/blob/f619e754f212980261eb7e2b151cd73ba0213da8/packages/types/HAFAS/JourneyMatch.ts#L4-L23
  460. input: lineNameOrFahrtNr,
  461. onlyCR: opt.onlyCurrentlyRunning,
  462. jnyFltrL: [
  463. profile.formatProductsFilter({profile}, opt.products),
  464. ],
  465. // todo: passing `tripId` yields a `CGI_READ_FAILED` error
  466. // todo: passing a stop ID as `extId` yields a `PARAMETER` error
  467. // todo: `onlyRT: true` reduces the number of results, but filters recent trips 🤔
  468. // todo: `onlyTN: true` yields a `NO_MATCH` error
  469. // todo: useAeqi
  470. }
  471. if (opt.when !== null) {
  472. req.date = profile.formatDate(profile, new Date(opt.when))
  473. req.time = profile.formatTime(profile, new Date(opt.when))
  474. }
  475. // todo: fromWhen doesn't work yet, but untilWhen does
  476. if (opt.fromWhen !== null) {
  477. req.dateB = profile.formatDate(profile, new Date(opt.fromWhen))
  478. req.timeB = profile.formatTime(profile, new Date(opt.fromWhen))
  479. }
  480. if (opt.untilWhen !== null) {
  481. req.dateE = profile.formatDate(profile, new Date(opt.untilWhen))
  482. req.timeE = profile.formatTime(profile, new Date(opt.untilWhen))
  483. }
  484. const filter = (mode, type, value) => ({mode, type, value})
  485. if (opt.currentlyStoppingAt !== null) {
  486. if (!isNonEmptyString(opt.currentlyStoppingAt)) {
  487. throw new TypeError('opt.currentlyStoppingAt must be a non-empty string.')
  488. }
  489. req.jnyFltrL.push(filter('INC', 'STATIONS', opt.currentlyStoppingAt))
  490. }
  491. if (opt.lineName !== null) {
  492. if (!isNonEmptyString(opt.lineName)) {
  493. throw new TypeError('opt.lineName must be a non-empty string.')
  494. }
  495. // todo: does this target `line` or `lineId`?
  496. req.jnyFltrL.push(filter('INC', 'LINE', opt.lineName))
  497. }
  498. if (opt.operatorNames !== null) {
  499. if (
  500. !Array.isArray(opt.operatorNames)
  501. || opt.operatorNames.length === 0
  502. || !opt.operatorNames.every(isNonEmptyString)
  503. ) {
  504. throw new TypeError('opt.operatorNames must be an array of non-empty strings.')
  505. }
  506. // todo: is the an escaping mechanism for ","
  507. req.jnyFltrL.push(filter('INC', 'OP', opt.operatorNames.join(',')))
  508. }
  509. req.jnyFltrL = [...req.jnyFltrL, ...opt.additionalFilters]
  510. // todo [breaking]: return object with realtimeDataUpdatedAt
  511. return profile.request({profile, opt}, userAgent, {
  512. cfg: {polyEnc: 'GPA'},
  513. meth: 'JourneyMatch',
  514. req,
  515. })
  516. // todo [breaking]: catch `NO_MATCH` errors, return []
  517. .then(({res, common}) => {
  518. const ctx = {profile, opt, common, res}
  519. return res.jnyL.map(t => profile.parseTrip(ctx, t))
  520. })
  521. }
  522. const radar = ({north, west, south, east}, opt) => {
  523. if ('number' !== typeof north) throw new TypeError('north must be a number.')
  524. if ('number' !== typeof west) throw new TypeError('west must be a number.')
  525. if ('number' !== typeof south) throw new TypeError('south must be a number.')
  526. if ('number' !== typeof east) throw new TypeError('east must be a number.')
  527. if (north <= south) throw new Error('north must be larger than south.')
  528. if (east <= west) throw new Error('east must be larger than west.')
  529. opt = Object.assign({
  530. results: 256, // maximum number of vehicles
  531. duration: 30, // compute frames for the next n seconds
  532. // todo: what happens with `frames: 0`?
  533. frames: 3, // nr of frames to compute
  534. products: null, // optionally an object of booleans
  535. polylines: true, // return a track shape for each vehicle?
  536. subStops: true, // parse & expose sub-stops of stations?
  537. entrances: true, // parse & expose entrances of stops/stations?
  538. }, opt || {})
  539. opt.when = new Date(opt.when || Date.now())
  540. if (Number.isNaN(+opt.when)) throw new TypeError('opt.when is invalid')
  541. const req = profile.formatRadarReq({profile, opt}, north, west, south, east)
  542. // todo [breaking]: return object with realtimeDataUpdatedAt
  543. return profile.request({profile, opt}, userAgent, req)
  544. .then(({res, common}) => {
  545. if (!Array.isArray(res.jnyL)) return []
  546. const ctx = {profile, opt, common, res}
  547. return res.jnyL.map(m => profile.parseMovement(ctx, m))
  548. })
  549. }
  550. const reachableFrom = (address, opt = {}) => {
  551. validateLocation(address, 'address')
  552. opt = Object.assign({
  553. when: Date.now(),
  554. maxTransfers: 5, // maximum of 5 transfers
  555. maxDuration: 20, // maximum travel duration in minutes, pass `null` for infinite
  556. products: {},
  557. subStops: true, // parse & expose sub-stops of stations?
  558. entrances: true, // parse & expose entrances of stops/stations?
  559. polylines: false, // return leg shapes?
  560. }, opt)
  561. if (Number.isNaN(+opt.when)) throw new TypeError('opt.when is invalid')
  562. const req = profile.formatReachableFromReq({profile, opt}, address)
  563. const refetch = () => {
  564. return profile.request({profile, opt}, userAgent, req)
  565. .then(({res, common}) => {
  566. if (!Array.isArray(res.posL)) {
  567. const err = new Error('invalid response')
  568. err.shouldRetry = true
  569. throw err
  570. }
  571. const byDuration = []
  572. let i = 0, lastDuration = NaN
  573. for (const pos of sortBy(res.posL, 'dur')) {
  574. const loc = common.locations[pos.locX]
  575. if (!loc) continue
  576. if (pos.dur !== lastDuration) {
  577. lastDuration = pos.dur
  578. i = byDuration.length
  579. byDuration.push({
  580. duration: pos.dur,
  581. stations: [loc]
  582. })
  583. } else {
  584. byDuration[i].stations.push(loc)
  585. }
  586. }
  587. // todo [breaking]: return object with realtimeDataUpdatedAt
  588. return byDuration
  589. })
  590. }
  591. return pRetry(refetch, {
  592. retries: 3,
  593. factor: 2,
  594. minTimeout: 2 * 1000
  595. })
  596. }
  597. const remarks = async (opt = {}) => {
  598. opt = {
  599. results: 100, // maximum number of remarks
  600. // filter by time
  601. from: Date.now(),
  602. to: null,
  603. products: null, // filter by affected products
  604. polylines: false, // return leg shapes? (not supported by all endpoints)
  605. ...opt
  606. }
  607. if (opt.from !== null) {
  608. opt.from = new Date(opt.from)
  609. validateWhen(opt.from, 'opt.from')
  610. }
  611. if (opt.to !== null) {
  612. opt.to = new Date(opt.to)
  613. validateWhen(opt.to, 'opt.to')
  614. }
  615. const req = profile.formatRemarksReq({profile, opt})
  616. const {
  617. res, common,
  618. } = await profile.request({profile, opt}, userAgent, req)
  619. // todo [breaking]: return object with realtimeDataUpdatedAt
  620. const ctx = {profile, opt, common, res}
  621. return (res.msgL || [])
  622. .map(w => profile.parseWarning(ctx, w))
  623. }
  624. const lines = async (query, opt = {}) => {
  625. if (!isNonEmptyString(query)) {
  626. throw new TypeError('query must be a non-empty string.')
  627. }
  628. const req = profile.formatLinesReq({profile, opt}, query)
  629. const {
  630. res, common,
  631. } = await profile.request({profile, opt}, userAgent, req)
  632. // todo [breaking]: return object with realtimeDataUpdatedAt
  633. if (!Array.isArray(res.lineL)) return []
  634. const ctx = {profile, opt, common, res}
  635. return res.lineL.map(l => {
  636. const parseDirRef = i => (res.common.dirL[i] || {}).txt || null
  637. return {
  638. ...omit(l.line, ['id', 'fahrtNr']),
  639. id: l.lineId,
  640. // todo: what is locX?
  641. directions: Array.isArray(l.dirRefL)
  642. ? l.dirRefL.map(parseDirRef)
  643. : null,
  644. trips: Array.isArray(l.jnyL)
  645. ? l.jnyL.map(t => profile.parseTrip(ctx, t))
  646. : null,
  647. }
  648. })
  649. }
  650. const serverInfo = async (opt = {}) => {
  651. const {res, common} = await profile.request({profile, opt}, userAgent, {
  652. meth: 'ServerInfo',
  653. req: {}
  654. })
  655. const ctx = {profile, opt, common, res}
  656. return {
  657. timetableStart: res.fpB || null,
  658. timetableEnd: res.fpE || null,
  659. serverTime: res.sD && res.sT
  660. ? profile.parseDateTime(ctx, res.sD, res.sT)
  661. : null,
  662. realtimeDataUpdatedAt: res.planrtTS
  663. ? parseInt(res.planrtTS)
  664. : null,
  665. }
  666. }
  667. const client = {
  668. departures,
  669. arrivals,
  670. journeys,
  671. locations,
  672. stop,
  673. nearby,
  674. serverInfo,
  675. }
  676. if (profile.trip) client.trip = trip
  677. if (profile.radar) client.radar = radar
  678. if (profile.refreshJourney) client.refreshJourney = refreshJourney
  679. if (profile.journeysFromTrip) client.journeysFromTrip = journeysFromTrip
  680. if (profile.reachableFrom) client.reachableFrom = reachableFrom
  681. if (profile.tripsByName) client.tripsByName = tripsByName
  682. if (profile.remarks !== false) client.remarks = remarks
  683. if (profile.lines !== false) client.lines = lines
  684. Object.defineProperty(client, 'profile', {value: profile})
  685. return client
  686. }
  687. module.exports = createClient