parks/efteling/efteling.js

  1. import {attractionType, statusType, queueType, tagType, scheduleType, entityType, returnTimeState} from '../parkTypes.js';
  2. import moment from 'moment-timezone';
  3. import Destination from '../destination.js';
  4. // get directory of this script
  5. import {fileURLToPath} from 'url';
  6. import {dirname, join as pathJoin} from 'path';
  7. import {promises as fs} from 'fs';
  8. const __filename = fileURLToPath(import.meta.url);
  9. const __dirname = dirname(__filename);
  10. /**
  11. * Efteling Park Object
  12. */
  13. export class Efteling extends Destination {
  14. /**
  15. * Create a new Efteling Park object
  16. * @param {object} options
  17. */
  18. constructor(options = {}) {
  19. options.name = options.name || 'Efteling';
  20. options.timezone = options.timezone || 'Europe/Amsterdam';
  21. options.apiKey = options.apiKey || '';
  22. options.apiVersion = options.apiVersion || '';
  23. options.appVersion = options.appVersion || '';
  24. options.searchUrl = options.searchUrl || 'https://prd-search-acs.efteling.com/2013-01-01/search';
  25. options.waitTimesUrl = options.waitTimesUrl || 'https://api.efteling.com/app/wis/';
  26. options.virtualQueueWindowMinutes = options.virtualQueueWindowMinutes || 15;
  27. // bump cache to invalidate the POI data that has been updated
  28. options.cacheVersion = 1;
  29. super(options);
  30. if (!this.config.apiKey) throw new Error('Missing Efteling apiKey');
  31. if (!this.config.apiVersion) throw new Error('Missing Efteling apiVersion');
  32. if (!this.config.appVersion) throw new Error('Missing Efteling appVersion');
  33. this.http.injectForDomain({
  34. // match either of the API domains
  35. $or: [
  36. {
  37. hostname: 'api.efteling.com',
  38. },
  39. {
  40. hostname: 'prd-search-acs.efteling.com',
  41. },
  42. {
  43. hostname: 'cloud.efteling.com',
  44. }
  45. ],
  46. }, (method, url, data, options) => {
  47. // all requests from the app to any efteling subdomain should send these headers
  48. options.headers['x-app-version'] = this.config.appVersion;
  49. options.headers['x-app-name'] = 'Efteling';
  50. options.headers['x-app-id'] = 'nl.efteling.android';
  51. options.headers['x-app-platform'] = 'Android';
  52. options.headers['x-app-language'] = 'en';
  53. options.headers['x-app-timezone'] = this.config.timezone;
  54. // override user-agent here, rather than class-wide
  55. // any other non-Efteling API requests can use the default user-agent
  56. options.headers['user-agent'] = 'okhttp/4.12.0';
  57. options.compressed = true;
  58. });
  59. this.http.injectForDomain({
  60. // only use these headers for the main API domain
  61. hostname: 'api.efteling.com',
  62. }, (method, url, data, options) => {
  63. // api.efteling.com requries an API key as well as the above headers
  64. options.headers['x-api-key'] = this.config.apiKey;
  65. options.headers['x-api-version'] = this.config.apiVersion;
  66. });
  67. }
  68. /**
  69. * Fetch POI data from Efteling API
  70. * @return {array<object>}
  71. */
  72. async _fetchPOIData({language = en} = {}) {
  73. // cache for 12 hours
  74. '@cache|720';
  75. // build path to our JSON data
  76. const jsonDataPath = pathJoin(__dirname, `poi-feed-${language}.json`);
  77. // check if we have a local copy of the POI data
  78. try {
  79. const data = await fs.readFile(jsonDataPath, 'utf8');
  80. const JSONdata = JSON.parse(data);
  81. return JSONdata?.hits?.hit;
  82. } catch (err) {
  83. // return null if we can't find the file
  84. return null;
  85. }
  86. }
  87. /**
  88. * Get Efteling POI data
  89. * This data contains general ride names, descriptions etc.
  90. * Wait time data references this to get ride names
  91. */
  92. async getPOIData() {
  93. '@cache|5';
  94. // grab English data first
  95. const data = await this._fetchPOIData({language: 'en'});
  96. if (!data) {
  97. throw new Error('Failed to fetch Efteling POI data [en]');
  98. }
  99. // also grab native language data and insert any missing entries
  100. const nativeData = await this._fetchPOIData({language: 'nl'});
  101. if (!nativeData) {
  102. throw new Error('Failed to fetch Efteling POI data [nl]');
  103. }
  104. // merge the two arrays with English data replacing NL data
  105. const mergedData = nativeData.map((nativeItem) => {
  106. const englishItem = data.find((item) => item.fields.id === nativeItem.fields.id);
  107. if (!englishItem) {
  108. // if the native item is missing, add it to the end of the array
  109. return nativeItem;
  110. }
  111. // if English version exists, use that one
  112. return englishItem;
  113. });
  114. const poiData = {};
  115. mergedData.forEach((hit) => {
  116. // skip any entries that aren't shown in the app
  117. if (hit.hide_in_app) return;
  118. if (hit.fields) {
  119. poiData[hit.fields.id] = {
  120. id: hit.fields.id,
  121. name: hit.fields.name,
  122. type: hit.fields.category,
  123. props: hit.fields.properties,
  124. };
  125. // hard-code station names so they can be distinct
  126. if (hit.fields.id === 'stoomtreinr') {
  127. poiData[hit.fields.id].name = poiData[hit.fields.id].name + ' - Oost';
  128. }
  129. if (hit.fields.id === 'stoomtreinm') {
  130. poiData[hit.fields.id].name = poiData[hit.fields.id].name + ' - Marerijk';
  131. }
  132. // try to parse lat/long
  133. // edge-case: some rides have dud "0.0,0.0" location, ignore these
  134. if (hit.fields.latlon && hit.fields.latlon !== '0.0,0.0') {
  135. const match = /([0-9.]+),([0-9.]+)/.exec(hit.fields.latlon);
  136. if (match) {
  137. poiData[hit.fields.id].location = {
  138. latitude: Number(match[1]),
  139. longitude: Number(match[2]),
  140. };
  141. }
  142. }
  143. // check for any alternative versions of the ride
  144. // this is usually the single rider line, though one is a "boatride"
  145. if (hit.fields.alternateid && hit.fields.alternatetype === 'singlerider') {
  146. poiData[hit.fields.id].singleRiderId = hit.fields.alternateid;
  147. }
  148. }
  149. });
  150. return poiData;
  151. }
  152. /**
  153. * Get calendar data for the given month and year
  154. * @param {string} month
  155. * @param {string} year
  156. * @return {array<object>}
  157. */
  158. async getCalendarMonth(month, year) {
  159. return await this.cache.wrap(`calendar_${year}_${month}`, async () => {
  160. const data = await this.http(
  161. 'GET',
  162. `https://www.efteling.com/service/cached/getpoiinfo/en/${year}/${month}`,
  163. null,
  164. {
  165. headers: {
  166. 'X-Requested-With': 'XMLHttpRequest',
  167. 'referer': 'https://www.efteling.com/en/park/opening-hours?app=true',
  168. 'cookie': 'website#lang=en',
  169. },
  170. json: true,
  171. },
  172. );
  173. // Efteling returns 400 once the month is in the past
  174. if (data.statusCode === 400) {
  175. return undefined;
  176. }
  177. if (!data?.body?.OpeningHours) throw new Error(`Unable to find opening hours for Efteling ${data.body}`);
  178. return data.body;
  179. }, 1000 * 60 * 60 * 12); // 12 hours
  180. }
  181. /**
  182. * Get restaurant operating hours from API
  183. * @param {string} day
  184. * @param {string} month
  185. * @param {string} year
  186. */
  187. async getRestaurantOperatingHours(day, month, year) {
  188. return await this.cache.wrap(`restaurant_${year}_${month}_${day}`, async () => {
  189. const waitTimes = await this.http('GET', this.config.waitTimesUrl, {
  190. language: 'en',
  191. });
  192. if (!waitTimes?.body?.AttractionInfo) {
  193. throw new Error(`Unable to find restaurant operating hours for Efteling ${data.body}`);
  194. }
  195. return waitTimes.body;
  196. }, 1000 * 60 * 60 * 12); // 12 hours
  197. }
  198. /**
  199. * Return restaurant operating hours for the supplied date
  200. * @param {moment} date
  201. */
  202. async _getRestaurantOperatingHoursForDate(date) {
  203. const cal = await this.getRestaurantOperatingHours(date.format('D'), date.format('M'), date.format('YYYY'));
  204. if (cal === undefined) return undefined;
  205. const data = cal.AttractionInfo;
  206. return data.map((entry) => {
  207. if (entry.Type !== 'Horeca') return;
  208. if (!entry.OpeningTimes || entry.OpeningTimes.length == 0) {
  209. return {
  210. restaurantID: entry.Id,
  211. openingTime: 0,
  212. closingTime: 0,
  213. status: statusType.closed,
  214. };
  215. }
  216. const openingTimes = entry.OpeningTimes;
  217. return {
  218. restaurantID: entry.Id,
  219. openingTime: moment(openingTimes[0].HourFrom).format(),
  220. closingTime: moment(openingTimes[0].HourTo).format(),
  221. type: scheduleType.operating,
  222. };
  223. }).filter((x) => x !== undefined);
  224. }
  225. /**
  226. * Helper function to build a basic entity document
  227. * Useful to avoid copy/pasting
  228. * @param {object} data
  229. * @returns {object}
  230. */
  231. buildBaseEntityObject(data) {
  232. const entity = Destination.prototype.buildBaseEntityObject.call(this, data);
  233. entity._id = data?.id || entity._id;
  234. entity.name = data?.name || entity.name;
  235. // add location (if found)
  236. if (data?.location !== undefined) {
  237. entity.location = {
  238. longitude: data.location.longitude,
  239. latitude: data.location.latitude,
  240. };
  241. }
  242. // TODO - extra facet data
  243. /*
  244. // look for any other useful tags
  245. // may get wet
  246. await this.toggleAttractionTag(id, tagType.mayGetWet, p.props.indexOf('wet') >= 0);
  247. // tag "pregnant people should not ride" attractions
  248. await this.toggleAttractionTag(
  249. id,
  250. tagType.unsuitableForPregnantPeople,
  251. p.props.indexOf('pregnantwomen') >= 0,
  252. );
  253. // single rider queue available?
  254. await this.setAttractionTag(
  255. id,
  256. null,
  257. tagType.singleRider,
  258. !!p.singleRiderId,
  259. );
  260. // look for attraction minimum height
  261. const minHeightProp = p.props.find((prop) => prop.indexOf('minimum') === 0);
  262. if (minHeightProp !== undefined) {
  263. const minHeightNumber = Number(minHeightProp.slice(7));
  264. if (!isNaN(minHeightNumber)) {
  265. await this.setAttractionTag(id, 'minimumHeight', tagType.minimumHeight, {
  266. height: minHeightNumber,
  267. unit: 'cm',
  268. });
  269. }
  270. }*/
  271. return entity;
  272. }
  273. /**
  274. * Build the destination entity representing this destination
  275. */
  276. async buildDestinationEntity() {
  277. return {
  278. ...this.buildBaseEntityObject({
  279. name: "Efteling Themepark Resort",
  280. }),
  281. _id: 'eftelingresort',
  282. slug: 'eftelingresort',
  283. entityType: entityType.destination,
  284. location: {
  285. latitude: 51.649515,
  286. longitude: 5.043776
  287. },
  288. };
  289. }
  290. /**
  291. * Build the park entities for this destination
  292. */
  293. async buildParkEntities() {
  294. const destination = await this.buildDestinationEntity();
  295. return [
  296. {
  297. ...this.buildBaseEntityObject({
  298. name: this.config.name,
  299. }),
  300. _id: 'efteling',
  301. _destinationId: destination._id,
  302. _parentId: destination._id,
  303. slug: 'efteling',
  304. entityType: entityType.park,
  305. location: {
  306. latitude: 51.649515,
  307. longitude: 5.043776
  308. }
  309. },
  310. ];
  311. }
  312. async _buildArrayOfEntitiesOfType(type, fields = {}) {
  313. const destination = await this.buildDestinationEntity();
  314. const poi = await this.getPOIData();
  315. // some valid attraction types from the Efteling API:
  316. // 'attraction', 'show', 'merchandise', 'restaurant', 'fairytale', 'facilities-toilets', 'facilities-generic', 'eventlocation', 'game'
  317. const attrs = [];
  318. const poiKeys = Object.keys(poi);
  319. for (let i = 0; i < poiKeys.length; i++) {
  320. const id = poiKeys[i];
  321. const p = poi[id];
  322. // if poi data matches our wanted types
  323. if (p.type === type) {
  324. const attr = {
  325. ...fields,
  326. ...this.buildBaseEntityObject(p),
  327. _destinationId: destination._id,
  328. // TODO - are all rides/shows inside the park?
  329. _parkId: 'efteling',
  330. _parentId: 'efteling',
  331. };
  332. attrs.push(attr);
  333. }
  334. }
  335. return attrs;
  336. }
  337. /**
  338. * Build the attraction entities for this destination
  339. */
  340. async buildAttractionEntities() {
  341. return this._buildArrayOfEntitiesOfType('attraction', {
  342. entityType: entityType.attraction,
  343. attractionType: attractionType.ride,
  344. });
  345. }
  346. /**
  347. * Build the show entities for this destination
  348. */
  349. async buildShowEntities() {
  350. return this._buildArrayOfEntitiesOfType('show', {
  351. entityType: entityType.show,
  352. });
  353. }
  354. /**
  355. * Build the restaurant entities for this destination
  356. */
  357. async buildRestaurantEntities() {
  358. // TODO
  359. return [];
  360. }
  361. async _fetchWaitTimes() {
  362. // cache 1 minute
  363. '@cache|1';
  364. return (await this.http('GET', this.config.waitTimesUrl, {
  365. language: 'en',
  366. })).body;
  367. }
  368. /**
  369. * @inheritdoc
  370. */
  371. async buildEntityLiveData() {
  372. const poiData = await this.getPOIData();
  373. // this function should return all the live data for all entities in this destination
  374. const waitTimes = await this._fetchWaitTimes();
  375. const attractions = waitTimes?.AttractionInfo;
  376. if (!attractions) throw new Error('Efteling wait times response missing AttractionInfo');
  377. const livedata = [];
  378. // first, look for single-rider entries
  379. const singleRiderData = [];
  380. for (let i = 0; i < attractions.length; i++) {
  381. const entry = attractions[i];
  382. if (poiData[entry.Id] === undefined) {
  383. // if we don't have POI data for this attraction, check for single rider IDs and update the main attraction
  384. const singleRiderPOI = Object.keys(poiData).find((k) => {
  385. return poiData[k].singleRiderId && poiData[k].singleRiderId === entry.Id;
  386. });
  387. if (singleRiderPOI !== undefined) {
  388. // we have found a matching single-rider entry!
  389. singleRiderData.push({
  390. id: singleRiderPOI,
  391. time: parseInt(entry.WaitingTime, 10),
  392. });
  393. }
  394. }
  395. }
  396. // helper function to create or get a live data entry
  397. const createOrGetLiveData = (id) => {
  398. // smush standby and virtual queue data together for droomvlucht
  399. if (id === 'droomvluchtstandby') {
  400. return createOrGetLiveData('droomvlucht');
  401. }
  402. const existing = livedata.find((x) => x._id === id);
  403. if (existing) return existing;
  404. const newEntry = {
  405. _id: id,
  406. status: null,
  407. };
  408. livedata.push(newEntry);
  409. return newEntry;
  410. };
  411. const populateAttractionLiveData = (entry) => {
  412. const live = createOrGetLiveData(entry.Id);
  413. let rideStatus = null;
  414. const rideWaitTime = parseInt(entry.WaitingTime, 10);
  415. const rideState = entry.State.toLowerCase();
  416. // update ride with wait time data
  417. if (rideState === 'storing' || rideState === 'tijdelijkbuitenbedrijf') {
  418. // Ride down because of an interruption
  419. rideStatus = statusType.down;
  420. } else if (rideState === 'buitenbedrijf') {
  421. // ride is closed "for the day"
  422. rideStatus = statusType.closed;
  423. } else if (rideState === 'inonderhoud') {
  424. // Ride down because of maintenance/refurbishment
  425. rideStatus = statusType.refurbishment;
  426. } else if (rideState === 'gesloten' || rideState === '' || rideState === 'wachtrijgesloten' || rideState === 'nognietopen') {
  427. // ride is "closed"
  428. rideStatus = statusType.closed;
  429. } else if (rideState === 'open') {
  430. // Ride operating
  431. rideStatus = statusType.operating;
  432. }
  433. live.status = rideStatus || live.status;
  434. if (live.status === null) {
  435. this.emit('error', new Error(`Unknown Efteling rideStatus ${JSON.stringify(rideState)}`));
  436. console.log('Unknown Efteling rideStatus', JSON.stringify(rideState));
  437. }
  438. live.queue = {
  439. [queueType.standBy]: {
  440. waitTime: rideStatus == statusType.operating ? (
  441. isNaN(rideWaitTime) ? null : rideWaitTime
  442. ) : null,
  443. },
  444. };
  445. // add any single rider data (if available)
  446. const singleRider = singleRiderData.find((x) => x.id === entry.Id);
  447. if (singleRider) {
  448. live.queue[queueType.singleRider] = {
  449. waitTime: rideStatus == statusType.operating ? (
  450. isNaN(singleRider.time) ? null : singleRider.time
  451. ) : null,
  452. };
  453. }
  454. // TODO - add virtual queue data
  455. if (entry.VirtualQueue) {
  456. // debugger;
  457. // known states:
  458. // walkin (park not open, but there is a virtual queue)
  459. // enabled (virtual queue is open)
  460. // full (virtual queue is full for the day)
  461. const vqObj = {
  462. state: returnTimeState.finished,
  463. returnStart: null,
  464. returnEnd: null,
  465. };
  466. if (entry.VirtualQueue.State === 'walkin') {
  467. // walkin = "Currently, you do not need to join the virtual queue"
  468. vqObj.state = returnTimeState.temporarilyFull;
  469. vqObj.returnStart = null;
  470. vqObj.returnEnd = null;
  471. } else if (entry.VirtualQueue.State === 'enabled') {
  472. vqObj.state = returnTimeState.available;
  473. // generate startTime for return time by adding the waiting time to the current time
  474. const nowInPark = this.getTimeNowMoment();
  475. const startTime = nowInPark.clone().set({
  476. // blank out the seconds and milliseconds
  477. seconds: 0,
  478. milliseconds: 0,
  479. }).add(entry.VirtualQueue.WaitingTime, 'minutes');
  480. vqObj.returnStart = startTime.format();
  481. // you have a 15 minute window to return, according to the app
  482. vqObj.returnEnd = startTime.clone().add(this.config.virtualQueueWindowMinutes, 'minutes').format();
  483. } else if (entry.VirtualQueue.State === 'full') {
  484. // full
  485. vqObj.state = returnTimeState.finished;
  486. vqObj.returnStart = null;
  487. vqObj.returnEnd = null;
  488. } else {
  489. this.emit('error', new Error(`Unknown Efteling VirtualQueue state ${JSON.stringify(entry.VirtualQueue)}`));
  490. console.log('Unknown Efteling virtualQueue state', JSON.stringify(entry.VirtualQueue));
  491. // TODO - add any other valid states
  492. }
  493. live.queue[queueType.returnTime] = vqObj;
  494. }
  495. };
  496. const populateShowLiveData = (entry) => {
  497. const live = createOrGetLiveData(entry.Id);
  498. live.status = statusType.operating;
  499. // if we have no upcoming showtimes, assume the show is closed
  500. if (!entry.ShowTimes || entry.ShowTimes.length === 0) {
  501. live.status = statusType.closed;
  502. }
  503. const allTimes = (entry.ShowTimes || []).concat(entry.PastShowTimes || []);
  504. live.showtimes = allTimes.map((time) => {
  505. const show = {
  506. type: time.Edition || 'Showtime',
  507. startTime: moment.tz(time.StartDateTime, 'YYYY-MM-DDTHH:mm:ssZ', this.config.timezone).format(),
  508. endTime: moment.tz(time.EndDateTime, 'YYYY-MM-DDTHH:mm:ssZ', this.config.timezone).format(),
  509. };
  510. return show;
  511. });
  512. };
  513. for (let i = 0; i < attractions.length; i++) {
  514. const entry = attractions[i];
  515. // some hack, skip entries that don't have POI data
  516. if (entry.Id != 'droomvluchtstandby' && poiData[entry.Id] === undefined) continue;
  517. // populate live data for attractions
  518. if (entry.Type === 'Attraction' || entry.Type === 'Attracties') {
  519. populateAttractionLiveData(entry);
  520. }
  521. // populate live data for shows
  522. if (entry.Type === 'Shows en Entertainment') {
  523. populateShowLiveData(entry);
  524. }
  525. }
  526. return livedata;
  527. }
  528. /**
  529. * Return schedule data for all scheduled entities in this destination
  530. * Eg. parks
  531. * @returns {array<object>}
  532. */
  533. async buildEntityScheduleData() {
  534. // get operating hours for next x months
  535. const parkSchedule = [];
  536. const now = this.getTimeNowMoment();
  537. const monthsToFetch = 3;
  538. const end = now.clone().add(monthsToFetch, 'months');
  539. for (; now.isSameOrBefore(end, 'month'); now.add(1, 'month')) {
  540. const calData = await this.getCalendarMonth(now.format('M'), now.format('YYYY'));
  541. if (calData === undefined) continue;
  542. calData.OpeningHours.forEach((x) => {
  543. const date = moment.tz(x.Date, 'YYYY-MM-DD', this.config.timezone);
  544. x.OpeningHours.sort((a, b) => a.Open - b.Open);
  545. x.OpeningHours.forEach((d, idx) => {
  546. const open = d.Open.split(':').map(Number);
  547. const close = d.Close.split(':').map(Number);
  548. parkSchedule.push({
  549. date: date.format('YYYY-MM-DD'),
  550. openingTime: date.clone().set('hour', open[0]).set('minute', open[1]).format(),
  551. closingTime: date.clone().set('hour', close[0]).set('minute', close[1]).format(),
  552. type: idx === 0 ? scheduleType.operating : scheduleType.informational,
  553. description: idx === 0 ? undefined : 'Evening Hours',
  554. });
  555. });
  556. });
  557. }
  558. return [
  559. {
  560. _id: 'efteling',
  561. schedule: parkSchedule,
  562. }
  563. ];
  564. }
  565. }
  566. export default Efteling;