import {attractionType, statusType, queueType, tagType, scheduleType, entityType} from '../parkTypes.js';
import moment from 'moment-timezone';
import Destination from '../destination.js';
// get directory of this script
import { fileURLToPath } from 'url';
import { dirname, join as pathJoin } from 'path';
import { promises as fs } from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Efteling Park Object
*/
export class Efteling extends Destination {
/**
* Create a new Efteling Park object
* @param {object} options
*/
constructor(options = {}) {
options.name = options.name || 'Efteling';
options.timezone = options.timezone || 'Europe/Amsterdam';
options.apiKey = options.apiKey || '';
options.apiVersion = options.apiVersion || '';
options.appVersion = options.appVersion || '';
options.searchUrl = options.searchUrl || 'https://prd-search-acs.efteling.com/2013-01-01/search';
options.waitTimesUrl = options.waitTimesUrl || 'https://api.efteling.com/app/wis/';
// bump cache to invalidate the POI data that has been updated
options.cacheVersion = 1;
super(options);
if (!this.config.apiKey) throw new Error('Missing Efteling apiKey');
if (!this.config.apiVersion) throw new Error('Missing Efteling apiVersion');
if (!this.config.appVersion) throw new Error('Missing Efteling appVersion');
this.http.injectForDomain({
// match either of the API domains
$or: [
{
hostname: 'api.efteling.com',
},
{
hostname: 'prd-search-acs.efteling.com',
},
{
hostname: 'cloud.efteling.com',
}
],
}, (method, url, data, options) => {
// all requests from the app to any efteling subdomain should send these headers
options.headers['x-app-version'] = this.config.appVersion;
options.headers['x-app-name'] = 'Efteling';
options.headers['x-app-id'] = 'nl.efteling.android';
options.headers['x-app-platform'] = 'Android';
options.headers['x-app-language'] = 'en';
options.headers['x-app-timezone'] = this.config.timezone;
// override user-agent here, rather than class-wide
// any other non-Efteling API requests can use the default user-agent
options.headers['user-agent'] = 'okhttp/4.12.0';
options.compressed = true;
});
this.http.injectForDomain({
// only use these headers for the main API domain
hostname: 'api.efteling.com',
}, (method, url, data, options) => {
// api.efteling.com requries an API key as well as the above headers
options.headers['x-api-key'] = this.config.apiKey;
options.headers['x-api-version'] = this.config.apiVersion;
});
}
/**
* Fetch POI data from Efteling API
* @return {array<object>}
*/
async _fetchPOIData({language = en} = {}) {
// cache for 12 hours
'@cache|720';
// build path to our JSON data
const jsonDataPath = pathJoin(__dirname, `poi-feed-${language}.json`);
// check if we have a local copy of the POI data
try {
const data = await fs.readFile(jsonDataPath, 'utf8');
const JSONdata = JSON.parse(data);
return JSONdata?.hits?.hit;
} catch (err) {
// return null if we can't find the file
return null;
}
}
/**
* Get Efteling POI data
* This data contains general ride names, descriptions etc.
* Wait time data references this to get ride names
*/
async getPOIData() {
'@cache|5';
// grab English data first
const data = await this._fetchPOIData({language: 'en'});
if (!data) {
throw new Error('Failed to fetch Efteling POI data [en]');
}
// also grab native language data and insert any missing entries
const nativeData = await this._fetchPOIData({language: 'nl'});
if (!nativeData) {
throw new Error('Failed to fetch Efteling POI data [nl]');
}
// merge the two arrays with English data replacing NL data
const mergedData = nativeData.map((nativeItem) => {
const englishItem = data.find((item) => item.fields.id === nativeItem.fields.id);
if (!englishItem) {
// if the native item is missing, add it to the end of the array
return nativeItem;
}
// if English version exists, use that one
return englishItem;
});
const poiData = {};
mergedData.forEach((hit) => {
// skip any entries that aren't shown in the app
if (hit.hide_in_app) return;
if (hit.fields) {
poiData[hit.fields.id] = {
id: hit.fields.id,
name: hit.fields.name,
type: hit.fields.category,
props: hit.fields.properties,
};
// hard-code station names so they can be distinct
if (hit.fields.id === 'stoomtreinr') {
poiData[hit.fields.id].name = poiData[hit.fields.id].name + ' - Oost';
}
if (hit.fields.id === 'stoomtreinm') {
poiData[hit.fields.id].name = poiData[hit.fields.id].name + ' - Marerijk';
}
// try to parse lat/long
// edge-case: some rides have dud "0.0,0.0" location, ignore these
if (hit.fields.latlon && hit.fields.latlon !== '0.0,0.0') {
const match = /([0-9.]+),([0-9.]+)/.exec(hit.fields.latlon);
if (match) {
poiData[hit.fields.id].location = {
latitude: Number(match[1]),
longitude: Number(match[2]),
};
}
}
// check for any alternative versions of the ride
// this is usually the single rider line, though one is a "boatride"
if (hit.fields.alternateid && hit.fields.alternatetype === 'singlerider') {
poiData[hit.fields.id].singleRiderId = hit.fields.alternateid;
}
}
});
return poiData;
}
/**
* Get calendar data for the given month and year
* @param {string} month
* @param {string} year
* @return {array<object>}
*/
async getCalendarMonth(month, year) {
return await this.cache.wrap(`calendar_${year}_${month}`, async () => {
const data = await this.http(
'GET',
`https://www.efteling.com/service/cached/getpoiinfo/en/${year}/${month}`,
null,
{
headers: {
'X-Requested-With': 'XMLHttpRequest',
'referer': 'https://www.efteling.com/en/park/opening-hours?app=true',
'cookie': 'website#lang=en',
},
json: true,
},
);
// Efteling returns 400 once the month is in the past
if (data.statusCode === 400) {
return undefined;
}
if (!data?.body?.OpeningHours) throw new Error(`Unable to find opening hours for Efteling ${data.body}`);
return data.body;
}, 1000 * 60 * 60 * 12); // 12 hours
}
/**
* Get restaurant operating hours from API
* @param {string} day
* @param {string} month
* @param {string} year
*/
async getRestaurantOperatingHours(day, month, year) {
return await this.cache.wrap(`restaurant_${year}_${month}_${day}`, async () => {
const waitTimes = await this.http('GET', this.config.waitTimesUrl, {
language: 'en',
});
if (!waitTimes?.body?.AttractionInfo) {
throw new Error(`Unable to find restaurant operating hours for Efteling ${data.body}`);
}
return waitTimes.body;
}, 1000 * 60 * 60 * 12); // 12 hours
}
/**
* Return restaurant operating hours for the supplied date
* @param {moment} date
*/
async _getRestaurantOperatingHoursForDate(date) {
const cal = await this.getRestaurantOperatingHours(date.format('D'), date.format('M'), date.format('YYYY'));
if (cal === undefined) return undefined;
const data = cal.AttractionInfo;
return data.map((entry) => {
if (entry.Type !== 'Horeca') return;
if (!entry.OpeningTimes || entry.OpeningTimes.length == 0) {
return {
restaurantID: entry.Id,
openingTime: 0,
closingTime: 0,
status: statusType.closed,
};
}
const openingTimes = entry.OpeningTimes;
return {
restaurantID: entry.Id,
openingTime: moment(openingTimes[0].HourFrom).format(),
closingTime: moment(openingTimes[0].HourTo).format(),
type: scheduleType.operating,
};
}).filter((x) => x !== undefined);
}
/**
* Helper function to build a basic entity document
* Useful to avoid copy/pasting
* @param {object} data
* @returns {object}
*/
buildBaseEntityObject(data) {
const entity = Destination.prototype.buildBaseEntityObject.call(this, data);
entity._id = data?.id || entity._id;
entity.name = data?.name || entity.name;
// add location (if found)
if (data?.location !== undefined) {
entity.location = {
longitude: data.location.longitude,
latitude: data.location.latitude,
};
}
// TODO - extra facet data
/*
// look for any other useful tags
// may get wet
await this.toggleAttractionTag(id, tagType.mayGetWet, p.props.indexOf('wet') >= 0);
// tag "pregnant people should not ride" attractions
await this.toggleAttractionTag(
id,
tagType.unsuitableForPregnantPeople,
p.props.indexOf('pregnantwomen') >= 0,
);
// single rider queue available?
await this.setAttractionTag(
id,
null,
tagType.singleRider,
!!p.singleRiderId,
);
// look for attraction minimum height
const minHeightProp = p.props.find((prop) => prop.indexOf('minimum') === 0);
if (minHeightProp !== undefined) {
const minHeightNumber = Number(minHeightProp.slice(7));
if (!isNaN(minHeightNumber)) {
await this.setAttractionTag(id, 'minimumHeight', tagType.minimumHeight, {
height: minHeightNumber,
unit: 'cm',
});
}
}*/
return entity;
}
/**
* Build the destination entity representing this destination
*/
async buildDestinationEntity() {
return {
...this.buildBaseEntityObject({
name: "Efteling Themepark Resort",
}),
_id: 'eftelingresort',
slug: 'eftelingresort',
entityType: entityType.destination,
location: {
latitude: 51.649515,
longitude: 5.043776
},
};
}
/**
* Build the park entities for this destination
*/
async buildParkEntities() {
const destination = await this.buildDestinationEntity();
return [
{
...this.buildBaseEntityObject({
name: this.config.name,
}),
_id: 'efteling',
_destinationId: destination._id,
_parentId: destination._id,
slug: 'efteling',
entityType: entityType.park,
location: {
latitude: 51.649515,
longitude: 5.043776
}
},
];
}
async _buildArrayOfEntitiesOfType(type, fields = {}) {
const destination = await this.buildDestinationEntity();
const poi = await this.getPOIData();
// some valid attraction types from the Efteling API:
// 'attraction', 'show', 'merchandise', 'restaurant', 'fairytale', 'facilities-toilets', 'facilities-generic', 'eventlocation', 'game'
const attrs = [];
const poiKeys = Object.keys(poi);
for (let i = 0; i < poiKeys.length; i++) {
const id = poiKeys[i];
const p = poi[id];
// if poi data matches our wanted types
if (p.type === type) {
const attr = {
...fields,
...this.buildBaseEntityObject(p),
_destinationId: destination._id,
// TODO - are all rides/shows inside the park?
_parkId: 'efteling',
_parentId: 'efteling',
};
attrs.push(attr);
}
}
return attrs;
}
/**
* Build the attraction entities for this destination
*/
async buildAttractionEntities() {
return this._buildArrayOfEntitiesOfType('attraction', {
entityType: entityType.attraction,
attractionType: attractionType.ride,
});
}
/**
* Build the show entities for this destination
*/
async buildShowEntities() {
return this._buildArrayOfEntitiesOfType('show', {
entityType: entityType.show,
});
}
/**
* Build the restaurant entities for this destination
*/
async buildRestaurantEntities() {
// TODO
return [];
}
async _fetchWaitTimes() {
// cache 1 minute
'@cache|1';
return (await this.http('GET', this.config.waitTimesUrl, {
language: 'en',
})).body;
}
/**
* @inheritdoc
*/
async buildEntityLiveData() {
const poiData = await this.getPOIData();
// this function should return all the live data for all entities in this destination
const waitTimes = await this._fetchWaitTimes();
const attractions = waitTimes?.AttractionInfo;
if (!attractions) throw new Error('Efteling wait times response missing AttractionInfo');
const livedata = [];
// first, look for single-rider entries
const singleRiderData = [];
for (let i = 0; i < attractions.length; i++) {
const entry = attractions[i];
if (poiData[entry.Id] === undefined) {
// if we don't have POI data for this attraction, check for single rider IDs and update the main attraction
const singleRiderPOI = Object.keys(poiData).find((k) => {
return poiData[k].singleRiderId && poiData[k].singleRiderId === entry.Id;
});
if (singleRiderPOI !== undefined) {
// we have found a matching single-rider entry!
singleRiderData.push({
id: singleRiderPOI,
time: parseInt(entry.WaitingTime, 10),
});
}
}
}
// helper function to create or get a live data entry
const createOrGetLiveData = (id) => {
// smush standby and virtual queue data together for droomvlucht
if (id === 'droomvluchtstandby') {
return createOrGetLiveData('droomvlucht');
}
const existing = livedata.find((x) => x._id === id);
if (existing) return existing;
const newEntry = {
_id: id,
status: null,
};
livedata.push(newEntry);
return newEntry;
};
const populateAttractionLiveData = (entry) => {
const live = createOrGetLiveData(entry.Id);
let rideStatus = null;
const rideWaitTime = parseInt(entry.WaitingTime, 10);
const rideState = entry.State.toLowerCase();
// update ride with wait time data
if (rideState === 'storing' || rideState === 'tijdelijkbuitenbedrijf') {
// Ride down because of an interruption
rideStatus = statusType.down;
} else if (rideState === 'buitenbedrijf') {
// ride is closed "for the day"
rideStatus = statusType.closed;
} else if (rideState === 'inonderhoud') {
// Ride down because of maintenance/refurbishment
rideStatus = statusType.refurbishment;
} else if (rideState === 'gesloten' || rideState === '' || rideState === 'wachtrijgesloten' || rideState === 'nognietopen') {
// ride is "closed"
rideStatus = statusType.closed;
} else if (rideState === 'open') {
// Ride operating
rideStatus = statusType.operating;
}
live.status = rideStatus || live.status;
if (live.status === null) {
this.emit('error', new Error(`Unknown Efteling rideStatus ${JSON.stringify(rideState)}`));
console.log('Unknown Efteling rideStatus', JSON.stringify(rideState));
}
live.queue = {
[queueType.standBy]: {
waitTime: rideStatus == statusType.operating ? (
isNaN(rideWaitTime) ? null : rideWaitTime
) : null,
},
};
// add any single rider data (if available)
const singleRider = singleRiderData.find((x) => x.id === entry.Id);
if (singleRider) {
live.queue[queueType.singleRider] = {
waitTime: rideStatus == statusType.operating ? (
isNaN(singleRider.time) ? null : singleRider.time
) : null,
};
}
};
const populateShowLiveData = (entry) => {
const live = createOrGetLiveData(entry.Id);
live.status = statusType.operating;
// if we have no upcoming showtimes, assume the show is closed
if (!entry.ShowTimes || entry.ShowTimes.length === 0) {
live.status = statusType.closed;
}
const allTimes = (entry.ShowTimes || []).concat(entry.PastShowTimes || []);
live.showtimes = allTimes.map((time) => {
const show = {
type: time.Edition || 'Showtime',
startTime: moment.tz(time.StartDateTime, 'YYYY-MM-DDTHH:mm:ssZ', this.config.timezone).format(),
endTime: moment.tz(time.EndDateTime, 'YYYY-MM-DDTHH:mm:ssZ', this.config.timezone).format(),
};
return show;
});
};
for (let i = 0; i < attractions.length; i++) {
const entry = attractions[i];
// some hack, skip entries that don't have POI data
if (entry.Id != 'droomvluchtstandby' && poiData[entry.Id] === undefined) continue;
// populate live data for attractions
if (entry.Type === 'Attraction' || entry.Type === 'Attracties') {
populateAttractionLiveData(entry);
}
// populate live data for shows
if (entry.Type === 'Shows en Entertainment') {
populateShowLiveData(entry);
}
}
return livedata;
}
/**
* Return schedule data for all scheduled entities in this destination
* Eg. parks
* @returns {array<object>}
*/
async buildEntityScheduleData() {
// get operating hours for next x months
const parkSchedule = [];
const now = this.getTimeNowMoment();
const monthsToFetch = 3;
const end = now.clone().add(monthsToFetch, 'months');
for (; now.isSameOrBefore(end, 'month'); now.add(1, 'month')) {
const calData = await this.getCalendarMonth(now.format('M'), now.format('YYYY'));
if (calData === undefined) continue;
calData.OpeningHours.forEach((x) => {
const date = moment.tz(x.Date, 'YYYY-MM-DD', this.config.timezone);
x.OpeningHours.sort((a, b) => a.Open - b.Open);
x.OpeningHours.forEach((d, idx) => {
const open = d.Open.split(':').map(Number);
const close = d.Close.split(':').map(Number);
parkSchedule.push({
date: date.format('YYYY-MM-DD'),
openingTime: date.clone().set('hour', open[0]).set('minute', open[1]).format(),
closingTime: date.clone().set('hour', close[0]).set('minute', close[1]).format(),
type: idx === 0 ? scheduleType.operating : scheduleType.informational,
description: idx === 0 ? undefined : 'Evening Hours',
});
});
});
}
return [
{
_id: 'efteling',
schedule: parkSchedule,
}
];
}
}
export default Efteling;