Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

parse stav hall mealtime report data #730

Merged
merged 11 commits into from
May 20, 2024
4 changes: 4 additions & 0 deletions source/ccci-stolaf-college/v1/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)

Expand Down
15 changes: 15 additions & 0 deletions source/ccci-stolaf-college/v1/reports.js
Original file line number Diff line number Diff line change
@@ -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)
}
31 changes: 31 additions & 0 deletions source/ccci-stolaf-college/v1/reports.test.js
Original file line number Diff line number Diff line change
@@ -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))
})
}
})
12 changes: 12 additions & 0 deletions source/reports-bonapp/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {StavReportType} from './types.js'

/** @returns {StavReportType} */
export function CustomReportType({message}) {
return StavReportType.parse([
{
title: message,
times: [],
data: [],
},
])
}
117 changes: 117 additions & 0 deletions source/reports-bonapp/index.js
Original file line number Diff line number Diff line change
@@ -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<JSDOM>}
*/
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<StavReportType>}
*/
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<StavReportType>}
*/
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'})
}
}
17 changes: 17 additions & 0 deletions source/reports-bonapp/index.test.js
Original file line number Diff line number Diff line change
@@ -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))
})
})
9 changes: 9 additions & 0 deletions source/reports-bonapp/types.js
Original file line number Diff line number Diff line change
@@ -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()),
}),
)