feat: 🎸 Hämta lektionsschema (#110)

* feat: 🎸 add timetables from skola24

* refactor: 💡 Clean up sso authorization

* feat: 🎸 Reads timetable

* feat: 🎸 Veckoschema

Veckoschema kan laddas från Skola24

BREAKING CHANGE: 🧨 Child -> EtjanstChild

* feat: 🎸 Test data for skola24Children and timetable

* docs: ✏️ Updated instructions

Co-authored-by: Johan Öbrink <johan.obrink@gmail.com>
This commit is contained in:
Kajetan Kazimierczak 2021-04-09 10:35:00 +02:00 committed by GitHub
parent 3c33c75956
commit c2884497bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 911 additions and 51 deletions

View File

@ -16,69 +16,68 @@ the concrete implementation of fetch and cookie handler must be injected.
#### react-native #### react-native
```javascript ```javascript
import init from "@skolplattformen/embedded-api"; import init from '@skolplattformen/embedded-api'
import CookieManager from "@react-native-community/cookies"; import CookieManager from '@react-native-community/cookies'
const api = init(fetch, () => CookieManager.clearAll()); const api = init(fetch, () => CookieManager.clearAll())
``` ```
#### node #### node
```javascript ```javascript
import init from "@skolplattformen/embedded-api"; import init from '@skolplattformen/embedded-api'
import nodeFetch from "node-fetch"; import nodeFetch from 'node-fetch'
import fetchCookie from "fetch-cookie/node-fetch"; import fetchCookie from 'fetch-cookie/node-fetch'
import { CookieJar } from "tough-cookie"; import { CookieJar } from 'tough-cookie'
const cookieJar = new CookieJar(); const cookieJar = new CookieJar()
const fetch = fetchCookie(nodeFetch, cookieJar); const fetch = fetchCookie(nodeFetch, cookieJar)
const api = init(fetch, () => cookieJar.removeAllCookies()); const api = init(fetch, cookieJar)
``` ```
### Login / logout ### Login / logout
```javascript ```javascript
api.on("login", async () => { api.on('login', async () => {
// do stuff // do stuff
console.log(api.isLoggedIn) // true console.log(api.isLoggedIn) // true
console.log(api.getSessionCookie) // session cookie if you want to save it
await api.logout() await api.logout()
}); })
api.on('logout', () => { api.on('logout', () => {
// handle logout // handle logout
console.log(api.isLoggedIn) // false console.log(api.isLoggedIn) // false
} }
const loginStatus = await api.login("YYYYMMDDXXXX"); const loginStatus = await api.login('YYYYMMDDXXXX')
window.open( window.open(
`https://app.bankid.com/?autostarttoken=${loginStatus.token}&redirect=null` `https://app.bankid.com/?autostarttoken=${loginStatus.token}&redirect=null`
); )
loginStatus.on("PENDING", () => console.log("BankID app not yet opened")); loginStatus.on('PENDING', () => console.log('BankID app not yet opened'))
loginStatus.on("USER_SIGN", () => console.log("BankID app is open")); loginStatus.on('USER_SIGN', () => console.log('BankID app is open'))
loginStatus.on("ERROR", () => console.log("Something went wrong")); loginStatus.on('ERROR', () => console.log('Something went wrong'))
loginStatus.on("OK", () => loginStatus.on('OK', () =>
console.log("BankID sign successful. Session will be established.") console.log('BankID sign successful. Session will be established.')
); )
``` ```
### Loading data ### Loading data
```javascript ```javascript
// Get current user // Get current user
const user = await api.getUser(); const user = await api.getUser()
// List children // List children from Etjanster
const children = await api.getChildren(); const children = await api.getChildren()
// Get calendar // Get school calendar
const calendar = await api.getCalendar(children[0]); const calendar = await api.getCalendar(children[0])
// Get classmates // Get classmates - disabled for reasons
const classmates = await api.getClassmates(children[0]); // const classmates = await api.getClassmates(children[0])
// Get schedule // Get student's personal schedule
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
const from = DateTime.local() const from = DateTime.local()
@ -88,11 +87,22 @@ const schedule = await api.getSchedule(children[0], from, to)
// Get news // Get news
const news = await api.getNews(children[0]) const news = await api.getNews(children[0])
// Get news details
const newsDetails = await api.getNewsDetails(children[0], news[0])
// Get menu // Get menu
const menu = await api.getMenu(children[0]) const menu = await api.getMenu(children[0])
// Get notifications // Get notifications
const notifications = await api.getNotifications(children[0]) const notifications = await api.getNotifications(children[0])
// Get list of children from Skola24 (because of course it's different *DERP*)
const skola24Children = await getSkola24Children()
// Get timetable
const weekNumber = 15
const year = 2021
const timetable = await api.getTimeTable(skola24Children[0], weekNumber, year)
``` ```
### Setting session cookie ### Setting session cookie
@ -100,9 +110,9 @@ const notifications = await api.getNotifications(children[0])
It is possible to resurrect a logged in session by manually setting the session cookie. It is possible to resurrect a logged in session by manually setting the session cookie.
```javascript ```javascript
const sessionCookie = "some value"; const sessionCookie = 'some value'
api.setSessionCookie(sessionCookie); // will trigger `on('login')` event and set `.isLoggedIn = true` api.setSessionCookie(sessionCookie) // will trigger `on('login')` event and set `.isLoggedIn = true`
``` ```
### Fake user ### Fake user
@ -111,7 +121,7 @@ Login with personal number `12121212121212`, `201212121212` or `1212121212` and
api will be put into fake mode. api will be put into fake mode.
Static data will be returned and no calls to backend will be made. Static data will be returned and no calls to backend will be made.
The `LoginStatusChecker` returned by the login method will have `.token` set to "fake". The `LoginStatusChecker` returned by the login method will have `.token` set to 'fake'.
## Try it out ## Try it out

View File

@ -165,7 +165,7 @@ describe('api', () => {
status = await api.login('1212121212') status = await api.login('1212121212')
expect(status.token).toEqual('fake') expect(status.token).toEqual('fake')
}) })
it.skip('delivers fake data', async (done) => { it('delivers fake data', async (done) => {
api.on('login', async () => { api.on('login', async () => {
const user = await api.getUser() const user = await api.getUser()
expect(user).toEqual({ expect(user).toEqual({
@ -181,6 +181,12 @@ describe('api', () => {
const calendar2 = await api.getCalendar(children[1]) const calendar2 = await api.getCalendar(children[1])
expect(calendar2).toHaveLength(18) expect(calendar2).toHaveLength(18)
const skola24Children = await api.getSkola24Children()
expect(skola24Children).toHaveLength(1)
const timetable = await api.getTimetable(skola24Children[0], 2021, 15)
expect(timetable).toHaveLength(32)
done() done()
}) })
await api.login('121212121212') await api.login('121212121212')

View File

@ -2,11 +2,11 @@ import { DateTime } from 'luxon'
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { decode } from 'he' import { decode } from 'he'
import * as html from 'node-html-parser' import * as html from 'node-html-parser'
import { URLSearchParams } from 'url'
import { checkStatus, LoginStatusChecker } from './loginStatus' import { checkStatus, LoginStatusChecker } from './loginStatus'
import { import {
AuthTicket, AuthTicket,
CalendarItem, CalendarItem,
Child,
Classmate, Classmate,
CookieManager, CookieManager,
Fetch, Fetch,
@ -16,15 +16,35 @@ import {
RequestInit, RequestInit,
ScheduleItem, ScheduleItem,
User, User,
Skola24Child,
EtjanstChild,
SSOSystem,
} from './types' } from './types'
import * as routes from './routes' import * as routes from './routes'
import * as parse from './parse' import * as parse from './parse/index'
import wrap, { Fetcher, FetcherOptions } from './fetcher' import wrap, { Fetcher, FetcherOptions } from './fetcher'
import * as fake from './fakeData' import * as fake from './fakeData'
const fakeResponse = <T>(data: T): Promise<T> => const fakeResponse = <T>(data: T): Promise<T> =>
new Promise((res) => setTimeout(() => res(data), 200 + Math.random() * 800)) new Promise((res) => setTimeout(() => res(data), 200 + Math.random() * 800))
const s24Init = {
headers: {
accept: 'application/json, text/javascript, */*; q=0.01',
referer: 'https://fns.stockholm.se/ng/timetable/timetable-viewer/fns.stockholm.se/',
'accept-language': 'en-US,en;q=0.9,sv;q=0.8',
'cache-control': 'no-cache',
'content-type': 'application/json',
pragma: 'no-cache',
host: 'fns.stockholm.se',
'x-scope': '8a22163c-8662-4535-9050-bc5e1923df48',
},
}
interface SSOSystems {
[name: string]: boolean | undefined
}
export class Api extends EventEmitter { export class Api extends EventEmitter {
private fetch: Fetcher private fetch: Fetcher
@ -40,6 +60,8 @@ export class Api extends EventEmitter {
public childControllerUrl?: string public childControllerUrl?: string
private authorizedSystems: SSOSystems = {}
constructor( constructor(
fetch: Fetch, fetch: Fetch,
cookieManager: CookieManager, cookieManager: CookieManager,
@ -281,7 +303,7 @@ export class Api extends EventEmitter {
return parse.user(data) return parse.user(data)
} }
public async getChildren(): Promise<Child[]> { public async getChildren(): Promise<EtjanstChild[]> {
if (this.isFake) return fakeResponse(fake.children()) if (this.isFake) return fakeResponse(fake.children())
const cdnUrl = await this.retrieveCdnUrl() const cdnUrl = await this.retrieveCdnUrl()
@ -309,7 +331,7 @@ export class Api extends EventEmitter {
return parse.children(data) return parse.children(data)
} }
public async getCalendar(child: Child): Promise<CalendarItem[]> { public async getCalendar(child: EtjanstChild): Promise<CalendarItem[]> {
if (this.isFake) return fakeResponse(fake.calendar(child)) if (this.isFake) return fakeResponse(fake.calendar(child))
const url = routes.calendar(child.id) const url = routes.calendar(child.id)
@ -319,7 +341,7 @@ export class Api extends EventEmitter {
return parse.calendar(data) return parse.calendar(data)
} }
public async getClassmates(child: Child): Promise<Classmate[]> { public async getClassmates(child: EtjanstChild): Promise<Classmate[]> {
if (this.isFake) return fakeResponse(fake.classmates(child)) if (this.isFake) return fakeResponse(fake.classmates(child))
const url = routes.classmates(child.sdsId) const url = routes.classmates(child.sdsId)
@ -330,7 +352,7 @@ export class Api extends EventEmitter {
} }
public async getSchedule( public async getSchedule(
child: Child, child: EtjanstChild,
from: DateTime, from: DateTime,
to: DateTime to: DateTime
): Promise<ScheduleItem[]> { ): Promise<ScheduleItem[]> {
@ -343,7 +365,7 @@ export class Api extends EventEmitter {
return parse.schedule(data) return parse.schedule(data)
} }
public async getNews(child: Child): Promise<NewsItem[]> { public async getNews(child: EtjanstChild): Promise<NewsItem[]> {
if (this.isFake) return fakeResponse(fake.news(child)) if (this.isFake) return fakeResponse(fake.news(child))
const url = routes.news(child.id) const url = routes.news(child.id)
@ -353,7 +375,7 @@ export class Api extends EventEmitter {
return parse.news(data) return parse.news(data)
} }
public async getNewsDetails(child: Child, item: NewsItem): Promise<any> { public async getNewsDetails(child: EtjanstChild, item: NewsItem): Promise<any> {
if (this.isFake) { if (this.isFake) {
return fakeResponse(fake.news(child).find((ni) => ni.id === item.id)) return fakeResponse(fake.news(child).find((ni) => ni.id === item.id))
} }
@ -364,7 +386,7 @@ export class Api extends EventEmitter {
return parse.newsItemDetails(data) return parse.newsItemDetails(data)
} }
public async getMenu(child: Child): Promise<MenuItem[]> { public async getMenu(child: EtjanstChild): Promise<MenuItem[]> {
if (this.isFake) return fakeResponse(fake.menu(child)) if (this.isFake) return fakeResponse(fake.menu(child))
const menuService = await this.getMenuChoice(child) const menuService = await this.getMenuChoice(child)
@ -383,7 +405,7 @@ export class Api extends EventEmitter {
return parse.menuList(data) return parse.menuList(data)
} }
private async getMenuChoice(child: Child): Promise<string> { private async getMenuChoice(child: EtjanstChild): Promise<string> {
const url = routes.menuChoice(child.id) const url = routes.menuChoice(child.id)
const session = this.getRequestInit() const session = this.getRequestInit()
const response = await this.fetch('menu-choice', url, session) const response = await this.fetch('menu-choice', url, session)
@ -392,7 +414,7 @@ export class Api extends EventEmitter {
return etjanstResponse return etjanstResponse
} }
public async getNotifications(child: Child): Promise<Notification[]> { public async getNotifications(child: EtjanstChild): Promise<Notification[]> {
if (this.isFake) return fakeResponse(fake.notifications(child)) if (this.isFake) return fakeResponse(fake.notifications(child))
const url = routes.notifications(child.sdsId) const url = routes.notifications(child.sdsId)
@ -402,10 +424,138 @@ export class Api extends EventEmitter {
return parse.notifications(data) return parse.notifications(data)
} }
private async readSAMLRequest(targetSystem: string): Promise<string> {
const url = routes.ssoRequestUrl(targetSystem)
const session = this.getRequestInit({
redirect: 'follow',
})
const response = await this.fetch('samlRequest', url, session)
const text = await response.text()
const samlRequest = /name="SAMLRequest" value="(?<saml>\S+)">/gm.exec(text || '')?.groups?.saml
if (!samlRequest) {
throw new Error('Could not parse SAML Request')
} else {
return samlRequest
}
}
private async submitSAMLRequest(samlRequest: string): Promise<string> {
const body = new URLSearchParams({ SAMLRequest: samlRequest }).toString()
const url = routes.ssoResponseUrl
const session = this.getRequestInit({
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
redirect: 'follow',
method: 'POST',
body,
})
const response = await this.fetch('samlResponse', url, session)
const text = await response.text()
const samlResponse = /name="SAMLResponse" value="(?<saml>\S+)">/gm.exec(text)?.groups?.saml
if (!samlResponse) {
throw new Error('Could not parse SAML Response')
} else {
return samlResponse
}
}
private async ssoAuthorize(targetSystem: SSOSystem): Promise<string> {
if (this.authorizedSystems[targetSystem]) {
return ''
}
const samlRequest = await this.readSAMLRequest(targetSystem)
const samlResponse = await this.submitSAMLRequest(samlRequest)
const body = new URLSearchParams({ SAMLResponse: samlResponse }).toString()
const url = routes.samlResponseUrl
const session = this.getRequestInit({
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
},
redirect: 'follow',
method: 'POST',
body,
})
const response = await this.fetch('samlAuthorize', url, session)
const text = await response.text()
this.authorizedSystems[targetSystem] = true
return text
}
public async getSkola24Children(): Promise<Skola24Child[]>{
if (this.isFake) return fakeResponse(fake.skola24Children())
await this.ssoAuthorize('TimetableViewer')
const body = { getPersonalTimetablesRequest: {
hostName: 'fns.stockholm.se'
}}
const session = this.getRequestInit({
...s24Init,
body: JSON.stringify(body),
method: 'POST',
})
const url = routes.timetables
const response = await this.fetch('s24children', url, session)
const {
data: {
getPersonalTimetablesResponse: {
childrenTimetables
}
}
} = await response.json()
return childrenTimetables as Skola24Child[]
}
private async getRenderKey(): Promise<string> {
const url = routes.renderKey
const session = this.getRequestInit(s24Init)
const response = await this.fetch('renderKey', url, session)
const { data: { key } } = await response.json()
return key as string
}
public async getTimetable(child: Skola24Child, week: number, year: number): Promise<any> {
if (this.isFake) return fakeResponse(fake.timetable(child))
const url = routes.timetable
const renderKey = await this.getRenderKey()
const params = {
blackAndWhite: false,
customerKey: '',
endDate: null,
height: 1063,
host: 'fns.stockholm.se',
periodText: '',
privateFreeTextMode: null,
privateSelectionMode: true,
renderKey,
scheduleDay: 0,
selection: child.personGuid,
selectionType: 5,
showHeader: false,
startDate: null,
unitGuid: child.unitGuid,
week,
width: 1227,
year,
}
const session = this.getRequestInit({
...s24Init,
method: 'POST',
body: JSON.stringify(params),
})
const response = await this.fetch(`timetable_${child.personGuid}_${year}_${week}`, url, session)
const json = await response.json()
return parse.timetable(json, year, week)
}
public async logout() { public async logout() {
this.isFake = false this.isFake = false
this.personalNumber = undefined this.personalNumber = undefined
this.isLoggedIn = false this.isLoggedIn = false
this.authorizedSystems = {}
this.emit('logout') this.emit('logout')
await this.clearSession() await this.clearSession()
} }

View File

@ -3,10 +3,13 @@ import {
CalendarItem, CalendarItem,
Child, Child,
Classmate, Classmate,
EtjanstChild,
MenuItem, MenuItem,
NewsItem, NewsItem,
Notification, Notification,
ScheduleItem, ScheduleItem,
Skola24Child,
TimetableEntry,
User, User,
} from './types' } from './types'
@ -1090,7 +1093,7 @@ export const user = (): User => ({
lastName: 'Namnsson', lastName: 'Namnsson',
}) })
export const children = (): Child[] => [ export const children = (): EtjanstChild[] => [
{ {
name: 'Shanel Nilsson (elev)', name: 'Shanel Nilsson (elev)',
id: '39b59e-bf4b9f-f68ac25321-977218-bf0', id: '39b59e-bf4b9f-f68ac25321-977218-bf0',
@ -1106,8 +1109,19 @@ export const children = (): Child[] => [
schoolId: '8e6b13b-3116-e66c39b-a4c3fa5-a1d72d9', schoolId: '8e6b13b-3116-e66c39b-a4c3fa5-a1d72d9',
}, },
] ]
export const skola24Children = (): Skola24Child[] => [
{
firstName: 'Shanel',
lastName: 'Jonsson Nilsson',
personGuid: 'abc123',
schoolGuid: 'def456',
schoolID: 'ghi789',
timetableID: 'jkl012',
unitGuid: 'mno345'
},
]
export const classmates = (child: Child): Classmate[] => export const classmates = (child: EtjanstChild): Classmate[] =>
data[child.id].classmates data[child.id].classmates
export const news = (child: Child): NewsItem[] => data[child.id].news export const news = (child: Child): NewsItem[] => data[child.id].news
@ -1122,3 +1136,425 @@ export const menu = (child: Child): MenuItem[] => data[child.id].menu
export const notifications = (child: Child): Notification[] => export const notifications = (child: Child): Notification[] =>
data[child.id].notifications data[child.id].notifications
export const timetable = (child: Skola24Child): TimetableEntry[] => {
if (!child.personGuid || !child.unitGuid) return []
return [
{
id: 'N2FjMDc1NjYtZmM2Yy0wZDQyLTY3M2YtZWI5NGNiZDA3ZGU4',
blockName: '',
code: 'Lunch',
dayOfWeek: 1,
location: 'Ö5',
teacher: '',
timeEnd: '12:05:00',
timeStart: '11:40:00',
dateStart: '2021-04-12T11:40:00.000+02:00',
dateEnd: '2021-04-12T12:05:00.000+02:00',
name: '',
},
{
id: 'ZTQ1NWE0N2EtNzAwOS0wZTAzLTQ1ZDYtNTA1NWI4Y2JhNDYw',
blockName: '',
code: 'BL',
dayOfWeek: 1,
location: '221',
teacher: 'KUr',
timeEnd: '11:35:00',
timeStart: '09:40:00',
dateStart: '2021-04-12T09:40:00.000+02:00',
dateEnd: '2021-04-12T11:35:00.000+02:00',
name: '',
},
{
id: 'YjAxODRmY2QtNTJjZS0wMDJlLTYxOGItYmFlNTVlNDgzZmVk',
blockName: '',
code: 'NO',
dayOfWeek: 1,
location: '307',
teacher: 'TBo',
timeEnd: '13:30:00',
timeStart: '12:30:00',
dateStart: '2021-04-12T12:30:00.000+02:00',
dateEnd: '2021-04-12T13:30:00.000+02:00',
name: '',
},
{
id: 'MWRiZGI1NzgtYWIzNy0wYzMwLTVkMmEtMWFjNWRkMTRmOTdh',
blockName: '',
code: 'IDH',
dayOfWeek: 1,
location: '215',
teacher: 'HAl',
timeEnd: '15:45:00',
timeStart: '14:40:00',
dateStart: '2021-04-12T14:40:00.000+02:00',
dateEnd: '2021-04-12T15:45:00.000+02:00',
name: '',
},
{
id: 'MmZkZTZiMzMtMjdjMS0wZGIzLTUzYWYtZTg0Zjc1NDRlNzQw',
blockName: '',
code: 'M2FR',
dayOfWeek: 1,
location: '304',
teacher: 'DNi',
timeEnd: '14:25:00',
timeStart: '13:40:00',
dateStart: '2021-04-12T13:40:00.000+02:00',
dateEnd: '2021-04-12T14:25:00.000+02:00',
name: '',
},
{
id: 'MzAxMzU3MWItZGM1Ny0wOGVhLTVkZjUtOGFkMGIyYTY2OTAx',
blockName: '',
code: 'SO',
dayOfWeek: 1,
location: '303',
teacher: 'HRr',
timeEnd: '09:25:00',
timeStart: '08:15:00',
dateStart: '2021-04-12T08:15:00.000+02:00',
dateEnd: '2021-04-12T09:25:00.000+02:00',
name: '',
},
{
id: 'NDY3MDY1MmYtOTIzYi0wZmQ0LTVlZGEtNGVhZDRkOTExNTgz',
blockName: '',
code: 'M2FR',
dayOfWeek: 2,
location: '302,Fjärr',
teacher: 'DNi',
timeEnd: '09:50:00',
timeStart: '09:05:00',
dateStart: '2021-04-13T09:05:00.000+02:00',
dateEnd: '2021-04-13T09:50:00.000+02:00',
name: '',
},
{
id: 'NmE4OTU1NmItYzM0ZS0wYTI1LTYzM2QtYzBiN2M4OTVmYTQ3',
blockName: '',
code: 'EN',
dayOfWeek: 2,
location: 'Fjärr',
teacher: 'TPe',
timeEnd: '13:15:00',
timeStart: '12:30:00',
dateStart: '2021-04-13T12:30:00.000+02:00',
dateEnd: '2021-04-13T13:15:00.000+02:00',
name: '',
},
{
id: 'NDAxODRjOTctMmE5ZC0wMzdjLTY2NDMtODhlODEzOTQ3YTJh',
blockName: '',
code: 'Lunch',
dayOfWeek: 2,
location: 'Fjärr',
teacher: '',
timeEnd: '12:05:00',
timeStart: '11:40:00',
dateStart: '2021-04-13T11:40:00.000+02:00',
dateEnd: '2021-04-13T12:05:00.000+02:00',
name: '',
},
{
id: 'ZTc4YTcyZTUtMDc0NS0wNDE0LTVjODctYjY0MzQ2MGM3MDll',
blockName: '',
code: 'MA',
dayOfWeek: 2,
location: 'Fjärr',
teacher: 'CBr',
timeEnd: '11:20:00',
timeStart: '10:00:00',
dateStart: '2021-04-13T10:00:00.000+02:00',
dateEnd: '2021-04-13T11:20:00.000+02:00',
name: '',
},
{
id: 'MjRkMWE4YTItYTk5ZC0wYTFmLTVhMDgtMThiMmNhZDc1ZDUz',
blockName: '',
code: 'MU',
dayOfWeek: 2,
location: 'Fjärr',
teacher: 'KBj',
timeEnd: '14:15:00',
timeStart: '13:30:00',
dateStart: '2021-04-13T13:30:00.000+02:00',
dateEnd: '2021-04-13T14:15:00.000+02:00',
name: '',
},
{
id: 'NTU4ZTc4ZTctNDQyMy0wMjVkLTRiYzktZGUwYmFmYzk2YTlj',
blockName: '',
code: 'EN',
dayOfWeek: 3,
location: '303',
teacher: 'TPe',
timeEnd: '09:55:00',
timeStart: '09:10:00',
dateStart: '2021-04-14T09:10:00.000+02:00',
dateEnd: '2021-04-14T09:55:00.000+02:00',
name: '',
},
{
id: 'NDUyNjIxODItYzFiOC0wOTFjLTYwODYtZDllZjZjN2QyYzA3',
blockName: '',
code: 'SV a)',
dayOfWeek: 3,
location: '303',
teacher: 'JCa',
timeEnd: '14:45:00',
timeStart: '14:00:00',
dateStart: '2021-04-14T14:00:00.000+02:00',
dateEnd: '2021-04-14T14:45:00.000+02:00',
name: '',
},
{
id: 'NDdkMGI0ZjItMjkxMC0wYWI1LTQ0YWMtNDY3NTdkZTE2Njg3',
blockName: '',
code: 'SO',
dayOfWeek: 3,
location: '303',
teacher: 'HRr',
timeEnd: '11:00:00',
timeStart: '10:05:00',
dateStart: '2021-04-14T10:05:00.000+02:00',
dateEnd: '2021-04-14T11:00:00.000+02:00',
name: '',
},
{
id: 'ZTI2ZDgyNWUtM2ZlOS0wZDVmLTY5NTctNGYzZThjMTMxOTdh',
blockName: '',
code: 'NO a)',
dayOfWeek: 3,
location: '307',
teacher: 'TBo',
timeEnd: '13:50:00',
timeStart: '12:50:00',
dateStart: '2021-04-14T12:50:00.000+02:00',
dateEnd: '2021-04-14T13:50:00.000+02:00',
name: '',
},
{
id: 'NzMxNjczNGMtMmZmZi0wM2YzLTU0ZjMtODdjOTAwYzIwNTUw',
blockName: '',
code: 'Lunch',
dayOfWeek: 3,
location: 'Ö5',
teacher: '',
timeEnd: '12:40:00',
timeStart: '12:15:00',
dateStart: '2021-04-14T12:15:00.000+02:00',
dateEnd: '2021-04-14T12:40:00.000+02:00',
name: '',
},
{
id: 'MWRkZjhlZTktNTBmMC0wZjNhLTQ1OTgtMWJkOWM3MjI2NWQ4',
blockName: '',
code: 'SV',
dayOfWeek: 3,
location: '303',
teacher: 'JCa',
timeEnd: '12:05:00',
timeStart: '11:20:00',
dateStart: '2021-04-14T11:20:00.000+02:00',
dateEnd: '2021-04-14T12:05:00.000+02:00',
name: '',
},
{
id: 'NzM2Mjc2ZTYtY2JlYy0wOTc1LTU1ZGYtNjMwZjhjZWVjNjgy',
blockName: '',
code: 'MA a)',
dayOfWeek: 3,
location: '307',
teacher: 'CBr',
timeEnd: '15:45:00',
timeStart: '15:00:00',
dateStart: '2021-04-14T15:00:00.000+02:00',
dateEnd: '2021-04-14T15:45:00.000+02:00',
name: '',
},
{
id: 'YWNlZmEzZjYtM2EwNC0wYWY3LTU1N2MtMDBlMTA4MDQzMzRl',
blockName: '',
code: 'MU',
dayOfWeek: 3,
location: '504',
teacher: 'KBj',
timeEnd: '09:00:00',
timeStart: '08:15:00',
dateStart: '2021-04-14T08:15:00.000+02:00',
dateEnd: '2021-04-14T09:00:00.000+02:00',
name: '',
},
{
id: 'NDc4MThmMDYtYmYxYi0wZDBkLTdhNmItZGVjMjY3OWY3MmYz',
blockName: '',
code: 'IDH',
dayOfWeek: 4,
location: 'Fjärr',
teacher: 'AKö,CSv,HAl',
timeEnd: '15:45:00',
timeStart: '14:35:00',
dateStart: '2021-04-15T14:35:00.000+02:00',
dateEnd: '2021-04-15T15:45:00.000+02:00',
name: '',
},
{
id: 'ZjQyZjNkOWItYWMzZi0wYWRhLTQ3YzItNTZiNTJkOTRmY2Iy',
blockName: '',
code: 'M2FR',
dayOfWeek: 4,
location: 'Fjärr',
teacher: 'DNi',
timeEnd: '11:55:00',
timeStart: '11:10:00',
dateStart: '2021-04-15T11:10:00.000+02:00',
dateEnd: '2021-04-15T11:55:00.000+02:00',
name: '',
},
{
id: 'YzQ2NWZlOWMtYzM3ZC0wYzBlLTQzNTQtODMyYmU3ODcxMDQ3',
blockName: '',
code: 'MTID Arbetslagsråd 7C',
dayOfWeek: 4,
location: 'Fjärr',
teacher: 'JCa,CBr',
timeEnd: '10:00:00',
timeStart: '09:15:00',
dateStart: '2021-04-15T09:15:00.000+02:00',
dateEnd: '2021-04-15T10:00:00.000+02:00',
name: '',
},
{
id: 'YzMwMGY0YzAtNjhjNi0wYzY0LTU1MjctODg2MWQ4ZTRmZTI2',
blockName: '',
code: 'MU',
dayOfWeek: 4,
location: 'Fjärr',
teacher: 'KBj',
timeEnd: '10:55:00',
timeStart: '10:10:00',
dateStart: '2021-04-15T10:10:00.000+02:00',
dateEnd: '2021-04-15T10:55:00.000+02:00',
name: '',
},
{
id: 'ZDNlNTFhMGUtYWFlYy0wOGI0LTVlMGItOTc0MzFiZmIwODcx',
blockName: '',
code: 'Lunch',
dayOfWeek: 4,
location: 'Fjärr',
teacher: '',
timeEnd: '12:25:00',
timeStart: '12:00:00',
dateStart: '2021-04-15T12:00:00.000+02:00',
dateEnd: '2021-04-15T12:25:00.000+02:00',
name: '',
},
{
id: 'MDRiZWMyODMtNjEwZC0wZDYwLTRlOWItYTY1MjAwZTc0YTZm',
blockName: '',
code: 'SO',
dayOfWeek: 4,
location: 'Fjärr',
teacher: 'HRr',
timeEnd: '13:10:00',
timeStart: '12:35:00',
dateStart: '2021-04-15T12:35:00.000+02:00',
dateEnd: '2021-04-15T13:10:00.000+02:00',
name: '',
},
{
id: 'YTA0ZTA2NTktYTU5MS0wMTFmLTVlYWYtNWM1MTgxNDJlMDcy',
blockName: '',
code: 'EN a)',
dayOfWeek: 4,
location: 'Fjärr',
teacher: 'TPe',
timeEnd: '14:20:00',
timeStart: '13:35:00',
dateStart: '2021-04-15T13:35:00.000+02:00',
dateEnd: '2021-04-15T14:20:00.000+02:00',
name: '',
},
{
id: 'OGJhN2MxYTYtMDQ4NS0wNWNhLTUwZWEtZDQ5YzQyMzFhYzc5',
blockName: '',
code: 'Lunch',
dayOfWeek: 5,
location: 'Ö5',
teacher: '',
timeEnd: '12:05:00',
timeStart: '11:40:00',
dateStart: '2021-04-16T11:40:00.000+02:00',
dateEnd: '2021-04-16T12:05:00.000+02:00',
name: '',
},
{
id: 'ZmUwMGEwM2QtNTExMy0wODliLTY1ZGEtODM0YmRjNjc1NDIw',
blockName: '',
code: 'MA a)',
dayOfWeek: 5,
location: '303',
teacher: 'CBr',
timeEnd: '14:00:00',
timeStart: '13:15:00',
dateStart: '2021-04-16T13:15:00.000+02:00',
dateEnd: '2021-04-16T14:00:00.000+02:00',
name: '',
},
{
id: 'Y2IwYjYzZDEtODAxYi0wMTNjLTRjNDMtMDFlODgzMmY4MWEy',
blockName: '',
code: 'MU a)',
dayOfWeek: 5,
location: '510',
teacher: 'KBj',
timeEnd: '13:05:00',
timeStart: '12:20:00',
dateStart: '2021-04-16T12:20:00.000+02:00',
dateEnd: '2021-04-16T13:05:00.000+02:00',
name: '',
},
{
id: 'N2JkMGFiOTYtMjI5OC0wMjZiLTc3OGEtN2JkN2Q4MDZkNTEy',
blockName: '',
code: 'SL tmtx)',
dayOfWeek: 5,
location: '860',
teacher: 'EAl',
timeEnd: '15:10:00',
timeStart: '14:10:00',
dateStart: '2021-04-16T14:10:00.000+02:00',
dateEnd: '2021-04-16T15:10:00.000+02:00',
name: '',
},
{
id: 'NzkxMjE3MDctMWExNS0wN2RmLTQwMzQtNTEyZTczZjQyZTUw',
blockName: '',
code: 'SV',
dayOfWeek: 5,
location: '303',
teacher: 'JCa',
timeEnd: '10:35:00',
timeStart: '09:20:00',
dateStart: '2021-04-16T09:20:00.000+02:00',
dateEnd: '2021-04-16T10:35:00.000+02:00',
name: '',
},
{
id: 'ZTU1ZDQxNzQtN2Q3Yy0wMDMxLTY2ZmYtZmIyNGM5MjM3ZTRj',
blockName: '',
code: 'MA',
dayOfWeek: 5,
location: '303',
teacher: 'CBr',
timeEnd: '11:35:00',
timeStart: '10:40:00',
dateStart: '2021-04-16T10:40:00.000+02:00',
dateEnd: '2021-04-16T11:35:00.000+02:00',
name: '',
}
]
}

View File

@ -0,0 +1,148 @@
import { timetable, timetableEntry, TimetableResponse } from '../'
let response: TimetableResponse
describe('Timetable', () => {
beforeEach(() => {
response = {
error: null,
data: {
textList: [
{
x: 11,
y: 64,
fColor: '#000000',
fontsize: 14,
text: '8:30',
bold: false,
italic: false,
id: 9,
parentId: 6,
type: 'ClockAxisBox'
},
{
x: 11,
y: 125,
fColor: '#000000',
fontsize: 14,
text: '9:00',
bold: false,
italic: false,
id: 12,
parentId: 6,
type: 'ClockAxisBox'
},
],
boxList: [
{
x: 0,
y: 950,
width: 1226,
height: 112,
bColor: '#FFFFFF',
fColor: '#FFFFFF',
id: 0,
parentId: null,
type: 'Footer',
lessonGuids: null
},
{
x: 56,
y: 0,
width: 223,
height: 34,
bColor: '#FFFFFF',
fColor: '#000000',
id: 1,
parentId: null,
type: 'HeadingDay',
lessonGuids: null
},
],
lineList: [
{
p1x: 51,
p1y: 34,
p2x: 56,
p2y: 34,
color: '#000000',
id: 7,
parentId: 6,
type: 'ClockAxisGradiation'
},
{
p1x: 0,
p1y: 64,
p2x: 56,
p2y: 64,
color: '#000000',
id: 8,
parentId: 6,
type: 'ClockAxisGradiation'
},
],
lessonInfo: [
{
guidId: 'N2FjMDc1NjYtZmM2Yy0wZDQyLTY3M2YtZWI5NGNiZDA3ZGU4',
texts: [
'Lunch',
'',
'Ö5'
],
timeStart: '11:40:00',
timeEnd: '12:05:00',
dayOfWeekNumber: 1,
blockName: ''
},
{
guidId: 'ZTQ1NWE0N2EtNzAwOS0wZTAzLTQ1ZDYtNTA1NWI4Y2JhNDYw',
texts: [
'BL',
'KUr',
'221'
],
timeStart: '09:40:00',
timeEnd: '11:35:00',
dayOfWeekNumber: 1,
blockName: 'block'
},
]
},
exception: null,
validation: [],
}
})
describe('timetableEntry', () => {
it('parses basic timeTableEntry data correctly', () => {
const entry = timetableEntry(response.data.lessonInfo[1], 2021, 15)
expect(entry.id).toEqual('ZTQ1NWE0N2EtNzAwOS0wZTAzLTQ1ZDYtNTA1NWI4Y2JhNDYw')
expect(entry.code).toEqual('BL')
expect(entry.teacher).toEqual('KUr')
expect(entry.location).toEqual('221')
expect(entry.timeStart).toEqual('09:40:00')
expect(entry.timeEnd).toEqual('11:35:00')
expect(entry.dayOfWeek).toEqual(1)
expect(entry.blockName).toEqual('block')
})
it('parses dates correctly', () => {
const entry = timetableEntry(response.data.lessonInfo[1], 2021, 15)
expect(entry.dateStart).toEqual('2021-04-12T09:40:00.000+02:00')
expect(entry.dateEnd).toEqual('2021-04-12T11:35:00.000+02:00')
})
})
describe('timetable', () => {
it('throws error', () => {
response.error = 'b0rk'
expect(() => timetable(response, 2021, 15)).toThrow('b0rk')
})
it('parses lessonInfo', () => {
const table = timetable(response, 2021, 15)
expect(table).toHaveLength(2)
expect(table[0].id).toEqual('N2FjMDc1NjYtZmM2Yy0wZDQyLTY3M2YtZWI5NGNiZDA3ZGU4')
expect(table[1].id).toEqual('ZTQ1NWE0N2EtNzAwOS0wZTAzLTQ1ZDYtNTA1NWI4Y2JhNDYw')
})
})
})

View File

@ -6,4 +6,5 @@ export * from './menu'
export * from './news' export * from './news'
export * from './notifications' export * from './notifications'
export * from './schedule' export * from './schedule'
export * from './timetable'
export * from './user' export * from './user'

62
lib/parse/timetable.ts Normal file
View File

@ -0,0 +1,62 @@
import { DateTime } from 'luxon'
import { TimetableEntry } from '../types'
const calculateDate = (year: number, weekNumber: number, weekday: number, time: string): string => {
const [hours, minutes, seconds] = time.split(':')
return DateTime.local()
.set({
year,
weekNumber,
weekday,
hour: parseInt(hours, 10),
minute: parseInt(minutes, 10),
second: parseInt(seconds, 10),
millisecond: 0,
}).toISO()
}
interface TimetableResponseEntry {
guidId: string
texts: string[]
timeStart: string
timeEnd: string
dayOfWeekNumber: number
blockName: string
}
export interface TimetableResponse {
error: string | null
data: {
textList: any[]
boxList: any[]
lineList: any[]
lessonInfo: TimetableResponseEntry[]
}
exception: any
validation: any[]
}
interface EntryParser {
(args: TimetableResponseEntry, year: number, week: number): TimetableEntry
}
export const timetableEntry: EntryParser = ({
guidId, texts: [code, teacher, location], timeStart, timeEnd, dayOfWeekNumber, blockName,
}, year, week) => ({
id: guidId,
blockName,
code,
dayOfWeek: dayOfWeekNumber,
location,
teacher,
timeEnd,
timeStart,
dateStart: calculateDate(year, week, dayOfWeekNumber, timeStart),
dateEnd: calculateDate(year, week, dayOfWeekNumber, timeEnd),
name: ''
})
export const timetable = (response: TimetableResponse, year: number, week: number) => {
if (response.error) {
throw new Error(response.error)
}
return response.data.lessonInfo.map((entry) => timetableEntry(entry, year, week))
}

View File

@ -62,3 +62,14 @@ export const childcontrollerScript = `https://etjanst.stockholm.se/vardnadshavar
export const createItemConfig = export const createItemConfig =
'https://raw.githubusercontent.com/kolplattformen/embedded-api/main/config.json' 'https://raw.githubusercontent.com/kolplattformen/embedded-api/main/config.json'
// Skola24
export const ssoRequestUrl = (targetSystem: string) =>
`https://fnsservicesso1.stockholm.se/sso-ng/saml-2.0/authenticate?customer=https://login001.stockholm.se&targetsystem=${targetSystem}`
export const ssoResponseUrl = 'https://login001.stockholm.se/affwebservices/public/saml2sso'
export const samlResponseUrl = 'https://fnsservicesso1.stockholm.se/sso-ng/saml-2.0/response'
export const timetables = 'https://fns.stockholm.se/ng/api/services/skola24/get/personal/timetables'
export const renderKey = 'https://fns.stockholm.se/ng/api/get/timetable/render/key'
export const timetable = 'https://fns.stockholm.se/ng/api/render/timetable'

View File

@ -66,7 +66,7 @@ export interface CalendarItem {
* @export * @export
* @interface Child * @interface Child
*/ */
export interface Child { export interface EtjanstChild {
id: string id: string
/** /**
* <p>Special ID used to access certain subsystems</p> * <p>Special ID used to access certain subsystems</p>
@ -84,6 +84,8 @@ export interface Child {
schoolId?: string schoolId?: string
} }
export interface Child extends EtjanstChild, Skola24Child {}
/** /**
* @export * @export
* @interface Classmate * @interface Classmate
@ -181,3 +183,29 @@ export interface User {
email?: string | null email?: string | null
notificationId?: string notificationId?: string
} }
export interface Skola24Child {
schoolGuid?: string
unitGuid?: string
schoolID?: string
timetableID?: string
personGuid?: string
firstName?: string
lastName?: string
}
export type SSOSystem = 'TimetableViewer'
export interface TimetableEntry {
id: string
code: string
name: string
teacher: string
location: string
timeStart: string
timeEnd: string
dayOfWeek: number
blockName: string
dateStart: string
dateEnd: string
}

16
run.js
View File

@ -22,6 +22,7 @@ const fetchCookie = require('fetch-cookie/node-fetch')
const { writeFile } = require('fs/promises') const { writeFile } = require('fs/promises')
const path = require('path') const path = require('path')
const fs = require('fs') const fs = require('fs')
const { inspect } = require('util')
const init = require('./dist').default const init = require('./dist').default
@ -118,23 +119,30 @@ async function run() {
console.log('news') console.log('news')
const news = await api.getNews(children[0]) const news = await api.getNews(children[0])
*/ */
/*console.log('news details') /* console.log('news details')
const newsItems = await Promise.all( const newsItems = await Promise.all(
news.map((newsItem) => news.map((newsItem) =>
api.getNewsDetails(children[0], newsItem) api.getNewsDetails(children[0], newsItem)
.catch((err) => { console.error(newsItem.id, err) }) .catch((err) => { console.error(newsItem.id, err) })
) )
) )
console.log(newsItems)*/ console.log(newsItems) */
/*console.log('menu') /* console.log('menu')
const menu = await api.getMenu(children[0]) const menu = await api.getMenu(children[0])
console.log(menu)*/ console.log(menu) */
// console.log('notifications') // console.log('notifications')
// const notifications = await api.getNotifications(children[0]) // const notifications = await api.getNotifications(children[0])
// console.log(notifications) // console.log(notifications)
const skola24children = await api.getSkola24Children()
console.log(skola24children)
console.log('timetable')
const timetable = await api.getTimetable(skola24children[0], 15, 2021)
console.log(inspect(timetable, false, 1000, true))
await api.logout() await api.logout()
}) })