@udondan/dsbmobile 1.1.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.
@@ -0,0 +1,341 @@
1
+ import axios from 'axios';
2
+ import { DSB_API_BASE_URL, DSB_APP_VERSION, DSB_BUNDLE_ID, DSB_OS_VERSION, REQUEST_TIMEOUT_MS, } from '../constants.js';
3
+ import { createAuthError, handleApiError } from '../utils/errors.js';
4
+ /** Decode common HTML entities and trim whitespace. */
5
+ function decodeHtmlEntities(s) {
6
+ return s
7
+ .replaceAll(' ', ' ')
8
+ .replaceAll('&', '&')
9
+ .replaceAll('&lt;', '<')
10
+ .replaceAll('&gt;', '>')
11
+ .replaceAll('&quot;', '"')
12
+ .trim();
13
+ }
14
+ /** Strip all HTML tags and trim whitespace. */
15
+ function stripHtmlTags(s) {
16
+ return s.replaceAll(/<[^>]+>/g, '').trim();
17
+ }
18
+ /**
19
+ * Client for the DSBmobile Mobile API.
20
+ *
21
+ * Accepts explicit credentials via {@link DsbmobileConfig} and provides
22
+ * methods to fetch timetables, news, documents, and substitution plans.
23
+ *
24
+ * The authentication token is cached in memory for the session lifetime.
25
+ */
26
+ export class DsbmobileClient {
27
+ username;
28
+ password;
29
+ http;
30
+ token;
31
+ constructor(config) {
32
+ this.username = config.username;
33
+ this.password = config.password;
34
+ this.http = axios.create({
35
+ baseURL: DSB_API_BASE_URL,
36
+ timeout: REQUEST_TIMEOUT_MS,
37
+ headers: {
38
+ Accept: 'application/json',
39
+ 'User-Agent': `Dalvik/2.1.0 (Linux; U; Android 5.1; ${DSB_BUNDLE_ID})`,
40
+ },
41
+ });
42
+ }
43
+ /**
44
+ * Authenticates with DSBmobile and caches the token.
45
+ * The token is stable per username, so it can be cached indefinitely.
46
+ *
47
+ * @throws Error if credentials are invalid or the request fails
48
+ */
49
+ async authenticate() {
50
+ const response = await this.http.get('/authid', {
51
+ params: {
52
+ bundleid: DSB_BUNDLE_ID,
53
+ appversion: DSB_APP_VERSION,
54
+ osversion: DSB_OS_VERSION,
55
+ pushid: '',
56
+ user: this.username,
57
+ password: this.password,
58
+ },
59
+ });
60
+ // The API returns the token as a JSON string (with quotes), e.g. "uuid-here"
61
+ // An empty string "" means invalid credentials
62
+ const token = response.data;
63
+ if (!token || token === '""' || token === '') {
64
+ throw new Error(createAuthError());
65
+ }
66
+ // Remove surrounding quotes if present
67
+ this.token = token.replaceAll(/^"|"$/g, '');
68
+ }
69
+ /**
70
+ * Ensures a valid token is available, authenticating if necessary.
71
+ */
72
+ async ensureAuthenticated() {
73
+ if (!this.token) {
74
+ await this.authenticate();
75
+ }
76
+ return this.token;
77
+ }
78
+ /**
79
+ * Fetches all available substitution plan (Vertretungsplan) entries.
80
+ *
81
+ * @returns Array of timetable entries with URLs to HTML plan pages
82
+ * @throws Error if authentication fails or the request fails
83
+ */
84
+ async getTimetables() {
85
+ try {
86
+ const token = await this.ensureAuthenticated();
87
+ const response = await this.http.get('/dsbtimetables', {
88
+ params: { authid: token },
89
+ });
90
+ return this.parseTimetables(response.data);
91
+ }
92
+ catch (error) {
93
+ if (error instanceof Error && error.message.startsWith('Error:')) {
94
+ throw error;
95
+ }
96
+ throw new Error(handleApiError(error), { cause: error });
97
+ }
98
+ }
99
+ /**
100
+ * Fetches all news and announcements.
101
+ *
102
+ * @returns Array of news entries
103
+ * @throws Error if authentication fails or the request fails
104
+ */
105
+ async getNews() {
106
+ try {
107
+ const token = await this.ensureAuthenticated();
108
+ const response = await this.http.get('/newstab', {
109
+ params: { authid: token },
110
+ });
111
+ return this.parseNews(response.data);
112
+ }
113
+ catch (error) {
114
+ if (error instanceof Error && error.message.startsWith('Error:')) {
115
+ throw error;
116
+ }
117
+ throw new Error(handleApiError(error), { cause: error });
118
+ }
119
+ }
120
+ /**
121
+ * Fetches all available documents.
122
+ *
123
+ * @returns Array of document entries with download URLs
124
+ * @throws Error if authentication fails or the request fails
125
+ */
126
+ async getDocuments() {
127
+ try {
128
+ const token = await this.ensureAuthenticated();
129
+ const response = await this.http.get('/dsbdocuments', {
130
+ params: { authid: token },
131
+ });
132
+ return this.parseDocuments(response.data);
133
+ }
134
+ catch (error) {
135
+ if (error instanceof Error && error.message.startsWith('Error:')) {
136
+ throw error;
137
+ }
138
+ throw new Error(handleApiError(error), { cause: error });
139
+ }
140
+ }
141
+ /**
142
+ * Parses raw DSBmobile timetable items into structured TimetableEntry objects.
143
+ * Timetables have ConType=2 with children that contain the actual plan URLs.
144
+ * The parent item holds the descriptive title; children are individual pages.
145
+ */
146
+ parseTimetables(items) {
147
+ const entries = [];
148
+ for (const item of items) {
149
+ if (item.ConType === 2 && item.Childs && item.Childs.length > 0) {
150
+ const validChildren = item.Childs.filter((c) => c.Detail);
151
+ const multiPage = validChildren.length > 1;
152
+ // Each child represents a page of the plan
153
+ for (const [index, validChild] of validChildren.entries()) {
154
+ const child = validChild;
155
+ // Use the parent's descriptive title; append page number if multi-page
156
+ const title = multiPage ? `${item.Title} (Seite ${index + 1})` : item.Title;
157
+ const entry = {
158
+ id: child.Id,
159
+ title,
160
+ date: item.Date,
161
+ url: child.Detail,
162
+ };
163
+ if (child.Preview) {
164
+ entry.previewUrl = `https://light.dsbcontrol.de/DSBlightWebsite/Data/${child.Preview}`;
165
+ }
166
+ entries.push(entry);
167
+ }
168
+ }
169
+ else if (item.Detail) {
170
+ // Direct URL in Detail field
171
+ const entry = {
172
+ id: item.Id,
173
+ title: item.Title,
174
+ date: item.Date,
175
+ url: item.Detail,
176
+ };
177
+ if (item.Preview) {
178
+ entry.previewUrl = `https://light.dsbcontrol.de/DSBlightWebsite/Data/${item.Preview}`;
179
+ }
180
+ entries.push(entry);
181
+ }
182
+ }
183
+ return entries;
184
+ }
185
+ /**
186
+ * Parses raw DSBmobile news items into structured NewsEntry objects.
187
+ */
188
+ parseNews(items) {
189
+ const entries = [];
190
+ for (const item of items) {
191
+ entries.push({
192
+ id: item.Id,
193
+ title: item.Title,
194
+ detail: item.Detail,
195
+ date: item.Date,
196
+ tags: item.Tags,
197
+ });
198
+ // Also process nested children if present
199
+ if (item.Childs && item.Childs.length > 0) {
200
+ for (const child of item.Childs) {
201
+ entries.push({
202
+ id: child.Id,
203
+ title: child.Title || item.Title,
204
+ detail: child.Detail,
205
+ date: child.Date || item.Date,
206
+ tags: child.Tags || item.Tags,
207
+ });
208
+ }
209
+ }
210
+ }
211
+ return entries;
212
+ }
213
+ /**
214
+ * Fetches and parses all substitution plan pages, returning structured entries.
215
+ * This fetches the actual HTML content of each timetable page and parses the
216
+ * substitution table into structured data.
217
+ *
218
+ * @returns Array of parsed substitution plans (one per HTML page)
219
+ * @throws Error if authentication fails or the request fails
220
+ */
221
+ async getSubstitutions() {
222
+ try {
223
+ const timetables = await this.getTimetables();
224
+ const plans = [];
225
+ for (const timetable of timetables) {
226
+ try {
227
+ // Use a standalone axios call with the full URL (not the base-URL-bound instance)
228
+ // responseType 'arraybuffer' lets us decode the latin-1 charset ourselves
229
+ const response = await axios.get(timetable.url, {
230
+ responseType: 'arraybuffer',
231
+ timeout: REQUEST_TIMEOUT_MS,
232
+ });
233
+ // The HTML is encoded in iso-8859-1/windows-1252 — decode it properly
234
+ const htmlText = new TextDecoder('windows-1252').decode(response.data);
235
+ const plan = parseSubstitutionHtml(htmlText, timetable.title, timetable.date, timetable.url);
236
+ plans.push(plan);
237
+ }
238
+ catch {
239
+ // Skip pages that fail to load; don't abort the whole request
240
+ }
241
+ }
242
+ return plans;
243
+ }
244
+ catch (error) {
245
+ if (error instanceof Error && error.message.startsWith('Error:')) {
246
+ throw error;
247
+ }
248
+ throw new Error(handleApiError(error), { cause: error });
249
+ }
250
+ }
251
+ /**
252
+ * Parses raw DSBmobile document items into structured DocumentEntry objects.
253
+ */
254
+ parseDocuments(items) {
255
+ const entries = [];
256
+ for (const item of items) {
257
+ if (item.ConType === 2 && item.Childs && item.Childs.length > 0) {
258
+ // Documents are often nested
259
+ for (const child of item.Childs) {
260
+ if (child.Detail) {
261
+ entries.push({
262
+ id: child.Id,
263
+ title: child.Title || item.Title,
264
+ url: child.Detail,
265
+ date: child.Date || item.Date,
266
+ });
267
+ }
268
+ }
269
+ }
270
+ else if (item.Detail) {
271
+ entries.push({
272
+ id: item.Id,
273
+ title: item.Title,
274
+ url: item.Detail,
275
+ date: item.Date,
276
+ });
277
+ }
278
+ }
279
+ return entries;
280
+ }
281
+ }
282
+ /**
283
+ * Parses the HTML content of a DSBmobile substitution plan page.
284
+ * The HTML is generated by Untis and has a consistent structure.
285
+ * Exported for unit testing.
286
+ */
287
+ export function parseSubstitutionHtml(html, title, lastUpdated, url) {
288
+ // Extract plan date (e.g. "20.3.2026 Freitag (Seite 1 / 8)")
289
+ const dateMatch = /class="mon_title">(.*?)<\/div>/.exec(html);
290
+ const planDate = dateMatch ? decodeHtmlEntities(stripHtmlTags(dateMatch[1])) : '';
291
+ // Extract affected classes from info table
292
+ let affectedClasses = '';
293
+ const infoRows = html.matchAll(/<tr class="info"><td[^>]*>(.*?)<\/td><td[^>]*>(.*?)<\/td><\/tr>/gs);
294
+ for (const match of infoRows) {
295
+ const label = decodeHtmlEntities(stripHtmlTags(match[1]));
296
+ const value = decodeHtmlEntities(stripHtmlTags(match[2]));
297
+ if (label.toLowerCase().includes('klassen')) {
298
+ affectedClasses = value;
299
+ }
300
+ }
301
+ // Parse all table rows
302
+ const entries = [];
303
+ let currentClass = '';
304
+ const allRows = html.matchAll(/<tr[^>]*>(.*?)<\/tr>/gs);
305
+ for (const rowMatch of allRows) {
306
+ const rowHtml = rowMatch[1];
307
+ const cells = [...rowHtml.matchAll(/<t[dh][^>]*>(.*?)<\/t[dh]>/gs)].map((m) => decodeHtmlEntities(stripHtmlTags(m[1])));
308
+ if (cells.length === 0)
309
+ continue;
310
+ // Class header row: single cell with class name (e.g. "10a 10a" or just "10a")
311
+ if (cells.length === 1) {
312
+ currentClass = cells[0].split(/\s+/)[0] ?? '';
313
+ continue;
314
+ }
315
+ // Skip header row and info rows
316
+ if (cells.length < 5 || cells[0] === 'Art')
317
+ continue;
318
+ // Substitution row: Art | Stunde | Vertreter | Fach | Raum | Text
319
+ const [type = '', period = '', teacherField = '', subject = '', roomField = '', text = ''] = cells;
320
+ // Teacher field: "OriginalTeacher?SubstituteTeacher" or just "SubstituteTeacher"
321
+ const [originalTeacher = '', substituteTeacher = ''] = teacherField.includes('?')
322
+ ? teacherField.split('?')
323
+ : ['', teacherField];
324
+ // Room field: "OriginalRoom?SubstituteRoom" or just "SubstituteRoom"
325
+ const [originalRoom = '', substituteRoom = ''] = roomField.includes('?')
326
+ ? roomField.split('?')
327
+ : ['', roomField];
328
+ entries.push({
329
+ className: currentClass,
330
+ type,
331
+ period,
332
+ originalTeacher,
333
+ substituteTeacher,
334
+ subject,
335
+ originalRoom,
336
+ substituteRoom,
337
+ text: text === '\u00A0' ? '' : text,
338
+ });
339
+ }
340
+ return { title, planDate, lastUpdated, url, affectedClasses, entries };
341
+ }
@@ -0,0 +1,8 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { DsbmobileClient } from '../services/dsbmobile.js';
3
+ /**
4
+ * Registers the get_documents tool with the MCP server.
5
+ *
6
+ * This tool retrieves all documents and files available on DSBmobile.
7
+ */
8
+ export declare function registerDocumentsTool(server: McpServer, client: DsbmobileClient): void;
@@ -0,0 +1,80 @@
1
+ import { z } from 'zod';
2
+ import { CHARACTER_LIMIT } from '../constants.js';
3
+ /**
4
+ * Registers the get_documents tool with the MCP server.
5
+ *
6
+ * This tool retrieves all documents and files available on DSBmobile.
7
+ */
8
+ export function registerDocumentsTool(server, client) {
9
+ server.registerTool('get_documents', {
10
+ title: 'Get DSBmobile Documents',
11
+ description: `Retrieves all documents and files available on DSBmobile.
12
+
13
+ Returns a list of document entries, each with:
14
+ - id: Unique identifier for the document
15
+ - title: Document name or title
16
+ - url: Download URL for the document (typically a PDF or image)
17
+ - date: Upload date in DD.MM.YYYY HH:MM format
18
+
19
+ Use this tool when:
20
+ - A user asks about school documents or files
21
+ - A user wants to download a specific document
22
+ - A user asks "Are there any documents on DSBmobile?"
23
+
24
+ Returns "No documents available" if no documents are currently published.
25
+
26
+ Error handling:
27
+ - Returns an error message if authentication fails (check DSB_USERNAME and DSB_PASSWORD)
28
+ - Returns an error message if the DSBmobile service is unavailable`,
29
+ inputSchema: z.object({}).strict(),
30
+ annotations: {
31
+ readOnlyHint: true,
32
+ destructiveHint: false,
33
+ idempotentHint: true,
34
+ openWorldHint: true,
35
+ },
36
+ }, async () => {
37
+ try {
38
+ const entries = await client.getDocuments();
39
+ if (entries.length === 0) {
40
+ return {
41
+ content: [
42
+ {
43
+ type: 'text',
44
+ text: 'Aktuell sind keine Dokumente auf DSBmobile verfügbar.',
45
+ },
46
+ ],
47
+ };
48
+ }
49
+ const output = {
50
+ count: entries.length,
51
+ documents: entries,
52
+ };
53
+ const text = formatDocuments(entries);
54
+ const finalText = text.length > CHARACTER_LIMIT
55
+ ? text.slice(0, CHARACTER_LIMIT) + '\n\n[Response truncated due to length.]'
56
+ : text;
57
+ return {
58
+ content: [{ type: 'text', text: finalText }],
59
+ structuredContent: output,
60
+ };
61
+ }
62
+ catch (error) {
63
+ const message = error instanceof Error ? error.message : 'An unexpected error occurred.';
64
+ return {
65
+ content: [{ type: 'text', text: message }],
66
+ isError: true,
67
+ };
68
+ }
69
+ });
70
+ }
71
+ /**
72
+ * Formats document entries as human-readable markdown.
73
+ */
74
+ function formatDocuments(entries) {
75
+ const lines = [`# DSBmobile Documents (${entries.length} available)`, ''];
76
+ for (const entry of entries) {
77
+ lines.push(`## ${entry.title}`, `- **ID**: ${entry.id}`, `- **Date**: ${entry.date}`, `- **Download URL**: ${entry.url}`, '');
78
+ }
79
+ return lines.join('\n');
80
+ }
@@ -0,0 +1,8 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { DsbmobileClient } from '../services/dsbmobile.js';
3
+ /**
4
+ * Registers the get_news tool with the MCP server.
5
+ *
6
+ * This tool retrieves all news and announcements from DSBmobile.
7
+ */
8
+ export declare function registerNewsTool(server: McpServer, client: DsbmobileClient): void;
@@ -0,0 +1,88 @@
1
+ import { z } from 'zod';
2
+ import { CHARACTER_LIMIT } from '../constants.js';
3
+ /**
4
+ * Registers the get_news tool with the MCP server.
5
+ *
6
+ * This tool retrieves all news and announcements from DSBmobile.
7
+ */
8
+ export function registerNewsTool(server, client) {
9
+ server.registerTool('get_news', {
10
+ title: 'Get DSBmobile News and Announcements',
11
+ description: `Retrieves all news and announcements posted on DSBmobile.
12
+
13
+ Returns a list of news items, each with:
14
+ - id: Unique identifier for the news item
15
+ - title: News headline or title
16
+ - detail: News content, URL to more details, or plain text announcement
17
+ - date: Publication date in DD.MM.YYYY HH:MM format
18
+ - tags: Associated tags or categories (may be empty)
19
+
20
+ Use this tool when:
21
+ - A user asks about school announcements or news
22
+ - A user wants to know about upcoming school events
23
+ - A user asks "What's new at school?"
24
+
25
+ Returns "No news available" if no news items are currently published.
26
+
27
+ Error handling:
28
+ - Returns an error message if authentication fails (check DSB_USERNAME and DSB_PASSWORD)
29
+ - Returns an error message if the DSBmobile service is unavailable`,
30
+ inputSchema: z.object({}).strict(),
31
+ annotations: {
32
+ readOnlyHint: true,
33
+ destructiveHint: false,
34
+ idempotentHint: true,
35
+ openWorldHint: true,
36
+ },
37
+ }, async () => {
38
+ try {
39
+ const entries = await client.getNews();
40
+ if (entries.length === 0) {
41
+ return {
42
+ content: [
43
+ {
44
+ type: 'text',
45
+ text: 'Aktuell sind keine Neuigkeiten auf DSBmobile verfügbar.',
46
+ },
47
+ ],
48
+ };
49
+ }
50
+ const output = {
51
+ count: entries.length,
52
+ news: entries,
53
+ };
54
+ const text = formatNews(entries);
55
+ const finalText = text.length > CHARACTER_LIMIT
56
+ ? text.slice(0, CHARACTER_LIMIT) + '\n\n[Response truncated due to length.]'
57
+ : text;
58
+ return {
59
+ content: [{ type: 'text', text: finalText }],
60
+ structuredContent: output,
61
+ };
62
+ }
63
+ catch (error) {
64
+ const message = error instanceof Error ? error.message : 'An unexpected error occurred.';
65
+ return {
66
+ content: [{ type: 'text', text: message }],
67
+ isError: true,
68
+ };
69
+ }
70
+ });
71
+ }
72
+ /**
73
+ * Formats news entries as human-readable markdown.
74
+ */
75
+ function formatNews(entries) {
76
+ const lines = [`# DSBmobile News (${entries.length} items)`, ''];
77
+ for (const entry of entries) {
78
+ lines.push(`## ${entry.title}`, `- **ID**: ${entry.id}`, `- **Date**: ${entry.date}`);
79
+ if (entry.tags) {
80
+ lines.push(`- **Tags**: ${entry.tags}`);
81
+ }
82
+ if (entry.detail) {
83
+ lines.push(`- **Content**: ${entry.detail}`);
84
+ }
85
+ lines.push('');
86
+ }
87
+ return lines.join('\n');
88
+ }
@@ -0,0 +1,9 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { DsbmobileClient } from '../services/dsbmobile.js';
3
+ /**
4
+ * Registers the get_substitutions tool with the MCP server.
5
+ *
6
+ * This tool fetches and parses the actual substitution plan HTML pages,
7
+ * returning structured substitution entries grouped by class.
8
+ */
9
+ export declare function registerSubstitutionsTool(server: McpServer, client: DsbmobileClient): void;