import {Destination} from '../destination.js';
import {attractionType, statusType, queueType, tagType, scheduleType, entityType} from '../parkTypes.js';
import {v4 as uuidv4} from 'uuid';
import moment from 'moment';
import unzip from 'yauzl';
import {promisify} from 'util';
const unzipFromBuffer = promisify(unzip.fromBuffer);
const langs = ['en-GB', 'en-US', 'en-AU', 'en-CA', 'es-419', 'de-DE', 'it'];
/**
* Helper to extract name from a variable
* @param {String|Object} name
* @returns {String}
*/
function extractName(name) {
if (typeof name === 'object') {
// if we have translations, pick in priority order...
const langIdx = langs.findIndex((lang) => !!name[lang]);
if (langIdx > -1) {
return name[langs[langIdx]];
} else {
// otherwise just pick the first one
return Object.values(name)[0];
}
}
return name;
}
export class AttractionsIO extends Destination {
constructor(options = {}) {
options.destinationId = options.destinationId || '';
options.parkId = options.parkId || '';
options.baseURL = options.baseURL || '';
options.timezone = options.timezone || 'Europe/London';
options.appBuild = options.appBuild || undefined;
options.appVersion = options.appVersion || '';
options.deviceIdentifier = options.deviceIdentifier || '123';
options.apiKey = options.apiKey || '';
options.initialDataVersion = options.initialDataVersion || undefined;
options.calendarURL = options.calendarURL || '';
// allow env config for all attractionsio destinations
options.configPrefixes = ['ATTRACTIONSIO'];
// invalidate cache
options.cacheVersion = options.cacheVersion || '3';
super(options);
if (!this.config.destinationId) throw new Error('destinationId is required');
if (!this.config.parkId) throw new Error('parkId is required');
if (!this.config.baseURL) throw new Error('Missing attractions.io base URL');
if (!this.config.appBuild) throw new Error('Missing appBuild');
if (!this.config.appVersion) throw new Error('Missing appVersion');
if (!this.config.deviceIdentifier) throw new Error('Missing deviceIdentifier');
if (!this.config.apiKey) throw new Error('Missing apiKey');
if (!this.config.calendarURL) throw new Error('Missing calendarURL');
// API hooks for auto-login
const baseURLHostname = new URL(this.config.baseURL).hostname;
// login when accessing API domain
this.http.injectForDomain({
hostname: baseURLHostname,
}, async (method, url, data, options) => {
// always include the current date
options.headers.date = moment().format();
if (options.skipDeviceId) {
// special case for initial device setup
options.headers['authorization'] = `Attractions-Io api-key="${this.config.apiKey}"`;
return;
}
const deviceId = await this.getDeviceId();
options.headers['authorization'] = `Attractions-Io api-key="${this.config.apiKey}", installation-token="${deviceId}"`;
});
}
/**
* Create a device ID to login to the API
*/
async getDeviceId() {
'@cache|481801'; // cache 11 months
const deviceId = uuidv4();
const resp = await this.http('POST', `${this.config.baseURL}installation`, {
user_identifier: deviceId,
app_build: this.config.appBuild,
app_version: this.config.appVersion,
device_identifier: this.config.deviceIdentifier,
}, {
skipDeviceId: true,
});
return resp.body.token;
}
/**
* Get POI data for this destination
*/
async getPOIData(depth = 0) {
'@cache|720'; // cache for 12 hours
// get current data asset version
const currentParkDataVersion = (await this.cache.get('currentParkDataVersion')) || this.config.initialDataVersion;
const dataQueryOptions = {};
if (currentParkDataVersion) {
dataQueryOptions.version = currentParkDataVersion;
}
// query current data version
const dataVersionQuery = await this.http(
'GET',
`${this.config.baseURL}data`,
Object.keys(dataQueryOptions).length > 0 ? dataQueryOptions : undefined,
{
// allow up to 10 minutes
read_timeout: 10 * 60 * 1000,
},
);
if (dataVersionQuery.statusCode === 202) {
// data is being generated, wait for it to finish and try again...
// give up after 5 tries...
if (depth < 5) {
// wait 10 x depth seconds
const seconds = 10 * (depth + 1);
this.log(`Status Code 202 receieved. Data is still being generated, waiting ${seconds} seconds before trying again...`);
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
// try again
return await this.getPOIData(depth + 1);
} else {
throw new Error('Data generation still in progress after 5 attempts');
}
}
if (dataVersionQuery.statusCode === 303) {
// redirect to new data asset
const newDataAssets = dataVersionQuery.headers.location;
// download the new data asset and extract records.json
const assetData = await this.downloadAssetPack(newDataAssets);
// save assetData in long-term cache
await this.cache.set('assetData', assetData, 1000 * 60 * 60 * 24 * 365 * 2); // cache for 2 years
// save the current data asset version
await this.cache.set('currentParkDataVersion', assetData.manifestData.version, 1000 * 60 * 60 * 24 * 365 * 2); // cache for 2 years
return assetData.recordsData;
}
// in all other scenarios, return our previously cached data
const assetData = await this.cache.get('assetData');
if (!assetData?.recordsData) {
this.emit('error', new Error(`No asset data found, return code ${dataVersionQuery.statusCode}`));
}
return assetData.recordsData;
}
/**
* Download asset zip file. Extract manifest and records data.
* @param {String} url
* @returns {object}
*/
async downloadAssetPack(url) {
const resp = await this.http('GET', url);
// read a single JSON file from a zip object
const readZipFile = async (zip, file) => {
const openReadStream = promisify(zip.openReadStream.bind(zip));
const readStream = await openReadStream(file);
let data = '';
readStream.on('data', (chunk) => {
data += chunk;
});
return new Promise((resolve, reject) => {
readStream.on('end', () => {
try {
data = JSON.parse(data);
return resolve(data);
} catch (e) {
return reject(new Error(`JSON parse error extracting ${file.fileName}: ${e}`));
}
});
});
}
// unzip data
const zip = await unzipFromBuffer(resp.body, {
lazyEntries: true,
});
let manifestData;
let recordsData;
const filenames = [
'manifest.json',
'records.json',
];
zip.on('entry', async (file) => {
if (filenames.indexOf(file.fileName) > -1) {
// read the file
const data = await readZipFile(zip, file);
// store the data
if (file.fileName === 'manifest.json') {
manifestData = data;
} else if (file.fileName === 'records.json') {
recordsData = data;
}
}
zip.readEntry();
});
return new Promise((resolve, reject) => {
zip.on('end', () => {
if (!manifestData) {
return reject(new Error('No manifest.json found in zip file'));
}
if (!recordsData) {
return reject(new Error('No records.json found in zip file'));
}
return resolve({
manifestData,
recordsData,
});
});
// start reading file...
zip.readEntry();
});
}
/**
* Given a category string, return all category IDs
* eg. "Attractions" will return the "Attractions" category and all child categories, such as "Thrills" etc.
*/
async getCategoryIDs(categoryName) {
'@cache|120';
const destinationData = await this.getPOIData();
// find parent category
const cats = [];
const attractionCats = destinationData.Category.filter((x) => {
return extractName(x.Name) === categoryName;
});
if (!attractionCats || attractionCats.length === 0) return [];
// return main category
// cats.push(attractionCat._id);
cats.push(...attractionCats.map((x) => x._id));
// concat child cateories too
attractionCats.forEach((parentCat) => {
cats.push(...destinationData.Category.filter((x) => {
return x.Parent == parentCat._id;
}).map((x) => x._id));
});
return cats;
}
/**
* 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 || undefined}`;
entity.name = extractName(data?.Name || undefined);
if (data?.DirectionsLocation) {
try {
const loc = data.DirectionsLocation.split(',').map(Number);
entity.location = {
latitude: loc[0],
longitude: loc[1],
};
} catch (e) {
// ignore
}
}
if (data?.Location) {
try {
const loc = data.Location.split(',').map(Number);
entity.location = {
latitude: loc[0],
longitude: loc[1],
};
} catch (e) {
// ignore
}
}
entity._tags = [];
// minimum height
if (data?.MinimumHeightRequirement !== undefined) {
entity._tags.push({
id: 'minimumHeight',
value: Math.floor(data.MinimumHeightRequirement * 100),
});
}
// minimum height unaccompanied
if (data?.MinimumUnaccompaniedHeightRequirement !== null && data?.MinimumUnaccompaniedHeightRequirement !== undefined) {
entity._tags.push({
id: 'minimumHeightUnaccompanied',
value: Math.floor(data.MinimumUnaccompaniedHeightRequirement * 100),
});
}
return entity;
}
/**
* Build the destination entity representing this destination
*/
async buildDestinationEntity() {
const destinationData = await this.getPOIData();
// TODO - hardcode or find a better way to find our destination data
// Note: What about merlin resorts with multiple parks? i.e, Legoland Orlando - any others?
if (!destinationData?.Resort) {
throw new Error('No resort data found');
}
if (destinationData.Resort.length > 1) {
throw new Error('Multiple resorts found in destination data');
}
const resortData = destinationData.Resort[0];
if (!resortData) throw new Error('No resort data found');
return {
...this.buildBaseEntityObject(resortData),
_id: this.config.destinationId,
slug: this.config.destinationId,
entityType: entityType.destination,
};
}
/**
* Build the park entities for this destination
*/
async buildParkEntities() {
const destinationData = await this.getPOIData();
if (!destinationData?.Resort?.length) {
throw new Error('No resort data found');
}
const park = destinationData.Resort[0];
const parkObj = {
...this.buildBaseEntityObject(park),
_parentId: this.config.destinationId,
_destinationId: this.config.destinationId,
entityType: entityType.park,
_id: this.config.parkId,
};
parkObj.name = parkObj.name.replace(/\s*Resort/, '');
parkObj.slug = parkObj.name.toLowerCase().replace(/[^\w]/g, '');
return [parkObj];
}
/**
* Helper function to generate entities from a list of category names
* @param {Array<String>} categoryNames
* @returns {Array<Object>}
*/
async _buildEntitiesFromCategories(categoryNames, parentId, attributes = {}) {
const categoryIDs = [];
for (let i = 0; i < categoryNames.length; i++) {
const categories = await this.getCategoryIDs(categoryNames[i]);
categoryIDs.push(...categories);
}
const categoryData = await this.getPOIData();
const ents = categoryData.Item.filter((x) => categoryIDs.indexOf(x.Category) >= 0);
return ents.map((x) => {
return {
...this.buildBaseEntityObject(x),
_parentId: parentId,
_parkId: parentId,
_destinationId: this.config.destinationId,
...attributes,
};
});
}
/**
* Build the attraction entities for this destination
*/
async buildAttractionEntities() {
return this._buildEntitiesFromCategories(['Attractions', 'Rides', 'Water Rides', 'Thrill Rides', 'Coasters', 'Intense Thrills', 'Rides & Shows', 'Thrills & Mini-Thrills', 'RIDES', ''], this.config.parkId, {
entityType: entityType.attraction,
attractionType: attractionType.ride,
});
}
/**
* Build the show entities for this destination
*/
async buildShowEntities() {
return this._buildEntitiesFromCategories(['Shows', 'Show', 'Live Shows'], this.config.parkId, {
entityType: entityType.show,
});
}
/**
* Build the restaurant entities for this destination
*/
async buildRestaurantEntities() {
return this._buildEntitiesFromCategories(['Restaurants', 'Fast Food', 'Snacks', 'Healthy Food', 'Food', 'Dining', 'Food & Drink'], this.config.parkId, {
entityType: entityType.restaurant,
});
}
async _fetchLiveData() {
'@cache|1'; // cache for 1 minute
const resp = await this.http('GET', `https://live-data.attractions.io/${this.config.apiKey}.json`);
return resp.body.entities;
}
/**
* @inheritdoc
*/
async buildEntityLiveData() {
const liveData = await this._fetchLiveData();
// only return attractions
const attrs = await this.getAttractionEntities();
const attrIds = attrs.map((x) => `${x._id}`);
const validEnts = liveData.Item.records.filter((x) => {
return attrIds.indexOf(`${x._id}`) >= 0;
});
return validEnts.map((x) => {
const data = {
_id: `${x._id}`,
status: (!!x.IsOperational) ? statusType.operating : statusType.closed,
};
if (x.QueueTime !== undefined && x.QueueTime !== null && !isNaN(x.QueueTime)) {
data.queue = {
[queueType.standBy]: {
waitTime: Math.floor(x.QueueTime / 60),
},
};
} else if (x.QueueTime === null) {
// null wait time should still be recorded
data.queue = {
[queueType.standBy]: {
waitTime: null,
},
};
}
return data;
});
}
async _fetchCalendar() {
'@cache|120'; // cache for 2 hours
const scheduleData = await this.http('GET', this.config.calendarURL);
return scheduleData.body;
}
/**
* Return schedule data for all scheduled entities in this destination
* Eg. parks
* @returns {array<object>}
*/
async buildEntityScheduleData() {
const scheduleData = await this._fetchCalendar();
if (!scheduleData || !scheduleData.Locations || !scheduleData.Locations.length) {
return [
{
_id: this.config.parkId,
schedule: [],
}
];
}
// assume 1 park per destination
const days = scheduleData.Locations[0].days;
// various random formats the calendar API can return in
const hourFormats = [
{
// eg. 9:30am - 7pm
regex: /^(\d{1,2}):(\d{1,2})([a|p]m)\s*\-\s*(\d{1,2})([a|p]m)$/,
process: (match, date) => {
return {
openingTime: date.clone().hour(Number(match[1]) + (match[3] === 'pm' ? 12 : 0)).minute(Number(match[2])).second(0).millisecond(0),
closingTime: date.clone().hour(Number(match[4]) + (match[5] === 'pm' ? 12 : 0)).minute(0).second(0).millisecond(0),
};
},
},
{
// eg. 10am - 5pm
regex: /^(\d{1,2})([a|p]m)\s*\-\s*(\d{1,2})([a|p]m)$/,
process: (match, date) => {
return {
openingTime: date.clone().hour(Number(match[1]) + (match[2] === 'pm' ? 12 : 0)).minute(0).second(0).millisecond(0),
closingTime: date.clone().hour(Number(match[3]) + (match[4] === 'pm' ? 12 : 0)).minute(0).second(0).millisecond(0),
};
},
},
{
// eg. 10:00 - 17:00
regex: /^(\d{1,2}):(\d{1,2})\s*\-\s*(\d{1,2}):(\d{1,2})$/,
process: (match, date) => {
return {
openingTime: date.clone().hour(Number(match[1])).minute(Number(match[2])).second(0).millisecond(0),
closingTime: date.clone().hour(Number(match[3])).minute(Number(match[4])).second(0).millisecond(0),
};
},
},
];
const schedule = days.map((x) => {
const date = moment(x.key, 'YYYYMMDD').tz(this.config.timezone, true);
for (let i = 0; i < hourFormats.length; i++) {
const format = hourFormats[i];
const match = format.regex.exec(x.openingHours);
if (!match) continue;
const times = format.process(match, date);
return {
"date": date.format('YYYY-MM-DD'),
"type": "OPERATING",
"openingTime": times.openingTime.format(),
"closingTime": times.closingTime.format(),
};
}
return null;
}).filter((x) => !!x);
return [
{
_id: this.config.parkId,
schedule,
}
];
}
}
export class AltonTowers extends AttractionsIO {
constructor(config = {}) {
config.destinationId = config.destinationId || 'altontowersresort';
config.parkId = config.parkId || 'altontowers';
config.initialDataVersion = config.initialDataVersion || '2021-07-06T07:48:43Z';
config.appBuild = config.appBuild || 293;
config.appVersion = config.appVersion || '5.3';
super(config);
}
}
export class ThorpePark extends AttractionsIO {
constructor(config = {}) {
config.destinationId = config.destinationId || 'thorpeparkresort';
config.parkId = config.parkId || 'thorpepark';
config.initialDataVersion = config.initialDataVersion || '2021-04-15T15:28:08Z';
config.appBuild = config.appBuild || 299;
config.appVersion = config.appVersion || '1.4';
super(config);
}
}
export class ChessingtonWorldOfAdventures extends AttractionsIO {
constructor(config = {}) {
config.destinationId = config.destinationId || 'chessingtonworldofadventuresresort';
config.parkId = config.parkId || 'chessingtonworldofadventures';
config.initialDataVersion = config.initialDataVersion || '2021-08-19T09:59:06Z';
config.appBuild = config.appBuild || 178;
config.appVersion = config.appVersion || '3.3';
super(config);
}
}
export class LegolandWindsor extends AttractionsIO {
constructor(config = {}) {
config.destinationId = config.destinationId || 'legolandwindsorresort';
config.parkId = config.parkId || 'legolandwindsor';
config.initialDataVersion = config.initialDataVersion || '2021-08-20T08:22:20Z';
config.appBuild = config.appBuild || 113;
config.appVersion = config.appVersion || '2.4';
super(config);
}
}
export class LegolandOrlando extends AttractionsIO {
constructor(config = {}) {
config.timezone = config.timezone || 'America/New_York';
config.destinationId = config.destinationId || 'legolandorlandoresort';
config.parkId = config.parkId || 'legolandorlando';
config.initialDataVersion = config.initialDataVersion || '2021-08-09T15:48:56Z';
config.appBuild = config.appBuild || 115;
config.appVersion = config.appVersion || '1.6.1';
super(config);
}
}
export class LegolandCalifornia extends AttractionsIO {
constructor(config = {}) {
config.timezone = config.timezone || 'America/Los_Angeles';
config.destinationId = config.destinationId || 'legolandcaliforniaresort';
config.parkId = config.parkId || 'legolandcalifornia';
config.initialDataVersion = config.initialDataVersion || '';//'2023-03-15T16:27:19Z';
config.appBuild = config.appBuild || 800000074;
config.appVersion = config.appVersion || '8.4.11';
super(config);
}
}
export class LegolandBillund extends AttractionsIO {
constructor(config = {}) {
config.destinationId = config.destinationId || 'legolandbillundresort';
config.parkId = config.parkId || 'legolandbillund';
config.initialDataVersion = config.initialDataVersion || '2023-10-31T09:22:05Z';
config.timezone = config.timezone || 'Europe/Copenhagen';
config.appBuild = config.appBuild || 162;
config.appVersion = config.appVersion || '3.4.17';
super(config);
}
}
export class LegolandDeutschland extends AttractionsIO {
constructor(config = {}) {
config.destinationId = config.destinationId || 'legolanddeutschlandresort';
config.parkId = config.parkId || 'legolanddeutschland';
config.initialDataVersion = config.initialDataVersion || '';
config.timezone = config.timezone || 'Europe/Berlin';
config.appBuild = config.appBuild || 113;
config.appVersion = config.appVersion || '1.4.15';
super(config);
}
}
export class Gardaland extends AttractionsIO {
constructor(config = {}) {
config.timezone = config.timezone || 'Europe/Rome';
config.destinationId = config.destinationId || 'gardalandresort';
config.parkId = config.parkId || 'gardaland';
config.initialDataVersion = config.initialDataVersion || '2020-10-27T08:40:37Z';
config.appBuild = config.appBuild || 119;
config.appVersion = config.appVersion || '4.2';
super(config);
}
}
export class HeidePark extends AttractionsIO {
constructor(config = {}) {
config.timezone = config.timezone || 'Europe/Berlin';
config.destinationId = config.destinationId || 'heideparkresort';
config.parkId = config.parkId || 'heidepark';
config.initialDataVersion = config.initialDataVersion || '2022-05-10T09:00:46Z';
config.appBuild = config.appBuild || 302018;
config.appVersion = config.appVersion || '4.0.5';
super(config);
}
}
export class Knoebels extends AttractionsIO {
constructor(config = {}) {
config.timezone = config.timezone || 'America/New_York';
config.destinationId = config.destinationId || 'knoebels';
config.parkId = config.parkId || 'knoebelspark';
config.initialDataVersion = config.initialDataVersion || '2024-05-05T15:08:58Z';
config.appBuild = config.appBuild || 48;
config.appVersion = config.appVersion || '1.1.2';
super(config);
}
}