@wilm-ai/wilma-client 1.4.1 → 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/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?: {
@@ -24,6 +36,11 @@ export declare class WilmaClient {
24
36
  date?: string;
25
37
  }) => Promise<LessonNote[]>;
26
38
  };
39
+ schedule: {
40
+ list: (opts?: {
41
+ date?: string;
42
+ }) => Promise<OverviewData["schedule"]>;
43
+ };
27
44
  overview: {
28
45
  get: () => Promise<OverviewData>;
29
46
  };
package/dist/client.js CHANGED
@@ -5,7 +5,12 @@ import { parseNewsDetailHtml, parseNewsDetailJson, parseNewsList, parseNewsListH
5
5
  import { parseExamsHtml } from "./parsers/exams.js";
6
6
  import { parseAttendanceHtml } from "./parsers/attendance.js";
7
7
  import { parseOverview } from "./parsers/overview.js";
8
+ import { parseScheduleHtml } from "./parsers/schedule.js";
8
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";
9
14
  export class WilmaClient {
10
15
  session;
11
16
  constructor(session) {
@@ -103,10 +108,31 @@ export class WilmaClient {
103
108
  if (!contentType.includes("text/html")) {
104
109
  const data = safeJson(text);
105
110
  if (Object.keys(data).length) {
106
- return parseNewsDetailJson(newsId, data);
111
+ return parseNewsDetailJson(newsId, data, resp.url);
107
112
  }
108
113
  }
109
- 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" };
110
136
  },
111
137
  };
112
138
  exams = {
@@ -138,6 +164,18 @@ export class WilmaClient {
138
164
  return parseAttendanceHtml(text, opts?.date ?? "");
139
165
  },
140
166
  };
167
+ schedule = {
168
+ list: async (opts) => {
169
+ if (!opts?.date) {
170
+ return (await this.overview.get()).schedule;
171
+ }
172
+ const params = new URLSearchParams();
173
+ params.set("date", isoDateToFinnish(opts.date));
174
+ const resp = await this.session.get(`/schedule?${params.toString()}`);
175
+ const text = await resp.text();
176
+ return parseScheduleHtml(text);
177
+ },
178
+ };
141
179
  overview = {
142
180
  get: async () => {
143
181
  const resp = await this.session.get("/overview");
@@ -146,6 +184,87 @@ export class WilmaClient {
146
184
  },
147
185
  };
148
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
+ }
149
268
  function safeJson(text) {
150
269
  try {
151
270
  return JSON.parse(text);
@@ -154,3 +273,10 @@ function safeJson(text) {
154
273
  return {};
155
274
  }
156
275
  }
276
+ function isoDateToFinnish(date) {
277
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
278
+ if (!match) {
279
+ return date;
280
+ }
281
+ return `${match[3]}.${match[2]}.${match[1]}`;
282
+ }
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";
@@ -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>): NewsItem;
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[];
@@ -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: (data["content"] ?? data["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
- const contentElem = $(".panel-body, .news-content, .content, .ckeditor, article").first();
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.find("script, style").remove();
54
- content = contentElem.text().trim();
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();
@@ -0,0 +1,2 @@
1
+ import type { ScheduleLesson } from "../types.js";
2
+ export declare function parseScheduleHtml(html: string): ScheduleLesson[];
@@ -0,0 +1,83 @@
1
+ import * as cheerio from "cheerio";
2
+ export function parseScheduleHtml(html) {
3
+ const $ = cheerio.load(html);
4
+ const scripts = $("script:not([src])")
5
+ .map((_, el) => $(el).html() ?? "")
6
+ .get();
7
+ const script = scripts.find((text) => text.includes("eventsJSON") && text.includes("Events"));
8
+ if (!script) {
9
+ return [];
10
+ }
11
+ const match = /Events\s*:\s*(\[[\s\S]*?\])\s*,\s*ActiveTyyppi/.exec(script);
12
+ if (!match) {
13
+ return [];
14
+ }
15
+ let events;
16
+ try {
17
+ events = JSON.parse(match[1]);
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ return events
23
+ .map(parseScheduleEvent)
24
+ .filter((lesson) => lesson !== null)
25
+ .sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start));
26
+ }
27
+ function parseScheduleEvent(event) {
28
+ const date = finnishDateToIso(event.Date ?? "");
29
+ if (!date) {
30
+ return null;
31
+ }
32
+ const subject = firstRecordValue(event.Text) || firstRecordValue(event.LongText);
33
+ const teacher = firstTeacher(event.OpeInfo);
34
+ const dateObj = new Date(`${date}T12:00:00`);
35
+ return {
36
+ date,
37
+ dayOfWeek: dateObj.getDay(),
38
+ start: minutesToTime(event.Start),
39
+ end: minutesToTime(event.End),
40
+ subject,
41
+ subjectCode: firstSubjectToken(subject),
42
+ teacher: teacher.name,
43
+ teacherCode: teacher.code,
44
+ groupId: Number(event.Id) || 0,
45
+ };
46
+ }
47
+ function finnishDateToIso(value) {
48
+ const match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(value.trim());
49
+ if (!match) {
50
+ return "";
51
+ }
52
+ return `${match[3]}-${match[2].padStart(2, "0")}-${match[1].padStart(2, "0")}`;
53
+ }
54
+ function minutesToTime(value) {
55
+ if (typeof value !== "number" || !Number.isFinite(value)) {
56
+ return "";
57
+ }
58
+ const hours = Math.floor(value / 60);
59
+ const minutes = value % 60;
60
+ return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
61
+ }
62
+ function firstRecordValue(record) {
63
+ if (!record) {
64
+ return "";
65
+ }
66
+ return Object.values(record).find((value) => value.trim()) ?? "";
67
+ }
68
+ function firstTeacher(groups) {
69
+ if (!groups) {
70
+ return { name: "", code: "" };
71
+ }
72
+ for (const group of Object.values(groups)) {
73
+ for (const person of Object.values(group)) {
74
+ if (person?.nimi || person?.lyhenne) {
75
+ return { name: person.nimi ?? "", code: person.lyhenne ?? "" };
76
+ }
77
+ }
78
+ }
79
+ return { name: "", code: "" };
80
+ }
81
+ function firstSubjectToken(subject) {
82
+ return subject.trim().split(/\s+/, 1)[0] ?? "";
83
+ }
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.4.1",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -26,7 +26,7 @@
26
26
  "scripts": {
27
27
  "build": "tsc -p tsconfig.json",
28
28
  "lint": "echo 'add lint'",
29
- "test": "pnpm run build && node test/parser-messages.mjs",
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
30
  "test:smoke": "tsc -p tsconfig.json && node test/smoke.mjs",
31
31
  "test:live": "tsc -p tsconfig.json && node test/live.spec.mjs"
32
32
  }