@wilm-ai/wilma-client 1.5.2 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/client.d.ts +67 -2
- package/dist/client.js +167 -128
- package/dist/external-fetch.d.ts +18 -0
- package/dist/external-fetch.js +263 -0
- package/dist/finnish-time.d.ts +19 -0
- package/dist/finnish-time.js +64 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/parsers/attendance.d.ts +4 -2
- package/dist/parsers/attendance.js +50 -13
- package/dist/parsers/dates.js +14 -17
- package/dist/parsers/exams.d.ts +8 -1
- package/dist/parsers/exams.js +37 -0
- package/dist/parsers/gradebook.d.ts +8 -0
- package/dist/parsers/gradebook.js +44 -0
- package/dist/parsers/html-text.d.ts +6 -0
- package/dist/parsers/html-text.js +42 -0
- package/dist/parsers/messages.d.ts +6 -2
- package/dist/parsers/messages.js +54 -8
- package/dist/parsers/news.js +61 -7
- package/dist/parsers/overview.d.ts +2 -1
- package/dist/parsers/overview.js +4 -3
- package/dist/parsers/printouts.d.ts +3 -0
- package/dist/parsers/printouts.js +20 -0
- package/dist/parsers/schedule.d.ts +6 -0
- package/dist/parsers/schedule.js +37 -0
- package/dist/parsers/students.d.ts +2 -5
- package/dist/parsers/students.js +22 -7
- package/dist/session.d.ts +58 -6
- package/dist/session.js +357 -77
- package/dist/timeouts.d.ts +20 -0
- package/dist/timeouts.js +82 -0
- package/dist/types.d.ts +56 -0
- package/package.json +21 -7
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wilm.ai
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# @wilm-ai/wilma-client
|
|
2
|
+
|
|
3
|
+
A TypeScript client for **Wilma**, the Finnish school information system, as a parent (guardian) sees it: schedule, homework, exams and grades, the gradebook, lesson notes and absences, messages with their replies, bulletins and their attachments, and printouts. Read-only.
|
|
4
|
+
|
|
5
|
+
It powers the [`wilma` CLI and MCP server](https://www.npmjs.com/package/@wilm-ai/wilma-cli) ([wilm.ai](https://wilm.ai)).
|
|
6
|
+
|
|
7
|
+
> Independent open-source project, not affiliated with, endorsed by, or connected to Visma or the official Wilma service.
|
|
8
|
+
|
|
9
|
+
Needs Node.js 20.18.1 or newer.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm i @wilm-ai/wilma-client
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Use
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { WilmaClient, listTenants } from "@wilm-ai/wilma-client";
|
|
19
|
+
|
|
20
|
+
// Find the Wilma address (most city schools share their city's Wilma).
|
|
21
|
+
const wilmas = await listTenants();
|
|
22
|
+
|
|
23
|
+
// Log in once; every child shares the login.
|
|
24
|
+
const client = await WilmaClient.login({ baseUrl: "https://<school>.inschool.fi", username, password }, onMfa);
|
|
25
|
+
for (const student of await client.students()) {
|
|
26
|
+
const kid = client.forStudent(student.studentNumber);
|
|
27
|
+
const overview = await kid.overview.get(); // this week's lessons, homework, exams, exam grades
|
|
28
|
+
const exams = await kid.exams.upcoming(); // with start times when the school gives one
|
|
29
|
+
const lessons = await kid.schedule.list({ from: "2026-10-05", to: "2026-10-09" }); // teachers and rooms
|
|
30
|
+
const notes = await kid.attendance.list({ from: "2026-09-01", to: "2026-09-30" }); // lesson notes, with the teacher's words
|
|
31
|
+
const summary = await kid.attendance.summary(); // counts by kind for the school year
|
|
32
|
+
const gradebook = await kid.gradebook.get(); // subject > syllabus > course, with grades and dates
|
|
33
|
+
const messages = await kid.messages.list("inbox"); // unread and replyCount included
|
|
34
|
+
const thread = await kid.messages.get(messages[0].wilmaId); // the message and its replies
|
|
35
|
+
const news = await kid.news.list(); // dated, pinned and older bulletins
|
|
36
|
+
const printouts = await kid.printouts.list(); // PDFs such as report cards
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`onMfa(formkey)` returns a one-time code for accounts with two-step verification (e.g. generated from the authenticator setup key).
|
|
41
|
+
|
|
42
|
+
### One session per account
|
|
43
|
+
|
|
44
|
+
Wilma allows one live session per account: a new login ends the previous one, and also logs the parent out of Wilma in their own browser. So:
|
|
45
|
+
|
|
46
|
+
- **Log in once** and use `client.forStudent(number)` for each child; they share the login.
|
|
47
|
+
- A session that Wilma ends (another login, or expiry) **logs in again by itself** on the next request — once, even when many requests notice at the same time, including the two-step verification step. A refused password stops further attempts, so a changed password can't lock the account.
|
|
48
|
+
- To continue a session in another process, save `client.exportSession()` (treat it like the password) and later call `WilmaClient.resume(profile, saved, onMfa)`. `client.onLogin(callback)` tells you when a new session should be saved.
|
|
49
|
+
|
|
50
|
+
### Times
|
|
51
|
+
|
|
52
|
+
Wilma's times are Finnish wall-clock times. The client reads them as such wherever it runs (dates are correct on a UTC server too), and exports helpers: `finnishDateString()`, `finnishTime()`, and `finnishIsoString(date)` for output like `2026-10-02T13:37:00+03:00`.
|
|
53
|
+
|
|
54
|
+
### Where the data comes from
|
|
55
|
+
|
|
56
|
+
JSON wherever Wilma has it: the account's roles, the front page (overview), message lists and threads, and the timetable API. Bulletins, lesson notes, the gradebook, printouts and the exam calendar exist only as pages and are parsed; the parsers are tested against anonymised copies of real pages. Older Wilma versions without the JSON endpoints fall back to the pages.
|
|
57
|
+
|
|
58
|
+
### Safety
|
|
59
|
+
|
|
60
|
+
- Requests stay on the Wilma address; redirects to other sites never carry Wilma's cookies.
|
|
61
|
+
- Files linked from bulletins are fetched without the Wilma login and only from public internet addresses (private, loopback and cloud-metadata addresses are refused, also after DNS resolution).
|
|
62
|
+
- Requests and downloads fail after a while without progress instead of hanging.
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT
|
package/dist/client.d.ts
CHANGED
|
@@ -1,13 +1,34 @@
|
|
|
1
|
-
import type { Exam, Message, MessageFolder, NewsItem, NewsResource, OverviewData,
|
|
2
|
-
import {
|
|
1
|
+
import type { Exam, GradebookEntry, LessonNote, LessonNoteSummary, Message, MessageFolder, NewsItem, NewsResource, OverviewData, Printout, StudentInfo, UpcomingExam, WilmaProfile } from "./types.js";
|
|
2
|
+
import type { Response } from "undici";
|
|
3
3
|
export type MfaCallback = (formkey: string) => Promise<string>;
|
|
4
4
|
export declare class WilmaClient {
|
|
5
5
|
private session;
|
|
6
6
|
private constructor();
|
|
7
7
|
static login(profile: WilmaProfile, onMfaRequired?: MfaCallback): Promise<WilmaClient>;
|
|
8
|
+
/**
|
|
9
|
+
* A client on a session saved earlier with exportSession(), without logging
|
|
10
|
+
* in. If Wilma has ended that session, the first request logs in again by
|
|
11
|
+
* itself. Returns null when the saved state has no session.
|
|
12
|
+
*/
|
|
13
|
+
static resume(profile: WilmaProfile, state: string, onMfaRequired?: MfaCallback): WilmaClient | null;
|
|
14
|
+
/** This login's session (cookies) as a string, for WilmaClient.resume() in a later process. Treat it like a password. */
|
|
15
|
+
exportSession(): string;
|
|
16
|
+
/** Run a callback after every successful login, e.g. to save the new session. */
|
|
17
|
+
onLogin(callback: (() => void) | undefined): void;
|
|
8
18
|
static listStudents(profile: WilmaProfile, onMfaRequired?: MfaCallback): Promise<StudentInfo[]>;
|
|
19
|
+
/**
|
|
20
|
+
* A client for one of the account's students that reuses this client's
|
|
21
|
+
* login. Log in once, then call this per child instead of logging in again.
|
|
22
|
+
*/
|
|
23
|
+
forStudent(studentNumber: string | null | undefined): WilmaClient;
|
|
24
|
+
/** The guardian's students, discovered with this client's login. */
|
|
25
|
+
students(): Promise<StudentInfo[]>;
|
|
9
26
|
messages: {
|
|
10
27
|
list: (folder?: MessageFolder) => Promise<Message[]>;
|
|
28
|
+
/**
|
|
29
|
+
* One message with its replies. Wilma answers `?format=json` with the whole
|
|
30
|
+
* thread; older versions only have the HTML page, which is parsed instead.
|
|
31
|
+
*/
|
|
11
32
|
get: (messageId: number) => Promise<Message>;
|
|
12
33
|
};
|
|
13
34
|
news: {
|
|
@@ -26,21 +47,65 @@ export declare class WilmaClient {
|
|
|
26
47
|
}>;
|
|
27
48
|
};
|
|
28
49
|
exams: {
|
|
50
|
+
/**
|
|
51
|
+
* Upcoming exams with topics and teachers (front page), plus the start
|
|
52
|
+
* time when the school gives one (exam calendar). Without the calendar,
|
|
53
|
+
* exams come back without times.
|
|
54
|
+
*/
|
|
55
|
+
upcoming: () => Promise<UpcomingExam[]>;
|
|
56
|
+
/** The exam calendar, or [] if it can't be read (start times are a bonus). */
|
|
57
|
+
calendarOrEmpty: () => Promise<Exam[]>;
|
|
58
|
+
/** The exam calendar page: upcoming exams with dates, start times, teachers and notes. */
|
|
29
59
|
list: (opts?: {
|
|
30
60
|
start?: string;
|
|
31
61
|
end?: string;
|
|
32
62
|
}) => Promise<Exam[]>;
|
|
33
63
|
};
|
|
34
64
|
attendance: {
|
|
65
|
+
/**
|
|
66
|
+
* Lesson notes (merkinnät): absences, lateness and teachers' feedback, with
|
|
67
|
+
* any words the teacher wrote. One day (default today), or a period with
|
|
68
|
+
* `from`/`to` (YYYY-MM-DD).
|
|
69
|
+
*/
|
|
35
70
|
list: (opts?: {
|
|
36
71
|
date?: string;
|
|
72
|
+
from?: string;
|
|
73
|
+
to?: string;
|
|
37
74
|
}) => Promise<LessonNote[]>;
|
|
75
|
+
/** How many lesson notes of each kind from `from` to `to` (default today); without `from`, this school year. */
|
|
76
|
+
summary: (opts?: {
|
|
77
|
+
from?: string;
|
|
78
|
+
to?: string;
|
|
79
|
+
}) => Promise<LessonNoteSummary>;
|
|
38
80
|
};
|
|
81
|
+
/** The lesson notes page for a period ("range" as Wilma's own period links use it). */
|
|
82
|
+
private attendancePage;
|
|
39
83
|
schedule: {
|
|
84
|
+
/**
|
|
85
|
+
* Lessons on a date or in a period (YYYY-MM-DD), from Wilma's timetable
|
|
86
|
+
* API, with teachers and rooms. Without dates: this week, from the front
|
|
87
|
+
* page. Older Wilma versions without the API fall back to the schedule
|
|
88
|
+
* page (one date) or the front page (this week).
|
|
89
|
+
*/
|
|
40
90
|
list: (opts?: {
|
|
41
91
|
date?: string;
|
|
92
|
+
from?: string;
|
|
93
|
+
to?: string;
|
|
42
94
|
}) => Promise<OverviewData["schedule"]>;
|
|
43
95
|
};
|
|
96
|
+
gradebook: {
|
|
97
|
+
/** Completed courses and grades (Suoritukset), as a tree: subject > syllabus > course. */
|
|
98
|
+
get: () => Promise<GradebookEntry[]>;
|
|
99
|
+
};
|
|
100
|
+
printouts: {
|
|
101
|
+
/** PDF documents the school offers (Tulosteet): report cards, absence reports… */
|
|
102
|
+
list: () => Promise<Printout[]>;
|
|
103
|
+
/** Download one printout; the response body is the PDF. */
|
|
104
|
+
fetch: (id: string) => Promise<{
|
|
105
|
+
printout: Printout;
|
|
106
|
+
response: Response;
|
|
107
|
+
}>;
|
|
108
|
+
};
|
|
44
109
|
overview: {
|
|
45
110
|
get: () => Promise<OverviewData>;
|
|
46
111
|
};
|
package/dist/client.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import { WilmaSession, MfaRequiredError } from "./session.js";
|
|
1
|
+
import { WilmaSession, MfaRequiredError, APIError } from "./session.js";
|
|
2
2
|
import { parseWilmaTimestamp } from "./parsers/dates.js";
|
|
3
|
-
import {
|
|
3
|
+
import { finnishDateString } from "./finnish-time.js";
|
|
4
|
+
import { parseMessagesList, parseMessageDetailHtml, parseMessageDetailJson } from "./parsers/messages.js";
|
|
4
5
|
import { parseNewsDetailHtml, parseNewsDetailJson, parseNewsList, parseNewsListHtml, } from "./parsers/news.js";
|
|
5
|
-
import { parseExamsHtml } from "./parsers/exams.js";
|
|
6
|
-
import { parseAttendanceHtml } from "./parsers/attendance.js";
|
|
6
|
+
import { addExamTimes, parseExamsHtml } from "./parsers/exams.js";
|
|
7
|
+
import { parseAttendanceHtml, summarizeLessonNotes } from "./parsers/attendance.js";
|
|
8
|
+
import { parseGradebookHtml } from "./parsers/gradebook.js";
|
|
9
|
+
import { parsePrintoutsHtml } from "./parsers/printouts.js";
|
|
7
10
|
import { parseOverview } from "./parsers/overview.js";
|
|
8
|
-
import { parseScheduleHtml } from "./parsers/schedule.js";
|
|
11
|
+
import { parseScheduleHtml, parseTimetableJson } from "./parsers/schedule.js";
|
|
9
12
|
import { parseStudentsFromAccountsRoles, parseStudentsFromHome } from "./parsers/students.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { NetworkError, wrapNetworkError } from "./network-error.js";
|
|
13
|
-
const EXTERNAL_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
|
|
14
|
-
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36";
|
|
13
|
+
import { NetworkError } from "./network-error.js";
|
|
14
|
+
import { DOWNLOAD_TIMEOUT_MS, fetchExternalFile, isHtmlResponse } from "./external-fetch.js";
|
|
15
15
|
export class WilmaClient {
|
|
16
16
|
session;
|
|
17
17
|
constructor(session) {
|
|
@@ -22,13 +22,13 @@ export class WilmaClient {
|
|
|
22
22
|
studentNumber: profile.studentNumber ?? null,
|
|
23
23
|
debug: profile.debug ?? false,
|
|
24
24
|
});
|
|
25
|
+
session.setMfaCallback(onMfaRequired);
|
|
25
26
|
try {
|
|
26
27
|
await session.login(profile.username, profile.password);
|
|
27
28
|
}
|
|
28
29
|
catch (err) {
|
|
29
30
|
if (err instanceof MfaRequiredError && onMfaRequired) {
|
|
30
|
-
|
|
31
|
-
await session.submitMfaCode(err.formkey, otpCode);
|
|
31
|
+
await session.answerMfa(err.formkey, onMfaRequired);
|
|
32
32
|
}
|
|
33
33
|
else {
|
|
34
34
|
throw err;
|
|
@@ -36,22 +36,42 @@ export class WilmaClient {
|
|
|
36
36
|
}
|
|
37
37
|
return new WilmaClient(session);
|
|
38
38
|
}
|
|
39
|
-
|
|
39
|
+
/**
|
|
40
|
+
* A client on a session saved earlier with exportSession(), without logging
|
|
41
|
+
* in. If Wilma has ended that session, the first request logs in again by
|
|
42
|
+
* itself. Returns null when the saved state has no session.
|
|
43
|
+
*/
|
|
44
|
+
static resume(profile, state, onMfaRequired) {
|
|
40
45
|
const session = new WilmaSession(profile.baseUrl, {
|
|
46
|
+
studentNumber: profile.studentNumber ?? null,
|
|
41
47
|
debug: profile.debug ?? false,
|
|
42
48
|
});
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
session.setMfaCallback(onMfaRequired);
|
|
50
|
+
return session.resumeState(state, profile.username, profile.password) ? new WilmaClient(session) : null;
|
|
51
|
+
}
|
|
52
|
+
/** This login's session (cookies) as a string, for WilmaClient.resume() in a later process. Treat it like a password. */
|
|
53
|
+
exportSession() {
|
|
54
|
+
return this.session.exportState();
|
|
55
|
+
}
|
|
56
|
+
/** Run a callback after every successful login, e.g. to save the new session. */
|
|
57
|
+
onLogin(callback) {
|
|
58
|
+
this.session.onLogin(callback);
|
|
59
|
+
}
|
|
60
|
+
static async listStudents(profile, onMfaRequired) {
|
|
61
|
+
const client = await WilmaClient.login({ ...profile, studentNumber: null }, onMfaRequired);
|
|
62
|
+
return client.students();
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A client for one of the account's students that reuses this client's
|
|
66
|
+
* login. Log in once, then call this per child instead of logging in again.
|
|
67
|
+
*/
|
|
68
|
+
forStudent(studentNumber) {
|
|
69
|
+
return new WilmaClient(this.session.forStudent(studentNumber || null));
|
|
70
|
+
}
|
|
71
|
+
/** The guardian's students, discovered with this client's login. */
|
|
72
|
+
async students() {
|
|
73
|
+
// Discovery pages live outside any student's "/!<number>/" prefix.
|
|
74
|
+
const session = this.session.forStudent(null);
|
|
55
75
|
try {
|
|
56
76
|
const accountsResp = await session.get("/api/v1/accounts/me/roles");
|
|
57
77
|
const accountsText = await accountsResp.text();
|
|
@@ -89,7 +109,22 @@ export class WilmaClient {
|
|
|
89
109
|
const data = safeJson(text);
|
|
90
110
|
return parseMessagesList(data, folder);
|
|
91
111
|
},
|
|
112
|
+
/**
|
|
113
|
+
* One message with its replies. Wilma answers `?format=json` with the whole
|
|
114
|
+
* thread; older versions only have the HTML page, which is parsed instead.
|
|
115
|
+
*/
|
|
92
116
|
get: async (messageId) => {
|
|
117
|
+
try {
|
|
118
|
+
const resp = await this.session.get(`/messages/${messageId}?format=json`);
|
|
119
|
+
const thread = parseMessageDetailJson(safeJson(await resp.text()), messageId);
|
|
120
|
+
if (thread)
|
|
121
|
+
return thread;
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
if (err instanceof NetworkError || !(err instanceof APIError) || err.status !== 404)
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
// Older versions: the message page (flat JSON on some, HTML on most).
|
|
93
128
|
const resp = await this.session.get(`/messages/${messageId}`);
|
|
94
129
|
const contentType = resp.headers.get("content-type")?.toLowerCase() ?? "";
|
|
95
130
|
const text = await resp.text();
|
|
@@ -142,7 +177,21 @@ export class WilmaClient {
|
|
|
142
177
|
}
|
|
143
178
|
if (resource.authContext === "wilma") {
|
|
144
179
|
const url = new URL(resource.url);
|
|
145
|
-
|
|
180
|
+
// Collapse leading slashes so the path can't turn into "//other-host/...".
|
|
181
|
+
const path = `/${url.pathname.replace(/^\/+/, "")}${url.search}`;
|
|
182
|
+
const response = await this.session.get(path, undefined, {
|
|
183
|
+
externalRedirect: "return",
|
|
184
|
+
timeoutMs: DOWNLOAD_TIMEOUT_MS,
|
|
185
|
+
});
|
|
186
|
+
if (response.status >= 300 && response.status < 400) {
|
|
187
|
+
// Wilma handed the file off to another site: fetch it there, without Wilma credentials.
|
|
188
|
+
const location = response.headers.get("location");
|
|
189
|
+
await response.body?.cancel();
|
|
190
|
+
const external = location ? await fetchExternalFile(new URL(location, url).href) : null;
|
|
191
|
+
return external
|
|
192
|
+
? { resource, response: external, status: "fetched" }
|
|
193
|
+
: { resource, response: null, status: "not_a_file" };
|
|
194
|
+
}
|
|
146
195
|
if (isHtmlResponse(response)) {
|
|
147
196
|
await response.body?.cancel();
|
|
148
197
|
return { resource, response: null, status: "not_a_file" };
|
|
@@ -157,6 +206,27 @@ export class WilmaClient {
|
|
|
157
206
|
},
|
|
158
207
|
};
|
|
159
208
|
exams = {
|
|
209
|
+
/**
|
|
210
|
+
* Upcoming exams with topics and teachers (front page), plus the start
|
|
211
|
+
* time when the school gives one (exam calendar). Without the calendar,
|
|
212
|
+
* exams come back without times.
|
|
213
|
+
*/
|
|
214
|
+
upcoming: async () => {
|
|
215
|
+
const [overview, calendar] = await Promise.all([this.overview.get(), this.exams.calendarOrEmpty()]);
|
|
216
|
+
return addExamTimes(overview.upcomingExams, calendar);
|
|
217
|
+
},
|
|
218
|
+
/** The exam calendar, or [] if it can't be read (start times are a bonus). */
|
|
219
|
+
calendarOrEmpty: async () => {
|
|
220
|
+
try {
|
|
221
|
+
return await this.exams.list();
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
if (err instanceof NetworkError)
|
|
225
|
+
throw err;
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
/** The exam calendar page: upcoming exams with dates, start times, teachers and notes. */
|
|
160
230
|
list: async (opts) => {
|
|
161
231
|
const params = new URLSearchParams();
|
|
162
232
|
if (opts?.start) {
|
|
@@ -173,28 +243,84 @@ export class WilmaClient {
|
|
|
173
243
|
},
|
|
174
244
|
};
|
|
175
245
|
attendance = {
|
|
246
|
+
/**
|
|
247
|
+
* Lesson notes (merkinnät): absences, lateness and teachers' feedback, with
|
|
248
|
+
* any words the teacher wrote. One day (default today), or a period with
|
|
249
|
+
* `from`/`to` (YYYY-MM-DD).
|
|
250
|
+
*/
|
|
176
251
|
list: async (opts) => {
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
252
|
+
const from = opts?.from ?? opts?.date ?? finnishDateString();
|
|
253
|
+
const to = opts?.to ?? opts?.date ?? from;
|
|
254
|
+
const notes = parseAttendanceHtml(await this.attendancePage(`range=-3&first=${isoDateToFinnish(from)}&last=${isoDateToFinnish(to)}`));
|
|
255
|
+
return notes.filter((note) => note.date >= from && note.date <= to);
|
|
256
|
+
},
|
|
257
|
+
/** How many lesson notes of each kind from `from` to `to` (default today); without `from`, this school year. */
|
|
258
|
+
summary: async (opts) => {
|
|
259
|
+
if (!opts?.from) {
|
|
260
|
+
const notes = parseAttendanceHtml(await this.attendancePage("range=-4"));
|
|
261
|
+
return summarizeLessonNotes(opts?.to ? notes.filter((note) => note.date <= opts.to) : notes, null, opts?.to ?? null);
|
|
180
262
|
}
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
const resp = await this.session.get(path);
|
|
184
|
-
const text = await resp.text();
|
|
185
|
-
return parseAttendanceHtml(text, opts?.date ?? "");
|
|
263
|
+
const to = opts.to ?? finnishDateString();
|
|
264
|
+
return summarizeLessonNotes(await this.attendance.list({ from: opts.from, to }), opts.from, to);
|
|
186
265
|
},
|
|
187
266
|
};
|
|
267
|
+
/** The lesson notes page for a period ("range" as Wilma's own period links use it). */
|
|
268
|
+
async attendancePage(range) {
|
|
269
|
+
const resp = await this.session.get(`/attendance/view?${range}`);
|
|
270
|
+
return resp.text();
|
|
271
|
+
}
|
|
188
272
|
schedule = {
|
|
273
|
+
/**
|
|
274
|
+
* Lessons on a date or in a period (YYYY-MM-DD), from Wilma's timetable
|
|
275
|
+
* API, with teachers and rooms. Without dates: this week, from the front
|
|
276
|
+
* page. Older Wilma versions without the API fall back to the schedule
|
|
277
|
+
* page (one date) or the front page (this week).
|
|
278
|
+
*/
|
|
189
279
|
list: async (opts) => {
|
|
190
|
-
|
|
191
|
-
|
|
280
|
+
const from = opts?.from ?? opts?.date;
|
|
281
|
+
const to = opts?.to ?? opts?.date ?? from;
|
|
282
|
+
if (from && to) {
|
|
283
|
+
try {
|
|
284
|
+
const resp = await this.session.get(`/api/v1/schedules/timetable?startdate=${from}&enddate=${to}`);
|
|
285
|
+
const data = safeJson(await resp.text());
|
|
286
|
+
if (Array.isArray(data.payload)) {
|
|
287
|
+
return parseTimetableJson(data.payload).filter((lesson) => lesson.date >= from && lesson.date <= to);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
if (!(err instanceof APIError) || (err.status !== 403 && err.status !== 404))
|
|
292
|
+
throw err;
|
|
293
|
+
}
|
|
294
|
+
if (from === to) {
|
|
295
|
+
const params = new URLSearchParams({ date: isoDateToFinnish(from) });
|
|
296
|
+
const resp = await this.session.get(`/schedule?${params.toString()}`);
|
|
297
|
+
return parseScheduleHtml(await resp.text()).filter((lesson) => lesson.date === from);
|
|
298
|
+
}
|
|
299
|
+
return (await this.overview.get()).schedule.filter((lesson) => lesson.date >= from && lesson.date <= to);
|
|
192
300
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
301
|
+
return (await this.overview.get()).schedule;
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
gradebook = {
|
|
305
|
+
/** Completed courses and grades (Suoritukset), as a tree: subject > syllabus > course. */
|
|
306
|
+
get: async () => {
|
|
307
|
+
const resp = await this.session.get("/gradebook");
|
|
308
|
+
return parseGradebookHtml(await resp.text());
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
printouts = {
|
|
312
|
+
/** PDF documents the school offers (Tulosteet): report cards, absence reports… */
|
|
313
|
+
list: async () => {
|
|
314
|
+
const resp = await this.session.get("/printouts");
|
|
315
|
+
return parsePrintoutsHtml(await resp.text());
|
|
316
|
+
},
|
|
317
|
+
/** Download one printout; the response body is the PDF. */
|
|
318
|
+
fetch: async (id) => {
|
|
319
|
+
const printout = (await this.printouts.list()).find((candidate) => candidate.id === id);
|
|
320
|
+
if (!printout)
|
|
321
|
+
throw new Error(`Printout "${id}" not found`);
|
|
322
|
+
const response = await this.session.get(printout.path, undefined, { timeoutMs: DOWNLOAD_TIMEOUT_MS });
|
|
323
|
+
return { printout, response: response };
|
|
198
324
|
},
|
|
199
325
|
};
|
|
200
326
|
overview = {
|
|
@@ -205,93 +331,6 @@ export class WilmaClient {
|
|
|
205
331
|
},
|
|
206
332
|
};
|
|
207
333
|
}
|
|
208
|
-
// Some document hosts serve an HTML viewer page for a sharing URL but return
|
|
209
|
-
// the file itself when a conventional download parameter is present. These are
|
|
210
|
-
// generic retry variants, not provider detection: the original URL is always
|
|
211
|
-
// attempted first, and a variant is tried only after an HTML answer.
|
|
212
|
-
const DOWNLOAD_PARAM_VARIANTS = [
|
|
213
|
-
["download", "1"],
|
|
214
|
-
["dl", "1"],
|
|
215
|
-
];
|
|
216
|
-
function buildDownloadCandidates(rawUrl) {
|
|
217
|
-
const original = new URL(rawUrl);
|
|
218
|
-
const candidates = [original];
|
|
219
|
-
for (const [param, value] of DOWNLOAD_PARAM_VARIANTS) {
|
|
220
|
-
if (!original.searchParams.has(param)) {
|
|
221
|
-
const variant = new URL(original.href);
|
|
222
|
-
variant.searchParams.set(param, value);
|
|
223
|
-
candidates.push(variant);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
return candidates;
|
|
227
|
-
}
|
|
228
|
-
function isHtmlResponse(response) {
|
|
229
|
-
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
230
|
-
return contentType.startsWith("text/html") || contentType.startsWith("application/xhtml");
|
|
231
|
-
}
|
|
232
|
-
// Fetch an external URL the way a signed-out browser would: an isolated
|
|
233
|
-
// in-memory cookie jar, redirects followed across hosts, and no Wilma
|
|
234
|
-
// credentials anywhere. Returns null when every candidate answered with an
|
|
235
|
-
// HTML page instead of a file.
|
|
236
|
-
async function fetchExternalFile(rawUrl) {
|
|
237
|
-
for (const candidate of buildDownloadCandidates(rawUrl)) {
|
|
238
|
-
const response = await fetchFollowingRedirects(candidate);
|
|
239
|
-
if (!response) {
|
|
240
|
-
continue;
|
|
241
|
-
}
|
|
242
|
-
if (response.ok && isHtmlResponse(response)) {
|
|
243
|
-
await response.body?.cancel();
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
return response;
|
|
247
|
-
}
|
|
248
|
-
return null;
|
|
249
|
-
}
|
|
250
|
-
async function fetchFollowingRedirects(initialUrl) {
|
|
251
|
-
const cookieJar = new CookieJar();
|
|
252
|
-
let currentUrl = initialUrl;
|
|
253
|
-
for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
|
|
254
|
-
const headers = new Headers({
|
|
255
|
-
"User-Agent": EXTERNAL_USER_AGENT,
|
|
256
|
-
"Accept": "*/*",
|
|
257
|
-
});
|
|
258
|
-
const cookieHeader = cookieJar.getCookieStringSync(currentUrl.href);
|
|
259
|
-
if (cookieHeader) {
|
|
260
|
-
headers.set("Cookie", cookieHeader);
|
|
261
|
-
}
|
|
262
|
-
let response;
|
|
263
|
-
try {
|
|
264
|
-
response = await fetch(currentUrl, { headers, redirect: "manual" });
|
|
265
|
-
}
|
|
266
|
-
catch (err) {
|
|
267
|
-
throw wrapNetworkError(err, currentUrl.href);
|
|
268
|
-
}
|
|
269
|
-
const setCookies = response.headers
|
|
270
|
-
.getSetCookie?.() ?? [];
|
|
271
|
-
for (const cookie of setCookies) {
|
|
272
|
-
cookieJar.setCookieSync(cookie, currentUrl.href, { ignoreError: true });
|
|
273
|
-
}
|
|
274
|
-
if (!setCookies.length) {
|
|
275
|
-
const cookie = response.headers.get("set-cookie");
|
|
276
|
-
if (cookie)
|
|
277
|
-
cookieJar.setCookieSync(cookie, currentUrl.href, { ignoreError: true });
|
|
278
|
-
}
|
|
279
|
-
if (response.status < 300 || response.status >= 400) {
|
|
280
|
-
return response;
|
|
281
|
-
}
|
|
282
|
-
const location = response.headers.get("location");
|
|
283
|
-
await response.body?.cancel();
|
|
284
|
-
if (!location) {
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
const nextUrl = new URL(location, currentUrl);
|
|
288
|
-
if (nextUrl.protocol !== "https:" && nextUrl.protocol !== "http:") {
|
|
289
|
-
return null;
|
|
290
|
-
}
|
|
291
|
-
currentUrl = nextUrl;
|
|
292
|
-
}
|
|
293
|
-
throw new Error("External resource exceeded the redirect limit");
|
|
294
|
-
}
|
|
295
334
|
function safeJson(text) {
|
|
296
335
|
try {
|
|
297
336
|
return JSON.parse(text);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Response } from "undici";
|
|
2
|
+
/** How long a download may stall (before answering, or mid-file) before it fails. */
|
|
3
|
+
export declare const DOWNLOAD_TIMEOUT_MS = 60000;
|
|
4
|
+
export declare function isBlockedAddress(address: string): boolean;
|
|
5
|
+
/** Refuse URLs that point at private or special addresses, or at unusual ports. */
|
|
6
|
+
export declare function assertPublicUrl(url: URL): void;
|
|
7
|
+
export declare function isHtmlResponse(response: {
|
|
8
|
+
headers: {
|
|
9
|
+
get(name: string): string | null;
|
|
10
|
+
};
|
|
11
|
+
}): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Fetch an external URL the way a signed-out browser would: an isolated
|
|
14
|
+
* in-memory cookie jar, redirects followed across hosts, and no Wilma
|
|
15
|
+
* credentials anywhere. Returns null when every candidate answered with an
|
|
16
|
+
* HTML page instead of a file; throws for HTTP errors and refused addresses.
|
|
17
|
+
*/
|
|
18
|
+
export declare function fetchExternalFile(rawUrl: string): Promise<Response | null>;
|