@wilm-ai/wilma-client 1.5.2 → 1.6.1
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 +11 -4
- package/dist/parsers/attendance.js +66 -19
- 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 +42 -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/dist/parsers/exams.js
CHANGED
|
@@ -27,6 +27,9 @@ export function parseExamsHtml(html) {
|
|
|
27
27
|
const examDate = new Date(Date.UTC(y, m - 1, d, 12, 0, 0));
|
|
28
28
|
// Also store as local date string to avoid any serialization issues
|
|
29
29
|
const dateString = `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
|
30
|
+
// "Pe 30.10.2026 Klo 08:30": some exams have a start time.
|
|
31
|
+
const timeMatch = /klo\s*(\d{1,2})[:.](\d{2})/i.exec(dateText);
|
|
32
|
+
const time = timeMatch ? `${timeMatch[1].padStart(2, "0")}:${timeMatch[2]}` : null;
|
|
30
33
|
// Compact whitespace first to normalize the text before parsing
|
|
31
34
|
const subjDesc = compactText($(firstCells.get(1)).text());
|
|
32
35
|
let subject = subjDesc;
|
|
@@ -66,12 +69,46 @@ export function parseExamsHtml(html) {
|
|
|
66
69
|
description: description ? compactText(description) : null,
|
|
67
70
|
teacher: teacher ? compactText(teacher) : null,
|
|
68
71
|
notes: notes ? compactText(notes) : null,
|
|
72
|
+
time,
|
|
69
73
|
fetchedAt: now,
|
|
70
74
|
});
|
|
71
75
|
autoId += 1;
|
|
72
76
|
});
|
|
73
77
|
return exams;
|
|
74
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Add start times from the exam calendar to upcoming exams (the front page's
|
|
81
|
+
* exam list has dates only). An exam is matched by date and its course code
|
|
82
|
+
* (e.g. "MA_91" appears in the calendar entry), or by date alone when it is
|
|
83
|
+
* the only exam that day in both lists.
|
|
84
|
+
*/
|
|
85
|
+
export function addExamTimes(upcoming, calendar) {
|
|
86
|
+
return upcoming.map((exam) => {
|
|
87
|
+
const sameDay = calendar.filter((entry) => entry.dateString === exam.date);
|
|
88
|
+
const code = exam.subjectCode ? new RegExp(`(^|\\s)${escapeRegExp(exam.subjectCode)}(\\s|$|[.,:])`) : null;
|
|
89
|
+
const text = (entry) => `${entry.description ?? ""} ${entry.subject}`;
|
|
90
|
+
let byCode = code ? sameDay.filter((entry) => code.test(text(entry))) : [];
|
|
91
|
+
if (byCode.length > 1) {
|
|
92
|
+
// Several exams of one course that day: the exam's name tells them apart.
|
|
93
|
+
const nameWords = words(exam.name);
|
|
94
|
+
const scored = byCode.map((entry) => {
|
|
95
|
+
const entryWords = new Set(words(text(entry)));
|
|
96
|
+
return { entry, score: nameWords.filter((word) => entryWords.has(word)).length };
|
|
97
|
+
});
|
|
98
|
+
const best = Math.max(...scored.map((s) => s.score));
|
|
99
|
+
byCode = best > 0 ? scored.filter((s) => s.score === best).map((s) => s.entry) : byCode;
|
|
100
|
+
}
|
|
101
|
+
const onlyOne = sameDay.length === 1 && upcoming.filter((other) => other.date === exam.date).length === 1;
|
|
102
|
+
const match = byCode.length === 1 ? byCode[0] : onlyOne ? sameDay[0] : null;
|
|
103
|
+
return { ...exam, time: match?.time ?? null };
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function words(value) {
|
|
107
|
+
return value.toLowerCase().match(/[\p{L}\d]{3,}/gu) ?? [];
|
|
108
|
+
}
|
|
109
|
+
function escapeRegExp(value) {
|
|
110
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
111
|
+
}
|
|
75
112
|
function compactText(value) {
|
|
76
113
|
return value.replace(/\s+/g, " ").trim();
|
|
77
114
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { GradebookEntry } from "../types.js";
|
|
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 declare function parseGradebookHtml(html: string): GradebookEntry[];
|
|
@@ -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,45 @@ 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
|
+
// The caption is the course code, sometimes followed by the name
|
|
102
|
+
// ("BI_ Biologia", or just "yRUB1.1" in upper secondary schools); the
|
|
103
|
+
// name alone is in courseName. Lessons without a course (lunch, a
|
|
104
|
+
// class teacher's hour) have only a caption.
|
|
105
|
+
const caption = (module.caption ?? "").trim();
|
|
106
|
+
const subject = (module.courseName ?? "").trim() || caption;
|
|
107
|
+
const teachers = module.teachers ?? [];
|
|
108
|
+
const rooms = (module.rooms ?? []).map((room) => (room.abbreviation ?? room.caption ?? "").trim()).filter(Boolean);
|
|
109
|
+
lessons.push({
|
|
110
|
+
date,
|
|
111
|
+
dayOfWeek: new Date(`${date}T12:00:00Z`).getUTCDay(),
|
|
112
|
+
start,
|
|
113
|
+
end,
|
|
114
|
+
subject,
|
|
115
|
+
subjectCode: firstSubjectToken(caption),
|
|
116
|
+
teacher: teachers.map((t) => [t.lastname, t.firstname].filter(Boolean).join(" ")).join(", "),
|
|
117
|
+
teacherCode: teachers.map((t) => t.abbreviation ?? "").filter(Boolean).join(", "),
|
|
118
|
+
groupId: Number(module.id) || 0,
|
|
119
|
+
room: rooms.length ? rooms.join(", ") : null,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return lessons.sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start));
|
|
125
|
+
}
|
|
@@ -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()];
|