@wilm-ai/wilma-client 1.5.1 → 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 +183 -124
- 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 +12 -6
- package/dist/parsers/students.js +128 -11
- 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,6 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import type { StudentInfo } from "../types.js";
|
|
2
|
+
export type { StudentInfo };
|
|
3
|
+
export declare function studentNumberFromHref(href: string): string | null;
|
|
4
|
+
export declare function parseStudentFromUrl(url: string): StudentInfo | null;
|
|
5
|
+
/**
|
|
6
|
+
* Parse GET /api/v1/accounts/me/roles.
|
|
7
|
+
* Accepts `{ payload: AccountRole[] }` or a raw array.
|
|
8
|
+
* Skips the account itself (`type === "passwd"` / type 7). Guardian (and other
|
|
9
|
+
* non-passwd) roles contribute a student from `slug` + `name`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseStudentsFromAccountsRoles(data: unknown): StudentInfo[];
|
|
12
|
+
export declare function parseStudentsFromHome(html: string, pageUrl?: string): StudentInfo[];
|
package/dist/parsers/students.js
CHANGED
|
@@ -12,33 +12,150 @@ const NAV_KEYWORDS = [
|
|
|
12
12
|
"printouts",
|
|
13
13
|
"news",
|
|
14
14
|
];
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
const STUDENT_HREF_RE = /\/!(\d+)(?:\/|$)/;
|
|
16
|
+
export function studentNumberFromHref(href) {
|
|
17
|
+
const match = STUDENT_HREF_RE.exec(href);
|
|
18
|
+
return match ? match[1] : null;
|
|
19
|
+
}
|
|
20
|
+
function isPasswdAccountRole(type) {
|
|
21
|
+
return type === "passwd" || type === 7 || type === "7";
|
|
22
|
+
}
|
|
23
|
+
function studentNumberFromSlug(slug) {
|
|
24
|
+
const trimmed = slug.trim();
|
|
25
|
+
if (!trimmed) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (/^\d+$/.test(trimmed)) {
|
|
29
|
+
return trimmed;
|
|
30
|
+
}
|
|
31
|
+
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
32
|
+
return studentNumberFromHref(withSlash);
|
|
33
|
+
}
|
|
34
|
+
export function parseStudentFromUrl(url) {
|
|
35
|
+
const studentNumber = studentNumberFromHref(url);
|
|
36
|
+
if (!studentNumber) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
studentNumber,
|
|
41
|
+
name: studentNumber,
|
|
42
|
+
href: `/!${studentNumber}/`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function roleType(rec) {
|
|
46
|
+
return rec.Type ?? rec.type ?? rec.RoleType ?? rec.roleType;
|
|
47
|
+
}
|
|
48
|
+
function roleName(rec, fallback) {
|
|
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;
|
|
55
|
+
}
|
|
56
|
+
function collectStudent(students, studentNumber, name) {
|
|
57
|
+
if (!students.has(studentNumber)) {
|
|
58
|
+
students.set(studentNumber, {
|
|
59
|
+
studentNumber,
|
|
60
|
+
name,
|
|
61
|
+
href: `/!${studentNumber}/`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Parse GET /api/v1/accounts/me/roles.
|
|
67
|
+
* Accepts `{ payload: AccountRole[] }` or a raw array.
|
|
68
|
+
* Skips the account itself (`type === "passwd"` / type 7). Guardian (and other
|
|
69
|
+
* non-passwd) roles contribute a student from `slug` + `name`.
|
|
70
|
+
*/
|
|
71
|
+
export function parseStudentsFromAccountsRoles(data) {
|
|
17
72
|
const students = new Map();
|
|
18
|
-
|
|
73
|
+
let roles = [];
|
|
74
|
+
if (Array.isArray(data)) {
|
|
75
|
+
roles = data;
|
|
76
|
+
}
|
|
77
|
+
else if (data && typeof data === "object") {
|
|
78
|
+
const rec = data;
|
|
79
|
+
const payload = rec.payload ?? rec.Payload;
|
|
80
|
+
if (Array.isArray(payload)) {
|
|
81
|
+
roles = payload;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const item of roles) {
|
|
85
|
+
if (!item || typeof item !== "object") {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const rec = item;
|
|
89
|
+
if (isPasswdAccountRole(roleType(rec))) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const slug = String(rec.slug ?? rec.Slug ?? "");
|
|
93
|
+
const studentNumber = studentNumberFromSlug(slug);
|
|
94
|
+
if (!studentNumber) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const name = roleName(rec, studentNumber);
|
|
98
|
+
if (!name) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
collectStudent(students, studentNumber, name);
|
|
102
|
+
}
|
|
103
|
+
return [...students.values()];
|
|
104
|
+
}
|
|
105
|
+
export function parseStudentsFromHome(html, pageUrl) {
|
|
106
|
+
const $ = cheerio.load(html);
|
|
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();
|
|
110
|
+
const fromHref = new Map();
|
|
111
|
+
$("a[href]").each((_, anchor) => {
|
|
19
112
|
const href = $(anchor).attr("href") ?? "";
|
|
20
|
-
const
|
|
21
|
-
if (!
|
|
113
|
+
const studentNumber = studentNumberFromHref(href);
|
|
114
|
+
if (!studentNumber) {
|
|
22
115
|
return;
|
|
23
116
|
}
|
|
24
|
-
|
|
117
|
+
fromHref.set(studentNumber, {
|
|
118
|
+
studentNumber,
|
|
119
|
+
name: studentNumber,
|
|
120
|
+
href,
|
|
121
|
+
});
|
|
25
122
|
const cloned = $(anchor).clone();
|
|
26
123
|
cloned.find("small, span.lem").remove();
|
|
27
124
|
const text = cloned.text().trim();
|
|
28
125
|
if (!text) {
|
|
29
126
|
return;
|
|
30
127
|
}
|
|
31
|
-
|
|
32
|
-
|
|
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))) {
|
|
33
132
|
return;
|
|
34
133
|
}
|
|
35
|
-
|
|
36
|
-
|
|
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, {
|
|
37
139
|
studentNumber,
|
|
38
140
|
name: text,
|
|
39
141
|
href,
|
|
40
142
|
});
|
|
41
143
|
}
|
|
42
144
|
});
|
|
43
|
-
|
|
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));
|
|
150
|
+
}
|
|
151
|
+
if (fromHref.size > 0) {
|
|
152
|
+
return [...fromHref.values()];
|
|
153
|
+
}
|
|
154
|
+
if (pageUrl) {
|
|
155
|
+
const fromUrl = parseStudentFromUrl(pageUrl);
|
|
156
|
+
if (fromUrl) {
|
|
157
|
+
return [fromUrl];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return [];
|
|
44
161
|
}
|