From b22356880651324ded037ca4ccd8901337c09be1 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sun, 23 Jun 2019 22:48:18 +0100 Subject: [PATCH 1/6] [+] Initial Island H20 Live park support --- lib/islandh20live/islandh20live.js | 117 +++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 lib/islandh20live/islandh20live.js diff --git a/lib/islandh20live/islandh20live.js b/lib/islandh20live/islandh20live.js new file mode 100644 index 00000000..30cf507c --- /dev/null +++ b/lib/islandh20live/islandh20live.js @@ -0,0 +1,117 @@ +const crypto = require('crypto'); +const Park = require('../park'); + +const sApiBase = Symbol('Island H20 Live Park API Base URL'); +const sTokenSalt = Symbol('Island H20 Token Salt'); +const sAppID = Symbol('App ID'); + +/** + * Implements the Island H20 Live API framework. + * @class + * @extends Park + */ +class IslandH20Live extends Park { + /** + * Create new IslandH20Live Object. + * @param {Object} [options] + * @param {String} [options.apiBase] Optional base URL for API requests + */ + constructor(options = {}) { + options.name = options.name || 'IslandH20Live'; + + // Island H20 Live Entrance coordinates + options.latitude = options.latitude || 48.268931; + options.longitude = options.longitude || 7.721559; + + // park's timezone + options.timezone = 'Europe/Berlin'; + + // inherit from base class + super(options); + + // accept overriding the API base URL + this[sApiBase] = options.apiBase || 'https://horizon.vantagelabs.co/vapi/'; + this[sTokenSalt] = options.tokenSalt || 'scfj8ut3'; + this[sAppID] = options.appID || 'vantagega'; + } + + GetAPIToken() { + return this.Cache.Wrap('token', () => { + return this.HTTP({ + url: `${this[sApiBase]}login.php`, + method: 'GET', + data: { + id: this[sAppID], + }, + forceJSON: true, + }).then((data) => { + if (data.result !== 'OK') { + return Promise.reject(new Error(`Unable to login to Island H20 Live: ${data.mantext}`)); + } + + // generate API token using our salt and random string from the login script + const apiToken = crypto.createHash('md5').update(`${this[sAppID]}+${this[sTokenSalt]}+${data.data.random}`).digest('hex'); + + return Promise.resolve(apiToken); + }); + }, 60 * 60 * 2); // cache for 2 hours + } + + /** Wrapper for making HTTP requests against the Island H20 Live API */ + MakeAPIRequest(options) { + return this.GetAPIToken().then((token) => { + // inject our API token and app ID to the HTTP options + if (!options.data) { + options.data = {}; + } + options.data.token = token; + options.data.id = this[sAppID]; + + // default these to POST + if (!options.method) { + options.method = 'POST'; + } + + options.forceJSON = true; + + return this.HTTP(options); + }); + } + + FetchWaitTimes() { + return this.MakeAPIRequest({ + url: `${this[sApiBase]}ListAttractions.php`, + }).then((data) => { + if (data.result === 'OK') { + data.data.forEach((ride) => { + this.UpdateRide(ride.id, { + name: ride.name, + // TODO - down/closed status? + waitTime: ride.waittime, + meta: { + // yep, rides have gamified points here. I love it, so add these to the meta data + points: ride.perks, + }, + }); + }); + + return Promise.resolve(); + } + + return Promise.reject(new Error(`Island H20 API returned unexpected response ${data.result}`)); + }); + } + + // TODO - opening times + FetchOpeningTimes() { + return Promise.reject(new Error('Not Implemented')); + } +} + +// export the class +module.exports = IslandH20Live; + +if (!module.parent) { + const A = new IslandH20Live(); + A.GetWaitTimes().then(console.log); +} From 25a47c3bdbca9d98b50eac8c2c06c55bcad82a0c Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Mon, 24 Jun 2019 13:53:53 +0100 Subject: [PATCH 2/6] [+] Add Island H20 Live to parks list --- lib/index.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/index.js b/lib/index.js index a2ecf4d1..3db28fde 100644 --- a/lib/index.js +++ b/lib/index.js @@ -80,6 +80,8 @@ const ThorpePark = require('./merlinparks/thorpepark'); const ChessingtonWorldOfAdventures = require('./merlinparks/chessingtonworldofadventures'); // Bellewaerde const Bellewaerde = require('./bellewaerde/bellewaerde'); +// Island H20 Live +const IslandH20Live = require('./islandh20live/islandh20live'); // === Expose Parks === @@ -163,6 +165,8 @@ exports.AllParks = [ ChessingtonWorldOfAdventures, // Bellewaerde Bellewaerde, + // Island H20 Live + IslandH20Live, ]; // export all parks by name in a JavaScript object too @@ -244,4 +248,6 @@ exports.Parks = { ChessingtonWorldOfAdventures, // Bellewaerde Bellewaerde, + // Island H20 Live + IslandH20Live, }; From da205f4d64e7c818cfad133e88f441f49e95b028 Mon Sep 17 00:00:00 2001 From: Thomas Stoeckert Date: Thu, 5 Sep 2019 08:30:20 -0400 Subject: [PATCH 3/6] Updated park location information for IslandH20 --- lib/islandh20live/islandh20live.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/islandh20live/islandh20live.js b/lib/islandh20live/islandh20live.js index 30cf507c..4f445cca 100644 --- a/lib/islandh20live/islandh20live.js +++ b/lib/islandh20live/islandh20live.js @@ -20,11 +20,11 @@ class IslandH20Live extends Park { options.name = options.name || 'IslandH20Live'; // Island H20 Live Entrance coordinates - options.latitude = options.latitude || 48.268931; - options.longitude = options.longitude || 7.721559; + options.latitude = options.latitude || 28.343379; + options.longitude = options.longitude || -81.606582; // park's timezone - options.timezone = 'Europe/Berlin'; + options.timezone = 'America/New_York'; // inherit from base class super(options); From 546f65c1502b2c1366775b3db87131f22a815487 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sun, 20 Oct 2019 22:44:31 +0100 Subject: [PATCH 4/6] [+] Add initial implementation of calendar * Setup Wix GCal parser to grab times from islandh2o's site * Still missing some details on how to parse weirder dates * All park dates are entered onto a shared Google calendar and shown on the site as a widget * All entries are written by hand, so no standard formatting exists --- lib/islandh20live/gcal.js | 129 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 lib/islandh20live/gcal.js diff --git a/lib/islandh20live/gcal.js b/lib/islandh20live/gcal.js new file mode 100644 index 00000000..7d14d977 --- /dev/null +++ b/lib/islandh20live/gcal.js @@ -0,0 +1,129 @@ +// basic gcal API fetcher for Wix calendars +// built for H20 Live park API +const needle = require('needle'); +const Moment = require('moment'); +const cache = require('../cache'); + +const sCalendarID = Symbol('Calendar ID for Caching'); +const sCompId = Symbol('Wix Comp ID'); +const sInstanceID = Symbol('Wix Instance ID'); +const sWixBaseURL = Symbol('Wix Base URL'); +const sTimezone = Symbol('Timezone of calendar'); + +function StringMatchesToHourMinutes(Hours, Minutes, APM) { + // switch to 24-hour clock + let H = parseInt(Hours, 10); + if (APM.toLowerCase() === 'pm' && H <= 12) { + H += 12; + } + const M = Minutes === undefined ? 0 : parseInt(Minutes, 10); + + return `${H < 10 ? '0' : ''}${H}:${M < 10 ? '0' : ''}${M}:00`; +} + +class WixGCal { + constructor(options) { + if (!options.instanceID) throw new Error('Missing instance ID for Wix GCal calendar'); + this[sInstanceID] = options.instanceID; + + if (!options.compId) throw new Error('Missing compId for Wix GCal calendar'); + this[sCompId] = options.compId; + + this[sWixBaseURL] = options.wixBaseURL || 'http://google-calendar.galilcloud.wixapps.net/'; + + if (!options.id) throw new Error('Missing unique calendar ID for caching'); + this[sCalendarID] = options.id; + + if (!options.timezone) throw new Error('Missing calendar timezone'); + this[sTimezone] = options.timezone; + } + + /** + * Parse a Wix calendar page for the GCals API key to use for our calendar + */ + FetchGCalAPIKey() { + return cache.WrapGlobal(`wixGCalAPIKey_${this[sCalendarID]}`, () => { + return needle('GET', `${this[sWixBaseURL]}`, { + compId: this[sCompId], + instance: this[sInstanceID], + }).then((HTMLBody) => { + const apiKeyMatch = /GOOGLE_CALENDAR_API_KEY"\s*:\s*"([^"]+)"/.exec(HTMLBody.body); + if (apiKeyMatch) { + return Promise.resolve(apiKeyMatch[1]); + } + return Promise.resolve(undefined); + }); + }, 60 * 60 * 24); // cache key for 24 hours + } + + GetEvents(start, end) { + return this.FetchGCalAPIKey().then((apiKey) => { + return needle('GET', `https://www.googleapis.com/calendar/v3/calendars/arcadetracker.com_9oj2tjeqnportmc6trgf7iqei4%40group.calendar.google.com/events`, { + orderBy: 'startTime', + key: apiKey, + timeMin: `${start}T00:00:00+00:00`, + timeMax: `${end}T00:00:00+00:00`, + singleEvents: true, + maxResults: 9999, + }).then((resp) => { + const calendar = []; + + resp.body.items.forEach((item) => { + // skip unknown item types + if (item.kind !== 'calendar#event') return; + + if (!item.start || !item.end) { + return; + } + + // skip closed days + if (item.summary.toLowerCase().indexOf('closed') >= 0) return; + + const date = item.start.date ? Moment(item.start.date, 'YYYY-MM-DD') : Moment(item.start.dateTime.slice(0, 10), 'YYYY-MM-DD'); + + const CalendarEntry = { + date, + }; + + // TODO - some dates have *3* different opening times + // eg. 26th October + // handle this properly and figure out the correct actual time and what is a special event + + if (item.start.dateTime) { + CalendarEntry.openingTime = Moment.tz(item.start.dateTime, 'YYYY-MM-DDTHH:mm:ssz', this[sTimezone]); + CalendarEntry.closingTime = Moment.tz(item.end.dateTime, 'YYYY-MM-DDTHH:mm:ssz', this[sTimezone]); + + CalendarEntry.specialHours = item.summary.indexOf('Current S') >= 0 ? false : true; + CalendarEntry.type = CalendarEntry.specialHours ? item.summary : 'Operating'; + } else { + // search for times + const timesMatch = /(\d{1,2})(?:\:(\d{2}))?\s*([ap]m)\s*-\s*(\d{1,2})(?:\:(\d{2}))?\s*([ap]m)/.exec(item.summary); + if (timesMatch) { + CalendarEntry.openingTime = Moment.tz(`${CalendarEntry.date.format('YYYY-MM-DD')}T${StringMatchesToHourMinutes(timesMatch[1], timesMatch[2], timesMatch[3])}`, 'YYYY-MM-DDTHH:mm:ssz', this[sTimezone]); + // don't worry about whether the event goes over into the next day, the schedule lib will handle this for us + CalendarEntry.closingTime = Moment.tz(`${CalendarEntry.date.format('YYYY-MM-DD')}T${StringMatchesToHourMinutes(timesMatch[4], timesMatch[5], timesMatch[6])}`, 'YYYY-MM-DDTHH:mm:ssz', this[sTimezone]); + CalendarEntry.type = 'Operating'; + } + } + + calendar.push(CalendarEntry); + }); + + return Promise.resolve(calendar); + }); + }); + } +} + +module.exports = WixGCal; + +if (!module.parent) { + const C = new WixGCal({ + id: 'h20live', + compId: 'comp-jw7w0e57', + instanceID: 'y69NdnEfY_z6H4ObCb6MbJD0bhF4AAv6pQddPvY5ZrE.eyJpbnN0YW5jZUlkIjoiZDBkMzIzMTEtMDFiZC00ZjNkLTg4NDYtNTNkYzFkYzkyMzlhIiwiYXBwRGVmSWQiOiIxMjlhY2I0NC0yYzhhLTgzMTQtZmJjOC03M2Q1Yjk3M2E4OGYiLCJtZXRhU2l0ZUlkIjoiNjcwNTEzYzUtYTYyNC00NmU3LTk4OTYtODM1MzBlNWY5YWE2Iiwic2lnbkRhdGUiOiIyMDE5LTEwLTIwVDE0OjA5OjAyLjA4M1oiLCJ1aWQiOm51bGwsImlwQW5kUG9ydCI6IjgyLjIuMS4xOTUvNDE2ODQiLCJ2ZW5kb3JQcm9kdWN0SWQiOm51bGwsImRlbW9Nb2RlIjpmYWxzZSwiYWlkIjoiZTU3MGI4ZmMtZWUwYS00N2I5LWI5MDctODg3NmM5YzdjMTA5IiwiYmlUb2tlbiI6ImI3ZDYzMGQ0LWE3OTktMDlkYS0xMGQwLWQwOGYxMzk2YjkzYyIsInNpdGVPd25lcklkIjoiMjBlOTNiNTEtODA2Yy00MmE3LWE1MzYtYjU4OTZhZGFiM2EwIn0', + timezone: 'America/New_York', + }); + + C.GetEvents('2019-10-20', '2019-12-20').then(console.log); +} From 58a1e09095b0957c56a09a0b78373b549b1907a7 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sun, 20 Oct 2019 22:50:02 +0100 Subject: [PATCH 5/6] [+] Please the lint Gods --- lib/islandh20live/gcal.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/islandh20live/gcal.js b/lib/islandh20live/gcal.js index 7d14d977..a0bc80fc 100644 --- a/lib/islandh20live/gcal.js +++ b/lib/islandh20live/gcal.js @@ -58,7 +58,7 @@ class WixGCal { GetEvents(start, end) { return this.FetchGCalAPIKey().then((apiKey) => { - return needle('GET', `https://www.googleapis.com/calendar/v3/calendars/arcadetracker.com_9oj2tjeqnportmc6trgf7iqei4%40group.calendar.google.com/events`, { + return needle('GET', 'https://www.googleapis.com/calendar/v3/calendars/arcadetracker.com_9oj2tjeqnportmc6trgf7iqei4%40group.calendar.google.com/events', { orderBy: 'startTime', key: apiKey, timeMin: `${start}T00:00:00+00:00`, @@ -93,11 +93,11 @@ class WixGCal { CalendarEntry.openingTime = Moment.tz(item.start.dateTime, 'YYYY-MM-DDTHH:mm:ssz', this[sTimezone]); CalendarEntry.closingTime = Moment.tz(item.end.dateTime, 'YYYY-MM-DDTHH:mm:ssz', this[sTimezone]); - CalendarEntry.specialHours = item.summary.indexOf('Current S') >= 0 ? false : true; + CalendarEntry.specialHours = !(item.summary.indexOf('Current S') >= 0); CalendarEntry.type = CalendarEntry.specialHours ? item.summary : 'Operating'; } else { // search for times - const timesMatch = /(\d{1,2})(?:\:(\d{2}))?\s*([ap]m)\s*-\s*(\d{1,2})(?:\:(\d{2}))?\s*([ap]m)/.exec(item.summary); + const timesMatch = /(\d{1,2})(?::(\d{2}))?\s*([ap]m)\s*-\s*(\d{1,2})(?::(\d{2}))?\s*([ap]m)/.exec(item.summary); if (timesMatch) { CalendarEntry.openingTime = Moment.tz(`${CalendarEntry.date.format('YYYY-MM-DD')}T${StringMatchesToHourMinutes(timesMatch[1], timesMatch[2], timesMatch[3])}`, 'YYYY-MM-DDTHH:mm:ssz', this[sTimezone]); // don't worry about whether the event goes over into the next day, the schedule lib will handle this for us From 3319200ebfc0b707a04446de27d8c4ec5157d6a7 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Sun, 27 Oct 2019 17:57:53 +0000 Subject: [PATCH 6/6] [!] Find calendar instance automatically * Turns out this is generated each page view --- lib/islandh20live/gcal.js | 47 ++++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/lib/islandh20live/gcal.js b/lib/islandh20live/gcal.js index a0bc80fc..7335665c 100644 --- a/lib/islandh20live/gcal.js +++ b/lib/islandh20live/gcal.js @@ -6,9 +6,9 @@ const cache = require('../cache'); const sCalendarID = Symbol('Calendar ID for Caching'); const sCompId = Symbol('Wix Comp ID'); -const sInstanceID = Symbol('Wix Instance ID'); const sWixBaseURL = Symbol('Wix Base URL'); const sTimezone = Symbol('Timezone of calendar'); +const sCalendarURL = Symbol('Calendar Wix Page URL'); function StringMatchesToHourMinutes(Hours, Minutes, APM) { // switch to 24-hour clock @@ -23,19 +23,30 @@ function StringMatchesToHourMinutes(Hours, Minutes, APM) { class WixGCal { constructor(options) { - if (!options.instanceID) throw new Error('Missing instance ID for Wix GCal calendar'); - this[sInstanceID] = options.instanceID; - if (!options.compId) throw new Error('Missing compId for Wix GCal calendar'); this[sCompId] = options.compId; - this[sWixBaseURL] = options.wixBaseURL || 'http://google-calendar.galilcloud.wixapps.net/'; + this[sWixBaseURL] = options.wixBaseURL || 'https://google-calendar.galilcloud.wixapps.net/'; if (!options.id) throw new Error('Missing unique calendar ID for caching'); this[sCalendarID] = options.id; if (!options.timezone) throw new Error('Missing calendar timezone'); this[sTimezone] = options.timezone; + + this[sCalendarURL] = options.calendarURL || undefined; + } + + FetchWixCalendarInstanceID() { + return needle('GET', this[sCalendarURL]).then((resp) => { + const regexSearchForInstanceID = /google-calendar\.galilcloud\.wixapps\.net[^"]+instance=([^&]+)/m; + const match = regexSearchForInstanceID.exec(resp.body); + if (!match) { + return Promise.reject(new Error(`Failed to find Wix calendar ID in url ${this[sCalendarURL]}`)); + } + + return Promise.resolve(match[1]); + }); } /** @@ -43,15 +54,21 @@ class WixGCal { */ FetchGCalAPIKey() { return cache.WrapGlobal(`wixGCalAPIKey_${this[sCalendarID]}`, () => { - return needle('GET', `${this[sWixBaseURL]}`, { - compId: this[sCompId], - instance: this[sInstanceID], - }).then((HTMLBody) => { - const apiKeyMatch = /GOOGLE_CALENDAR_API_KEY"\s*:\s*"([^"]+)"/.exec(HTMLBody.body); - if (apiKeyMatch) { - return Promise.resolve(apiKeyMatch[1]); - } - return Promise.resolve(undefined); + return this.FetchWixCalendarInstanceID().then((instanceID) => { + return needle('GET', `${this[sWixBaseURL]}`, { + compId: this[sCompId], + instance: instanceID, + }, { + headers: { + referer: this[sCalendarURL], + }, + }).then((HTMLBody) => { + const apiKeyMatch = /GOOGLE_CALENDAR_API_KEY"\s*:\s*"([^"]+)"/.exec(HTMLBody.body); + if (apiKeyMatch) { + return Promise.resolve(apiKeyMatch[1]); + } + return Promise.resolve(undefined); + }); }); }, 60 * 60 * 24); // cache key for 24 hours } @@ -121,8 +138,8 @@ if (!module.parent) { const C = new WixGCal({ id: 'h20live', compId: 'comp-jw7w0e57', - instanceID: 'y69NdnEfY_z6H4ObCb6MbJD0bhF4AAv6pQddPvY5ZrE.eyJpbnN0YW5jZUlkIjoiZDBkMzIzMTEtMDFiZC00ZjNkLTg4NDYtNTNkYzFkYzkyMzlhIiwiYXBwRGVmSWQiOiIxMjlhY2I0NC0yYzhhLTgzMTQtZmJjOC03M2Q1Yjk3M2E4OGYiLCJtZXRhU2l0ZUlkIjoiNjcwNTEzYzUtYTYyNC00NmU3LTk4OTYtODM1MzBlNWY5YWE2Iiwic2lnbkRhdGUiOiIyMDE5LTEwLTIwVDE0OjA5OjAyLjA4M1oiLCJ1aWQiOm51bGwsImlwQW5kUG9ydCI6IjgyLjIuMS4xOTUvNDE2ODQiLCJ2ZW5kb3JQcm9kdWN0SWQiOm51bGwsImRlbW9Nb2RlIjpmYWxzZSwiYWlkIjoiZTU3MGI4ZmMtZWUwYS00N2I5LWI5MDctODg3NmM5YzdjMTA5IiwiYmlUb2tlbiI6ImI3ZDYzMGQ0LWE3OTktMDlkYS0xMGQwLWQwOGYxMzk2YjkzYyIsInNpdGVPd25lcklkIjoiMjBlOTNiNTEtODA2Yy00MmE3LWE1MzYtYjU4OTZhZGFiM2EwIn0', timezone: 'America/New_York', + calendarURL: 'https://www.islandh2olive.com/operating-calendar', }); C.GetEvents('2019-10-20', '2019-12-20').then(console.log);