db-vendo-client/p/db/index.js

603 lines
14 KiB
JavaScript
Raw Normal View History

2022-05-07 16:17:37 +02:00
// 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 trim from 'lodash/trim.js';
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 {parseHint as _parseHint} from '../../parse/hint.js';
import {parseLocation as _parseLocation} from '../../parse/location.js';
import {formatStation as _formatStation} from '../../format/station.js';
import {bike} from '../../format/filters.js';
const baseProfile = require('./base.json');
import {products} from './products.js';
import {formatLoyaltyCard} from './loyalty-cards.js';
import {ageGroup, ageGroupFromAge} from './ageGroup.js';
import {routingModes} from './routing-modes.js';
2017-11-12 23:51:39 +01:00
2019-10-20 00:19:52 +02:00
const transformReqBody = (ctx, body) => {
const req = body.svcReqL[0] || {};
2019-11-18 18:30:50 +01:00
// see https://pastebin.com/qZ9WS3Cx
const rtMode = 'routingMode' in ctx.opt
? ctx.opt.routingMode
: routingModes.REALTIME;
req.cfg = {
...req.cfg,
rtMode,
};
2019-11-18 18:30:50 +01:00
return body;
};
2017-11-12 23:51:39 +01:00
const transformReq = (ctx, req) => {
const body = JSON.parse(req.body);
// stop() a.k.a. LocDetails seems broken with ver >1.16, all other methods work
if (body.svcReqL[0].meth === 'LocDetails') {
req.body = JSON.stringify({
...body,
ver: '1.16',
});
}
return req;
};
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`
2021-01-26 21:04:10 +01:00
// 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,
2021-01-26 21:04:10 +01:00
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, tcocL, tcocX) => {
const cls = opt.firstClass
? 'FIRST'
: 'SECOND';
const load = tcocX.map(i => tcocL[i])
.find(lf => lf.c === cls);
return load && loadFactors[load.r] || null;
};
2019-10-20 00:19:52 +02:00
const parseArrOrDepWithLoadFactor = ({parsed, res, opt}, d) => {
if (d.stbStop.dTrnCmpSX && Array.isArray(d.stbStop.dTrnCmpSX.tcocX)) {
const load = parseLoadFactor(opt, res.common.tcocL || [], d.stbStop.dTrnCmpSX.tcocX);
if (load) {
parsed.loadFactor = load;
}
}
return parsed;
};
2019-10-20 00:19:52 +02:00
const transformJourneysQuery = ({opt}, query) => {
const filters = query.jnyFltrL;
if (opt.bike) {
filters.push(bike);
}
2017-11-12 23:51:39 +01:00
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.`);
}
2022-04-23 20:32:05 +02:00
const tvlrAgeGroup = 'age' in opt
? ageGroupFromAge(opt.age)
: opt.ageGroup;
2022-04-23 20:32:05 +02:00
2017-11-12 23:51:39 +01:00
query.trfReq = {
// todo: what are these?
2023-05-15 12:58:40 +02:00
// "directESuiteCall": true,
// "rType": "DB-PE",
jnyCl: opt.firstClass === true
? 1
: 2,
2021-12-29 13:51:37 +01:00
// todo [breaking]: support multiple travelers
2017-11-12 23:51:39 +01:00
tvlrProf: [{
2022-04-23 20:32:05 +02:00
type: tvlrAgeGroup || ageGroup.ADULT,
...'age' in opt
? {age: opt.age}
: {},
2017-11-12 23:51:39 +01:00
redtnCard: opt.loyaltyCard
? formatLoyaltyCard(opt.loyaltyCard)
: null,
2017-11-12 23:51:39 +01:00
}],
cType: 'PK',
};
2017-11-12 23:51:39 +01:00
return query;
};
2017-11-12 23:51:39 +01:00
2021-10-26 14:15:17 +02:00
// todo: fix this
// line: {
// type: 'line',
// id: '5-vbbbvb-x9',
// fahrtNr: '52496',
// name: 'X9',
// public: true,
// mode: 'bus',
// product: 'bus',
// operator: {type: 'operator', id: 'nahreisezug', name: 'Nahreisezug'}
// }
2019-10-20 00:19:52 +02:00
const parseLineWithAdditionalName = ({parsed}, l) => {
if (l.nameS && ['bus', 'tram', 'ferry'].includes(l.product)) {
parsed.name = l.nameS;
2019-01-09 10:51:32 +01:00
}
2019-10-20 00:19:52 +02:00
if (l.addName) {
parsed.additionalName = parsed.name;
parsed.name = l.addName;
2019-10-20 00:19:52 +02:00
}
return parsed;
};
2019-01-09 10:51:32 +01:00
2019-10-20 00:19:52 +02:00
// todo: sotRating, conSubscr, isSotCon, showARSLink, sotCtxt
// todo: conSubscr, showARSLink, useableTime
const parseJourneyWithPrice = ({parsed}, raw) => {
parsed.price = null;
2019-10-20 00:19:52 +02:00
// todo: find cheapest, find discounts
// todo: write a parser like vbb-parse-ticket
2021-08-18 05:31:50 +02:00
// {
// "statusCode": "OK",
// "fareSetL": [
// {
// "fareL": [
// {
// "isFromPrice": true,
// "isPartPrice": false,
// "isBookable": true,
// "isUpsell": false,
// "targetCtx": "D",
// "buttonText": "To offer selection",
// "price": {
// "amount": 11400
// }
// }
// ]
// }
// ]
// }
2023-05-15 12:58:40 +02:00
// "fareSetL": [
// {
// "fareL": [
// {
// "isFromPrice": true,
// "isPartPrice": false,
// "isBookable": true,
// "isUpsell": false,
// "targetCtx": "D",
// "buttonText": "To offer selection",
// "price": {
// "amount": 13990
// },
// "retPriceIsCompletePrice": false,
// "retPrice": -1
// }
// ]
// }
// ]
2019-10-20 00:19:52 +02:00
if (
raw.trfRes
&& Array.isArray(raw.trfRes.fareSetL)
&& raw.trfRes.fareSetL[0]
&& Array.isArray(raw.trfRes.fareSetL[0].fareL)
&& raw.trfRes.fareSetL[0].fareL[0]
2019-10-20 00:19:52 +02:00
) {
const tariff = raw.trfRes.fareSetL[0].fareL[0];
2021-08-18 05:31:50 +02:00
if (tariff.price && tariff.price.amount >= 0) { // wat
2019-10-20 00:19:52 +02:00
parsed.price = {
2021-08-18 05:31:50 +02:00
amount: tariff.price.amount / 100,
2019-10-20 00:19:52 +02:00
currency: 'EUR',
hint: null,
};
2017-12-11 16:06:37 +01:00
}
}
return parsed;
};
2017-12-11 16:06:37 +01:00
2019-10-20 00:19:52 +02:00
const parseJourneyLegWithLoadFactor = ({parsed, res, opt}, raw) => {
const tcocX = raw.jny && raw.jny.dTrnCmpSX && raw.jny.dTrnCmpSX.tcocX;
if (Array.isArray(tcocX) && Array.isArray(res.common.tcocL)) {
const load = parseLoadFactor(opt, res.common.tcocL, tcocX);
if (load) {
parsed.loadFactor = load;
}
}
return parsed;
};
2019-08-30 16:27:26 +02:00
// 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 codesByText = Object.assign(Object.create(null), {
'journey cancelled': 'journey-cancelled', // todo: German variant
'stop cancelled': 'stop-cancelled', // todo: change to `stopover-cancelled`, German variant
'signal failure': 'signal-failure',
'signalstörung': 'signal-failure',
'additional stop': 'additional-stopover', // todo: German variant
'platform change': 'changed platform', // todo: use dash, German variant
});
2019-10-20 00:19:52 +02:00
const parseHintByCode = ({parsed}, raw) => {
// plain-text hints used e.g. for stop metadata
if (raw.type === 'K') {
return {type: 'hint', text: raw.txtN};
}
2019-10-20 00:19:52 +02:00
if (raw.type === 'A') {
const hint = hintsByCode[raw.code && raw.code.trim()
.toLowerCase()];
if (hint) {
return Object.assign({text: raw.txtN}, hint);
}
}
2019-10-20 00:19:52 +02:00
if (parsed && raw.txtN) {
const text = trim(raw.txtN.toLowerCase(), ' ()');
if (codesByText[text]) {
parsed.code = codesByText[text];
}
}
2019-10-20 00:19:52 +02:00
return parsed;
};
const isIBNR = /^\d{6,}$/;
2017-11-12 23:51:39 +01:00
const formatStation = (id) => {
if (!isIBNR.test(id)) {
throw new Error('station ID must be an IBNR.');
}
return _formatStation(id);
};
2017-11-12 23:51:39 +01:00
// todo: find option for absolute number of results
2022-05-07 16:17:37 +02:00
const profile = {
2021-01-14 20:31:31 +01:00
...baseProfile,
locale: 'de-DE',
2017-11-12 23:51:39 +01:00
timezone: 'Europe/Berlin',
addChecksum: true,
2017-11-12 23:51:39 +01:00
transformReqBody,
transformReq,
2017-11-12 23:51:39 +01:00
transformJourneysQuery,
products: products,
2017-12-12 03:28:54 +01:00
parseLocation: parseHook(_parseLocation, parseLocWithDetails),
2019-10-20 00:19:52 +02:00
parseJourney: parseHook(_parseJourney, parseJourneyWithPrice),
parseJourneyLeg: parseHook(_parseJourneyLeg, parseJourneyLegWithLoadFactor),
parseLine: parseHook(_parseLine, parseLineWithAdditionalName),
parseArrival: parseHook(_parseArrival, parseArrOrDepWithLoadFactor),
parseDeparture: parseHook(_parseDeparture, parseArrOrDepWithLoadFactor),
parseHint: parseHook(_parseHint, parseHintByCode),
2017-11-12 23:51:39 +01:00
formatStation,
refreshJourneyUseOutReconL: true,
trip: true,
2018-10-02 16:36:37 +02:00
journeysFromTrip: true,
2019-07-20 12:47:32 +02:00
radar: true,
2020-09-16 17:21:59 +02:00
reachableFrom: true,
lines: false, // `.svcResL[0].res.lineL[]` is missing 🤔
};
2017-11-12 23:51:39 +01:00
2022-05-07 16:17:37 +02:00
export {
profile,
};