From ec723b3414159e2ca7d5ab84a0fef75122db0d3b Mon Sep 17 00:00:00 2001 From: Traines Date: Sat, 21 Dec 2024 15:26:49 +0000 Subject: [PATCH] refactoring --- format/filters.js | 12 - format/lines-req.js | 12 - {p/db => format}/loyalty-cards.js | 0 format/rectangle.js | 16 - format/refresh-journey-req.js | 24 - format/remarks-req.js | 38 -- format/station.js | 5 + format/stop-req.js | 8 +- index.js | 2 +- p/db/ageGroup.js => lib/age-group.js | 0 lib/default-profile.js | 68 ++- {p/db => lib}/products.js | 0 lib/profile-hooks.js | 23 - lib/slice-leg.js | 63 --- lib/validate-profile.js | 1 + p/db/index.js | 541 +-------------------- p/db/journeys-req.js | 29 ++ {format => p/db}/location-filter.js | 0 {format => p/db}/locations-req.js | 0 p/db/tickets.js | 65 +++ p/db/travellers.js | 29 ++ parse/arrival-or-departure.js | 5 +- parse/hints-by-code.js | 206 ++++++++ parse/journey-leg.js | 5 + parse/journey.js | 6 + parse/load-factor.js | 31 ++ parse/location.js | 2 +- parse/{products-bitmask.js => products.js} | 4 +- test/format/db-journeys-query.js | 6 +- test/parse/location.js | 4 +- 30 files changed, 436 insertions(+), 769 deletions(-) delete mode 100644 format/filters.js delete mode 100644 format/lines-req.js rename {p/db => format}/loyalty-cards.js (100%) delete mode 100644 format/rectangle.js delete mode 100644 format/refresh-journey-req.js delete mode 100644 format/remarks-req.js rename p/db/ageGroup.js => lib/age-group.js (100%) rename {p/db => lib}/products.js (100%) delete mode 100644 lib/profile-hooks.js delete mode 100644 lib/slice-leg.js create mode 100644 p/db/journeys-req.js rename {format => p/db}/location-filter.js (100%) rename {format => p/db}/locations-req.js (100%) create mode 100644 p/db/tickets.js create mode 100644 p/db/travellers.js create mode 100644 parse/hints-by-code.js create mode 100644 parse/load-factor.js rename parse/{products-bitmask.js => products.js} (71%) diff --git a/format/filters.js b/format/filters.js deleted file mode 100644 index 3eecd8f6..00000000 --- a/format/filters.js +++ /dev/null @@ -1,12 +0,0 @@ -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'}, -}; - -export { - bike, - accessibility, -}; diff --git a/format/lines-req.js b/format/lines-req.js deleted file mode 100644 index 6d78a93c..00000000 --- a/format/lines-req.js +++ /dev/null @@ -1,12 +0,0 @@ -const formatLinesReq = (ctx, query) => { - return { - meth: 'LineMatch', - req: { - input: query, - }, - }; -}; - -export { - formatLinesReq, -}; diff --git a/p/db/loyalty-cards.js b/format/loyalty-cards.js similarity index 100% rename from p/db/loyalty-cards.js rename to format/loyalty-cards.js diff --git a/format/rectangle.js b/format/rectangle.js deleted file mode 100644 index c36fd827..00000000 --- a/format/rectangle.js +++ /dev/null @@ -1,16 +0,0 @@ -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), - }, - }; -}; - -export { - formatRectangle, -}; diff --git a/format/refresh-journey-req.js b/format/refresh-journey-req.js deleted file mode 100644 index 92f23b18..00000000 --- a/format/refresh-journey-req.js +++ /dev/null @@ -1,24 +0,0 @@ -const formatRefreshJourneyReq = (ctx, refreshToken) => { - const {profile, opt} = ctx; - - const req = { - getIST: true, // todo: make an option - getPasslist: Boolean(opt.stopovers), - getPolyline: Boolean(opt.polylines), - getTariff: Boolean(opt.tickets), - }; - if (profile.refreshJourneyUseOutReconL) { - req.outReconL = [{ctx: refreshToken}]; - } else { - req.ctxRecon = refreshToken; - } - - return { - meth: 'Reconstruction', - req, - }; -}; - -export { - formatRefreshJourneyReq, -}; diff --git a/format/remarks-req.js b/format/remarks-req.js deleted file mode 100644 index ba45bc3e..00000000 --- a/format/remarks-req.js +++ /dev/null @@ -1,38 +0,0 @@ -const formatRemarksReq = (ctx) => { - const {profile, opt} = ctx; - - const himFltrL = []; - // todo: https://github.com/marudor/BahnhofsAbfahrten/blob/95fef0217d01344642dd423457473fe9b8b6056e/src/types/HAFAS/index.ts#L76-L91 - if (opt.products) { - himFltrL.push(profile.formatProductsFilter(ctx, opt.products)); - } - - const req = { - himFltrL, - }; - if (profile.remarksGetPolyline) { - req.getPolyline = Boolean(opt.polylines); - } - // todo: stLoc, dirLoc - // todo: comp, dept, onlyHimId, onlyToday - // todo: dailyB, dailyE - // see https://github.com/marudor/BahnhofsAbfahrten/blob/46a74957d68edc15713112df44e1a25150f5a178/src/types/HAFAS/HimSearch.ts#L3-L18 - - if (opt.results !== null) { - req.maxNum = opt.results; - } - if (opt.from !== null) { - req.dateB = profile.formatDate(profile, opt.from); - req.timeB = profile.formatTime(profile, opt.from); - } - if (opt.to !== null) { - req.dateE = profile.formatDate(profile, opt.to); - req.timeE = profile.formatTime(profile, opt.to); - } - - return {meth: 'HimSearch', req}; -}; - -export { - formatRemarksReq, -}; diff --git a/format/station.js b/format/station.js index dc9e793c..f3a1b099 100644 --- a/format/station.js +++ b/format/station.js @@ -1,6 +1,11 @@ import {formatLocationIdentifier} from './location-identifier.js'; +const isIBNR = /^\d{6,}$/; + const formatStation = (id) => { + if (!isIBNR.test(id)) { + throw new Error('station ID must be an IBNR.'); + } return { type: 'S', // station // todo: name necessary? diff --git a/format/stop-req.js b/format/stop-req.js index 11f3a5f4..6bcdad68 100644 --- a/format/stop-req.js +++ b/format/stop-req.js @@ -1,11 +1,5 @@ const formatStopReq = (ctx, stopRef) => { - return { - // todo: there's also `StationDetails`, are there differences? - meth: 'LocDetails', - req: { - locL: [stopRef], - }, - }; + // TODO }; export { diff --git a/index.js b/index.js index 7eadabd9..3d50e68e 100644 --- a/index.js +++ b/index.js @@ -223,7 +223,7 @@ const createClient = (profile, userAgent, opt = {}) => { if (opt.results !== null) { // TODO query.numF = opt.results; } - const req = profile.transformJourneysQuery({profile, opt}, query); + const req = profile.formatJourneysReq({profile, opt}, query); const {res} = await profile.request({profile, opt}, userAgent, req); const ctx = {profile, opt, common, res}; const verbindungen = opt.results ? res.verbindungen.slice(0, opt.results) : res.verbindungen; diff --git a/p/db/ageGroup.js b/lib/age-group.js similarity index 100% rename from p/db/ageGroup.js rename to lib/age-group.js diff --git a/lib/default-profile.js b/lib/default-profile.js index e64ef707..9bb5bf52 100644 --- a/lib/default-profile.js +++ b/lib/default-profile.js @@ -1,17 +1,15 @@ import {request} from '../lib/request.js'; +import {products} from '../lib/products.js'; +import {ageGroup, ageGroupFromAge, ageGroupLabel} from './age-group.js'; import {formatStationBoardReq} from '../format/station-board-req.js'; -import {formatLocationsReq} from '../format/locations-req.js'; import {formatStopReq} from '../format/stop-req.js'; import {formatTripReq} from '../format/trip-req.js'; -import {formatRefreshJourneyReq} from '../format/refresh-journey-req.js'; -import {formatRemarksReq} from '../format/remarks-req.js'; -import {formatLinesReq} from '../format/lines-req.js'; import {formatNearbyReq} from '../format/nearby-req.js'; import {parseDateTime} from '../parse/date-time.js'; import {parsePlatform} from '../parse/platform.js'; -import {parseBitmask as parseProductsBitmask} from '../parse/products-bitmask.js'; +import {parseProducts} from '../parse/products.js'; import {parseWhen} from '../parse/when.js'; import {parseDeparture} from '../parse/departure.js'; import {parseArrival} from '../parse/arrival.js'; @@ -24,53 +22,54 @@ import {parsePolyline} from '../parse/polyline.js'; import {parseOperator} from '../parse/operator.js'; import {parseRemarks} from '../parse/remarks.js'; import {parseStopover} from '../parse/stopover.js'; +import {parseLoadFactor, parseArrOrDepWithLoadFactor} from '../parse/load-factor.js'; +import {parseHintByCode} from '../parse/hints-by-code.js'; import {formatAddress} from '../format/address.js'; import {formatCoord} from '../format/coord.js'; import {formatDate} from '../format/date.js'; -import {formatLocationFilter} from '../format/location-filter.js'; import {formatProductsFilter} from '../format/products-filter.js'; import {formatPoi} from '../format/poi.js'; import {formatStation} from '../format/station.js'; import {formatTime} from '../format/time.js'; import {formatLocation} from '../format/location.js'; -import {formatRectangle} from '../format/rectangle.js'; -import * as filters from '../format/filters.js'; +import {formatLoyaltyCard} from '../format/loyalty-cards.js'; const DEBUG = (/(^|,)hafas-client(,|$)/).test(process.env.DEBUG || ''); const logRequest = DEBUG ? (_, req, reqId) => console.error(String(req.body)) - : () => {}; + : () => { }; const logResponse = DEBUG ? (_, res, body, reqId) => console.error(body) - : () => {}; + : () => { }; -const id = (ctx, x) => x; +const id = (_ctx, x) => x; +const notImplemented = (_ctx, _x) => { + throw new Error('NotImplemented'); +}; const defaultProfile = { request, + products, + ageGroup, ageGroupFromAge, ageGroupLabel, transformReqBody: id, transformReq: id, - salt: null, - addChecksum: false, - addMicMac: false, randomizeUserAgent: true, logRequest, logResponse, formatStationBoardReq, - formatLocationsReq, + formatLocationsReq: notImplemented, formatStopReq, formatTripReq, - formatRefreshJourneyReq, - formatRemarksReq, - formatLinesReq, formatNearbyReq, + formatJourneysReq: notImplemented, + formatRefreshJourneyReq: notImplemented, transformJourneysQuery: id, parseDateTime, parsePlatform, - parseProductsBitmask, + parseProducts, parseWhen, parseDeparture, parseArrival, @@ -78,42 +77,41 @@ const defaultProfile = { parseJourneyLeg, parseJourney, parseLine, - parseStationName: (_, name) => name, + parseStationName: id, parseLocation, parsePolyline, parseOperator, parseRemarks, parseStopover, + parseLoadFactor, + parseArrOrDepWithLoadFactor, + parseHintByCode, + parsePrice: notImplemented, + parseTickets: notImplemented, formatAddress, formatCoord, formatDate, - formatLocationFilter, - formatProductsFilter, + formatLocation, + formatLocationFilter: notImplemented, + formatLoyaltyCard, formatPoi, + formatProductsFilter, formatStation, formatTime, - formatLocation, - formatRectangle, - filters, + formatTravellers: notImplemented, + formatRectangle: id, - journeysOutFrwd: true, // `journeys()` method: support for `outFrwd` field? - // todo: https://github.com/KDE/kpublictransport/commit/c7c54304160d8f22eab0c91812a107aca82304b7 - - // `departures()` method: support for `getPasslist` field? + journeysOutFrwd: true, departuresGetPasslist: false, - // `departures()` method: support for `stbFltrEquiv` field? departuresStbFltrEquiv: false, - - trip: false, + trip: true, radar: false, refreshJourney: true, - // refreshJourney(): use `outReconL[]` instead of `ctxRecon`? refreshJourneyUseOutReconL: false, tripsByName: false, remarks: false, - // `remarks()` method: support for `getPolyline` field? - remarksGetPolyline: false, // `remarks()` method: support for `getPolyline` field? + remarksGetPolyline: false, lines: false, }; diff --git a/p/db/products.js b/lib/products.js similarity index 100% rename from p/db/products.js rename to lib/products.js diff --git a/lib/profile-hooks.js b/lib/profile-hooks.js deleted file mode 100644 index e06bf3f1..00000000 --- a/lib/profile-hooks.js +++ /dev/null @@ -1,23 +0,0 @@ -// For any type of "thing to parse", there's >=1 parse functions. -// By composing custom parse function(s) with the default ones, one -// can customize the behaviour of hafas-client. Profiles extensively -// use this mechanism. - -// Each parse function has the following signature: -// ({opt, profile, common, res}, ...raw) => newParsed - -// Compose a new/custom parse function with the old/existing parse -// function, so that the new fn will be called with the output of the -// old fn. -const parseHook = (oldParse, newParse) => { - return (ctx, ...args) => { - return newParse({ - ...ctx, - parsed: oldParse({...ctx, parsed: {}}, ...args), - }, ...args); - }; -}; - -export { - parseHook, -}; diff --git a/lib/slice-leg.js b/lib/slice-leg.js deleted file mode 100644 index 94e3f4f5..00000000 --- a/lib/slice-leg.js +++ /dev/null @@ -1,63 +0,0 @@ -const findById = (needle) => { - const needleStopId = needle.id; - const needleStationId = needle.station - ? needle.station.id - : null; - - return (stop) => { - if (needleStopId === stop.id) { - return true; - } - const stationId = stop.station - ? stop.station.id - : null; - if (needleStationId && stationId && needleStationId === stationId) { - return true; - } - // todo: `needleStationId === stop.id`? `needleStopId === stationId`? - return false; - }; -}; - -const sliceLeg = (leg, from, to) => { - if (!Array.isArray(leg.stopovers)) { - throw new Error('leg.stopovers must be an array.'); - } - - const stops = leg.stopovers.map(st => st.stop); - const fromI = stops.findIndex(findById(from)); - if (fromI === -1) { - throw new Error('from not found in stopovers'); - } - const fromStopover = leg.stopovers[fromI]; - - const toI = stops.findIndex(findById(to)); - if (toI === -1) { - throw new Error('to not found in stopovers'); - } - const toStopover = leg.stopovers[toI]; - - if (fromI === 0 && toI === leg.stopovers.length - 1) { - return leg; - } - const newLeg = Object.assign({}, leg); - newLeg.stopovers = leg.stopovers.slice(fromI, toI + 1); - - newLeg.origin = fromStopover.stop; - newLeg.departure = fromStopover.departure; - newLeg.departureDelay = fromStopover.departureDelay; - newLeg.scheduledDeparture = fromStopover.scheduledDeparture; - newLeg.departurePlatform = fromStopover.departurePlatform; - - newLeg.destination = toStopover.stop; - newLeg.arrival = toStopover.arrival; - newLeg.arrivalDelay = toStopover.arrivalDelay; - newLeg.scheduledArrival = toStopover.scheduledArrival; - newLeg.arrivalPlatform = toStopover.arrivalPlatform; - - return newLeg; -}; - -export { - sliceLeg, -}; diff --git a/lib/validate-profile.js b/lib/validate-profile.js index cc4e91c3..f027c820 100644 --- a/lib/validate-profile.js +++ b/lib/validate-profile.js @@ -11,6 +11,7 @@ const types = { formatStopReq: 'function', formatTripReq: 'function', formatRefreshJourneyReq: 'function', + formatJourneysReq: 'function', transformJourneysQuery: 'function', products: 'array', diff --git a/p/db/index.js b/p/db/index.js index 8af394cc..5125a790 100644 --- a/p/db/index.js +++ b/p/db/index.js @@ -1,540 +1,27 @@ -// todo: use import assertions once they're supported by Node.js & ESLint -// https://github.com/tc39/proposal-import-assertions import {createRequire} from 'module'; const require = createRequire(import.meta.url); -import uniqBy from 'lodash/uniqBy.js'; -import slugg from 'slugg'; -import without from 'lodash/without.js'; -import {parseHook} from '../../lib/profile-hooks.js'; - -import {parseJourney as _parseJourney} from '../../parse/journey.js'; -import {parseJourneyLeg as _parseJourneyLeg} from '../../parse/journey-leg.js'; -import {parseLine as _parseLine} from '../../parse/line.js'; -import {parseArrival as _parseArrival} from '../../parse/arrival.js'; -import {parseDeparture as _parseDeparture} from '../../parse/departure.js'; -import {parseLocation as _parseLocation} from '../../parse/location.js'; -import {formatStation as _formatStation} from '../../format/station.js'; -import {parseDateTime} from '../../parse/date-time.js'; - const baseProfile = require('./base.json'); -import {products} from './products.js'; -import {formatLoyaltyCard} from './loyalty-cards.js'; -import {ageGroup, ageGroupFromAge, ageGroupLabel} from './ageGroup.js'; - -const transformReqBody = (ctx, body) => { - return body; -}; - -const slices = (n, arr) => { - const initialState = {slices: [], count: Infinity}; - return arr.reduce(({slices, count}, item) => { - if (count >= n) { - slices.push([item]); - count = 1; - } else { - slices[slices.length - 1].push(item); - count++; - } - return {slices, count}; - }, initialState).slices; -}; - -const parseGrid = (g) => { - // todo: g.type, e.g. `S` - // todo: respect `g.itemL[].(col|row)`? - - // todo - // parseGrid is being called by parseLocWithDetails, which is being called as - // profile.parseLocation by profile.parseCommon, parseCommon hasn't finished - // resolving all references yet, so we have to resolve them manually here. - // This would be fixed if we resolve references on-the-fly or in a recursive/ - // iterative process. - return { - title: g.title, - rows: slices(g.nCols, g.itemL.map(item => Array.isArray(item.hints) && item.hints[0] - || Array.isArray(item.remarkRefs) && item.remarkRefs[0] && item.remarkRefs[0].hint - || {}, - )), - }; -}; - -const ausstattungKeys = Object.assign(Object.create(null), { - '3-s-zentrale': '3SZentrale', - 'parkplatze': 'parkingLots', - 'fahrrad-stellplatze': 'bicycleParkingRacks', - 'opnv-anbindung': 'localPublicTransport', - 'wc': 'toilets', - 'schliessfacher': 'lockers', - 'reisebedarf': 'travelShop', - 'stufenfreier-zugang': 'stepFreeAccess', - 'ein-umsteigehilfe': 'boardingAid', - 'taxi-am-bahnhof': 'taxis', -}); -const parseAusstattungVal = (val) => { - val = val.toLowerCase(); - return val === 'ja' - ? true - : val === 'nein' - ? false - : val; -}; - -const parseAusstattungGrid = (g) => { - // filter duplicate hint rows - const rows = uniqBy(g.rows, ([key, val]) => key + ':' + val); - - const res = {}; - Object.defineProperty(res, 'raw', {value: rows}); - for (let [key, val] of rows) { - key = ausstattungKeys[slugg(key)]; - if (key) { - res[key] = parseAusstattungVal(val); - } - } - return res; -}; - -const parseReisezentrumÖffnungszeiten = (g) => { - const res = {}; - for (const [dayOfWeek, val] of g.rows) { - res[dayOfWeek] = val; - } - res.raw = g.rows; - return res; -}; - -const parseLocWithDetails = ({parsed, common}, l) => { - if (!parsed) { - return parsed; - } - if (parsed.type !== 'stop' && parsed.type !== 'station') { - return parsed; - } - - if (Array.isArray(l.gridL)) { - const resolveCells = grid => ({ - ...grid, - rows: grid.rows.map(row => row.map(cell => cell && cell.text)), - }); - - let grids = l.gridL - .map(grid => parseGrid(grid, common)) - .map(resolveCells); - - const ausstattung = grids.find(g => slugg(g.title) === 'ausstattung'); - if (ausstattung) { - parsed.facilities = parseAusstattungGrid(ausstattung); - } - const öffnungszeiten = grids.find(g => slugg(g.title) === 'offnungszeiten-reisezentrum'); - if (öffnungszeiten) { - parsed.reisezentrumOpeningHours = parseReisezentrumÖffnungszeiten(öffnungszeiten); - } - - grids = without(grids, ausstattung, öffnungszeiten); - if (grids.length > 0) { - parsed.grids = grids; - } - } - - return parsed; -}; - -// https://www.bahn.de/p/view/service/buchung/auslastungsinformation.shtml -const loadFactors = []; -loadFactors[1] = 'low-to-medium'; -loadFactors[2] = 'high'; -loadFactors[3] = 'very-high'; -loadFactors[4] = 'exceptionally-high'; - -const parseLoadFactor = (opt, auslastung) => { - if (!auslastung) { - return null; - } - const cls = opt.firstClass - ? 'KLASSE_1' - : 'KLASSE_2'; - const load = auslastung.find(a => a.klasse === cls)?.stufe; - return load && loadFactors[load.r] || null; -}; - -const parseArrOrDepWithLoadFactor = ({parsed, res, opt}, d) => { - - /* const load = parseLoadFactor(opt, d); - if (load) { - parsed.loadFactor = load; - }*/ // TODO - return parsed; -}; - -const trfReq = (opt, refreshJourney) => { - if ('age' in opt && 'ageGroup' in opt) { - throw new TypeError(`\ -opt.age and opt.ageGroup are mutually exclusive. -Pass in just opt.age, and the age group will calculated automatically.`); - } - - const tvlrAgeGroup = 'age' in opt - ? ageGroupFromAge(opt.age) - : opt.ageGroup; - - const basicCtrfReq = { - klasse: opt.firstClass === true ? 'KLASSE_1' : 'KLASSE_2', - // todo [breaking]: support multiple travelers - reisende: [{ - typ: ageGroupLabel[tvlrAgeGroup || ageGroup.ADULT], - anzahl: 1, - alter: 'age' in opt - ? [String(opt.age)] - : [], - ermaessigungen: [formatLoyaltyCard(opt.loyaltyCard)], - }], - }; - return basicCtrfReq; -}; - -const transformJourneysQuery = ({profile, opt}, query) => { - query = Object.assign(query, trfReq(opt, false)); - return { - endpoint: profile.journeysEndpoint, - body: query, - method: 'post', - }; -}; - -const formatRefreshJourneyReq = (ctx, refreshToken) => { - const {profile, opt} = ctx; - let query = { - ctxRecon: refreshToken, - deutschlandTicketVorhanden: false, - nurDeutschlandTicketVerbindungen: false, - reservierungsKontingenteVorhanden: false, - }; - query = Object.assign(query, trfReq(opt, true)); - return { - endpoint: profile.refreshJourneysEndpoint, - body: query, - method: 'post', - }; -}; - -const parseLineWithAdditionalName = ({parsed}, l) => { - if (l.nameS && ['bus', 'tram', 'ferry'].includes(l.product)) { - parsed.name = l.nameS; - } - if (l.addName) { - parsed.additionalName = parsed.name; - parsed.name = l.addName; - } - return parsed; -}; - -const mutateToAddPrice = (parsed, raw) => { - parsed.price = null; - if (raw.angebotsPreis?.betrag) { - parsed.price = { - amount: raw.angebotsPreis.betrag, - currency: raw.angebotsPreis.waehrung, - hint: null, - }; - } - return parsed; -}; - -const mutateToAddTickets = (parsed, opt, j) => { - if (!opt.tickets) { - return; - } - if (j.reiseAngebote && j.reiseAngebote.length > 0) { // if refreshJourney() - parsed.tickets = j.reiseAngebote - .filter(s => s.typ == 'REISEANGEBOT' && !s.angebotsbeziehungList.flatMap(b => b.referenzen) - .find(r => r.referenzAngebotsoption == 'PFLICHT')) - .map((s) => { - const p = { - name: s.name, - priceObj: { - amount: Math.round(s.preis?.betrag * 100), - currency: s.preis?.waehrung, - }, - firstClass: s.klasse == 'KLASSE_1', - partialFare: s.teilpreis, - }; - if (s.teilpreis) { - p.addData = 'Teilpreis / partial fare'; - } - if (s.konditionsAnzeigen) { - p.addDataTicketInfo = s.konditionsAnzeigen?.map(a => a.anzeigeUeberschrift) - .join('. '); - p.addDataTicketDetails = s.konditionsAnzeigen?.map(a => a.textLang) - .join(' '); - } - if (s.leuchtturmInfo) { - p.addDataTravelInfo = s.leuchtturmInfo?.text; - } - return p; - }); - if (opt.generateUnreliableTicketUrls) { - // TODO - } - - } else if (j.angebotsPreis?.betrag) { // if journeys() - parsed.tickets = [{ - name: 'from', - priceObj: { - amount: Math.round(j.angebotsPreis.betrag * 100), - currency: j.angebotsPreis.waehrung, - }, - }]; - } -}; - -const parseJourneyWithPriceAndTickets = ({parsed, opt}, raw) => { - mutateToAddPrice(parsed, raw); - mutateToAddTickets(parsed, opt, raw); - return parsed; -}; - -const parseJourneyLegWithLoadFactor = ({parsed, res, opt}, raw) => { - const load = parseLoadFactor(opt, raw.auslastungsmeldungen); - if (load) { - parsed.loadFactor = load; - } - return parsed; -}; - -// todo: -// [ { type: 'hint', -// code: 'P5', -// text: 'Es gilt ein besonderer Fahrpreis' } -const hintsByCode = Object.assign(Object.create(null), { - fb: { - type: 'hint', - code: 'bicycle-conveyance', - summary: 'bicycles conveyed', - }, - fr: { - type: 'hint', - code: 'bicycle-conveyance-reservation', - summary: 'bicycles conveyed, subject to reservation', - }, - nf: { - type: 'hint', - code: 'no-bicycle-conveyance', - summary: 'bicycles not conveyed', - }, - k2: { - type: 'hint', - code: '2nd-class-only', - summary: '2. class only', - }, - eh: { - type: 'hint', - code: 'boarding-ramp', - summary: 'vehicle-mounted boarding ramp available', - }, - ro: { - type: 'hint', - code: 'wheelchairs-space', - summary: 'space for wheelchairs', - }, - oa: { - type: 'hint', - code: 'wheelchairs-space-reservation', - summary: 'space for wheelchairs, subject to reservation', - }, - wv: { - type: 'hint', - code: 'wifi', - summary: 'WiFi available', - }, - wi: { - type: 'hint', - code: 'wifi', - summary: 'WiFi available', - }, - sn: { - type: 'hint', - code: 'snacks', - summary: 'snacks available for purchase', - }, - mb: { - type: 'hint', - code: 'snacks', - summary: 'snacks available for purchase', - }, - mp: { - type: 'hint', - code: 'snacks', - summary: 'snacks available for purchase at the seat', - }, - bf: { - type: 'hint', - code: 'barrier-free', - summary: 'barrier-free', - }, - rg: { - type: 'hint', - code: 'barrier-free-vehicle', - summary: 'barrier-free vehicle', - }, - bt: { - type: 'hint', - code: 'on-board-bistro', - summary: 'Bordbistro available', - }, - br: { - type: 'hint', - code: 'on-board-restaurant', - summary: 'Bordrestaurant available', - }, - ki: { - type: 'hint', - code: 'childrens-area', - summary: 'children\'s area available', - }, - kk: { - type: 'hint', - code: 'parents-childrens-compartment', - summary: 'parent-and-children compartment available', - }, - kr: { - type: 'hint', - code: 'kids-service', - summary: 'DB Kids Service available', - }, - ls: { - type: 'hint', - code: 'power-sockets', - summary: 'power sockets available', - }, - ev: { - type: 'hint', - code: 'replacement-service', - summary: 'replacement service', - }, - kl: { - type: 'hint', - code: 'air-conditioned', - summary: 'air-conditioned vehicle', - }, - r0: { - type: 'hint', - code: 'upward-escalator', - summary: 'upward escalator', - }, - au: { - type: 'hint', - code: 'elevator', - summary: 'elevator available', - }, - ck: { - type: 'hint', - code: 'komfort-checkin', - summary: 'Komfort-Checkin available', - }, - it: { - type: 'hint', - code: 'ice-sprinter', - summary: 'ICE Sprinter service', - }, - rp: { - type: 'hint', - code: 'compulsory-reservation', - summary: 'compulsory seat reservation', - }, - rm: { - type: 'hint', - code: 'optional-reservation', - summary: 'optional seat reservation', - }, - scl: { - type: 'hint', - code: 'all-2nd-class-seats-reserved', - summary: 'all 2nd class seats reserved', - }, - acl: { - type: 'hint', - code: 'all-seats-reserved', - summary: 'all seats reserved', - }, - sk: { - type: 'hint', - code: 'oversize-luggage-forbidden', - summary: 'oversize luggage not allowed', - }, - hu: { - type: 'hint', - code: 'animals-forbidden', - summary: 'animals not allowed, except guide dogs', - }, - ik: { - type: 'hint', - code: 'baby-cot-required', - summary: 'baby cot/child seat required', - }, - ee: { - type: 'hint', - code: 'on-board-entertainment', - summary: 'on-board entertainment available', - }, - toilet: { - type: 'hint', - code: 'toilet', - summary: 'toilet available', - }, - oc: { - type: 'hint', - code: 'wheelchair-accessible-toilet', - summary: 'wheelchair-accessible toilet available', - }, - iz: { - type: 'hint', - code: 'intercity-2', - summary: 'Intercity 2', - }, -}); - -const parseHintByCode = (raw) => { - const hint = hintsByCode[raw.key.trim() - .toLowerCase()]; - if (hint) { - return Object.assign({text: raw.value}, hint); - } - return null; -}; - -const isIBNR = /^\d{6,}$/; -const formatStation = (id) => { - if (!isIBNR.test(id)) { - throw new Error('station ID must be an IBNR.'); - } - return _formatStation(id); -}; - -// todo: find option for absolute number of results +import {products} from '../../lib/products.js'; +import {formatJourneysReq, formatRefreshJourneyReq} from './journeys-req.js'; +import {formatLocationFilter} from './location-filter.js'; +import {formatLocationsReq} from './locations-req.js'; +import {formatTravellers} from './travellers.js'; +import {parseTickets, parsePrice} from './tickets.js'; const profile = { ...baseProfile, locale: 'de-DE', timezone: 'Europe/Berlin', - addChecksum: true, - transformReqBody, - transformJourneysQuery, + products, + formatJourneysReq, formatRefreshJourneyReq, - - products: products, - - parseLocation: parseHook(_parseLocation, parseLocWithDetails), - parseJourney: parseHook(_parseJourney, parseJourneyWithPriceAndTickets), - parseJourneyLeg: parseHook(_parseJourneyLeg, parseJourneyLegWithLoadFactor), - parseLine: parseHook(_parseLine, parseLineWithAdditionalName), - parseArrival: parseHook(_parseArrival, parseArrOrDepWithLoadFactor), - parseDeparture: parseHook(_parseDeparture, parseArrOrDepWithLoadFactor), - parseDateTime, - parseLoadFactor, - parseHintByCode, - formatStation, + formatLocationsReq, + formatLocationFilter, + parsePrice, + parseTickets, + formatTravellers, generateUnreliableTicketUrls: false, refreshJourneyUseOutReconL: true, @@ -542,7 +29,7 @@ const profile = { journeysFromTrip: true, radar: true, reachableFrom: true, - lines: false, // `.svcResL[0].res.lineL[]` is missing 🤔 + lines: false, }; export { diff --git a/p/db/journeys-req.js b/p/db/journeys-req.js new file mode 100644 index 00000000..959316c7 --- /dev/null +++ b/p/db/journeys-req.js @@ -0,0 +1,29 @@ +const formatJourneysReq = (ctx, query) => { + query = Object.assign(query, ctx.profile.formatTravellers(ctx)); + return { + endpoint: ctx.profile.journeysEndpoint, + body: query, + method: 'post', + }; +}; + +const formatRefreshJourneyReq = (ctx, refreshToken) => { + const {profile} = ctx; + let query = { + ctxRecon: refreshToken, + deutschlandTicketVorhanden: false, + nurDeutschlandTicketVerbindungen: false, + reservierungsKontingenteVorhanden: false, + }; + query = Object.assign(query, profile.formatTravellers(ctx)); + return { + endpoint: profile.refreshJourneysEndpoint, + body: query, + method: 'post', + }; +}; + +export { + formatJourneysReq, + formatRefreshJourneyReq, +}; diff --git a/format/location-filter.js b/p/db/location-filter.js similarity index 100% rename from format/location-filter.js rename to p/db/location-filter.js diff --git a/format/locations-req.js b/p/db/locations-req.js similarity index 100% rename from format/locations-req.js rename to p/db/locations-req.js diff --git a/p/db/tickets.js b/p/db/tickets.js new file mode 100644 index 00000000..36444e64 --- /dev/null +++ b/p/db/tickets.js @@ -0,0 +1,65 @@ + +const parsePrice = (ctx, raw) => { + if (raw.angebotsPreis?.betrag) { + return { + amount: raw.angebotsPreis.betrag, + currency: raw.angebotsPreis.waehrung, + hint: null, + }; + } + return undefined; +}; + +const parseTickets = (ctx, j) => { + if (!ctx.opt.tickets) { + return undefined; + } + let tickets = undefined; + if (j.reiseAngebote && j.reiseAngebote.length > 0) { // if refreshJourney() + tickets = j.reiseAngebote + .filter(s => s.typ == 'REISEANGEBOT' && !s.angebotsbeziehungList.flatMap(b => b.referenzen) + .find(r => r.referenzAngebotsoption == 'PFLICHT')) + .map((s) => { + const p = { + name: s.name, + priceObj: { + amount: Math.round(s.preis?.betrag * 100), + currency: s.preis?.waehrung, + }, + firstClass: s.klasse == 'KLASSE_1', + partialFare: s.teilpreis, + }; + if (s.teilpreis) { + p.addData = 'Teilpreis / partial fare'; + } + if (s.konditionsAnzeigen) { + p.addDataTicketInfo = s.konditionsAnzeigen?.map(a => a.anzeigeUeberschrift) + .join('. '); + p.addDataTicketDetails = s.konditionsAnzeigen?.map(a => a.textLang) + .join(' '); + } + if (s.leuchtturmInfo) { + p.addDataTravelInfo = s.leuchtturmInfo?.text; + } + return p; + }); + if (ctx.opt.generateUnreliableTicketUrls) { + // TODO + } + + } else if (j.angebotsPreis?.betrag) { // if journeys() + tickets = [{ + name: 'from', + priceObj: { + amount: Math.round(j.angebotsPreis.betrag * 100), + currency: j.angebotsPreis.waehrung, + }, + }]; + } + return tickets; +}; + +export { + parsePrice, + parseTickets, +}; diff --git a/p/db/travellers.js b/p/db/travellers.js new file mode 100644 index 00000000..2c572073 --- /dev/null +++ b/p/db/travellers.js @@ -0,0 +1,29 @@ +const formatTravellers = ({profile, opt}) => { + if ('age' in opt && 'ageGroup' in opt) { + throw new TypeError(`\ +opt.age and opt.ageGroup are mutually exclusive. +Pass in just opt.age, and the age group will calculated automatically.`); + } + + const tvlrAgeGroup = 'age' in opt + ? profile.ageGroupFromAge(opt.age) + : opt.ageGroup; + + const basicCtrfReq = { + klasse: opt.firstClass === true ? 'KLASSE_1' : 'KLASSE_2', + // todo [breaking]: support multiple travelers + reisende: [{ + typ: profile.ageGroupLabel[tvlrAgeGroup || profile.ageGroup.ADULT], + anzahl: 1, + alter: 'age' in opt + ? [String(opt.age)] + : [], + ermaessigungen: [profile.formatLoyaltyCard(opt.loyaltyCard)], + }], + }; + return basicCtrfReq; +}; + +export { + formatTravellers, +}; diff --git a/parse/arrival-or-departure.js b/parse/arrival-or-departure.js index 26ceaae1..8f972092 100644 --- a/parse/arrival-or-departure.js +++ b/parse/arrival-or-departure.js @@ -1,5 +1,3 @@ -import {parseRemarks} from './remarks.js'; - const ARRIVAL = 'a'; const DEPARTURE = 'd'; @@ -23,6 +21,7 @@ const createParseArrOrDep = (prefix) => { remarks: [], origin: profile.parseLocation(ctx, d.transport?.origin || d.origin) || null, destination: profile.parseLocation(ctx, d.transport?.destination || d.destination) || null, + // loadFactor: profile.parseArrOrDepWithLoadFactor(ctx, d) }; // TODO pos @@ -33,7 +32,7 @@ const createParseArrOrDep = (prefix) => { } if (opt.remarks) { - res.remarks = parseRemarks(ctx, d); + res.remarks = profile.parseRemarks(ctx, d); } // TODO opt.stopovers return res; diff --git a/parse/hints-by-code.js b/parse/hints-by-code.js new file mode 100644 index 00000000..9f4a2e40 --- /dev/null +++ b/parse/hints-by-code.js @@ -0,0 +1,206 @@ + +// todo: +// [ { type: 'hint', +// code: 'P5', +// text: 'Es gilt ein besonderer Fahrpreis' } +const hintsByCode = Object.assign(Object.create(null), { + fb: { + type: 'hint', + code: 'bicycle-conveyance', + summary: 'bicycles conveyed', + }, + fr: { + type: 'hint', + code: 'bicycle-conveyance-reservation', + summary: 'bicycles conveyed, subject to reservation', + }, + nf: { + type: 'hint', + code: 'no-bicycle-conveyance', + summary: 'bicycles not conveyed', + }, + k2: { + type: 'hint', + code: '2nd-class-only', + summary: '2. class only', + }, + eh: { + type: 'hint', + code: 'boarding-ramp', + summary: 'vehicle-mounted boarding ramp available', + }, + ro: { + type: 'hint', + code: 'wheelchairs-space', + summary: 'space for wheelchairs', + }, + oa: { + type: 'hint', + code: 'wheelchairs-space-reservation', + summary: 'space for wheelchairs, subject to reservation', + }, + wv: { + type: 'hint', + code: 'wifi', + summary: 'WiFi available', + }, + wi: { + type: 'hint', + code: 'wifi', + summary: 'WiFi available', + }, + sn: { + type: 'hint', + code: 'snacks', + summary: 'snacks available for purchase', + }, + mb: { + type: 'hint', + code: 'snacks', + summary: 'snacks available for purchase', + }, + mp: { + type: 'hint', + code: 'snacks', + summary: 'snacks available for purchase at the seat', + }, + bf: { + type: 'hint', + code: 'barrier-free', + summary: 'barrier-free', + }, + rg: { + type: 'hint', + code: 'barrier-free-vehicle', + summary: 'barrier-free vehicle', + }, + bt: { + type: 'hint', + code: 'on-board-bistro', + summary: 'Bordbistro available', + }, + br: { + type: 'hint', + code: 'on-board-restaurant', + summary: 'Bordrestaurant available', + }, + ki: { + type: 'hint', + code: 'childrens-area', + summary: 'children\'s area available', + }, + kk: { + type: 'hint', + code: 'parents-childrens-compartment', + summary: 'parent-and-children compartment available', + }, + kr: { + type: 'hint', + code: 'kids-service', + summary: 'DB Kids Service available', + }, + ls: { + type: 'hint', + code: 'power-sockets', + summary: 'power sockets available', + }, + ev: { + type: 'hint', + code: 'replacement-service', + summary: 'replacement service', + }, + kl: { + type: 'hint', + code: 'air-conditioned', + summary: 'air-conditioned vehicle', + }, + r0: { + type: 'hint', + code: 'upward-escalator', + summary: 'upward escalator', + }, + au: { + type: 'hint', + code: 'elevator', + summary: 'elevator available', + }, + ck: { + type: 'hint', + code: 'komfort-checkin', + summary: 'Komfort-Checkin available', + }, + it: { + type: 'hint', + code: 'ice-sprinter', + summary: 'ICE Sprinter service', + }, + rp: { + type: 'hint', + code: 'compulsory-reservation', + summary: 'compulsory seat reservation', + }, + rm: { + type: 'hint', + code: 'optional-reservation', + summary: 'optional seat reservation', + }, + scl: { + type: 'hint', + code: 'all-2nd-class-seats-reserved', + summary: 'all 2nd class seats reserved', + }, + acl: { + type: 'hint', + code: 'all-seats-reserved', + summary: 'all seats reserved', + }, + sk: { + type: 'hint', + code: 'oversize-luggage-forbidden', + summary: 'oversize luggage not allowed', + }, + hu: { + type: 'hint', + code: 'animals-forbidden', + summary: 'animals not allowed, except guide dogs', + }, + ik: { + type: 'hint', + code: 'baby-cot-required', + summary: 'baby cot/child seat required', + }, + ee: { + type: 'hint', + code: 'on-board-entertainment', + summary: 'on-board entertainment available', + }, + toilet: { + type: 'hint', + code: 'toilet', + summary: 'toilet available', + }, + oc: { + type: 'hint', + code: 'wheelchair-accessible-toilet', + summary: 'wheelchair-accessible toilet available', + }, + iz: { + type: 'hint', + code: 'intercity-2', + summary: 'Intercity 2', + }, +}); + +const parseHintByCode = (raw) => { + const hint = hintsByCode[raw.key.trim() + .toLowerCase()]; + if (hint) { + return Object.assign({text: raw.value}, hint); + } + return null; +}; + +export { + hintsByCode, + parseHintByCode, +}; diff --git a/parse/journey-leg.js b/parse/journey-leg.js index 4feea7d3..cfb5e1e2 100644 --- a/parse/journey-leg.js +++ b/parse/journey-leg.js @@ -96,6 +96,11 @@ const parseJourneyLeg = (ctx, pt, date, fallbackLocations) => { // pt = raw leg Object.defineProperty(res, 'canceled', {value: true}); } + const load = profile.parseLoadFactor(opt, pt.auslastungsmeldungen); + if (load) { + res.loadFactor = load; + } + return res; }; diff --git a/parse/journey.js b/parse/journey.js index 4c7e9161..98151f8b 100644 --- a/parse/journey.js +++ b/parse/journey.js @@ -40,6 +40,12 @@ const parseJourney = (ctx, j) => { // j = raw journey // res.scheduledDays = profile.parseScheduledDays(ctx, j.serviceDays); } + res.price = profile.parsePrice(ctx, j); + const tickets = profile.parseTickets(ctx, j); + if (tickets) { + res.tickets = tickets; + } + return res; }; diff --git a/parse/load-factor.js b/parse/load-factor.js new file mode 100644 index 00000000..582b4df5 --- /dev/null +++ b/parse/load-factor.js @@ -0,0 +1,31 @@ +// https://www.bahn.de/p/view/service/buchung/auslastungsinformation.shtml +const loadFactors = []; +loadFactors[1] = 'low-to-medium'; +loadFactors[2] = 'high'; +loadFactors[3] = 'very-high'; +loadFactors[4] = 'exceptionally-high'; + +const parseLoadFactor = (opt, auslastung) => { + if (!auslastung) { + return null; + } + const cls = opt.firstClass + ? 'KLASSE_1' + : 'KLASSE_2'; + const load = auslastung.find(a => a.klasse === cls)?.stufe; + return load && loadFactors[load.r] || null; +}; + +const parseArrOrDepWithLoadFactor = (ctx, d) => { + + /* const load = parseLoadFactor(opt, d); + if (load) { + parsed.loadFactor = load; + }*/ // TODO + return undefined; +}; + +export { + parseArrOrDepWithLoadFactor, + parseLoadFactor, +}; diff --git a/parse/location.js b/parse/location.js index 1732e687..ad88f8fe 100644 --- a/parse/location.js +++ b/parse/location.js @@ -40,7 +40,7 @@ const parseLocation = (ctx, l) => { // TODO subStops if ('products' in l) { - stop.products = profile.parseProductsBitmask(ctx, l.products); + stop.products = profile.parseProducts(ctx, l.products); } if (common && common.locations && common.locations[stop.id]) { diff --git a/parse/products-bitmask.js b/parse/products.js similarity index 71% rename from parse/products-bitmask.js rename to parse/products.js index 3370645f..1f198359 100644 --- a/parse/products-bitmask.js +++ b/parse/products.js @@ -1,4 +1,4 @@ -const parseBitmask = ({profile}, bitmask) => { +const parseProducts = ({profile}, bitmask) => { const res = {}; for (let product of profile.products) { res[product.id] = Boolean(bitmask.find(p => p == product.vendo)); @@ -7,5 +7,5 @@ const parseBitmask = ({profile}, bitmask) => { }; export { - parseBitmask, + parseProducts, }; diff --git a/test/format/db-journeys-query.js b/test/format/db-journeys-query.js index b18c4719..c9618163 100644 --- a/test/format/db-journeys-query.js +++ b/test/format/db-journeys-query.js @@ -2,7 +2,7 @@ import tap from 'tap'; import {createClient} from '../../index.js'; import {profile as rawProfile} from '../../p/db/index.js'; -import {data as loyaltyCards} from '../../p/db/loyalty-cards.js'; +import {data as loyaltyCards} from '../../format/loyalty-cards.js'; const client = createClient(rawProfile, 'public-transport/hafas-client:test'); const {profile} = client; @@ -69,7 +69,7 @@ tap.test('formats a journeys() request correctly (DB)', (t) => { // transformJourneysQuery() mutates its 2nd argument! const query = {...berlinWienQuery0}; - const req = profile.transformJourneysQuery(ctx, query); + const req = profile.formatJourneysReq(ctx, query); t.same(req.body, { ...berlinWienQuery0, @@ -96,7 +96,7 @@ tap.test('formats a journeys() request with BC correctly (DB)', (t) => { // transformJourneysQuery() mutates its 2nd argument! const query = {...berlinWienQuery0}; - const req = profile.transformJourneysQuery(ctx, query); + const req = profile.formatJourneysReq(ctx, query); t.same(req.body, { ...berlinWienQuery0, diff --git a/test/parse/location.js b/test/parse/location.js index 17b601a6..f2eb4fed 100644 --- a/test/parse/location.js +++ b/test/parse/location.js @@ -1,11 +1,11 @@ import tap from 'tap'; import {parseLocation as parse} from '../../parse/location.js'; -import {parseBitmask as parseProductsBitmask} from '../../parse/products-bitmask.js'; +import {parseProducts} from '../../parse/products.js'; const profile = { parseLocation: parse, parseStationName: (_, name) => name.toLowerCase(), - parseProductsBitmask, + parseProducts, products: [{ id: 'nationalExpress', vendo: 'ICE',