├── .travis.yml
├── src
├── history
│ ├── package.json
│ ├── chunk.js
│ ├── index.js
│ ├── scrape.js
│ ├── condense.js
│ └── history.test.js
├── gradebook
│ ├── package.json
│ ├── index.js
│ ├── scrape.js
│ ├── gradebook.test.js
│ ├── parse.js
│ └── data
│ │ └── payload.data.js
├── reportcard
│ ├── package.json
│ ├── index.js
│ ├── parse.js
│ ├── scrape.js
│ ├── condense.js
│ └── reportcard.test.js
└── authenticate
│ ├── package.json
│ ├── index.js
│ ├── decode.js
│ ├── login.js
│ └── authenticate.test.js
├── .eslintrc.json
├── CHANGELOG.md
├── package.json
├── index.js
├── LICENSE
├── .gitignore
├── index.test.js
└── README.md
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
3 | node_js:
4 | - '7.8.0'
--------------------------------------------------------------------------------
/src/history/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "history",
3 | "main": "index.js"
4 | }
--------------------------------------------------------------------------------
/src/gradebook/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "gradebook",
3 | "main": "index.js"
4 | }
--------------------------------------------------------------------------------
/src/reportcard/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "reportcard",
3 | "main": "index.js"
4 | }
--------------------------------------------------------------------------------
/src/authenticate/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "authenticate",
3 | "main": "index.js"
4 | }
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "airbnb-base",
3 | "rules": {
4 | "linebreak-style": 0,
5 | "import/no-extraneous-dependencies": false
6 | }
7 | }
--------------------------------------------------------------------------------
/src/history/chunk.js:
--------------------------------------------------------------------------------
1 | module.exports = chunkFunc => (arr, yearData) => [
2 | ...arr,
3 | ...yearData.reduce((years, row) => (chunkFunc(row)
4 | ? [...years, [row]]
5 | : [...years.slice(0, -1), [...years.slice(-1)[0], row]]), []),
6 | ];
7 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # 1.0.1 (5/6/19)
2 |
3 | #### Bug Fixes
4 |
5 | * fixed `.scrapeReport` returning empty courses
6 |
7 | # 1.0.0 (3/17/19)
8 |
9 | #### Features
10 |
11 | * added `scraper`
12 | * added `.scrapeGradebook`
13 | * added `.scrapeHistory`
14 | * added `.scrapeReport`
--------------------------------------------------------------------------------
/src/authenticate/index.js:
--------------------------------------------------------------------------------
1 |
2 | const axios = require('axios');
3 | const decode = require('./decode');
4 | const login = require('./login');
5 |
6 | /* expose a more friendly api */
7 | module.exports = skywardURL => (
8 | (user, pass) => login(axios, skywardURL)({ user, pass })
9 | .then(decode)
10 | );
11 |
--------------------------------------------------------------------------------
/src/gradebook/index.js:
--------------------------------------------------------------------------------
1 |
2 | const axios = require('axios');
3 | const scrape = require('./scrape');
4 | const parse = require('./parse');
5 |
6 | module.exports = {
7 | fetch: skywardURL => (
8 | auth => (
9 | (course, bucket) => scrape(axios, skywardURL)(auth, course, bucket)
10 | )
11 | ),
12 |
13 | getData: parse,
14 | };
15 |
--------------------------------------------------------------------------------
/src/history/index.js:
--------------------------------------------------------------------------------
1 |
2 | const axios = require('axios');
3 | const scrape = require('./scrape');
4 | const parse = require('../reportcard/parse');
5 | const condense = require('./condense');
6 |
7 | module.exports = {
8 | fetch: skywardURL => (
9 | auth => scrape(axios, skywardURL)(auth)
10 | ),
11 |
12 | getData: raw => condense(parse(raw)),
13 | };
14 |
--------------------------------------------------------------------------------
/src/reportcard/index.js:
--------------------------------------------------------------------------------
1 |
2 | const axios = require('axios');
3 | const scrape = require('./scrape');
4 | const parse = require('./parse');
5 | const condense = require('./condense');
6 |
7 | /* expose a more friendly api */
8 | module.exports = {
9 | fetch: skywardURL => (
10 | auth => scrape(axios, skywardURL)(auth)
11 | ),
12 |
13 | getData: raw => condense(parse(raw)),
14 | };
15 |
--------------------------------------------------------------------------------
/src/reportcard/parse.js:
--------------------------------------------------------------------------------
1 | const cheerio = require('cheerio');
2 |
3 | module.exports = ({ data }) => {
4 | const $ = cheerio.load(data);
5 |
6 | const script = $('script[data-rel="sff"]').html();
7 |
8 | const results = /\$\.extend\(\(sff\.getValue\('sf_gridObjects'\) \|\| {}\), ([\s\S]*)\)\);/g.exec(script);
9 |
10 | return (results === null) ? {} : eval(`0 || ${results[1]}`); // eslint doesn't like `eval`, and neither do I
11 | };
12 |
--------------------------------------------------------------------------------
/src/authenticate/decode.js:
--------------------------------------------------------------------------------
1 | module.exports = ({ data } = {}) => {
2 | if (!data) throw new TypeError('data is required');
3 |
4 | if (data === '
Invalid login or password.') throw new Error('Invalid Skyward credentials');
5 |
6 | const tokens = data.slice(4, -5)
7 | .split('^');
8 |
9 | if (tokens.length < 15) throw new Error('Malformed auth data');
10 |
11 | return {
12 | dwd: tokens[0], wfaacl: tokens[3], encses: tokens[14], sessionId: `${tokens[1]}%15${tokens[2]}`,
13 | };
14 | };
15 |
--------------------------------------------------------------------------------
/src/reportcard/scrape.js:
--------------------------------------------------------------------------------
1 |
2 | const body = ({ dwd, wfaacl, encses }) => {
3 | if (!dwd || !wfaacl || !encses) throw new TypeError('dwd, wfaacl, & encses are required');
4 |
5 | return `dwd=${dwd}&wfaacl=${wfaacl}&encses=${encses}`;
6 | };
7 |
8 | module.exports = (axios, skywardURL) => (auth) => {
9 | if (!axios || !skywardURL) throw new TypeError('axios & skywardURL are required');
10 |
11 | return axios({
12 | url: '../sfgradebook001.w',
13 | baseURL: skywardURL,
14 | method: 'post',
15 | data: body(auth),
16 | });
17 | };
18 |
--------------------------------------------------------------------------------
/src/authenticate/login.js:
--------------------------------------------------------------------------------
1 |
2 | const body = ({ user, pass }) => {
3 | if (!user || !pass) throw new TypeError('user & pass are required');
4 |
5 | return `requestAction=eel&codeType=tryLogin&login=${user}&password=${pass}`;
6 | };
7 |
8 | module.exports = (axios, skywardURL) => (credentials) => {
9 | if (!axios || !skywardURL) throw new TypeError('axios & skywardURL are required');
10 |
11 | return axios({
12 | url: '../skyporthttp.w',
13 | baseURL: skywardURL,
14 | method: 'post',
15 | data: body(credentials),
16 | });
17 | };
18 |
--------------------------------------------------------------------------------
/src/history/scrape.js:
--------------------------------------------------------------------------------
1 |
2 | const body = ({ dwd, wfaacl, encses }) => {
3 | if (!dwd || !wfaacl || !encses) throw new TypeError('dwd, wfaacl, & encses are required');
4 |
5 | return `dwd=${dwd}&wfaacl=${wfaacl}&encses=${encses}`;
6 | };
7 |
8 | module.exports = (axios, skywardURL) => (auth) => {
9 | if (!axios || !skywardURL) throw new TypeError('axios & skywardURL are required');
10 |
11 | return axios({
12 | url: '../sfacademichistory001.w',
13 | baseURL: skywardURL,
14 | method: 'post',
15 | data: body(auth),
16 | });
17 | };
18 |
--------------------------------------------------------------------------------
/src/gradebook/scrape.js:
--------------------------------------------------------------------------------
1 |
2 | const body = ({ encses, sessionId }, course, bucket) => {
3 | if (!encses || !sessionId) throw new TypeError('encses & sessionId are required');
4 |
5 | return 'action=viewGradeInfoDialog&fromHttp=yes&ishttp=true'
6 | + `&corNumId=${course}&bucket=${bucket}`
7 | + `&sessionid=${sessionId}&encses=${encses}`;
8 | };
9 |
10 | module.exports = (axios, skywardURL) => (auth, course, bucket) => {
11 | if (!axios || !skywardURL) throw new TypeError('axios & skywardURL are required');
12 |
13 | return axios({
14 | url: '../httploader.p?file=sfgradebook001.w',
15 | baseURL: skywardURL,
16 | method: 'post',
17 | data: body(auth, course, bucket),
18 | });
19 | };
20 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "skyward-rest",
3 | "version": "1.0.1",
4 | "description": "Unofficial Rest API for Skyward",
5 | "main": "index.js",
6 | "dependencies": {
7 | "axios": "^0.18.0",
8 | "bluebird": "^3.5.3",
9 | "cheerio": "^1.0.0-rc.2",
10 | "eslint": "^5.15.0"
11 | },
12 | "devDependencies": {
13 | "ava": "^1.3.1",
14 | "dotenv": "^7.0.0",
15 | "eslint-config-airbnb-base": "^13.1.0",
16 | "eslint-plugin-import": "^2.16.0"
17 | },
18 | "scripts": {
19 | "test": "ava",
20 | "watch": "npx ava --watch"
21 | },
22 | "repository": {
23 | "type": "git",
24 | "url": "git+https://github.com/Kaelinator/skyward-rest.git"
25 | },
26 | "keywords": [
27 | "Skyward",
28 | "rest",
29 | "api",
30 | "get",
31 | "grab",
32 | "scrape",
33 | "grades"
34 | ],
35 | "author": "Kael Kirk",
36 | "license": "MIT",
37 | "bugs": {
38 | "url": "https://github.com/Kaelinator/skyward-rest/issues"
39 | },
40 | "homepage": "https://github.com/Kaelinator/skyward-rest#readme"
41 | }
42 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 |
2 | const reportcard = require('./src/reportcard');
3 | const gradebook = require('./src/gradebook');
4 | const authenticate = require('./src/authenticate');
5 | const history = require('./src/history');
6 |
7 | module.exports = skywardURL => ({
8 | scrapeReport: (user, pass) => (
9 | authenticate(skywardURL)(user, pass)
10 | .then(auth => reportcard.fetch(skywardURL)(auth))
11 | .then(response => ({
12 | raw: response.data,
13 | data: reportcard.getData(response),
14 | }))
15 | ),
16 |
17 | scrapeGradebook: (user, pass, { course, bucket }) => (
18 | authenticate(skywardURL)(user, pass)
19 | .then(auth => gradebook.fetch(skywardURL)(auth)(course, bucket))
20 | .then(response => ({
21 | raw: response.data,
22 | data: gradebook.getData(response),
23 | }))
24 | ),
25 |
26 | scrapeHistory: (user, pass) => (
27 | authenticate(skywardURL)(user, pass)
28 | .then(history.fetch(skywardURL))
29 | .then(response => ({
30 | raw: response.data,
31 | data: history.getData(response),
32 | }))
33 | ),
34 | });
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 FruitsNVeggies
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 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # tmp data
2 | tmp
3 |
4 | # Logs
5 | logs
6 | *.log
7 | npm-debug.log*
8 | yarn-debug.log*
9 | yarn-error.log*
10 |
11 | # Runtime data
12 | pids
13 | *.pid
14 | *.seed
15 | *.pid.lock
16 |
17 | # Directory for instrumented libs generated by jscoverage/JSCover
18 | lib-cov
19 |
20 | # Coverage directory used by tools like istanbul
21 | coverage
22 |
23 | # nyc test coverage
24 | .nyc_output
25 |
26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
27 | .grunt
28 |
29 | # Bower dependency directory (https://bower.io/)
30 | bower_components
31 |
32 | # node-waf configuration
33 | .lock-wscript
34 |
35 | # Compiled binary addons (http://nodejs.org/api/addons.html)
36 | build/Release
37 |
38 | # Dependency directories
39 | node_modules/
40 | jspm_packages/
41 |
42 | # Typescript v1 declaration files
43 | typings/
44 |
45 | # Optional npm cache directory
46 | .npm
47 |
48 | # Optional eslint cache
49 | .eslintcache
50 |
51 | # Optional REPL history
52 | .node_repl_history
53 |
54 | # Output of 'npm pack'
55 | *.tgz
56 |
57 | # Yarn Integrity file
58 | .yarn-integrity
59 |
60 | # dotenv environment variables file
61 | .env
62 |
63 |
--------------------------------------------------------------------------------
/index.test.js:
--------------------------------------------------------------------------------
1 | const test = require('ava');
2 | const fs = require('fs');
3 | const Promise = require('bluebird');
4 | require('dotenv').config();
5 |
6 | Promise.promisifyAll(fs);
7 |
8 | const skyward = require('./index');
9 |
10 | const writeResults = prefix => ({ raw, data }) => Promise.all([
11 | fs.writeFileAsync(`./tmp/test/${prefix}_raw.html`, raw),
12 | fs.writeFileAsync(`./tmp/test/${prefix}_data.json`, JSON.stringify(data, null, 2)),
13 | ]);
14 |
15 | const { SKY_USER, SKY_PASS, SKY_URL } = process.env;
16 | const myisd = skyward(SKY_URL);
17 |
18 | test.serial.skip('scrapeReport api integration', t => (
19 | myisd.scrapeReport(SKY_USER, SKY_PASS)
20 | .then(writeResults('report'))
21 | .then(t.pass)
22 | .catch(t.fail)
23 | ));
24 |
25 | test.serial.skip('scrapeGradebook api integration', t => (
26 | myisd.scrapeGradebook(SKY_USER, SKY_PASS, { course: 97791, bucket: 'TERM 1' })
27 | .then(writeResults('gradebook'))
28 | .then(t.pass)
29 | .catch(t.fail)
30 | ));
31 |
32 | test.serial.skip('scrapeHistory api integration', t => (
33 | myisd.scrapeHistory(SKY_USER, SKY_PASS)
34 | .then(writeResults('history'))
35 | .then(t.pass)
36 | .catch(t.fail)
37 | ));
38 |
--------------------------------------------------------------------------------
/src/reportcard/condense.js:
--------------------------------------------------------------------------------
1 | const $ = require('cheerio');
2 |
3 | const isClassHeader = ({ c }) => c !== undefined && c.length > 0 && c[0].cId !== undefined;
4 |
5 | const isScoreElement = ({ h }) => h !== undefined && $(h).find('a').length;
6 |
7 | const isEmpty = data => data.course !== undefined;
8 |
9 | const getData = ({ h }) => {
10 | const element = $(h).find('a')[0];
11 |
12 | return ({
13 | course: Number($(element).attr('data-cni')),
14 | bucket: $(element).attr('data-bkt'),
15 | score: Number($(element).text()),
16 | });
17 | };
18 |
19 | const merge = (parent, child) => ({
20 | course: parent.course || child.course,
21 | scores: parent.scores.concat({ bucket: child.bucket, score: child.score }),
22 | });
23 |
24 | module.exports = (data) => {
25 | const values = Object.entries(data);
26 | const targetPair = values.find(([key]) => /stuGradesGrid_\d{5}_\d{3}/.test(key));
27 |
28 | if (targetPair === undefined) throw new Error('stuGradesGrid not found');
29 |
30 | const targetData = targetPair[1];
31 | if (targetData.tb === undefined) throw new Error('stuGradesGrid.tb not found');
32 |
33 | const { r } = targetData.tb;
34 | if (r === undefined) return [];
35 |
36 | return targetData.tb.r
37 | .filter(isClassHeader)
38 | .map(({ c }) => c.filter(isScoreElement)
39 | .map(getData)
40 | .reduce(merge, { scores: [] }))
41 | .filter(isEmpty);
42 | };
43 |
--------------------------------------------------------------------------------
/src/authenticate/authenticate.test.js:
--------------------------------------------------------------------------------
1 | const test = require('ava');
2 |
3 | const login = require('./login');
4 |
5 | test('throws when given malformed arguments', (t) => {
6 | t.throws(() => login()(), /axios & skywardURL/, 'given no arguments');
7 | t.throws(() => login(x => x, 'fakeUrl')({}), /user & pass/, 'given no credentials');
8 | });
9 |
10 | test('credentials placed correctly', (t) => {
11 | const mockCredentials = { user: 1, pass: 2 };
12 | const mockAxios = ({ data }) => data;
13 |
14 | t.is(login(mockAxios, 'fakeUrl')(mockCredentials), 'requestAction=eel&codeType=tryLogin&login=1&password=2');
15 | });
16 |
17 | const decode = require('./decode');
18 |
19 | test('decode identifies incorrect credentials & throws when given malformed auth data', (t) => {
20 | t.throws(() => decode({ data: 'Invalid login or password.' }), /Invalid Skyward credentials/, 'given invalid credentials');
21 | t.throws(() => decode(), /data is required/, 'given no arguments');
22 | t.throws(() => decode({ data: '' }), /Malformed auth data/, 'given malformed data');
23 | });
24 |
25 | test('decode identifies tokens', (t) => {
26 | const data = '319238^279419^23009402^27834052^58192^s219261^2^sfhome01.w^false^no ^no^no^^zdkNjlfkjbwanfcX^jDWadubjdaCOdEjY';
27 | const expected = {
28 | dwd: '319238', wfaacl: '27834052', encses: 'jDWadubjdaCOdEjY', sessionId: '279419%1523009402',
29 | };
30 | t.deepEqual(decode({ data }), expected);
31 | });
32 |
--------------------------------------------------------------------------------
/src/reportcard/reportcard.test.js:
--------------------------------------------------------------------------------
1 | const test = require('ava');
2 | const Promise = require('bluebird');
3 | const fs = require('fs');
4 |
5 | Promise.promisifyAll(fs);
6 |
7 | const scrape = require('./scrape');
8 |
9 | test('throws when given malformed arguments', (t) => {
10 | t.throws(() => scrape()(), /axios & skywardURL/, 'given no arguments');
11 | t.throws(() => scrape(x => x, 'fakeUrl')({}), /dwd, wfaacl, & encses/, 'given no auth data');
12 | });
13 |
14 | test('auth data placed correctly', (t) => {
15 | const auth = { dwd: 1, wfaacl: 2, encses: 3 };
16 | const mockAxios = ({ data }) => data;
17 |
18 | t.is(scrape(mockAxios, 'fakeUrl')(auth), 'dwd=1&wfaacl=2&encses=3');
19 | });
20 |
21 | const parse = require('./parse');
22 | const payload = require('./data/payload.data');
23 |
24 | test('parse extracts javascript', (t) => {
25 | t.deepEqual(parse({ data: payload.slimHtml }), { x: 'marks the spot' });
26 | t.notThrows(() => parse({ data: payload.fullHtml }), 'parse executes without throwing');
27 | });
28 |
29 | const condense = require('./condense');
30 |
31 | test('condense handles malformed input', (t) => {
32 | t.throws(() => condense({}), /stuGradesGrid not found/, 'no \'stuGradesGrid\' key exists');
33 |
34 | const noTb = { stuGradesGrid_74477_004: {} };
35 | t.throws(() => condense(noTb), /tb not found/, 'no \'tb\' key exists');
36 |
37 | const noR = { stuGradesGrid_74477_004: { tb: {} } };
38 | t.deepEqual(condense(noR), [], 'no \'r\' key exists');
39 | });
40 |
41 | test('condense matches example data', (t) => {
42 | const payloadTest = ({ input, output }, message) => t.deepEqual(condense(input), output, message);
43 |
44 | payloadTest(payload.slimSingleCourse, 'matches with minimal single course data');
45 | payloadTest(payload.fullSingleCourse, 'matches with full single course data');
46 | payloadTest(payload.slimMultiCourse, 'matches with slim multi course data');
47 | payloadTest(payload.emptyMultiCourse, 'removes null with empty multi course data');
48 | payloadTest(payload.fullMultiCourse, 'matches with full multi course data');
49 | });
50 |
--------------------------------------------------------------------------------
/src/history/condense.js:
--------------------------------------------------------------------------------
1 | const $ = require('cheerio');
2 | const chunk = require('./chunk');
3 |
4 | const parseHeader = ({ c }) => {
5 | const headerText = $(c[0].h).text();
6 | const headerResults = /(\d+)\D+(\d+)\D+(\d+)/.exec(headerText);
7 |
8 | const begin = headerResults && headerResults[1];
9 | const end = headerResults && headerResults[2];
10 | const dates = { begin, end };
11 |
12 | const grade = headerResults ? Number(headerResults[3]) : null;
13 |
14 | return { dates, grade, courses: [] };
15 | };
16 |
17 | const parseLits = ({ c }) => c.slice(2)
18 | .map(({ h }) => $(h).text())
19 | .map(lit => ({ lit }));
20 |
21 | const parseCourses = ({ c }) => {
22 | const course = $(c[0].h).text();
23 | const scores = c.slice(2)
24 | .map(({ h }) => $(h).text().trim())
25 | .map(text => ({ grade: Number(text) || text || null }));
26 | return { course, scores };
27 | };
28 |
29 | const merge = (obj, row) => {
30 | if (row.courses) return row; // set base object
31 | if (row.scores) return Object.assign(obj, { courses: obj.courses.concat(row) }); // append score
32 |
33 | /* place 'lit' information into every score */
34 | const courses = obj.courses
35 | .map(course => Object.assign(course, {
36 | scores: course.scores
37 | .map(({ grade }, i) => Object.assign({ grade }, row[i]))
38 | .filter(({ grade }) => !!grade), // get rid of null elements
39 | }));
40 |
41 | return Object.assign(obj, { courses });
42 | };
43 |
44 | module.exports = (data) => {
45 | const values = Object.entries(data);
46 | const targetPairs = values.filter(([key]) => /gradeGrid_\d{5}_\d{3}_\d{4}/.test(key));
47 |
48 | // if (targetPairs.length === 0) throw new Error('gradeGrid not found');
49 |
50 | // const targetData = targetPairs[1];
51 | // if (targetData.tb === undefined) throw new Error('gradeGrid.tb not found');
52 |
53 | // const { r } = targetData.tb;
54 | // if (r === undefined) return [];
55 |
56 | const isHeader = row => /(\d+)\D+(\d+)\D+(\d+)/.test($(row.c[0].h).find('div').first().text());
57 |
58 | return targetPairs
59 | .map(pair => pair[1])
60 | .map(({ tb: { r } }) => r)
61 | .reduce(chunk(isHeader), [])
62 | .map(([header, lits, ...courses]) => [
63 | parseHeader(header),
64 | ...courses.map(parseCourses),
65 | parseLits(lits),
66 | ].reduce(merge));
67 | };
68 |
--------------------------------------------------------------------------------
/src/gradebook/gradebook.test.js:
--------------------------------------------------------------------------------
1 | const test = require('ava');
2 | const Promise = require('bluebird');
3 | const fs = require('fs');
4 |
5 | Promise.promisifyAll(fs);
6 |
7 | const scrape = require('./scrape');
8 |
9 | test('throws when given malformed arguments', (t) => {
10 | t.throws(() => scrape()(), /axios & skywardURL/, 'given no arguments');
11 | t.throws(() => scrape(x => x, 'fakeUrl')({}), /encses & sessionId/, 'given no auth data');
12 | });
13 |
14 | test('auth & request data placed correctly', (t) => {
15 | const auth = { encses: 1, sessionId: 2 };
16 | const mockAxios = ({ data }) => data;
17 |
18 | const expectedBody = 'action=viewGradeInfoDialog&fromHttp=yes&ishttp=true'
19 | + '&corNumId=98112&bucket=TERM 1&sessionid=2&encses=1';
20 |
21 | t.is(scrape(mockAxios, 'fakeUrl')(auth, 98112, 'TERM 1'), expectedBody);
22 | });
23 |
24 | const parse = require('./parse');
25 | const payload = require('./data/payload.data');
26 |
27 | const testParsePlan = t => ({ input, output }, message) => {
28 | const result = parse({ data: input });
29 |
30 | t.deepEqual(result.course, output.course, `course value matches ${message}`);
31 | t.deepEqual(result.instructor, output.instructor, `instructor value matches ${message}`);
32 | t.deepEqual(result.lit, output.lit, `lit value matches ${message}`);
33 | t.deepEqual(result.period, output.period, `period value matches ${message}`);
34 | t.deepEqual(result.grade, output.grade, `grade value matches ${message}`);
35 | t.deepEqual(result.gradeAdjustment, output.gradeAdjustment, `gradeAdjustment value matches ${message}`);
36 | t.deepEqual(result.score, output.score, `score value matches ${message}`);
37 | t.deepEqual(result.breakdown, output.breakdown, `breakdown value matches ${message}`);
38 | t.deepEqual(result.gradebook, output.gradebook, `breakdown value matches ${message}`);
39 | };
40 |
41 | test('parse matches example data', (t) => {
42 | const testParse = testParsePlan(t);
43 |
44 | testParse(payload.simplePR, 'with a simple Progress Report');
45 | testParse(payload.simpleQ, 'with a simple Quarter');
46 | testParse(payload.simpleSem, 'with a simple Semester');
47 |
48 | testParse(payload.emptyMajorPR, 'with a Progress Report missing major grades');
49 | testParse(payload.gradeAdjustedQ, 'with a Quarter that has grade adjustment');
50 | testParse(payload.emptyScores, 'with a Quarter that has empty scores');
51 | testParse(payload.decimalScores, 'with a Quarter that has decimal scores');
52 | });
53 |
--------------------------------------------------------------------------------
/src/history/history.test.js:
--------------------------------------------------------------------------------
1 | const $ = require('cheerio');
2 | const test = require('ava');
3 | const scrape = require('./scrape');
4 |
5 | test('throws when given malformed arguments', (t) => {
6 | t.throws(() => scrape()(), /axios & skywardURL/, 'given no arguments');
7 | t.throws(() => scrape(x => x, 'fakeUrl')({}), /dwd, wfaacl, & encses are required/, 'given no credentials');
8 | });
9 |
10 | test('credentials placed correctly', (t) => {
11 | const auth = { dwd: 1, wfaacl: 2, encses: 3 };
12 | const mockAxios = ({ data }) => data;
13 |
14 | t.is(scrape(mockAxios, 'fakeUrl')(auth), 'dwd=1&wfaacl=2&encses=3');
15 | });
16 |
17 |
18 | const parse = require('../reportcard/parse');
19 | const payload = require('./data/payload.data');
20 |
21 | test('reportcard parse extracts javascript', (t) => {
22 | t.deepEqual(parse({ data: payload.slimHtml }), { x: 'marks the spot' });
23 | });
24 |
25 | const chunk = require('./chunk');
26 |
27 | test('chunk helper function regroups arrays', (t) => {
28 | const chunkBy2s = [[0, 1, 2, 3]].reduce(chunk(n => n % 2 === 0), []);
29 | t.deepEqual(chunkBy2s, [[0, 1], [2, 3]], 'chunks with single nested array');
30 |
31 | const chunkBy10s = [[0, 5, 10, 15], [20, 25], [30, 35, 40, 45, 50, 55]]
32 | .reduce(chunk(n => n % 10 === 0), []);
33 |
34 | t.deepEqual(chunkBy10s, [[0, 5], [10, 15], [20, 25], [30, 35], [40, 45], [50, 55]], 'chunks with multiple nested arrays');
35 |
36 | const chunkAdjacent = [[0, 5, 10, 20, 30, 31, 32, 35], [40, 45]]
37 | .reduce(chunk(n => n % 10 === 0), []);
38 | t.deepEqual(chunkAdjacent, [[0, 5], [10], [20], [30, 31, 32, 35], [40, 45]], 'chunks with adjacent matching values');
39 |
40 | const isHeader = row => /(\d+)\D+(\d+)\D+(\d+)/.test($(row.c[0].h).find('div').first().text());
41 |
42 | const { input, output } = payload.chunkConjoinedYear;
43 | t.deepEqual(input.reduce(chunk(isHeader), []), output, 'chunks by each year');
44 | });
45 |
46 | const condense = require('./condense');
47 |
48 | test('reportcard condense extracts meaningful data', (t) => {
49 | const payloadTest = ({ input, output }, message) => t.deepEqual(condense(input), output, message);
50 |
51 | payloadTest(payload.slimSingle, 'matches with minimal single year, single course data');
52 | payloadTest(payload.slimMultiCourse, 'matches with minimal single year, multi course data');
53 | payloadTest(payload.slimMultiYear, 'matches with minimal multi year, single course data');
54 | payloadTest(payload.fullConjoinedYear, 'matches with full multi conjoined years with multi course data');
55 | });
56 |
--------------------------------------------------------------------------------
/src/gradebook/parse.js:
--------------------------------------------------------------------------------
1 | const cheerio = require('cheerio');
2 |
3 | const extractNumber = (regexp, text) => {
4 | const result = regexp.exec(text);
5 | const n = result && Number(result[1]);
6 | return (n === 0) ? 0 : n || null;
7 | };
8 |
9 | const extractPoints = (pointsText) => {
10 | const earned = extractNumber(/([\d*.]+)\D+[\d*.]+/, pointsText);
11 | const total = extractNumber(/[\d*.]+\D+([\d*.]+)/, pointsText);
12 | return { earned, total };
13 | };
14 |
15 | const parseHeader = ($) => {
16 | const course = $('h2.gb_heading>span>a').first().text(); // e.g. 'PHYSICS 2 AP'
17 | const instructor = $('h2.gb_heading>span>a').last().text(); // e.g. 'Jay'
18 |
19 | const periodText = $('h2.gb_heading>span>span').text(); // e.g. '(Period #)'
20 | const period = extractNumber(/\(\D+(\d+)\)/, periodText);
21 |
22 | return { course, instructor, period };
23 | };
24 |
25 | const parseSummary = ($) => {
26 | const resultRow = $('table[id*="grid_stuTermSummaryGrid"]>tbody>tr').first();
27 |
28 | const gradeText = resultRow.find('td').first().text();
29 | const grade = extractNumber(/(\d+)/, gradeText);
30 |
31 | const scoreText = resultRow.find('td').last().text();
32 | const score = extractNumber(/(\d+\.\d+)/, scoreText);
33 |
34 | const gradeAdjustmentText = $('table[id*="grid_stuTermSummaryGrid"]>tbody>tr').slice(1, 2).find('td').last()
35 | .text();
36 | const gradeAdjustment = extractNumber(/(\d+\.\d+)/, gradeAdjustmentText);
37 |
38 | const litText = $('table[id*="grid_stuTermSummaryGrid"]>thead>tr>th').first().text();
39 | const litResults = /(\w+)\D+\((\d{2}\/\d{2}\/\d{4})\s-\s(\d{2}\/\d{2}\/\d{4})\)/.exec(litText);
40 | const name = litResults && litResults[1];
41 | const begin = litResults && litResults[2];
42 | const end = litResults && litResults[3];
43 | const lit = { name, begin, end };
44 |
45 | return {
46 | grade, score, lit, gradeAdjustment,
47 | };
48 | };
49 |
50 | const parseBreakdown = ($) => {
51 | const breakdown = $('table[id*="grid_stuTermSummaryGrid"]>tbody>tr.even');
52 |
53 | if (breakdown.first().text() === '') return null; // no header
54 |
55 | const extractData = (i, tr) => {
56 | const scoreText = $(tr).find('td').last().text();
57 | const score = extractNumber(/(\d+\.\d+)/, scoreText);
58 |
59 | const rest = $(tr).find('td').first();
60 |
61 | const litText = rest.find('div').first().text();
62 | const lit = /(\w*)/.exec(litText)[1];
63 |
64 | const gradeText = rest.find('div').slice(1, 2).text();
65 | const grade = extractNumber(/(\d+)/, gradeText);
66 |
67 | const weightText = rest.find('div').last().text();
68 | const weight = extractNumber(/\((\d+)%\D+\d\D+\)/, weightText);
69 |
70 | return {
71 | lit,
72 | grade,
73 | score,
74 | weight,
75 | };
76 | };
77 |
78 | return breakdown
79 | .filter(i => i !== 0) // skip the header
80 | .map(extractData)
81 | .toArray();
82 | };
83 |
84 | const parseGradebook = ($) => {
85 | const parseSemesterCategory = (parentTr) => {
86 | const category = $(parentTr).text().trim();
87 | const breakdown = [
88 | $(parentTr).next(),
89 | $(parentTr).next().next(),
90 | ].map((tr) => {
91 | const label = $(tr).find('td').slice(1, 2);
92 | const lit = label.find('span').first().text();
93 |
94 | const datesText = label.find('span').first().attr('tooltip');
95 | const datesResults = /(\d{2}\/\d{2}\/\d{4})\s-\s(\d{2}\/\d{2}\/\d{4})/.exec(datesText);
96 | const begin = datesResults ? datesResults[1] : '';
97 | const end = datesResults ? datesResults[2] : '';
98 | const dates = { begin, end };
99 |
100 | const weightText = label.find('span').last().text();
101 | const weight = extractNumber(/\(\D+(\d+\.\d+)%\)/, weightText);
102 |
103 | const gradeText = $(tr).find('td').slice(2, 3).text();
104 | const grade = extractNumber(/(\d+)/, gradeText);
105 |
106 | const scoreText = $(tr).find('td').slice(3, 4).text();
107 | const score = extractNumber(/(\d+.\d+)/, scoreText);
108 |
109 | const pointsText = $(tr).find('td').slice(4, 5).text();
110 | const points = extractPoints(pointsText);
111 |
112 | return {
113 | lit,
114 | weight,
115 | dates,
116 | grade,
117 | score,
118 | points,
119 | };
120 | });
121 |
122 | return {
123 | category,
124 | breakdown,
125 | assignments: [],
126 | };
127 | };
128 |
129 | const extractData = (_, tr) => {
130 | if ($(tr).find('td').length <= 1) return null;
131 |
132 | const isCategory = $(tr).hasClass('sf_Section cat');
133 | if (isCategory && $(tr).prev().hasClass('sf_Section cat')) return null;
134 | if (isCategory && $(tr).next().hasClass('sf_Section cat')) return parseSemesterCategory(tr);
135 |
136 | const gradeText = $(tr).find('td').slice(2, 3).text();
137 | const grade = extractNumber(/(\d+)/, gradeText);
138 |
139 | const scoreText = $(tr).find('td').slice(3, 4).text();
140 | const score = extractNumber(/(\d+.\d+)/, scoreText);
141 |
142 | const pointsText = $(tr).find('td').slice(4, 5).text();
143 | const points = extractPoints(pointsText);
144 |
145 | /* if it's a category */
146 | if (isCategory) {
147 | const label = $(tr).find('td').slice(1, 2);
148 |
149 | const category = label.clone().children().remove().end()
150 | .text()
151 | .trim();
152 |
153 | const weightText = label.find('span').text();
154 | const weight = extractNumber(/\D+([\d*.]+)%/, weightText);
155 | const adjustedWeight = extractNumber(/\D+[\d*.]+\D+(\d+\.\d+)%/, weightText);
156 |
157 | return {
158 | category,
159 | weight,
160 | adjustedWeight,
161 | grade,
162 | score,
163 | points,
164 | assignments: [],
165 | };
166 | }
167 |
168 | const date = $(tr).find('td').first().text();
169 | const title = $(tr).find('td').slice(1, 2).text();
170 |
171 | const missingText = $(tr).find('td').slice(5, 6).text();
172 | const noCountText = $(tr).find('td').slice(6, 7).text();
173 | const absentText = $(tr).find('td').slice(7, 8).text();
174 | const meta = [
175 | { type: 'missing', note: missingText },
176 | { type: 'noCount', note: noCountText },
177 | { type: 'absent', note: absentText },
178 | ].filter(({ note }) => !note.match(/^\s+$/));
179 |
180 | return {
181 | title,
182 | grade,
183 | score,
184 | points,
185 | date,
186 | meta,
187 | };
188 | };
189 |
190 | const nest = (gradebook, data) => {
191 | if (data === null) return gradebook;
192 | if (data.category) return gradebook.concat(data);
193 |
194 | const previousCategory = gradebook.slice(-1)[0];
195 | const assignments = previousCategory.assignments.concat(data);
196 |
197 | return [
198 | ...gradebook.slice(0, -1),
199 | Object.assign(previousCategory, { assignments }),
200 | ];
201 | };
202 |
203 | return $('table[id*="grid_stuAssignmentSummaryGrid"]>tbody>tr')
204 | .map(extractData)
205 | .toArray()
206 | .reduce(nest, []);
207 | };
208 |
209 | module.exports = ({ data }) => {
210 | const $ = cheerio.load(data);
211 |
212 | const { course, instructor, period } = parseHeader($);
213 | const {
214 | lit, grade, score, gradeAdjustment,
215 | } = parseSummary($);
216 | const breakdown = parseBreakdown($);
217 | const gradebook = parseGradebook($);
218 |
219 | return {
220 | course,
221 | instructor,
222 | lit,
223 | period,
224 | grade,
225 | gradeAdjustment,
226 | score,
227 | breakdown,
228 | gradebook,
229 | };
230 | };
231 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Skyward REST
2 |
3 | [](https://travis-ci.org/Kaelinator/skyward-rest)
4 |
5 | ## Summary
6 |
7 | **Unofficial Rest API for Skyward**
8 | - Queries data for the fastest output
9 | - Breaks down and parses complex responses
10 | - Handles edge cases with ease
11 | - Built functionally
12 |
13 | ## Examples
14 |
15 | **Boilerplate**
16 |
17 | ```javascript
18 | const skyward = require('skyward-rest')
19 |
20 | const url = 'https://skyward.cooldistrict.net/...'
21 |
22 | const scraper = skyward(url) // the scraper!
23 | ```
24 |
25 | **Scrape a user's course gradebook**
26 |
27 | ```javascript
28 | scraper.scrapeGradebook(user, pass, options)
29 | .then(console.log) // => Large Object
30 | ```
31 |
32 | **Scrape a user's academic history**
33 |
34 | ```javascript
35 | scraper.scrapeHistory(user, pass)
36 | .then(console.log) // => Array of Sizeable Objects
37 | ```
38 |
39 | ## API
40 |
41 | - [skyward( loginURL )](#skyward-loginurl-)
42 | - [.scrapeReport( user, pass )](#scrapereport-user-pass-)
43 | - [Report](#report)
44 | - [.scrapeGradebook( user, pass, options )](#scrapegradebook-user-pass-options-)
45 | - [Gradebook](#gradebook)
46 | - [.scrapeHistory( user, pass )](#scrapehistory-user-pass-)
47 | - [SchoolYear](#schoolyear)
48 |
49 | ### skyward( loginURL )
50 |
51 | Function which returns an object containing the API.
52 |
53 | * **loginURL** _string_ - URL to the login page of the specific district's Skyward domain. Note that the URL should not redirect.
54 |
55 | ```javascript
56 | const skyward = require('skyward-rest')
57 |
58 | skyward('https://skyward.cooldistrict.net/scripts/wsisa.dll/WService=wsEAplus/seplog01.w')
59 | // => { usable functions }
60 | ```
61 |
62 | ### .scrapeReport( user, pass )
63 |
64 | Fetches and parses a student's report card, returning a promise which results in an object that's `data` property is an array of [`Report`](#report)s. Note that this differs from `.scrapeGradebook` in that individual assignments in a course are not scraped, only the bucket's score.
65 |
66 | * **user** _string_ - the username or Login ID of the student who's grades will be retrieved
67 | * **pass** _string_ - the password of the student
68 |
69 | ```javascript
70 | scraper.scrapeReport(user, pass)
71 | .then(({ data, raw }) => {
72 | console.log(data) // array of reports
73 | console.log(raw) // fetched html before parsing
74 | })
75 | ```
76 |
77 | #### Report
78 |
79 | An object that contains scores from a specific course over each bucket.
80 |
81 | ```javascript
82 | {
83 | course: 97776, // the five-digit course ID
84 | scores: [
85 | {
86 | bucket: 'TERM 1',
87 | score: 100
88 | },
89 | {
90 | bucket: 'TERM 2',
91 | score: 98
92 | },
93 | /* etc */
94 | ]
95 | }
96 | ```
97 |
98 | ### .scrapeGradebook( user, pass, options )
99 |
100 | Fetches and parses user's a gradebook, returning a promise which results in an object that's data property is a [`Gradebook`](#gradebook).
101 |
102 | * **user** _string_ - the username or Login ID of the student who's gradebook will be retrieved
103 | * **pass** _string_ - the password of the student
104 | * **options** _object_ - information identifying which gradebook to scrape
105 | * **course** _number_ - the five-digit course ID to scrape _(e.g. 97776, 97674, etc. )_
106 | * **bucket** _string_ - the term to scrape _(e.g. 'TERM 1', 'SEM 1', etc.)_
107 |
108 | ```javascript
109 | scraper.scrapeGradebook(user, pass, { course: 97776, bucket: 'TERM 3' })
110 | .then(({ data, raw }) => {
111 | console.log(data) // gradebook
112 | console.log(raw) // fetched xml before parsing
113 | })
114 | ```
115 |
116 | #### Gradebook
117 |
118 | An object that contains information and assignments about a course at a specific bucket.
119 |
120 | ```javascript
121 | {
122 | course: 'PHYSICS 2 AP', // name of the course
123 | instructor: 'Jennifer Smith', // name of the instructor
124 | lit: { // information about the specific bucket
125 | name: 'S1', // bucket's alias
126 | begin: '08/20/2018', // bucket's begin date
127 | end: '12/20/2018' // bucket's end date
128 | },
129 | period: 1, // course's order in the day
130 | score: 99.5, // score recieved (usually contains a decimal)
131 | grade: 100, // score after rounding (always a whole number)
132 | gradeAdjustment: 1.5, // points added to average to get score (null if no adjustment)
133 | breakdown: [ // buckets which make up this bucket's score (null if no breakdown)
134 | {
135 | lit: 'Q2', // bucket's alias
136 | score: 95.5, // score recieved
137 | grade: 96, // score after rounding
138 | weight: 50, // part that this bucket's score makes up the parent bucket's score (out of 100)
139 | },
140 | {
141 | lit: 'Q1',
142 | grade: 100,
143 | score: 100,
144 | weight: 50,
145 | },
146 | ],
147 | gradebook: [ // grade categories which make up this bucket's score
148 | {
149 | category: 'Major', // category title
150 | breakdown: [ // buckets which make up this category (undefined if no breakdown)
151 | {
152 | lit: 'Q2', // bucket's alias
153 | weight: 70, // part that this bucket's score makes up this category's score (out of 100)
154 | dates: {
155 | begin: '10/22/2018', // bucket's begin date
156 | end: '12/20/2018', // bucket's end date
157 | },
158 | score: 96.5, // score recieved
159 | grade: 97, // score after rounding
160 | points: {
161 | earned: 965, // sum of all assignments' earned points
162 | total: 1000, // sum of all assignments' total points
163 | },
164 | },
165 | /* etc. */
166 | ],
167 | assignments: [ // assignments which make up this category
168 | {
169 | title: 'TEST IV',
170 | score: 100, // score recieved (null if no score)
171 | grade: 100, // score after rounding (null if no grade)
172 | points: {
173 | earned: 100, // earned points (null if no earned)
174 | total: 100, // total points (null if no total)
175 | },
176 | date: '09/07/18', // date the assignment is/was due
177 | meta: [ // assignment modifiers
178 | {
179 | type: 'absent', // modifier type (e.g. 'absent', 'noCount', or 'missing')
180 | note: 'Parent note received within 5d', // extra message
181 | }
182 | ],
183 | },
184 | /* etc. */
185 | ]
186 | },
187 | /* etc. */
188 | ]
189 | }
190 | ```
191 |
192 | ### .scrapeHistory( user, pass )
193 |
194 | Fetches and parses user's a academic history, returning a promise which results in an object that's data property is an array of [`SchoolYear`](#schoolyear)s.
195 |
196 | * **user** _string_ - the username or Login ID of the student who's academic history will be retrieved
197 | * **pass** _string_ - the password of the student
198 |
199 | ```javascript
200 | scraper.scrapeHistory(user, pass)
201 | .then(({ data, raw }) => {
202 | console.log(data) // array of schoolYears
203 | console.log(raw) // fetched xml before parsing
204 | })
205 | ```
206 |
207 | #### SchoolYear
208 |
209 | An object that contins information, courses, and scores from a completed school year
210 |
211 | ```javascript
212 | {
213 | dates: {
214 | begin: '2018', // school year begin date
215 | end: '2019', // school year end date
216 | },
217 | grade: 12, // grade of student during the school year
218 | courses: [ // courses taken during the year
219 | {
220 | course: 'PHYSICS 2 AP', // course name
221 | scores: [
222 | {
223 | grade: 100, // grade recieved
224 | lit: 'S1', // bucket alias
225 | },
226 | /* etc. */
227 | ]
228 | },
229 | /* etc. */
230 | ]
231 | }
232 | ```
233 |
--------------------------------------------------------------------------------
/src/gradebook/data/payload.data.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | simplePR: {
3 | output: {
4 | course: 'PHYSICS 2 AP',
5 | instructor: 'Juergen Smith',
6 | lit: { name: 'PR1', begin: '08/20/2018', end: '09/07/2018' },
7 | period: 2,
8 | grade: 48,
9 | gradeAdjustment: null,
10 | score: 98.00,
11 | breakdown: null,
12 | gradebook: [
13 | {
14 | category: 'Major',
15 | weight: 70.00,
16 | adjustedWeight: null,
17 | grade: 100,
18 | score: 100.00,
19 | points: { earned: 100, total: 100 },
20 | assignments: [
21 | {
22 | title: 'TEST I Dimensional Analysis', grade: 100, score: 100.00, points: { earned: 100, total: 100 }, date: '09/06/18', meta: [],
23 | },
24 | ],
25 | },
26 | {
27 | category: 'Minor',
28 | weight: 30.00,
29 | adjustedWeight: null,
30 | grade: 92,
31 | score: 92.00,
32 | points: { earned: 184, total: 200 },
33 | assignments: [
34 | {
35 | title: 'Dimensional Analysis 2.1 RED', grade: 89, score: 89.00, points: { earned: 89, total: 100 }, date: '08/31/18', meta: [{ type: 'absent', note: 'Parent Note received within 5d' }],
36 | },
37 | {
38 | title: 'Dimensional Analysis 2', grade: 95, score: 95.00, points: { earned: 95, total: 100 }, date: '08/28/18', meta: [],
39 | },
40 | ],
41 | },
42 | ],
43 | },
44 | input: '\n\n\n\n$(document).ready(function(){\nsff.aee("b","click",function(e){var pointAt = this; sff.request("sfdialogs.w",{"action": "dialog", "type": "teacher", "id": "3563", "entity": "004","showEmail": "no"},function(pResponse) {if (pResponse.status === "success") { sff.dialog.show({"showTitle": false, "html": pResponse.output, type: "default", "autoHide": true, "pointAt": pointAt}); } });},"#VTjaaxhRljqiFJkt");\nsff.aee("b","click",function(e){var pointAt = this; sff.request("sfdialogs.w",{"action": "dialog", "type": "class", "student": "74872", "cornumid": "97678", "track": "0", "section": sff.revertCharReplaceForId("01"), "dialogOptions": ""},function(pResponse) {if (pResponse.status === "success") { sff.dialog.show({"id": "sf_classinfo", "showTitle": true, "html": pResponse.output, type: "default", "autoHide": false, "title": "Class Info", "pointAt": pointAt,"direction": "right"}); } });},"#JcdMQckkdlJiciFd");\n});\n\n]]>\nsff.sv("filesAdded", "fusion.js");\nsff.sv("gridCount", "2");\n\n]]>\n\n',
45 | },
46 | simpleQ: {
47 | output: {
48 | course: 'U.S. GOVT AP',
49 | instructor: 'Joel Smith',
50 | lit: { name: 'Q1', begin: '08/20/2018', end: '10/19/2018' },
51 | period: 3,
52 | grade: 90,
53 | gradeAdjustment: null,
54 | score: 90.00,
55 | breakdown: null,
56 | gradebook: [
57 | {
58 | category: 'Major',
59 | weight: 70.00,
60 | adjustedWeight: null,
61 | grade: 87,
62 | score: 87.33,
63 | points: { earned: 262, total: 300 },
64 | assignments: [
65 | {
66 | title: 'Opinion Poll', grade: 100, score: 100.00, points: { earned: 100, total: 100 }, date: '10/12/18', meta: [],
67 | },
68 | {
69 | title: 'Federalism TEST', grade: 84, score: 84.00, points: { earned: 84, total: 100 }, date: '09/18/18', meta: [],
70 | },
71 | {
72 | title: 'Unit 1 Test', grade: 78, score: 78.00, points: { earned: 78, total: 100 }, date: '09/06/18', meta: [{ type: 'absent', note: 'Teacher-recorded tardy ' }],
73 | },
74 | ],
75 | },
76 | {
77 | category: 'Minor',
78 | weight: 30.00,
79 | adjustedWeight: null,
80 | grade: 96,
81 | score: 95.67,
82 | points: { earned: 287, total: 300 },
83 | assignments: [
84 | {
85 | title: 'Political Ideology online test', grade: 100, score: 100.00, points: { earned: 100, total: 100 }, date: '10/10/18', meta: [],
86 | },
87 | {
88 | title: 'B of R Quiz', grade: 87, score: 87.00, points: { earned: 87, total: 100 }, date: '08/31/18', meta: [{ type: 'absent', note: 'Parent Note received within 5d' }],
89 | },
90 | {
91 | title: 'Bill of Rights Sign Language', grade: 100, score: 100.00, points: { earned: 100, total: 100 }, date: '08/20/18', meta: [],
92 | },
93 | ],
94 | },
95 | ],
96 | },
97 | input: '$(document).ready(function(){sff.aee("b","click",function(e){var pointAt = this; sff.request("sfdialogs.w",{"action": "dialog", "type": "class", "student": "74872", "cornumid": "97524", "track": "0", "section": sff.revertCharReplaceForId("02"), "dialogOptions": ""},function(pResponse) {if (pResponse.status === "success") { sff.dialog.show({"id": "sf_classinfo", "showTitle": true, "html": pResponse.output, type: "default", "autoHide": false, "title": "Class Info", "pointAt": pointAt,"direction": "right"}); } });},"#kdaNbaixmdbckdep");sff.aee("b","click",function(e){var pointAt = this; sff.request("sfdialogs.w",{"action": "dialog", "type": "teacher", "id": "1835", "entity": "004","showEmail": "no"},function(pResponse) {if (pResponse.status === "success") { sff.dialog.show({"showTitle": false, "html": pResponse.output, type: "default", "autoHide": true, "pointAt": pointAt}); } });},"#iBUonbeajdVQjkUV");});]]>sff.sv("filesAdded", "jquery.1.8.2.js,qsfmain001.css,sfgradebook.css,qsfmain001.min.js,sfgradebook.js,sfprint001.js,fusion.js");sff.sv("gridCount", "13");]]>',
98 | },
99 | simpleSem: {
100 | output: {
101 | course: 'U.S. GOVT AP',
102 | instructor: 'Joel Smith',
103 | lit: { name: 'S1', begin: '08/20/2018', end: '12/20/2018' },
104 | period: 3,
105 | grade: 85,
106 | gradeAdjustment: null,
107 | score: 85.1,
108 | breakdown: [
109 | {
110 | lit: 'SE1', grade: 86, score: 86.00, weight: 10,
111 | },
112 | {
113 | lit: 'Q2', grade: 80, score: 80.00, weight: 45,
114 | },
115 | {
116 | lit: 'Q1', grade: 90, score: 90.00, weight: 45,
117 | },
118 | ],
119 | gradebook: [
120 | {
121 | category: 'Major',
122 | breakdown: [
123 | {
124 | lit: 'Q2', weight: 70.00, grade: 75, score: 74.67, points: { earned: 224, total: 300 }, dates: { begin: '10/22/2018', end: '12/20/2018' },
125 | },
126 | {
127 | lit: 'Q1', weight: 70.00, grade: 87, score: 87.33, points: { earned: 262, total: 300 }, dates: { begin: '08/20/2018', end: '10/19/2018' },
128 | },
129 | ],
130 | assignments: [
131 | {
132 | title: 'Judiciary TEST', grade: 91, score: 91.00, points: { earned: 91, total: 100 }, date: '12/12/18', meta: [],
133 | },
134 | {
135 | title: 'PREZ TEST', grade: 71, score: 71.00, points: { earned: 71, total: 100 }, date: '11/27/18', meta: [],
136 | },
137 | {
138 | title: 'CONGRESS TEST', grade: 62, score: 62.00, points: { earned: 62, total: 100 }, date: '11/14/18', meta: [],
139 | },
140 | {
141 | title: 'Opinion Poll', grade: 100, score: 100.00, points: { earned: 100, total: 100 }, date: '10/12/18', meta: [],
142 | },
143 | {
144 | title: 'Federalism TEST', grade: 84, score: 84.00, points: { earned: 84, total: 100 }, date: '09/18/18', meta: [],
145 | },
146 | {
147 | title: 'Unit 1 Test', grade: 78, score: 78.00, points: { earned: 78, total: 100 }, date: '09/06/18', meta: [{ type: 'absent', note: 'Teacher-recorded tardy ' }],
148 | },
149 | ],
150 | },
151 | {
152 | category: 'Minor',
153 | breakdown: [
154 | {
155 | lit: 'Q2', weight: 30.00, grade: 92, score: 92.00, points: { earned: 1012, total: 1100 }, dates: { begin: '10/22/2018', end: '12/20/2018' },
156 | },
157 | {
158 | lit: 'Q1', weight: 30.00, grade: 96, score: 95.67, points: { earned: 287, total: 300 }, dates: { begin: '08/20/2018', end: '10/19/2018' },
159 | },
160 | ],
161 | assignments: [
162 | {
163 | title: 'Case Notecards', grade: 100, score: 100.00, points: { earned: 100, total: 100 }, date: '12/04/18', meta: [],
164 | },
165 | {
166 | title: 'Standard Deviants Judicial Qui', grade: 87, score: 87.00, points: { earned: 87, total: 100 }, date: '11/30/18', meta: [],
167 | },
168 | {
169 | title: 'Letter to your Congressman', grade: 100, score: 100.00, points: { earned: 200, total: 200 }, date: '11/13/18', meta: [],
170 | },
171 | {
172 | title: 'Congress FRQ', grade: 75, score: 75.00, points: { earned: 225, total: 300 }, date: '11/07/18', meta: [{ type: 'absent', note: 'Parent Note received within 5d' }],
173 | },
174 | {
175 | title: 'Write a Bill', grade: 100, score: 100.00, points: { earned: 200, total: 200 }, date: '11/02/18', meta: [],
176 | },
177 | {
178 | title: 'Hamilton Group', grade: 100, score: 100.00, points: { earned: 200, total: 200 }, date: '11/01/18', meta: [],
179 | },
180 | {
181 | title: 'Political Ideology online test', grade: 100, score: 100.00, points: { earned: 100, total: 100 }, date: '10/10/18', meta: [],
182 | },
183 | {
184 | title: 'B of R Quiz', grade: 87, score: 87.00, points: { earned: 87, total: 100 }, date: '08/31/18', meta: [{ type: 'absent', note: 'Parent Note received within 5d' }],
185 | },
186 | {
187 | title: 'Bill of Rights Sign Language', grade: 100, score: 100.00, points: { earned: 100, total: 100 }, date: '08/20/18', meta: [],
188 | },
189 | ],
190 | },
191 | ],
192 | },
193 | input: '$(document).ready(function(){sff.aee("b","click",function(e){var pointAt = this; sff.request("sfdialogs.w",{"action": "dialog", "type": "class", "student": "74872", "cornumid": "97524", "track": "0", "section": sff.revertCharReplaceForId("02"), "dialogOptions": ""},function(pResponse) {if (pResponse.status === "success") { sff.dialog.show({"id": "sf_classinfo", "showTitle": true, "html": pResponse.output, type: "default", "autoHide": false, "title": "Class Info", "pointAt": pointAt,"direction": "right"}); } });},"#hjKAKiDcKallijja");sff.aee("b","click",function(e){var pointAt = this; sff.request("sfdialogs.w",{"action": "dialog", "type": "teacher", "id": "1835", "entity": "004","showEmail": "no"},function(pResponse) {if (pResponse.status === "success") { sff.dialog.show({"showTitle": false, "html": pResponse.output, type: "default", "autoHide": true, "pointAt": pointAt}); } });},"#ccJktyjidiRPiKyO");});]]>sff.sv("filesAdded", "jquery.1.8.2.js,qsfmain001.css,sfgradebook.css,qsfmain001.min.js,sfgradebook.js,sfprint001.js");sff.sv("gridCount", "2");]]>',
194 | },
195 | emptyMajorPR: {
196 | output: {
197 | course: 'STATISTICS AP',
198 | instructor: 'Johnny Smith',
199 | lit: { name: 'PR1', begin: '08/20/2018', end: '09/07/2018' },
200 | period: 4,
201 | grade: 94,
202 | gradeAdjustment: null,
203 | score: 97.00,
204 | breakdown: null,
205 | gradebook: [
206 | {
207 | category: 'Homework',
208 | weight: 10.00,
209 | adjustedWeight: 33.33,
210 | grade: 100,
211 | score: 100.00,
212 | points: { earned: 400, total: 400 },
213 | assignments: [
214 | {
215 | title: 'Sec. 1.3 HW', grade: 100, score: 100.00, points: { earned: 100, total: 100 }, date: '09/06/18', meta: [],
216 | },
217 | {
218 | title: 'Sec. 1.2 HW', grade: 100, score: 100.00, points: { earned: 100, total: 100 }, date: '08/31/18', meta: [{ type: 'absent', note: 'Parent Note received within 5d' }],
219 | },
220 | {
221 | title: 'Sec. 1.1 HW', grade: 100, score: 100.00, points: { earned: 100, total: 100 }, date: '08/29/18', meta: [],
222 | },
223 | {
224 | title: 'Sec. 1.Intro HW', grade: 100, score: 100.00, points: { earned: 100, total: 100 }, date: '08/23/18', meta: [],
225 | },
226 | ],
227 | },
228 | {
229 | category: 'Major',
230 | weight: 70.00,
231 | adjustedWeight: 0.00,
232 | grade: null,
233 | score: null,
234 | points: { earned: null, total: null },
235 | assignments: [],
236 | },
237 | {
238 | category: 'Minor',
239 | weight: 20.00,
240 | adjustedWeight: 66.67,
241 | grade: 96,
242 | score: 95.67,
243 | points: { earned: 287, total: 300 },
244 | assignments: [
245 | {
246 | title: 'Ch. 1 Quiz Sec. 1.1 to 1.3', grade: 92, score: 92.00, points: { earned: 92, total: 100 }, date: '09/06/18', meta: [],
247 | },
248 | {
249 | title: '1.2 CYU', grade: 90, score: 90.00, points: { earned: 90, total: 100 }, date: '08/31/18', meta: [{ type: 'noCount', note: '' }, { type: 'absent', note: 'Parent Note received within 5d' }],
250 | },
251 | {
252 | title: '1.1 CYU', grade: 95, score: 95.00, points: { earned: 95, total: 100 }, date: '08/29/18', meta: [],
253 | },
254 | {
255 | title: 'Smelling Parkinson Act.', grade: 100, score: 100.00, points: { earned: 100, total: 100 }, date: '08/21/18', meta: [],
256 | },
257 | ],
258 | },
259 | ],
260 | },
261 | input: '$(document).ready(function(){sff.aee("b","click",function(e){var pointAt = this; sff.request("sfdialogs.w",{"action": "dialog", "type": "teacher", "id": "2411", "entity": "004","showEmail": "no"},function(pResponse) {if (pResponse.status === "success") { sff.dialog.show({"showTitle": false, "html": pResponse.output, type: "default", "autoHide": true, "pointAt": pointAt}); } });},"#affjdmaOlIybUnap");sff.aee("b","click",function(e){var pointAt = this; sff.request("sfdialogs.w",{"action": "dialog", "type": "class", "student": "74872", "cornumid": "97623", "track": "0", "section": sff.revertCharReplaceForId("03"), "dialogOptions": ""},function(pResponse) {if (pResponse.status === "success") { sff.dialog.show({"id": "sf_classinfo", "showTitle": true, "html": pResponse.output, type: "default", "autoHide": false, "title": "Class Info", "pointAt": pointAt,"direction": "right"}); } });},"#lThIklachfflQkli");});]]>sff.sv("filesAdded", "jquery.1.8.2.js,qsfmain001.css,sfgradebook.css,qsfmain001.min.js,sfgradebook.js,sfprint001.js,fusion.js");sff.sv("gridCount", "7");]]>',
262 | },
263 | gradeAdjustedQ: {
264 | output: {
265 | course: 'PHYSICS 2 AP',
266 | instructor: 'Juergen Smith',
267 | lit: { name: 'Q3', begin: '01/08/2019', end: '03/22/2019' },
268 | period: 2,
269 | grade: 93,
270 | gradeAdjustment: 1.00,
271 | score: 93.00,
272 | breakdown: null,
273 | gradebook: [
274 | {
275 | category: 'Major',
276 | weight: 70.00,
277 | adjustedWeight: null,
278 | grade: 93,
279 | score: 93.00,
280 | points: { earned: 372, total: 400 },
281 | assignments: [/* too many minors, but still not including majors */],
282 | },
283 | {
284 | category: 'Minor',
285 | weight: 30.00,
286 | adjustedWeight: null,
287 | grade: 89,
288 | score: 88.63,
289 | points: { earned: 1418, total: 1600 },
290 | assignments: [/* too many assignments to be included; Tough Quarter */],
291 | },
292 | ],
293 | },
294 | input: '$(document).ready(function(){sff.aee("b","click",function(e){var pointAt = this; sff.request("sfdialogs.w",{"action": "dialog", "type": "class", "student": "74872", "cornumid": "97678", "track": "0", "section": sff.revertCharReplaceForId("01"), "dialogOptions": ""},function(pResponse) {if (pResponse.status === "success") { sff.dialog.show({"id": "sf_classinfo", "showTitle": true, "html": pResponse.output, type: "default", "autoHide": false, "title": "Class Info", "pointAt": pointAt,"direction": "right"}); } });},"#acopbrndjlfcbnMh");sff.aee("b","click",function(e){var pointAt = this; sff.request("sfdialogs.w",{"action": "dialog", "type": "teacher", "id": "3563", "entity": "004","showEmail": "no"},function(pResponse) {if (pResponse.status === "success") { sff.dialog.show({"showTitle": false, "html": pResponse.output, type: "default", "autoHide": true, "pointAt": pointAt}); } });},"#ndqckZjckOcYpFii");});]]>sff.sv("filesAdded", "jquery.1.8.2.js,qsfmain001.css,sfgradebook.css,qsfmain001.min.js,sfgradebook.js,sfprint001.js,fusion.js");sff.sv("gridCount", "2");]]>',
295 | },
296 | emptyScores: {
297 | output: { /* data is simplified to isolate variables */
298 | course: 'ENGLISH 4 AP',
299 | instructor: 'Laurie Smith',
300 | lit: { name: 'Q3', begin: '01/08/2019', end: '03/22/2019' },
301 | period: 4,
302 | grade: 89,
303 | gradeAdjustment: null,
304 | score: 89.00,
305 | breakdown: null,
306 | gradebook: [
307 | {
308 | category: 'Major',
309 | weight: 70.00,
310 | adjustedWeight: null,
311 | grade: 87,
312 | score: 87.00,
313 | points: { earned: 261, total: 300 },
314 | assignments: [
315 | {
316 | title: 'Extra Credit play or poem', grade: null, score: null, points: { earned: null, total: 0 }, date: '02/13/19', meta: [{ type: 'absent', note: 'Assembly, Office, pep rally' }],
317 | },
318 | {
319 | title: 'Blog Extra Credit', grade: null, score: null, points: { earned: null, total: 0 }, date: '01/14/19', meta: [],
320 | },
321 | ],
322 | },
323 | {
324 | category: 'Minor',
325 | weight: 30.00,
326 | adjustedWeight: null,
327 | grade: 94,
328 | score: 94.22,
329 | points: { earned: 832, total: 883 },
330 | assignments: [
331 | {
332 | title: 'Realistic and Non Realistic', grade: null, score: null, points: { earned: null, total: 50 }, date: '01/15/19', meta: [{ type: 'noCount', note: '' }, { type: 'absent', note: 'Unknown abs. marked by teacher' }],
333 | },
334 | ],
335 | },
336 | ],
337 | },
338 | input: '$(document).ready(function(){sff.aee(\'b\',\'click\',function(e){var pointAt = this; sff.request(\'sfdialogs.w\',{\'action\': \'dialog\', \'type\': \'teacher\', \'id\': \'2423\', \'entity\': \'004\',"showEmail": "no"},function(pResponse) {if (pResponse.status === \'success\') { sff.dialog.show({\'showTitle\': false, \'html\': pResponse.output, type: \'default\', \'autoHide\': true, \'pointAt\': pointAt}); } });},\'#kydnPWbVzlikWkbb\');sff.aee(\'b\',\'click\',function(e){var pointAt = this; sff.request(\'sfdialogs.w\',{\'action\': \'dialog\', \'type\': \'class\', \'student\': \'18781\', \'cornumid\': \'97423\', \'track\': \'0\', \'section\': sff.revertCharReplaceForId(\'03\'), \'dialogOptions\': \'\'},function(pResponse) {if (pResponse.status === \'success\') { sff.dialog.show({\'id\': \'sf_classinfo\', \'showTitle\': true, \'html\': pResponse.output, type: \'default\', \'autoHide\': false, \'title\': \'Class Info\', \'pointAt\': pointAt,"direction": "right"}); } });},\'#bmkWncakLEblSljB\');});]]>sff.sv(\'filesAdded\', \'jquery.1.8.2.js,qsfmain001.css,sfgradebook.css,qsfmain001.min.js,sfgradebook.js,sfprint001.js,fusion.js\');sff.sv(\'gridCount\', \'2\');]]>',
339 | },
340 | decimalScores: {
341 | output: { /* data is simplified to isolate variables */
342 | course: 'ENGLISH 4 AP',
343 | instructor: 'Laurie Smith',
344 | lit: { name: 'Q3', begin: '01/08/2019', end: '03/22/2019' },
345 | period: 4,
346 | grade: 89,
347 | gradeAdjustment: null,
348 | score: 89.00,
349 | breakdown: null,
350 | gradebook: [
351 | {
352 | category: 'Major',
353 | weight: 70.00,
354 | adjustedWeight: null,
355 | grade: 95,
356 | score: 94.53,
357 | points: { earned: 95, total: 100.5 },
358 | assignments: [
359 | {
360 | title: 'Poetry Blog', grade: 95, score: 95.00, points: { earned: 95, total: 100.5 }, date: '01/10/19', meta: [],
361 | },
362 | ],
363 | },
364 | {
365 | category: 'Minor',
366 | weight: 30.00,
367 | adjustedWeight: null,
368 | grade: 78,
369 | score: 78.46,
370 | points: { earned: 104.75, total: 133.5 },
371 | assignments: [
372 | {
373 | title: 'PQ #16', grade: 100, score: 100.00, points: { earned: 11, total: 11 }, date: '03/01/19', meta: [],
374 | },
375 | {
376 | title: 'PQ 10 Drama', grade: 89, score: 88.89, points: { earned: 8, total: 9 }, date: '02/12/19', meta: [],
377 | },
378 | {
379 | title: 'AP Pilot PQ #9', grade: 62, score: 61.54, points: { earned: 8, total: 13 }, date: '01/28/19', meta: [],
380 | },
381 | {
382 | title: 'Drama Terms', grade: 79, score: 79.00, points: { earned: 39.5, total: 50 }, date: '01/10/19', meta: [],
383 | },
384 | {
385 | title: 'Nature of Drama', grade: 77, score: 77.00, points: { earned: 38.25, total: 50.5 }, date: '01/10/19', meta: [],
386 | },
387 | ],
388 | },
389 | ],
390 | },
391 | input: '$(document).ready(function(){sff.aee(\'b\',\'click\',function(e){var pointAt = this; sff.request(\'sfdialogs.w\',{\'action\': \'dialog\', \'type\': \'teacher\', \'id\': \'2423\', \'entity\': \'004\',"showEmail": "no"},function(pResponse) {if (pResponse.status === \'success\') { sff.dialog.show({\'showTitle\': false, \'html\': pResponse.output, type: \'default\', \'autoHide\': true, \'pointAt\': pointAt}); } });},\'#kydnPWbVzlikWkbb\');sff.aee(\'b\',\'click\',function(e){var pointAt = this; sff.request(\'sfdialogs.w\',{\'action\': \'dialog\', \'type\': \'class\', \'student\': \'18781\', \'cornumid\': \'97423\', \'track\': \'0\', \'section\': sff.revertCharReplaceForId(\'03\'), \'dialogOptions\': \'\'},function(pResponse) {if (pResponse.status === \'success\') { sff.dialog.show({\'id\': \'sf_classinfo\', \'showTitle\': true, \'html\': pResponse.output, type: \'default\', \'autoHide\': false, \'title\': \'Class Info\', \'pointAt\': pointAt,"direction": "right"}); } });},\'#bmkWncakLEblSljB\');});]]>sff.sv(\'filesAdded\', \'jquery.1.8.2.js,qsfmain001.css,sfgradebook.css,qsfmain001.min.js,sfgradebook.js,sfprint001.js,fusion.js\');sff.sv(\'gridCount\', \'2\');]]>',
392 | },
393 | };
394 |
--------------------------------------------------------------------------------