@wilm-ai/wilma-client 1.5.0 → 1.5.2
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/dist/client.js +31 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/network-error.d.ts +39 -0
- package/dist/network-error.js +171 -0
- package/dist/parsers/students.d.ts +10 -1
- package/dist/parsers/students.js +111 -9
- package/dist/session.js +13 -4
- package/package.json +9 -9
- package/LICENSE +0 -21
package/dist/client.js
CHANGED
|
@@ -6,9 +6,10 @@ import { parseExamsHtml } from "./parsers/exams.js";
|
|
|
6
6
|
import { parseAttendanceHtml } from "./parsers/attendance.js";
|
|
7
7
|
import { parseOverview } from "./parsers/overview.js";
|
|
8
8
|
import { parseScheduleHtml } from "./parsers/schedule.js";
|
|
9
|
-
import { parseStudentsFromHome } from "./parsers/students.js";
|
|
9
|
+
import { parseStudentsFromAccountsRoles, parseStudentsFromHome } from "./parsers/students.js";
|
|
10
10
|
import { CookieJar } from "tough-cookie";
|
|
11
11
|
import { fetch, Headers } from "undici";
|
|
12
|
+
import { NetworkError, wrapNetworkError } from "./network-error.js";
|
|
12
13
|
const EXTERNAL_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
|
|
13
14
|
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36";
|
|
14
15
|
export class WilmaClient {
|
|
@@ -36,7 +37,9 @@ export class WilmaClient {
|
|
|
36
37
|
return new WilmaClient(session);
|
|
37
38
|
}
|
|
38
39
|
static async listStudents(profile, onMfaRequired) {
|
|
39
|
-
const session = new WilmaSession(profile.baseUrl
|
|
40
|
+
const session = new WilmaSession(profile.baseUrl, {
|
|
41
|
+
debug: profile.debug ?? false,
|
|
42
|
+
});
|
|
40
43
|
try {
|
|
41
44
|
await session.login(profile.username, profile.password);
|
|
42
45
|
}
|
|
@@ -49,9 +52,27 @@ export class WilmaClient {
|
|
|
49
52
|
throw err;
|
|
50
53
|
}
|
|
51
54
|
}
|
|
55
|
+
try {
|
|
56
|
+
const accountsResp = await session.get("/api/v1/accounts/me/roles");
|
|
57
|
+
const accountsText = await accountsResp.text();
|
|
58
|
+
const fromAccounts = parseStudentsFromAccountsRoles(safeJson(accountsText));
|
|
59
|
+
if (fromAccounts.length > 0) {
|
|
60
|
+
return fromAccounts;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
if (err instanceof NetworkError) {
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
// API missing or not JSON (e.g. 404 on older Wilma)
|
|
68
|
+
}
|
|
52
69
|
const resp = await session.get("/");
|
|
53
70
|
const html = await resp.text();
|
|
54
|
-
|
|
71
|
+
const fromHome = parseStudentsFromHome(html, resp.url);
|
|
72
|
+
if (fromHome.length > 0) {
|
|
73
|
+
return fromHome;
|
|
74
|
+
}
|
|
75
|
+
return [];
|
|
55
76
|
}
|
|
56
77
|
messages = {
|
|
57
78
|
list: async (folder = "inbox") => {
|
|
@@ -238,7 +259,13 @@ async function fetchFollowingRedirects(initialUrl) {
|
|
|
238
259
|
if (cookieHeader) {
|
|
239
260
|
headers.set("Cookie", cookieHeader);
|
|
240
261
|
}
|
|
241
|
-
|
|
262
|
+
let response;
|
|
263
|
+
try {
|
|
264
|
+
response = await fetch(currentUrl, { headers, redirect: "manual" });
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
throw wrapNetworkError(err, currentUrl.href);
|
|
268
|
+
}
|
|
242
269
|
const setCookies = response.headers
|
|
243
270
|
.getSetCookie?.() ?? [];
|
|
244
271
|
for (const cookie of setCookies) {
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export type { Exam, ExamGrade, HomeworkItem, LessonNote, Message, MessageFolder,
|
|
|
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";
|
|
5
|
+
export { NetworkError, describeNetworkCode, extractCauseCode, wrapNetworkError, } from "./network-error.js";
|
|
5
6
|
export { loadTenantDiscovery, listTenants, searchTenantsByMunicipality, findTenantByUrl, } from "./tenants.js";
|
|
6
7
|
export { parseWilmaTimestamp } from "./parsers/dates.js";
|
|
7
8
|
export { parseStudentsFromHome } from "./parsers/students.js";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { WilmaClient } from "./client.js";
|
|
2
2
|
export { WilmaSession, AuthenticationError, MfaRequiredError, APIError } from "./session.js";
|
|
3
|
+
export { NetworkError, describeNetworkCode, extractCauseCode, wrapNetworkError, } from "./network-error.js";
|
|
3
4
|
export { loadTenantDiscovery, listTenants, searchTenantsByMunicipality, findTenantByUrl, } from "./tenants.js";
|
|
4
5
|
export { parseWilmaTimestamp } from "./parsers/dates.js";
|
|
5
6
|
export { parseStudentsFromHome } from "./parsers/students.js";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classification of network-level request failures.
|
|
3
|
+
*
|
|
4
|
+
* `fetch` rejects with an opaque `TypeError: fetch failed` and puts the real
|
|
5
|
+
* reason on `error.cause`. Reporting only `error.message` therefore tells the
|
|
6
|
+
* user nothing at all — the most common real cause, a TLS-intercepting proxy on
|
|
7
|
+
* the network, is indistinguishable from being offline.
|
|
8
|
+
*
|
|
9
|
+
* This module walks the cause chain, extracts the underlying code, and pairs it
|
|
10
|
+
* with actionable remediation text.
|
|
11
|
+
*/
|
|
12
|
+
/** A request that never produced an HTTP response (DNS, TCP or TLS failure). */
|
|
13
|
+
export declare class NetworkError extends Error {
|
|
14
|
+
/** Underlying cause code, e.g. `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`. */
|
|
15
|
+
readonly code?: string;
|
|
16
|
+
/** Multi-line remediation text, when the cause is recognised. */
|
|
17
|
+
readonly hint?: string;
|
|
18
|
+
/** Origin that failed (scheme and host only — never a path). */
|
|
19
|
+
readonly origin?: string;
|
|
20
|
+
constructor(message: string, options?: {
|
|
21
|
+
code?: string;
|
|
22
|
+
hint?: string;
|
|
23
|
+
origin?: string;
|
|
24
|
+
cause?: unknown;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** Human-readable summary plus remediation for a known cause code. */
|
|
28
|
+
export declare function describeNetworkCode(code: string | undefined, origin?: string): {
|
|
29
|
+
summary: string;
|
|
30
|
+
hint?: string;
|
|
31
|
+
};
|
|
32
|
+
/** Walk the cause chain for the first string `code` property. */
|
|
33
|
+
export declare function extractCauseCode(error: unknown, maxDepth?: number): string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Convert a thrown `fetch` rejection into a `NetworkError` carrying an
|
|
36
|
+
* actionable hint. Anything that is not a transport failure is returned
|
|
37
|
+
* unchanged, so callers can `throw wrapNetworkError(err, url)` safely.
|
|
38
|
+
*/
|
|
39
|
+
export declare function wrapNetworkError(error: unknown, url: string): unknown;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classification of network-level request failures.
|
|
3
|
+
*
|
|
4
|
+
* `fetch` rejects with an opaque `TypeError: fetch failed` and puts the real
|
|
5
|
+
* reason on `error.cause`. Reporting only `error.message` therefore tells the
|
|
6
|
+
* user nothing at all — the most common real cause, a TLS-intercepting proxy on
|
|
7
|
+
* the network, is indistinguishable from being offline.
|
|
8
|
+
*
|
|
9
|
+
* This module walks the cause chain, extracts the underlying code, and pairs it
|
|
10
|
+
* with actionable remediation text.
|
|
11
|
+
*/
|
|
12
|
+
/** A request that never produced an HTTP response (DNS, TCP or TLS failure). */
|
|
13
|
+
export class NetworkError extends Error {
|
|
14
|
+
/** Underlying cause code, e.g. `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`. */
|
|
15
|
+
code;
|
|
16
|
+
/** Multi-line remediation text, when the cause is recognised. */
|
|
17
|
+
hint;
|
|
18
|
+
/** Origin that failed (scheme and host only — never a path). */
|
|
19
|
+
origin;
|
|
20
|
+
constructor(message, options = {}) {
|
|
21
|
+
super(message, { cause: options.cause });
|
|
22
|
+
this.name = "NetworkError";
|
|
23
|
+
this.code = options.code;
|
|
24
|
+
this.hint = options.hint;
|
|
25
|
+
this.origin = options.origin;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Certificate-chain failures. Every one of these means "Node could not build a
|
|
30
|
+
* trust path", which on a managed device almost always means TLS interception.
|
|
31
|
+
*/
|
|
32
|
+
const TLS_TRUST_CODES = new Set([
|
|
33
|
+
"UNABLE_TO_GET_ISSUER_CERT",
|
|
34
|
+
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
|
35
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
36
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
37
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
38
|
+
"CERT_UNTRUSTED",
|
|
39
|
+
]);
|
|
40
|
+
const DNS_CODES = new Set(["ENOTFOUND", "EAI_AGAIN"]);
|
|
41
|
+
const TIMEOUT_CODES = new Set([
|
|
42
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
43
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
44
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
45
|
+
"ETIMEDOUT",
|
|
46
|
+
]);
|
|
47
|
+
function tlsTrustHint() {
|
|
48
|
+
return [
|
|
49
|
+
"The network appears to intercept TLS traffic (\"break-and-inspect\"), re-signing",
|
|
50
|
+
"certificates with a private root CA. This is common on managed devices behind a",
|
|
51
|
+
"corporate secure web gateway. Node ships its own CA bundle and ignores the",
|
|
52
|
+
"operating system trust store, so a root your browser already trusts is invisible",
|
|
53
|
+
"to Node.",
|
|
54
|
+
"",
|
|
55
|
+
"Fix, in order of preference:",
|
|
56
|
+
" 1. Node >=22.19 / >=24.6 NODE_USE_SYSTEM_CA=1 wilma <command>",
|
|
57
|
+
" 2. Node >=22.15 node --use-system-ca \"$(command -v wilma)\" <command>",
|
|
58
|
+
" 3. Trust the root directly NODE_EXTRA_CA_CERTS=/path/to/root-ca.pem wilma <command>",
|
|
59
|
+
"",
|
|
60
|
+
"Do not set NODE_TLS_REJECT_UNAUTHORIZED=0. It disables certificate verification",
|
|
61
|
+
"entirely, on a network that is already inspecting your traffic.",
|
|
62
|
+
].join("\n");
|
|
63
|
+
}
|
|
64
|
+
/** Human-readable summary plus remediation for a known cause code. */
|
|
65
|
+
export function describeNetworkCode(code, origin) {
|
|
66
|
+
const where = origin ? ` for ${origin}` : "";
|
|
67
|
+
if (code && TLS_TRUST_CODES.has(code)) {
|
|
68
|
+
return {
|
|
69
|
+
summary: `TLS certificate verification failed${where} (${code})`,
|
|
70
|
+
hint: tlsTrustHint(),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (code === "CERT_HAS_EXPIRED") {
|
|
74
|
+
return {
|
|
75
|
+
summary: `The certificate presented${where} has expired (${code})`,
|
|
76
|
+
hint: [
|
|
77
|
+
"Check the system clock first — a clock that is wrong by days will produce this.",
|
|
78
|
+
"If the clock is correct, a TLS-intercepting proxy on the network may be",
|
|
79
|
+
"presenting a stale certificate.",
|
|
80
|
+
].join("\n"),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (code === "ERR_TLS_CERT_ALTNAME_INVALID") {
|
|
84
|
+
return {
|
|
85
|
+
summary: `The certificate presented${where} does not match that hostname (${code})`,
|
|
86
|
+
hint: [
|
|
87
|
+
"Check that the tenant URL is correct. A TLS-intercepting proxy can also cause",
|
|
88
|
+
"this if it re-signs with a certificate issued for a different name.",
|
|
89
|
+
].join("\n"),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (code && DNS_CODES.has(code)) {
|
|
93
|
+
return {
|
|
94
|
+
summary: `Could not resolve the Wilma host${where} (${code})`,
|
|
95
|
+
hint: [
|
|
96
|
+
"Check the tenant URL and that you are online. On a VPN or split-tunnel network",
|
|
97
|
+
"the host may only resolve while connected.",
|
|
98
|
+
].join("\n"),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (code === "ECONNREFUSED") {
|
|
102
|
+
return {
|
|
103
|
+
summary: `Connection refused${where} (${code})`,
|
|
104
|
+
hint: "The host may be down, or a local firewall or proxy may be blocking the request.",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (code && TIMEOUT_CODES.has(code)) {
|
|
108
|
+
return {
|
|
109
|
+
summary: `Connection timed out${where} (${code})`,
|
|
110
|
+
hint: "Check your network. A proxy or firewall may be silently dropping the request.",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (code === "ECONNRESET") {
|
|
114
|
+
return {
|
|
115
|
+
summary: `Connection reset${where} (${code})`,
|
|
116
|
+
hint: "This is often a proxy or firewall interrupting the request.",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// Unrecognised: still surface the code rather than swallowing it.
|
|
120
|
+
return {
|
|
121
|
+
summary: code
|
|
122
|
+
? `Request failed${where} (${code})`
|
|
123
|
+
: `Request failed${where}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** Walk the cause chain for the first string `code` property. */
|
|
127
|
+
export function extractCauseCode(error, maxDepth = 5) {
|
|
128
|
+
let current = error;
|
|
129
|
+
for (let depth = 0; depth < maxDepth && current; depth += 1) {
|
|
130
|
+
const code = current.code;
|
|
131
|
+
if (typeof code === "string" && code.length > 0) {
|
|
132
|
+
return code;
|
|
133
|
+
}
|
|
134
|
+
current = current.cause;
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Scheme and host only. Wilma paths embed student numbers, so the full URL must
|
|
140
|
+
* never reach an error message or a log line.
|
|
141
|
+
*/
|
|
142
|
+
function safeOrigin(url) {
|
|
143
|
+
try {
|
|
144
|
+
return new URL(url).origin;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function looksLikeFetchFailure(error) {
|
|
151
|
+
if (!(error instanceof Error))
|
|
152
|
+
return false;
|
|
153
|
+
return error.message === "fetch failed" || error.name === "TypeError";
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Convert a thrown `fetch` rejection into a `NetworkError` carrying an
|
|
157
|
+
* actionable hint. Anything that is not a transport failure is returned
|
|
158
|
+
* unchanged, so callers can `throw wrapNetworkError(err, url)` safely.
|
|
159
|
+
*/
|
|
160
|
+
export function wrapNetworkError(error, url) {
|
|
161
|
+
if (error instanceof NetworkError) {
|
|
162
|
+
return error;
|
|
163
|
+
}
|
|
164
|
+
const code = extractCauseCode(error);
|
|
165
|
+
if (!code && !looksLikeFetchFailure(error)) {
|
|
166
|
+
return error;
|
|
167
|
+
}
|
|
168
|
+
const origin = safeOrigin(url);
|
|
169
|
+
const { summary, hint } = describeNetworkCode(code, origin);
|
|
170
|
+
return new NetworkError(summary, { code, hint, origin, cause: error });
|
|
171
|
+
}
|
|
@@ -3,4 +3,13 @@ export interface StudentInfo {
|
|
|
3
3
|
name: string;
|
|
4
4
|
href: string;
|
|
5
5
|
}
|
|
6
|
-
export declare function
|
|
6
|
+
export declare function studentNumberFromHref(href: string): string | null;
|
|
7
|
+
export declare function parseStudentFromUrl(url: string): StudentInfo | null;
|
|
8
|
+
/**
|
|
9
|
+
* Parse GET /api/v1/accounts/me/roles.
|
|
10
|
+
* Accepts `{ payload: AccountRole[] }` or a raw array.
|
|
11
|
+
* Skips the account itself (`type === "passwd"` / type 7). Guardian (and other
|
|
12
|
+
* non-passwd) roles contribute a student from `slug` + `name`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseStudentsFromAccountsRoles(data: unknown): StudentInfo[];
|
|
15
|
+
export declare function parseStudentsFromHome(html: string, pageUrl?: string): StudentInfo[];
|
package/dist/parsers/students.js
CHANGED
|
@@ -12,16 +12,106 @@ 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
|
+
return String(rec.Name ?? rec.name ?? rec.Caption ?? rec.caption ?? fallback);
|
|
50
|
+
}
|
|
51
|
+
function collectStudent(students, studentNumber, name) {
|
|
52
|
+
if (!students.has(studentNumber)) {
|
|
53
|
+
students.set(studentNumber, {
|
|
54
|
+
studentNumber,
|
|
55
|
+
name,
|
|
56
|
+
href: `/!${studentNumber}/`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Parse GET /api/v1/accounts/me/roles.
|
|
62
|
+
* Accepts `{ payload: AccountRole[] }` or a raw array.
|
|
63
|
+
* Skips the account itself (`type === "passwd"` / type 7). Guardian (and other
|
|
64
|
+
* non-passwd) roles contribute a student from `slug` + `name`.
|
|
65
|
+
*/
|
|
66
|
+
export function parseStudentsFromAccountsRoles(data) {
|
|
17
67
|
const students = new Map();
|
|
18
|
-
|
|
68
|
+
let roles = [];
|
|
69
|
+
if (Array.isArray(data)) {
|
|
70
|
+
roles = data;
|
|
71
|
+
}
|
|
72
|
+
else if (data && typeof data === "object") {
|
|
73
|
+
const rec = data;
|
|
74
|
+
const payload = rec.payload ?? rec.Payload;
|
|
75
|
+
if (Array.isArray(payload)) {
|
|
76
|
+
roles = payload;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const item of roles) {
|
|
80
|
+
if (!item || typeof item !== "object") {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const rec = item;
|
|
84
|
+
if (isPasswdAccountRole(roleType(rec))) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const slug = String(rec.slug ?? rec.Slug ?? "");
|
|
88
|
+
const studentNumber = studentNumberFromSlug(slug);
|
|
89
|
+
if (!studentNumber) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const name = roleName(rec, studentNumber);
|
|
93
|
+
if (!name) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
collectStudent(students, studentNumber, name);
|
|
97
|
+
}
|
|
98
|
+
return [...students.values()];
|
|
99
|
+
}
|
|
100
|
+
export function parseStudentsFromHome(html, pageUrl) {
|
|
101
|
+
const $ = cheerio.load(html);
|
|
102
|
+
const named = new Map();
|
|
103
|
+
const fromHref = new Map();
|
|
104
|
+
$("a[href]").each((_, anchor) => {
|
|
19
105
|
const href = $(anchor).attr("href") ?? "";
|
|
20
|
-
const
|
|
21
|
-
if (!
|
|
106
|
+
const studentNumber = studentNumberFromHref(href);
|
|
107
|
+
if (!studentNumber) {
|
|
22
108
|
return;
|
|
23
109
|
}
|
|
24
|
-
|
|
110
|
+
fromHref.set(studentNumber, {
|
|
111
|
+
studentNumber,
|
|
112
|
+
name: studentNumber,
|
|
113
|
+
href,
|
|
114
|
+
});
|
|
25
115
|
const cloned = $(anchor).clone();
|
|
26
116
|
cloned.find("small, span.lem").remove();
|
|
27
117
|
const text = cloned.text().trim();
|
|
@@ -32,13 +122,25 @@ export function parseStudentsFromHome(html) {
|
|
|
32
122
|
if (NAV_KEYWORDS.some((kw) => lower.includes(kw))) {
|
|
33
123
|
return;
|
|
34
124
|
}
|
|
35
|
-
if (!
|
|
36
|
-
|
|
125
|
+
if (!named.has(studentNumber)) {
|
|
126
|
+
named.set(studentNumber, {
|
|
37
127
|
studentNumber,
|
|
38
128
|
name: text,
|
|
39
129
|
href,
|
|
40
130
|
});
|
|
41
131
|
}
|
|
42
132
|
});
|
|
43
|
-
|
|
133
|
+
if (named.size > 0) {
|
|
134
|
+
return [...named.values()];
|
|
135
|
+
}
|
|
136
|
+
if (fromHref.size > 0) {
|
|
137
|
+
return [...fromHref.values()];
|
|
138
|
+
}
|
|
139
|
+
if (pageUrl) {
|
|
140
|
+
const fromUrl = parseStudentFromUrl(pageUrl);
|
|
141
|
+
if (fromUrl) {
|
|
142
|
+
return [fromUrl];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return [];
|
|
44
146
|
}
|
package/dist/session.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CookieJar } from "tough-cookie";
|
|
2
2
|
import { fetch } from "undici";
|
|
3
|
+
import { wrapNetworkError } from "./network-error.js";
|
|
3
4
|
export class AuthenticationError extends Error {
|
|
4
5
|
}
|
|
5
6
|
export class MfaRequiredError extends Error {
|
|
@@ -223,10 +224,18 @@ export class WilmaSession {
|
|
|
223
224
|
// Avoid logging sensitive headers or body
|
|
224
225
|
console.log(`[wilmai] ${method} ${url}`);
|
|
225
226
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
227
|
+
let resp;
|
|
228
|
+
try {
|
|
229
|
+
resp = await fetch(url, {
|
|
230
|
+
...init,
|
|
231
|
+
headers,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
// `fetch failed` on its own is undiagnosable; re-throw with the cause code
|
|
236
|
+
// and remediation attached.
|
|
237
|
+
throw wrapNetworkError(err, url);
|
|
238
|
+
}
|
|
230
239
|
const headersAny = resp.headers;
|
|
231
240
|
const setCookies = headersAny.getSetCookie?.() ?? [];
|
|
232
241
|
if (setCookies.length) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wilm-ai/wilma-client",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -14,6 +14,13 @@
|
|
|
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-news.mjs && node test/parser-schedule.mjs && node test/parser-students.mjs && node test/client-students.mjs && node test/client-schedule.mjs && node test/client-news-resources.mjs && node test/network-error.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
|
+
},
|
|
17
24
|
"dependencies": {
|
|
18
25
|
"cheerio": "^1.0.0-rc.12",
|
|
19
26
|
"tough-cookie": "^4.1.4",
|
|
@@ -22,12 +29,5 @@
|
|
|
22
29
|
"devDependencies": {
|
|
23
30
|
"typescript": "^5.6.3",
|
|
24
31
|
"@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
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
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.
|