├── test_script.pl ├── src ├── AcornError.ts ├── index.ts ├── AcornAPI.ts ├── BasicAcornAPI.ts ├── AcademicHistoryAcornAPI.ts ├── CourseAcornAPI.ts └── CourseInterfaces.ts ├── test ├── TestHelper.ts ├── BasicAcornAPI.spec.ts ├── AcademicHistoryAcornAPI.spec.ts └── CourseAcornAPI.spec.ts ├── .npmignore ├── LICENSE ├── .gitignore ├── README.md ├── package.json └── tsconfig.json /test_script.pl: -------------------------------------------------------------------------------- 1 | # Used by package.json test script 2 | use strict; 3 | use warnings; 4 | 5 | # to run all test, invoke `npm test -- "*"` 6 | my $module_name = $ARGV[0]; 7 | system "mocha -r ts-node/register ./test/$module_name.spec.ts"; -------------------------------------------------------------------------------- /src/AcornError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Charlie on 2017-09-22. 3 | */ 4 | 5 | export class AcornError implements Error { 6 | name: string = 'AcornError'; 7 | message: string; 8 | public constructor(message: string) { 9 | this.message = message; 10 | } 11 | } -------------------------------------------------------------------------------- /test/TestHelper.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Charlie on 2017-10-15. 3 | */ 4 | 5 | import fs = require('fs'); 6 | 7 | const LOG_DIR = './test/logs/'; 8 | if (!fs.existsSync(LOG_DIR)) { 9 | fs.mkdirSync(LOG_DIR); 10 | } 11 | 12 | export const config = 13 | JSON.parse(fs.readFileSync('./test/test_config.json', 'utf-8')); 14 | 15 | export function logToFileSync(file_name:string, data: object):void{ 16 | fs.writeFileSync(`${LOG_DIR}${file_name}.json`, JSON.stringify(data)); 17 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### macOS template 3 | *.DS_Store 4 | .AppleDouble 5 | .LSOverride 6 | 7 | # Icon must end with two \r 8 | Icon 9 | 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | ### Example user template template 30 | ### Example user template 31 | 32 | # IntelliJ project files 33 | .idea 34 | *.iml 35 | out 36 | gen 37 | 38 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Charlie on 2017-09-24. 3 | */ 4 | 5 | 'use strict'; 6 | import {AcornStateManager} from "./AcornAPI"; 7 | import request = require("request"); 8 | import {CourseAcornAPI} from "./CourseAcornAPI"; 9 | import {BasicAcornAPI} from "./BasicAcornAPI"; 10 | import {AcademicHistoryAcornAPI} from "./AcademicHistoryAcornAPI"; 11 | 12 | export class Acorn { 13 | private state: AcornStateManager; 14 | basic: BasicAcornAPI; 15 | course: CourseAcornAPI; 16 | academic: AcademicHistoryAcornAPI; 17 | public constructor() { 18 | this.state = new AcornStateManager(request.jar()); 19 | this.basic = new BasicAcornAPI(this.state); 20 | this.course = new CourseAcornAPI(this.state); 21 | this.academic = new AcademicHistoryAcornAPI(this.state); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/BasicAcornAPI.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Charlie on 2017-09-24. 3 | */ 4 | import {expect} from "chai"; 5 | import {BasicAcornAPI} from "../src/BasicAcornAPI"; 6 | import {config} from "./TestHelper"; 7 | 8 | require('chai').use(require('chai-as-promised')).should(); 9 | 10 | 11 | describe('BasicAcornAPI', function () { 12 | this.timeout(15000); // set timeout to be 15s instead of default 2 13 | let basicAPI: BasicAcornAPI; 14 | it('should be created', function () { 15 | basicAPI = new BasicAcornAPI(); 16 | expect(basicAPI).to.not.be.null; 17 | }); 18 | 19 | it('should throw Acorn Error when user/pass pair is incorrect', function () { 20 | expect(basicAPI.login('user', 'pass')).to.be.rejected; 21 | }); 22 | 23 | // High Delay 24 | it('should login the user', async function () { 25 | // console.log(basicAPI); 26 | let result = await basicAPI.login(config.data.user, config.data.pass); 27 | expect(result).to.be.true; 28 | expect(basicAPI.state.isLoggedIn).to.be.true; 29 | }); 30 | }); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Chenlei Hu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/AcademicHistoryAcornAPI.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Charlie on 2017-10-05. 3 | */ 4 | import {expect} from "chai"; 5 | import {AcademicHistoryAcornAPI} from "../src/AcademicHistoryAcornAPI"; 6 | import {BasicAcornAPI} from "../src/BasicAcornAPI"; 7 | import {AcornStateManager} from "../src/AcornAPI"; 8 | import request = require("request"); 9 | import {config, logToFileSync} from "./TestHelper"; 10 | 11 | require('chai').use(require('chai-as-promised')).should(); 12 | 13 | describe('AcademicHistoryAcornAPI', function () { 14 | this.timeout(15000); // set timeout to be 15s instead of default 2 15 | let academicAPI: AcademicHistoryAcornAPI; 16 | 17 | it('should be created', async function () { 18 | let state = new AcornStateManager(request.jar()); 19 | let basicAPI = new BasicAcornAPI(state); 20 | academicAPI = new AcademicHistoryAcornAPI(state); 21 | expect(academicAPI).to.not.be.null; 22 | await basicAPI.login(config.data.user, config.data.pass); 23 | }); 24 | 25 | it('should get academic history', async function () { 26 | logToFileSync('academic_history', await academicAPI.getAcademicHistory()); 27 | }); 28 | }); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # IDE 61 | /.idea 62 | 63 | # distribution js code generated by tsc 64 | /dist 65 | 66 | # Test Configurations 67 | /test/test_config.json 68 | 69 | # Test logs 70 | /test/logs/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Acorn API (typescript) 2 | This project is inspired by [AcornAPI](https://github.com/LesterLyu/AcornAPI) 3 | which is an Acorn API written in Java. 4 | 5 | ## Installation 6 | ```bash 7 | npm install acorn-api --save 8 | ``` 9 | 10 | ## Example 11 | 12 | ### Login 13 | ```javascript 14 | import { Acorn } from 'acorn-api-js'; 15 | const example = new Acorn(); 16 | example.basic.login('user', 'pass'); 17 | ``` 18 | 19 | ### Get Registrations 20 | ```javascript 21 | example.course.getEligibleRegistrations(); 22 | ``` 23 | 24 | ### Get Student Courses 25 | ```javascript 26 | example.course.getEnrolledCourses(); 27 | example.course.getCartedCourses(); 28 | ``` 29 | 30 | ### Get Course Info (Can also use it to get waiting list rank for a waitlisted course) 31 | ```javascript 32 | int registrationIndex = 0; 33 | const courseCode = "CSC373H1", sectionCode = "Y", courseSessionCode = "20175"; 34 | const course = example.getExtraCourseInfo(registrationIndex, courseCode, courseSessionCode, sectionCode); 35 | ``` 36 | 37 | ### Enroll a Course (Not yet tested) 38 | ```javascript 39 | int registrationIndex = 0; 40 | const courseCode = "CSC373H1", sectionCode = "Y", lecSection = "LEC,5101"; 41 | const result = example.course.enroll(registrationIndex, courseCode, sectionCode, lecSection); 42 | ``` 43 | 44 | ### Get Current Transcript 45 | ```javascript 46 | const academicReport = example.academic.getAcademicHistory(); 47 | ``` 48 | 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "acorn-api", 3 | "version": "1.0.2", 4 | "description": "University of Toronto Acorn API", 5 | "main": "./dist/index.js", 6 | "types": "./dist/index.d.ts", 7 | "scripts": { 8 | "test": "perl test_script.pl", 9 | "prepublishOnly": "tsc -p ./ --outDir dist/" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/huchenlei/acorn-api-js.git" 14 | }, 15 | "keywords": [ 16 | "Acorn" 17 | ], 18 | "author": "Chenlei Hu", 19 | "license": "ISC", 20 | "bugs": { 21 | "url": "https://github.com/huchenlei/acorn-api-js/issues" 22 | }, 23 | "homepage": "https://github.com/huchenlei/acorn-api-js#readme", 24 | "dependencies": { 25 | "@types/libxmljs": "^0.14.30", 26 | "@types/lodash": "^4.14.77", 27 | "@types/request": "^2.0.3", 28 | "@types/request-promise": "^4.1.37", 29 | "@types/tough-cookie": "^2.3.1", 30 | "libxmljs": "^0.18.7", 31 | "lodash": "^4.17.4", 32 | "request": "^2.82.0", 33 | "request-promise": "^4.2.2", 34 | "tough-cookie": "^2.3.3", 35 | "typescript": "^2.5.2" 36 | }, 37 | "devDependencies": { 38 | "@types/chai": "^4.0.4", 39 | "@types/chai-as-promised": "^7.1.0", 40 | "@types/mocha": "^2.2.43", 41 | "chai": "^4.1.2", 42 | "chai-as-promised": "^7.1.1", 43 | "mocha": "^3.5.3", 44 | "request-debug": "^0.2.0", 45 | "ts-node": "^3.3.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/AcornAPI.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Charlie on 2017-09-24. 3 | */ 4 | 5 | 'use strict'; 6 | import request = require('request'); 7 | import {AcornError} from "./AcornError"; 8 | 9 | /** 10 | * Share the state information among all API components 11 | */ 12 | 13 | export class AcornStateManager { 14 | private _cookieJar: request.CookieJar; 15 | 16 | get cookieJar(): request.CookieJar { 17 | return this._cookieJar; 18 | } 19 | 20 | public isLoggedIn: boolean; 21 | 22 | public constructor(cookieJar: request.CookieJar) { 23 | this.isLoggedIn = false; 24 | this._cookieJar = cookieJar; 25 | } 26 | 27 | // TODO add login expire check 28 | } 29 | 30 | /** 31 | * Every API components must have field AcornStateManager 32 | */ 33 | export interface AcornAPI { 34 | state: AcornStateManager; 35 | } 36 | 37 | /** 38 | * Base class for every Acorn API class 39 | */ 40 | export class BaseAcornAPI implements AcornAPI { 41 | public state: AcornStateManager; 42 | 43 | constructor(state: AcornStateManager = new AcornStateManager(request.jar())) { 44 | this.state = state; 45 | } 46 | } 47 | 48 | /** 49 | * Decorator to wrap member functions of BaseAcornAPI and it's ascendants. 50 | * the decorated member functions would first check the login state and then 51 | * proceed. 52 | * 53 | * The return type of decorated function should be a Promise 54 | * @param target BaseAcornAPI instance 55 | * @param propertyKey decorated method name 56 | * @param descriptor method descriptor 57 | * @return {PropertyDescriptor} 58 | */ 59 | export function needLogin(target: any, propertyKey: string, descriptor: any) { 60 | // save a reference to the original method this way we keep the values currently in the 61 | // descriptor and don't overwrite what another decorator might have done to the descriptor. 62 | if (descriptor === undefined) { 63 | descriptor = Object.getOwnPropertyDescriptor(target, propertyKey); 64 | } 65 | let originalMethod = descriptor.value; 66 | // editing the descriptor/value parameter 67 | descriptor.value = async function (...args: any[]) { 68 | if (!(this).state.isLoggedIn) { 69 | throw new AcornError('Need to first login to proceed with ' + propertyKey); 70 | } else { 71 | return originalMethod.apply(this, args); 72 | } 73 | }; 74 | return descriptor; 75 | } -------------------------------------------------------------------------------- /test/CourseAcornAPI.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Charlie on 2017-09-24. 3 | */ 4 | import {expect} from "chai"; 5 | import {CourseAcornAPI} from "../src/CourseAcornAPI"; 6 | import {BasicAcornAPI} from "../src/BasicAcornAPI"; 7 | import {AcornStateManager} from "../src/AcornAPI"; 8 | import {AcornError} from '../src/AcornError'; 9 | import request = require("request"); 10 | import assert = require('assert'); 11 | import _ = require("lodash"); 12 | import {config, logToFileSync} from "./TestHelper"; 13 | 14 | 15 | require('chai').use(require('chai-as-promised')).should(); 16 | 17 | describe('CourseAcornAPI', async function () { 18 | this.timeout(15000); // set timeout to be 15s instead of default 2 19 | let basicAPI: BasicAcornAPI; 20 | let courseAPI: CourseAcornAPI; 21 | it('should be created', function () { 22 | const state = new AcornStateManager(request.jar()); 23 | basicAPI = new BasicAcornAPI(state); 24 | courseAPI = new CourseAcornAPI(state); 25 | 26 | expect(courseAPI).to.not.be.null; 27 | expect(courseAPI.state).to.not.be.undefined; 28 | }); 29 | 30 | it('should share state with other AcornAPI instances', function () { 31 | expect(courseAPI.state).to.deep.equal(basicAPI.state); 32 | }); 33 | 34 | /** 35 | * Test for @needLogin decorator 36 | */ 37 | it('should not get registration if not logged in', function () { 38 | courseAPI.getEligibleRegistrations().should.be.rejected; 39 | }); 40 | 41 | it('should get registration if logged in', async function () { 42 | await basicAPI.login(config.data.user, config.data.pass); 43 | expect(basicAPI.state.isLoggedIn).to.be.true; 44 | 45 | let res = await courseAPI.getEligibleRegistrations(); 46 | res.should.be.a.instanceof(Array); 47 | (>res)[0].should.haveOwnProperty('registrationParams'); 48 | logToFileSync('registrations', res); 49 | }); 50 | 51 | it('should get enrolled courses if logged in', async function () { 52 | logToFileSync('enrolled_courses', await courseAPI.getEnrolledCourses()); 53 | }); 54 | 55 | let cartedCourse: Acorn.CartedCourse | null = null; 56 | it('should get carted courses if logged in', async function () { 57 | const cartedCourses = await courseAPI.getCartedCourses(); 58 | // For later testing use 59 | if (cartedCourses.length > 0) { 60 | cartedCourse = cartedCourses[0]; 61 | } 62 | logToFileSync('carted_courses', cartedCourses); 63 | }); 64 | 65 | // TODO test when course registration is open 66 | // it('should enroll course if logged in', async function () { 67 | // await courseAPI.enroll(0, "CSC467", "F", "LEC,0101"); 68 | // }); 69 | 70 | /** 71 | * To test this part 72 | * The tester's acorn account must have at least one course in enrollment cart 73 | */ 74 | it('should get extra course info if logged in', async function () { 75 | if (cartedCourse === null) { 76 | throw new AcornError('No course in course cart, unable to find extra info'); 77 | } 78 | const sessionCodes = _.compact(_.values(_.pick(cartedCourse, 79 | ['regSessionCode1', 'regSessionCode2', 'regSessionCode3']))); 80 | assert(sessionCodes.length > 0, 'no session code available'); // need at least one sessionCode 81 | const res = await courseAPI.getExtraCourseInfo( 82 | 0, cartedCourse.courseCode, sessionCodes[0], cartedCourse.sectionCode); 83 | logToFileSync('extra_course_info', res); 84 | }); 85 | }); -------------------------------------------------------------------------------- /src/BasicAcornAPI.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This Class provides basic acorn actions 3 | * 4 | * Created by Charlie on 2017-09-22. 5 | */ 6 | 7 | import tough = require('tough-cookie'); 8 | import request = require('request'); 9 | import rp = require('request-promise'); 10 | 11 | import libxmljs = require('libxmljs'); 12 | 13 | import {AcornError} from './AcornError'; 14 | import {BaseAcornAPI} from "./AcornAPI"; 15 | 16 | const ACORN_HOST = "https://acorn.utoronto.ca"; 17 | const urlTable = { 18 | "authURL1": ACORN_HOST + "/sws", 19 | "authURL2": "https://weblogin.utoronto.ca/", 20 | "authURL3": "https://idp.utorauth.utoronto.ca/PubCookie.reply", 21 | "acornURL": ACORN_HOST + "/spACS" 22 | }; 23 | 24 | const formHeader = { 25 | 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/27.0.1453.110 Safari/537.36', 26 | 'content-type': 'application/x-www-form-urlencoded', 27 | 'Accept': 'text/html' 28 | }; 29 | 30 | interface LooseObj { 31 | [key: string]: string; 32 | } 33 | 34 | export class BasicAcornAPI extends BaseAcornAPI { 35 | /** 36 | * Login to Acorn 37 | * @throws AcornError throw AcornError if login failed 38 | * @returns Boolean will be true if all processes goes properly 39 | */ 40 | public async login(user: string, pass: string): Promise { 41 | let body: string = await rp.get({ 42 | uri: urlTable.authURL1, 43 | jar: this.state.cookieJar 44 | }); 45 | body = await rp.post({ 46 | uri: urlTable.authURL2, 47 | jar: this.state.cookieJar, 48 | headers: formHeader, 49 | form: BasicAcornAPI.extractFormData(body), 50 | }); 51 | let loginInfo = BasicAcornAPI.extractFormData(body); 52 | loginInfo['user'] = user; 53 | loginInfo['pass'] = pass; 54 | body = await rp.post({ 55 | uri: urlTable.authURL2, 56 | jar: this.state.cookieJar, 57 | headers: formHeader, 58 | form: loginInfo, 59 | }); 60 | 61 | if (body.search('Authentication failed') > -1) 62 | throw new AcornError('Invalid Identity'); 63 | 64 | body = await rp.post({ 65 | uri: urlTable.authURL3, 66 | jar: this.state.cookieJar, 67 | headers: formHeader, 68 | form: BasicAcornAPI.extractFormData(body), 69 | followAllRedirects: true 70 | }); 71 | 72 | if (body.search('

A problem has occurred

') > -1) 73 | throw new AcornError('A problem has occurred'); 74 | 75 | body = await rp.post({ 76 | uri: urlTable.acornURL, 77 | jar: this.state.cookieJar, 78 | headers: formHeader, 79 | form: BasicAcornAPI.extractFormData(body), 80 | followAllRedirects: true 81 | }); 82 | 83 | if (!(body.search('ACORN') > -1)) 84 | throw new AcornError('Acorn Unavailable Now'); 85 | 86 | // TODO check cookie to verify whether logged in 87 | this.state.isLoggedIn = true; 88 | return true; 89 | } 90 | 91 | /** 92 | * Extract data from fields of all existing forms from HTML string or dom 93 | * Helper method to facilitate auth process 94 | * @param doc HTML Document or HTML string 95 | * @return LooseObj loose javascript object 96 | */ 97 | private static extractFormData(doc: libxmljs.HTMLDocument | string): LooseObj { 98 | let sanctifiedDoc: libxmljs.HTMLDocument; 99 | if (typeof doc === 'string') { 100 | sanctifiedDoc = libxmljs.parseHtml(doc); 101 | } else { 102 | sanctifiedDoc = doc; 103 | } 104 | const inputs: Array = sanctifiedDoc.find('//form//input[@type="hidden"]'); 105 | let result: LooseObj = {}; 106 | for (let input of inputs) { 107 | result[input.attr('name').value()] = input.attr('value') ? input.attr('value').value() : ""; 108 | } 109 | return result; 110 | } 111 | } -------------------------------------------------------------------------------- /src/AcademicHistoryAcornAPI.ts: -------------------------------------------------------------------------------- 1 | import {BaseAcornAPI, needLogin} from "./AcornAPI"; 2 | import rp = require('request-promise'); 3 | import libxmljs = require('libxmljs'); 4 | import _ = require('lodash'); 5 | import {isUndefined} from "util"; 6 | import {AcornError} from "./AcornError"; 7 | import {isNull} from "util"; 8 | import assert = require('assert'); 9 | 10 | /** 11 | * This class is responsible for all academic history operations 12 | * Created by Charlie on 2017-10-05. 13 | */ 14 | 15 | export namespace Acorn { 16 | export interface Score { 17 | code: string; 18 | title: string; 19 | weight: string; 20 | other?: string; 21 | score?: string; 22 | rank?: string; 23 | classRank?: string; 24 | } 25 | 26 | export interface SessionalAcademicHistory { 27 | header: string; 28 | scores: Score[]; 29 | extraInfo?: string; 30 | } 31 | } 32 | 33 | function getText(elements: Array): Array { 34 | return _.map(elements, (element: libxmljs.Element) => { 35 | return element.text(); 36 | }); 37 | } 38 | 39 | export class AcademicHistoryAcornAPI extends BaseAcornAPI { 40 | @needLogin 41 | public async getAcademicHistory(): Promise> { 42 | const page = await rp.get({ 43 | uri: 'https://acorn.utoronto.ca/sws/transcript/academic/main.do?main.dispatch&mode=complete', 44 | jar: this.state.cookieJar 45 | }); 46 | 47 | // const page = require('fs').readFileSync('./sample_original.html', 'utf-8'); 48 | const doc = libxmljs.parseHtml(page); 49 | const infoNode = doc.get("//div[@class='academic-history']//div[@class='academic-history-report row']"); 50 | if (isUndefined(infoNode)) throw new AcornError("Unable to locate academic info div!"); 51 | 52 | // [WARNING]: here only considered the case all session proceed case 53 | const headers = getText(infoNode.find("./h3[@class='sessionHeader']")); 54 | const scores = _.map(getText(infoNode.find("./div[@class='courses blok']")), 55 | sessionScore => { 56 | return _.map(_.filter(sessionScore.split('\n'), 57 | courseScore => { // Remove empty lines 58 | return !(/^[ \t\n]*$/.test(courseScore)); 59 | }), 60 | courseScore => { 61 | let match = /(\w{3,4}\d{3,4}\w\d) (.+?) (\d\.\d\d) (.+)/ 62 | .exec(courseScore); 63 | if (isNull(match)) { 64 | throw new AcornError("Unexpected course score format: " + courseScore); 65 | } 66 | match.shift(); // Remove the first match which is not a capture 67 | _.map(match, _.trim); 68 | const scoreRegex = /(\d{1,3})\s+(\w[+\-]?)\s+(\w[+\-]?)/; 69 | const mustFields = ["code", "title", "weight"]; 70 | const lastField = match[match.length - 1]; 71 | if (scoreRegex.test(lastField)) { 72 | match.pop(); 73 | const scoreMatch = scoreRegex.exec(lastField); 74 | if (isNull(scoreMatch)) throw new AcornError("Severe. This should never happen"); 75 | scoreMatch.shift(); 76 | return _.zipObject(mustFields.concat(["score", "rank", "classRank"]), 77 | match.concat(scoreMatch)); 78 | } else { 79 | return _.zipObject(mustFields.concat(["other"]), match); 80 | } 81 | } 82 | ); 83 | } 84 | ); 85 | const extraInfos = _.chunk(getText(infoNode.find("./div[@class='emph gpa-listing']")), 3); 86 | assert(headers.length === scores.length); 87 | let result = []; 88 | for (let i = 0; i < headers.length; i++) { 89 | const extraInfo = (extraInfos.length > i) ? extraInfos[i] : undefined; 90 | result.push({ 91 | header: headers[i], 92 | scores: scores[i], 93 | extraInfo 94 | }); 95 | } 96 | return result; 97 | } 98 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es6", 5 | /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ 6 | "module": "commonjs", 7 | /* Specify module code generation: 'none', commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 8 | // "lib": [], /* Specify library files to be included in the compilation: */ 9 | // "allowJs": true, /* Allow javascript files to be compiled. */ 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 12 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 13 | "sourceMap": true, 14 | /* Generates corresponding '.map' file. */ 15 | // "outFile": "./dist/", /* Concatenate and emit output to single file. */ 16 | "outDir": "./dist", 17 | /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, 27 | /* Enable all strict type-checking options. */ 28 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 29 | // "strictNullChecks": true, /* Enable strict null checks. */ 30 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 31 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 32 | 33 | /* Additional Checks */ 34 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 35 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 36 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 37 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 38 | 39 | /* Module Resolution Options */ 40 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 41 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 42 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 43 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 44 | // "typeRoots": [], /* List of folders to include type definitions from. */ 45 | // "types": [], /* Type declaration files to be included in compilation. */ 46 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 47 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 48 | 49 | /* Source Map Options */ 50 | "sourceRoot": "./", 51 | /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 52 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 53 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 54 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 55 | 56 | /* Experimental Options */ 57 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 58 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */ 59 | }, 60 | "files": [ 61 | "./node_modules/@types/request/index.d.ts", 62 | "./node_modules/@types/request-promise/index.d.ts", 63 | "./node_modules/@types/tough-cookie/index.d.ts", 64 | "./node_modules/@types/libxmljs/index.d.ts", 65 | "./node_modules/@types/mocha/index.d.ts", 66 | "./node_modules/@types/chai/index.d.ts", 67 | "./node_modules/@types/lodash/index.d.ts", 68 | ], 69 | "include": [ 70 | "src/**/*.ts", 71 | "./main.ts" 72 | ], 73 | "exclude": [ 74 | "node_modules" 75 | ] 76 | } -------------------------------------------------------------------------------- /src/CourseAcornAPI.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 'use strict'; 3 | import {BaseAcornAPI, needLogin} from "./AcornAPI"; 4 | import rp = require('request-promise'); 5 | import querystring = require('querystring'); 6 | import {AcornError} from "./AcornError"; 7 | import _ = require('lodash'); 8 | import assert = require('assert'); 9 | 10 | /** 11 | * This class handles all course related actions on acorn 12 | * Created by Charlie on 2017-09-23. 13 | */ 14 | 15 | 16 | function needRegistration(target: any, propertyKey: string, descriptor: any) { 17 | if (descriptor === undefined) { 18 | descriptor = Object.getOwnPropertyDescriptor(target, propertyKey); 19 | } 20 | let originalMethod = descriptor.value; 21 | descriptor.value = async function (registrationIndex: number = 0, ...args: any[]) { 22 | if ((this).cachedRegistrations.length === 0) { 23 | await (this).getEligibleRegistrations(); 24 | } 25 | const regisNum = (this).cachedRegistrations.length; 26 | if (!(regisNum > registrationIndex)) { 27 | throw new AcornError(`Registration IndexOutOfBound! no enough registrations(need ${registrationIndex + 1}, but got ${regisNum})`); 28 | } 29 | args.unshift(registrationIndex); // add registration index at the front of args 30 | return originalMethod.apply(this, args); 31 | }; 32 | return descriptor; 33 | } 34 | 35 | interface CookieValue { 36 | key: string; 37 | value: string; 38 | [key: string]: any; 39 | } 40 | 41 | export class CourseAcornAPI extends BaseAcornAPI { 42 | cachedRegistrations: Array = []; 43 | 44 | /** 45 | * Get user registration status 46 | * @return {TRequest} 47 | */ 48 | @needLogin 49 | public async getEligibleRegistrations(): Promise> { 50 | const res = await rp.get({ 51 | uri: 'https://acorn.utoronto.ca/sws/rest/enrolment/eligible-registrations', 52 | jar: this.state.cookieJar, 53 | json: true 54 | }); 55 | this.cachedRegistrations = res; 56 | return res; 57 | } 58 | 59 | /** 60 | * Get list of courses that are currently enrolled(APP), waitlisted(WAIT), dropped(DROP) 61 | * @param registrationIndex 62 | * @return {TRequest} 63 | */ 64 | @needLogin 65 | @needRegistration 66 | public async getEnrolledCourses(registrationIndex: number = 0): Promise { 67 | const getQueryStr = querystring.stringify(this.cachedRegistrations[registrationIndex].registrationParams); 68 | return await rp.get({ 69 | uri: 'https://acorn.utoronto.ca/sws/rest/enrolment/course/enrolled-courses?' + getQueryStr, 70 | jar: this.state.cookieJar, 71 | json: true 72 | }); 73 | } 74 | 75 | /** 76 | * Get list of courses that are in enrollment cart 77 | * @param registrationIndex 78 | * @return {TRequest} 79 | */ 80 | @needLogin 81 | @needRegistration 82 | public async getCartedCourses(registrationIndex: number = 0): Promise> { 83 | const getQueryStr = querystring.stringify(_.pick(this.cachedRegistrations[registrationIndex], 84 | ['candidacyPostCode', 'candidacySessionCode', 'sessionCode'])); 85 | return await rp.get({ 86 | uri: 'https://acorn.utoronto.ca/sws/rest/enrolment/plan?' + getQueryStr, 87 | jar: this.state.cookieJar, 88 | json: true 89 | }); 90 | } 91 | 92 | /** 93 | * Normally registrationIndex = 1 is summer course 94 | * {"course":{"code":"CSC236H1","sectionCode":"Y","primaryTeachMethod":"LEC","enroled":false},"lecture":{"sectionNo":"LEC,5101"},"tutorial":{},"practical":{}} 95 | * 96 | * [WARNING] this method has not been tested yet; 97 | * Currently its logic is directly copied from Acorn API(Java) 98 | * @param registrationIndex 99 | * @param code courseCode 100 | * @param sectionCode 101 | * @param lectureSectionNo 102 | */ 103 | @needLogin 104 | @needRegistration 105 | public async enroll(registrationIndex: number, 106 | code: string, 107 | sectionCode: string, 108 | lectureSectionNo: string): Promise { 109 | const payload = { 110 | activeCourse: { 111 | course: { 112 | code, 113 | sectionCode: sectionCode.toUpperCase(), 114 | primaryTeachMethod: "LEC", 115 | enrolled: "false" 116 | }, 117 | lecture: { 118 | sectionNo: lectureSectionNo.toUpperCase() 119 | }, 120 | tutorial: {}, 121 | practical: {} 122 | }, 123 | eligRegParams: this.cachedRegistrations[registrationIndex].registrationParams 124 | }; 125 | let xsrfToken = ""; 126 | this.state.cookieJar.getCookies('https://acorn.utoronto.ca').forEach(cookie => { 127 | const cv = JSON.parse(JSON.stringify(cookie)); 128 | if (cv.key === 'XSRF-TOKEN') { 129 | xsrfToken = cv.value; 130 | } 131 | }); 132 | 133 | assert(xsrfToken !== "", "unable to locate xsrf-token in cookies"); 134 | const res = await rp.post({ 135 | uri: 'https://acorn.utoronto.ca/sws/rest/enrolment/course/modify', 136 | body: payload, 137 | headers: { 138 | "X-XSRF-TOKEN": xsrfToken 139 | } 140 | }); 141 | return true; 142 | } 143 | 144 | /** 145 | * This method loads some extra information on courses 146 | * @param registrationIndex 147 | * @param courseCode 148 | * @param courseSessionCode 149 | * @param sectionCode 150 | * @return {TRequest} 151 | */ 152 | @needLogin 153 | @needRegistration 154 | public async getExtraCourseInfo(registrationIndex: number, 155 | courseCode: string, 156 | courseSessionCode: string, 157 | sectionCode: string): Promise { 158 | const getQueryStr = 159 | querystring.stringify(this.cachedRegistrations[registrationIndex].registrationParams) + '&' + 160 | querystring.stringify({ 161 | activityApprovedInd: "", 162 | activityApprovedOrg: "", 163 | courseCode, 164 | courseSessionCode, 165 | sectionCode 166 | }); 167 | 168 | return await rp.get({ 169 | uri: 'https://acorn.utoronto.ca/sws/rest/enrolment/course/view?' + getQueryStr, 170 | jar: this.state.cookieJar, 171 | json: true 172 | }); 173 | } 174 | } -------------------------------------------------------------------------------- /src/CourseInterfaces.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file describes course structure 3 | * Created by Charlie on 2017-10-03. 4 | */ 5 | namespace Acorn { 6 | export interface Post { 7 | code: string; 8 | description: string; 9 | acpDuration: number; 10 | } 11 | 12 | export interface RegistrationStatuses { 13 | [key: string]: { 14 | registrationStatusCode: string; 15 | yearOfStudy: string; 16 | }; 17 | } 18 | 19 | export interface RegistrationParams { 20 | postCode: string; 21 | postDescription: string; 22 | sessionCode: string; 23 | sessionDescription: string; 24 | status: string; 25 | assocOrgCode: string; 26 | acpDuration: string; 27 | levelOfInstruction: string; 28 | typeOfProgram: string; 29 | subjectCode1: string; 30 | designationCode1: string; 31 | primaryOrgCode: string; 32 | secondaryOrgCode: string; 33 | collaborativeOrgCode: string; 34 | adminOrgCode: string; 35 | coSecondaryOrgCode: string; 36 | yearOfStudy: string; 37 | postAcpDuration: string; 38 | useSws: string; 39 | } 40 | 41 | export interface Registration { 42 | post: Post; 43 | sessionCode: string; 44 | personId: number; 45 | acpDuration: number; 46 | status: string; 47 | yearOfStudy: string; 48 | assocOrgCode: string; 49 | levelOfInstruction: string; 50 | typeOfProgram: string; 51 | subjectCode1: string; 52 | designationCode1: string; 53 | primaryOrgCode: string; 54 | secondaryOrgCode: string; 55 | collaborativeOrgCode: string; 56 | adminOrgCode: string; 57 | coSecondaryOrgCode: string; 58 | overrideSubjectPostPrimaryOrg: string; 59 | candidacyPostCode: string; 60 | candidacySessionCode: string; 61 | postOfferCncLimit: string; 62 | postOfferCncAllowed: string; 63 | activityApprovedInd: string; 64 | activityApprovedOrg: string; 65 | academicStatusDesc?: any; 66 | exchangeTitle?: any; 67 | associatedOrgName?: any; 68 | attendanceClassDesc: string; 69 | gradFundingIndicator: string; 70 | useSws: string; 71 | sessionDescription: string; 72 | studentCampusCode: string; 73 | maxCourseLoad?: any; 74 | registrationSessions: string[]; 75 | registrationStatuses: RegistrationStatuses; 76 | registrationParams: RegistrationParams; 77 | currentRegistration: boolean; 78 | postInfo: string; 79 | sessionInfo: string; 80 | } 81 | 82 | export interface Day { 83 | dayCode: string; 84 | dayName: string; 85 | index: number; 86 | weekDay: boolean; 87 | gregorianCalendarDayOfWeek: number; 88 | } 89 | 90 | export interface Time { 91 | day: Day; 92 | startTime: string; 93 | endTime: string; 94 | buildingCode: string; 95 | room: string; 96 | instructors: string[]; 97 | commaSeparatedInstructorNames: string; 98 | } 99 | 100 | export interface MeetingResource { 101 | sessionCode: string; 102 | activityCode: string; 103 | sectionCode: string; 104 | teachMethod: string; 105 | sectionNumber: string; 106 | sequence: number; 107 | instructorOrgUnit?: any; 108 | teachPercent?: any; 109 | employeeNumber?: any; 110 | logCounter: number; 111 | employeeType?: any; 112 | instructorSurname: string; 113 | instructorFirstName: string; 114 | instructorInitials: string; 115 | } 116 | 117 | export interface Meeting { 118 | sectionNo: string; 119 | sessionCode: string; 120 | teachMethod: string; 121 | enrollSpace: number; 122 | enrollmentSpaceAvailable: number; 123 | totalSpace: number; 124 | cancelled: boolean; 125 | closed: boolean; 126 | waitlistable: boolean; 127 | subTitle1: string; 128 | subTitle2: string; 129 | subTitle3: string; 130 | waitlistRank: number; 131 | waitlistLookupMethod?: any; 132 | times: Time[]; 133 | displayTime: string; 134 | action: string; 135 | full: boolean; 136 | enrollmentControlFull: boolean; 137 | enrollmentControlMissing: boolean; 138 | enrolmentIndicator: string; 139 | enrolmentIndicatorDisplay?: any; 140 | enrolmentIndicatorMsg?: any; 141 | enrolmentIndicatorDates: any[]; 142 | deliveryMode: string; 143 | waitlistableForAll: boolean; 144 | message?: any; 145 | professorApprovalReq: string; 146 | meetingResources: MeetingResource[]; 147 | displayName: string; 148 | subTitle: string; 149 | hasSubTitle: boolean; 150 | commaSeparatedInstructorNames: string; 151 | } 152 | 153 | export interface Course { 154 | code: string; 155 | sectionCode: string; 156 | title: string; 157 | status: string; 158 | primaryTeachMethod: string; 159 | secondaryTeachMethod1: string; 160 | secondaryTeachMethod2: string; 161 | primarySectionNo: string; 162 | secondarySectionNo1: string; 163 | secondarySectionNo2: string; 164 | deliveryMode: string; 165 | waitlistTeachMethod: string; 166 | waitlistSectionNo: string; 167 | waitlistMeetings?: any; 168 | meetings: Meeting[]; 169 | enroled: boolean; 170 | attendanceStatus: string; 171 | sessionCode: string; 172 | courseCredits: string; 173 | markApprovedDate?: any; 174 | mark: string; 175 | regApprovedDate?: any; 176 | regApprovedTime?: any; 177 | subSessionCode: string; 178 | currentCncCreditsBalance?: any; 179 | cncAllowed: string; 180 | postCode?: any; 181 | activityPrimaryOrgCode?: any; 182 | activitySecondaryOrgCode?: any; 183 | activityCoSecondaryOrgCode?: any; 184 | courseStatusIfEnroling?: any; 185 | cancelled: boolean; 186 | regSessionCode1: string; 187 | regSessionCode2: string; 188 | regSessionCode3: string; 189 | enrolmentErrorCode: number; 190 | enrolmentErrorMessage?: any; 191 | planErrorCode: number; 192 | planningAllowed: boolean; 193 | planningStartDate?: any; 194 | planningEndDate?: any; 195 | enrolEnded: boolean; 196 | enrolNotStarted: boolean; 197 | retrieveCancelledCourses: boolean; 198 | displayName: string; 199 | meetingList: string[]; 200 | waitlistable: boolean; 201 | approved: boolean; 202 | dropped: boolean; 203 | refused: boolean; 204 | requested: boolean; 205 | interim: boolean; 206 | waitlisted: boolean; 207 | enroledInPrimary: boolean; 208 | enroledInSecondary1: boolean; 209 | enroledInSecondary2: boolean; 210 | isEligibleForCnc: boolean; 211 | isWithinSessionalDatesForCnc: boolean; 212 | enroledInAll: boolean; 213 | enroledMeetingSections: string[]; 214 | teachMethods: string[]; 215 | secondaryTeachMethods: string[]; 216 | deliveryModeDisplay: string; 217 | } 218 | 219 | export interface EnrolledCourses{ 220 | APP?: Course[]; 221 | WAIT?: Course[]; 222 | DROP?: Course[]; 223 | } 224 | 225 | export interface Info { 226 | primaryActivities: any[]; 227 | secondaryActivities: any[]; 228 | thirdActivities: any[]; 229 | } 230 | 231 | export interface Message { 232 | title: string; 233 | value: string; 234 | } 235 | 236 | export interface CartedCourse { 237 | _id: string; 238 | courseCode: string; 239 | sectionCode: string; 240 | primaryActivityId: string; 241 | secondaryActivityId: string; 242 | thirdActivityId: string; 243 | courseTitle: string; 244 | cancelled: boolean; 245 | regSessionCode1: string; 246 | regSessionCode2: string; 247 | regSessionCode3: string; 248 | messages: Message[]; 249 | info: Info; 250 | } 251 | } 252 | 253 | --------------------------------------------------------------------------------