diff --git a/source/ccci-stolaf-college/v1/index.js b/source/ccci-stolaf-college/v1/index.js index b888fa0e..98c775ec 100644 --- a/source/ccci-stolaf-college/v1/index.js +++ b/source/ccci-stolaf-college/v1/index.js @@ -13,6 +13,7 @@ import * as menus from './menu.js' import * as news from './news.js' import * as orgs from './orgs.js' import * as printing from './printing.js' +import * as reports from './reports.js' import * as streams from './streams.js' import * as transit from './transit.js' import * as util from './util.js' @@ -115,6 +116,9 @@ api.get('/streams/upcoming', streams.upcoming) // stoprint api.get('/printing/color-printers', printing.colorPrinters) +// reports +api.get('/reports/stav', reports.stavMealtimeReport) + // utilities api.get('/util/html-to-md', util.htmlToMarkdown) diff --git a/source/ccci-stolaf-college/v1/reports.js b/source/ccci-stolaf-college/v1/reports.js new file mode 100644 index 00000000..a02c514c --- /dev/null +++ b/source/ccci-stolaf-college/v1/reports.js @@ -0,0 +1,15 @@ +import {ONE_HOUR} from '../../ccc-lib/constants.js' +import * as report from '../../reports-bonapp/index.js' +import mem from 'memoize' + +const getReport = mem(report.report, {maxAge: ONE_HOUR}) + +export const REPORT_URLS = { + stav: 'https://www.stolaf.edu/apps/mealtimes/', +} + +export async function stavMealtimeReport(ctx) { + ctx.cacheControl(ONE_HOUR) + + ctx.body = await getReport(REPORT_URLS.stav) +} diff --git a/source/ccci-stolaf-college/v1/reports.test.js b/source/ccci-stolaf-college/v1/reports.test.js new file mode 100644 index 00000000..057ef4d0 --- /dev/null +++ b/source/ccci-stolaf-college/v1/reports.test.js @@ -0,0 +1,31 @@ +import {suite, test} from 'node:test' +import assert from 'node:assert/strict' +import lodash from 'lodash' + +import * as reports from './reports.js' +import {StavReportType} from '../../reports-bonapp/types.js' + +const {noop} = lodash + +const reportFunctions = { + stav: reports.stavMealtimeReport, +} + +suite('sanity checks', () => { + test('all report info endpoints are represented in this test file', () => { + assert.deepStrictEqual( + Object.keys(reportFunctions), + Object.keys(reports.REPORT_URLS), + ) + }) +}) + +suite('endpoints should not throw', {concurrency: 4}, () => { + for (const report of Object.keys(reports.REPORT_URLS)) { + test(`${report} report endpoint should return a StavReportType struct`, async () => { + let ctx = {cacheControl: noop, body: null} + await assert.doesNotReject(() => reportFunctions[report](ctx)) + assert.doesNotThrow(() => StavReportType.parse(ctx.body)) + }) + } +}) diff --git a/source/reports-bonapp/helpers.js b/source/reports-bonapp/helpers.js new file mode 100644 index 00000000..c9e3bf74 --- /dev/null +++ b/source/reports-bonapp/helpers.js @@ -0,0 +1,12 @@ +import {StavReportType} from './types.js' + +/** @returns {StavReportType} */ +export function CustomReportType({message}) { + return StavReportType.parse([ + { + title: message, + times: [], + data: [], + }, + ]) +} diff --git a/source/reports-bonapp/index.js b/source/reports-bonapp/index.js new file mode 100644 index 00000000..abd17dea --- /dev/null +++ b/source/reports-bonapp/index.js @@ -0,0 +1,117 @@ +import {get} from '../ccc-lib/http.js' +import {ONE_MINUTE} from '../ccc-lib/constants.js' + +import {JSDOM, VirtualConsole} from 'jsdom' +import mem from 'memoize' +import * as Sentry from '@sentry/node' + +import {CustomReportType} from './helpers.js' +import {StavReportType} from './types.js' + +const getBonAppPage = mem(get, {maxAge: ONE_MINUTE}) + +const NUMBER_OF_CHARTS_TO_PARSE = 7 + +/** + * @param {string|URL} url + * @return {Promise} + */ +async function getBonAppReportWebpage(url) { + const virtualConsole = new VirtualConsole() + virtualConsole.sendTo(console, {omitJSDOMErrors: true}) + virtualConsole.on('jsdomError', (err) => { + let jsdomErrorMessagesToSkip = [ + 'Uncaught [ReferenceError: wp is not defined]', + 'Uncaught [ReferenceError: jQuery is not defined]', + 'Uncaught [Error: Create skia surface failed]', + "Uncaught [TypeError: Cannot read properties of undefined (reading 'slice')]", + 'Not implemented: HTMLCanvasElement.prototype.getContext (without installing the canvas npm package)', + ] + if (jsdomErrorMessagesToSkip.includes(err.message)) { + return + } + console.error(err) + }) + + const body = await getBonAppPage(url).text() + return new JSDOM(body, { + runScripts: 'dangerously', + resources: 'usable', + virtualConsole, + beforeParse(window) { + window.fetch = global.fetch + }, + }) +} + +/** + * @param {string|URL} reportUrl + * @returns {Promise} + */ +async function _report(reportUrl) { + let dom = await getBonAppReportWebpage(reportUrl) + + dom.window.console.error = (error) => { + let errorMessagesToSkip = [ + "Failed to create chart: can't acquire context from the given item", + ] + if (errorMessagesToSkip.includes(error)) { + return + } + console.error(error) + } + + dom.window.console.info = (info) => { + let infoMessagesToSkip = [ + 'Initialized global navigation scripts', + 'Initialized mobile menu script scripts', + 'Initialized tools navigation scripts', + 'Initialized all javascript that targeted document ready.', + ] + if (infoMessagesToSkip.includes(info)) { + return + } + console.info(info) + } + + return new Promise((resolve, _reject) => { + dom.window.onload = () => { + const charts = dom.window.Chart.instances + + const parse = (chart) => { + const {labels, datasets} = chart.data + return { + title: datasets[0].label, + times: labels, + data: datasets[0].data, + } + } + + const payload = [] + + for (let i = 0; i < NUMBER_OF_CHARTS_TO_PARSE; ++i) { + try { + payload.push(parse(charts[i])) + } catch (err) { + console.warn({error: err.message}) + } + } + + resolve(StavReportType.parse(payload)) + } + }) +} + +/** + * @param {string|URL} reportUrl + * @returns {Promise} + */ +export function report(reportUrl) { + try { + return _report(reportUrl) + } catch (err) { + console.error(err) + Sentry.isInitialized() && Sentry.captureException(err) + return CustomReportType({message: 'Could not load BonApp report'}) + } +} diff --git a/source/reports-bonapp/index.test.js b/source/reports-bonapp/index.test.js new file mode 100644 index 00000000..f6478562 --- /dev/null +++ b/source/reports-bonapp/index.test.js @@ -0,0 +1,17 @@ +import {suite, test} from 'node:test' +import assert from 'node:assert/strict' + +import * as bonAppReport from './index.js' +import {StavReportType} from './types.js' +import {REPORT_URLS} from '../ccci-stolaf-college/v1/reports.js' + +suite('report info', {concurrency: true}, () => { + test('fetching the data should not throw', async () => { + await assert.doesNotReject(() => bonAppReport.report(REPORT_URLS.stav)) + }) + + test('it should return a StavReportType struct', async () => { + let data = await bonAppReport.report(REPORT_URLS.stav) + assert.doesNotThrow(() => StavReportType.parse(data)) + }) +}) diff --git a/source/reports-bonapp/types.js b/source/reports-bonapp/types.js new file mode 100644 index 00000000..e47c68d1 --- /dev/null +++ b/source/reports-bonapp/types.js @@ -0,0 +1,9 @@ +import {z} from 'zod' + +export const StavReportType = z.array( + z.object({ + title: z.string(), + times: z.array(z.string()), + data: z.array(z.number()), + }), +)