@wilm-ai/wilma-client 1.5.1 → 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 CHANGED
@@ -6,10 +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 { wrapNetworkError } from "./network-error.js";
12
+ import { NetworkError, wrapNetworkError } from "./network-error.js";
13
13
  const EXTERNAL_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
14
14
  "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36";
15
15
  export class WilmaClient {
@@ -37,7 +37,9 @@ export class WilmaClient {
37
37
  return new WilmaClient(session);
38
38
  }
39
39
  static async listStudents(profile, onMfaRequired) {
40
- const session = new WilmaSession(profile.baseUrl);
40
+ const session = new WilmaSession(profile.baseUrl, {
41
+ debug: profile.debug ?? false,
42
+ });
41
43
  try {
42
44
  await session.login(profile.username, profile.password);
43
45
  }
@@ -50,9 +52,27 @@ export class WilmaClient {
50
52
  throw err;
51
53
  }
52
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
+ }
53
69
  const resp = await session.get("/");
54
70
  const html = await resp.text();
55
- return parseStudentsFromHome(html);
71
+ const fromHome = parseStudentsFromHome(html, resp.url);
72
+ if (fromHome.length > 0) {
73
+ return fromHome;
74
+ }
75
+ return [];
56
76
  }
57
77
  messages = {
58
78
  list: async (folder = "inbox") => {
@@ -3,4 +3,13 @@ export interface StudentInfo {
3
3
  name: string;
4
4
  href: string;
5
5
  }
6
- export declare function parseStudentsFromHome(html: string): StudentInfo[];
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[];
@@ -12,16 +12,106 @@ const NAV_KEYWORDS = [
12
12
  "printouts",
13
13
  "news",
14
14
  ];
15
- export function parseStudentsFromHome(html) {
16
- const $ = cheerio.load(html);
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
- $("a[href^='/!']").each((_, anchor) => {
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 match = /\/!(\d+)\//.exec(href);
21
- if (!match) {
106
+ const studentNumber = studentNumberFromHref(href);
107
+ if (!studentNumber) {
22
108
  return;
23
109
  }
24
- const studentNumber = match[1];
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 (!students.has(studentNumber)) {
36
- students.set(studentNumber, {
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
- return [...students.values()];
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wilm-ai/wilma-client",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -17,7 +17,7 @@
17
17
  "scripts": {
18
18
  "build": "tsc -p tsconfig.json",
19
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/client-schedule.mjs && node test/client-news-resources.mjs && node test/network-error.mjs",
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
21
  "test:smoke": "tsc -p tsconfig.json && node test/smoke.mjs",
22
22
  "test:live": "tsc -p tsconfig.json && node test/live.spec.mjs"
23
23
  },