@wilm-ai/wilma-client 1.5.2 → 1.6.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/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/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @wilm-ai/wilma-client
2
+
3
+ A TypeScript client for **Wilma**, the Finnish school information system, as a parent (guardian) sees it: schedule, homework, exams and grades, the gradebook, lesson notes and absences, messages with their replies, bulletins and their attachments, and printouts. Read-only.
4
+
5
+ It powers the [`wilma` CLI and MCP server](https://www.npmjs.com/package/@wilm-ai/wilma-cli) ([wilm.ai](https://wilm.ai)).
6
+
7
+ > Independent open-source project, not affiliated with, endorsed by, or connected to Visma or the official Wilma service.
8
+
9
+ Needs Node.js 20.18.1 or newer.
10
+
11
+ ```bash
12
+ npm i @wilm-ai/wilma-client
13
+ ```
14
+
15
+ ## Use
16
+
17
+ ```ts
18
+ import { WilmaClient, listTenants } from "@wilm-ai/wilma-client";
19
+
20
+ // Find the Wilma address (most city schools share their city's Wilma).
21
+ const wilmas = await listTenants();
22
+
23
+ // Log in once; every child shares the login.
24
+ const client = await WilmaClient.login({ baseUrl: "https://<school>.inschool.fi", username, password }, onMfa);
25
+ for (const student of await client.students()) {
26
+ const kid = client.forStudent(student.studentNumber);
27
+ const overview = await kid.overview.get(); // this week's lessons, homework, exams, exam grades
28
+ const exams = await kid.exams.upcoming(); // with start times when the school gives one
29
+ const lessons = await kid.schedule.list({ from: "2026-10-05", to: "2026-10-09" }); // teachers and rooms
30
+ const notes = await kid.attendance.list({ from: "2026-09-01", to: "2026-09-30" }); // lesson notes, with the teacher's words
31
+ const summary = await kid.attendance.summary(); // counts by kind for the school year
32
+ const gradebook = await kid.gradebook.get(); // subject > syllabus > course, with grades and dates
33
+ const messages = await kid.messages.list("inbox"); // unread and replyCount included
34
+ const thread = await kid.messages.get(messages[0].wilmaId); // the message and its replies
35
+ const news = await kid.news.list(); // dated, pinned and older bulletins
36
+ const printouts = await kid.printouts.list(); // PDFs such as report cards
37
+ }
38
+ ```
39
+
40
+ `onMfa(formkey)` returns a one-time code for accounts with two-step verification (e.g. generated from the authenticator setup key).
41
+
42
+ ### One session per account
43
+
44
+ Wilma allows one live session per account: a new login ends the previous one, and also logs the parent out of Wilma in their own browser. So:
45
+
46
+ - **Log in once** and use `client.forStudent(number)` for each child; they share the login.
47
+ - A session that Wilma ends (another login, or expiry) **logs in again by itself** on the next request — once, even when many requests notice at the same time, including the two-step verification step. A refused password stops further attempts, so a changed password can't lock the account.
48
+ - To continue a session in another process, save `client.exportSession()` (treat it like the password) and later call `WilmaClient.resume(profile, saved, onMfa)`. `client.onLogin(callback)` tells you when a new session should be saved.
49
+
50
+ ### Times
51
+
52
+ Wilma's times are Finnish wall-clock times. The client reads them as such wherever it runs (dates are correct on a UTC server too), and exports helpers: `finnishDateString()`, `finnishTime()`, and `finnishIsoString(date)` for output like `2026-10-02T13:37:00+03:00`.
53
+
54
+ ### Where the data comes from
55
+
56
+ JSON wherever Wilma has it: the account's roles, the front page (overview), message lists and threads, and the timetable API. Bulletins, lesson notes, the gradebook, printouts and the exam calendar exist only as pages and are parsed; the parsers are tested against anonymised copies of real pages. Older Wilma versions without the JSON endpoints fall back to the pages.
57
+
58
+ ### Safety
59
+
60
+ - Requests stay on the Wilma address; redirects to other sites never carry Wilma's cookies.
61
+ - Files linked from bulletins are fetched without the Wilma login and only from public internet addresses (private, loopback and cloud-metadata addresses are refused, also after DNS resolution).
62
+ - Requests and downloads fail after a while without progress instead of hanging.
63
+
64
+ ## License
65
+
66
+ MIT
package/dist/client.d.ts CHANGED
@@ -1,13 +1,34 @@
1
- import type { Exam, Message, MessageFolder, NewsItem, NewsResource, OverviewData, WilmaProfile, StudentInfo, LessonNote } from "./types.js";
2
- import { type Response } from "undici";
1
+ import type { Exam, GradebookEntry, LessonNote, LessonNoteSummary, Message, MessageFolder, NewsItem, NewsResource, OverviewData, Printout, StudentInfo, UpcomingExam, WilmaProfile } from "./types.js";
2
+ import type { Response } from "undici";
3
3
  export type MfaCallback = (formkey: string) => Promise<string>;
4
4
  export declare class WilmaClient {
5
5
  private session;
6
6
  private constructor();
7
7
  static login(profile: WilmaProfile, onMfaRequired?: MfaCallback): Promise<WilmaClient>;
8
+ /**
9
+ * A client on a session saved earlier with exportSession(), without logging
10
+ * in. If Wilma has ended that session, the first request logs in again by
11
+ * itself. Returns null when the saved state has no session.
12
+ */
13
+ static resume(profile: WilmaProfile, state: string, onMfaRequired?: MfaCallback): WilmaClient | null;
14
+ /** This login's session (cookies) as a string, for WilmaClient.resume() in a later process. Treat it like a password. */
15
+ exportSession(): string;
16
+ /** Run a callback after every successful login, e.g. to save the new session. */
17
+ onLogin(callback: (() => void) | undefined): void;
8
18
  static listStudents(profile: WilmaProfile, onMfaRequired?: MfaCallback): Promise<StudentInfo[]>;
19
+ /**
20
+ * A client for one of the account's students that reuses this client's
21
+ * login. Log in once, then call this per child instead of logging in again.
22
+ */
23
+ forStudent(studentNumber: string | null | undefined): WilmaClient;
24
+ /** The guardian's students, discovered with this client's login. */
25
+ students(): Promise<StudentInfo[]>;
9
26
  messages: {
10
27
  list: (folder?: MessageFolder) => Promise<Message[]>;
28
+ /**
29
+ * One message with its replies. Wilma answers `?format=json` with the whole
30
+ * thread; older versions only have the HTML page, which is parsed instead.
31
+ */
11
32
  get: (messageId: number) => Promise<Message>;
12
33
  };
13
34
  news: {
@@ -26,21 +47,65 @@ export declare class WilmaClient {
26
47
  }>;
27
48
  };
28
49
  exams: {
50
+ /**
51
+ * Upcoming exams with topics and teachers (front page), plus the start
52
+ * time when the school gives one (exam calendar). Without the calendar,
53
+ * exams come back without times.
54
+ */
55
+ upcoming: () => Promise<UpcomingExam[]>;
56
+ /** The exam calendar, or [] if it can't be read (start times are a bonus). */
57
+ calendarOrEmpty: () => Promise<Exam[]>;
58
+ /** The exam calendar page: upcoming exams with dates, start times, teachers and notes. */
29
59
  list: (opts?: {
30
60
  start?: string;
31
61
  end?: string;
32
62
  }) => Promise<Exam[]>;
33
63
  };
34
64
  attendance: {
65
+ /**
66
+ * Lesson notes (merkinnät): absences, lateness and teachers' feedback, with
67
+ * any words the teacher wrote. One day (default today), or a period with
68
+ * `from`/`to` (YYYY-MM-DD).
69
+ */
35
70
  list: (opts?: {
36
71
  date?: string;
72
+ from?: string;
73
+ to?: string;
37
74
  }) => Promise<LessonNote[]>;
75
+ /** How many lesson notes of each kind from `from` to `to` (default today); without `from`, this school year. */
76
+ summary: (opts?: {
77
+ from?: string;
78
+ to?: string;
79
+ }) => Promise<LessonNoteSummary>;
38
80
  };
81
+ /** The lesson notes page for a period ("range" as Wilma's own period links use it). */
82
+ private attendancePage;
39
83
  schedule: {
84
+ /**
85
+ * Lessons on a date or in a period (YYYY-MM-DD), from Wilma's timetable
86
+ * API, with teachers and rooms. Without dates: this week, from the front
87
+ * page. Older Wilma versions without the API fall back to the schedule
88
+ * page (one date) or the front page (this week).
89
+ */
40
90
  list: (opts?: {
41
91
  date?: string;
92
+ from?: string;
93
+ to?: string;
42
94
  }) => Promise<OverviewData["schedule"]>;
43
95
  };
96
+ gradebook: {
97
+ /** Completed courses and grades (Suoritukset), as a tree: subject > syllabus > course. */
98
+ get: () => Promise<GradebookEntry[]>;
99
+ };
100
+ printouts: {
101
+ /** PDF documents the school offers (Tulosteet): report cards, absence reports… */
102
+ list: () => Promise<Printout[]>;
103
+ /** Download one printout; the response body is the PDF. */
104
+ fetch: (id: string) => Promise<{
105
+ printout: Printout;
106
+ response: Response;
107
+ }>;
108
+ };
44
109
  overview: {
45
110
  get: () => Promise<OverviewData>;
46
111
  };
package/dist/client.js CHANGED
@@ -1,17 +1,17 @@
1
- import { WilmaSession, MfaRequiredError } from "./session.js";
1
+ import { WilmaSession, MfaRequiredError, APIError } from "./session.js";
2
2
  import { parseWilmaTimestamp } from "./parsers/dates.js";
3
- import { parseMessagesList, parseMessageDetailHtml } from "./parsers/messages.js";
3
+ import { finnishDateString } from "./finnish-time.js";
4
+ import { parseMessagesList, parseMessageDetailHtml, parseMessageDetailJson } from "./parsers/messages.js";
4
5
  import { parseNewsDetailHtml, parseNewsDetailJson, parseNewsList, parseNewsListHtml, } from "./parsers/news.js";
5
- import { parseExamsHtml } from "./parsers/exams.js";
6
- import { parseAttendanceHtml } from "./parsers/attendance.js";
6
+ import { addExamTimes, parseExamsHtml } from "./parsers/exams.js";
7
+ import { parseAttendanceHtml, summarizeLessonNotes } from "./parsers/attendance.js";
8
+ import { parseGradebookHtml } from "./parsers/gradebook.js";
9
+ import { parsePrintoutsHtml } from "./parsers/printouts.js";
7
10
  import { parseOverview } from "./parsers/overview.js";
8
- import { parseScheduleHtml } from "./parsers/schedule.js";
11
+ import { parseScheduleHtml, parseTimetableJson } from "./parsers/schedule.js";
9
12
  import { parseStudentsFromAccountsRoles, parseStudentsFromHome } from "./parsers/students.js";
10
- import { CookieJar } from "tough-cookie";
11
- import { fetch, Headers } from "undici";
12
- import { NetworkError, 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";
13
+ import { NetworkError } from "./network-error.js";
14
+ import { DOWNLOAD_TIMEOUT_MS, fetchExternalFile, isHtmlResponse } from "./external-fetch.js";
15
15
  export class WilmaClient {
16
16
  session;
17
17
  constructor(session) {
@@ -22,13 +22,13 @@ export class WilmaClient {
22
22
  studentNumber: profile.studentNumber ?? null,
23
23
  debug: profile.debug ?? false,
24
24
  });
25
+ session.setMfaCallback(onMfaRequired);
25
26
  try {
26
27
  await session.login(profile.username, profile.password);
27
28
  }
28
29
  catch (err) {
29
30
  if (err instanceof MfaRequiredError && onMfaRequired) {
30
- const otpCode = await onMfaRequired(err.formkey);
31
- await session.submitMfaCode(err.formkey, otpCode);
31
+ await session.answerMfa(err.formkey, onMfaRequired);
32
32
  }
33
33
  else {
34
34
  throw err;
@@ -36,22 +36,42 @@ export class WilmaClient {
36
36
  }
37
37
  return new WilmaClient(session);
38
38
  }
39
- static async listStudents(profile, onMfaRequired) {
39
+ /**
40
+ * A client on a session saved earlier with exportSession(), without logging
41
+ * in. If Wilma has ended that session, the first request logs in again by
42
+ * itself. Returns null when the saved state has no session.
43
+ */
44
+ static resume(profile, state, onMfaRequired) {
40
45
  const session = new WilmaSession(profile.baseUrl, {
46
+ studentNumber: profile.studentNumber ?? null,
41
47
  debug: profile.debug ?? false,
42
48
  });
43
- try {
44
- await session.login(profile.username, profile.password);
45
- }
46
- catch (err) {
47
- if (err instanceof MfaRequiredError && onMfaRequired) {
48
- const otpCode = await onMfaRequired(err.formkey);
49
- await session.submitMfaCode(err.formkey, otpCode);
50
- }
51
- else {
52
- throw err;
53
- }
54
- }
49
+ session.setMfaCallback(onMfaRequired);
50
+ return session.resumeState(state, profile.username, profile.password) ? new WilmaClient(session) : null;
51
+ }
52
+ /** This login's session (cookies) as a string, for WilmaClient.resume() in a later process. Treat it like a password. */
53
+ exportSession() {
54
+ return this.session.exportState();
55
+ }
56
+ /** Run a callback after every successful login, e.g. to save the new session. */
57
+ onLogin(callback) {
58
+ this.session.onLogin(callback);
59
+ }
60
+ static async listStudents(profile, onMfaRequired) {
61
+ const client = await WilmaClient.login({ ...profile, studentNumber: null }, onMfaRequired);
62
+ return client.students();
63
+ }
64
+ /**
65
+ * A client for one of the account's students that reuses this client's
66
+ * login. Log in once, then call this per child instead of logging in again.
67
+ */
68
+ forStudent(studentNumber) {
69
+ return new WilmaClient(this.session.forStudent(studentNumber || null));
70
+ }
71
+ /** The guardian's students, discovered with this client's login. */
72
+ async students() {
73
+ // Discovery pages live outside any student's "/!<number>/" prefix.
74
+ const session = this.session.forStudent(null);
55
75
  try {
56
76
  const accountsResp = await session.get("/api/v1/accounts/me/roles");
57
77
  const accountsText = await accountsResp.text();
@@ -89,7 +109,22 @@ export class WilmaClient {
89
109
  const data = safeJson(text);
90
110
  return parseMessagesList(data, folder);
91
111
  },
112
+ /**
113
+ * One message with its replies. Wilma answers `?format=json` with the whole
114
+ * thread; older versions only have the HTML page, which is parsed instead.
115
+ */
92
116
  get: async (messageId) => {
117
+ try {
118
+ const resp = await this.session.get(`/messages/${messageId}?format=json`);
119
+ const thread = parseMessageDetailJson(safeJson(await resp.text()), messageId);
120
+ if (thread)
121
+ return thread;
122
+ }
123
+ catch (err) {
124
+ if (err instanceof NetworkError || !(err instanceof APIError) || err.status !== 404)
125
+ throw err;
126
+ }
127
+ // Older versions: the message page (flat JSON on some, HTML on most).
93
128
  const resp = await this.session.get(`/messages/${messageId}`);
94
129
  const contentType = resp.headers.get("content-type")?.toLowerCase() ?? "";
95
130
  const text = await resp.text();
@@ -142,7 +177,21 @@ export class WilmaClient {
142
177
  }
143
178
  if (resource.authContext === "wilma") {
144
179
  const url = new URL(resource.url);
145
- const response = await this.session.get(`${url.pathname}${url.search}`);
180
+ // Collapse leading slashes so the path can't turn into "//other-host/...".
181
+ const path = `/${url.pathname.replace(/^\/+/, "")}${url.search}`;
182
+ const response = await this.session.get(path, undefined, {
183
+ externalRedirect: "return",
184
+ timeoutMs: DOWNLOAD_TIMEOUT_MS,
185
+ });
186
+ if (response.status >= 300 && response.status < 400) {
187
+ // Wilma handed the file off to another site: fetch it there, without Wilma credentials.
188
+ const location = response.headers.get("location");
189
+ await response.body?.cancel();
190
+ const external = location ? await fetchExternalFile(new URL(location, url).href) : null;
191
+ return external
192
+ ? { resource, response: external, status: "fetched" }
193
+ : { resource, response: null, status: "not_a_file" };
194
+ }
146
195
  if (isHtmlResponse(response)) {
147
196
  await response.body?.cancel();
148
197
  return { resource, response: null, status: "not_a_file" };
@@ -157,6 +206,27 @@ export class WilmaClient {
157
206
  },
158
207
  };
159
208
  exams = {
209
+ /**
210
+ * Upcoming exams with topics and teachers (front page), plus the start
211
+ * time when the school gives one (exam calendar). Without the calendar,
212
+ * exams come back without times.
213
+ */
214
+ upcoming: async () => {
215
+ const [overview, calendar] = await Promise.all([this.overview.get(), this.exams.calendarOrEmpty()]);
216
+ return addExamTimes(overview.upcomingExams, calendar);
217
+ },
218
+ /** The exam calendar, or [] if it can't be read (start times are a bonus). */
219
+ calendarOrEmpty: async () => {
220
+ try {
221
+ return await this.exams.list();
222
+ }
223
+ catch (err) {
224
+ if (err instanceof NetworkError)
225
+ throw err;
226
+ return [];
227
+ }
228
+ },
229
+ /** The exam calendar page: upcoming exams with dates, start times, teachers and notes. */
160
230
  list: async (opts) => {
161
231
  const params = new URLSearchParams();
162
232
  if (opts?.start) {
@@ -173,28 +243,84 @@ export class WilmaClient {
173
243
  },
174
244
  };
175
245
  attendance = {
246
+ /**
247
+ * Lesson notes (merkinnät): absences, lateness and teachers' feedback, with
248
+ * any words the teacher wrote. One day (default today), or a period with
249
+ * `from`/`to` (YYYY-MM-DD).
250
+ */
176
251
  list: async (opts) => {
177
- const params = new URLSearchParams();
178
- if (opts?.date) {
179
- params.set("date", opts.date);
252
+ const from = opts?.from ?? opts?.date ?? finnishDateString();
253
+ const to = opts?.to ?? opts?.date ?? from;
254
+ const notes = parseAttendanceHtml(await this.attendancePage(`range=-3&first=${isoDateToFinnish(from)}&last=${isoDateToFinnish(to)}`));
255
+ return notes.filter((note) => note.date >= from && note.date <= to);
256
+ },
257
+ /** How many lesson notes of each kind from `from` to `to` (default today); without `from`, this school year. */
258
+ summary: async (opts) => {
259
+ if (!opts?.from) {
260
+ const notes = parseAttendanceHtml(await this.attendancePage("range=-4"));
261
+ return summarizeLessonNotes(opts?.to ? notes.filter((note) => note.date <= opts.to) : notes, null, opts?.to ?? null);
180
262
  }
181
- const query = params.toString();
182
- const path = query ? `/attendance/view?${query}` : "/attendance/view";
183
- const resp = await this.session.get(path);
184
- const text = await resp.text();
185
- return parseAttendanceHtml(text, opts?.date ?? "");
263
+ const to = opts.to ?? finnishDateString();
264
+ return summarizeLessonNotes(await this.attendance.list({ from: opts.from, to }), opts.from, to);
186
265
  },
187
266
  };
267
+ /** The lesson notes page for a period ("range" as Wilma's own period links use it). */
268
+ async attendancePage(range) {
269
+ const resp = await this.session.get(`/attendance/view?${range}`);
270
+ return resp.text();
271
+ }
188
272
  schedule = {
273
+ /**
274
+ * Lessons on a date or in a period (YYYY-MM-DD), from Wilma's timetable
275
+ * API, with teachers and rooms. Without dates: this week, from the front
276
+ * page. Older Wilma versions without the API fall back to the schedule
277
+ * page (one date) or the front page (this week).
278
+ */
189
279
  list: async (opts) => {
190
- if (!opts?.date) {
191
- return (await this.overview.get()).schedule;
280
+ const from = opts?.from ?? opts?.date;
281
+ const to = opts?.to ?? opts?.date ?? from;
282
+ if (from && to) {
283
+ try {
284
+ const resp = await this.session.get(`/api/v1/schedules/timetable?startdate=${from}&enddate=${to}`);
285
+ const data = safeJson(await resp.text());
286
+ if (Array.isArray(data.payload)) {
287
+ return parseTimetableJson(data.payload).filter((lesson) => lesson.date >= from && lesson.date <= to);
288
+ }
289
+ }
290
+ catch (err) {
291
+ if (!(err instanceof APIError) || (err.status !== 403 && err.status !== 404))
292
+ throw err;
293
+ }
294
+ if (from === to) {
295
+ const params = new URLSearchParams({ date: isoDateToFinnish(from) });
296
+ const resp = await this.session.get(`/schedule?${params.toString()}`);
297
+ return parseScheduleHtml(await resp.text()).filter((lesson) => lesson.date === from);
298
+ }
299
+ return (await this.overview.get()).schedule.filter((lesson) => lesson.date >= from && lesson.date <= to);
192
300
  }
193
- const params = new URLSearchParams();
194
- params.set("date", isoDateToFinnish(opts.date));
195
- const resp = await this.session.get(`/schedule?${params.toString()}`);
196
- const text = await resp.text();
197
- return parseScheduleHtml(text);
301
+ return (await this.overview.get()).schedule;
302
+ },
303
+ };
304
+ gradebook = {
305
+ /** Completed courses and grades (Suoritukset), as a tree: subject > syllabus > course. */
306
+ get: async () => {
307
+ const resp = await this.session.get("/gradebook");
308
+ return parseGradebookHtml(await resp.text());
309
+ },
310
+ };
311
+ printouts = {
312
+ /** PDF documents the school offers (Tulosteet): report cards, absence reports… */
313
+ list: async () => {
314
+ const resp = await this.session.get("/printouts");
315
+ return parsePrintoutsHtml(await resp.text());
316
+ },
317
+ /** Download one printout; the response body is the PDF. */
318
+ fetch: async (id) => {
319
+ const printout = (await this.printouts.list()).find((candidate) => candidate.id === id);
320
+ if (!printout)
321
+ throw new Error(`Printout "${id}" not found`);
322
+ const response = await this.session.get(printout.path, undefined, { timeoutMs: DOWNLOAD_TIMEOUT_MS });
323
+ return { printout, response: response };
198
324
  },
199
325
  };
200
326
  overview = {
@@ -205,93 +331,6 @@ export class WilmaClient {
205
331
  },
206
332
  };
207
333
  }
208
- // Some document hosts serve an HTML viewer page for a sharing URL but return
209
- // the file itself when a conventional download parameter is present. These are
210
- // generic retry variants, not provider detection: the original URL is always
211
- // attempted first, and a variant is tried only after an HTML answer.
212
- const DOWNLOAD_PARAM_VARIANTS = [
213
- ["download", "1"],
214
- ["dl", "1"],
215
- ];
216
- function buildDownloadCandidates(rawUrl) {
217
- const original = new URL(rawUrl);
218
- const candidates = [original];
219
- for (const [param, value] of DOWNLOAD_PARAM_VARIANTS) {
220
- if (!original.searchParams.has(param)) {
221
- const variant = new URL(original.href);
222
- variant.searchParams.set(param, value);
223
- candidates.push(variant);
224
- }
225
- }
226
- return candidates;
227
- }
228
- function isHtmlResponse(response) {
229
- const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
230
- return contentType.startsWith("text/html") || contentType.startsWith("application/xhtml");
231
- }
232
- // Fetch an external URL the way a signed-out browser would: an isolated
233
- // in-memory cookie jar, redirects followed across hosts, and no Wilma
234
- // credentials anywhere. Returns null when every candidate answered with an
235
- // HTML page instead of a file.
236
- async function fetchExternalFile(rawUrl) {
237
- for (const candidate of buildDownloadCandidates(rawUrl)) {
238
- const response = await fetchFollowingRedirects(candidate);
239
- if (!response) {
240
- continue;
241
- }
242
- if (response.ok && isHtmlResponse(response)) {
243
- await response.body?.cancel();
244
- continue;
245
- }
246
- return response;
247
- }
248
- return null;
249
- }
250
- async function fetchFollowingRedirects(initialUrl) {
251
- const cookieJar = new CookieJar();
252
- let currentUrl = initialUrl;
253
- for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
254
- const headers = new Headers({
255
- "User-Agent": EXTERNAL_USER_AGENT,
256
- "Accept": "*/*",
257
- });
258
- const cookieHeader = cookieJar.getCookieStringSync(currentUrl.href);
259
- if (cookieHeader) {
260
- headers.set("Cookie", cookieHeader);
261
- }
262
- let response;
263
- try {
264
- response = await fetch(currentUrl, { headers, redirect: "manual" });
265
- }
266
- catch (err) {
267
- throw wrapNetworkError(err, currentUrl.href);
268
- }
269
- const setCookies = response.headers
270
- .getSetCookie?.() ?? [];
271
- for (const cookie of setCookies) {
272
- cookieJar.setCookieSync(cookie, currentUrl.href, { ignoreError: true });
273
- }
274
- if (!setCookies.length) {
275
- const cookie = response.headers.get("set-cookie");
276
- if (cookie)
277
- cookieJar.setCookieSync(cookie, currentUrl.href, { ignoreError: true });
278
- }
279
- if (response.status < 300 || response.status >= 400) {
280
- return response;
281
- }
282
- const location = response.headers.get("location");
283
- await response.body?.cancel();
284
- if (!location) {
285
- return null;
286
- }
287
- const nextUrl = new URL(location, currentUrl);
288
- if (nextUrl.protocol !== "https:" && nextUrl.protocol !== "http:") {
289
- return null;
290
- }
291
- currentUrl = nextUrl;
292
- }
293
- throw new Error("External resource exceeded the redirect limit");
294
- }
295
334
  function safeJson(text) {
296
335
  try {
297
336
  return JSON.parse(text);
@@ -0,0 +1,18 @@
1
+ import { type Response } from "undici";
2
+ /** How long a download may stall (before answering, or mid-file) before it fails. */
3
+ export declare const DOWNLOAD_TIMEOUT_MS = 60000;
4
+ export declare function isBlockedAddress(address: string): boolean;
5
+ /** Refuse URLs that point at private or special addresses, or at unusual ports. */
6
+ export declare function assertPublicUrl(url: URL): void;
7
+ export declare function isHtmlResponse(response: {
8
+ headers: {
9
+ get(name: string): string | null;
10
+ };
11
+ }): boolean;
12
+ /**
13
+ * Fetch an external URL the way a signed-out browser would: an isolated
14
+ * in-memory cookie jar, redirects followed across hosts, and no Wilma
15
+ * credentials anywhere. Returns null when every candidate answered with an
16
+ * HTML page instead of a file; throws for HTTP errors and refused addresses.
17
+ */
18
+ export declare function fetchExternalFile(rawUrl: string): Promise<Response | null>;