@wilm-ai/wilma-client 1.4.2 → 1.5.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/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,11 @@ 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
+ import { wrapNetworkError } from "./network-error.js";
13
+ const EXTERNAL_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
14
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36";
10
15
  export class WilmaClient {
11
16
  session;
12
17
  constructor(session) {
@@ -104,10 +109,31 @@ export class WilmaClient {
104
109
  if (!contentType.includes("text/html")) {
105
110
  const data = safeJson(text);
106
111
  if (Object.keys(data).length) {
107
- return parseNewsDetailJson(newsId, data);
112
+ return parseNewsDetailJson(newsId, data, resp.url);
108
113
  }
109
114
  }
110
- return parseNewsDetailHtml(text, newsId);
115
+ return parseNewsDetailHtml(text, newsId, resp.url);
116
+ },
117
+ fetchResource: async (newsId, resourceId, options) => {
118
+ const item = options?.item ?? (await this.news.get(newsId));
119
+ const resource = item.resources?.find((candidate) => candidate.id === resourceId);
120
+ if (!resource) {
121
+ throw new Error(`News resource "${resourceId}" not found`);
122
+ }
123
+ if (resource.authContext === "wilma") {
124
+ const url = new URL(resource.url);
125
+ const response = await this.session.get(`${url.pathname}${url.search}`);
126
+ if (isHtmlResponse(response)) {
127
+ await response.body?.cancel();
128
+ return { resource, response: null, status: "not_a_file" };
129
+ }
130
+ return { resource, response: response, status: "fetched" };
131
+ }
132
+ const response = await fetchExternalFile(resource.url);
133
+ if (!response) {
134
+ return { resource, response: null, status: "not_a_file" };
135
+ }
136
+ return { resource, response, status: "fetched" };
111
137
  },
112
138
  };
113
139
  exams = {
@@ -159,6 +185,93 @@ export class WilmaClient {
159
185
  },
160
186
  };
161
187
  }
188
+ // Some document hosts serve an HTML viewer page for a sharing URL but return
189
+ // the file itself when a conventional download parameter is present. These are
190
+ // generic retry variants, not provider detection: the original URL is always
191
+ // attempted first, and a variant is tried only after an HTML answer.
192
+ const DOWNLOAD_PARAM_VARIANTS = [
193
+ ["download", "1"],
194
+ ["dl", "1"],
195
+ ];
196
+ function buildDownloadCandidates(rawUrl) {
197
+ const original = new URL(rawUrl);
198
+ const candidates = [original];
199
+ for (const [param, value] of DOWNLOAD_PARAM_VARIANTS) {
200
+ if (!original.searchParams.has(param)) {
201
+ const variant = new URL(original.href);
202
+ variant.searchParams.set(param, value);
203
+ candidates.push(variant);
204
+ }
205
+ }
206
+ return candidates;
207
+ }
208
+ function isHtmlResponse(response) {
209
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
210
+ return contentType.startsWith("text/html") || contentType.startsWith("application/xhtml");
211
+ }
212
+ // Fetch an external URL the way a signed-out browser would: an isolated
213
+ // in-memory cookie jar, redirects followed across hosts, and no Wilma
214
+ // credentials anywhere. Returns null when every candidate answered with an
215
+ // HTML page instead of a file.
216
+ async function fetchExternalFile(rawUrl) {
217
+ for (const candidate of buildDownloadCandidates(rawUrl)) {
218
+ const response = await fetchFollowingRedirects(candidate);
219
+ if (!response) {
220
+ continue;
221
+ }
222
+ if (response.ok && isHtmlResponse(response)) {
223
+ await response.body?.cancel();
224
+ continue;
225
+ }
226
+ return response;
227
+ }
228
+ return null;
229
+ }
230
+ async function fetchFollowingRedirects(initialUrl) {
231
+ const cookieJar = new CookieJar();
232
+ let currentUrl = initialUrl;
233
+ for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
234
+ const headers = new Headers({
235
+ "User-Agent": EXTERNAL_USER_AGENT,
236
+ "Accept": "*/*",
237
+ });
238
+ const cookieHeader = cookieJar.getCookieStringSync(currentUrl.href);
239
+ if (cookieHeader) {
240
+ headers.set("Cookie", cookieHeader);
241
+ }
242
+ let response;
243
+ try {
244
+ response = await fetch(currentUrl, { headers, redirect: "manual" });
245
+ }
246
+ catch (err) {
247
+ throw wrapNetworkError(err, currentUrl.href);
248
+ }
249
+ const setCookies = response.headers
250
+ .getSetCookie?.() ?? [];
251
+ for (const cookie of setCookies) {
252
+ cookieJar.setCookieSync(cookie, currentUrl.href, { ignoreError: true });
253
+ }
254
+ if (!setCookies.length) {
255
+ const cookie = response.headers.get("set-cookie");
256
+ if (cookie)
257
+ cookieJar.setCookieSync(cookie, currentUrl.href, { ignoreError: true });
258
+ }
259
+ if (response.status < 300 || response.status >= 400) {
260
+ return response;
261
+ }
262
+ const location = response.headers.get("location");
263
+ await response.body?.cancel();
264
+ if (!location) {
265
+ return null;
266
+ }
267
+ const nextUrl = new URL(location, currentUrl);
268
+ if (nextUrl.protocol !== "https:" && nextUrl.protocol !== "http:") {
269
+ return null;
270
+ }
271
+ currentUrl = nextUrl;
272
+ }
273
+ throw new Error("External resource exceeded the redirect limit");
274
+ }
162
275
  function safeJson(text) {
163
276
  try {
164
277
  return JSON.parse(text);
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
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";
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
+ }
@@ -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();
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
- const resp = await fetch(url, {
227
- ...init,
228
- headers,
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/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.2",
3
+ "version": "1.5.1",
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-schedule.mjs && node test/client-schedule.mjs",
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",
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
  },