@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
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as cheerio from "cheerio";
|
|
2
|
+
/**
|
|
3
|
+
* The gradebook (Suoritukset): subjects, their syllabi and courses, each with
|
|
4
|
+
* a grade, scope and completion date. Rows carry their depth as a class
|
|
5
|
+
* ("level1", "level2", …), which gives the tree. An empty gradebook (nothing
|
|
6
|
+
* graded yet) has no table and gives [].
|
|
7
|
+
*/
|
|
8
|
+
export function parseGradebookHtml(html) {
|
|
9
|
+
const $ = cheerio.load(html);
|
|
10
|
+
const roots = [];
|
|
11
|
+
const stack = [];
|
|
12
|
+
$("table tbody tr").each((_, row) => {
|
|
13
|
+
const depthMatch = /\blevel(\d+)\b/.exec($(row).attr("class") ?? "");
|
|
14
|
+
const cells = $(row).find("td");
|
|
15
|
+
if (!depthMatch || cells.length < 2)
|
|
16
|
+
return;
|
|
17
|
+
const depth = Number(depthMatch[1]);
|
|
18
|
+
const text = (index) => $(cells[index]).text().replace(/\s+/g, " ").trim();
|
|
19
|
+
const label = text(0);
|
|
20
|
+
if (!label)
|
|
21
|
+
return;
|
|
22
|
+
const codeMatch = /^([\p{L}\d]+_[\p{L}\d.]*)\s+(.*)$/u.exec(label);
|
|
23
|
+
const entry = {
|
|
24
|
+
name: codeMatch ? codeMatch[2] : label,
|
|
25
|
+
code: codeMatch ? codeMatch[1] : null,
|
|
26
|
+
grade: text(1) || null,
|
|
27
|
+
credits: cells.length > 3 ? text(2) || null : null,
|
|
28
|
+
date: isoDate(text(cells.length > 3 ? 3 : 2)),
|
|
29
|
+
children: [],
|
|
30
|
+
};
|
|
31
|
+
while (stack.length && stack[stack.length - 1].depth >= depth)
|
|
32
|
+
stack.pop();
|
|
33
|
+
if (stack.length)
|
|
34
|
+
stack[stack.length - 1].entry.children.push(entry);
|
|
35
|
+
else
|
|
36
|
+
roots.push(entry);
|
|
37
|
+
stack.push({ depth, entry });
|
|
38
|
+
});
|
|
39
|
+
return roots;
|
|
40
|
+
}
|
|
41
|
+
function isoDate(value) {
|
|
42
|
+
const match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(value);
|
|
43
|
+
return match ? `${match[3]}-${match[2].padStart(2, "0")}-${match[1].padStart(2, "0")}` : null;
|
|
44
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readable text from a fragment of Wilma HTML (message and reply bodies):
|
|
3
|
+
* paragraphs and line breaks kept, list items bulleted, and links written as
|
|
4
|
+
* "label (address)" so the address isn't lost.
|
|
5
|
+
*/
|
|
6
|
+
export declare function htmlToText(html: string | null | undefined): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as cheerio from "cheerio";
|
|
2
|
+
const BLOCKS = "p, div, tr, h1, h2, h3, h4, h5, h6, blockquote, table, ul, ol";
|
|
3
|
+
/**
|
|
4
|
+
* Readable text from a fragment of Wilma HTML (message and reply bodies):
|
|
5
|
+
* paragraphs and line breaks kept, list items bulleted, and links written as
|
|
6
|
+
* "label (address)" so the address isn't lost.
|
|
7
|
+
*/
|
|
8
|
+
export function htmlToText(html) {
|
|
9
|
+
if (!html)
|
|
10
|
+
return "";
|
|
11
|
+
const $ = cheerio.load(`<div id="wilmai-root">${html}</div>`);
|
|
12
|
+
const root = $("#wilmai-root");
|
|
13
|
+
root.find("script, style").remove();
|
|
14
|
+
root.find("a[href]").each((_, anchor) => {
|
|
15
|
+
const link = $(anchor);
|
|
16
|
+
const href = (link.attr("href") ?? "").trim();
|
|
17
|
+
const label = link.text().replace(/\s+/g, " ").trim();
|
|
18
|
+
if (!/^(https?:|mailto:)/i.test(href))
|
|
19
|
+
return;
|
|
20
|
+
const address = href.replace(/^mailto:/i, "");
|
|
21
|
+
if (!label)
|
|
22
|
+
link.text(address);
|
|
23
|
+
else if (!label.includes(address) && !address.includes(label))
|
|
24
|
+
link.text(`${label} (${address})`);
|
|
25
|
+
});
|
|
26
|
+
root.find("br").replaceWith("\n");
|
|
27
|
+
root.find("li").each((_, item) => {
|
|
28
|
+
$(item).prepend("- ");
|
|
29
|
+
$(item).append("\n");
|
|
30
|
+
});
|
|
31
|
+
root.find(BLOCKS).each((_, block) => {
|
|
32
|
+
$(block).prepend("\n");
|
|
33
|
+
$(block).append("\n");
|
|
34
|
+
});
|
|
35
|
+
return root
|
|
36
|
+
.text()
|
|
37
|
+
.replace(/ /g, " ")
|
|
38
|
+
.replace(/[ \t]+/g, " ")
|
|
39
|
+
.replace(/ *\n */g, "\n")
|
|
40
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
41
|
+
.trim();
|
|
42
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { Message, MessageFolder } from "../types.js";
|
|
2
2
|
export declare function parseMessagesList(data: unknown, folder: MessageFolder): Message[];
|
|
3
|
+
/**
|
|
4
|
+
* A message thread from `/messages/<id>?format=json`: the message, who it went
|
|
5
|
+
* to, and every reply. Returns null when the data isn't a thread (older Wilma
|
|
6
|
+
* versions answer with the HTML page instead).
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseMessageDetailJson(data: unknown, messageId: number): Message | null;
|
|
3
9
|
export declare function parseMessageDetailHtml(html: string, messageId: number): Message;
|
|
4
|
-
/** Export for debugging - returns raw field names from first message */
|
|
5
|
-
export declare function debugMessageFields(data: unknown): string[];
|
package/dist/parsers/messages.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import * as cheerio from "cheerio";
|
|
2
2
|
import { parseWilmaTimestamp } from "./dates.js";
|
|
3
|
+
import { htmlToText } from "./html-text.js";
|
|
3
4
|
export function parseMessagesList(data, folder) {
|
|
4
5
|
const now = new Date();
|
|
5
6
|
const list = normalizeMessagesList(data);
|
|
6
7
|
return list.flatMap((item) => {
|
|
7
8
|
try {
|
|
8
9
|
const wilmaId = Number(item["id"] ?? item["Id"]);
|
|
10
|
+
// Without a usable id a message can't be opened; skip it rather than carry NaN.
|
|
11
|
+
if (!Number.isInteger(wilmaId) || wilmaId <= 0)
|
|
12
|
+
return [];
|
|
9
13
|
const subject = compactText(String(item["Subject"] ?? item["subject"] ?? ""));
|
|
10
14
|
// Try many possible field names for the timestamp
|
|
11
15
|
const timeValue = item["Time"] ??
|
|
@@ -23,12 +27,17 @@ export function parseMessagesList(data, folder) {
|
|
|
23
27
|
item["created"] ??
|
|
24
28
|
item["CreatedAt"] ??
|
|
25
29
|
item["createdAt"];
|
|
30
|
+
const sender = item["Sender"] ?? item["sender"];
|
|
26
31
|
return [
|
|
27
32
|
{
|
|
28
33
|
wilmaId,
|
|
29
34
|
subject,
|
|
30
35
|
sentAt: parseWilmaTimestamp(timeValue),
|
|
31
36
|
folder,
|
|
37
|
+
senderName: typeof sender === "string" && sender.trim() ? compactText(sender) : null,
|
|
38
|
+
// Wilma marks unopened messages with a truthy Status (shown in bold).
|
|
39
|
+
unread: Boolean(item["Status"]),
|
|
40
|
+
replyCount: Number(item["Replies"]) || 0,
|
|
32
41
|
fetchedAt: now,
|
|
33
42
|
},
|
|
34
43
|
];
|
|
@@ -38,6 +47,51 @@ export function parseMessagesList(data, folder) {
|
|
|
38
47
|
}
|
|
39
48
|
});
|
|
40
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* A message thread from `/messages/<id>?format=json`: the message, who it went
|
|
52
|
+
* to, and every reply. Returns null when the data isn't a thread (older Wilma
|
|
53
|
+
* versions answer with the HTML page instead).
|
|
54
|
+
*/
|
|
55
|
+
export function parseMessageDetailJson(data, messageId) {
|
|
56
|
+
const list = data?.messages;
|
|
57
|
+
const m = Array.isArray(list) ? list[0] : undefined;
|
|
58
|
+
if (!m || typeof m !== "object")
|
|
59
|
+
return null;
|
|
60
|
+
const str = (value) => (typeof value === "string" && value.trim() ? value.trim() : null);
|
|
61
|
+
const recipients = Array.isArray(m["Recipients"])
|
|
62
|
+
? m["Recipients"].map(str).filter((r) => Boolean(r))
|
|
63
|
+
: str(m["Recipient"])
|
|
64
|
+
? [str(m["Recipient"])]
|
|
65
|
+
: null;
|
|
66
|
+
const replies = Array.isArray(m["ReplyList"])
|
|
67
|
+
? m["ReplyList"].flatMap((reply) => {
|
|
68
|
+
if (!reply || typeof reply !== "object")
|
|
69
|
+
return [];
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
id: Number(reply["Id"]) || 0,
|
|
73
|
+
sentAt: parseWilmaTimestamp(reply["TimeStamp"]),
|
|
74
|
+
senderName: str(reply["Sender"]),
|
|
75
|
+
content: htmlToText(str(reply["ContentHtml"])),
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
})
|
|
79
|
+
: [];
|
|
80
|
+
replies.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime());
|
|
81
|
+
return {
|
|
82
|
+
wilmaId: Number(m["Id"]) || messageId,
|
|
83
|
+
subject: compactText(String(m["Subject"] ?? "")),
|
|
84
|
+
sentAt: parseWilmaTimestamp(m["TimeStamp"]),
|
|
85
|
+
folder: String(m["Folder"] ?? "unknown"),
|
|
86
|
+
senderId: typeof m["SenderId"] === "number" ? m["SenderId"] : null,
|
|
87
|
+
senderType: typeof m["SenderType"] === "number" ? m["SenderType"] : null,
|
|
88
|
+
senderName: str(m["Sender"]),
|
|
89
|
+
content: htmlToText(str(m["ContentHtml"])),
|
|
90
|
+
recipients,
|
|
91
|
+
replies,
|
|
92
|
+
fetchedAt: new Date(),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
41
95
|
export function parseMessageDetailHtml(html, messageId) {
|
|
42
96
|
const $ = cheerio.load(html);
|
|
43
97
|
const panelBody = $("div#page-content-area.panel-body");
|
|
@@ -171,14 +225,6 @@ function normalizeMessagesList(data) {
|
|
|
171
225
|
}
|
|
172
226
|
return [];
|
|
173
227
|
}
|
|
174
|
-
/** Export for debugging - returns raw field names from first message */
|
|
175
|
-
export function debugMessageFields(data) {
|
|
176
|
-
const list = normalizeMessagesList(data);
|
|
177
|
-
if (list.length > 0) {
|
|
178
|
-
return Object.keys(list[0]);
|
|
179
|
-
}
|
|
180
|
-
return [];
|
|
181
|
-
}
|
|
182
228
|
function compactText(value) {
|
|
183
229
|
return value.replace(/\s+/g, " ").trim();
|
|
184
230
|
}
|
package/dist/parsers/news.js
CHANGED
|
@@ -7,6 +7,8 @@ export function parseNewsList(data) {
|
|
|
7
7
|
return data.flatMap((item) => {
|
|
8
8
|
try {
|
|
9
9
|
const wilmaId = Number(item["id"] ?? item["Id"]);
|
|
10
|
+
if (!Number.isInteger(wilmaId) || wilmaId <= 0)
|
|
11
|
+
return [];
|
|
10
12
|
const title = String(item["Title"] ?? item["title"] ?? "");
|
|
11
13
|
const published = parseWilmaTimestamp(item["Published"] ?? item["published"]);
|
|
12
14
|
return [
|
|
@@ -26,7 +28,8 @@ export function parseNewsList(data) {
|
|
|
26
28
|
return [];
|
|
27
29
|
}
|
|
28
30
|
export function parseNewsDetailJson(newsId, data, baseUrl) {
|
|
29
|
-
const
|
|
31
|
+
const rawContent = data["content"] ?? data["Content"];
|
|
32
|
+
const content = typeof rawContent === "string" ? rawContent : null;
|
|
30
33
|
let resources = [];
|
|
31
34
|
if (content && content.includes("<a")) {
|
|
32
35
|
const $ = cheerio.load(content);
|
|
@@ -35,7 +38,7 @@ export function parseNewsDetailJson(newsId, data, baseUrl) {
|
|
|
35
38
|
return {
|
|
36
39
|
wilmaId: newsId,
|
|
37
40
|
title: String(data["title"] ?? data["Title"] ?? ""),
|
|
38
|
-
subtitle: (data["subtitle"] ?? data["Subtitle"]),
|
|
41
|
+
subtitle: typeof (data["subtitle"] ?? data["Subtitle"]) === "string" ? (data["subtitle"] ?? data["Subtitle"]) : null,
|
|
39
42
|
author: (data["author"] ?? data["Author"]),
|
|
40
43
|
published: parseWilmaTimestamp(data["Published"] ?? data["published"]),
|
|
41
44
|
content,
|
|
@@ -74,17 +77,31 @@ export function parseNewsDetailHtml(html, newsId, baseUrl) {
|
|
|
74
77
|
proseOnly.find("a[href]").remove();
|
|
75
78
|
content = proseOnly.text().trim() ? text : null;
|
|
76
79
|
}
|
|
80
|
+
// "Sär (Teacher Name) Julkaistu 27.9." — the date (this year's lack the
|
|
81
|
+
// year) and the author's profile link sit together under the bulletin.
|
|
82
|
+
let published = null;
|
|
83
|
+
let author = null;
|
|
84
|
+
const meta = $("p, span, div")
|
|
85
|
+
.filter((_, el) => PUBLISHED_RE.test($(el).text()) && $(el).find("p, div").length === 0)
|
|
86
|
+
.first();
|
|
87
|
+
const publishedMatch = PUBLISHED_RE.exec(meta.text().replace(/\s+/g, " "));
|
|
88
|
+
if (publishedMatch) {
|
|
89
|
+
published = parseWilmaTimestamp(publishedMatch[1]);
|
|
90
|
+
const authorLink = meta.find("a.profile-link").first();
|
|
91
|
+
author = authorLink.attr("title")?.trim() || authorLink.text().trim() || null;
|
|
92
|
+
}
|
|
77
93
|
return {
|
|
78
94
|
wilmaId: newsId,
|
|
79
95
|
title,
|
|
80
96
|
subtitle,
|
|
81
|
-
author
|
|
82
|
-
published
|
|
97
|
+
author,
|
|
98
|
+
published,
|
|
83
99
|
content,
|
|
84
100
|
resources,
|
|
85
101
|
fetchedAt: new Date(),
|
|
86
102
|
};
|
|
87
103
|
}
|
|
104
|
+
const PUBLISHED_RE = /(?:Julkaistu|Publicerad|Published)\s*:?\s*(\d{1,2}\.\d{1,2}\.(?:\d{4})?)/i;
|
|
88
105
|
function extractNewsResources($, contentElem, baseUrl) {
|
|
89
106
|
const resources = [];
|
|
90
107
|
const seen = new Set();
|
|
@@ -143,9 +160,6 @@ export function parseNewsListHtml(html) {
|
|
|
143
160
|
const newsItems = [];
|
|
144
161
|
const seenIds = new Set();
|
|
145
162
|
const headers = $("div.left h2.no-border, h2.no-border");
|
|
146
|
-
if (!headers.length) {
|
|
147
|
-
return newsItems;
|
|
148
|
-
}
|
|
149
163
|
headers.each((_, header) => {
|
|
150
164
|
const headerEl = $(header);
|
|
151
165
|
const timestampRaw = headerEl.text().trim();
|
|
@@ -202,11 +216,51 @@ export function parseNewsListHtml(html) {
|
|
|
202
216
|
subtitle,
|
|
203
217
|
author,
|
|
204
218
|
published,
|
|
219
|
+
// A padlock icon marks a bulletin pinned to the page.
|
|
220
|
+
pinned: container.find(".vismaicon-locked").length > 0,
|
|
221
|
+
archived: false,
|
|
205
222
|
fetchedAt: now,
|
|
206
223
|
});
|
|
207
224
|
}
|
|
208
225
|
sib = sib.next();
|
|
209
226
|
}
|
|
210
227
|
});
|
|
228
|
+
// The side panels list pinned bulletins and older ones (titles only; the
|
|
229
|
+
// date is on each bulletin). Recognised by their heading, in Finnish,
|
|
230
|
+
// Swedish or English.
|
|
231
|
+
$("div.panel-body > h2").each((_, heading) => {
|
|
232
|
+
const label = $(heading).text().trim();
|
|
233
|
+
const pinned = SIDE_PANEL_PINNED.test(label);
|
|
234
|
+
const archived = SIDE_PANEL_OLDER.test(label);
|
|
235
|
+
if (!pinned && !archived)
|
|
236
|
+
return;
|
|
237
|
+
$(heading)
|
|
238
|
+
.parent()
|
|
239
|
+
.find("a[href*='/news/']")
|
|
240
|
+
.each((_, anchor) => {
|
|
241
|
+
const match = /\/news\/(\d+)/.exec($(anchor).attr("href") ?? "");
|
|
242
|
+
const newsId = match ? Number(match[1]) : null;
|
|
243
|
+
if (!newsId)
|
|
244
|
+
return;
|
|
245
|
+
const known = newsItems.find((item) => item.wilmaId === newsId);
|
|
246
|
+
if (known) {
|
|
247
|
+
if (pinned)
|
|
248
|
+
known.pinned = true;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
newsItems.push({
|
|
252
|
+
wilmaId: newsId,
|
|
253
|
+
title: $(anchor).text().trim() || "Untitled News",
|
|
254
|
+
subtitle: null,
|
|
255
|
+
author: null,
|
|
256
|
+
published: null,
|
|
257
|
+
pinned,
|
|
258
|
+
archived,
|
|
259
|
+
fetchedAt: now,
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
211
263
|
return newsItems;
|
|
212
264
|
}
|
|
265
|
+
const SIDE_PANEL_PINNED = /^(pysyvät|permanent|pinned|bestående|fasta|stående)/i;
|
|
266
|
+
const SIDE_PANEL_OLDER = /^(vanhat|vanhemmat|old|older|gamla|äldre|arkiv|archive)/i;
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { OverviewData } from "../types.js";
|
|
2
|
-
|
|
2
|
+
/** `now` decides which exams are upcoming (default: the current time). */
|
|
3
|
+
export declare function parseOverview(raw: unknown, now?: Date): OverviewData;
|
package/dist/parsers/overview.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { finnishDateString } from "../finnish-time.js";
|
|
1
2
|
/* ------------------------------------------------------------------ */
|
|
2
3
|
/* Helpers */
|
|
3
4
|
/* ------------------------------------------------------------------ */
|
|
@@ -23,10 +24,10 @@ function firstScheduleTeacher(teachers) {
|
|
|
23
24
|
/* ------------------------------------------------------------------ */
|
|
24
25
|
/* Main parser */
|
|
25
26
|
/* ------------------------------------------------------------------ */
|
|
26
|
-
|
|
27
|
+
/** `now` decides which exams are upcoming (default: the current time). */
|
|
28
|
+
export function parseOverview(raw, now = new Date()) {
|
|
27
29
|
const data = (raw ?? {});
|
|
28
|
-
const
|
|
29
|
-
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
30
|
+
const today = finnishDateString(now);
|
|
30
31
|
return {
|
|
31
32
|
schedule: parseSchedule(data.Schedule ?? []),
|
|
32
33
|
upcomingExams: parseUpcomingExams(data.Groups ?? [], today),
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as cheerio from "cheerio";
|
|
2
|
+
/** Printouts (Tulosteet): PDF documents the school offers, e.g. report cards or absence reports. */
|
|
3
|
+
export function parsePrintoutsHtml(html) {
|
|
4
|
+
const $ = cheerio.load(html);
|
|
5
|
+
const printouts = [];
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
$("a[href]").each((_, anchor) => {
|
|
8
|
+
const href = $(anchor).attr("href") ?? "";
|
|
9
|
+
const match = /^(?:https?:\/\/[^/]+)?(\/(?:![^/]+\/)?printouts\/(\d+)\.pdf)(?:[?#].*)?$/.exec(href);
|
|
10
|
+
if (!match || seen.has(match[2]))
|
|
11
|
+
return;
|
|
12
|
+
seen.add(match[2]);
|
|
13
|
+
printouts.push({
|
|
14
|
+
id: match[2],
|
|
15
|
+
title: $(anchor).text().replace(/\s+/g, " ").trim() || `Printout ${match[2]}`,
|
|
16
|
+
path: match[1],
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
return printouts;
|
|
20
|
+
}
|
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
import type { ScheduleLesson } from "../types.js";
|
|
2
2
|
export declare function parseScheduleHtml(html: string): ScheduleLesson[];
|
|
3
|
+
/**
|
|
4
|
+
* Lessons from the timetable API (`/api/v1/schedules/timetable`): one entry
|
|
5
|
+
* per date a lesson takes place, with every teacher and room. Field values
|
|
6
|
+
* match the schedule page's ("BI_ Biologia", "Lastname Firstname").
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseTimetableJson(payload: unknown): ScheduleLesson[];
|
package/dist/parsers/schedule.js
CHANGED
|
@@ -81,3 +81,40 @@ function firstTeacher(groups) {
|
|
|
81
81
|
function firstSubjectToken(subject) {
|
|
82
82
|
return subject.trim().split(/\s+/, 1)[0] ?? "";
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Lessons from the timetable API (`/api/v1/schedules/timetable`): one entry
|
|
86
|
+
* per date a lesson takes place, with every teacher and room. Field values
|
|
87
|
+
* match the schedule page's ("BI_ Biologia", "Lastname Firstname").
|
|
88
|
+
*/
|
|
89
|
+
export function parseTimetableJson(payload) {
|
|
90
|
+
if (!Array.isArray(payload))
|
|
91
|
+
return [];
|
|
92
|
+
const lessons = [];
|
|
93
|
+
for (const lesson of payload) {
|
|
94
|
+
const start = (lesson.startAt ?? "").slice(0, 5);
|
|
95
|
+
const end = (lesson.endsAt ?? "").slice(0, 5);
|
|
96
|
+
for (const rawDate of lesson.dates ?? []) {
|
|
97
|
+
const date = rawDate.slice(0, 10);
|
|
98
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
|
|
99
|
+
continue;
|
|
100
|
+
for (const module of lesson.modules ?? []) {
|
|
101
|
+
const subject = (module.caption ?? "").trim();
|
|
102
|
+
const teachers = module.teachers ?? [];
|
|
103
|
+
const rooms = (module.rooms ?? []).map((room) => (room.abbreviation ?? room.caption ?? "").trim()).filter(Boolean);
|
|
104
|
+
lessons.push({
|
|
105
|
+
date,
|
|
106
|
+
dayOfWeek: new Date(`${date}T12:00:00Z`).getUTCDay(),
|
|
107
|
+
start,
|
|
108
|
+
end,
|
|
109
|
+
subject,
|
|
110
|
+
subjectCode: firstSubjectToken(subject),
|
|
111
|
+
teacher: teachers.map((t) => [t.lastname, t.firstname].filter(Boolean).join(" ")).join(", "),
|
|
112
|
+
teacherCode: teachers.map((t) => t.abbreviation ?? "").filter(Boolean).join(", "),
|
|
113
|
+
groupId: Number(module.id) || 0,
|
|
114
|
+
room: rooms.length ? rooms.join(", ") : null,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return lessons.sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start));
|
|
120
|
+
}
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
name: string;
|
|
4
|
-
href: string;
|
|
5
|
-
}
|
|
1
|
+
import type { StudentInfo } from "../types.js";
|
|
2
|
+
export type { StudentInfo };
|
|
6
3
|
export declare function studentNumberFromHref(href: string): string | null;
|
|
7
4
|
export declare function parseStudentFromUrl(url: string): StudentInfo | null;
|
|
8
5
|
/**
|
package/dist/parsers/students.js
CHANGED
|
@@ -46,7 +46,12 @@ function roleType(rec) {
|
|
|
46
46
|
return rec.Type ?? rec.type ?? rec.RoleType ?? rec.roleType;
|
|
47
47
|
}
|
|
48
48
|
function roleName(rec, fallback) {
|
|
49
|
-
|
|
49
|
+
// First non-empty name; an empty Name must not drop the student.
|
|
50
|
+
for (const value of [rec.Name, rec.name, rec.Caption, rec.caption]) {
|
|
51
|
+
if (typeof value === "string" && value.trim())
|
|
52
|
+
return value.trim();
|
|
53
|
+
}
|
|
54
|
+
return fallback;
|
|
50
55
|
}
|
|
51
56
|
function collectStudent(students, studentNumber, name) {
|
|
52
57
|
if (!students.has(studentNumber)) {
|
|
@@ -100,6 +105,8 @@ export function parseStudentsFromAccountsRoles(data) {
|
|
|
100
105
|
export function parseStudentsFromHome(html, pageUrl) {
|
|
101
106
|
const $ = cheerio.load(html);
|
|
102
107
|
const named = new Map();
|
|
108
|
+
// Names from links deeper than a student's root: used only when a student has no root link.
|
|
109
|
+
const deepNamed = new Map();
|
|
103
110
|
const fromHref = new Map();
|
|
104
111
|
$("a[href]").each((_, anchor) => {
|
|
105
112
|
const href = $(anchor).attr("href") ?? "";
|
|
@@ -118,20 +125,28 @@ export function parseStudentsFromHome(html, pageUrl) {
|
|
|
118
125
|
if (!text) {
|
|
119
126
|
return;
|
|
120
127
|
}
|
|
121
|
-
|
|
122
|
-
|
|
128
|
+
// Keywords match whole words only, so a name like "Ella Newsome" isn't
|
|
129
|
+
// mistaken for "news".
|
|
130
|
+
const words = text.toLowerCase().split(/[\s()]+/);
|
|
131
|
+
if (NAV_KEYWORDS.some((kw) => words.includes(kw))) {
|
|
123
132
|
return;
|
|
124
133
|
}
|
|
125
|
-
|
|
126
|
-
|
|
134
|
+
// Menu links go deeper than the student's root (/!123/messages) and their
|
|
135
|
+
// text is usually a section name, so a root link's text wins.
|
|
136
|
+
const target = /\/!\d+\/[^/?#]+/.test(href) ? deepNamed : named;
|
|
137
|
+
if (!target.has(studentNumber)) {
|
|
138
|
+
target.set(studentNumber, {
|
|
127
139
|
studentNumber,
|
|
128
140
|
name: text,
|
|
129
141
|
href,
|
|
130
142
|
});
|
|
131
143
|
}
|
|
132
144
|
});
|
|
133
|
-
if (named.size > 0) {
|
|
134
|
-
|
|
145
|
+
if (named.size > 0 || deepNamed.size > 0) {
|
|
146
|
+
// Every named student, in page order; none dropped for lacking a root link.
|
|
147
|
+
return [...fromHref.keys()]
|
|
148
|
+
.map((number) => named.get(number) ?? deepNamed.get(number))
|
|
149
|
+
.filter((student) => Boolean(student));
|
|
135
150
|
}
|
|
136
151
|
if (fromHref.size > 0) {
|
|
137
152
|
return [...fromHref.values()];
|
package/dist/session.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type RequestInit, type Response } from "undici";
|
|
2
2
|
export declare class AuthenticationError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
3
4
|
}
|
|
4
5
|
export declare class MfaRequiredError extends Error {
|
|
5
6
|
formkey: string;
|
|
@@ -11,24 +12,75 @@ export declare class APIError extends Error {
|
|
|
11
12
|
}
|
|
12
13
|
export declare class WilmaSession {
|
|
13
14
|
private baseUrl;
|
|
14
|
-
private
|
|
15
|
-
private loggedIn;
|
|
16
|
-
private username?;
|
|
17
|
-
private password?;
|
|
15
|
+
private auth;
|
|
18
16
|
private studentNumber?;
|
|
19
17
|
private debug;
|
|
20
18
|
constructor(baseUrl: string, opts?: {
|
|
21
19
|
studentNumber?: string | null;
|
|
22
20
|
debug?: boolean;
|
|
23
21
|
});
|
|
22
|
+
/** The login's cookies, so another process can resume the session (see resumeState). */
|
|
23
|
+
exportState(): string;
|
|
24
|
+
/**
|
|
25
|
+
* Continue a session saved by exportState() instead of logging in. If Wilma
|
|
26
|
+
* has ended it meanwhile, the first request notices (401/403 or the login
|
|
27
|
+
* page) and logs in again with these credentials. Returns false when the
|
|
28
|
+
* state holds no session to resume.
|
|
29
|
+
*/
|
|
30
|
+
resumeState(state: string, username: string, password: string): boolean;
|
|
31
|
+
/** Called after every successful login (first or again). */
|
|
32
|
+
onLogin(callback: (() => void) | undefined): void;
|
|
33
|
+
/** Used to answer the two-step verification challenge when the session has to log in again. */
|
|
34
|
+
setMfaCallback(onMfa?: (formkey: string) => Promise<string>): void;
|
|
35
|
+
/**
|
|
36
|
+
* A session for another student on the same account. It shares this
|
|
37
|
+
* session's login (cookies and credentials), so no new login is needed —
|
|
38
|
+
* Wilma picks the student from the "/!<number>/" path prefix.
|
|
39
|
+
*/
|
|
40
|
+
forStudent(studentNumber: string | null): WilmaSession;
|
|
24
41
|
get urlPrefix(): string | null;
|
|
25
42
|
getPrefixedPath(path: string): string;
|
|
26
43
|
login(username: string, password: string): Promise<void>;
|
|
27
44
|
submitMfaCode(formkey: string, otpCode: string): Promise<void>;
|
|
28
45
|
private getLoginFormFields;
|
|
29
|
-
|
|
30
|
-
|
|
46
|
+
/**
|
|
47
|
+
* An authenticated request to this Wilma. Follows redirects within Wilma
|
|
48
|
+
* only; a redirect to another site is an error unless `externalRedirect`
|
|
49
|
+
* is "return" (then the 3xx response is handed back, e.g. so a download can
|
|
50
|
+
* continue without Wilma credentials).
|
|
51
|
+
*/
|
|
52
|
+
request(path: string, init?: RequestInit, opts?: {
|
|
53
|
+
externalRedirect?: "error" | "return"; /** Idle timeout, e.g. longer for downloads. */
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
}): Promise<Response>;
|
|
56
|
+
/** A redirect to this Wilma's login page: the session is gone. */
|
|
57
|
+
private isLoginRedirect;
|
|
58
|
+
/**
|
|
59
|
+
* Is this login still live? A cancelled session gets 401/403 everywhere;
|
|
60
|
+
* a live one answers the account roles API (or 404 on Wilmas without it).
|
|
61
|
+
* Lets a 403 on one item be told apart from a cancelled session without
|
|
62
|
+
* logging in again (which would log the parent out of their own browser).
|
|
63
|
+
*/
|
|
64
|
+
private sessionAlive;
|
|
65
|
+
/**
|
|
66
|
+
* Answer a two-step verification challenge. If Wilma rejects the code (most
|
|
67
|
+
* likely because it was already used — logins less than 30 seconds apart
|
|
68
|
+
* share one), ask once more for the same challenge; the callback then
|
|
69
|
+
* waits for a fresh code.
|
|
70
|
+
*/
|
|
71
|
+
answerMfa(formkey: string, onMfa: (formkey: string) => Promise<string>): Promise<void>;
|
|
72
|
+
/** Log in again, once for everyone sharing this login even if many requests notice at the same time. */
|
|
73
|
+
private relogin;
|
|
74
|
+
get(path: string, init?: RequestInit, opts?: {
|
|
75
|
+
externalRedirect?: "error" | "return";
|
|
76
|
+
timeoutMs?: number;
|
|
77
|
+
}): Promise<Response>;
|
|
31
78
|
post(path: string, init?: RequestInit): Promise<Response>;
|
|
32
79
|
private getLoginToken;
|
|
80
|
+
/**
|
|
81
|
+
* One request to this Wilma, following redirects within Wilma hop by hop
|
|
82
|
+
* (cookies are stored per hop, and only for Wilma). A redirect to another
|
|
83
|
+
* origin is returned as-is: Wilma's cookies never travel there.
|
|
84
|
+
*/
|
|
33
85
|
private rawRequest;
|
|
34
86
|
}
|