parks/attractionsio/attractionsio.js

  1. import {Destination} from '../destination.js';
  2. import {attractionType, statusType, queueType, tagType, scheduleType, entityType} from '../parkTypes.js';
  3. import {v4 as uuidv4} from 'uuid';
  4. import moment from 'moment';
  5. import unzip from 'yauzl';
  6. import {promisify} from 'util';
  7. const unzipFromBuffer = promisify(unzip.fromBuffer);
  8. const langs = ['en-GB', 'en-US', 'en-AU', 'en-CA', 'es-419', 'de-DE', 'it'];
  9. /**
  10. * Helper to extract name from a variable
  11. * @param {String|Object} name
  12. * @returns {String}
  13. */
  14. function extractName(name) {
  15. if (typeof name === 'object') {
  16. // if we have translations, pick in priority order...
  17. const langIdx = langs.findIndex((lang) => !!name[lang]);
  18. if (langIdx > -1) {
  19. return name[langs[langIdx]];
  20. } else {
  21. // otherwise just pick the first one
  22. return Object.values(name)[0];
  23. }
  24. }
  25. return name;
  26. }
  27. export class AttractionsIO extends Destination {
  28. constructor(options = {}) {
  29. options.destinationId = options.destinationId || '';
  30. options.parkId = options.parkId || '';
  31. options.baseURL = options.baseURL || '';
  32. options.timezone = options.timezone || 'Europe/London';
  33. options.appBuild = options.appBuild || undefined;
  34. options.appVersion = options.appVersion || '';
  35. options.deviceIdentifier = options.deviceIdentifier || '123';
  36. options.apiKey = options.apiKey || '';
  37. options.initialDataVersion = options.initialDataVersion || undefined;
  38. options.calendarURL = options.calendarURL || '';
  39. // allow env config for all attractionsio destinations
  40. options.configPrefixes = ['ATTRACTIONSIO'];
  41. // invalidate cache
  42. options.cacheVersion = options.cacheVersion || '3';
  43. super(options);
  44. if (!this.config.destinationId) throw new Error('destinationId is required');
  45. if (!this.config.parkId) throw new Error('parkId is required');
  46. if (!this.config.baseURL) throw new Error('Missing attractions.io base URL');
  47. if (!this.config.appBuild) throw new Error('Missing appBuild');
  48. if (!this.config.appVersion) throw new Error('Missing appVersion');
  49. if (!this.config.deviceIdentifier) throw new Error('Missing deviceIdentifier');
  50. if (!this.config.apiKey) throw new Error('Missing apiKey');
  51. if (!this.config.calendarURL) throw new Error('Missing calendarURL');
  52. // API hooks for auto-login
  53. const baseURLHostname = new URL(this.config.baseURL).hostname;
  54. // login when accessing API domain
  55. this.http.injectForDomain({
  56. hostname: baseURLHostname,
  57. }, async (method, url, data, options) => {
  58. // always include the current date
  59. options.headers.date = moment().format();
  60. if (options.skipDeviceId) {
  61. // special case for initial device setup
  62. options.headers['authorization'] = `Attractions-Io api-key="${this.config.apiKey}"`;
  63. return;
  64. }
  65. const deviceId = await this.getDeviceId();
  66. options.headers['authorization'] = `Attractions-Io api-key="${this.config.apiKey}", installation-token="${deviceId}"`;
  67. });
  68. }
  69. /**
  70. * Create a device ID to login to the API
  71. */
  72. async getDeviceId() {
  73. '@cache|481801'; // cache 11 months
  74. const deviceId = uuidv4();
  75. const resp = await this.http('POST', `${this.config.baseURL}installation`, {
  76. user_identifier: deviceId,
  77. app_build: this.config.appBuild,
  78. app_version: this.config.appVersion,
  79. device_identifier: this.config.deviceIdentifier,
  80. }, {
  81. skipDeviceId: true,
  82. });
  83. return resp.body.token;
  84. }
  85. /**
  86. * Get POI data for this destination
  87. */
  88. async getPOIData(depth = 0) {
  89. '@cache|720'; // cache for 12 hours
  90. // get current data asset version
  91. const currentParkDataVersion = (await this.cache.get('currentParkDataVersion')) || this.config.initialDataVersion;
  92. const dataQueryOptions = {};
  93. if (currentParkDataVersion) {
  94. dataQueryOptions.version = currentParkDataVersion;
  95. }
  96. // query current data version
  97. const dataVersionQuery = await this.http(
  98. 'GET',
  99. `${this.config.baseURL}data`,
  100. Object.keys(dataQueryOptions).length > 0 ? dataQueryOptions : undefined,
  101. {
  102. // allow up to 10 minutes
  103. read_timeout: 10 * 60 * 1000,
  104. },
  105. );
  106. if (dataVersionQuery.statusCode === 202) {
  107. // data is being generated, wait for it to finish and try again...
  108. // give up after 5 tries...
  109. if (depth < 5) {
  110. // wait 10 x depth seconds
  111. const seconds = 10 * (depth + 1);
  112. this.log(`Status Code 202 receieved. Data is still being generated, waiting ${seconds} seconds before trying again...`);
  113. await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
  114. // try again
  115. return await this.getPOIData(depth + 1);
  116. } else {
  117. throw new Error('Data generation still in progress after 5 attempts');
  118. }
  119. }
  120. if (dataVersionQuery.statusCode === 303) {
  121. // redirect to new data asset
  122. const newDataAssets = dataVersionQuery.headers.location;
  123. // download the new data asset and extract records.json
  124. const assetData = await this.downloadAssetPack(newDataAssets);
  125. // save assetData in long-term cache
  126. await this.cache.set('assetData', assetData, 1000 * 60 * 60 * 24 * 365 * 2); // cache for 2 years
  127. // save the current data asset version
  128. await this.cache.set('currentParkDataVersion', assetData.manifestData.version, 1000 * 60 * 60 * 24 * 365 * 2); // cache for 2 years
  129. return assetData.recordsData;
  130. }
  131. // in all other scenarios, return our previously cached data
  132. const assetData = await this.cache.get('assetData');
  133. if (!assetData?.recordsData) {
  134. this.emit('error', new Error(`No asset data found, return code ${dataVersionQuery.statusCode}`));
  135. }
  136. return assetData.recordsData;
  137. }
  138. /**
  139. * Download asset zip file. Extract manifest and records data.
  140. * @param {String} url
  141. * @returns {object}
  142. */
  143. async downloadAssetPack(url) {
  144. const resp = await this.http('GET', url);
  145. // read a single JSON file from a zip object
  146. const readZipFile = async (zip, file) => {
  147. const openReadStream = promisify(zip.openReadStream.bind(zip));
  148. const readStream = await openReadStream(file);
  149. let data = '';
  150. readStream.on('data', (chunk) => {
  151. data += chunk;
  152. });
  153. return new Promise((resolve, reject) => {
  154. readStream.on('end', () => {
  155. try {
  156. data = JSON.parse(data);
  157. return resolve(data);
  158. } catch (e) {
  159. return reject(new Error(`JSON parse error extracting ${file.fileName}: ${e}`));
  160. }
  161. });
  162. });
  163. }
  164. // unzip data
  165. const zip = await unzipFromBuffer(resp.body, {
  166. lazyEntries: true,
  167. });
  168. let manifestData;
  169. let recordsData;
  170. const filenames = [
  171. 'manifest.json',
  172. 'records.json',
  173. ];
  174. zip.on('entry', async (file) => {
  175. if (filenames.indexOf(file.fileName) > -1) {
  176. // read the file
  177. const data = await readZipFile(zip, file);
  178. // store the data
  179. if (file.fileName === 'manifest.json') {
  180. manifestData = data;
  181. } else if (file.fileName === 'records.json') {
  182. recordsData = data;
  183. }
  184. }
  185. zip.readEntry();
  186. });
  187. return new Promise((resolve, reject) => {
  188. zip.on('end', () => {
  189. if (!manifestData) {
  190. return reject(new Error('No manifest.json found in zip file'));
  191. }
  192. if (!recordsData) {
  193. return reject(new Error('No records.json found in zip file'));
  194. }
  195. return resolve({
  196. manifestData,
  197. recordsData,
  198. });
  199. });
  200. // start reading file...
  201. zip.readEntry();
  202. });
  203. }
  204. /**
  205. * Given a category string, return all category IDs
  206. * eg. "Attractions" will return the "Attractions" category and all child categories, such as "Thrills" etc.
  207. */
  208. async getCategoryIDs(categoryName) {
  209. '@cache|120';
  210. const destinationData = await this.getPOIData();
  211. // find parent category
  212. const cats = [];
  213. const attractionCats = destinationData.Category.filter((x) => {
  214. return extractName(x.Name) === categoryName;
  215. });
  216. if (!attractionCats || attractionCats.length === 0) return [];
  217. // return main category
  218. // cats.push(attractionCat._id);
  219. cats.push(...attractionCats.map((x) => x._id));
  220. // concat child cateories too
  221. attractionCats.forEach((parentCat) => {
  222. cats.push(...destinationData.Category.filter((x) => {
  223. return x.Parent == parentCat._id;
  224. }).map((x) => x._id));
  225. });
  226. return cats;
  227. }
  228. /**
  229. * Helper function to build a basic entity document
  230. * Useful to avoid copy/pasting
  231. * @param {object} data
  232. * @returns {object}
  233. */
  234. buildBaseEntityObject(data) {
  235. const entity = Destination.prototype.buildBaseEntityObject.call(this, data);
  236. entity._id = `${data?._id || undefined}`;
  237. entity.name = extractName(data?.Name || undefined);
  238. if (data?.DirectionsLocation) {
  239. try {
  240. const loc = data.DirectionsLocation.split(',').map(Number);
  241. entity.location = {
  242. latitude: loc[0],
  243. longitude: loc[1],
  244. };
  245. } catch (e) {
  246. // ignore
  247. }
  248. }
  249. if (data?.Location) {
  250. try {
  251. const loc = data.Location.split(',').map(Number);
  252. entity.location = {
  253. latitude: loc[0],
  254. longitude: loc[1],
  255. };
  256. } catch (e) {
  257. // ignore
  258. }
  259. }
  260. entity._tags = [];
  261. // minimum height
  262. if (data?.MinimumHeightRequirement !== undefined) {
  263. entity._tags.push({
  264. id: 'minimumHeight',
  265. value: Math.floor(data.MinimumHeightRequirement * 100),
  266. });
  267. }
  268. // minimum height unaccompanied
  269. if (data?.MinimumUnaccompaniedHeightRequirement !== null && data?.MinimumUnaccompaniedHeightRequirement !== undefined) {
  270. entity._tags.push({
  271. id: 'minimumHeightUnaccompanied',
  272. value: Math.floor(data.MinimumUnaccompaniedHeightRequirement * 100),
  273. });
  274. }
  275. return entity;
  276. }
  277. /**
  278. * Build the destination entity representing this destination
  279. */
  280. async buildDestinationEntity() {
  281. const destinationData = await this.getPOIData();
  282. // TODO - hardcode or find a better way to find our destination data
  283. // Note: What about merlin resorts with multiple parks? i.e, Legoland Orlando - any others?
  284. if (!destinationData?.Resort) {
  285. throw new Error('No resort data found');
  286. }
  287. if (destinationData.Resort.length > 1) {
  288. throw new Error('Multiple resorts found in destination data');
  289. }
  290. const resortData = destinationData.Resort[0];
  291. if (!resortData) throw new Error('No resort data found');
  292. return {
  293. ...this.buildBaseEntityObject(resortData),
  294. _id: this.config.destinationId,
  295. slug: this.config.destinationId,
  296. entityType: entityType.destination,
  297. };
  298. }
  299. /**
  300. * Build the park entities for this destination
  301. */
  302. async buildParkEntities() {
  303. const destinationData = await this.getPOIData();
  304. if (!destinationData?.Resort?.length) {
  305. throw new Error('No resort data found');
  306. }
  307. const park = destinationData.Resort[0];
  308. const parkObj = {
  309. ...this.buildBaseEntityObject(park),
  310. _parentId: this.config.destinationId,
  311. _destinationId: this.config.destinationId,
  312. entityType: entityType.park,
  313. _id: this.config.parkId,
  314. };
  315. parkObj.name = parkObj.name.replace(/\s*Resort/, '');
  316. parkObj.slug = parkObj.name.toLowerCase().replace(/[^\w]/g, '');
  317. return [parkObj];
  318. }
  319. /**
  320. * Helper function to generate entities from a list of category names
  321. * @param {Array<String>} categoryNames
  322. * @returns {Array<Object>}
  323. */
  324. async _buildEntitiesFromCategories(categoryNames, parentId, attributes = {}) {
  325. const categoryIDs = [];
  326. for (let i = 0; i < categoryNames.length; i++) {
  327. const categories = await this.getCategoryIDs(categoryNames[i]);
  328. categoryIDs.push(...categories);
  329. }
  330. const categoryData = await this.getPOIData();
  331. const ents = categoryData.Item.filter((x) => categoryIDs.indexOf(x.Category) >= 0);
  332. return ents.map((x) => {
  333. return {
  334. ...this.buildBaseEntityObject(x),
  335. _parentId: parentId,
  336. _parkId: parentId,
  337. _destinationId: this.config.destinationId,
  338. ...attributes,
  339. };
  340. });
  341. }
  342. /**
  343. * Build the attraction entities for this destination
  344. */
  345. async buildAttractionEntities() {
  346. return this._buildEntitiesFromCategories(['Attractions', 'Rides', 'Water Rides', 'Thrill Rides', 'Coasters', 'Intense Thrills', 'Rides & Shows', 'Thrills & Mini-Thrills', 'RIDES', ''], this.config.parkId, {
  347. entityType: entityType.attraction,
  348. attractionType: attractionType.ride,
  349. });
  350. }
  351. /**
  352. * Build the show entities for this destination
  353. */
  354. async buildShowEntities() {
  355. return this._buildEntitiesFromCategories(['Shows', 'Show', 'Live Shows'], this.config.parkId, {
  356. entityType: entityType.show,
  357. });
  358. }
  359. /**
  360. * Build the restaurant entities for this destination
  361. */
  362. async buildRestaurantEntities() {
  363. return this._buildEntitiesFromCategories(['Restaurants', 'Fast Food', 'Snacks', 'Healthy Food', 'Food', 'Dining', 'Food & Drink'], this.config.parkId, {
  364. entityType: entityType.restaurant,
  365. });
  366. }
  367. async _fetchLiveData() {
  368. '@cache|1'; // cache for 1 minute
  369. const resp = await this.http('GET', `https://live-data.attractions.io/${this.config.apiKey}.json`);
  370. return resp.body.entities;
  371. }
  372. /**
  373. * @inheritdoc
  374. */
  375. async buildEntityLiveData() {
  376. const liveData = await this._fetchLiveData();
  377. // only return attractions
  378. const attrs = await this.getAttractionEntities();
  379. const attrIds = attrs.map((x) => `${x._id}`);
  380. const validEnts = liveData.Item.records.filter((x) => {
  381. return attrIds.indexOf(`${x._id}`) >= 0;
  382. });
  383. return validEnts.map((x) => {
  384. const data = {
  385. _id: `${x._id}`,
  386. status: (!!x.IsOperational) ? statusType.operating : statusType.closed,
  387. };
  388. if (x.QueueTime !== undefined && x.QueueTime !== null && !isNaN(x.QueueTime)) {
  389. data.queue = {
  390. [queueType.standBy]: {
  391. waitTime: Math.floor(x.QueueTime / 60),
  392. },
  393. };
  394. } else if (x.QueueTime === null) {
  395. // null wait time should still be recorded
  396. data.queue = {
  397. [queueType.standBy]: {
  398. waitTime: null,
  399. },
  400. };
  401. }
  402. return data;
  403. });
  404. }
  405. async _fetchCalendar() {
  406. '@cache|120'; // cache for 2 hours
  407. const scheduleData = await this.http('GET', this.config.calendarURL);
  408. return scheduleData.body;
  409. }
  410. /**
  411. * Return schedule data for all scheduled entities in this destination
  412. * Eg. parks
  413. * @returns {array<object>}
  414. */
  415. async buildEntityScheduleData() {
  416. const scheduleData = await this._fetchCalendar();
  417. if (!scheduleData || (!scheduleData.Locations && !scheduleData.locations) || (!scheduleData.Locations?.length && !scheduleData.locations?.length)) {
  418. return [
  419. {
  420. _id: this.config.parkId,
  421. schedule: [],
  422. }
  423. ];
  424. }
  425. // assume 1 park per destination
  426. const days = scheduleData.Locations ? scheduleData.Locations[0].days : scheduleData.locations[0].days;
  427. // various random formats the calendar API can return in
  428. const hourFormats = [
  429. {
  430. // eg. 9:30am - 7pm
  431. regex: /^(\d{1,2}):(\d{1,2})([a|p]m)\s*\-\s*(\d{1,2})([a|p]m)$/,
  432. process: (match, date) => {
  433. return {
  434. openingTime: date.clone().hour(Number(match[1]) + (match[3] === 'pm' ? 12 : 0)).minute(Number(match[2])).second(0).millisecond(0),
  435. closingTime: date.clone().hour(Number(match[4]) + (match[5] === 'pm' ? 12 : 0)).minute(0).second(0).millisecond(0),
  436. };
  437. },
  438. },
  439. {
  440. // eg. 10am - 5pm
  441. regex: /^(\d{1,2})([a|p]m)\s*\-\s*(\d{1,2})([a|p]m)$/,
  442. process: (match, date) => {
  443. return {
  444. openingTime: date.clone().hour(Number(match[1]) + (match[2] === 'pm' ? 12 : 0)).minute(0).second(0).millisecond(0),
  445. closingTime: date.clone().hour(Number(match[3]) + (match[4] === 'pm' ? 12 : 0)).minute(0).second(0).millisecond(0),
  446. };
  447. },
  448. },
  449. {
  450. // eg. 10:00 - 17:00
  451. regex: /^(\d{1,2}):(\d{1,2})\s*\-\s*(\d{1,2}):(\d{1,2})$/,
  452. process: (match, date) => {
  453. return {
  454. openingTime: date.clone().hour(Number(match[1])).minute(Number(match[2])).second(0).millisecond(0),
  455. closingTime: date.clone().hour(Number(match[3])).minute(Number(match[4])).second(0).millisecond(0),
  456. };
  457. },
  458. },
  459. ];
  460. const schedule = days.map((x) => {
  461. const date = moment(x.key, 'YYYYMMDD').tz(this.config.timezone, true);
  462. for (let i = 0; i < hourFormats.length; i++) {
  463. const format = hourFormats[i];
  464. const match = format.regex.exec(x.openingHours);
  465. if (!match) continue;
  466. const times = format.process(match, date);
  467. return {
  468. "date": date.format('YYYY-MM-DD'),
  469. "type": "OPERATING",
  470. "openingTime": times.openingTime.format(),
  471. "closingTime": times.closingTime.format(),
  472. };
  473. }
  474. return null;
  475. }).filter((x) => !!x);
  476. return [
  477. {
  478. _id: this.config.parkId,
  479. schedule,
  480. }
  481. ];
  482. }
  483. }
  484. export class AltonTowers extends AttractionsIO {
  485. constructor(config = {}) {
  486. config.destinationId = config.destinationId || 'altontowersresort';
  487. config.parkId = config.parkId || 'altontowers';
  488. config.initialDataVersion = config.initialDataVersion || '2021-07-06T07:48:43Z';
  489. config.appBuild = config.appBuild || 293;
  490. config.appVersion = config.appVersion || '5.3';
  491. super(config);
  492. }
  493. }
  494. export class ThorpePark extends AttractionsIO {
  495. constructor(config = {}) {
  496. config.destinationId = config.destinationId || 'thorpeparkresort';
  497. config.parkId = config.parkId || 'thorpepark';
  498. config.initialDataVersion = config.initialDataVersion || '2021-04-15T15:28:08Z';
  499. config.appBuild = config.appBuild || 299;
  500. config.appVersion = config.appVersion || '1.4';
  501. super(config);
  502. }
  503. }
  504. export class ChessingtonWorldOfAdventures extends AttractionsIO {
  505. constructor(config = {}) {
  506. config.destinationId = config.destinationId || 'chessingtonworldofadventuresresort';
  507. config.parkId = config.parkId || 'chessingtonworldofadventures';
  508. config.initialDataVersion = config.initialDataVersion || '2021-08-19T09:59:06Z';
  509. config.appBuild = config.appBuild || 178;
  510. config.appVersion = config.appVersion || '3.3';
  511. super(config);
  512. }
  513. }
  514. export class LegolandWindsor extends AttractionsIO {
  515. constructor(config = {}) {
  516. config.destinationId = config.destinationId || 'legolandwindsorresort';
  517. config.parkId = config.parkId || 'legolandwindsor';
  518. config.initialDataVersion = config.initialDataVersion || '2021-08-20T08:22:20Z';
  519. config.appBuild = config.appBuild || 113;
  520. config.appVersion = config.appVersion || '2.4';
  521. super(config);
  522. }
  523. }
  524. export class LegolandOrlando extends AttractionsIO {
  525. constructor(config = {}) {
  526. config.timezone = config.timezone || 'America/New_York';
  527. config.destinationId = config.destinationId || 'legolandorlandoresort';
  528. config.parkId = config.parkId || 'legolandorlando';
  529. config.initialDataVersion = config.initialDataVersion || '2021-08-09T15:48:56Z';
  530. config.appBuild = config.appBuild || 115;
  531. config.appVersion = config.appVersion || '1.6.1';
  532. super(config);
  533. }
  534. }
  535. export class LegolandCalifornia extends AttractionsIO {
  536. constructor(config = {}) {
  537. config.timezone = config.timezone || 'America/Los_Angeles';
  538. config.destinationId = config.destinationId || 'legolandcaliforniaresort';
  539. config.parkId = config.parkId || 'legolandcalifornia';
  540. config.initialDataVersion = config.initialDataVersion || '';//'2023-03-15T16:27:19Z';
  541. config.appBuild = config.appBuild || 800000074;
  542. config.appVersion = config.appVersion || '8.4.11';
  543. super(config);
  544. }
  545. }
  546. export class LegolandBillund extends AttractionsIO {
  547. constructor(config = {}) {
  548. config.destinationId = config.destinationId || 'legolandbillundresort';
  549. config.parkId = config.parkId || 'legolandbillund';
  550. config.initialDataVersion = config.initialDataVersion || '2023-10-31T09:22:05Z';
  551. config.timezone = config.timezone || 'Europe/Copenhagen';
  552. config.appBuild = config.appBuild || 162;
  553. config.appVersion = config.appVersion || '3.4.17';
  554. super(config);
  555. }
  556. }
  557. export class LegolandDeutschland extends AttractionsIO {
  558. constructor(config = {}) {
  559. config.destinationId = config.destinationId || 'legolanddeutschlandresort';
  560. config.parkId = config.parkId || 'legolanddeutschland';
  561. config.initialDataVersion = config.initialDataVersion || '';
  562. config.timezone = config.timezone || 'Europe/Berlin';
  563. config.appBuild = config.appBuild || 113;
  564. config.appVersion = config.appVersion || '1.4.15';
  565. super(config);
  566. }
  567. }
  568. export class Gardaland extends AttractionsIO {
  569. constructor(config = {}) {
  570. config.timezone = config.timezone || 'Europe/Rome';
  571. config.destinationId = config.destinationId || 'gardalandresort';
  572. config.parkId = config.parkId || 'gardaland';
  573. config.initialDataVersion = config.initialDataVersion || '2020-10-27T08:40:37Z';
  574. config.appBuild = config.appBuild || 119;
  575. config.appVersion = config.appVersion || '4.2';
  576. super(config);
  577. }
  578. }
  579. export class HeidePark extends AttractionsIO {
  580. constructor(config = {}) {
  581. config.timezone = config.timezone || 'Europe/Berlin';
  582. config.destinationId = config.destinationId || 'heideparkresort';
  583. config.parkId = config.parkId || 'heidepark';
  584. config.initialDataVersion = config.initialDataVersion || '2022-05-10T09:00:46Z';
  585. config.appBuild = config.appBuild || 302018;
  586. config.appVersion = config.appVersion || '4.0.5';
  587. super(config);
  588. }
  589. }
  590. export class Knoebels extends AttractionsIO {
  591. constructor(config = {}) {
  592. config.timezone = config.timezone || 'America/New_York';
  593. config.destinationId = config.destinationId || 'knoebels';
  594. config.parkId = config.parkId || 'knoebelspark';
  595. config.initialDataVersion = config.initialDataVersion || '2024-05-05T15:08:58Z';
  596. config.appBuild = config.appBuild || 48;
  597. config.appVersion = config.appVersion || '1.1.2';
  598. super(config);
  599. }
  600. }