diff --git a/docs/journeys.md b/docs/journeys.md index cdd29d26..f6560994 100644 --- a/docs/journeys.md +++ b/docs/journeys.md @@ -82,6 +82,7 @@ With `opt`, you can override the default options, which look like this: loyaltyCard: null, // BahnCards etc., see below language: 'en', // language to get results in 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 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. \ No newline at end of file +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 +} +``` \ No newline at end of file diff --git a/index.js b/index.js index b26e9345..29a1d9b9 100644 --- a/index.js +++ b/index.js @@ -184,6 +184,7 @@ const createClient = (profile, userAgent, opt = {}) => { deutschlandTicketDiscount: false, deutschlandTicketConnectionsOnly: false, bmisNumber: null, // 7-digit BMIS number for business customer rates + autoFetchVerbundtickets: false, // automatically fetch Verbundticket prices via recon API }, opt); if (opt.when !== undefined) { @@ -245,6 +246,7 @@ const createClient = (profile, userAgent, opt = {}) => { deutschlandTicketDiscount: false, deutschlandTicketConnectionsOnly: false, bmisNumber: null, // 7-digit BMIS number for business customer rates + autoFetchVerbundtickets: false, // automatically fetch Verbundticket prices via recon API }, opt); const req = profile.formatRefreshJourneyReq({profile, opt}, refreshToken); diff --git a/lib/fetch-verbundticket-prices.js b/lib/fetch-verbundticket-prices.js new file mode 100644 index 00000000..a10c03e1 --- /dev/null +++ b/lib/fetch-verbundticket-prices.js @@ -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, +}; \ No newline at end of file diff --git a/parse/journey.js b/parse/journey.js index 45c7aaa3..71388878 100644 --- a/parse/journey.js +++ b/parse/journey.js @@ -1,4 +1,5 @@ import {parseRemarks} from './remarks.js'; +import {fetchVerbundticketPrices} from '../lib/fetch-verbundticket-prices.js'; const createFakeWalkingLeg = (prevLeg, leg) => { const fakeWalkingLeg = { @@ -80,10 +81,49 @@ const parseJourney = (ctx, jj) => { // j = raw journey })); } - res.price = profile.parsePrice(ctx, jj); - const tickets = profile.parseTickets(ctx, jj); - if (tickets) { - res.tickets = tickets; + // Check if this is a Verbundticket that needs price fetching + // DB Navigator mobile API: Verbundtickets have an 'angebote' section with 'verbundCode' but empty 'angebotsCluster' + const angebote = jj.angebote || j.angebote; + 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; diff --git a/parse/tickets.js b/parse/tickets.js index c56a19e5..3c233667 100644 --- a/parse/tickets.js +++ b/parse/tickets.js @@ -11,6 +11,39 @@ const parsePrice = (ctx, raw) => { 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; }; @@ -20,19 +53,43 @@ const parseTickets = (ctx, j) => { } let tickets = undefined; let price = parsePrice(ctx, j); - let ang = j.reiseAngebote - || j.angebote?.angebotsCluster?.flatMap(c => c.angebotsSubCluster + // Handle DB Navigator mobile API format + 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(p => [ - p.einfacheFahrt?.standard?.reisePosition, - p.einfacheFahrt?.upsellEntgelt?.einfacheFahrt?.reisePosition, - ].filter(p => p) - .map(p => { - p.reisePosition.teilpreis = Boolean(p.teilpreisInformationen?.length); - return p.reisePosition; - })), + .flatMap(p => { + // Extract all possible ticket types from DB Navigator format + const positions = []; + + // Handle verbundAngebot + if (p.verbundAngebot?.reisePosition?.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() tickets = ang .filter(s => s.typ == 'REISEANGEBOT' && !s.angebotsbeziehungList?.flatMap(b => b.referenzen) @@ -74,6 +131,61 @@ const parseTickets = (ctx, j) => { 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; };