parks/shdr/shanghaidisneyresort.js

  1. import {attractionType, statusType, queueType, tagType, scheduleType, entityType} from '../parkTypes.js';
  2. //import level from 'level';
  3. import path from 'path';
  4. import moment from 'moment-timezone';
  5. import zlib from 'zlib';
  6. import util, {callbackify} from 'util';
  7. import {fileURLToPath} from 'url';
  8. import {promises as fs} from 'fs';
  9. import Destination from '../destination.js';
  10. import sift from 'sift';
  11. import levelup from 'levelup';
  12. import leveldown from 'leveldown';
  13. const zDecompress = util.promisify(zlib.unzip);
  14. const zCompress = util.promisify(zlib.deflate);
  15. /**
  16. * Shanghai Disneyland Resort
  17. */
  18. export class ShanghaiDisneylandResort extends Destination {
  19. /**
  20. * Create a new ShanghaiDisneylandResort
  21. * @param {object} options
  22. */
  23. constructor(options = {}) {
  24. options.name = options.name || 'Shanghai Disney Resort';
  25. options.timezone = options.timezone || 'Asia/Shanghai';
  26. options.apiBase = options.apiBase || '';
  27. options.apiAuth = options.apiAuth || '';
  28. // options.parkId = options.parkId || 'desShanghaiDisneyland';
  29. options.configPrefixes = ['SHDR'].concat(options.configPrefixes || []);
  30. options.parkIds = options.parkIds || ['desShanghaiDisneyland'];
  31. super(options);
  32. // here we can validate the resulting this.config object
  33. if (!this.config.apiBase) throw new Error('Missing Shanghai Disney Resort apiBase');
  34. if (!this.config.apiAuth) throw new Error('Missing Shanghai Disney Resort apiAuth');
  35. // add our auth token to any API requests
  36. this.http.injectForDomain({
  37. hostname: new URL(this.config.apiBase).hostname,
  38. }, async (method, url, data, options) => {
  39. const accessToken = await this.getAccessToken();
  40. options.headers['Authorization'] = `BEARER ${accessToken}`;
  41. // gather in English where possible
  42. options.headers['Accept-Language'] = 'en';
  43. });
  44. // catch unauthorised requests and force a token refresh
  45. this.http.injectForDomainResponse({
  46. hostname: new URL(this.config.apiBase).hostname,
  47. }, async (resp) => {
  48. // if we get an unauthorised response, refetch our access_token
  49. if (resp?.statusCode === 401) {
  50. this.log('Got unauthorised response, refresh access_token...');
  51. this.cache.set('access_token', undefined, -1);
  52. return undefined;
  53. }
  54. if (resp?.statusCode === 500) {
  55. // API will just return 500 fairly often, throw an error to use cached data instead
  56. throw new Error('SHDR API returned 500 error response to fetching facility data');
  57. }
  58. return resp;
  59. });
  60. // setup our local database for our attraction data
  61. this.db = levelup(leveldown(path.join(process.cwd(), 'db.shdr')))
  62. }
  63. /**
  64. * Get an access token for making requests to the API
  65. */
  66. async getAccessToken() {
  67. let expiresIn = 0;
  68. return await this.cache.wrap('access_token', async () => {
  69. const resp = await this.http('POST', this.config.apiAuth, {
  70. grant_type: 'assertion',
  71. assertion_type: 'public',
  72. client_id: 'DPRD-SHDR.MOBILE.ANDROID-PROD',
  73. });
  74. // remember the expirey time sent by the server
  75. expiresIn = resp?.body?.expires_in;
  76. const token = resp?.body?.access_token;
  77. this.log(`Got new access_token ${token}`);
  78. return token;
  79. }, () => {
  80. // return the expires_in field we got from our response, or 899 seconds, the default
  81. return (expiresIn || 899) * 1000;
  82. });
  83. }
  84. /**
  85. * Extract the key information from an attraction entity doc ID
  86. * @param {string|object} doc Either the document or the document ID
  87. * @return {object}
  88. */
  89. extractEntityData(doc) {
  90. const id = doc?.id || doc;
  91. const parts = id.split(';');
  92. const ret = {
  93. entityId: id.replace(/;cacheId=\d*;?/, ''),
  94. };
  95. ret.id = parts[0].replace(/id=/, '');
  96. parts.forEach((str, idx) => {
  97. if (idx === 0) return;
  98. const keyVal = str.split('=');
  99. if (keyVal && keyVal.length == 2) {
  100. ret[keyVal[0]] = keyVal[1];
  101. }
  102. });
  103. return ret;
  104. }
  105. /**
  106. * Get all stored entities
  107. * @return {array<string>}
  108. */
  109. async getAllEntityKeys() {
  110. return new Promise((resolve) => {
  111. const keys = [];
  112. const keyStream = this.db.createKeyStream();
  113. keyStream.on('data', (data) => {
  114. keys.push(data);
  115. });
  116. keyStream.on('end', () => {
  117. return resolve(keys);
  118. });
  119. });
  120. }
  121. /**
  122. * Get an entity doc using it's ID from the local database
  123. * @param {string} id
  124. */
  125. async getEntity(id) {
  126. const doc = await this.db.get(id);
  127. try {
  128. const jsonDoc = JSON.parse(doc);
  129. return jsonDoc;
  130. } catch (e) {
  131. console.trace(`Error parsing SHDR doc ${id}: ${doc}`);
  132. this.emit('error', e);
  133. }
  134. return undefined;
  135. }
  136. /**
  137. * Get all attraction data
  138. */
  139. async getAttractionData() {
  140. // cache 2 hours
  141. '@cache|120';
  142. try {
  143. await this._refreshAttractionData();
  144. } catch (e) {
  145. this.log('Failed to refresh Shanghai facilities data', e);
  146. }
  147. const docs = await this.getAllEntityKeys();
  148. const allEnts = await Promise.all(docs.map((docId) => {
  149. return this.getEntity(docId);
  150. }));
  151. // HACK - manually add Hot Persuit if it's missing
  152. const existingEnt = allEnts.find((x) => x?.attractionID === 'attZootopiaHotPursuit');
  153. if (existingEnt) return allEnts;
  154. const hotPersuit = {
  155. attractionId: "attZootopiaHotPursuit",
  156. id: "attZootopiaHotPursuit;entityType=Attraction;destination=shdr",
  157. type: "Attraction",
  158. cacheId: "attZootopiaHotPursuit;entityType=Attraction;destination=shdr;cacheId=-2111797129",
  159. name: "Zootopia: Hot Pursuit",
  160. ancestors: [
  161. {
  162. id: "shdr;entityType=destination;destination=shdr",
  163. type: "destination",
  164. },
  165. {
  166. id: "desShanghaiDisneyland;entityType=theme-park;destination=shdr",
  167. type: "theme-park",
  168. },
  169. ],
  170. relatedLocations: [
  171. {
  172. id: "attZootopiaHotPursuit;entityType=Attraction;destination=shdr",
  173. type: "primaryLocation",
  174. name: "Zootopia: Hot Pursuit",
  175. coordinates: [
  176. {
  177. latitude: "31.15180406306",
  178. longitude: "121.665299510689",
  179. type: "Guest Entrance",
  180. },
  181. ],
  182. ancestors: [
  183. {
  184. id: "shdr;entityType=destination;destination=shdr",
  185. type: "destination",
  186. },
  187. {
  188. id: "desShanghaiDisneyland;entityType=theme-park;destination=shdr",
  189. type: "theme-park",
  190. },
  191. ],
  192. },
  193. ],
  194. facets: [],
  195. fastPass: "false",
  196. webLink: "",
  197. policies: [],
  198. };
  199. allEnts.push(hotPersuit);
  200. return allEnts;
  201. }
  202. /**
  203. * Refresh attraction data, getting new and updated documents from the API
  204. */
  205. async _refreshAttractionData() {
  206. const docs = await this.getAllEntityKeys();
  207. const entityCacheString = [];
  208. await Promise.allSettled(docs.map(async (id) => {
  209. const doc = await this.getEntity(id);
  210. if (doc !== undefined) {
  211. entityCacheString.push(`id=${doc.cacheId}`);
  212. }
  213. }));
  214. const resp = await this.http(
  215. 'POST',
  216. `${this.config.apiBase}explorer-service/public/destinations/shdr;entityType=destination/facilities?region=cn`,
  217. entityCacheString.join('&'),
  218. {
  219. headers: {
  220. 'content-type': 'application/x-www-form-urlencoded',
  221. },
  222. retries: 0,
  223. },
  224. );
  225. const addReplaceDoc = async (doc) => {
  226. const info = this.extractEntityData(doc);
  227. await this.db.put(info.id, JSON.stringify({
  228. attractionId: info.id,
  229. ...doc,
  230. }));
  231. };
  232. await Promise.all(resp.body.added.map((add) => {
  233. this.log(`Adding entity ${add?.id}`);
  234. return addReplaceDoc(add);
  235. }));
  236. await Promise.all(resp.body.updated.map((updated) => {
  237. this.log(`Updating entity ${updated?.id}`);
  238. return addReplaceDoc(updated);
  239. }));
  240. await Promise.all(resp.body.removed.map((removed) => {
  241. if (removed === undefined) return;
  242. // removed just gives us the ID
  243. this.log(`Removing entity ${removed}`);
  244. const info = this.extractEntityData(removed);
  245. return this.db.del(info.id);
  246. }));
  247. }
  248. /**
  249. * @inheritdoc
  250. */
  251. async _buildAttractionObject(attractionID) {
  252. const data = await this.getAttractionData();
  253. if (data === undefined) {
  254. console.error('Failed to fetch SHDR attraction data');
  255. return undefined;
  256. }
  257. const entryInfo = this.extractEntityData(attractionID);
  258. const attr = data.find((x) => x.attractionId === entryInfo.id);
  259. if (attr === undefined) {
  260. return undefined;
  261. }
  262. if (attr.name.toLowerCase().indexOf('standby pass required') >= 0) {
  263. // process "standby pass" attractions separately in the live data of the "normal" attraction
  264. const originalName = attr.name.slice(0, ' (Standby Pass Required)'.length);
  265. const originalAttraction = data.find((x) => x.name === originalName);
  266. if (originalAttraction !== undefined) {
  267. // store a mapping of attraction -> standby version in our database
  268. await this.db.put(`standbypass_${originalAttraction.attractionId}`, JSON.stringify(attr.attractionId));
  269. }
  270. return undefined;
  271. }
  272. // TODO - only return Attractions for now, return shows etc. too
  273. if (attr.type !== 'Attraction') return undefined;
  274. let type = attractionType.other;
  275. switch (attr.type) {
  276. // TODO - support other types
  277. case 'Attraction':
  278. type = attractionType.ride;
  279. break;
  280. }
  281. const tags = [];
  282. tags.push({
  283. type: tagType.fastPass,
  284. value: (attr.fastPass == 'true'),
  285. });
  286. const location = attr.relatedLocations.find((x) => x.type === 'primaryLocation' && x.coordinates.length > 0);
  287. if (location !== undefined) {
  288. tags.push({
  289. key: 'location',
  290. type: tagType.location,
  291. value: {
  292. longitude: Number(location.coordinates[0].longitude),
  293. latitude: Number(location.coordinates[0].latitude),
  294. },
  295. });
  296. }
  297. tags.push({
  298. type: tagType.unsuitableForPregnantPeople,
  299. value: attr.policies && (attr.policies.find((x) => x.id === 'policyExpectantMothers') !== undefined),
  300. });
  301. const hasMinHeight = attr.facets.find((x) => x.group === 'height');
  302. if (hasMinHeight !== undefined) {
  303. const minHeight = /(\d+)cm-\d+in-or-taller/.exec(hasMinHeight.id);
  304. if (minHeight) {
  305. tags.push({
  306. type: tagType.minimumHeight,
  307. key: 'minimumHeight',
  308. value: {
  309. unit: 'cm',
  310. value: Number(minHeight[1]),
  311. },
  312. });
  313. }
  314. }
  315. tags.push({
  316. type: tagType.mayGetWet,
  317. value: attr.policies && (attr.policies.find((x) =>
  318. x?.descriptions && x.descriptions.length > 0 && (x.descriptions[0].text.indexOf('You may get wet.') >= 0),
  319. ) !== undefined),
  320. });
  321. return {
  322. name: attr.name,
  323. type,
  324. tags,
  325. };
  326. }
  327. /**
  328. * Dump the SHDR database to a buffer
  329. * @return {buffer}
  330. */
  331. async _dumpDB() {
  332. const keys = await this.getAllEntityKeys();
  333. const docs = {};
  334. await Promise.allSettled(keys.map(async (key) => {
  335. docs[key] = await this.db.get(key);
  336. }));
  337. return await zCompress(JSON.stringify(docs));
  338. }
  339. /**
  340. * Load a SHDR database from an existing buffer
  341. * @param {buffer} buff
  342. */
  343. async _loadDB(buff) {
  344. const data = await zDecompress(buff);
  345. const json = JSON.parse(data.toString('utf8'));
  346. await Promise.allSettled(Object.keys(json).map(async (key) => {
  347. await this.db.put(key, json[key]);
  348. }));
  349. }
  350. /**
  351. * @inheritdoc
  352. */
  353. async _init() {
  354. // restore backup of data if we haven't yet started syncing SHDR data
  355. const hasInitialData = await this.getAllEntityKeys();
  356. if (hasInitialData.length === 0) {
  357. console.log('Restoring SHDR backup before syncing...');
  358. const thisDir = path.dirname(fileURLToPath(import.meta.url));
  359. const backupFile = path.join(thisDir, 'shdr_data.gz');
  360. const backupData = await fs.readFile(backupFile);
  361. await this._loadDB(backupData);
  362. }
  363. }
  364. /**
  365. * Helper function to build a basic entity document
  366. * Useful to avoid copy/pasting
  367. * @param {object} data
  368. * @returns {object}
  369. */
  370. buildBaseEntityObject(data) {
  371. const entity = Destination.prototype.buildBaseEntityObject.call(this, data);
  372. entity._id = data?.id;
  373. entity.name = data?.name;
  374. if (data?.relatedLocations) {
  375. const loc = data.relatedLocations.find((x) => x.type === 'primaryLocation' && x.coordinates.length > 0);
  376. if (loc) {
  377. entity.location = {
  378. longitude: Number(loc.coordinates[0].longitude),
  379. latitude: Number(loc.coordinates[0].latitude),
  380. };
  381. }
  382. }
  383. return entity;
  384. }
  385. /**
  386. * Build the destination entity representing this destination
  387. */
  388. async buildDestinationEntity() {
  389. return {
  390. ...this.buildBaseEntityObject(),
  391. _id: 'shanghaidisneyresort',
  392. slug: 'shanghaidisneyresort',
  393. name: this.config.name,
  394. location: {
  395. latitude: 31.143040,
  396. longitude: 121.658369
  397. },
  398. entityType: entityType.destination,
  399. };
  400. }
  401. /**
  402. * Build the park entities for this destination
  403. */
  404. async buildParkEntities() {
  405. const dest = await this.buildDestinationEntity();
  406. const parks = [];
  407. for (let i = 0; i < this.config.parkIds.length; i++) {
  408. const parkData = await this.getEntity(this.config.parkIds[i]);
  409. parks.push({
  410. ...this.buildBaseEntityObject(parkData),
  411. _destinationId: dest._id,
  412. _parentId: dest._id,
  413. slug: parkData.attractionId.toLowerCase().replace(/^des/, ''),
  414. entityType: entityType.park,
  415. });
  416. }
  417. return parks;
  418. }
  419. /**
  420. * Build array of entities matching filterFn
  421. * @param {function} filterFn
  422. */
  423. async _buildEntities(filterFn, attrs = {}) {
  424. const dest = await this.buildDestinationEntity();
  425. const ents = await this.getAttractionData();
  426. return ents.filter(sift(filterFn)).map((x) => {
  427. if (x.name.indexOf(' (Standby Pass Required)') > 0) {
  428. return undefined;
  429. }
  430. const ent = {
  431. ...this.buildBaseEntityObject(x),
  432. _destinationId: dest._id,
  433. ...attrs,
  434. };
  435. const park = x.ancestors.find((y) => y.type === 'theme-park');
  436. if (park) {
  437. ent._parentId = park.id;
  438. ent._parkId = park.id;
  439. } else {
  440. ent._parentId = dest._id;
  441. }
  442. return ent;
  443. }).filter((x) => !!x);
  444. }
  445. /**
  446. * Build the attraction entities for this destination
  447. */
  448. async buildAttractionEntities() {
  449. return this._buildEntities({
  450. type: 'Attraction',
  451. }, {
  452. entityType: entityType.attraction,
  453. attractionType: attractionType.ride,
  454. });
  455. }
  456. /**
  457. * Build the show entities for this destination
  458. */
  459. async buildShowEntities() {
  460. return [];
  461. }
  462. /**
  463. * Build the restaurant entities for this destination
  464. */
  465. async buildRestaurantEntities() {
  466. return [];
  467. }
  468. /**
  469. * Fetch wait time data
  470. * @return {array<object>}
  471. */
  472. async _fetchWaitTimes() {
  473. '@cache|1';
  474. const waitTimes = await this.http(
  475. 'GET',
  476. `${this.config.apiBase}explorer-service/public/wait-times/shdr;entityType=destination?region=cn`,
  477. undefined,
  478. {json: true},
  479. );
  480. return waitTimes?.body?.entries;
  481. }
  482. /**
  483. * @inheritdoc
  484. */
  485. async buildEntityLiveData() {
  486. await this.init();
  487. const waitTimes = await this._fetchWaitTimes();
  488. const livedata = [];
  489. const allAttrs = await this.getAttractionData();
  490. // first, loop over and find any standby ticket varients
  491. // build an object of attraction -> standbypass version
  492. const standbyPass = {};
  493. for (let i = 0; i < waitTimes.length; i++) {
  494. const cleanID = this.extractEntityData(waitTimes[i]).id;
  495. try {
  496. const ent = await this.getEntity(cleanID);
  497. if (ent && ent?.name.toLowerCase().indexOf(' (standby pass required)') > 0) {
  498. const originalName = ent.name.slice(0, ent?.name.toLowerCase().indexOf(' (standby pass required)'));
  499. const originalAttraction = allAttrs.find((x) => x.name === originalName);
  500. if (originalAttraction) {
  501. standbyPass[originalAttraction.attractionId] = ent.attractionId;
  502. }
  503. }
  504. } catch (e) { }
  505. }
  506. for (let i = 0; i < waitTimes.length; i++) {
  507. const dat = waitTimes[i];
  508. const live = {
  509. _id: dat.id,
  510. status: statusType.operating,
  511. };
  512. switch (dat.waitTime?.status) {
  513. case 'Closed':
  514. live.status = statusType.closed;
  515. break;
  516. case 'Down':
  517. live.status = statusType.down;
  518. break;
  519. case 'Renewal':
  520. live.status = statusType.refurbishment;
  521. break;
  522. }
  523. // skip if standby pass object
  524. const cleanID = this.extractEntityData(dat).id;
  525. try {
  526. const ent = await this.getEntity(cleanID);
  527. if (!ent || !ent.name || ent.name.toLowerCase().indexOf(' (standby pass required)') > 0) {
  528. continue;
  529. }
  530. } catch (e) { }
  531. // base standby queue time
  532. live.queue = {
  533. [queueType.standBy]: {
  534. waitTime: dat.waitTime?.postedWaitMinutes !== undefined ? dat.waitTime?.postedWaitMinutes : null,
  535. },
  536. };
  537. // show single rider time
  538. if (dat.waitTime?.singleRider) {
  539. live.queue[queueType.singleRider] = {
  540. // API doesn't give us the wait times, just that the queue exists
  541. waitTime: null,
  542. };
  543. }
  544. // look for standby pass entry (this gives us return times)
  545. const standbyVersionId = standbyPass[dat.attractionId];
  546. if (standbyVersionId) {
  547. const passDat = waitTimes.find((x) => x.attractionId === standbyVersionId);
  548. if (passDat) {
  549. live.queue[queueType.returnTime] = {
  550. // currently no way of getting the latest return time
  551. // return null so the API shows that return times are active
  552. returnStart: null,
  553. returnEnd: null,
  554. // API doesn't reveal current state of return time tickets
  555. status: null,
  556. };
  557. }
  558. }
  559. livedata.push(live);
  560. }
  561. return livedata;
  562. }
  563. async _fetchUpcomingCalendar() {
  564. // 12 hours
  565. '@cache|720';
  566. const todaysDate = this.getTimeNowMoment().add(-1, 'days');
  567. const endOfTarget = todaysDate.clone().add(190, 'days');
  568. return (await this.http(
  569. 'GET',
  570. `${this.config.apiBase}explorer-service/public/ancestor-activities-schedules/shdr;entityType=destination`,
  571. {
  572. filters: 'resort,theme-park,water-park,restaurant,Attraction',
  573. startDate: todaysDate.format('YYYY-MM-DD'),
  574. endDate: endOfTarget.format('YYYY-MM-DD'),
  575. region: 'cn',
  576. childSchedules: 'guest-service(point-of-interest)',
  577. },
  578. )).body;
  579. }
  580. /**
  581. * Return schedule data for all scheduled entities in this destination
  582. * Eg. parks
  583. * @returns {array<object>}
  584. */
  585. async buildEntityScheduleData() {
  586. const cal = await this._fetchUpcomingCalendar();
  587. const timeFormat = 'YYYY-MM-DDTHH:mm:ss';
  588. if (!cal?.activities) return [];
  589. return cal.activities.map((entity) => {
  590. if (!entity.schedule) return undefined;
  591. return {
  592. _id: entity.id,
  593. schedule: entity.schedule.schedules.map((x) => {
  594. if (x.type !== 'Operating') return undefined;
  595. return {
  596. date: x.date,
  597. openingTime: moment.tz(`${x.date}T${x.startTime}`, timeFormat, this.config.timezone).format(),
  598. closingTime: moment.tz(`${x.date}T${x.endTime}`, timeFormat, this.config.timezone).format(),
  599. type: scheduleType.operating,
  600. };
  601. }).filter((x) => !!x),
  602. }
  603. }).filter((x) => !!x);
  604. }
  605. }
  606. export default ShanghaiDisneylandResort;