@ranzenjs/client 0.3.0 → 0.4.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/README.md +40 -2
- package/dist/apps/substitutions/parse.js +25 -1
- package/dist/apps/substitutions/types.d.ts +11 -2
- package/dist/kurswahl/connection.d.ts +34 -0
- package/dist/kurswahl/connection.js +126 -0
- package/dist/kurswahl/index.d.ts +30 -0
- package/dist/kurswahl/index.js +55 -0
- package/dist/kurswahl/parse.d.ts +13 -0
- package/dist/kurswahl/parse.js +149 -0
- package/dist/kurswahl/session.d.ts +29 -0
- package/dist/kurswahl/session.js +125 -0
- package/dist/kurswahl/sign-in.d.ts +19 -0
- package/dist/kurswahl/sign-in.js +134 -0
- package/dist/kurswahl/state.d.ts +12 -0
- package/dist/kurswahl/state.js +43 -0
- package/dist/kurswahl/types.d.ts +68 -0
- package/dist/kurswahl/types.js +1 -0
- package/dist/rotation.d.ts +2 -0
- package/dist/rotation.js +1 -0
- package/dist/state.d.ts +2 -0
- package/dist/state.js +8 -4
- package/dist/transport/cookies.js +15 -1
- package/dist/transport/http.d.ts +3 -0
- package/dist/transport/http.js +2 -2
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -7,8 +7,8 @@ does, keeps the session alive, and reads the portal's apps into plain, typed obj
|
|
|
7
7
|
bun add @ranzenjs/client
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
Runs on Bun and Node 22.1 or newer. It has
|
|
11
|
-
css-select) and no native code.
|
|
10
|
+
Runs on Bun and Node 22.1 or newer. It has four small dependencies (htmlparser2, domhandler,
|
|
11
|
+
css-select, and hash-wasm for Kurswahl Online) and no native code.
|
|
12
12
|
|
|
13
13
|
## Usage
|
|
14
14
|
|
|
@@ -81,6 +81,44 @@ that a session still works, so sign in again when one reports `SESSION_EXPIRED`.
|
|
|
81
81
|
`maxConcurrency` (default 4) caps parallel requests per connection. Extra calls wait for a free
|
|
82
82
|
slot, and a download frees its slot once the headers arrive.
|
|
83
83
|
|
|
84
|
+
## Kurswahl Online
|
|
85
|
+
|
|
86
|
+
Many schools run their course timetable on [Kurswahl Online](https://www.kurswahl-online.de/).
|
|
87
|
+
Its client stands on its own: a school may use it, Schulportal, or both, and neither needs the
|
|
88
|
+
other.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { createKurswahl } from '@ranzenjs/client/kurswahl';
|
|
92
|
+
|
|
93
|
+
const kurswahl = createKurswahl({ onDrift: (drift) => console.warn('ranzen drift', drift) });
|
|
94
|
+
const schools = await kurswahl.schools(); // [{ id: 'musterschule', name: '…' }, …], no sign-in needed
|
|
95
|
+
const connection = await kurswahl.login({
|
|
96
|
+
school: 'musterschule',
|
|
97
|
+
username: 'max.muster',
|
|
98
|
+
password
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const plan = await connection.timetable(); // the general plan, A and B weeks included
|
|
102
|
+
const week = await connection.timetable({ week: '2025-08-25' }); // one week, one rotation
|
|
103
|
+
const rotation = await connection.currentRotation(); // 'A', 'B', or null; this week
|
|
104
|
+
await connection.logout();
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Kurswahl lists only the courses the student takes, so treat it as the source of truth for them.
|
|
108
|
+
Each lesson names its course, ISO weekday, period, room and `rotation` (`'A'`, `'B'`, or `null`
|
|
109
|
+
for every week). Teachers come with their short code, which Schulportal uses too, and their
|
|
110
|
+
salutation. Sign-in hashes the password with Argon2 the way the login page does, which takes a few
|
|
111
|
+
seconds of CPU. The week list can still show last school year's dates, so ask
|
|
112
|
+
`currentRotation()` for this week's rotation rather than picking a week by date.
|
|
113
|
+
|
|
114
|
+
`exportState()`, `restore()`, `keepAlive()`, `keepWarm()` and `dispose()` work as they do for
|
|
115
|
+
Schulportal, with requests one at a time, since each page hands out the next CSRF token. Export
|
|
116
|
+
once the connection is idle, and let one connection at a time use a session.
|
|
117
|
+
|
|
118
|
+
To line both up, substitution days carry `rotation` next to their `week` label, and each
|
|
119
|
+
substitution has `periodNumbers` (`"3 - 4"` → `[3, 4]`). Break duties such as `"2/3"` or `"MP"`
|
|
120
|
+
have none.
|
|
121
|
+
|
|
84
122
|
## Errors
|
|
85
123
|
|
|
86
124
|
ranzen throws a `RanzenError` with a `code` and a `fault`. The fault says who has to act:
|
|
@@ -46,10 +46,12 @@ export function parsePlan(document, drift = ignoreDrift) {
|
|
|
46
46
|
const entries = rows(table).filter((row) => !isPlaceholder(row));
|
|
47
47
|
const stamp = /(\d{2}\.\d{2}\.\d{4}) um ([01]\d|2[0-3]):([0-5]\d):([0-5]\d)/.exec(text(panel));
|
|
48
48
|
const stampDay = stamp && isoDate(stamp[1]);
|
|
49
|
+
const week = text(one('.badge.woche', panel)) || null;
|
|
49
50
|
return [
|
|
50
51
|
{
|
|
51
52
|
date,
|
|
52
|
-
week
|
|
53
|
+
week,
|
|
54
|
+
rotation: rotation(week, drift),
|
|
53
55
|
notices: parseNotices(panel),
|
|
54
56
|
substitutions: entries.map((row) => {
|
|
55
57
|
const columns = cells(row);
|
|
@@ -98,6 +100,7 @@ function substitution(values, date, marked, drift) {
|
|
|
98
100
|
const entry = {
|
|
99
101
|
date,
|
|
100
102
|
periods: values.Stunde ?? '',
|
|
103
|
+
periodNumbers: periodNumbers(values.Stunde ?? '', drift),
|
|
101
104
|
classes: null,
|
|
102
105
|
previousClasses: null,
|
|
103
106
|
substitute: null,
|
|
@@ -126,6 +129,27 @@ function substitution(values, date, marked, drift) {
|
|
|
126
129
|
});
|
|
127
130
|
return entry;
|
|
128
131
|
}
|
|
132
|
+
// "A-Woche" or "WA-Woche" → "A".
|
|
133
|
+
function rotation(label, drift) {
|
|
134
|
+
if (!label)
|
|
135
|
+
return null;
|
|
136
|
+
const match = /^W?([AB])-?Woche$/i.exec(label);
|
|
137
|
+
if (!match)
|
|
138
|
+
drift('A plan day has an unknown week label.');
|
|
139
|
+
return match ? match[1].toUpperCase() : null;
|
|
140
|
+
}
|
|
141
|
+
// "3 - 4" → [3, 4]; break duties such as "2/3" (between periods 2 and 3) or "MP" (lunch) hold no lesson.
|
|
142
|
+
function periodNumbers(label, drift) {
|
|
143
|
+
const range = /^(\d{1,2})\.?(?:\s*[-–]\s*(\d{1,2})\.?)?$/.exec(label.trim());
|
|
144
|
+
const first = Number(range?.[1]);
|
|
145
|
+
const last = Number(range?.[2] ?? range?.[1]);
|
|
146
|
+
if (range && last >= first)
|
|
147
|
+
return Array.from({ length: last - first + 1 }, (_, i) => first + i);
|
|
148
|
+
if (!/^(?:\d{1,2}\s*\/\s*\d{1,2}|MP)$/i.test(label.trim())) {
|
|
149
|
+
drift('A substitution has periods in an unknown format.');
|
|
150
|
+
}
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
129
153
|
// An empty day is one cell spanning the table, e.g. "Keine Einträge!".
|
|
130
154
|
function isPlaceholder(row) {
|
|
131
155
|
const [only, ...rest] = cells(row);
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import type { Rotation } from '../../rotation.js';
|
|
2
|
+
export type { Rotation };
|
|
1
3
|
export interface Substitution {
|
|
2
4
|
/** ISO calendar date. */
|
|
3
5
|
date: string;
|
|
4
|
-
/** As shown by the portal, e.g. "3 - 4". */
|
|
6
|
+
/** As shown by the portal, e.g. "3 - 4", "2/3" or "MP". */
|
|
5
7
|
periods: string;
|
|
8
|
+
/**
|
|
9
|
+
* The lesson periods, e.g. [3, 4] for "3 - 4". Empty for break duties such as "2/3"
|
|
10
|
+
* (between periods 2 and 3) or "MP" (lunch break), and for labels ranzen cannot read.
|
|
11
|
+
*/
|
|
12
|
+
periodNumbers: number[];
|
|
6
13
|
classes: string | null;
|
|
7
14
|
previousClasses: string | null;
|
|
8
15
|
substitute: string | null;
|
|
@@ -27,8 +34,10 @@ export interface Notice {
|
|
|
27
34
|
}
|
|
28
35
|
export interface Day {
|
|
29
36
|
date: string;
|
|
30
|
-
/** The school's week label, e.g. "A-Woche". */
|
|
37
|
+
/** The school's week label, e.g. "A-Woche" or "WA-Woche". */
|
|
31
38
|
week: string | null;
|
|
39
|
+
/** The week label as A/B rotation; null without a label or for one ranzen cannot read. */
|
|
40
|
+
rotation: Rotation | null;
|
|
32
41
|
notices: Notice[];
|
|
33
42
|
substitutions: Substitution[];
|
|
34
43
|
/** Local portal time, e.g. "2026-10-16T11:58:40". */
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Drift } from '../app.js';
|
|
2
|
+
import type { SignalOption } from '../connection.js';
|
|
3
|
+
import type { KurswahlAccount, KurswahlSession } from './session.js';
|
|
4
|
+
import type { Rotation, Timetable } from './types.js';
|
|
5
|
+
export interface TimetableOptions extends SignalOption {
|
|
6
|
+
/** First day of a week the portal offers, as ISO date, e.g. "2025-08-25"; omit for the general plan. */
|
|
7
|
+
week?: string;
|
|
8
|
+
}
|
|
9
|
+
/** One signed-in Kurswahl session. Save it with exportState(), end it with logout(). */
|
|
10
|
+
export declare class KurswahlConnection {
|
|
11
|
+
#private;
|
|
12
|
+
constructor(session: KurswahlSession, onDrift?: (drift: Drift) => void);
|
|
13
|
+
get account(): KurswahlAccount;
|
|
14
|
+
/**
|
|
15
|
+
* The personal timetable. The general plan lists every lesson, both rotations included;
|
|
16
|
+
* a dated week shows only the lessons of that week's rotation.
|
|
17
|
+
*/
|
|
18
|
+
timetable({ week, signal }?: TimetableOptions): Promise<Timetable>;
|
|
19
|
+
/**
|
|
20
|
+
* This week's rotation, read from the plan Kurswahl shows by default, which is the current week.
|
|
21
|
+
* Null when that week has no A/B lessons, e.g. for a student without alternating courses.
|
|
22
|
+
*/
|
|
23
|
+
currentRotation({ signal }?: SignalOption): Promise<Rotation | null>;
|
|
24
|
+
/** Extends the session; throws SESSION_EXPIRED once Kurswahl has ended it. */
|
|
25
|
+
keepAlive({ signal }?: SignalOption): Promise<void>;
|
|
26
|
+
/** Pings Kurswahl on an interval, as its pages do; stops on dispose() or a lasting failure. */
|
|
27
|
+
keepWarm(intervalMs?: number): void;
|
|
28
|
+
/** Cookies, CSRF token and identity; never the password. Store it encrypted. */
|
|
29
|
+
exportState(): string;
|
|
30
|
+
/** Stops keepWarm() and cancels pending requests; the session stays valid for restore(). */
|
|
31
|
+
dispose(): void;
|
|
32
|
+
/** Leaves through the logout link, then disposes. */
|
|
33
|
+
logout({ signal }?: SignalOption): Promise<void>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { fail, isRanzenError, layoutChanged } from '../errors.js';
|
|
2
|
+
import { positive } from '../util.js';
|
|
3
|
+
import { parseHtml } from '../html.js';
|
|
4
|
+
import { isGeneralPlan, parseTimetable, timetableDatabase } from './parse.js';
|
|
5
|
+
import { writeKurswahlState } from './state.js';
|
|
6
|
+
/** One signed-in Kurswahl session. Save it with exportState(), end it with logout(). */
|
|
7
|
+
export class KurswahlConnection {
|
|
8
|
+
#session;
|
|
9
|
+
#onDrift;
|
|
10
|
+
#drifted = new Set();
|
|
11
|
+
#heartbeat;
|
|
12
|
+
#beating = false;
|
|
13
|
+
constructor(session, onDrift) {
|
|
14
|
+
this.#session = session;
|
|
15
|
+
this.#onDrift = onDrift;
|
|
16
|
+
}
|
|
17
|
+
get account() {
|
|
18
|
+
return this.#session.account;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The personal timetable. The general plan lists every lesson, both rotations included;
|
|
22
|
+
* a dated week shows only the lessons of that week's rotation.
|
|
23
|
+
*/
|
|
24
|
+
async timetable({ week, signal } = {}) {
|
|
25
|
+
const value = week === undefined ? '0' : weekValue(week);
|
|
26
|
+
const session = this.#session;
|
|
27
|
+
// The timetable form names its database; the first visit learns it.
|
|
28
|
+
if (!session.database) {
|
|
29
|
+
const first = await session.page('stundenplan.php', {}, signal);
|
|
30
|
+
session.database = timetableDatabase(parseHtml(first.text));
|
|
31
|
+
}
|
|
32
|
+
const { text } = await session.page('stundenplan.php', { woche: value, db_name: session.database, show_optimierung: 'false' }, signal);
|
|
33
|
+
const timetable = parseTimetable(text, (detail) => this.#drift(detail));
|
|
34
|
+
session.database = timetable.database;
|
|
35
|
+
if (week !== undefined && timetable.week?.start !== week) {
|
|
36
|
+
throw fail('NOT_FOUND', 'Kurswahl Online does not offer this week.');
|
|
37
|
+
}
|
|
38
|
+
// A single week holds only one rotation, so passing it off as the general plan would lose lessons.
|
|
39
|
+
if (week === undefined && !isGeneralPlan(text))
|
|
40
|
+
throw layoutChanged('the general plan');
|
|
41
|
+
return timetable;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* This week's rotation, read from the plan Kurswahl shows by default, which is the current week.
|
|
45
|
+
* Null when that week has no A/B lessons, e.g. for a student without alternating courses.
|
|
46
|
+
*/
|
|
47
|
+
async currentRotation({ signal } = {}) {
|
|
48
|
+
const { text } = await this.#session.page('stundenplan.php', {}, signal);
|
|
49
|
+
const timetable = parseTimetable(text, (detail) => this.#drift(detail));
|
|
50
|
+
this.#session.database = timetable.database;
|
|
51
|
+
if (timetable.week || isGeneralPlan(text))
|
|
52
|
+
throw layoutChanged('the current week');
|
|
53
|
+
const rotations = new Set(timetable.lessons.map((lesson) => lesson.rotation));
|
|
54
|
+
rotations.delete(null);
|
|
55
|
+
if (rotations.size > 1)
|
|
56
|
+
throw layoutChanged('a single rotation in the current week');
|
|
57
|
+
return [...rotations][0] ?? null;
|
|
58
|
+
}
|
|
59
|
+
/** Extends the session; throws SESSION_EXPIRED once Kurswahl has ended it. */
|
|
60
|
+
async keepAlive({ signal } = {}) {
|
|
61
|
+
await this.#session.ajax('index.php', { ajax: 'keepalive' }, signal);
|
|
62
|
+
}
|
|
63
|
+
/** Pings Kurswahl on an interval, as its pages do; stops on dispose() or a lasting failure. */
|
|
64
|
+
keepWarm(intervalMs = 60_000) {
|
|
65
|
+
this.#session.ensureOpen();
|
|
66
|
+
positive(intervalMs, 'intervalMs');
|
|
67
|
+
clearInterval(this.#heartbeat);
|
|
68
|
+
this.#heartbeat = setInterval(() => {
|
|
69
|
+
if (this.#beating)
|
|
70
|
+
return;
|
|
71
|
+
this.#beating = true;
|
|
72
|
+
this.keepAlive()
|
|
73
|
+
.catch((error) => {
|
|
74
|
+
if (!isRanzenError(error) || !error.retryable)
|
|
75
|
+
clearInterval(this.#heartbeat);
|
|
76
|
+
})
|
|
77
|
+
.finally(() => (this.#beating = false));
|
|
78
|
+
}, intervalMs);
|
|
79
|
+
this.#heartbeat.unref();
|
|
80
|
+
}
|
|
81
|
+
/** Cookies, CSRF token and identity; never the password. Store it encrypted. */
|
|
82
|
+
exportState() {
|
|
83
|
+
const session = this.#session;
|
|
84
|
+
session.ensureOpen();
|
|
85
|
+
return writeKurswahlState({
|
|
86
|
+
account: session.account,
|
|
87
|
+
token: session.token,
|
|
88
|
+
database: session.database,
|
|
89
|
+
cookies: session.transport.jar.snapshot()
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** Stops keepWarm() and cancels pending requests; the session stays valid for restore(). */
|
|
93
|
+
dispose() {
|
|
94
|
+
clearInterval(this.#heartbeat);
|
|
95
|
+
this.#heartbeat = undefined;
|
|
96
|
+
this.#session.dispose();
|
|
97
|
+
}
|
|
98
|
+
/** Leaves through the logout link, then disposes. */
|
|
99
|
+
async logout({ signal } = {}) {
|
|
100
|
+
try {
|
|
101
|
+
await this.#session.leave(signal);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
this.dispose();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Once per kind and connection, so a timetable read in a loop does not flood the hook.
|
|
108
|
+
#drift(detail) {
|
|
109
|
+
if (!this.#onDrift || this.#drifted.has(detail))
|
|
110
|
+
return;
|
|
111
|
+
this.#drifted.add(detail);
|
|
112
|
+
try {
|
|
113
|
+
this.#onDrift({ app: 'kurswahl', detail });
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// A failing log hook must not fail a read that succeeded.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// "2025-08-25" → "25_08_2025", the portal's week value.
|
|
121
|
+
function weekValue(week) {
|
|
122
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(week);
|
|
123
|
+
if (!match)
|
|
124
|
+
throw fail('INVALID_OPTION', 'week must be an ISO date such as "2025-08-25".');
|
|
125
|
+
return `${match[3]}_${match[2]}_${match[1]}`;
|
|
126
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Drift } from '../app.js';
|
|
2
|
+
import type { SignalOption } from '../connection.js';
|
|
3
|
+
import { type FetchFunction } from '../transport/http.js';
|
|
4
|
+
import { KurswahlConnection } from './connection.js';
|
|
5
|
+
import { type KurswahlCredentials } from './sign-in.js';
|
|
6
|
+
import type { School } from './types.js';
|
|
7
|
+
export { KurswahlConnection, type TimetableOptions } from './connection.js';
|
|
8
|
+
export type { KurswahlAccount } from './session.js';
|
|
9
|
+
export type { KurswahlCredentials } from './sign-in.js';
|
|
10
|
+
export type * from './types.js';
|
|
11
|
+
export interface KurswahlOptions {
|
|
12
|
+
/** Replaces the global fetch, e.g. for a proxy or tests. */
|
|
13
|
+
fetch?: FetchFunction;
|
|
14
|
+
/** Must look like a browser's, or Kurswahl Online refuses; defaults to a current Firefox. */
|
|
15
|
+
userAgent?: string;
|
|
16
|
+
/** Milliseconds per request, including the wait for each body chunk; defaults to 15000. */
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
/** Called once per connection for each kind of drift; log it to learn that ranzen needs an update. */
|
|
19
|
+
onDrift?: (drift: Drift) => void;
|
|
20
|
+
}
|
|
21
|
+
export interface Kurswahl {
|
|
22
|
+
/** Every school on Kurswahl Online; no sign-in needed. */
|
|
23
|
+
schools(options?: SignalOption): Promise<School[]>;
|
|
24
|
+
/** Signs in like the login page does, Argon2 hashing included (about a second of CPU). */
|
|
25
|
+
login(credentials: KurswahlCredentials, options?: SignalOption): Promise<KurswahlConnection>;
|
|
26
|
+
/** Validates and rebuilds a connection from exportState() without any request. */
|
|
27
|
+
restore(state: string): KurswahlConnection;
|
|
28
|
+
}
|
|
29
|
+
/** A Kurswahl Online client, independent of any Schulportal connection; creating it makes no request. */
|
|
30
|
+
export declare function createKurswahl(options?: KurswahlOptions): Kurswahl;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { fail, layoutChanged } from '../errors.js';
|
|
2
|
+
import { CookieJar } from '../transport/cookies.js';
|
|
3
|
+
import { Transport } from '../transport/http.js';
|
|
4
|
+
import { isRecord, parseJson, positive } from '../util.js';
|
|
5
|
+
import { KurswahlConnection } from './connection.js';
|
|
6
|
+
import { KurswahlSession, WWW } from './session.js';
|
|
7
|
+
import { signIn } from './sign-in.js';
|
|
8
|
+
import { isSchool, readKurswahlState } from './state.js';
|
|
9
|
+
export { KurswahlConnection } from './connection.js';
|
|
10
|
+
// Kurswahl Online sends any client it does not take for a browser to its main site.
|
|
11
|
+
const BROWSER = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0';
|
|
12
|
+
/** A Kurswahl Online client, independent of any Schulportal connection; creating it makes no request. */
|
|
13
|
+
export function createKurswahl(options = {}) {
|
|
14
|
+
const base = {
|
|
15
|
+
fetch: options.fetch ?? ((url, init) => fetch(url, init)),
|
|
16
|
+
userAgent: options.userAgent ?? BROWSER,
|
|
17
|
+
timeoutMs: positive(options.timeoutMs ?? 15_000, 'timeoutMs'),
|
|
18
|
+
maxConcurrency: 1
|
|
19
|
+
};
|
|
20
|
+
// One host per transport, so a signed-in session never reaches the main site or another school.
|
|
21
|
+
const settings = (school) => ({
|
|
22
|
+
...base,
|
|
23
|
+
hosts: new Set([school ? `${school}.kurswahl-online.de` : new URL(WWW).host])
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
async schools({ signal } = {}) {
|
|
27
|
+
const reply = await new Transport(settings(null)).send({
|
|
28
|
+
url: `${WWW}/ajax.php`,
|
|
29
|
+
query: { type: 'schulen' },
|
|
30
|
+
signal
|
|
31
|
+
});
|
|
32
|
+
const data = parseJson(reply.text, 'the school list');
|
|
33
|
+
if (!isRecord(data) || Array.isArray(data))
|
|
34
|
+
throw layoutChanged('the school list as an object');
|
|
35
|
+
return Object.entries(data).flatMap(([id, name]) => isSchool(id) && typeof name === 'string' ? [{ id, name: name.trim() }] : []);
|
|
36
|
+
},
|
|
37
|
+
async login(credentials, { signal } = {}) {
|
|
38
|
+
for (const field of ['school', 'username', 'password']) {
|
|
39
|
+
if (typeof credentials[field] !== 'string' || !credentials[field]) {
|
|
40
|
+
throw fail('INVALID_OPTION', `${field} must be a non-empty string.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!isSchool(credentials.school)) {
|
|
44
|
+
throw fail('INVALID_OPTION', 'school must be a Kurswahl subdomain such as "musterschule".');
|
|
45
|
+
}
|
|
46
|
+
const session = await signIn(new Transport(settings(credentials.school)), credentials, signal);
|
|
47
|
+
return new KurswahlConnection(session, options.onDrift);
|
|
48
|
+
},
|
|
49
|
+
restore(state) {
|
|
50
|
+
const { account, token, database, cookies } = readKurswahlState(state);
|
|
51
|
+
const transport = new Transport(settings(account.school), CookieJar.restore(cookies));
|
|
52
|
+
return new KurswahlConnection(new KurswahlSession(account, transport, token, database), options.onDrift);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Document } from '../html.js';
|
|
2
|
+
import { type DriftSink } from '../util.js';
|
|
3
|
+
import type { Timetable } from './types.js';
|
|
4
|
+
/** The anti-CSRF token every Kurswahl page carries. */
|
|
5
|
+
export declare function csrfToken(html: string): string | null;
|
|
6
|
+
/** The timetable database the page's week form posts, e.g. "musterschule_stundenplan_1". */
|
|
7
|
+
export declare function timetableDatabase(doc: Document): string;
|
|
8
|
+
/** Kurswahl marks "allgemeiner Stundenplan" as selected only when it shows the general plan. */
|
|
9
|
+
export declare function isGeneralPlan(html: string): boolean;
|
|
10
|
+
/** Visible ASCII only, since the token travels in a header; shared with state validation. */
|
|
11
|
+
export declare function isToken(value: string): boolean;
|
|
12
|
+
export declare function isDatabase(value: string): boolean;
|
|
13
|
+
export declare function parseTimetable(html: string, drift: DriftSink): Timetable;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { layoutChanged } from '../errors.js';
|
|
2
|
+
import { all, attr, cells, hasClass, one, ownText, parseHtml, stripTags, text } from '../html.js';
|
|
3
|
+
import { isoDate } from '../util.js';
|
|
4
|
+
const DAYS = ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'];
|
|
5
|
+
// "<b>Leiste 1: LK 1: MATHEMATIK bei Muster, Frau<br>Raum: 101, Woche: A</b><br>Realkurs: 7"
|
|
6
|
+
const TOOLTIP = /^<b>\s*Leiste\s+(\d+):\s*(.*?)(?:\s+bei\s+(.*?))?\s*<br\s*\/?>\s*Raum:\s*(.*?)(?:,\s*Woche:\s*([AB]))?\s*<\/b>\s*<br\s*\/?>\s*Realkurs:\s*(\d+)\s*$/is;
|
|
7
|
+
// "2: LK 2: PH-LK (Mu)" or "18: TUT (Bsp)".
|
|
8
|
+
const LABEL = /^(\d+):\s*(?:LK\s*\d+:\s*)?(.+?)(?:\s*\(([^()]+)\))?$/;
|
|
9
|
+
// "27.08.26 12:12:51"
|
|
10
|
+
const STAMP = /(\d{2})\.(\d{2})\.(\d{2}|\d{4})\s+(\d{2}):(\d{2}):(\d{2})/g;
|
|
11
|
+
/** The anti-CSRF token every Kurswahl page carries. */
|
|
12
|
+
export function csrfToken(html) {
|
|
13
|
+
const token = attr(one('meta[name="anti_csrf_token"]', parseHtml(html)), 'content');
|
|
14
|
+
return token && isToken(token) ? token : null;
|
|
15
|
+
}
|
|
16
|
+
/** The timetable database the page's week form posts, e.g. "musterschule_stundenplan_1". */
|
|
17
|
+
export function timetableDatabase(doc) {
|
|
18
|
+
const database = attr(one('input[name="db_name"]', doc), 'value');
|
|
19
|
+
if (!database || !isDatabase(database))
|
|
20
|
+
throw layoutChanged('the timetable database field');
|
|
21
|
+
return database;
|
|
22
|
+
}
|
|
23
|
+
/** Kurswahl marks "allgemeiner Stundenplan" as selected only when it shows the general plan. */
|
|
24
|
+
export function isGeneralPlan(html) {
|
|
25
|
+
return all('select[name="woche"] option[selected]', parseHtml(html)).some((option) => attr(option, 'value') === '0');
|
|
26
|
+
}
|
|
27
|
+
/** Visible ASCII only, since the token travels in a header; shared with state validation. */
|
|
28
|
+
export function isToken(value) {
|
|
29
|
+
return /^[\x21-\x7e]{1,512}$/.test(value);
|
|
30
|
+
}
|
|
31
|
+
export function isDatabase(value) {
|
|
32
|
+
return /^[\w-]{1,200}$/.test(value);
|
|
33
|
+
}
|
|
34
|
+
export function parseTimetable(html, drift) {
|
|
35
|
+
const doc = parseHtml(html);
|
|
36
|
+
const database = timetableDatabase(doc);
|
|
37
|
+
const table = one('table.stundenplan', doc);
|
|
38
|
+
if (!table)
|
|
39
|
+
throw layoutChanged('the timetable table');
|
|
40
|
+
const [head, ...body] = all('tr', table);
|
|
41
|
+
const days = all('th', head ?? table)
|
|
42
|
+
.slice(1)
|
|
43
|
+
.map((th) => DAYS.indexOf(text(th).toLowerCase()) + 1);
|
|
44
|
+
if (!days.length || !days.every(isWeekday))
|
|
45
|
+
throw layoutChanged('weekday headers in the timetable');
|
|
46
|
+
const periods = [];
|
|
47
|
+
const courses = new Map();
|
|
48
|
+
const lessons = [];
|
|
49
|
+
for (const row of body) {
|
|
50
|
+
const [first, ...slots] = cells(row);
|
|
51
|
+
const match = /^(\d+)\.\s*(?:(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2}))?/.exec(text(first));
|
|
52
|
+
if (!match || slots.length !== days.length)
|
|
53
|
+
throw layoutChanged('timetable rows per period');
|
|
54
|
+
const period = Number(match[1]);
|
|
55
|
+
periods.push({ period, start: match[2] ?? null, end: match[3] ?? null });
|
|
56
|
+
slots.forEach((slot, index) => {
|
|
57
|
+
const entries = all('div[tooltip]', slot);
|
|
58
|
+
if (!entries.length && text(slot))
|
|
59
|
+
throw layoutChanged('course entries in timetable cells');
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const { course, room, rotation } = parseEntry(entry);
|
|
62
|
+
const known = courses.get(course.id);
|
|
63
|
+
if (!known)
|
|
64
|
+
courses.set(course.id, course);
|
|
65
|
+
else if (JSON.stringify(known) !== JSON.stringify(course)) {
|
|
66
|
+
drift('A course shows different details in different cells.');
|
|
67
|
+
}
|
|
68
|
+
lessons.push({ course: course.id, day: days[index], period, room, rotation });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const options = all('select[name="woche"] option', doc).map((option) => {
|
|
73
|
+
const value = attr(option, 'value') ?? '';
|
|
74
|
+
const week = parseWeek(value, text(option));
|
|
75
|
+
if (!week && value !== '0')
|
|
76
|
+
drift('A week option has an unknown format.');
|
|
77
|
+
return { value, week, selected: 'selected' in option.attribs };
|
|
78
|
+
});
|
|
79
|
+
// The general plan selects no option (or "0"); an unreadable dated week must not pass for it.
|
|
80
|
+
const chosen = options.find((option) => option.selected && option.value !== '0');
|
|
81
|
+
if (chosen && !chosen.week)
|
|
82
|
+
throw layoutChanged('the selected week');
|
|
83
|
+
return {
|
|
84
|
+
database,
|
|
85
|
+
week: chosen?.week ?? null,
|
|
86
|
+
weeks: options.flatMap((option) => (option.week ? [option.week] : [])),
|
|
87
|
+
periods,
|
|
88
|
+
courses: [...courses.values()],
|
|
89
|
+
lessons,
|
|
90
|
+
...changeTimes(doc)
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function parseEntry(entry) {
|
|
94
|
+
const tooltip = TOOLTIP.exec((attr(entry, 'tooltip') ?? '').trim());
|
|
95
|
+
const label = LABEL.exec(ownText(entry));
|
|
96
|
+
if (!tooltip || !label)
|
|
97
|
+
throw layoutChanged('a known course entry in the timetable');
|
|
98
|
+
const [, block = '', heading = '', teacher = '', room = '', week, id = ''] = tooltip;
|
|
99
|
+
const code = label[2] ?? '';
|
|
100
|
+
const level = /^(LK|GK)\b\s*(?:\d+:)?\s*(.*)$/.exec(stripTags(heading));
|
|
101
|
+
// The heading names the level; failing that, a "-LK"/"-GK" code or the cell's lk class does.
|
|
102
|
+
const kind = level?.[1] ?? /-(LK|GK)$/.exec(code)?.[1] ?? (hasClass(entry, 'lk') ? 'LK' : null);
|
|
103
|
+
const person = /^(.*?)(?:,\s*(Frau|Herr))?$/.exec(stripTags(teacher));
|
|
104
|
+
const shown = text(one('span', entry));
|
|
105
|
+
return {
|
|
106
|
+
course: {
|
|
107
|
+
id,
|
|
108
|
+
block: Number(block),
|
|
109
|
+
code,
|
|
110
|
+
title: (level ? level[2] : stripTags(heading)) || null,
|
|
111
|
+
level: kind,
|
|
112
|
+
teacher: {
|
|
113
|
+
code: label[3]?.trim() || null,
|
|
114
|
+
lastName: person?.[1] || null,
|
|
115
|
+
salutation: person?.[2] ?? null
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
room: stripTags(room) || shown.replace(/\[[AB]\]/, '').trim() || null,
|
|
119
|
+
rotation: (week ?? /\[([AB])\]/.exec(shown)?.[1] ?? null)
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function isWeekday(day) {
|
|
123
|
+
return day >= 1 && day <= 7;
|
|
124
|
+
}
|
|
125
|
+
function parseWeek(value, label) {
|
|
126
|
+
const [start, end] = label.split(/\s+-\s+/).map((part) => isoDate(part));
|
|
127
|
+
return /^\d{2}_\d{2}_\d{4}$/.test(value) && start && end ? { id: value, start, end } : null;
|
|
128
|
+
}
|
|
129
|
+
// "Letzte Änderung Stundenplan (Kurszuteilung): 27.08.26 12:12:51 (25.08.26 09:02:04)"
|
|
130
|
+
function changeTimes(doc) {
|
|
131
|
+
let updatedAt = null;
|
|
132
|
+
let assignedAt = null;
|
|
133
|
+
let examsUpdatedAt = null;
|
|
134
|
+
for (const row of all('tr', doc)) {
|
|
135
|
+
const [label = '', value = ''] = cells(row).map((cell) => text(cell));
|
|
136
|
+
if (!label.startsWith('Letzte Änderung'))
|
|
137
|
+
continue;
|
|
138
|
+
const [first = null, second = null] = [...value.matchAll(STAMP)].map(localTime);
|
|
139
|
+
if (label.includes('Klausurplan'))
|
|
140
|
+
examsUpdatedAt = first;
|
|
141
|
+
else if (label.includes('Stundenplan'))
|
|
142
|
+
[updatedAt, assignedAt] = [first, second];
|
|
143
|
+
}
|
|
144
|
+
return { updatedAt, assignedAt, examsUpdatedAt };
|
|
145
|
+
}
|
|
146
|
+
function localTime([, day, month, year = '', hours, minutes, seconds]) {
|
|
147
|
+
const date = isoDate(`${day}.${month}.${year.length === 2 ? `20${year}` : year}`);
|
|
148
|
+
return date && `${date}T${hours}:${minutes}:${seconds}`;
|
|
149
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Reply, Transport } from '../transport/http.js';
|
|
2
|
+
export interface KurswahlAccount {
|
|
3
|
+
/** The school's subdomain, e.g. "musterschule". */
|
|
4
|
+
readonly school: string;
|
|
5
|
+
readonly username: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const WWW = "https://www.kurswahl-online.de";
|
|
8
|
+
export declare function schoolUrl(school: string): string;
|
|
9
|
+
export declare class KurswahlSession {
|
|
10
|
+
#private;
|
|
11
|
+
readonly account: KurswahlAccount;
|
|
12
|
+
readonly transport: Transport;
|
|
13
|
+
token: string | null;
|
|
14
|
+
database: string | null;
|
|
15
|
+
constructor(account: KurswahlAccount, transport: Transport, token: string | null, database?: string | null);
|
|
16
|
+
get disposed(): boolean;
|
|
17
|
+
ensureOpen(): void;
|
|
18
|
+
dispose(): void;
|
|
19
|
+
/** A page the way post_link() opens it: a multipart POST carrying the current token. */
|
|
20
|
+
page(path: string, fields: Record<string, string>, signal?: AbortSignal): Promise<Reply>;
|
|
21
|
+
/** An XHR call; the token travels as a header, and a new one may come back the same way. */
|
|
22
|
+
ajax(path: string, form: Record<string, string>, signal?: AbortSignal): Promise<Reply>;
|
|
23
|
+
/** Leaves through the logout link, after any request still running. */
|
|
24
|
+
leave(signal?: AbortSignal): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
/** A token handed back in the XHR header, kept only if exported state would accept it. */
|
|
27
|
+
export declare function nextToken(reply: Reply, current: string | null): string | null;
|
|
28
|
+
/** The sign-in form, which Kurswahl serves in place of any page once the session is gone. */
|
|
29
|
+
export declare function isLoginPage(html: string): boolean;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { ensureOk } from '../app.js';
|
|
2
|
+
import { fail, isRanzenError, layoutChanged } from '../errors.js';
|
|
3
|
+
import { Limiter } from '../transport/limiter.js';
|
|
4
|
+
import { csrfToken, isToken } from './parse.js';
|
|
5
|
+
export const WWW = 'https://www.kurswahl-online.de';
|
|
6
|
+
export function schoolUrl(school) {
|
|
7
|
+
return `https://${school}.kurswahl-online.de`;
|
|
8
|
+
}
|
|
9
|
+
// Package-internal: one request at a time, since every reply hands out the next CSRF token.
|
|
10
|
+
export class KurswahlSession {
|
|
11
|
+
account;
|
|
12
|
+
transport;
|
|
13
|
+
token;
|
|
14
|
+
database;
|
|
15
|
+
#life = new AbortController();
|
|
16
|
+
#queue = new Limiter(1);
|
|
17
|
+
constructor(account, transport, token, database = null) {
|
|
18
|
+
this.account = account;
|
|
19
|
+
this.transport = transport;
|
|
20
|
+
this.token = token;
|
|
21
|
+
this.database = database;
|
|
22
|
+
}
|
|
23
|
+
get disposed() {
|
|
24
|
+
return this.#life.signal.aborted;
|
|
25
|
+
}
|
|
26
|
+
ensureOpen() {
|
|
27
|
+
if (this.disposed)
|
|
28
|
+
throw fail('DISPOSED', 'The connection was disposed.');
|
|
29
|
+
}
|
|
30
|
+
dispose() {
|
|
31
|
+
this.#life.abort();
|
|
32
|
+
this.transport.jar.clear();
|
|
33
|
+
}
|
|
34
|
+
/** A page the way post_link() opens it: a multipart POST carrying the current token. */
|
|
35
|
+
page(path, fields, signal) {
|
|
36
|
+
return this.#run(signal, async (bound) => {
|
|
37
|
+
const form = new FormData();
|
|
38
|
+
for (const [name, value] of Object.entries(fields))
|
|
39
|
+
form.set(name, value);
|
|
40
|
+
form.set('_scrollLeft', '0');
|
|
41
|
+
form.set('_scrollTop', '0');
|
|
42
|
+
form.set('_scrollLocation', `/${path}`);
|
|
43
|
+
if (this.token)
|
|
44
|
+
form.set('_anti_csrf_token', this.token);
|
|
45
|
+
const reply = await this.transport.send({
|
|
46
|
+
url: `${schoolUrl(this.account.school)}/${path}`,
|
|
47
|
+
method: 'POST',
|
|
48
|
+
multipart: form,
|
|
49
|
+
redirects: 'manual',
|
|
50
|
+
signal: bound
|
|
51
|
+
});
|
|
52
|
+
this.#check(reply, path);
|
|
53
|
+
// Every page hands out the token for the next request; going on with a stale one would fail later.
|
|
54
|
+
const token = csrfToken(reply.text);
|
|
55
|
+
if (!token)
|
|
56
|
+
throw layoutChanged('an anti-CSRF token on the page');
|
|
57
|
+
this.token = token;
|
|
58
|
+
return reply;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** An XHR call; the token travels as a header, and a new one may come back the same way. */
|
|
62
|
+
ajax(path, form, signal) {
|
|
63
|
+
return this.#run(signal, async (bound) => {
|
|
64
|
+
const headers = { 'x-requested-with': 'XMLHttpRequest' };
|
|
65
|
+
if (this.token)
|
|
66
|
+
headers['x-csrf-token'] = this.token;
|
|
67
|
+
const reply = await this.transport.send({
|
|
68
|
+
url: `${schoolUrl(this.account.school)}/${path}`,
|
|
69
|
+
method: 'POST',
|
|
70
|
+
form,
|
|
71
|
+
headers,
|
|
72
|
+
redirects: 'manual',
|
|
73
|
+
signal: bound
|
|
74
|
+
});
|
|
75
|
+
this.#check(reply, path);
|
|
76
|
+
this.token = nextToken(reply, this.token);
|
|
77
|
+
return reply;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** Leaves through the logout link, after any request still running. */
|
|
81
|
+
leave(signal) {
|
|
82
|
+
return this.#run(signal, async (bound) => {
|
|
83
|
+
const url = `${schoolUrl(this.account.school)}/login.php`;
|
|
84
|
+
const reply = await this.transport.send({ url, redirects: 'manual', signal: bound });
|
|
85
|
+
// A refused logout may leave the session alive, so it must not pass for success.
|
|
86
|
+
if (!reply.location)
|
|
87
|
+
ensureOk(reply.status, 'login.php');
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
// Kurswahl flags an ended session with X-LOGIN-FORM on XHR and a redirect to login.php on pages.
|
|
91
|
+
#check(reply, path) {
|
|
92
|
+
const toLogin = reply.location && /\/login\.php$|^\/?$/.test(reply.location.pathname);
|
|
93
|
+
if (reply.headers.get('x-login-form') === 'true' || toLogin || isLoginPage(reply.text)) {
|
|
94
|
+
throw fail('SESSION_EXPIRED', 'The Kurswahl session has ended.');
|
|
95
|
+
}
|
|
96
|
+
if (reply.location)
|
|
97
|
+
throw layoutChanged(`${path} to answer without a redirect`);
|
|
98
|
+
ensureOk(reply.status, path);
|
|
99
|
+
}
|
|
100
|
+
async #run(signal, work) {
|
|
101
|
+
this.ensureOpen();
|
|
102
|
+
const life = this.#life.signal;
|
|
103
|
+
const bound = signal ? AbortSignal.any([signal, life]) : life;
|
|
104
|
+
try {
|
|
105
|
+
return await this.#queue.run(() => work(bound), bound);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (isRanzenError(error) || !bound.aborted)
|
|
109
|
+
throw error;
|
|
110
|
+
throw fail('ABORTED', 'Request aborted.', error);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
this.ensureOpen();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** A token handed back in the XHR header, kept only if exported state would accept it. */
|
|
118
|
+
export function nextToken(reply, current) {
|
|
119
|
+
const token = reply.headers.get('x-csrf-token');
|
|
120
|
+
return token && isToken(token) ? token : current;
|
|
121
|
+
}
|
|
122
|
+
/** The sign-in form, which Kurswahl serves in place of any page once the session is gone. */
|
|
123
|
+
export function isLoginPage(html) {
|
|
124
|
+
return /<input\b[^>]*\bname\s*=\s*["']?realpasswort\b/i.test(html);
|
|
125
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Transport } from '../transport/http.js';
|
|
2
|
+
import { KurswahlSession } from './session.js';
|
|
3
|
+
export interface KurswahlCredentials {
|
|
4
|
+
/** The school's subdomain, e.g. "musterschule"; see schools(). */
|
|
5
|
+
school: string;
|
|
6
|
+
username: string;
|
|
7
|
+
password: string;
|
|
8
|
+
}
|
|
9
|
+
interface Argon2Parameters {
|
|
10
|
+
type: 1 | 2;
|
|
11
|
+
time: number;
|
|
12
|
+
mem: number;
|
|
13
|
+
parallelism: number;
|
|
14
|
+
hashLen: number;
|
|
15
|
+
}
|
|
16
|
+
/** Signs in like the login page's script does, hashing the password with Argon2 on this side. */
|
|
17
|
+
export declare function signIn(transport: Transport, { school, username, password }: KurswahlCredentials, signal: AbortSignal | undefined): Promise<KurswahlSession>;
|
|
18
|
+
export declare function argon2(password: string, salt: string, parameters: Argon2Parameters | null | undefined): Promise<string>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { argon2i, argon2id } from 'hash-wasm';
|
|
2
|
+
import { fail, layoutChanged } from '../errors.js';
|
|
3
|
+
import { isRecord, parseJson } from '../util.js';
|
|
4
|
+
import { csrfToken } from './parse.js';
|
|
5
|
+
import { isLoginPage, KurswahlSession, nextToken, schoolUrl } from './session.js';
|
|
6
|
+
/** Signs in like the login page's script does, hashing the password with Argon2 on this side. */
|
|
7
|
+
export async function signIn(transport, { school, username, password }, signal) {
|
|
8
|
+
const base = schoolUrl(school);
|
|
9
|
+
const login = await transport.send({ url: `${base}/login.php`, redirects: 'manual', signal });
|
|
10
|
+
// An unknown subdomain bounces to the main site.
|
|
11
|
+
if (login.location)
|
|
12
|
+
throw fail('NOT_FOUND', 'Kurswahl Online does not know this school.');
|
|
13
|
+
const versions = argon2Parameters(login.text);
|
|
14
|
+
let token = csrfToken(login.text);
|
|
15
|
+
// The login page's XHR carries the token as a header and may get the next one back.
|
|
16
|
+
const headers = { 'x-requested-with': 'XMLHttpRequest' };
|
|
17
|
+
if (token)
|
|
18
|
+
headers['x-csrf-token'] = token;
|
|
19
|
+
const answer = await transport.send({
|
|
20
|
+
url: `${base}/login.php`,
|
|
21
|
+
method: 'POST',
|
|
22
|
+
form: { ajax: 'argon2', username },
|
|
23
|
+
headers,
|
|
24
|
+
redirects: 'manual',
|
|
25
|
+
signal
|
|
26
|
+
});
|
|
27
|
+
token = nextToken(answer, token);
|
|
28
|
+
const { salts, rotating } = readSalts(parseJson(answer.text, 'the Argon2 salts'));
|
|
29
|
+
const latest = versions.length - 1;
|
|
30
|
+
const hash = (salt, version) => argon2(password, salt, versions[version]);
|
|
31
|
+
const hashes = await Promise.all(salts.map(async ({ version, raw, salt, upgradeSalt }) => {
|
|
32
|
+
const entry = {
|
|
33
|
+
version: raw,
|
|
34
|
+
hash: version === 0 ? password : await hash(salt, version),
|
|
35
|
+
rotating: false
|
|
36
|
+
};
|
|
37
|
+
if (upgradeSalt)
|
|
38
|
+
entry.upgrade_hash = await hash(upgradeSalt, latest);
|
|
39
|
+
return entry;
|
|
40
|
+
}));
|
|
41
|
+
if (rotating) {
|
|
42
|
+
hashes.push({
|
|
43
|
+
version: latest,
|
|
44
|
+
hash: await hash(rotating, latest),
|
|
45
|
+
upgrade_hash: null,
|
|
46
|
+
rotating: true
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const form = new FormData();
|
|
50
|
+
form.set('username', username);
|
|
51
|
+
form.set('passwort', '');
|
|
52
|
+
form.set('keep_active', 'on');
|
|
53
|
+
form.set('resolution', '1920x1080');
|
|
54
|
+
form.set('realpasswort', JSON.stringify(hashes));
|
|
55
|
+
form.set('_scrollLeft', '0');
|
|
56
|
+
form.set('_scrollTop', '0');
|
|
57
|
+
form.set('_scrollLocation', '/login.php');
|
|
58
|
+
if (token)
|
|
59
|
+
form.set('_anti_csrf_token', token);
|
|
60
|
+
const result = await transport.send({
|
|
61
|
+
url: `${base}/login.php`,
|
|
62
|
+
method: 'POST',
|
|
63
|
+
multipart: form,
|
|
64
|
+
redirects: 'manual',
|
|
65
|
+
signal
|
|
66
|
+
});
|
|
67
|
+
if (isLoginPage(result.text))
|
|
68
|
+
throw fail('AUTH_FAILED', 'Kurswahl Online rejected the login.');
|
|
69
|
+
// The answer is an interstitial whose script continues with post_link("index.php").
|
|
70
|
+
if (result.status !== 200 || !/post_link\(\s*["']index\.php["']/.test(result.text)) {
|
|
71
|
+
throw layoutChanged('the page that follows a Kurswahl login');
|
|
72
|
+
}
|
|
73
|
+
const account = { school, username };
|
|
74
|
+
const session = new KurswahlSession(account, transport, csrfToken(result.text) ?? token);
|
|
75
|
+
await session.page('index.php', {}, signal);
|
|
76
|
+
return session;
|
|
77
|
+
}
|
|
78
|
+
// kwo.settings("argon2_client_parameters", ["dummy for old schema", {type, time, mem, ...}, ...])
|
|
79
|
+
function argon2Parameters(html) {
|
|
80
|
+
const json = /kwo\.settings\(\s*"argon2_client_parameters"\s*,\s*(\[.*?\])\s*\)\s*;/s.exec(html)?.[1];
|
|
81
|
+
const list = json === undefined ? null : parseJson(json, 'the Argon2 parameters');
|
|
82
|
+
if (!Array.isArray(list) || list.length < 2)
|
|
83
|
+
throw layoutChanged('the Argon2 parameters');
|
|
84
|
+
return list.map((entry) => (isParameters(entry) ? entry : null));
|
|
85
|
+
}
|
|
86
|
+
// Far above today's cost (time 14, 56 MiB), so a broken page cannot exhaust the host.
|
|
87
|
+
const LIMITS = { time: 100, mem: 512 * 1024, parallelism: 16, hashLen: 256 };
|
|
88
|
+
function isParameters(value) {
|
|
89
|
+
if (!isRecord(value) || (value.type !== 1 && value.type !== 2))
|
|
90
|
+
return false;
|
|
91
|
+
return ['time', 'mem', 'parallelism', 'hashLen'].every((key) => {
|
|
92
|
+
const number = value[key];
|
|
93
|
+
return Number.isSafeInteger(number) && Number(number) > 0 && Number(number) <= LIMITS[key];
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
function readSalts(data) {
|
|
97
|
+
if (!isRecord(data) || !Array.isArray(data.salts) || !data.salts.length) {
|
|
98
|
+
throw layoutChanged('Argon2 salts for the login');
|
|
99
|
+
}
|
|
100
|
+
const salts = data.salts.map((entry) => {
|
|
101
|
+
const version = isRecord(entry) ? Number(entry.version) : NaN;
|
|
102
|
+
if (!isRecord(entry) || !Number.isSafeInteger(version) || version < 0) {
|
|
103
|
+
throw layoutChanged('Argon2 salts for the login');
|
|
104
|
+
}
|
|
105
|
+
const salt = typeof entry.salt === 'string' ? entry.salt : '';
|
|
106
|
+
if (version !== 0 && !salt)
|
|
107
|
+
throw layoutChanged('Argon2 salts for the login');
|
|
108
|
+
return {
|
|
109
|
+
version,
|
|
110
|
+
raw: entry.version,
|
|
111
|
+
salt,
|
|
112
|
+
upgradeSalt: typeof entry.upgrade_salt === 'string' ? entry.upgrade_salt : null
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
return { salts, rotating: typeof data.rotating_salt === 'string' ? data.rotating_salt : null };
|
|
116
|
+
}
|
|
117
|
+
// The browser worker passes UTF-8 bytes but the JS string length, so longer UTF-8 input is cut short.
|
|
118
|
+
export function argon2(password, salt, parameters) {
|
|
119
|
+
if (!parameters)
|
|
120
|
+
throw layoutChanged('Argon2 parameters for every salt version');
|
|
121
|
+
const bytes = (value) => new TextEncoder().encode(value).slice(0, value.length);
|
|
122
|
+
return (parameters.type === 2 ? argon2id : argon2i)({
|
|
123
|
+
password: bytes(password),
|
|
124
|
+
salt: bytes(salt),
|
|
125
|
+
iterations: parameters.time,
|
|
126
|
+
memorySize: parameters.mem,
|
|
127
|
+
parallelism: parameters.parallelism,
|
|
128
|
+
hashLength: parameters.hashLen,
|
|
129
|
+
outputType: 'hex'
|
|
130
|
+
}).catch((error) => {
|
|
131
|
+
// hash-wasm refuses combinations such as too little memory per lane or a short salt.
|
|
132
|
+
throw layoutChanged('usable Argon2 parameters and salts', error);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { StoredCookie } from '../transport/cookies.js';
|
|
2
|
+
import type { KurswahlAccount } from './session.js';
|
|
3
|
+
export interface KurswahlState {
|
|
4
|
+
account: KurswahlAccount;
|
|
5
|
+
token: string | null;
|
|
6
|
+
database: string | null;
|
|
7
|
+
cookies: StoredCookie[];
|
|
8
|
+
}
|
|
9
|
+
export declare function writeKurswahlState({ account, token, database, cookies }: KurswahlState): string;
|
|
10
|
+
export declare function readKurswahlState(text: string): KurswahlState;
|
|
11
|
+
/** A Kurswahl subdomain, so a school key can never point a request at another host. */
|
|
12
|
+
export declare function isSchool(value: string): boolean;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { fail } from '../errors.js';
|
|
2
|
+
import { copyCookie, isCookie } from '../state.js';
|
|
3
|
+
import { isRecord } from '../util.js';
|
|
4
|
+
import { isDatabase, isToken } from './parse.js';
|
|
5
|
+
const FORMAT = 'ranzen/kurswahl@1';
|
|
6
|
+
export function writeKurswahlState({ account, token, database, cookies }) {
|
|
7
|
+
const { school, username } = account;
|
|
8
|
+
return JSON.stringify({ format: FORMAT, school, username, token, database, cookies });
|
|
9
|
+
}
|
|
10
|
+
export function readKurswahlState(text) {
|
|
11
|
+
let data;
|
|
12
|
+
try {
|
|
13
|
+
data = JSON.parse(text);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw fail('INVALID_STATE', 'Connection state is not valid JSON.');
|
|
17
|
+
}
|
|
18
|
+
if (!isRecord(data) || data.format !== FORMAT) {
|
|
19
|
+
throw fail('INVALID_STATE', 'Connection state has an unknown format.');
|
|
20
|
+
}
|
|
21
|
+
const { school, username, token, database, cookies } = data;
|
|
22
|
+
if (typeof school !== 'string' || !isSchool(school)) {
|
|
23
|
+
throw fail('INVALID_STATE', 'Connection state has an invalid school.');
|
|
24
|
+
}
|
|
25
|
+
if (typeof username !== 'string' || !username) {
|
|
26
|
+
throw fail('INVALID_STATE', 'Connection state has an invalid username.');
|
|
27
|
+
}
|
|
28
|
+
if (!isNullOr(token, isToken) || !isNullOr(database, isDatabase)) {
|
|
29
|
+
throw fail('INVALID_STATE', 'Connection state has an invalid token or database.');
|
|
30
|
+
}
|
|
31
|
+
if (!Array.isArray(cookies) || !cookies.every(isCookie)) {
|
|
32
|
+
throw fail('INVALID_STATE', 'Connection state has invalid cookies.');
|
|
33
|
+
}
|
|
34
|
+
return { account: { school, username }, token, database, cookies: cookies.map(copyCookie) };
|
|
35
|
+
}
|
|
36
|
+
/** A Kurswahl subdomain, so a school key can never point a request at another host. */
|
|
37
|
+
export function isSchool(value) {
|
|
38
|
+
return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(value) && value !== 'www';
|
|
39
|
+
}
|
|
40
|
+
// The same checks the parser applies, so every exported state restores.
|
|
41
|
+
function isNullOr(value, valid) {
|
|
42
|
+
return value === null || (typeof value === 'string' && valid(value));
|
|
43
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Rotation } from '../rotation.js';
|
|
2
|
+
export type { Rotation };
|
|
3
|
+
/** ISO weekday, 1 = Monday. */
|
|
4
|
+
export type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
|
5
|
+
export interface School {
|
|
6
|
+
/** The subdomain, e.g. "musterschule" for musterschule.kurswahl-online.de. */
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
}
|
|
10
|
+
export interface KurswahlTeacher {
|
|
11
|
+
/** The school's short code, the same one SPH uses, e.g. "Mu". */
|
|
12
|
+
code: string | null;
|
|
13
|
+
/** As written, e.g. "McLeod"; named like the SPH teacher field. */
|
|
14
|
+
lastName: string | null;
|
|
15
|
+
salutation: 'Frau' | 'Herr' | null;
|
|
16
|
+
}
|
|
17
|
+
export interface KurswahlCourse {
|
|
18
|
+
/** Kurswahl's "Realkurs" number; unrelated to SPH course ids. */
|
|
19
|
+
id: string;
|
|
20
|
+
/** The "Leiste" the course runs in. */
|
|
21
|
+
block: number | null;
|
|
22
|
+
/** The short name shown in the timetable, e.g. "M-LK" or "TUT". */
|
|
23
|
+
code: string;
|
|
24
|
+
/** The long name as written, e.g. "MATHEMATIK" or "Politik&Wirtschaft". */
|
|
25
|
+
title: string | null;
|
|
26
|
+
level: 'LK' | 'GK' | null;
|
|
27
|
+
teacher: KurswahlTeacher;
|
|
28
|
+
}
|
|
29
|
+
export interface KurswahlLesson {
|
|
30
|
+
/** A KurswahlCourse id. */
|
|
31
|
+
course: string;
|
|
32
|
+
day: Weekday;
|
|
33
|
+
/** The period number, as SPH counts them. */
|
|
34
|
+
period: number;
|
|
35
|
+
room: string | null;
|
|
36
|
+
/** Null when the lesson takes place every week. */
|
|
37
|
+
rotation: Rotation | null;
|
|
38
|
+
}
|
|
39
|
+
export interface PeriodTime {
|
|
40
|
+
period: number;
|
|
41
|
+
/** Local time, e.g. "08:00". */
|
|
42
|
+
start: string | null;
|
|
43
|
+
end: string | null;
|
|
44
|
+
}
|
|
45
|
+
export interface Week {
|
|
46
|
+
/** The portal's value, e.g. "25_08_2025". */
|
|
47
|
+
id: string;
|
|
48
|
+
/** ISO calendar dates. */
|
|
49
|
+
start: string;
|
|
50
|
+
end: string;
|
|
51
|
+
}
|
|
52
|
+
export interface Timetable {
|
|
53
|
+
/** The timetable database, e.g. "musterschule_stundenplan_1". */
|
|
54
|
+
database: string;
|
|
55
|
+
/** The week shown; null for the general plan. */
|
|
56
|
+
week: Week | null;
|
|
57
|
+
/** Weeks the portal offers; schools sometimes leave last year's dates here, so prefer rotation. */
|
|
58
|
+
weeks: Week[];
|
|
59
|
+
periods: PeriodTime[];
|
|
60
|
+
courses: KurswahlCourse[];
|
|
61
|
+
lessons: KurswahlLesson[];
|
|
62
|
+
/** Local portal time of the last timetable change, e.g. "2026-08-27T12:12:51". */
|
|
63
|
+
updatedAt: string | null;
|
|
64
|
+
/** Local portal time of the last course assignment change. */
|
|
65
|
+
assignedAt: string | null;
|
|
66
|
+
/** Local portal time of the last exam plan change. */
|
|
67
|
+
examsUpdatedAt: string | null;
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/rotation.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/state.d.ts
CHANGED
|
@@ -7,3 +7,5 @@ export interface ConnectionState {
|
|
|
7
7
|
}
|
|
8
8
|
export declare function writeState({ account, secret, cookies }: ConnectionState): string;
|
|
9
9
|
export declare function readState(text: string): ConnectionState;
|
|
10
|
+
export declare function copyCookie({ name, value, domain, hostOnly, path, secure, expires }: StoredCookie): StoredCookie;
|
|
11
|
+
export declare function isCookie(value: unknown): value is StoredCookie;
|
package/dist/state.js
CHANGED
|
@@ -38,15 +38,19 @@ export function readState(text) {
|
|
|
38
38
|
return {
|
|
39
39
|
account: { schoolId, username },
|
|
40
40
|
secret: key,
|
|
41
|
-
cookies: cookies.map(
|
|
42
|
-
? { name, value, domain, hostOnly, path, secure }
|
|
43
|
-
: { name, value, domain, hostOnly, path, secure, expires })
|
|
41
|
+
cookies: cookies.map(copyCookie)
|
|
44
42
|
};
|
|
45
43
|
}
|
|
44
|
+
// Only the known fields, so stray keys in stored state never reach the jar.
|
|
45
|
+
export function copyCookie({ name, value, domain, hostOnly, path, secure, expires }) {
|
|
46
|
+
return expires === undefined
|
|
47
|
+
? { name, value, domain, hostOnly, path, secure }
|
|
48
|
+
: { name, value, domain, hostOnly, path, secure, expires };
|
|
49
|
+
}
|
|
46
50
|
function isRecord(value) {
|
|
47
51
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
48
52
|
}
|
|
49
|
-
function isCookie(value) {
|
|
53
|
+
export function isCookie(value) {
|
|
50
54
|
return (isRecord(value) &&
|
|
51
55
|
typeof value.name === 'string' &&
|
|
52
56
|
typeof value.value === 'string' &&
|
|
@@ -25,6 +25,7 @@ export class CookieJar {
|
|
|
25
25
|
secure: false
|
|
26
26
|
};
|
|
27
27
|
let maxAge;
|
|
28
|
+
let pathSet = false;
|
|
28
29
|
for (const attribute of attributes) {
|
|
29
30
|
const index = attribute.indexOf('=');
|
|
30
31
|
const key = (index < 0 ? attribute : attribute.slice(0, index)).trim().toLowerCase();
|
|
@@ -36,8 +37,10 @@ export class CookieJar {
|
|
|
36
37
|
cookie.domain = domain;
|
|
37
38
|
cookie.hostOnly = false;
|
|
38
39
|
}
|
|
39
|
-
else if (key === 'path' && value.startsWith('/'))
|
|
40
|
+
else if (key === 'path' && value.startsWith('/')) {
|
|
40
41
|
cookie.path = value;
|
|
42
|
+
pathSet = true;
|
|
43
|
+
}
|
|
41
44
|
else if (key === 'secure')
|
|
42
45
|
cookie.secure = true;
|
|
43
46
|
else if (key === 'max-age' && /^-?\d+$/.test(value))
|
|
@@ -50,6 +53,8 @@ export class CookieJar {
|
|
|
50
53
|
}
|
|
51
54
|
if (maxAge !== undefined)
|
|
52
55
|
cookie.expires = this.now() + maxAge * 1000;
|
|
56
|
+
if (!prefixAllows(cookie, url, pathSet))
|
|
57
|
+
return;
|
|
53
58
|
const key = `${cookie.domain};${cookie.path};${cookie.name}`;
|
|
54
59
|
if (cookie.expires !== undefined && cookie.expires <= this.now())
|
|
55
60
|
this.cookies.delete(key);
|
|
@@ -90,6 +95,15 @@ export class CookieJar {
|
|
|
90
95
|
.sort((a, b) => b.path.length - a.path.length);
|
|
91
96
|
}
|
|
92
97
|
}
|
|
98
|
+
// RFC 6265bis §4.1.3: __Secure- needs Secure over https; __Host- also needs no Domain and an explicit Path=/.
|
|
99
|
+
function prefixAllows(cookie, url, pathSet) {
|
|
100
|
+
const name = cookie.name.toLowerCase();
|
|
101
|
+
const secure = cookie.secure && url.protocol === 'https:';
|
|
102
|
+
if (name.startsWith('__host-')) {
|
|
103
|
+
return secure && cookie.hostOnly && pathSet && cookie.path === '/';
|
|
104
|
+
}
|
|
105
|
+
return !name.startsWith('__secure-') || secure;
|
|
106
|
+
}
|
|
93
107
|
function domainMatches(host, domain) {
|
|
94
108
|
return host === domain || host.endsWith(`.${domain}`);
|
|
95
109
|
}
|
package/dist/transport/http.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface TransportSettings {
|
|
|
6
6
|
userAgent: string;
|
|
7
7
|
timeoutMs: number;
|
|
8
8
|
maxConcurrency: number;
|
|
9
|
+
/** Hosts requests may reach over https; defaults to Schulportal's. */
|
|
10
|
+
hosts?: ReadonlySet<string>;
|
|
9
11
|
}
|
|
10
12
|
export interface Exchange {
|
|
11
13
|
url: string | URL;
|
|
@@ -29,6 +31,7 @@ export interface Reply extends Head {
|
|
|
29
31
|
export interface StreamReply extends Head {
|
|
30
32
|
body: ReadableStream<Uint8Array>;
|
|
31
33
|
}
|
|
34
|
+
export declare const PORTAL_HOSTS: ReadonlySet<string>;
|
|
32
35
|
export declare class Transport {
|
|
33
36
|
private readonly settings;
|
|
34
37
|
readonly jar: CookieJar;
|
package/dist/transport/http.js
CHANGED
|
@@ -2,7 +2,7 @@ import { RanzenError, fail, layoutChanged } from '../errors.js';
|
|
|
2
2
|
import { CookieJar } from './cookies.js';
|
|
3
3
|
import { Limiter } from './limiter.js';
|
|
4
4
|
// The hosts sign-in and apps use; any other host, subdomain or port is a portal change.
|
|
5
|
-
const PORTAL_HOSTS = new Set(['start', 'login', 'connect'].map((name) => `${name}.schulportal.hessen.de`));
|
|
5
|
+
export const PORTAL_HOSTS = new Set(['start', 'login', 'connect'].map((name) => `${name}.schulportal.hessen.de`));
|
|
6
6
|
const MAX_REDIRECTS = 10;
|
|
7
7
|
export class Transport {
|
|
8
8
|
settings;
|
|
@@ -113,7 +113,7 @@ export class Transport {
|
|
|
113
113
|
let method = exchange.method ?? 'GET';
|
|
114
114
|
let body = exchange.multipart ?? (exchange.form && new URLSearchParams(exchange.form).toString());
|
|
115
115
|
for (let hop = 0;; hop++) {
|
|
116
|
-
if (url.protocol !== 'https:' || !PORTAL_HOSTS.has(url.host)) {
|
|
116
|
+
if (url.protocol !== 'https:' || !(this.settings.hosts ?? PORTAL_HOSTS).has(url.host)) {
|
|
117
117
|
throw layoutChanged('a portal address');
|
|
118
118
|
}
|
|
119
119
|
const headers = new Headers(exchange.headers);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ranzenjs/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Typed client for Schulportal Hessen (SPH, LANIS)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Suko",
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
"types": "./dist/apps/catalog/index.d.ts",
|
|
43
43
|
"default": "./dist/apps/catalog/index.js"
|
|
44
44
|
},
|
|
45
|
+
"./kurswahl": {
|
|
46
|
+
"types": "./dist/kurswahl/index.d.ts",
|
|
47
|
+
"default": "./dist/kurswahl/index.js"
|
|
48
|
+
},
|
|
45
49
|
"./html": {
|
|
46
50
|
"types": "./dist/html.d.ts",
|
|
47
51
|
"default": "./dist/html.js"
|
|
@@ -66,6 +70,7 @@
|
|
|
66
70
|
"dependencies": {
|
|
67
71
|
"css-select": "^7.0.0",
|
|
68
72
|
"domhandler": "^6.0.1",
|
|
73
|
+
"hash-wasm": "^4.12.0",
|
|
69
74
|
"htmlparser2": "^12.0.0"
|
|
70
75
|
},
|
|
71
76
|
"devDependencies": {
|