@wilm-ai/wilma-client 0.0.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.
@@ -0,0 +1,205 @@
1
+ import { CookieJar } from "tough-cookie";
2
+ import { fetch } from "undici";
3
+ export class AuthenticationError extends Error {
4
+ }
5
+ export class APIError extends Error {
6
+ status;
7
+ constructor(message, status) {
8
+ super(message);
9
+ this.status = status;
10
+ }
11
+ }
12
+ const 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";
14
+ const LOGIN_FAIL_RE = /loginFailed/i;
15
+ export class WilmaSession {
16
+ baseUrl;
17
+ cookieJar;
18
+ loggedIn = false;
19
+ username;
20
+ password;
21
+ studentNumber;
22
+ debug = false;
23
+ constructor(baseUrl, opts) {
24
+ this.baseUrl = baseUrl.replace(/\/$/, "");
25
+ this.cookieJar = new CookieJar();
26
+ this.studentNumber = opts?.studentNumber ?? null;
27
+ this.debug = Boolean(opts?.debug);
28
+ }
29
+ get urlPrefix() {
30
+ return this.studentNumber ? `!${this.studentNumber}` : null;
31
+ }
32
+ getPrefixedPath(path) {
33
+ if (this.urlPrefix && !path.startsWith("/!")) {
34
+ if (path.startsWith("/")) {
35
+ return `/${this.urlPrefix}${path}`;
36
+ }
37
+ return `/${this.urlPrefix}/${path}`;
38
+ }
39
+ return path;
40
+ }
41
+ async login(username, password) {
42
+ if (this.loggedIn) {
43
+ return;
44
+ }
45
+ const loginFields = await this.getLoginFormFields();
46
+ let sessionId = loginFields.SESSIONID;
47
+ if (!sessionId) {
48
+ sessionId = await this.getLoginToken();
49
+ }
50
+ const form = new URLSearchParams({
51
+ ...loginFields,
52
+ Login: username,
53
+ Password: password,
54
+ SESSIONID: sessionId,
55
+ });
56
+ const resp = await this.rawRequest("/login", {
57
+ method: "POST",
58
+ body: form.toString(),
59
+ headers: {
60
+ "Content-Type": "application/x-www-form-urlencoded",
61
+ },
62
+ redirect: "manual",
63
+ });
64
+ const hasSessionCookie = this.cookieJar
65
+ .getCookiesSync(this.baseUrl)
66
+ .some((c) => c.key === "Wilma2SID");
67
+ const text = await resp.text();
68
+ if (hasSessionCookie || isLoginOk(text)) {
69
+ this.loggedIn = true;
70
+ this.username = username;
71
+ this.password = password;
72
+ return;
73
+ }
74
+ throw new AuthenticationError("Wilma login failed");
75
+ }
76
+ async getLoginFormFields() {
77
+ const resp = await this.rawRequest("/login", { method: "GET" });
78
+ if (resp.status >= 400) {
79
+ return {};
80
+ }
81
+ const html = await resp.text();
82
+ return parseLoginFormFields(html);
83
+ }
84
+ async request(path, init) {
85
+ if (!this.loggedIn) {
86
+ throw new AuthenticationError("WilmaSession not logged in – call login() first");
87
+ }
88
+ const prefixedPath = this.getPrefixedPath(path);
89
+ let resp = await this.rawRequest(prefixedPath, init);
90
+ if (resp.status === 401 && this.username && this.password) {
91
+ this.loggedIn = false;
92
+ await this.login(this.username, this.password);
93
+ resp = await this.rawRequest(prefixedPath, init);
94
+ }
95
+ if (resp.status >= 400) {
96
+ throw new APIError(`Wilma HTTP ${resp.status} at ${prefixedPath}`, resp.status);
97
+ }
98
+ return resp;
99
+ }
100
+ async get(path, init) {
101
+ return this.request(path, { ...init, method: "GET" });
102
+ }
103
+ async post(path, init) {
104
+ return this.request(path, { ...init, method: "POST" });
105
+ }
106
+ async getLoginToken() {
107
+ const resp = await this.rawRequest("/token", { method: "GET" });
108
+ if (resp.status !== 200) {
109
+ throw new AuthenticationError("/token fetch failed");
110
+ }
111
+ const text = await resp.text();
112
+ try {
113
+ const data = JSON.parse(text);
114
+ if (data.Wilma2LoginID) {
115
+ return data.Wilma2LoginID;
116
+ }
117
+ }
118
+ catch {
119
+ // fall through to regex
120
+ }
121
+ const match = /"Wilma2LoginID"\s*:\s*"([^"\s]+)"/.exec(text);
122
+ if (!match) {
123
+ throw new AuthenticationError("Wilma2LoginID not found in /token response");
124
+ }
125
+ return match[1];
126
+ }
127
+ async rawRequest(path, init) {
128
+ const url = new URL(path, this.baseUrl).toString();
129
+ const cookieHeader = this.cookieJar.getCookieStringSync(url);
130
+ const headers = new Headers();
131
+ if (init?.headers) {
132
+ const incoming = init.headers;
133
+ if (incoming instanceof Headers) {
134
+ incoming.forEach((value, key) => headers.set(key, value));
135
+ }
136
+ else if (Array.isArray(incoming)) {
137
+ for (const entry of incoming) {
138
+ if (entry.length >= 2) {
139
+ headers.set(entry[0], entry[1]);
140
+ }
141
+ }
142
+ }
143
+ else {
144
+ for (const [key, value] of Object.entries(incoming)) {
145
+ if (value !== undefined) {
146
+ headers.set(key, String(value));
147
+ }
148
+ }
149
+ }
150
+ }
151
+ headers.set("User-Agent", USER_AGENT);
152
+ headers.set("Referer", `${this.baseUrl}/`);
153
+ if (cookieHeader) {
154
+ headers.set("Cookie", cookieHeader);
155
+ }
156
+ if (this.debug) {
157
+ const method = init?.method ?? "GET";
158
+ // Avoid logging sensitive headers or body
159
+ console.log(`[wilmai] ${method} ${url}`);
160
+ }
161
+ const resp = await fetch(url, {
162
+ ...init,
163
+ headers,
164
+ });
165
+ const headersAny = resp.headers;
166
+ const setCookies = headersAny.getSetCookie?.() ?? [];
167
+ if (setCookies.length) {
168
+ for (const cookie of setCookies) {
169
+ this.cookieJar.setCookieSync(cookie, url);
170
+ }
171
+ }
172
+ else {
173
+ const single = resp.headers.get("set-cookie");
174
+ if (single) {
175
+ this.cookieJar.setCookieSync(single, url);
176
+ }
177
+ }
178
+ return resp;
179
+ }
180
+ }
181
+ function isLoginOk(text) {
182
+ return !LOGIN_FAIL_RE.test(text);
183
+ }
184
+ function parseLoginFormFields(html) {
185
+ const fields = {};
186
+ const inputRegex = /<input[^>]+>/gi;
187
+ const nameRegex = /name=['"]([^'"]+)['"]/i;
188
+ const valueRegex = /value=['"]([^'"]*)['"]/i;
189
+ const typeRegex = /type=['"]([^'"]+)['"]/i;
190
+ const matches = html.match(inputRegex) ?? [];
191
+ for (const tag of matches) {
192
+ const name = nameRegex.exec(tag)?.[1];
193
+ if (!name)
194
+ continue;
195
+ const type = typeRegex.exec(tag)?.[1]?.toLowerCase() ?? "text";
196
+ if (name === "Login" || name === "Password") {
197
+ continue;
198
+ }
199
+ if (type === "hidden" || type === "submit") {
200
+ const value = valueRegex.exec(tag)?.[1] ?? "";
201
+ fields[name] = value;
202
+ }
203
+ }
204
+ return fields;
205
+ }
@@ -0,0 +1,5 @@
1
+ import type { TenantDiscoveryResponse, TenantInfo } from "./types.js";
2
+ export declare function loadTenantDiscovery(): Promise<TenantDiscoveryResponse>;
3
+ export declare function listTenants(): Promise<TenantInfo[]>;
4
+ export declare function searchTenantsByMunicipality(municipality: string, limit?: number): Promise<TenantInfo[]>;
5
+ export declare function findTenantByUrl(url: string): Promise<TenantInfo | null>;
@@ -0,0 +1,35 @@
1
+ import { readFile } from "node:fs/promises";
2
+ const TENANT_LIST_URL = new URL("../tenant_list.json", import.meta.url);
3
+ export async function loadTenantDiscovery() {
4
+ const raw = await readFile(TENANT_LIST_URL, "utf-8");
5
+ const data = JSON.parse(raw);
6
+ if (!data || !Array.isArray(data.wilmat)) {
7
+ throw new Error("Invalid tenant list format");
8
+ }
9
+ return data;
10
+ }
11
+ export async function listTenants() {
12
+ const data = await loadTenantDiscovery();
13
+ return data.wilmat;
14
+ }
15
+ export async function searchTenantsByMunicipality(municipality, limit = 50) {
16
+ const data = await loadTenantDiscovery();
17
+ const needle = (municipality ?? "").toLowerCase();
18
+ const results = data.wilmat.filter((tenant) => {
19
+ const municipalities = Array.isArray(tenant.municipalities) ? tenant.municipalities : [];
20
+ return municipalities.some((m) => {
21
+ if (!m) {
22
+ return false;
23
+ }
24
+ const nameFi = (m.nameFi ?? "").toLowerCase();
25
+ const nameSv = (m.nameSv ?? "").toLowerCase();
26
+ return nameFi.includes(needle) || nameSv.includes(needle);
27
+ });
28
+ });
29
+ return results.slice(0, limit);
30
+ }
31
+ export async function findTenantByUrl(url) {
32
+ const data = await loadTenantDiscovery();
33
+ const target = url.replace(/\/$/, "");
34
+ return data.wilmat.find((t) => t.url.replace(/\/$/, "") === target) ?? null;
35
+ }
@@ -0,0 +1,57 @@
1
+ export type MessageFolder = "inbox" | "archive" | "outbox" | "drafts" | "appointments";
2
+ export interface Message {
3
+ wilmaId: number;
4
+ subject: string;
5
+ sentAt: Date;
6
+ folder: MessageFolder | string;
7
+ senderId?: number | null;
8
+ senderType?: number | null;
9
+ senderName?: string | null;
10
+ sendersJson?: Record<string, unknown> | null;
11
+ status?: number | null;
12
+ content?: string | null;
13
+ fetchedAt: Date;
14
+ }
15
+ export interface NewsItem {
16
+ wilmaId: number;
17
+ title: string;
18
+ subtitle?: string | null;
19
+ author?: string | null;
20
+ published?: Date | null;
21
+ content?: string | null;
22
+ fetchedAt: Date;
23
+ }
24
+ export interface Exam {
25
+ wilmaId: number;
26
+ examDate: Date;
27
+ subject: string;
28
+ description?: string | null;
29
+ teacher?: string | null;
30
+ notes?: string | null;
31
+ fetchedAt: Date;
32
+ }
33
+ export interface StudentInfo {
34
+ studentNumber: string;
35
+ name: string;
36
+ href: string;
37
+ }
38
+ export interface Municipality {
39
+ nameFi: string;
40
+ nameSv: string;
41
+ }
42
+ export interface TenantInfo {
43
+ url: string;
44
+ name: string;
45
+ municipalities: Municipality[];
46
+ formerUrl?: string | null;
47
+ }
48
+ export interface TenantDiscoveryResponse {
49
+ wilmat: TenantInfo[];
50
+ }
51
+ export interface WilmaProfile {
52
+ baseUrl: string;
53
+ username: string;
54
+ password: string;
55
+ studentNumber?: string | null;
56
+ debug?: boolean;
57
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@wilm-ai/wilma-client",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "tenant_list.json"
16
+ ],
17
+ "dependencies": {
18
+ "cheerio": "^1.0.0-rc.12",
19
+ "tough-cookie": "^4.1.4",
20
+ "undici": "^6.19.8"
21
+ },
22
+ "devDependencies": {
23
+ "typescript": "^5.6.3",
24
+ "@types/tough-cookie": "^4.0.5"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.json",
28
+ "lint": "echo 'add lint'",
29
+ "test": "echo 'add tests'",
30
+ "test:smoke": "tsc -p tsconfig.json && node test/smoke.mjs",
31
+ "test:live": "tsc -p tsconfig.json && node test/live.spec.mjs"
32
+ }
33
+ }