import {attractionType, statusType, queueType, tagType, scheduleType, entityType} from '../parkTypes.js';
//import level from 'level';
import path from 'path';
import moment from 'moment-timezone';
import zlib from 'zlib';
import util, {callbackify} from 'util';
import {fileURLToPath} from 'url';
import {promises as fs} from 'fs';
import Destination from '../destination.js';
import sift from 'sift';
import levelup from 'levelup';
import leveldown from 'leveldown';
const zDecompress = util.promisify(zlib.unzip);
const zCompress = util.promisify(zlib.deflate);
/**
* Shanghai Disneyland Resort
*/
export class ShanghaiDisneylandResort extends Destination {
/**
* Create a new ShanghaiDisneylandResort
* @param {object} options
*/
constructor(options = {}) {
options.name = options.name || 'Shanghai Disney Resort';
options.timezone = options.timezone || 'Asia/Shanghai';
options.apiBase = options.apiBase || '';
options.apiAuth = options.apiAuth || '';
// options.parkId = options.parkId || 'desShanghaiDisneyland';
options.configPrefixes = ['SHDR'].concat(options.configPrefixes || []);
options.parkIds = options.parkIds || ['desShanghaiDisneyland'];
super(options);
// here we can validate the resulting this.config object
if (!this.config.apiBase) throw new Error('Missing Shanghai Disney Resort apiBase');
if (!this.config.apiAuth) throw new Error('Missing Shanghai Disney Resort apiAuth');
// add our auth token to any API requests
this.http.injectForDomain({
hostname: new URL(this.config.apiBase).hostname,
}, async (method, url, data, options) => {
const accessToken = await this.getAccessToken();
options.headers['Authorization'] = `BEARER ${accessToken}`;
// gather in English where possible
options.headers['Accept-Language'] = 'en';
});
// catch unauthorised requests and force a token refresh
this.http.injectForDomainResponse({
hostname: new URL(this.config.apiBase).hostname,
}, async (resp) => {
// if we get an unauthorised response, refetch our access_token
if (resp?.statusCode === 401) {
this.log('Got unauthorised response, refresh access_token...');
this.cache.set('access_token', undefined, -1);
return undefined;
}
if (resp?.statusCode === 500) {
// API will just return 500 fairly often, throw an error to use cached data instead
throw new Error('SHDR API returned 500 error response to fetching facility data');
}
return resp;
});
// setup our local database for our attraction data
this.db = levelup(leveldown(path.join(process.cwd(), 'db.shdr')))
}
/**
* Get an access token for making requests to the API
*/
async getAccessToken() {
let expiresIn = 0;
return await this.cache.wrap('access_token', async () => {
const resp = await this.http('POST', this.config.apiAuth, {
grant_type: 'assertion',
assertion_type: 'public',
client_id: 'DPRD-SHDR.MOBILE.ANDROID-PROD',
});
// remember the expirey time sent by the server
expiresIn = resp?.body?.expires_in;
const token = resp?.body?.access_token;
this.log(`Got new access_token ${token}`);
return token;
}, () => {
// return the expires_in field we got from our response, or 899 seconds, the default
return (expiresIn || 899) * 1000;
});
}
/**
* Extract the key information from an attraction entity doc ID
* @param {string|object} doc Either the document or the document ID
* @return {object}
*/
extractEntityData(doc) {
const id = doc?.id || doc;
const parts = id.split(';');
const ret = {
entityId: id.replace(/;cacheId=\d*;?/, ''),
};
ret.id = parts[0].replace(/id=/, '');
parts.forEach((str, idx) => {
if (idx === 0) return;
const keyVal = str.split('=');
if (keyVal && keyVal.length == 2) {
ret[keyVal[0]] = keyVal[1];
}
});
return ret;
}
/**
* Get all stored entities
* @return {array<string>}
*/
async getAllEntityKeys() {
return new Promise((resolve) => {
const keys = [];
const keyStream = this.db.createKeyStream();
keyStream.on('data', (data) => {
keys.push(data);
});
keyStream.on('end', () => {
return resolve(keys);
});
});
}
/**
* Get an entity doc using it's ID from the local database
* @param {string} id
*/
async getEntity(id) {
const doc = await this.db.get(id);
try {
const jsonDoc = JSON.parse(doc);
return jsonDoc;
} catch (e) {
console.trace(`Error parsing SHDR doc ${id}: ${doc}`);
this.emit('error', e);
}
return undefined;
}
/**
* Get all attraction data
*/
async getAttractionData() {
// cache 2 hours
'@cache|120';
try {
await this._refreshAttractionData();
} catch (e) {
this.log('Failed to refresh Shanghai facilities data', e);
}
const docs = await this.getAllEntityKeys();
const allEnts = await Promise.all(docs.map((docId) => {
return this.getEntity(docId);
}));
// HACK - manually add Hot Persuit if it's missing
const existingEnt = allEnts.find((x) => x?.attractionID === 'attZootopiaHotPursuit');
if (existingEnt) return allEnts;
const hotPersuit = {
attractionId: "attZootopiaHotPursuit",
id: "attZootopiaHotPursuit;entityType=Attraction;destination=shdr",
type: "Attraction",
cacheId: "attZootopiaHotPursuit;entityType=Attraction;destination=shdr;cacheId=-2111797129",
name: "Zootopia: Hot Pursuit",
ancestors: [
{
id: "shdr;entityType=destination;destination=shdr",
type: "destination",
},
{
id: "desShanghaiDisneyland;entityType=theme-park;destination=shdr",
type: "theme-park",
},
],
relatedLocations: [
{
id: "attZootopiaHotPursuit;entityType=Attraction;destination=shdr",
type: "primaryLocation",
name: "Zootopia: Hot Pursuit",
coordinates: [
{
latitude: "31.15180406306",
longitude: "121.665299510689",
type: "Guest Entrance",
},
],
ancestors: [
{
id: "shdr;entityType=destination;destination=shdr",
type: "destination",
},
{
id: "desShanghaiDisneyland;entityType=theme-park;destination=shdr",
type: "theme-park",
},
],
},
],
facets: [],
fastPass: "false",
webLink: "",
policies: [],
};
allEnts.push(hotPersuit);
return allEnts;
}
/**
* Refresh attraction data, getting new and updated documents from the API
*/
async _refreshAttractionData() {
const docs = await this.getAllEntityKeys();
const entityCacheString = [];
await Promise.allSettled(docs.map(async (id) => {
const doc = await this.getEntity(id);
if (doc !== undefined) {
entityCacheString.push(`id=${doc.cacheId}`);
}
}));
const resp = await this.http(
'POST',
`${this.config.apiBase}explorer-service/public/destinations/shdr;entityType=destination/facilities?region=cn`,
entityCacheString.join('&'),
{
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
retries: 0,
},
);
const addReplaceDoc = async (doc) => {
const info = this.extractEntityData(doc);
await this.db.put(info.id, JSON.stringify({
attractionId: info.id,
...doc,
}));
};
await Promise.all(resp.body.added.map((add) => {
this.log(`Adding entity ${add?.id}`);
return addReplaceDoc(add);
}));
await Promise.all(resp.body.updated.map((updated) => {
this.log(`Updating entity ${updated?.id}`);
return addReplaceDoc(updated);
}));
await Promise.all(resp.body.removed.map((removed) => {
if (removed === undefined) return;
// removed just gives us the ID
this.log(`Removing entity ${removed}`);
const info = this.extractEntityData(removed);
return this.db.del(info.id);
}));
}
/**
* @inheritdoc
*/
async _buildAttractionObject(attractionID) {
const data = await this.getAttractionData();
if (data === undefined) {
console.error('Failed to fetch SHDR attraction data');
return undefined;
}
const entryInfo = this.extractEntityData(attractionID);
const attr = data.find((x) => x.attractionId === entryInfo.id);
if (attr === undefined) {
return undefined;
}
if (attr.name.toLowerCase().indexOf('standby pass required') >= 0) {
// process "standby pass" attractions separately in the live data of the "normal" attraction
const originalName = attr.name.slice(0, ' (Standby Pass Required)'.length);
const originalAttraction = data.find((x) => x.name === originalName);
if (originalAttraction !== undefined) {
// store a mapping of attraction -> standby version in our database
await this.db.put(`standbypass_${originalAttraction.attractionId}`, JSON.stringify(attr.attractionId));
}
return undefined;
}
// TODO - only return Attractions for now, return shows etc. too
if (attr.type !== 'Attraction') return undefined;
let type = attractionType.other;
switch (attr.type) {
// TODO - support other types
case 'Attraction':
type = attractionType.ride;
break;
}
const tags = [];
tags.push({
type: tagType.fastPass,
value: (attr.fastPass == 'true'),
});
const location = attr.relatedLocations.find((x) => x.type === 'primaryLocation' && x.coordinates.length > 0);
if (location !== undefined) {
tags.push({
key: 'location',
type: tagType.location,
value: {
longitude: Number(location.coordinates[0].longitude),
latitude: Number(location.coordinates[0].latitude),
},
});
}
tags.push({
type: tagType.unsuitableForPregnantPeople,
value: attr.policies && (attr.policies.find((x) => x.id === 'policyExpectantMothers') !== undefined),
});
const hasMinHeight = attr.facets.find((x) => x.group === 'height');
if (hasMinHeight !== undefined) {
const minHeight = /(\d+)cm-\d+in-or-taller/.exec(hasMinHeight.id);
if (minHeight) {
tags.push({
type: tagType.minimumHeight,
key: 'minimumHeight',
value: {
unit: 'cm',
value: Number(minHeight[1]),
},
});
}
}
tags.push({
type: tagType.mayGetWet,
value: attr.policies && (attr.policies.find((x) =>
x?.descriptions && x.descriptions.length > 0 && (x.descriptions[0].text.indexOf('You may get wet.') >= 0),
) !== undefined),
});
return {
name: attr.name,
type,
tags,
};
}
/**
* Dump the SHDR database to a buffer
* @return {buffer}
*/
async _dumpDB() {
const keys = await this.getAllEntityKeys();
const docs = {};
await Promise.allSettled(keys.map(async (key) => {
docs[key] = await this.db.get(key);
}));
return await zCompress(JSON.stringify(docs));
}
/**
* Load a SHDR database from an existing buffer
* @param {buffer} buff
*/
async _loadDB(buff) {
const data = await zDecompress(buff);
const json = JSON.parse(data.toString('utf8'));
await Promise.allSettled(Object.keys(json).map(async (key) => {
await this.db.put(key, json[key]);
}));
}
/**
* @inheritdoc
*/
async _init() {
// restore backup of data if we haven't yet started syncing SHDR data
const hasInitialData = await this.getAllEntityKeys();
if (hasInitialData.length === 0) {
console.log('Restoring SHDR backup before syncing...');
const thisDir = path.dirname(fileURLToPath(import.meta.url));
const backupFile = path.join(thisDir, 'shdr_data.gz');
const backupData = await fs.readFile(backupFile);
await this._loadDB(backupData);
}
}
/**
* 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.name = data?.name;
if (data?.relatedLocations) {
const loc = data.relatedLocations.find((x) => x.type === 'primaryLocation' && x.coordinates.length > 0);
if (loc) {
entity.location = {
longitude: Number(loc.coordinates[0].longitude),
latitude: Number(loc.coordinates[0].latitude),
};
}
}
return entity;
}
/**
* Build the destination entity representing this destination
*/
async buildDestinationEntity() {
return {
...this.buildBaseEntityObject(),
_id: 'shanghaidisneyresort',
slug: 'shanghaidisneyresort',
name: this.config.name,
location: {
latitude: 31.143040,
longitude: 121.658369
},
entityType: entityType.destination,
};
}
/**
* Build the park entities for this destination
*/
async buildParkEntities() {
const dest = await this.buildDestinationEntity();
const parks = [];
for (let i = 0; i < this.config.parkIds.length; i++) {
const parkData = await this.getEntity(this.config.parkIds[i]);
parks.push({
...this.buildBaseEntityObject(parkData),
_destinationId: dest._id,
_parentId: dest._id,
slug: parkData.attractionId.toLowerCase().replace(/^des/, ''),
entityType: entityType.park,
});
}
return parks;
}
/**
* Build array of entities matching filterFn
* @param {function} filterFn
*/
async _buildEntities(filterFn, attrs = {}) {
const dest = await this.buildDestinationEntity();
const ents = await this.getAttractionData();
return ents.filter(sift(filterFn)).map((x) => {
if (x.name.indexOf(' (Standby Pass Required)') > 0) {
return undefined;
}
const ent = {
...this.buildBaseEntityObject(x),
_destinationId: dest._id,
...attrs,
};
const park = x.ancestors.find((y) => y.type === 'theme-park');
if (park) {
ent._parentId = park.id;
ent._parkId = park.id;
} else {
ent._parentId = dest._id;
}
return ent;
}).filter((x) => !!x);
}
/**
* Build the attraction entities for this destination
*/
async buildAttractionEntities() {
return this._buildEntities({
type: 'Attraction',
}, {
entityType: entityType.attraction,
attractionType: attractionType.ride,
});
}
/**
* Build the show entities for this destination
*/
async buildShowEntities() {
return [];
}
/**
* Build the restaurant entities for this destination
*/
async buildRestaurantEntities() {
return [];
}
/**
* Fetch wait time data
* @return {array<object>}
*/
async _fetchWaitTimes() {
'@cache|1';
const waitTimes = await this.http(
'GET',
`${this.config.apiBase}explorer-service/public/wait-times/shdr;entityType=destination?region=cn`,
undefined,
{json: true},
);
return waitTimes?.body?.entries;
}
/**
* @inheritdoc
*/
async buildEntityLiveData() {
await this.init();
const waitTimes = await this._fetchWaitTimes();
const livedata = [];
const allAttrs = await this.getAttractionData();
// first, loop over and find any standby ticket varients
// build an object of attraction -> standbypass version
const standbyPass = {};
for (let i = 0; i < waitTimes.length; i++) {
const cleanID = this.extractEntityData(waitTimes[i]).id;
try {
const ent = await this.getEntity(cleanID);
if (ent && ent?.name.toLowerCase().indexOf(' (standby pass required)') > 0) {
const originalName = ent.name.slice(0, ent?.name.toLowerCase().indexOf(' (standby pass required)'));
const originalAttraction = allAttrs.find((x) => x.name === originalName);
if (originalAttraction) {
standbyPass[originalAttraction.attractionId] = ent.attractionId;
}
}
} catch (e) { }
}
for (let i = 0; i < waitTimes.length; i++) {
const dat = waitTimes[i];
const live = {
_id: dat.id,
status: statusType.operating,
};
switch (dat.waitTime?.status) {
case 'Closed':
live.status = statusType.closed;
break;
case 'Down':
live.status = statusType.down;
break;
case 'Renewal':
live.status = statusType.refurbishment;
break;
}
// skip if standby pass object
const cleanID = this.extractEntityData(dat).id;
try {
const ent = await this.getEntity(cleanID);
if (!ent || !ent.name || ent.name.toLowerCase().indexOf(' (standby pass required)') > 0) {
continue;
}
} catch (e) { }
// base standby queue time
live.queue = {
[queueType.standBy]: {
waitTime: dat.waitTime?.postedWaitMinutes !== undefined ? dat.waitTime?.postedWaitMinutes : null,
},
};
// show single rider time
if (dat.waitTime?.singleRider) {
live.queue[queueType.singleRider] = {
// API doesn't give us the wait times, just that the queue exists
waitTime: null,
};
}
// look for standby pass entry (this gives us return times)
const standbyVersionId = standbyPass[dat.attractionId];
if (standbyVersionId) {
const passDat = waitTimes.find((x) => x.attractionId === standbyVersionId);
if (passDat) {
live.queue[queueType.returnTime] = {
// currently no way of getting the latest return time
// return null so the API shows that return times are active
returnStart: null,
returnEnd: null,
// API doesn't reveal current state of return time tickets
status: null,
};
}
}
livedata.push(live);
}
return livedata;
}
async _fetchUpcomingCalendar() {
// 12 hours
'@cache|720';
const todaysDate = this.getTimeNowMoment().add(-1, 'days');
const endOfTarget = todaysDate.clone().add(190, 'days');
return (await this.http(
'GET',
`${this.config.apiBase}explorer-service/public/ancestor-activities-schedules/shdr;entityType=destination`,
{
filters: 'resort,theme-park,water-park,restaurant,Attraction',
startDate: todaysDate.format('YYYY-MM-DD'),
endDate: endOfTarget.format('YYYY-MM-DD'),
region: 'cn',
childSchedules: 'guest-service(point-of-interest)',
},
)).body;
}
/**
* Return schedule data for all scheduled entities in this destination
* Eg. parks
* @returns {array<object>}
*/
async buildEntityScheduleData() {
const cal = await this._fetchUpcomingCalendar();
const timeFormat = 'YYYY-MM-DDTHH:mm:ss';
if (!cal?.activities) return [];
return cal.activities.map((entity) => {
if (!entity.schedule) return undefined;
return {
_id: entity.id,
schedule: entity.schedule.schedules.map((x) => {
if (x.type !== 'Operating') return undefined;
return {
date: x.date,
openingTime: moment.tz(`${x.date}T${x.startTime}`, timeFormat, this.config.timezone).format(),
closingTime: moment.tz(`${x.date}T${x.endTime}`, timeFormat, this.config.timezone).format(),
type: scheduleType.operating,
};
}).filter((x) => !!x),
}
}).filter((x) => !!x);
}
}
export default ShanghaiDisneylandResort;