@ranzenjs/client 0.2.1 → 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/lessons/parse.js +23 -26
- package/dist/apps/lessons/types.d.ts +5 -2
- package/dist/apps/substitutions/parse.js +25 -1
- package/dist/apps/substitutions/types.d.ts +11 -2
- package/dist/html.d.ts +2 -0
- package/dist/html.js +13 -0
- 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:
|
|
@@ -1,39 +1,36 @@
|
|
|
1
1
|
import { layoutChanged } from '../../errors.js';
|
|
2
|
-
import { all, attr, cells, hasClass, one, ownText, rows, text } from '../../html.js';
|
|
2
|
+
import { all, attr, cells, hasClass, one, ownText, rawText, rows, text } from '../../html.js';
|
|
3
3
|
import { ignoreDrift, isoDate, parseCount, queryParam } from '../../util.js';
|
|
4
4
|
export function parseOverview(document, drift = ignoreDrift) {
|
|
5
5
|
const folders = one('#mappen', document);
|
|
6
6
|
if (!folders)
|
|
7
7
|
throw layoutChanged('#mappen on the lessons page');
|
|
8
8
|
const cards = all('.thumbnail', folders);
|
|
9
|
-
// An empty #mappen is a valid account without courses; course
|
|
9
|
+
// An empty #mappen is a valid account without courses; a skipped course would read as gone.
|
|
10
|
+
const links = all('a[href*="sus_view"]', folders);
|
|
10
11
|
const carded = new Set(cards.flatMap((card) => all('a[href*="sus_view"]', card)));
|
|
11
|
-
if (
|
|
12
|
-
|
|
12
|
+
if (links.some((link) => !carded.has(link))) {
|
|
13
|
+
throw layoutChanged('.thumbnail cards around every course in #mappen');
|
|
13
14
|
}
|
|
14
15
|
const latest = one('#aktuellTable', document);
|
|
15
16
|
if (!latest && one('tr[data-entry]', document)) {
|
|
16
17
|
drift('The lessons page lists entries outside #aktuellTable.');
|
|
17
18
|
}
|
|
19
|
+
const courses = cards.map((card) => {
|
|
20
|
+
const id = queryParam(attr(one('a[href*="sus_view"]', card), 'href'), 'id');
|
|
21
|
+
if (!id)
|
|
22
|
+
throw layoutChanged('a course link in every .thumbnail card');
|
|
23
|
+
return {
|
|
24
|
+
id,
|
|
25
|
+
name: attr(one('h2 [title]', card), 'title') ?? text(one('h2', card)),
|
|
26
|
+
teachers: all('button.dropdown-toggle[title]', card).map(teacher),
|
|
27
|
+
marks: count(one('a.btn-info[href*="#leistungen"]', card)),
|
|
28
|
+
undoneHomework: count(one('a.btn-danger', card)),
|
|
29
|
+
upcomingEntries: count(one('a.btn-warning', card))
|
|
30
|
+
};
|
|
31
|
+
});
|
|
18
32
|
return {
|
|
19
|
-
courses
|
|
20
|
-
const id = queryParam(attr(one('a[href*="sus_view"]', card), 'href'), 'id');
|
|
21
|
-
if (!id) {
|
|
22
|
-
drift('A course card has no course link.');
|
|
23
|
-
return [];
|
|
24
|
-
}
|
|
25
|
-
const name = attr(one('h2 [title]', card), 'title') ?? text(one('h2', card));
|
|
26
|
-
return [
|
|
27
|
-
{
|
|
28
|
-
id,
|
|
29
|
-
name,
|
|
30
|
-
teachers: all('button.dropdown-toggle[title]', card).map(teacher),
|
|
31
|
-
marks: count(one('a.btn-info[href*="#leistungen"]', card)),
|
|
32
|
-
undoneHomework: count(one('a.btn-danger', card)),
|
|
33
|
-
upcomingEntries: count(one('a.btn-warning', card))
|
|
34
|
-
}
|
|
35
|
-
];
|
|
36
|
-
}),
|
|
33
|
+
courses,
|
|
37
34
|
latest: entryRows(rows(latest), 'A latest entry', drift).map((row) => ({
|
|
38
35
|
course: { id: attr(row, 'data-book') ?? '', name: text(one('.name', row)) },
|
|
39
36
|
teachers: all('.teacher button[title]', row).map(teacher),
|
|
@@ -111,7 +108,7 @@ function historyEntry(row, drift) {
|
|
|
111
108
|
return {
|
|
112
109
|
...emptyEntry(attr(row, 'data-entry') ?? ''),
|
|
113
110
|
date: entryDate(text(when), 'A history row', drift),
|
|
114
|
-
periods: periods(
|
|
111
|
+
periods: periods(rawText(when && one('small', when)), drift),
|
|
115
112
|
topic: text(body && one(':scope > b', body)) || null,
|
|
116
113
|
content: text(contentOf(body), true) || null,
|
|
117
114
|
homework: historyHomework(body, drift),
|
|
@@ -171,7 +168,7 @@ function submission(group, drift) {
|
|
|
171
168
|
return [
|
|
172
169
|
{
|
|
173
170
|
id,
|
|
174
|
-
name:
|
|
171
|
+
name: rawText(button, true),
|
|
175
172
|
open: hasClass(button, 'btn-warning'),
|
|
176
173
|
uploaded: text(button && one('.badge', button)) || null,
|
|
177
174
|
due: day && time ? `${day}T${time[1].padStart(2, '0')}:${time[2]}` : day
|
|
@@ -185,7 +182,7 @@ function parseMarks(document, drift) {
|
|
|
185
182
|
const [name, date, value, ...rest] = cells(row);
|
|
186
183
|
const last = marks.at(-1);
|
|
187
184
|
if (name && date && value && !rest.length) {
|
|
188
|
-
marks.push({ name:
|
|
185
|
+
marks.push({ name: rawText(name), date: rawText(date), value: text(value), comment: null });
|
|
189
186
|
}
|
|
190
187
|
else if (last &&
|
|
191
188
|
name &&
|
|
@@ -300,7 +297,7 @@ function periods(label, drift) {
|
|
|
300
297
|
const match = /^(\d+)\.?(?:\s*[-–]\s*(\d+)\.?)?(?:\s*Stunden?)?$/.exec(label.trim());
|
|
301
298
|
if (!match) {
|
|
302
299
|
drift('A history row has unreadable periods.');
|
|
303
|
-
return null;
|
|
300
|
+
return { from: null, to: null, label };
|
|
304
301
|
}
|
|
305
302
|
const from = Number(match[1]);
|
|
306
303
|
return { from, to: match[2] ? Number(match[2]) : from, label };
|
|
@@ -5,8 +5,9 @@ export interface Teacher {
|
|
|
5
5
|
lastName: string | null;
|
|
6
6
|
}
|
|
7
7
|
export interface Periods {
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
/** Null with `to` when the label names no period numbers, e.g. "08:00 - 09:30". */
|
|
9
|
+
from: number | null;
|
|
10
|
+
to: number | null;
|
|
10
11
|
/** The portal's wording, unchanged, e.g. "1. - 2. Stunde". */
|
|
11
12
|
label: string;
|
|
12
13
|
}
|
|
@@ -22,6 +23,7 @@ export interface EntryFile {
|
|
|
22
23
|
}
|
|
23
24
|
export interface Submission {
|
|
24
25
|
id: string;
|
|
26
|
+
/** As written, so it can serve as a key. */
|
|
25
27
|
name: string;
|
|
26
28
|
open: boolean;
|
|
27
29
|
/** The portal's badge, e.g. "2" files handed in; null without one. */
|
|
@@ -70,6 +72,7 @@ export interface Overview {
|
|
|
70
72
|
attendance: AttendanceSummary[];
|
|
71
73
|
}
|
|
72
74
|
export interface Mark {
|
|
75
|
+
/** As written, like `date`, so it can serve as a key. */
|
|
73
76
|
name: string;
|
|
74
77
|
/** As shown by the portal, which omits the year, e.g. "Do, 17.09.". */
|
|
75
78
|
date: string;
|
|
@@ -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". */
|
package/dist/html.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export declare function rows(table: Element | null | undefined): Element[];
|
|
|
14
14
|
* Multiline keeps <br> and block breaks, for free text such as lesson content and homework.
|
|
15
15
|
*/
|
|
16
16
|
export declare function text(node: Root | null | undefined, multiline?: boolean): string;
|
|
17
|
+
/** Visible text with its whitespace as written, only trimmed, for names callers store as keys. */
|
|
18
|
+
export declare function rawText(node: Element | null | undefined, own?: boolean): string;
|
|
17
19
|
/** Only the element's own text, without that of badges or labels nested in it. */
|
|
18
20
|
export declare function ownText(node: Element | null | undefined): string;
|
|
19
21
|
export declare function attr(node: Element | null | undefined, name: string): string | undefined;
|
package/dist/html.js
CHANGED
|
@@ -73,6 +73,19 @@ export function text(node, multiline = false) {
|
|
|
73
73
|
.replace(/\n{3,}/g, '\n\n')
|
|
74
74
|
.trim();
|
|
75
75
|
}
|
|
76
|
+
/** Visible text with its whitespace as written, only trimmed, for names callers store as keys. */
|
|
77
|
+
export function rawText(node, own = false) {
|
|
78
|
+
const parts = [];
|
|
79
|
+
const walk = (current) => {
|
|
80
|
+
if (isText(current))
|
|
81
|
+
parts.push(current.data);
|
|
82
|
+
else if (!own && isTag(current) && !hasClass(current, 'hidden_encoded')) {
|
|
83
|
+
current.children.forEach(walk);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
node?.children.forEach(walk);
|
|
87
|
+
return parts.join('').trim();
|
|
88
|
+
}
|
|
76
89
|
/** Only the element's own text, without that of badges or labels nested in it. */
|
|
77
90
|
export function ownText(node) {
|
|
78
91
|
return (node?.children ?? [])
|
|
@@ -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;
|