parks/efteling/efteling.js

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;