diff --git a/cspell.config.json b/cspell.config.json index fb6e073f..2f5f88a0 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -634,7 +634,13 @@ "dbbahnhof", "Deutschlandticket", "fahrverguenstigungen", - "cancelation" + "cancelation", + "Verbund", + "Verbundticket", + "Verbundtickets", + "verbundticket", + "Vergangenheit", + "reisewunsch" ], "ignorePaths": [ "docs/dumps/**", 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..5c231a9e 100644 --- a/index.js +++ b/index.js @@ -110,7 +110,7 @@ const createClient = (profile, userAgent, opt = {}) => { const {res} = await profile.request({profile, opt}, userAgent, req); - const ctx = {profile, opt, common, res}; + const ctx = {profile, opt, common, res, userAgent}; let results = (res[resultsField] || res.items || res.bahnhofstafelAbfahrtPositionen || res.bahnhofstafelAnkunftPositionen || res.entries.flat()) .map(res => parse(ctx, res)); // TODO sort?, slice @@ -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) { @@ -208,13 +209,13 @@ const createClient = (profile, userAgent, opt = {}) => { const req = profile.formatJourneysReq({profile, opt}, from, to, when, outFrwd, journeysRef); const {res} = await profile.request({profile, opt}, userAgent, req); - const ctx = {profile, opt, common, res}; + const ctx = {profile, opt, common, res, userAgent}; if (opt.bestprice) { res.verbindungen = (res.intervalle || res.tagesbestPreisIntervalle).flatMap(i => i.verbindungen.map(v => ({...v, ...v.verbindung}))); } const verbindungen = Number.isInteger(opt.results) && opt.results != 3 ? res.verbindungen.slice(0, opt.results) : res.verbindungen; // TODO remove default from hafas-rest-api - const journeys = verbindungen - .map(j => profile.parseJourney(ctx, j)); + const journeys = await Promise.all(verbindungen + .map(j => profile.parseJourney(ctx, j))); if (opt.bestprice) { journeys.sort((a, b) => a.price?.amount - b.price?.amount); } @@ -245,15 +246,16 @@ 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); const {res} = await profile.request({profile, opt}, userAgent, req); - const ctx = {profile, opt, common, res}; + const ctx = {profile, opt, common, res, userAgent}; return { - journey: profile.parseJourney(ctx, res.verbindungen && res.verbindungen[0] || res), + journey: await profile.parseJourney(ctx, res.verbindungen && res.verbindungen[0] || res), realtimeDataUpdatedAt: null, // TODO }; }; @@ -278,7 +280,7 @@ const createClient = (profile, userAgent, opt = {}) => { const {res} = await profile.request({profile, opt}, userAgent, req); - const ctx = {profile, opt, common, res}; + const ctx = {profile, opt, common, res, userAgent}; const results = res.map(loc => profile.parseLocation(ctx, loc)); return Number.isInteger(opt.results) @@ -327,7 +329,7 @@ const createClient = (profile, userAgent, opt = {}) => { const req = profile.formatNearbyReq({profile, opt}, location); const {res} = await profile.request({profile, opt}, userAgent, req); - const ctx = {profile, opt, common, res}; + const ctx = {profile, opt, common, res, userAgent}; const results = res.map(loc => { const res = profile.parseLocation(ctx, loc); if (res.latitude || res.location?.latitude) { @@ -359,7 +361,7 @@ const createClient = (profile, userAgent, opt = {}) => { const req = profile.formatTripReq({profile, opt}, id); const {res} = await profile.request({profile, opt}, userAgent, req); - const ctx = {profile, opt, common, res}; + const ctx = {profile, opt, common, res, userAgent}; const trip = profile.parseTrip(ctx, res, id); diff --git a/lib/fetch-verbundticket-prices.js b/lib/fetch-verbundticket-prices.js new file mode 100644 index 00000000..ed5dcbf8 --- /dev/null +++ b/lib/fetch-verbundticket-prices.js @@ -0,0 +1,94 @@ +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, +}; diff --git a/p/dbnav/parse-journey.js b/p/dbnav/parse-journey.js index ddc46d29..bc98ca6a 100644 --- a/p/dbnav/parse-journey.js +++ b/p/dbnav/parse-journey.js @@ -1,6 +1,6 @@ import {parseJourney as parseJourneyDefault} from '../../parse/journey.js'; -const parseJourney = (ctx, jj) => { +const parseJourney = async (ctx, jj) => { const legs = (jj.verbindung || jj).verbindungsAbschnitte; if (legs.length > 0) { legs[0] = preprocessJourneyLeg(legs[0]); diff --git a/parse/journey.js b/parse/journey.js index 45c7aaa3..13393460 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 = { @@ -40,7 +41,7 @@ const trimJourneyId = (journeyId) => { return journeyId; }; -const parseJourney = (ctx, jj) => { // j = raw journey +const parseJourney = async (ctx, jj) => { // j = raw journey const {profile, opt} = ctx; const j = jj.verbindung || jj; const fallbackLocations = parseLocationsFromCtxRecon(ctx, j); @@ -80,10 +81,48 @@ 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 && ctx.userAgent && opt.tickets && opt.autoFetchVerbundtickets) { + // Fetch Verbundticket prices via recon API + const reconResult = await fetchVerbundticketPrices(ctx, 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..3ab00779 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.reisePosition || 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.reisePosition || 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; }; diff --git a/test/dbnav-refresh-journey.js b/test/dbnav-refresh-journey.js index 50b9f7df..7e9f990d 100644 --- a/test/dbnav-refresh-journey.js +++ b/test/dbnav-refresh-journey.js @@ -26,9 +26,9 @@ const opt = { products: {}, }; -tap.test('parses a refresh journey correctly (DB)', (t) => { - const ctx = {profile, opt, common: null, res}; - const journey = profile.parseJourney(ctx, res); +tap.test('parses a refresh journey correctly (DB)', async (t) => { + const ctx = {profile, opt, common: null, res, userAgent: 'test'}; + const journey = await profile.parseJourney(ctx, res); t.same(journey, expected.journey); t.end(); diff --git a/test/dbweb-journey.js b/test/dbweb-journey.js index 29d7a88b..c61ae9e2 100644 --- a/test/dbweb-journey.js +++ b/test/dbweb-journey.js @@ -26,9 +26,9 @@ const opt = { products: {}, }; -tap.test('parses a dbweb journey correctly', (t) => { // TODO DEVI leg +tap.test('parses a dbweb journey correctly', async (t) => { // TODO DEVI leg const ctx = {profile, opt, common: null, res}; - const journey = profile.parseJourney(ctx, res.verbindungen[0]); + const journey = await profile.parseJourney(ctx, res.verbindungen[0]); t.same(journey, expected); t.end(); diff --git a/test/dbweb-refresh-journey.js b/test/dbweb-refresh-journey.js index 0278f115..55e9470f 100644 --- a/test/dbweb-refresh-journey.js +++ b/test/dbweb-refresh-journey.js @@ -26,9 +26,9 @@ const opt = { products: {}, }; -tap.test('parses a refresh journey correctly (dbweb)', (t) => { +tap.test('parses a refresh journey correctly (dbweb)', async (t) => { const ctx = {profile, opt, common: null, res}; - const journey = profile.parseJourney(ctx, res.verbindungen[0]); + const journey = await profile.parseJourney(ctx, res.verbindungen[0]); t.same(journey, expected.journey); t.end(); diff --git a/test/parse/dbnav-journey.js b/test/parse/dbnav-journey.js index baedbd4d..844a11f2 100644 --- a/test/parse/dbnav-journey.js +++ b/test/parse/dbnav-journey.js @@ -469,8 +469,8 @@ const input = { ], } -tap.test('dbnav profile fixes time zone bug', (t) => { // see https://github.com/public-transport/db-vendo-client/issues/24 - const parsedJourney = parseJourneyDbnav(ctx, structuredClone(input)); +tap.test('dbnav profile fixes time zone bug', async (t) => { // see https://github.com/public-transport/db-vendo-client/issues/24 + const parsedJourney = await parseJourneyDbnav(ctx, structuredClone(input)); const expectedDeparture = '2025-03-06T14:52:00+01:00'; t.equal(parsedJourney.legs[0].departure, expectedDeparture) @@ -484,14 +484,14 @@ tap.test('dbnav profile fixes time zone bug', (t) => { // see https://github.com t.end(); }) -tap.test('dbnav profile parses journey without time zone bug like other profiles', (t) => { +tap.test('dbnav profile parses journey without time zone bug like other profiles', async (t) => { const _input = structuredClone(input); // fix bug by hand _input.verbindungsAbschnitte[0].ezAbgangsDatum = '2025-03-06T14:52:00+01:00'; _input.verbindungsAbschnitte[0].ezAnkunftsDatum = '2025-03-06T14:54:00+01:00'; - const expected = parseJourneyDefault(ctx, _input) + const expected = await parseJourneyDefault(ctx, _input) - t.same(parseJourneyDbnav(ctx, _input), expected); + t.same(await parseJourneyDbnav(ctx, _input), expected); t.end(); })