@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 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 three small dependencies (htmlparser2, domhandler,
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 links outside cards are not.
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 (all('a[href*="sus_view"]', folders).some((link) => !carded.has(link))) {
12
- drift('The lessons page lists courses outside known cards.');
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: cards.flatMap((card) => {
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(text(when && one('small', when)), drift),
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: ownText(button),
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: text(name), date: text(date), value: text(value), comment: null });
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
- from: number;
9
- to: number;
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: text(one('.badge.woche', panel)) || null,
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;