mav-prices
Version:
Find the cheapest fares using the MAV API.
364 lines (334 loc) • 13.7 kB
JavaScript
import { randomUUID } from 'crypto';
import { parseJourney } from './parse.js';
const MINUTE_IN_MS = 60 * 1000;
const EIGHT_HOURS_IN_MINUTES = 8 * 60;
const addMinutes = (date, minutes) =>
new Date(date.getTime() + minutes * MINUTE_IN_MS);
// returns search results within 8 hours after departure time or before arrival time
const API_BASE = 'https://jegy-a.mav.hu/IK_API_PROD/api';
const OFFER_URL = `${API_BASE}/OfferRequestApi/GetOfferRequest`;
const EXCHANGE_RATE_URL = `${API_BASE}/BaseDataApi/GetExchangeRate?currencyKey=EUR`;
const defaults = {
class: 2,
seatReservation: false,
directConnection: false,
longerTransferTime: false, // >=10 minutes transfer time not guaranteed
isArrivalDate: false, // date parameter is departure date
duration: undefined, // use default 8 hour window
intermediateStations: [],
// one adult; use `age` (preferred) or `type` (@deprecated) — see docs
travellers: [{ age: 30, discounts: [] }],
};
// age brackets: [maxAge, apiKey] — first match where age < maxAge wins
const internationalAgeBrackets = [
[4, '109_000-004'], // child 0–3
[6, '11_004-006'], // child 4–5
[12, '30'], // child 6–11
[14, '11_012-014'], // child 12–13
[15, '107_014-015'], // youth 14
[16, '58'], // youth 15
[18, '59'], // teenager 16–17
[26, '107_18-26'], // young adult 18–25
[200, '44'], // adult 26+
];
const domesticAgeBrackets = [
[3, '110'], // child 0–2
[6, 'HU_109_003-006'], // child 3–5
[14, 'HU_31_006-014'], // child 6–13
[18, 'HU_107_014-018_20240301'], // youth 14–17
[25, 'HU_107_018-025'], // youth 18–24
[65, 'HU_44_025-065'], // adult 25–64
[200, 'HU_108_065-199'], // senior 65+
];
// @deprecated type maps — indexed by position in age brackets
const internationalPassengerTypes = Object.fromEntries(
internationalAgeBrackets.map(([, key], i) => [i, key]),
);
const domesticPassengerTypes = Object.fromEntries(
domesticAgeBrackets.map(([, key], i) => [i, key]),
);
const resolvePassengerType = (traveller, domestic) => {
if (traveller.age != null) {
const brackets = domestic ? domesticAgeBrackets : internationalAgeBrackets;
const match = brackets.find(([maxAge]) => traveller.age < maxAge);
if (!match) throw new Error(`Invalid passenger age: ${traveller.age}`);
return match[1];
}
// @deprecated: type IDs differ between domestic and international
const typeMap = domestic
? domesticPassengerTypes
: internationalPassengerTypes;
const key = typeMap[traveller.type];
if (!key)
throw new Error(
`Invalid passenger type: ${traveller.type} (use \`age\` instead of \`type\`; dog/bicycle are not supported)`,
);
return key;
};
const HUNGARIAN_PREFIX = '0055';
const inferDomestic = (from, to) =>
from.startsWith(HUNGARIAN_PREFIX) && to.startsWith(HUNGARIAN_PREFIX);
// unified discount map — each entry has an API key per mode (international/domestic)
// discounts only valid for one mode have null for the other;
// IDs 1–28 preserve the old international discount IDs for backwards compatibility
const discountCodes = {
// German
1: { international: 'BahnCard 25', domestic: null },
3: { international: 'BahnCard 50', domestic: null },
5: { international: 'BahnCard 100', domestic: null },
// Austrian
8: { international: 'VORTEILSCARD', domestic: null },
11: { international: 'KLIMATICKET', domestic: null },
12: { international: 'ÖSTERREICHCARD', domestic: null },
// Swiss
9: { international: 'Generalabonnement', domestic: null },
10: { international: 'Halbtaxabonnement', domestic: null },
13: { international: 'SwissPass 50%', domestic: null },
14: { international: 'SwissPass 100%', domestic: null },
// Czech/Slovak
15: { international: 'MAXI KLASIK', domestic: null },
16: { international: 'INKARTA 25', domestic: null },
17: { international: 'INKARTA 50', domestic: null },
18: { international: 'INKARTA 100', domestic: null },
// Hungarian
19: { international: 'START KLUB', domestic: 'HU_START_KLUB_50' },
20: { international: 'HU_START_KLUB_VIP', domestic: null },
21: { international: 'BERLET', domestic: null },
// Interrail
22: { international: 'Interrail/Eurail Pass (egyországos)', domestic: null },
// Companion
23: { international: 'KEREKESSZÉKES KÍSÉRŐJE', domestic: null },
24: { international: 'VAK KÍSÉRŐJE', domestic: null },
// Railway employee / FIP
25: { international: 'HU_VASUTAS', domestic: null },
26: { international: 'FIP_SZABADJEGY', domestic: null },
27: { international: 'FIP_EGYORSZAGOS_SZABADJEGY', domestic: null },
28: { international: 'FIP_IGAZOLVANY', domestic: 'HU_FIP_IGAZOLVANY_2O' },
// --- new IDs for domestic-only discounts (29+) ---
29: { international: null, domestic: 'HU_START_KLUB_UTITARS_20240301' },
30: { international: null, domestic: 'HU_ALKALMAZASBAN_ALLOK' },
31: { international: null, domestic: 'HU_FIP_IGAZOLVANY_1O' },
// Domestic passes
32: { international: null, domestic: 'HU_ORSZAGBERLET' },
33: { international: null, domestic: 'HU_MAGYARORSZAG24' },
34: { international: null, domestic: 'HU_BKK_BERLET_JEGY_HEVHEZ' },
// Domestic free-of-charge
35: { international: null, domestic: 'HU_NEMZETKOZI_BERLET_JEGY_2_OSZTALY' },
36: { international: null, domestic: 'HU_NEMZETKOZI_BERLET_JEGY_1_OSZTALY' },
37: { international: null, domestic: 'HU_NAGYCSALAD_TAGJA' },
38: { international: null, domestic: 'HU_FOGYATEKKAL_ELOK_KEDVEZMENYE' },
39: {
international: null,
domestic: 'HU_ELLATOTTAK_UTAZASI_UTALVANYA_JEGYHEZ',
},
40: { international: null, domestic: 'HU_MAGYAR_IGAZOLVANY' },
41: { international: null, domestic: 'HU_MENEKULTEK_IGAZOLASA' },
42: { international: null, domestic: 'HU_HADIROKKANT_CSALADTAG' },
// Domestic railway employee
43: { international: null, domestic: 'HU_MAV_START_VASUTI_UTAZASI_IG_2O' },
44: { international: null, domestic: 'HU_MAV_START_VASUTI_UTAZASI_IG_1O' },
45: {
international: null,
domestic: 'HU_MAV_START_VASUTI_UTAZASI_IG_CSALADTAG_2O',
},
46: {
international: null,
domestic: 'HU_MAV_START_VASUTI_UTAZASI_IG_CSALADTAG_1O',
},
47: { international: null, domestic: 'HU_GYSEV_VASUTI_UTAZASI_IG_2O' },
48: { international: null, domestic: 'HU_GYSEV_VASUTI_UTAZASI_IG_1O' },
49: {
international: null,
domestic: 'HU_GYSEV_VASUTI_UTAZASI_IG_CSALADTAG_2O',
},
50: {
international: null,
domestic: 'HU_GYSEV_VASUTI_UTAZASI_IG_CSALADTAG_1O',
},
51: { international: null, domestic: 'HU_OSZZSD_IGAZOLVANY_MAGAN' },
52: { international: null, domestic: 'HU_HEV_U_IGAZOLVÁNY' },
53: { international: null, domestic: 'HU_VOLAN_SZABADJEGY_ORSZAGOS' },
54: { international: null, domestic: 'HU_RENDOR_KESZENLETI_IGAZOLVANY' },
55: { international: null, domestic: 'HU_BKV MUNKAVÁLLALO_2OSZT' },
56: { international: null, domestic: 'HU_BKK MUNKAVÁLLALO_2OSZT' },
};
const buildPassengerList = (options) => {
const mode = options.domestic ? 'domestic' : 'international';
return options.travellers.map((traveller, index) => ({
passengerCount: 1,
passengerId: index,
customerTypeKey: resolvePassengerType(traveller, options.domestic),
customerDiscountsKeys: traveller.discounts
.filter(Boolean)
.map((discountId) => discountCodes[discountId]?.[mode])
.filter(Boolean),
}));
};
// service codes: [ticket, seat reservation] per class
// international: 1st=[49,61] 2nd=[50,62]
// domestic: 1st=[51,63] 2nd=[52,64]
const buildServiceList = (options) => {
const list = [];
if (options.domestic) {
list.push(options.class === 1 ? 51 : 52);
if (options.seatReservation) list.push(options.class === 1 ? 63 : 64);
} else {
list.push(options.class === 1 ? 49 : 50);
if (options.seatReservation) list.push(options.class === 1 ? 61 : 62);
}
return list;
};
const buildSearchServiceList = (options) => {
const list = [];
if (options.directConnection) list.push('ATSZALLAS_NELKUL');
if (options.seatReservation) list.push('HELYBIZTOSITASSAL');
if (options.longerTransferTime) list.push('MIN_ATSZALLASI_IDO');
return list;
};
const TIMEOUT_MS = 30_000;
const sendRequest = (body, sessionId, raw) =>
fetch(OFFER_URL, {
method: 'POST',
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: {
'Content-Type': 'application/json',
UserSessionId: sessionId,
Language: 'en',
},
body: JSON.stringify(body),
})
.then(async (response) => {
if (!response.ok) {
const responseBody = await response.text().catch(() => '');
const err = new Error(`${response.statusText}: ${responseBody}`);
err.statusCode = response.status;
throw err;
}
return response.json();
})
.then((data) => {
if (!Array.isArray(data?.route)) {
const err = new Error('Unexpected API response: missing route array');
err.responseData = data;
throw err;
}
return data.route
.map((route) => {
const journey = parseJourney(route);
if (raw) {
journey.raw = {
offerIdentity: route.offerIdentity,
serializedOfferData: route.serializedOfferData,
trainIds: route.details?.routes
?.map((r) => r.trainDetails?.trainId)
.filter(Boolean),
};
}
return journey;
})
.filter((journey) => journey.price.amount > 0);
});
// remove duplicates (same route, price)
const deduplicateJourneys = (journeys) => {
const seen = new Set();
return journeys.filter((journey) => {
const route = journey.legs.map((leg) => leg.origin.id).join(',');
const finalDestination = journey.legs.at(-1).destination.id;
const key = `${route},${finalDestination}|${journey.price.amount}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const buildRequestBody = (from, to, travelDate, options) => ({
offerkind: options.domestic ? '1' : '4',
startStationCode: from,
innerStationsCodes: options.intermediateStations,
endStationCode: to,
modalities: options.domestic ? [100, 200, 109] : [100],
// note: "passangers" is the actual API field name (their typo)
passangers: buildPassengerList(options),
isOneWayTicket: true,
isTravelEndTime: options.isArrivalDate, // false = departure time, true = arrival time
travelStartDate: travelDate.toISOString(),
selectedServices: buildServiceList(options),
selectedSearchServices: buildSearchServiceList(options),
isOfDetailedSearch: true,
});
const fetchExchangeRate = async (sessionId) => {
const res = await fetch(EXCHANGE_RATE_URL, {
method: 'POST',
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: {
'Content-Type': 'application/json',
UserSessionId: sessionId,
Language: 'en',
},
body: null,
});
if (!res.ok)
throw new Error(`Failed to fetch exchange rate: ${res.statusText}`);
return res.json(); // returns HUF per EUR, e.g. 380
};
const convertToEur = (journeys, hufPerEur) =>
journeys.map((journey) => ({
...journey,
price: {
...journey.price,
amount: Math.round((journey.price.amount / hufPerEur) * 100) / 100,
currency: 'EUR',
originalAmount: journey.price.amount,
originalCurrency: 'HUF',
},
}));
const querySingle = (from, to, travelDate, options, sessionId) =>
sendRequest(
buildRequestBody(from, to, travelDate, options),
sessionId,
options.raw,
);
const queryDuration = (from, to, travelDate, options, sessionId) => {
const requestCount = Math.ceil(options.duration / EIGHT_HOURS_IN_MINUTES);
const deadline = addMinutes(travelDate, options.duration).getTime();
// always treat travelDate as departure time when duration is given
const baseOptions = { ...options, isArrivalDate: false };
// send one request per 8-hour window to cover the full duration
const requests = Array.from({ length: requestCount }, (_, i) => {
const windowStart = addMinutes(travelDate, EIGHT_HOURS_IN_MINUTES * i);
return sendRequest(
buildRequestBody(from, to, windowStart, baseOptions),
sessionId,
options.raw,
);
});
return Promise.allSettled(requests).then((results) => {
const journeys = results
.filter((result) => result.status === 'fulfilled')
.flatMap((result) => result.value)
// filter out connections departing after travelDate + duration
.filter(
(journey) => new Date(journey.legs[0].departure).getTime() <= deadline,
);
return deduplicateJourneys(journeys);
});
};
/**
* Query railway connection prices from the MAV API.
* Supports both international and domestic Hungarian connections.
* @param {string} from - MAV station ID (e.g., "008099970")
* @param {string} to - MAV station ID
* @param {Date} [date] - Departure/arrival date (default: now)
* @param {object} [opt] - Override defaults (class, duration, travellers, etc.)
* @returns {Promise<object[]>} FPTF journey objects with price
*/
export const queryPrices = async (from, to, date, opt = {}) => {
const options = { ...defaults, ...opt, domestic: inferDomestic(from, to) };
const travelDate = date ?? new Date();
const sessionId = randomUUID();
const journeys = await (options.duration
? queryDuration(from, to, travelDate, options, sessionId)
: querySingle(from, to, travelDate, options, sessionId));
if (!options.domestic) return journeys;
const hufPerEur = await fetchExchangeRate(sessionId);
return convertToEur(journeys, hufPerEur);
};