refactoring

This commit is contained in:
Traines 2024-12-21 15:26:49 +00:00
parent bc56d41fbe
commit ec723b3414
30 changed files with 436 additions and 769 deletions

View file

@ -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,
};

View file

@ -1,12 +0,0 @@
const formatLinesReq = (ctx, query) => {
return {
meth: 'LineMatch',
req: {
input: query,
},
};
};
export {
formatLinesReq,
};

View file

@ -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,
};

View file

@ -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,
};

View file

@ -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,
};

View file

@ -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?

View file

@ -1,11 +1,5 @@
const formatStopReq = (ctx, stopRef) => {
return {
// todo: there's also `StationDetails`, are there differences?
meth: 'LocDetails',
req: {
locL: [stopRef],
},
};
// TODO
};
export {

View file

@ -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;

View file

@ -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,
};

View file

@ -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,
};

View file

@ -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,
};

View file

@ -11,6 +11,7 @@ const types = {
formatStopReq: 'function',
formatTripReq: 'function',
formatRefreshJourneyReq: 'function',
formatJourneysReq: 'function',
transformJourneysQuery: 'function',
products: 'array',

View file

@ -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 {

29
p/db/journeys-req.js Normal file
View file

@ -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,
};

65
p/db/tickets.js Normal file
View file

@ -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,
};

29
p/db/travellers.js Normal file
View file

@ -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,
};

View file

@ -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;

206
parse/hints-by-code.js Normal file
View file

@ -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,
};

View file

@ -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;
};

View file

@ -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;
};

31
parse/load-factor.js Normal file
View file

@ -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,
};

View file

@ -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]) {

View file

@ -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,
};

View file

@ -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,

View file

@ -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',