@wilm-ai/wilma-client 1.4.2 → 1.5.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/dist/client.d.ts +13 -1
- package/dist/client.js +108 -2
- package/dist/index.d.ts +1 -1
- package/dist/parsers/news.d.ts +2 -2
- package/dist/parsers/news.js +80 -6
- package/dist/types.d.ts +9 -0
- package/package.json +9 -9
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/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Exam, Message, MessageFolder, NewsItem, OverviewData, WilmaProfile, StudentInfo, LessonNote } from "./types.js";
|
|
1
|
+
import type { Exam, Message, MessageFolder, NewsItem, NewsResource, OverviewData, WilmaProfile, StudentInfo, LessonNote } from "./types.js";
|
|
2
|
+
import { type Response } from "undici";
|
|
2
3
|
export type MfaCallback = (formkey: string) => Promise<string>;
|
|
3
4
|
export declare class WilmaClient {
|
|
4
5
|
private session;
|
|
@@ -12,6 +13,17 @@ export declare class WilmaClient {
|
|
|
12
13
|
news: {
|
|
13
14
|
list: () => Promise<NewsItem[]>;
|
|
14
15
|
get: (newsId: number) => Promise<NewsItem>;
|
|
16
|
+
fetchResource: (newsId: number, resourceId: string, options?: {
|
|
17
|
+
item?: NewsItem;
|
|
18
|
+
}) => Promise<{
|
|
19
|
+
resource: NewsResource;
|
|
20
|
+
response: Response;
|
|
21
|
+
status: "fetched";
|
|
22
|
+
} | {
|
|
23
|
+
resource: NewsResource;
|
|
24
|
+
response: null;
|
|
25
|
+
status: "not_a_file";
|
|
26
|
+
}>;
|
|
15
27
|
};
|
|
16
28
|
exams: {
|
|
17
29
|
list: (opts?: {
|
package/dist/client.js
CHANGED
|
@@ -7,6 +7,10 @@ import { parseAttendanceHtml } from "./parsers/attendance.js";
|
|
|
7
7
|
import { parseOverview } from "./parsers/overview.js";
|
|
8
8
|
import { parseScheduleHtml } from "./parsers/schedule.js";
|
|
9
9
|
import { parseStudentsFromHome } from "./parsers/students.js";
|
|
10
|
+
import { CookieJar } from "tough-cookie";
|
|
11
|
+
import { fetch, Headers } from "undici";
|
|
12
|
+
const EXTERNAL_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
|
|
13
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36";
|
|
10
14
|
export class WilmaClient {
|
|
11
15
|
session;
|
|
12
16
|
constructor(session) {
|
|
@@ -104,10 +108,31 @@ export class WilmaClient {
|
|
|
104
108
|
if (!contentType.includes("text/html")) {
|
|
105
109
|
const data = safeJson(text);
|
|
106
110
|
if (Object.keys(data).length) {
|
|
107
|
-
return parseNewsDetailJson(newsId, data);
|
|
111
|
+
return parseNewsDetailJson(newsId, data, resp.url);
|
|
108
112
|
}
|
|
109
113
|
}
|
|
110
|
-
return parseNewsDetailHtml(text, newsId);
|
|
114
|
+
return parseNewsDetailHtml(text, newsId, resp.url);
|
|
115
|
+
},
|
|
116
|
+
fetchResource: async (newsId, resourceId, options) => {
|
|
117
|
+
const item = options?.item ?? (await this.news.get(newsId));
|
|
118
|
+
const resource = item.resources?.find((candidate) => candidate.id === resourceId);
|
|
119
|
+
if (!resource) {
|
|
120
|
+
throw new Error(`News resource "${resourceId}" not found`);
|
|
121
|
+
}
|
|
122
|
+
if (resource.authContext === "wilma") {
|
|
123
|
+
const url = new URL(resource.url);
|
|
124
|
+
const response = await this.session.get(`${url.pathname}${url.search}`);
|
|
125
|
+
if (isHtmlResponse(response)) {
|
|
126
|
+
await response.body?.cancel();
|
|
127
|
+
return { resource, response: null, status: "not_a_file" };
|
|
128
|
+
}
|
|
129
|
+
return { resource, response: response, status: "fetched" };
|
|
130
|
+
}
|
|
131
|
+
const response = await fetchExternalFile(resource.url);
|
|
132
|
+
if (!response) {
|
|
133
|
+
return { resource, response: null, status: "not_a_file" };
|
|
134
|
+
}
|
|
135
|
+
return { resource, response, status: "fetched" };
|
|
111
136
|
},
|
|
112
137
|
};
|
|
113
138
|
exams = {
|
|
@@ -159,6 +184,87 @@ export class WilmaClient {
|
|
|
159
184
|
},
|
|
160
185
|
};
|
|
161
186
|
}
|
|
187
|
+
// Some document hosts serve an HTML viewer page for a sharing URL but return
|
|
188
|
+
// the file itself when a conventional download parameter is present. These are
|
|
189
|
+
// generic retry variants, not provider detection: the original URL is always
|
|
190
|
+
// attempted first, and a variant is tried only after an HTML answer.
|
|
191
|
+
const DOWNLOAD_PARAM_VARIANTS = [
|
|
192
|
+
["download", "1"],
|
|
193
|
+
["dl", "1"],
|
|
194
|
+
];
|
|
195
|
+
function buildDownloadCandidates(rawUrl) {
|
|
196
|
+
const original = new URL(rawUrl);
|
|
197
|
+
const candidates = [original];
|
|
198
|
+
for (const [param, value] of DOWNLOAD_PARAM_VARIANTS) {
|
|
199
|
+
if (!original.searchParams.has(param)) {
|
|
200
|
+
const variant = new URL(original.href);
|
|
201
|
+
variant.searchParams.set(param, value);
|
|
202
|
+
candidates.push(variant);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return candidates;
|
|
206
|
+
}
|
|
207
|
+
function isHtmlResponse(response) {
|
|
208
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
209
|
+
return contentType.startsWith("text/html") || contentType.startsWith("application/xhtml");
|
|
210
|
+
}
|
|
211
|
+
// Fetch an external URL the way a signed-out browser would: an isolated
|
|
212
|
+
// in-memory cookie jar, redirects followed across hosts, and no Wilma
|
|
213
|
+
// credentials anywhere. Returns null when every candidate answered with an
|
|
214
|
+
// HTML page instead of a file.
|
|
215
|
+
async function fetchExternalFile(rawUrl) {
|
|
216
|
+
for (const candidate of buildDownloadCandidates(rawUrl)) {
|
|
217
|
+
const response = await fetchFollowingRedirects(candidate);
|
|
218
|
+
if (!response) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (response.ok && isHtmlResponse(response)) {
|
|
222
|
+
await response.body?.cancel();
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
return response;
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
async function fetchFollowingRedirects(initialUrl) {
|
|
230
|
+
const cookieJar = new CookieJar();
|
|
231
|
+
let currentUrl = initialUrl;
|
|
232
|
+
for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
|
|
233
|
+
const headers = new Headers({
|
|
234
|
+
"User-Agent": EXTERNAL_USER_AGENT,
|
|
235
|
+
"Accept": "*/*",
|
|
236
|
+
});
|
|
237
|
+
const cookieHeader = cookieJar.getCookieStringSync(currentUrl.href);
|
|
238
|
+
if (cookieHeader) {
|
|
239
|
+
headers.set("Cookie", cookieHeader);
|
|
240
|
+
}
|
|
241
|
+
const response = await fetch(currentUrl, { headers, redirect: "manual" });
|
|
242
|
+
const setCookies = response.headers
|
|
243
|
+
.getSetCookie?.() ?? [];
|
|
244
|
+
for (const cookie of setCookies) {
|
|
245
|
+
cookieJar.setCookieSync(cookie, currentUrl.href, { ignoreError: true });
|
|
246
|
+
}
|
|
247
|
+
if (!setCookies.length) {
|
|
248
|
+
const cookie = response.headers.get("set-cookie");
|
|
249
|
+
if (cookie)
|
|
250
|
+
cookieJar.setCookieSync(cookie, currentUrl.href, { ignoreError: true });
|
|
251
|
+
}
|
|
252
|
+
if (response.status < 300 || response.status >= 400) {
|
|
253
|
+
return response;
|
|
254
|
+
}
|
|
255
|
+
const location = response.headers.get("location");
|
|
256
|
+
await response.body?.cancel();
|
|
257
|
+
if (!location) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
const nextUrl = new URL(location, currentUrl);
|
|
261
|
+
if (nextUrl.protocol !== "https:" && nextUrl.protocol !== "http:") {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
currentUrl = nextUrl;
|
|
265
|
+
}
|
|
266
|
+
throw new Error("External resource exceeded the redirect limit");
|
|
267
|
+
}
|
|
162
268
|
function safeJson(text) {
|
|
163
269
|
try {
|
|
164
270
|
return JSON.parse(text);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { Exam, ExamGrade, HomeworkItem, LessonNote, Message, MessageFolder, Municipality, NewsItem, OverviewData, ScheduleLesson, TenantDiscoveryResponse, TenantInfo, UpcomingExam, WilmaProfile, StudentInfo, } from "./types.js";
|
|
1
|
+
export type { Exam, ExamGrade, HomeworkItem, LessonNote, Message, MessageFolder, Municipality, NewsItem, NewsResource, NewsResourceAuthContext, OverviewData, ScheduleLesson, TenantDiscoveryResponse, TenantInfo, UpcomingExam, WilmaProfile, StudentInfo, } from "./types.js";
|
|
2
2
|
export { WilmaClient } from "./client.js";
|
|
3
3
|
export type { MfaCallback } from "./client.js";
|
|
4
4
|
export { WilmaSession, AuthenticationError, MfaRequiredError, APIError } from "./session.js";
|
package/dist/parsers/news.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { NewsItem } from "../types.js";
|
|
2
2
|
export declare function parseNewsList(data: unknown): NewsItem[];
|
|
3
|
-
export declare function parseNewsDetailJson(newsId: number, data: Record<string, unknown
|
|
4
|
-
export declare function parseNewsDetailHtml(html: string, newsId: number): NewsItem;
|
|
3
|
+
export declare function parseNewsDetailJson(newsId: number, data: Record<string, unknown>, baseUrl?: string): NewsItem;
|
|
4
|
+
export declare function parseNewsDetailHtml(html: string, newsId: number, baseUrl?: string): NewsItem;
|
|
5
5
|
export declare function parseNewsListHtml(html: string): NewsItem[];
|
package/dist/parsers/news.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as cheerio from "cheerio";
|
|
2
2
|
import { parseWilmaTimestamp } from "./dates.js";
|
|
3
|
+
const FILE_EXTENSION_RE = /\.(?:pdf|docx?|xlsx?|pptx?|odt|ods|odp|rtf|txt|csv|zip|7z|png|jpe?g|gif|webp)$/i;
|
|
3
4
|
export function parseNewsList(data) {
|
|
4
5
|
if (Array.isArray(data)) {
|
|
5
6
|
const now = new Date();
|
|
@@ -24,18 +25,25 @@ export function parseNewsList(data) {
|
|
|
24
25
|
}
|
|
25
26
|
return [];
|
|
26
27
|
}
|
|
27
|
-
export function parseNewsDetailJson(newsId, data) {
|
|
28
|
+
export function parseNewsDetailJson(newsId, data, baseUrl) {
|
|
29
|
+
const content = (data["content"] ?? data["Content"]);
|
|
30
|
+
let resources = [];
|
|
31
|
+
if (content && content.includes("<a")) {
|
|
32
|
+
const $ = cheerio.load(content);
|
|
33
|
+
resources = extractNewsResources($, $.root(), baseUrl);
|
|
34
|
+
}
|
|
28
35
|
return {
|
|
29
36
|
wilmaId: newsId,
|
|
30
37
|
title: String(data["title"] ?? data["Title"] ?? ""),
|
|
31
38
|
subtitle: (data["subtitle"] ?? data["Subtitle"]),
|
|
32
39
|
author: (data["author"] ?? data["Author"]),
|
|
33
40
|
published: parseWilmaTimestamp(data["Published"] ?? data["published"]),
|
|
34
|
-
content
|
|
41
|
+
content,
|
|
42
|
+
resources,
|
|
35
43
|
fetchedAt: new Date(),
|
|
36
44
|
};
|
|
37
45
|
}
|
|
38
|
-
export function parseNewsDetailHtml(html, newsId) {
|
|
46
|
+
export function parseNewsDetailHtml(html, newsId, baseUrl) {
|
|
39
47
|
const $ = cheerio.load(html);
|
|
40
48
|
let title = $("title").text().trim();
|
|
41
49
|
if (title.endsWith(" - Wilma")) {
|
|
@@ -48,10 +56,23 @@ export function parseNewsDetailHtml(html, newsId) {
|
|
|
48
56
|
const subtitleElem = $("p.sub-text, .subtitle").first();
|
|
49
57
|
const subtitle = subtitleElem.length ? subtitleElem.text().trim() : null;
|
|
50
58
|
let content = null;
|
|
51
|
-
|
|
59
|
+
let resources = [];
|
|
60
|
+
// Wilma can hide link-only bulletin content when it renders the target in an
|
|
61
|
+
// iframe. Prefer the dedicated container even when it carries .hidden.
|
|
62
|
+
const dedicatedContent = $("#news-content").first();
|
|
63
|
+
const contentElem = dedicatedContent.length
|
|
64
|
+
? dedicatedContent
|
|
65
|
+
: $(".news-content, .content, .ckeditor, article, .panel-body").first();
|
|
52
66
|
if (contentElem.length) {
|
|
53
|
-
contentElem
|
|
54
|
-
|
|
67
|
+
resources = extractNewsResources($, contentElem, baseUrl);
|
|
68
|
+
const cleanContent = contentElem.clone();
|
|
69
|
+
cleanContent.find("script, style").remove();
|
|
70
|
+
const text = cleanContent.text().trim();
|
|
71
|
+
// A container made entirely from resource anchors has no prose body. Keep
|
|
72
|
+
// the labels in resources instead of duplicating them in content.
|
|
73
|
+
const proseOnly = cleanContent.clone();
|
|
74
|
+
proseOnly.find("a[href]").remove();
|
|
75
|
+
content = proseOnly.text().trim() ? text : null;
|
|
55
76
|
}
|
|
56
77
|
return {
|
|
57
78
|
wilmaId: newsId,
|
|
@@ -60,9 +81,62 @@ export function parseNewsDetailHtml(html, newsId) {
|
|
|
60
81
|
author: null,
|
|
61
82
|
published: null,
|
|
62
83
|
content,
|
|
84
|
+
resources,
|
|
63
85
|
fetchedAt: new Date(),
|
|
64
86
|
};
|
|
65
87
|
}
|
|
88
|
+
function extractNewsResources($, contentElem, baseUrl) {
|
|
89
|
+
const resources = [];
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
contentElem.find("a[href]").each((_, element) => {
|
|
92
|
+
const anchor = $(element);
|
|
93
|
+
const rawHref = (anchor.attr("href") ?? "").trim();
|
|
94
|
+
const url = resolveSafeHttpUrl(rawHref, baseUrl);
|
|
95
|
+
if (!url || seen.has(url.href)) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
seen.add(url.href);
|
|
99
|
+
const authContext = baseUrl && url.origin === new URL(baseUrl).origin ? "wilma" : "external";
|
|
100
|
+
// Only a naming hint — never used to decide whether a download may be
|
|
101
|
+
// attempted. The download attempt itself reveals whether a URL is a file.
|
|
102
|
+
const fileName = FILE_EXTENSION_RE.test(decodeURIComponentSafely(url.pathname))
|
|
103
|
+
? fileNameFromUrl(url)
|
|
104
|
+
: null;
|
|
105
|
+
const label = anchor.text().trim() || fileName || url.host;
|
|
106
|
+
resources.push({
|
|
107
|
+
id: `resource-${resources.length + 1}`,
|
|
108
|
+
label,
|
|
109
|
+
url: url.href,
|
|
110
|
+
authContext,
|
|
111
|
+
fileName,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
return resources;
|
|
115
|
+
}
|
|
116
|
+
function resolveSafeHttpUrl(rawHref, baseUrl) {
|
|
117
|
+
if (!rawHref || rawHref.startsWith("#")) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const url = baseUrl ? new URL(rawHref, baseUrl) : new URL(rawHref);
|
|
122
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url : null;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function fileNameFromUrl(url) {
|
|
129
|
+
const lastSegment = url.pathname.split("/").filter(Boolean).at(-1);
|
|
130
|
+
return lastSegment ? decodeURIComponentSafely(lastSegment) : null;
|
|
131
|
+
}
|
|
132
|
+
function decodeURIComponentSafely(value) {
|
|
133
|
+
try {
|
|
134
|
+
return decodeURIComponent(value);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
66
140
|
export function parseNewsListHtml(html) {
|
|
67
141
|
const $ = cheerio.load(html);
|
|
68
142
|
const now = new Date();
|
package/dist/types.d.ts
CHANGED
|
@@ -19,8 +19,17 @@ export interface NewsItem {
|
|
|
19
19
|
author?: string | null;
|
|
20
20
|
published?: Date | null;
|
|
21
21
|
content?: string | null;
|
|
22
|
+
resources?: NewsResource[];
|
|
22
23
|
fetchedAt: Date;
|
|
23
24
|
}
|
|
25
|
+
export type NewsResourceAuthContext = "external" | "wilma";
|
|
26
|
+
export interface NewsResource {
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
url: string;
|
|
30
|
+
authContext: NewsResourceAuthContext;
|
|
31
|
+
fileName?: string | null;
|
|
32
|
+
}
|
|
24
33
|
export interface Exam {
|
|
25
34
|
wilmaId: number;
|
|
26
35
|
examDate: Date;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wilm-ai/wilma-client",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -14,13 +14,6 @@
|
|
|
14
14
|
"dist",
|
|
15
15
|
"tenant_list.json"
|
|
16
16
|
],
|
|
17
|
-
"scripts": {
|
|
18
|
-
"build": "tsc -p tsconfig.json",
|
|
19
|
-
"lint": "echo 'add lint'",
|
|
20
|
-
"test": "pnpm run build && node test/parser-messages.mjs && node test/parser-schedule.mjs && node test/client-schedule.mjs",
|
|
21
|
-
"test:smoke": "tsc -p tsconfig.json && node test/smoke.mjs",
|
|
22
|
-
"test:live": "tsc -p tsconfig.json && node test/live.spec.mjs"
|
|
23
|
-
},
|
|
24
17
|
"dependencies": {
|
|
25
18
|
"cheerio": "^1.0.0-rc.12",
|
|
26
19
|
"tough-cookie": "^4.1.4",
|
|
@@ -29,5 +22,12 @@
|
|
|
29
22
|
"devDependencies": {
|
|
30
23
|
"typescript": "^5.6.3",
|
|
31
24
|
"@types/tough-cookie": "^4.0.5"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.json",
|
|
28
|
+
"lint": "echo 'add lint'",
|
|
29
|
+
"test": "pnpm run build && node test/parser-messages.mjs && node test/parser-news.mjs && node test/parser-schedule.mjs && node test/client-schedule.mjs && node test/client-news-resources.mjs",
|
|
30
|
+
"test:smoke": "tsc -p tsconfig.json && node test/smoke.mjs",
|
|
31
|
+
"test:live": "tsc -p tsconfig.json && node test/live.spec.mjs"
|
|
32
32
|
}
|
|
33
|
-
}
|
|
33
|
+
}
|