Add automatic Verbundticket price fetching support

Introduces the `autoFetchVerbundtickets` option to automatically fetch Verbundticket prices via the recon API, updates documentation, and enhances journey and ticket parsing to handle Verbundticket price retrieval and parsing. Also adds a helper for manual price fetching and improves ticket extraction logic for DB Navigator mobile API responses.
This commit is contained in:
mariusangelmann 2025-07-29 23:30:05 +02:00
parent d15369406d
commit 5a0bfd954e
5 changed files with 292 additions and 15 deletions

View file

@ -82,6 +82,7 @@ With `opt`, you can override the default options, which look like this:
loyaltyCard: null, // BahnCards etc., see below loyaltyCard: null, // BahnCards etc., see below
language: 'en', // language to get results in language: 'en', // language to get results in
bmisNumber: null, // 7-digit BMIS number for business customer rates bmisNumber: null, // 7-digit BMIS number for business customer rates
autoFetchVerbundtickets: false, // automatically fetch Verbundticket prices via recon API (see below)
} }
``` ```
@ -340,4 +341,32 @@ When a BMIS number is provided, the request will include a `firmenZugehoerigkeit
## The `routingMode` option ## The `routingMode` option
The `routingMode` option is not supported by db-vendo-client. The behavior will be the same as the [`HYBRID` mode of hafas-client](https://github.com/public-transport/hafas-client/blob/main/p/db/readme.md#using-the-routingmode-option), i.e. cancelled trains/infeasible journeys will also be contained for informational purpose. The `routingMode` option is not supported by db-vendo-client. The behavior will be the same as the [`HYBRID` mode of hafas-client](https://github.com/public-transport/hafas-client/blob/main/p/db/readme.md#using-the-routingmode-option), i.e. cancelled trains/infeasible journeys will also be contained for informational purpose.
## Verbundtickets
Verbundtickets (local transport network tickets) require a two-step API process to fetch prices. By default, journeys with Verbundtickets will not include ticket prices. To automatically fetch these prices, use the `autoFetchVerbundtickets` option:
```js
const journeys = await client.journeys(from, to, {
tickets: true,
autoFetchVerbundtickets: true // enable automatic Verbundticket price fetching
})
```
**Note:** This option will make an additional API request for each journey that contains Verbundtickets, which may impact performance. Use it only when you need ticket prices for local transport networks.
### Manual Verbundticket price fetching
If you prefer to fetch Verbundticket prices manually (or if automatic detection doesn't work), you can use the `refreshJourney` method:
```js
const journeys = await client.journeys(from, to, { tickets: true })
const journey = journeys.journeys[0]
// Check if journey has empty ticket prices but a refresh token
if (!journey.price && journey.refreshToken) {
const refreshed = await client.refreshJourney(journey.refreshToken, { tickets: true })
// refreshed.journey will have the Verbundticket prices
}
```

View file

@ -184,6 +184,7 @@ const createClient = (profile, userAgent, opt = {}) => {
deutschlandTicketDiscount: false, deutschlandTicketDiscount: false,
deutschlandTicketConnectionsOnly: false, deutschlandTicketConnectionsOnly: false,
bmisNumber: null, // 7-digit BMIS number for business customer rates bmisNumber: null, // 7-digit BMIS number for business customer rates
autoFetchVerbundtickets: false, // automatically fetch Verbundticket prices via recon API
}, opt); }, opt);
if (opt.when !== undefined) { if (opt.when !== undefined) {
@ -245,6 +246,7 @@ const createClient = (profile, userAgent, opt = {}) => {
deutschlandTicketDiscount: false, deutschlandTicketDiscount: false,
deutschlandTicketConnectionsOnly: false, deutschlandTicketConnectionsOnly: false,
bmisNumber: null, // 7-digit BMIS number for business customer rates bmisNumber: null, // 7-digit BMIS number for business customer rates
autoFetchVerbundtickets: false, // automatically fetch Verbundticket prices via recon API
}, opt); }, opt);
const req = profile.formatRefreshJourneyReq({profile, opt}, refreshToken); const req = profile.formatRefreshJourneyReq({profile, opt}, refreshToken);

View file

@ -0,0 +1,94 @@
import {formatBaseJourneysReq} from '../p/dbnav/journeys-req.js';
import {getHeaders} from '../p/dbnav/header.js';
// Helper to fetch Verbundticket prices via recon API
const fetchVerbundticketPrices = async (ctx, userAgent, journey) => {
const {profile, opt} = ctx;
// Verbundtickets require a kontext token to fetch prices
const kontext = journey.kontext || journey.ctxRecon;
if (!kontext) {
return null;
}
// Extract journey details
const firstLeg = journey.verbindungsAbschnitte?.[0];
const lastLeg = journey.verbindungsAbschnitte?.[journey.verbindungsAbschnitte.length - 1];
const abgangsOrt = firstLeg?.abgangsOrt;
const ankunftsOrt = lastLeg?.ankunftsOrt;
const abgangsDatum = firstLeg?.abgangsDatum;
// Build recon request exactly matching DB Navigator mobile API format
const reconRequest = {
endpoint: profile.refreshJourneysEndpointTickets || 'https://app.vendo.noncd.db.de/mob/angebote/recon',
method: 'post',
headers: getHeaders('application/x.db.vendo.mob.verbindungssuche.v9+json'),
body: {
fahrverguenstigungen: {
nurDeutschlandTicketVerbindungen: ctx.opt.deutschlandTicketConnectionsOnly || false,
deutschlandTicketVorhanden: ctx.opt.deutschlandTicketDiscount || false
},
reservierungsKontingenteVorhanden: false,
suchParameter: {
reisewunschHin: {
economic: false,
abgangsLocationId: abgangsOrt?.locationId || extractLocationFromKontext(kontext, 'from'),
viaLocations: [],
fahrradmitnahme: false,
zielLocationId: ankunftsOrt?.locationId || extractLocationFromKontext(kontext, 'to'),
zeitWunsch: {
reiseDatum: abgangsDatum || new Date().toISOString(),
zeitPunktArt: 'ABFAHRT'
},
verkehrsmittel: ['ALL']
}
},
einstiegsTypList: ['STANDARD'],
klasse: 'KLASSE_2',
verbindungHin: {
kontext: kontext
},
reisendenProfil: {
reisende: [{
reisendenTyp: 'ERWACHSENER',
ermaessigungen: ['KEINE_ERMAESSIGUNG KLASSENLOS']
}]
}
}
};
// Add business customer affiliation if BMIS number is provided
if (ctx.opt.bmisNumber) {
reconRequest.body.firmenZugehoerigkeit = {
bmisNr: ctx.opt.bmisNumber,
identifikationsart: 'BMIS'
};
}
try {
const {res} = await profile.request(ctx, userAgent, reconRequest);
return res;
} catch (err) {
// Log error but don't fail the entire journey request
if (opt.debug || profile.DEBUG) {
console.error('Failed to fetch Verbundticket prices:', err);
}
return null;
}
};
// Helper to extract location from kontext string
const extractLocationFromKontext = (kontext, type) => {
// Kontext format: "¶HKI¶T$A=1@O=From@...@$A=1@O=To@...@$..."
const parts = kontext.split('$');
if (type === 'from' && parts[1]) {
return parts[1];
} else if (type === 'to' && parts[2]) {
return parts[2];
}
return null;
};
export {
fetchVerbundticketPrices,
};

View file

@ -1,4 +1,5 @@
import {parseRemarks} from './remarks.js'; import {parseRemarks} from './remarks.js';
import {fetchVerbundticketPrices} from '../lib/fetch-verbundticket-prices.js';
const createFakeWalkingLeg = (prevLeg, leg) => { const createFakeWalkingLeg = (prevLeg, leg) => {
const fakeWalkingLeg = { const fakeWalkingLeg = {
@ -80,10 +81,49 @@ const parseJourney = (ctx, jj) => { // j = raw journey
})); }));
} }
res.price = profile.parsePrice(ctx, jj); // Check if this is a Verbundticket that needs price fetching
const tickets = profile.parseTickets(ctx, jj); // DB Navigator mobile API: Verbundtickets have an 'angebote' section with 'verbundCode' but empty 'angebotsCluster'
if (tickets) { const angebote = jj.angebote || j.angebote;
res.tickets = tickets; const hasVerbundCode = angebote?.verbundCode;
const hasEmptyAngebotsCluster = !angebote?.angebotsCluster || angebote.angebotsCluster.length === 0;
const hasKontext = j.kontext || j.ctxRecon;
// Also check for the warning message that prices need to be fetched
const hasVerbundWarning = angebote?.angebotsMeldungen?.some(msg =>
msg.includes('Verbindung liegt in der Vergangenheit') ||
msg.includes('Preis') ||
msg.includes('Verbund')
);
if ((hasVerbundCode && hasEmptyAngebotsCluster || hasVerbundWarning) && hasKontext && userAgent && opt.tickets && opt.autoFetchVerbundtickets) {
// Fetch Verbundticket prices via recon API
const reconResult = await fetchVerbundticketPrices(ctx, userAgent, j);
if (reconResult) {
// Use the recon result for price and ticket parsing
if (reconResult.angebote) {
// Store the fetched angebote data in the original response for parsing
jj.angebote = reconResult.angebote;
}
res.price = profile.parsePrice(ctx, jj);
const tickets = profile.parseTickets(ctx, jj);
if (tickets) {
res.tickets = tickets;
}
} else {
// Fallback to original parsing
res.price = profile.parsePrice(ctx, jj);
const tickets = profile.parseTickets(ctx, jj);
if (tickets) {
res.tickets = tickets;
}
}
} else {
// Regular journey parsing
res.price = profile.parsePrice(ctx, jj);
const tickets = profile.parseTickets(ctx, jj);
if (tickets) {
res.tickets = tickets;
}
} }
return res; return res;

View file

@ -11,6 +11,39 @@ const parsePrice = (ctx, raw) => {
partialFare: partialFare, partialFare: partialFare,
}; };
} }
// For Verbundtickets, try to get the lowest price from reiseAngebote
if (raw.reiseAngebote && raw.reiseAngebote.length > 0) {
let lowestPrice = null;
for (const angebot of raw.reiseAngebote) {
const fahrtAngebote = angebot.hinfahrt?.fahrtAngebote;
if (fahrtAngebote && fahrtAngebote.length > 0) {
for (const fahrt of fahrtAngebote) {
if (fahrt.preis?.betrag && (!lowestPrice || fahrt.preis.betrag < lowestPrice.amount)) {
lowestPrice = {
amount: fahrt.preis.betrag,
currency: fahrt.preis.waehrung,
hint: null,
partialFare: fahrt.teilpreis || false,
};
}
}
}
// Also check direct price on reiseAngebot
if (angebot.preis?.betrag && (!lowestPrice || angebot.preis.betrag < lowestPrice.amount)) {
lowestPrice = {
amount: angebot.preis.betrag,
currency: angebot.preis.waehrung,
hint: null,
partialFare: angebot.teilpreis || false,
};
}
}
if (lowestPrice) {
return lowestPrice;
}
}
return undefined; return undefined;
}; };
@ -20,19 +53,43 @@ const parseTickets = (ctx, j) => {
} }
let tickets = undefined; let tickets = undefined;
let price = parsePrice(ctx, j); let price = parsePrice(ctx, j);
let ang = j.reiseAngebote // Handle DB Navigator mobile API format
|| j.angebote?.angebotsCluster?.flatMap(c => c.angebotsSubCluster let ang = j.reiseAngebote;
// If no reiseAngebote, check for angebote.angebotsCluster (DB Navigator mobile format)
if (!ang && j.angebote?.angebotsCluster) {
ang = j.angebote.angebotsCluster.flatMap(c => c.angebotsSubCluster
.flatMap(c => c.angebotsPositionen .flatMap(c => c.angebotsPositionen
.flatMap(p => [ .flatMap(p => {
p.einfacheFahrt?.standard?.reisePosition, // Extract all possible ticket types from DB Navigator format
p.einfacheFahrt?.upsellEntgelt?.einfacheFahrt?.reisePosition, const positions = [];
].filter(p => p)
.map(p => { // Handle verbundAngebot
p.reisePosition.teilpreis = Boolean(p.teilpreisInformationen?.length); if (p.verbundAngebot?.reisePosition?.reisePosition) {
return p.reisePosition; const rp = p.verbundAngebot.reisePosition.reisePosition;
})), rp.teilpreis = Boolean(p.verbundAngebot.reisePosition.teilpreisInformationen?.length);
positions.push(rp);
}
// Handle regular einfacheFahrt
if (p.einfacheFahrt?.standard?.reisePosition) {
const rp = p.einfacheFahrt.standard.reisePosition;
rp.teilpreis = Boolean(p.einfacheFahrt.standard.teilpreisInformationen?.length);
positions.push(rp);
}
// Handle upsell
if (p.einfacheFahrt?.upsellEntgelt?.einfacheFahrt?.reisePosition) {
const rp = p.einfacheFahrt.upsellEntgelt.einfacheFahrt.reisePosition;
rp.teilpreis = Boolean(p.einfacheFahrt.upsellEntgelt.einfacheFahrt.teilpreisInformationen?.length);
positions.push(rp);
}
return positions;
}),
), ),
); );
}
if (ang && ang.length > 0) { // if refreshJourney() if (ang && ang.length > 0) { // if refreshJourney()
tickets = ang tickets = ang
.filter(s => s.typ == 'REISEANGEBOT' && !s.angebotsbeziehungList?.flatMap(b => b.referenzen) .filter(s => s.typ == 'REISEANGEBOT' && !s.angebotsbeziehungList?.flatMap(b => b.referenzen)
@ -74,6 +131,61 @@ const parseTickets = (ctx, j) => {
currency: price.currency, currency: price.currency,
}, },
}]; }];
} else if (j.reiseAngebote && j.reiseAngebote.length > 0) {
// Handle Verbundtickets in initial journey response
tickets = [];
for (const angebot of j.reiseAngebote) {
// Extract tickets from fahrtAngebote
const fahrtAngebote = angebot.hinfahrt?.fahrtAngebote;
if (fahrtAngebote && fahrtAngebote.length > 0) {
for (const fahrt of fahrtAngebote) {
if (fahrt.preis?.betrag && fahrt.name) {
const ticket = {
name: fahrt.name,
priceObj: {
amount: Math.round(fahrt.preis.betrag * 100),
currency: fahrt.preis.waehrung,
},
firstClass: fahrt.klasse === 'KLASSE_1',
partialFare: fahrt.teilpreis || false,
};
// Add additional info if available
const conds = fahrt.konditionsAnzeigen || fahrt.konditionen;
if (conds) {
ticket.addDataTicketInfo = conds.map(a => a.anzeigeUeberschrift || a.bezeichnung)
.join('. ');
ticket.addDataTicketDetails = conds.map(a => a.textLang || a.details)
.join(' ');
}
tickets.push(ticket);
}
}
}
// Also check if reiseAngebot has direct price/name
if (angebot.preis?.betrag && angebot.name) {
const ticket = {
name: angebot.name,
priceObj: {
amount: Math.round(angebot.preis.betrag * 100),
currency: angebot.preis.waehrung,
},
firstClass: angebot.klasse === 'KLASSE_1',
partialFare: angebot.teilpreis || false,
};
const conds = angebot.konditionsAnzeigen || angebot.konditionen;
if (conds) {
ticket.addDataTicketInfo = conds.map(a => a.anzeigeUeberschrift || a.bezeichnung)
.join('. ');
ticket.addDataTicketDetails = conds.map(a => a.textLang || a.details)
.join(' ');
}
tickets.push(ticket);
}
}
// Sort tickets by price (lowest first)
if (tickets.length > 0) {
tickets.sort((a, b) => a.priceObj.amount - b.priceObj.amount);
}
} }
return tickets; return tickets;
}; };