diff --git a/src/executor.ts b/src/executor.ts index 4b96afd..2e3c091 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -9,18 +9,18 @@ import readline from "node:readline"; import { open } from "node:fs/promises"; import { - COOKIE_FILE_PATH, - CREDENTIALS_FILE_PATH, - DUMP_BASE_PATH, - RESULT_TABLE_PATH, - TIMETABLE_TABLE_PATH, + COOKIE_FILE_PATH, + CREDENTIALS_FILE_PATH, + DUMP_BASE_PATH, + RESULT_TABLE_PATH, + TIMETABLE_TABLE_PATH, } from "./lib/constants"; import { - AnsiColourEnum, - AnsiTextStyleEnum, - CommandEnum, - TCommandActionParams, - TiMaluumLoginCredentials, + AnsiColourEnum, + AnsiTextStyleEnum, + CommandEnum, + TCommandActionParams, + TiMaluumLoginCredentials, } from "./lib/types"; import { Loader, TTYSync } from "./lib/utils/loader"; import { IMaluumPage, IMaluumSubPage } from "./imaluum"; @@ -28,313 +28,325 @@ import { readFromFileSync, styleText, writeToFileSync } from "./lib/utils"; import { ErrorOptions } from "commander"; export class CommandExecutor { - private imaluum: IMaluumPage | null = null; - private errorCallback: - | ((message: string, errorOptions?: ErrorOptions) => never) - | null = null; - - static async setup(): Promise { - // create dump dir - fs.mkdirSync(DUMP_BASE_PATH, { recursive: true }); - - const executor = new CommandExecutor(); - executor.imaluum = await IMaluumPage.launch(); - - return executor; - } - - async execute(command: T, ...args: any[]) { - if (command === CommandEnum.Authenticate) { - await this.authenticate(...args); - } else { - try { - // check if got cookie file - // if yes, use `loginFromCookie` - // else, throw error `unauthenticatd` or something - await this.loginFromCookie(); - } catch (e) { - // console.error(e); - throw this.error( - `${styleText( - "You are not yet authenticated!", - AnsiColourEnum.RED, - AnsiTextStyleEnum.BOLD - )} Please run the ${styleText( - "authenticate", - AnsiColourEnum.CYAN - )} command first.` - ); - } - - switch (command) { - case CommandEnum.Result: { - await this.result(...args); - break; - } - case CommandEnum.Timetable: { - await this.timetable(...args); - break; - } - case CommandEnum.Test: { - await this._test(command, ...args); - break; - } - default: - throw new Error("UNKNOWN COMMAND!"); - } - } - - await this.imaluum?.close(); - } - - setErrorCallback(fn: typeof this.errorCallback): void { - this.errorCallback = fn; - } - - private error(message: string, errorOptions?: ErrorOptions): Error { - return this.errorCallback - ? this.errorCallback(message, errorOptions) - : new Error(message); - } - - async loginFromCookie() { - const str = await readFromFileSync(COOKIE_FILE_PATH); - const cookeis = JSON.parse(str); - - await this.imaluum?.page?.setCookie(...cookeis); - - const homePage = "https://imaluum.iium.edu.my/home"; - await this.imaluum?.page?.goto(homePage); - - const currentUrl = this.imaluum?.page?.url(); - if (currentUrl !== homePage) - throw new Error(`Wrong page. Expected ${homePage} but got ${currentUrl}.`); - } - - async login() { - const { username, password } = await this._getSavedCredentials(); - try { - await this._loginWithUsername(username, password); - } catch (e) { - throw e; - } - } - - async _loginWithUsername(username: string, password: string) { - const data = await this.imaluum?.login(username, password); - // save cookies - writeToFileSync(COOKIE_FILE_PATH, JSON.stringify(data?.cookies || "")); - } - - async authenticate(...args: any) { - try { - const { username, password }: TiMaluumLoginCredentials = - await this._authenticate({ options: args[0] }); - - writeToFileSync(CREDENTIALS_FILE_PATH, `${username}\n${password}`); - - console.log( - `🎉 ${styleText( - "You are authenticated!", - AnsiColourEnum.GREEN, - AnsiTextStyleEnum.BOLD - )}` - ); - } catch (e) { - // console.error(e); - throw e; - } - } - - private _authenticate({ options }: TCommandActionParams) { - return new Promise(async (resolve, reject) => { - const credentials = { - username: options.username, - password: options.password, - }; - - try { - if (options.interactive) { - const userInputs = await this._promptForUserCredentials(); - credentials.username = userInputs[0]; - credentials.password = userInputs[1]; - } - - if (!credentials.username) throw new Error("Username is missing"); - if (!credentials.password) throw new Error("Password is missing"); - - const loader = new Loader("Authenticating to i-Ma'luum"); - - await loader.startAsyncTask( - async () => - await this._loginWithUsername( - credentials.username as string, - credentials.password as string - ) - ); - - resolve(credentials as TiMaluumLoginCredentials); - } catch (error) { - reject(error); - } - }); - } - - private async _promptForUserCredentials() { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - const prompt = async (query: string) => - await new Promise((resolve) => rl.question(query, resolve)); - - try { - const username = (await prompt( - `${styleText("# Username >> ", AnsiTextStyleEnum.BOLD)}` - )) as string; - - const password = (await prompt( - `${styleText("# Password >> ", AnsiTextStyleEnum.BOLD)}` - )) as string; - - await TTYSync.clearAboveUntilSync(3); - - rl.close(); - - return [username, password]; - } catch (error) { - throw error; - } - } - - async result(...args: any[]) { - if (this.imaluum?.page == null) throw new Error("i-Ma'luum page not running!"); - - const resultArgs: TCommandActionParams = { - semester: args[0], - year: args[1], - options: args[2], - }; - - const loader = new Loader( - `Fetching exam result for Sem ${resultArgs.semester} ${resultArgs.year}` - ); - - const output = await loader.startAsyncTask(async () => { - const resultPage = await this.imaluum?.goToSubPage("RESULT"); - return await this._result(resultPage as IMaluumSubPage, resultArgs); - }); - - this._displayTable(output); - } - - private _result( - page: IMaluumSubPage, - { semester, year, options }: TCommandActionParams - ) { - return new Promise(async (resolve, reject) => { - if (semester.match(/^[123]$/) == null) - throw new Error( - "Invalid . must be in the range of 1 >= semester <= 3." - ); - - if (year.match(/^([\d]*)\/([\d]*)*$/) == null) - throw new Error( - "Invalid . Hint: Make sure it is in the format XXXX/XXXX. (e.g. 2021/2022)" - ); - - await page.selectTimetableOrResultDropDownMenuItem(semester, year); - - const tableStr = await page.extractTableElement(); - - if (tableStr.length !== 0) resolve(tableStr); - else reject(); - }); - } - - async timetable(...args: any[]) { - if (this.imaluum?.page == null) throw new Error("i-Ma'luum page not running!"); - - const timetableArgs: TCommandActionParams = { - semester: args[0], - year: args[1], - options: args[2], - }; - - const loader = new Loader( - `Fetching timetable for Sem ${timetableArgs.semester} ${timetableArgs.year}` - ); - - const output = await loader.startAsyncTask(async () => { - const timetablePage = await this.imaluum?.goToSubPage("TIMETABLE"); - return await this._timetable(timetablePage as IMaluumSubPage, timetableArgs); - }); - - this._displayTable(output); - } - - private async _timetable( - page: IMaluumSubPage, - { semester, year, options }: TCommandActionParams - ) { - return new Promise(async (resolve, reject) => { - if (semester.match(/^[123]$/) == null) { - throw new Error( - "Invalid . must be in the range of 1 >= semester <= 3." - ); - } - - if (year.match(/^([\d]*)\/([\d]*)*$/) == null) { - throw new Error( - "Invalid . Hint: Make sure it is in the format XXXX/XXXX. (e.g. 2021/2022)" - ); - } - - await page.selectTimetableOrResultDropDownMenuItem(semester, year); - - const tableStr = await page.extractTableElement(); - - if (tableStr.length !== 0) resolve(tableStr); - else reject(); - }); - } - - private _displayTable(table: string) { - console.log(table); - } - - private async _getSavedCredentials(): Promise { - const values = []; - - try { - const file = await open(CREDENTIALS_FILE_PATH, "as+"); - const fileStream = file.createReadStream({ encoding: "utf8" }); - - // taken from https://nodejs.org/api/readline.html#readline_example_read_file_stream_line_by_line - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity, - }); - // Note: we use the crlfDelay option to recognize all instances of CR LF - // ('\r\n') in input.txt as a single line break. - - for await (const line of rl) { - // Each line in input.txt will be successively available here as `line`. - // console.log(`Line from file: ${line}`); - values.push(line); - } - - return { - username: values[0], - password: values[1], - }; - } catch (e) { - throw e; - } - } - - private async _test(command: any, ...args: any) { - console.log("this is a test", command, args); - } + private imaluum: IMaluumPage | null = null; + private errorCallback: + | ((message: string, errorOptions?: ErrorOptions) => never) + | null = null; + + static async setup(): Promise { + // create dump dir + fs.mkdirSync(DUMP_BASE_PATH, { recursive: true }); + + const executor = new CommandExecutor(); + executor.imaluum = await IMaluumPage.launch(); + + return executor; + } + + async execute(command: T, ...args: any[]) { + if (command === CommandEnum.Authenticate) { + await this.login(...args); + } else { + try { + // check if got cookie file + // if yes, use `loginFromCookie` + // else, throw error `unauthenticatd` or something + await this.loginFromCookie(); + } catch (e) { + // console.error(e); + throw this.error( + `${styleText( + "You are not yet authenticated!", + AnsiColourEnum.RED, + AnsiTextStyleEnum.BOLD + )} Please run the ${styleText( + "authenticate", + AnsiColourEnum.CYAN + )} command first.` + ); + } + + switch (command) { + case CommandEnum.Result: { + await this.result(...args); + break; + } + case CommandEnum.Timetable: { + await this.timetable(...args); + break; + } + case CommandEnum.Test: { + await this._test(command, ...args); + break; + } + default: + throw new Error("UNKNOWN COMMAND!"); + } + } + + await this.imaluum?.close(); + } + + setErrorCallback(fn: typeof this.errorCallback): void { + this.errorCallback = fn; + } + + private error(message: string, errorOptions?: ErrorOptions): Error { + return this.errorCallback + ? this.errorCallback(message, errorOptions) + : new Error(message); + } + + async loginFromCookie() { + const str = await readFromFileSync(COOKIE_FILE_PATH); + const cookeis = JSON.parse(str); + + await this.imaluum?.page?.setCookie(...cookeis); + + const homePage = "https://imaluum.iium.edu.my/home"; + await this.imaluum?.page?.goto(homePage); + + const currentUrl = this.imaluum?.page?.url(); + if (currentUrl !== homePage) + throw new Error( + `Wrong page. Expected ${homePage} but got ${currentUrl}.` + ); + } + + async lastLogin() { + const { username, password } = await this._getSavedCredentials(); + try { + await this._loginWithUsername(username, password); + } catch (e) { + throw e; + } + } + + async _loginWithUsername(username: string, password: string) { + const data = await this.imaluum?.login(username, password); + // save cookies + writeToFileSync(COOKIE_FILE_PATH, JSON.stringify(data?.cookies || "")); + } + + async login(...args: any) { + try { + // console.time("after"); + const { username, password }: TiMaluumLoginCredentials = + await this._authenticate({ options: args[0] }); + + writeToFileSync(CREDENTIALS_FILE_PATH, `${username}\n${password}`); + + console.log( + `🎉 ${styleText( + "You are authenticated!", + AnsiColourEnum.GREEN, + AnsiTextStyleEnum.BOLD + )}` + ); + + // console.timeEnd("after"); + } catch (e) { + // console.error(e); + throw e; + } + } + + private _authenticate({ + options, + }: TCommandActionParams) { + return new Promise(async (resolve, reject) => { + const credentials = { + username: options.username, + password: options.password, + }; + + try { + if (options.interactive) { + const userInputs = await this._promptForUserCredentials(); + credentials.username = userInputs[0]; + credentials.password = userInputs[1]; + } + + if (!credentials.username) throw new Error("Username is missing"); + if (!credentials.password) throw new Error("Password is missing"); + + const loader = new Loader("Authenticating to i-Ma'luum"); + + await loader.startAsyncTask( + async () => + await this._loginWithUsername( + credentials.username as string, + credentials.password as string + ) + ); + + resolve(credentials as TiMaluumLoginCredentials); + } catch (error) { + reject(error); + } + }); + } + + private async _promptForUserCredentials() { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const prompt = async (query: string) => + await new Promise((resolve) => rl.question(query, resolve)); + + try { + const username = (await prompt( + `${styleText("# Username >> ", AnsiTextStyleEnum.BOLD)}` + )) as string; + + const password = (await prompt( + `${styleText("# Password >> ", AnsiTextStyleEnum.BOLD)}` + )) as string; + + await TTYSync.clearAboveUntilSync(3); + + rl.close(); + + return [username, password]; + } catch (error) { + throw error; + } + } + + async result(...args: any[]) { + if (this.imaluum?.page == null) + throw new Error("i-Ma'luum page not running!"); + + const resultArgs: TCommandActionParams = { + semester: args[0], + year: args[1], + options: args[2], + }; + + const loader = new Loader( + `Fetching exam result for Sem ${resultArgs.semester} ${resultArgs.year}` + ); + + const output = await loader.startAsyncTask(async () => { + const resultPage = await this.imaluum?.goToSubPage("RESULT"); + return await this._result(resultPage as IMaluumSubPage, resultArgs); + }); + + this._displayTable(output); + } + + private _result( + page: IMaluumSubPage, + { semester, year, options }: TCommandActionParams + ) { + return new Promise(async (resolve, reject) => { + if (semester.match(/^[123]$/) == null) + throw new Error( + "Invalid . must be in the range of 1 >= semester <= 3." + ); + + if (year.match(/^([\d]*)\/([\d]*)*$/) == null) + throw new Error( + "Invalid . Hint: Make sure it is in the format XXXX/XXXX. (e.g. 2021/2022)" + ); + + await page.selectTimetableOrResultDropDownMenuItem(semester, year); + + const tableStr = await page.extractTableElement(); + + if (tableStr.length !== 0) resolve(tableStr); + else reject(); + }); + } + + async timetable(...args: any[]) { + if (this.imaluum?.page == null) + throw new Error("i-Ma'luum page not running!"); + + const timetableArgs: TCommandActionParams = { + semester: args[0], + year: args[1], + options: args[2], + }; + + const loader = new Loader( + `Fetching timetable for Sem ${timetableArgs.semester} ${timetableArgs.year}` + ); + + const output = await loader.startAsyncTask(async () => { + const timetablePage = await this.imaluum?.goToSubPage("TIMETABLE"); + return await this._timetable( + timetablePage as IMaluumSubPage, + timetableArgs + ); + }); + + this._displayTable(output); + } + + private async _timetable( + page: IMaluumSubPage, + { semester, year, options }: TCommandActionParams + ) { + return new Promise(async (resolve, reject) => { + if (semester.match(/^[123]$/) == null) { + throw new Error( + "Invalid . must be in the range of 1 >= semester <= 3." + ); + } + + if (year.match(/^([\d]*)\/([\d]*)*$/) == null) { + throw new Error( + "Invalid . Hint: Make sure it is in the format XXXX/XXXX. (e.g. 2021/2022)" + ); + } + + await page.selectTimetableOrResultDropDownMenuItem(semester, year); + + const tableStr = await page.extractTableElement(); + + if (tableStr.length !== 0) resolve(tableStr); + else reject(); + }); + } + + private _displayTable(table: string) { + console.log(table); + } + + private async _getSavedCredentials(): Promise { + const values = []; + + try { + const file = await open(CREDENTIALS_FILE_PATH, "as+"); + const fileStream = file.createReadStream({ encoding: "utf8" }); + + // taken from https://nodejs.org/api/readline.html#readline_example_read_file_stream_line_by_line + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + // Note: we use the crlfDelay option to recognize all instances of CR LF + // ('\r\n') in input.txt as a single line break. + + for await (const line of rl) { + // Each line in input.txt will be successively available here as `line`. + // console.log(`Line from file: ${line}`); + values.push(line); + } + + return { + username: values[0], + password: values[1], + }; + } catch (e) { + throw e; + } + } + + private async _test(command: any, ...args: any) { + console.log("this is a test", command, args); + } } diff --git a/src/imaluum.ts b/src/imaluum.ts index 97f1e20..767dffc 100644 --- a/src/imaluum.ts +++ b/src/imaluum.ts @@ -1,220 +1,277 @@ import puppeteer from "puppeteer"; import { table } from "table"; - -import fs from "node:fs"; -import cp from "child_process"; - import { - IMALUUM_HOME_PAGE, - IMALUUM_LOGIN_PAGE, - IMALUUM_SUBPAGE_LINKS, + IMALUUM_HOME_PAGE, + IMALUUM_LOGIN_PAGE, + IMALUUM_SUBPAGE_LINKS, } from "./lib/constants"; import { capitalize, parseHTMLTableJson } from "./lib/utils"; import { TiMaluumSubPage } from "./lib/types"; +const minimal_args = [ + "--autoplay-policy=user-gesture-required", + "--disable-background-networking", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-breakpad", + "--disable-client-side-phishing-detection", + "--disable-component-update", + "--disable-default-apps", + "--disable-dev-shm-usage", + "--disable-domain-reliability", + "--disable-extensions", + "--disable-features=AudioServiceOutOfProcess", + "--disable-hang-monitor", + "--disable-ipc-flooding-protection", + "--disable-notifications", + "--disable-offer-store-unmasked-wallet-cards", + "--disable-popup-blocking", + "--disable-print-preview", + "--disable-prompt-on-repost", + "--disable-renderer-backgrounding", + "--disable-setuid-sandbox", + "--disable-speech-api", + "--disable-sync", + "--hide-scrollbars", + "--ignore-gpu-blacklist", + "--metrics-recording-only", + "--mute-audio", + "--no-default-browser-check", + "--no-first-run", + "--no-pings", + "--no-sandbox", + "--no-zygote", + "--password-store=basic", + "--use-gl=swiftshader", + "--use-mock-keychain", +]; class Page { - browser: puppeteer.Browser | null = null; - page: puppeteer.Page | null = null; - - protected async _open() { - this.browser = await puppeteer.launch({ - headless: true, - }); - this.page = await this.browser.newPage(); - } - - protected async _close() { - if (this.browser == null) - throw new Error("Unable to close browser. No browser is running!"); - await this.browser.close(); - } + browser: puppeteer.Browser | null = null; + page: puppeteer.Page | null = null; + + protected async _open() { + this.browser = await puppeteer.launch({ + headless: true, + args: minimal_args, + }); + this.page = await this.browser.newPage(); + + this.page.setDefaultNavigationTimeout(0); + await this.page.setRequestInterception(true); + + this.page.on("request", (req) => { + if ( + req.resourceType() == "stylesheet" || + req.resourceType() == "font" || + req.resourceType() == "image" + ) { + req.abort(); + } else { + req.continue(); + } + }); + } + + protected async _close() { + if (this.browser == null) + throw new Error("Unable to close browser. No browser is running!"); + await this.browser.close(); + } } export class IMaluumPage extends Page { - // static async launch(): Promise { - // const page = await Page.launch(); - // } - - async close() { - await this._close(); - } - - static async launch() { - const page = new IMaluumPage(); - await page._open(); - return page; - } - - async login( - username: string, - password: string - ): Promise<{ - response: puppeteer.HTTPResponse | null | undefined; - cookies: puppeteer.Protocol.Network.Cookie[]; - }> { - if (!username || !password) throw new Error("User credentials missing!"); - return await this._loginToIMaluum(username, password); - } - - // this function will always assume that `username` & `password` is not null, - private async _loginToIMaluum( - username: string, - password: string - ): Promise<{ - response: puppeteer.HTTPResponse | null | undefined; - cookies: puppeteer.Protocol.Network.Cookie[]; - }> { - await this.goToLoginPage(); - - await this.page?.$eval( - "#username", - // @ts-ignore - (node, value) => (node.value = value), - username - ); - - await this.page?.$eval( - "#password", - // @ts-ignore - (node, value) => (node.value = value), - password - ); - - const [response] = await Promise.all([ - this.page?.waitForNavigation({ - waitUntil: "networkidle0", - }), - this.page?.$eval("#fm1 input.btn-submit", (node) => { - // @ts-ignore - node.disabled = false; - // @ts-ignore - node.click(); - }), - ]); - - if (response?.status() == 401) { - const invalidStr = await this.page?.$eval( - "form#fm1 div.alert.alert-danger span", - (elem) => elem.textContent - ); - - if (invalidStr == "Invalid credentials.") - throw new Error("Invalid credentials"); - else throw new Error("Unable to proceed from login page"); - } - - // check that we have landed in the expected page (home page) - const currentUrl = this.page?.url(); - - if (currentUrl !== IMALUUM_HOME_PAGE) { - console.log("test"); - throw new Error( - `Wrong page. Expected ${IMALUUM_HOME_PAGE} but got ${currentUrl}.` - ); - } - - const cookies = (await this.page?.cookies())?.filter((value) => { - if (value.name == "XSRF-TOKEN" || value.name == "laravel_session") - return value; - else if (value.name == "MOD_AUTH_CAS") { - value.expires = 10000000000; - value.session = false; - value.secure = false; - return value; - } - }); - - return { - response, - cookies: cookies || [], - }; - } - - private async goToLoginPage() { - await this.page?.goto(IMALUUM_LOGIN_PAGE, {}); - } - - // should only be used when on domain http://imaluum.iium.edu.my - async goToSubPage(page: TiMaluumSubPage): Promise { - if (this.page == null) throw new Error("Puppeteer page not running"); - - const url = IMALUUM_SUBPAGE_LINKS[page]; - - await this.page.$eval(`a[href="${url}"]`, (elem) => { - // @ts-expect-error - elem.click(); - }); - - await this.page.waitForNavigation(); - - const currentUrl = this.page.url(); - if (currentUrl !== url) - throw new Error(`Wrong page. Expected ${url} but got ${currentUrl}.`); - - return new IMaluumSubPage(this.page, page); - } + // static async launch(): Promise { + // const page = await Page.launch(); + // } + + async close() { + await this._close(); + } + + static async launch() { + const page = new IMaluumPage(); + await page._open(); + return page; + } + + async login( + username: string, + password: string + ): Promise<{ + response: puppeteer.HTTPResponse | null | undefined; + cookies: puppeteer.Protocol.Network.Cookie[]; + }> { + if (!username || !password) throw new Error("User credentials missing!"); + return await this._loginToIMaluum(username, password); + } + + // this function will always assume that `username` & `password` is not null, + private async _loginToIMaluum( + username: string, + password: string + ): Promise<{ + response: puppeteer.HTTPResponse | null | undefined; + cookies: puppeteer.Protocol.Network.Cookie[]; + }> { + await this.goToLoginPage(); + + await this.page?.$eval( + "#username", + // @ts-ignore + (node, value) => (node.value = value), + username + ); + + await this.page?.$eval( + "#password", + // @ts-ignore + (node, value) => (node.value = value), + password + ); + + const [response] = await Promise.all([ + this.page?.waitForNavigation({ + waitUntil: "networkidle0", + }), + this.page?.$eval("#fm1 input.btn-submit", (node) => { + // @ts-ignore + node.disabled = false; + // @ts-ignore + node.click(); + }), + ]); + + if (response?.status() == 401) { + const invalidStr = await this.page?.$eval( + "form#fm1 div.alert.alert-danger span", + (elem) => elem.textContent + ); + + if (invalidStr == "Invalid credentials.") + throw new Error("Invalid credentials"); + else throw new Error("Unable to proceed from login page"); + } + + // check that we have landed in the expected page (home page) + const currentUrl = this.page?.url(); + + if (currentUrl !== IMALUUM_HOME_PAGE) { + console.log("test"); + throw new Error( + `Wrong page. Expected ${IMALUUM_HOME_PAGE} but got ${currentUrl}.` + ); + } + + const cookies = (await this.page?.cookies())?.filter((value) => { + if (value.name == "XSRF-TOKEN" || value.name == "laravel_session") + return value; + else if (value.name == "MOD_AUTH_CAS") { + value.expires = 10000000000; + value.session = false; + value.secure = false; + return value; + } + }); + + return { + response, + cookies: cookies || [], + }; + } + + private async goToLoginPage() { + await this.page?.goto(IMALUUM_LOGIN_PAGE, {}); + } + + // should only be used when on domain http://imaluum.iium.edu.my + async goToSubPage(page: TiMaluumSubPage): Promise { + if (this.page == null) throw new Error("Puppeteer page not running"); + + const url = IMALUUM_SUBPAGE_LINKS[page]; + + await this.page.$eval(`a[href="${url}"]`, (elem) => { + // @ts-expect-error + elem.click(); + }); + + await this.page.waitForNavigation(); + + const currentUrl = this.page.url(); + if (currentUrl !== url) + throw new Error(`Wrong page. Expected ${url} but got ${currentUrl}.`); + + return new IMaluumSubPage(this.page, page); + } } export class IMaluumSubPage { - page: puppeteer.Page; - type: TiMaluumSubPage; - - constructor(page: puppeteer.Page, type: TiMaluumSubPage) { - this.page = page; - this.type = type; - } - - // `width` the extracted table width - async extractTableElement(): Promise { - const tableHTMLString = await this.page?.$eval( - "section.content table", - (elem) => elem.outerHTML - ); - - if (this.type == "TIMETABLE") - return table(parseHTMLTableJson(tableHTMLString)); - else { - let tableJson = parseHTMLTableJson(tableHTMLString); - // remove last elem bcs not valid table elem - // TODO: parse this last elem - tableJson.pop() - return table(tableJson); - } - } - - async selectTimetableOrResultDropDownMenuItem(semester: string, year: string) { - const [elemHandles, elemTextContents] = await this._extractDropDownMenuItem(); - - const selected = { - fullStr: `Sem ${semester}, ${year}`, - idx: -1, - }; - - elemTextContents.find((elem, idx) => - elem === selected.fullStr ? (selected.idx = idx) : null - ); - - if (selected.idx === -1) - throw new Error( - `[ERROR] ${capitalize(this.type)} for ${selected.fullStr} does not exist!` - ); - - // @ts-ignore - elemHandles[selected.idx].evaluate((elem) => elem.click()); - - await this.page.waitForNavigation(); - } - - private async _extractDropDownMenuItem(): Promise< - [Array>, Array] - > { - // make a check if dropdown menu elem exist - const elemHandles = await this.page.$$("section.content ul.dropdown-menu a"); - const elemTextContent = await this.page.$$eval( - "section.content ul.dropdown-menu a", - (elems) => elems.map((elem) => elem.textContent) - ); - return [elemHandles, elemTextContent]; - } + page: puppeteer.Page; + type: TiMaluumSubPage; + + constructor(page: puppeteer.Page, type: TiMaluumSubPage) { + this.page = page; + this.type = type; + } + + // `width` the extracted table width + async extractTableElement(): Promise { + const tableHTMLString = await this.page?.$eval( + "section.content table", + (elem) => elem.outerHTML + ); + + if (this.type == "TIMETABLE") + return table(parseHTMLTableJson(tableHTMLString)); + else { + let tableJson = parseHTMLTableJson(tableHTMLString); + // remove last elem bcs not valid table elem + // TODO: parse this last elem + tableJson.pop(); + return table(tableJson); + } + } + + async selectTimetableOrResultDropDownMenuItem( + semester: string, + year: string + ) { + const [elemHandles, elemTextContents] = + await this._extractDropDownMenuItem(); + + const selected = { + fullStr: `Sem ${semester}, ${year}`, + idx: -1, + }; + + elemTextContents.find((elem, idx) => + elem === selected.fullStr ? (selected.idx = idx) : null + ); + + if (selected.idx === -1) + throw new Error( + `[ERROR] ${capitalize(this.type)} for ${ + selected.fullStr + } does not exist!` + ); + + // @ts-ignore + elemHandles[selected.idx].evaluate((elem) => elem.click()); + + await this.page.waitForNavigation(); + } + + private async _extractDropDownMenuItem(): Promise< + [Array>, Array] + > { + // make a check if dropdown menu elem exist + const elemHandles = await this.page.$$( + "section.content ul.dropdown-menu a" + ); + const elemTextContent = await this.page.$$eval( + "section.content ul.dropdown-menu a", + (elems) => elems.map((elem) => elem.textContent) + ); + return [elemHandles, elemTextContent]; + } } diff --git a/src/lib/commandList.ts b/src/lib/commandList.ts index c76360c..1cb52c7 100644 --- a/src/lib/commandList.ts +++ b/src/lib/commandList.ts @@ -1,62 +1,60 @@ import { TCommandCollections } from "./types"; const list: TCommandCollections = { - result: { - name: "result", - description: "Get your examination result.", - arguments: [ - { - name: "", - description: " Must be in the range of 1 >= semester <= 3.", - }, - { - name: "", - description: - "Year of the semester. MUST BE IN THE FORMAT XXXX/XXXX (e.g. 2021/2022)", - }, - ], - options: [ - ], - }, - timetable: { - name: "timetable", - description: "Show class timetable for the given semester.", - arguments: [ - { - name: "", - description: " Must be in the range of 1 >= semester <= 3.", - }, - { - name: "", - description: - "Year of the semester. MUST BE IN THE FORMAT XXXX/XXXX (e.g. 2021/2022)", - }, - ], - options: [ - ], - }, - test: { - name: "test", - description: "", - arguments: [], - options: [], - }, - authenticate: { - name: "authenticate", - description: "Authenticate to i-Ma'luum site.", - arguments: [], - options: [ - { - flags: "-i, --interactive", - }, - { - flags: "-u, --username ", - }, - { - flags: "-p, --password ", - }, - ], - }, + result: { + name: "result", + description: "Get your examination result.", + arguments: [ + { + name: "", + description: " Must be in the range of 1 >= semester <= 3.", + }, + { + name: "", + description: + "Year of the semester. MUST BE IN THE FORMAT XXXX/XXXX (e.g. 2021/2022)", + }, + ], + options: [], + }, + timetable: { + name: "timetable", + description: "Show class timetable for the given semester.", + arguments: [ + { + name: "", + description: " Must be in the range of 1 >= semester <= 3.", + }, + { + name: "", + description: + "Year of the semester. MUST BE IN THE FORMAT XXXX/XXXX (e.g. 2021/2022)", + }, + ], + options: [], + }, + test: { + name: "test", + description: "", + arguments: [], + options: [], + }, + authenticate: { + name: "login", + description: "Authenticate to i-Ma'luum site.", + arguments: [], + options: [ + { + flags: "-i, --interactive", + }, + { + flags: "-u, --username ", + }, + { + flags: "-p, --password ", + }, + ], + }, }; export default list;