offcourse 1.0.1 → 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.
- package/README.md +107 -8
- package/dist/cli/commands/sync.js +4 -1
- package/dist/cli/commands/sync.js.map +1 -1
- package/dist/cli/commands/syncHighLevel.d.ts.map +1 -1
- package/dist/cli/commands/syncHighLevel.js +4 -1
- package/dist/cli/commands/syncHighLevel.js.map +1 -1
- package/dist/cli/commands/syncLearningSuite.d.ts +35 -0
- package/dist/cli/commands/syncLearningSuite.d.ts.map +1 -0
- package/dist/cli/commands/syncLearningSuite.js +765 -0
- package/dist/cli/commands/syncLearningSuite.js.map +1 -0
- package/dist/cli/index.js +38 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/downloader/hlsDownloader.d.ts +10 -4
- package/dist/downloader/hlsDownloader.d.ts.map +1 -1
- package/dist/downloader/hlsDownloader.js +38 -16
- package/dist/downloader/hlsDownloader.js.map +1 -1
- package/dist/downloader/index.d.ts +4 -0
- package/dist/downloader/index.d.ts.map +1 -1
- package/dist/downloader/index.js +6 -6
- package/dist/downloader/index.js.map +1 -1
- package/dist/downloader/loomDownloader.d.ts +1 -1
- package/dist/downloader/loomDownloader.d.ts.map +1 -1
- package/dist/downloader/loomDownloader.js +9 -7
- package/dist/downloader/loomDownloader.js.map +1 -1
- package/dist/scraper/learningsuite/extractor.d.ts +50 -0
- package/dist/scraper/learningsuite/extractor.d.ts.map +1 -0
- package/dist/scraper/learningsuite/extractor.js +429 -0
- package/dist/scraper/learningsuite/extractor.js.map +1 -0
- package/dist/scraper/learningsuite/index.d.ts +4 -0
- package/dist/scraper/learningsuite/index.d.ts.map +1 -0
- package/dist/scraper/learningsuite/index.js +4 -0
- package/dist/scraper/learningsuite/index.js.map +1 -0
- package/dist/scraper/learningsuite/navigator.d.ts +122 -0
- package/dist/scraper/learningsuite/navigator.d.ts.map +1 -0
- package/dist/scraper/learningsuite/navigator.js +736 -0
- package/dist/scraper/learningsuite/navigator.js.map +1 -0
- package/dist/scraper/learningsuite/schemas.d.ts +270 -0
- package/dist/scraper/learningsuite/schemas.d.ts.map +1 -0
- package/dist/scraper/learningsuite/schemas.js +147 -0
- package/dist/scraper/learningsuite/schemas.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
import { safeParse, CourseSchema, ModuleSchema, LessonSchema, } from "./schemas.js";
|
|
2
|
+
// ============================================================================
|
|
3
|
+
// Tenant Extraction
|
|
4
|
+
// ============================================================================
|
|
5
|
+
/**
|
|
6
|
+
* Extracts the tenant ID from a LearningSuite URL.
|
|
7
|
+
* URL format: https://{subdomain}.learningsuite.io/...
|
|
8
|
+
*/
|
|
9
|
+
export function extractTenantFromUrl(url) {
|
|
10
|
+
const urlObj = new URL(url);
|
|
11
|
+
const hostname = urlObj.hostname;
|
|
12
|
+
// Extract subdomain from learningsuite.io
|
|
13
|
+
const match = /^([^.]+)\.learningsuite\.io$/.exec(hostname);
|
|
14
|
+
if (!match?.[1]) {
|
|
15
|
+
return { subdomain: "", tenantId: null };
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
subdomain: match[1],
|
|
19
|
+
tenantId: null, // Will be resolved by API
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Browser/API Automation
|
|
24
|
+
// ============================================================================
|
|
25
|
+
/* v8 ignore start */
|
|
26
|
+
/**
|
|
27
|
+
* Extracts the tenant ID from the page by inspecting network requests or localStorage.
|
|
28
|
+
*/
|
|
29
|
+
export async function extractTenantId(page) {
|
|
30
|
+
return page.evaluate(() => {
|
|
31
|
+
// Try to find tenant ID in localStorage
|
|
32
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
33
|
+
const key = localStorage.key(i);
|
|
34
|
+
if (key) {
|
|
35
|
+
const value = localStorage.getItem(key);
|
|
36
|
+
if (value) {
|
|
37
|
+
// Look for tenant ID patterns
|
|
38
|
+
const match = /"tenantId":\s*"([^"]+)"/.exec(value);
|
|
39
|
+
if (match?.[1])
|
|
40
|
+
return match[1];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Check meta tags or data attributes
|
|
45
|
+
const metaTenant = document.querySelector('meta[name="tenant-id"]');
|
|
46
|
+
if (metaTenant) {
|
|
47
|
+
return metaTenant.getAttribute("content");
|
|
48
|
+
}
|
|
49
|
+
// Check for tenant in script tags (common in SPAs)
|
|
50
|
+
const scripts = Array.from(document.querySelectorAll("script"));
|
|
51
|
+
for (const script of scripts) {
|
|
52
|
+
const content = script.textContent ?? "";
|
|
53
|
+
const tenantMatch = /tenantId['":\s]+['"]([a-z0-9]+)['"]/i.exec(content);
|
|
54
|
+
if (tenantMatch?.[1]) {
|
|
55
|
+
return tenantMatch[1];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Extracts tenant ID from page by looking at script content and link hrefs.
|
|
63
|
+
*/
|
|
64
|
+
async function extractTenantIdFromPage(page) {
|
|
65
|
+
return page.evaluate(() => {
|
|
66
|
+
// Check for any API calls that contain the tenant ID
|
|
67
|
+
const apiPattern = /api\.learningsuite\.io\/([a-z0-9]+)\/graphql/;
|
|
68
|
+
const scripts = Array.from(document.querySelectorAll("script"));
|
|
69
|
+
for (const script of scripts) {
|
|
70
|
+
const src = script.src ?? "";
|
|
71
|
+
const content = script.textContent ?? "";
|
|
72
|
+
const match = apiPattern.exec(src) ?? apiPattern.exec(content);
|
|
73
|
+
if (match?.[1])
|
|
74
|
+
return match[1];
|
|
75
|
+
}
|
|
76
|
+
// Check network resource hints
|
|
77
|
+
const links = Array.from(document.querySelectorAll('link[href*="learningsuite"]'));
|
|
78
|
+
for (const link of links) {
|
|
79
|
+
const href = link.href ?? "";
|
|
80
|
+
const match = /\/([a-z0-9]{20,})\//.exec(href);
|
|
81
|
+
if (match?.[1])
|
|
82
|
+
return match[1];
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Gets the auth token from localStorage.
|
|
89
|
+
*/
|
|
90
|
+
export async function getAuthToken(page) {
|
|
91
|
+
return page.evaluate(() => {
|
|
92
|
+
// Look for common token storage patterns
|
|
93
|
+
const tokenKeys = ["accessToken", "token", "authToken", "jwt", "access_token"];
|
|
94
|
+
for (const key of tokenKeys) {
|
|
95
|
+
const value = localStorage.getItem(key);
|
|
96
|
+
if (value)
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
// Try sessionStorage
|
|
100
|
+
for (const key of tokenKeys) {
|
|
101
|
+
const value = sessionStorage.getItem(key);
|
|
102
|
+
if (value)
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
// Look in any localStorage key that might contain a token
|
|
106
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
107
|
+
const key = localStorage.key(i);
|
|
108
|
+
if (key?.toLowerCase().includes("auth") || key?.toLowerCase().includes("token")) {
|
|
109
|
+
const value = localStorage.getItem(key);
|
|
110
|
+
if (value) {
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(value);
|
|
113
|
+
if (typeof parsed.accessToken === "string")
|
|
114
|
+
return parsed.accessToken;
|
|
115
|
+
if (typeof parsed.token === "string")
|
|
116
|
+
return parsed.token;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// If it's not JSON, it might be the token itself
|
|
120
|
+
if (value.length > 20 && !value.includes(" ")) {
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Makes a GraphQL request to the LearningSuite API.
|
|
132
|
+
*/
|
|
133
|
+
export async function graphqlRequest(page, tenantId, query, variables) {
|
|
134
|
+
const authToken = await getAuthToken(page);
|
|
135
|
+
const result = await page.evaluate(async ({ tenantId, query, variables, authToken }) => {
|
|
136
|
+
try {
|
|
137
|
+
const headers = {
|
|
138
|
+
"Content-Type": "application/json",
|
|
139
|
+
};
|
|
140
|
+
if (authToken) {
|
|
141
|
+
headers.Authorization = `Bearer ${authToken}`;
|
|
142
|
+
}
|
|
143
|
+
const response = await fetch(`https://api.learningsuite.io/${tenantId}/graphql`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers,
|
|
146
|
+
body: JSON.stringify({ query, variables }),
|
|
147
|
+
});
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
return { error: `HTTP ${response.status}`, status: response.status };
|
|
150
|
+
}
|
|
151
|
+
const data = await response.json();
|
|
152
|
+
return { data };
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
return { error: String(error) };
|
|
156
|
+
}
|
|
157
|
+
}, { tenantId, query, variables, authToken });
|
|
158
|
+
if ("error" in result) {
|
|
159
|
+
// Note: LearningSuite uses persisted queries, so most custom queries will fail with HTTP 400.
|
|
160
|
+
// This is expected behavior - we fall back to DOM-based extraction.
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
return result.data;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Fetches all courses available to the user.
|
|
167
|
+
*/
|
|
168
|
+
export async function fetchCourses(page, tenantId) {
|
|
169
|
+
const query = `
|
|
170
|
+
query GetMyCourses {
|
|
171
|
+
myCourses {
|
|
172
|
+
id
|
|
173
|
+
title
|
|
174
|
+
description
|
|
175
|
+
thumbnailUrl
|
|
176
|
+
progress
|
|
177
|
+
moduleCount
|
|
178
|
+
lessonCount
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
`;
|
|
182
|
+
const response = await graphqlRequest(page, tenantId, query);
|
|
183
|
+
if (!response?.data?.myCourses) {
|
|
184
|
+
// Try alternative query
|
|
185
|
+
const altQuery = `
|
|
186
|
+
query GetProducts {
|
|
187
|
+
products {
|
|
188
|
+
id
|
|
189
|
+
title
|
|
190
|
+
description
|
|
191
|
+
imageUrl
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
`;
|
|
195
|
+
const altResponse = await graphqlRequest(page, tenantId, altQuery);
|
|
196
|
+
if (altResponse?.data?.products) {
|
|
197
|
+
return altResponse.data.products
|
|
198
|
+
.map((p) => safeParse(CourseSchema, p, "fetchCourses.alt"))
|
|
199
|
+
.filter((c) => c !== null);
|
|
200
|
+
}
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
return response.data.myCourses
|
|
204
|
+
.map((c) => safeParse(CourseSchema, c, "fetchCourses"))
|
|
205
|
+
.filter((c) => c !== null);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Fetches modules for a course.
|
|
209
|
+
*/
|
|
210
|
+
export async function fetchModules(page, tenantId, courseId) {
|
|
211
|
+
const query = `
|
|
212
|
+
query GetCourseModules($courseId: ID!) {
|
|
213
|
+
course(id: $courseId) {
|
|
214
|
+
modules {
|
|
215
|
+
id
|
|
216
|
+
title
|
|
217
|
+
description
|
|
218
|
+
position
|
|
219
|
+
isLocked
|
|
220
|
+
lessonCount
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
`;
|
|
225
|
+
const response = await graphqlRequest(page, tenantId, query, { courseId });
|
|
226
|
+
if (!response?.data?.course?.modules) {
|
|
227
|
+
// Try alternative query (chapters)
|
|
228
|
+
const altQuery = `
|
|
229
|
+
query GetCourseChapters($courseId: ID!) {
|
|
230
|
+
course(id: $courseId) {
|
|
231
|
+
chapters {
|
|
232
|
+
id
|
|
233
|
+
title
|
|
234
|
+
description
|
|
235
|
+
order
|
|
236
|
+
isLocked
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
`;
|
|
241
|
+
const altResponse = await graphqlRequest(page, tenantId, altQuery, { courseId });
|
|
242
|
+
if (altResponse?.data?.course?.chapters) {
|
|
243
|
+
return altResponse.data.course.chapters
|
|
244
|
+
.map((m) => safeParse(ModuleSchema, m, "fetchModules.alt"))
|
|
245
|
+
.filter((m) => m !== null);
|
|
246
|
+
}
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
return response.data.course.modules
|
|
250
|
+
.map((m) => safeParse(ModuleSchema, m, "fetchModules"))
|
|
251
|
+
.filter((m) => m !== null);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Fetches lessons for a module.
|
|
255
|
+
*/
|
|
256
|
+
export async function fetchLessons(page, tenantId, courseId, moduleId) {
|
|
257
|
+
const query = `
|
|
258
|
+
query GetModuleLessons($courseId: ID!, $moduleId: ID!) {
|
|
259
|
+
course(id: $courseId) {
|
|
260
|
+
module(id: $moduleId) {
|
|
261
|
+
lessons {
|
|
262
|
+
id
|
|
263
|
+
title
|
|
264
|
+
description
|
|
265
|
+
position
|
|
266
|
+
isLocked
|
|
267
|
+
isCompleted
|
|
268
|
+
duration
|
|
269
|
+
contentType
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
`;
|
|
275
|
+
const response = await graphqlRequest(page, tenantId, query, { courseId, moduleId });
|
|
276
|
+
if (!response?.data?.course?.module?.lessons) {
|
|
277
|
+
// Try alternative query
|
|
278
|
+
const altQuery = `
|
|
279
|
+
query GetChapterLessons($chapterId: ID!) {
|
|
280
|
+
chapter(id: $chapterId) {
|
|
281
|
+
lessons {
|
|
282
|
+
id
|
|
283
|
+
title
|
|
284
|
+
description
|
|
285
|
+
order
|
|
286
|
+
isLocked
|
|
287
|
+
isCompleted
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
`;
|
|
292
|
+
const altResponse = await graphqlRequest(page, tenantId, altQuery, { chapterId: moduleId });
|
|
293
|
+
if (altResponse?.data?.chapter?.lessons) {
|
|
294
|
+
return altResponse.data.chapter.lessons
|
|
295
|
+
.map((l) => safeParse(LessonSchema, l, "fetchLessons.alt"))
|
|
296
|
+
.filter((l) => l !== null);
|
|
297
|
+
}
|
|
298
|
+
return [];
|
|
299
|
+
}
|
|
300
|
+
return response.data.course.module.lessons
|
|
301
|
+
.map((l) => safeParse(LessonSchema, l, "fetchLessons"))
|
|
302
|
+
.filter((l) => l !== null);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Extracts courses from the page DOM (fallback method).
|
|
306
|
+
*/
|
|
307
|
+
export async function extractCoursesFromPage(page) {
|
|
308
|
+
return page.evaluate(() => {
|
|
309
|
+
const courses = [];
|
|
310
|
+
// Look for course cards/links
|
|
311
|
+
const courseElements = document.querySelectorAll('[class*="course"], [class*="Course"], [data-course-id], a[href*="/course/"]');
|
|
312
|
+
const seen = new Set();
|
|
313
|
+
for (const el of Array.from(courseElements)) {
|
|
314
|
+
// Try to extract course ID from data attribute or href
|
|
315
|
+
let id = el.dataset.courseId ?? "";
|
|
316
|
+
const href = el.href ?? "";
|
|
317
|
+
if (!id && href) {
|
|
318
|
+
const match = /\/course[s]?\/([^/]+)/.exec(href);
|
|
319
|
+
if (match?.[1])
|
|
320
|
+
id = match[1];
|
|
321
|
+
}
|
|
322
|
+
if (!id || seen.has(id))
|
|
323
|
+
continue;
|
|
324
|
+
seen.add(id);
|
|
325
|
+
// Extract title
|
|
326
|
+
const titleEl = el.querySelector("h2, h3, h4, [class*='title'], [class*='Title']");
|
|
327
|
+
const title = titleEl?.textContent?.trim() ?? `Course ${courses.length + 1}`;
|
|
328
|
+
// Extract description
|
|
329
|
+
const descEl = el.querySelector("p, [class*='description'], [class*='Description']");
|
|
330
|
+
const description = descEl?.textContent?.trim() ?? null;
|
|
331
|
+
// Extract thumbnail
|
|
332
|
+
const imgEl = el.querySelector("img");
|
|
333
|
+
const thumbnailUrl = imgEl?.src ?? null;
|
|
334
|
+
courses.push({
|
|
335
|
+
id,
|
|
336
|
+
title,
|
|
337
|
+
description,
|
|
338
|
+
thumbnailUrl,
|
|
339
|
+
moduleCount: 0,
|
|
340
|
+
lessonCount: 0,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return courses;
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Builds the complete course structure for a LearningSuite course using DOM extraction.
|
|
348
|
+
* This is more reliable than GraphQL as the API structure may vary between instances.
|
|
349
|
+
*/
|
|
350
|
+
export async function buildLearningSuiteCourseStructure(page, courseUrl, onProgress) {
|
|
351
|
+
// Extract domain and tenant info
|
|
352
|
+
const urlObj = new URL(courseUrl);
|
|
353
|
+
const domain = urlObj.hostname;
|
|
354
|
+
onProgress?.({ phase: "init" });
|
|
355
|
+
// Navigate to course page
|
|
356
|
+
await page.goto(courseUrl, { timeout: 30000 });
|
|
357
|
+
await page.waitForLoadState("networkidle").catch(() => { });
|
|
358
|
+
await page.waitForTimeout(3000);
|
|
359
|
+
// Extract tenant ID from page
|
|
360
|
+
const tenantId = (await extractTenantId(page)) ?? (await extractTenantIdFromPage(page));
|
|
361
|
+
if (!tenantId) {
|
|
362
|
+
console.error("Could not determine tenant ID for LearningSuite portal");
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
// Extract course ID from URL
|
|
366
|
+
// URL formats:
|
|
367
|
+
// - /student/course/{slug}/{id} (e.g., /student/course/einfuehrung-in-die-akademie/mgLFsjbW)
|
|
368
|
+
// - /course/{id}
|
|
369
|
+
let courseId = null;
|
|
370
|
+
// Try format: /student/course/{slug}/{id}
|
|
371
|
+
const twoPartMatch = /\/course\/[^/]+\/([^/?]+)/.exec(courseUrl);
|
|
372
|
+
if (twoPartMatch?.[1]) {
|
|
373
|
+
courseId = twoPartMatch[1];
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
// Try format: /course/{id}
|
|
377
|
+
const onePartMatch = /\/course\/([^/?]+)/.exec(courseUrl);
|
|
378
|
+
if (onePartMatch?.[1]) {
|
|
379
|
+
courseId = onePartMatch[1];
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (!courseId) {
|
|
383
|
+
// Try to find course ID from current page URL
|
|
384
|
+
const pageUrl = page.url();
|
|
385
|
+
const pageTwoPartMatch = /\/course\/[^/]+\/([^/?]+)/.exec(pageUrl);
|
|
386
|
+
if (pageTwoPartMatch?.[1]) {
|
|
387
|
+
courseId = pageTwoPartMatch[1];
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
const pageOnePartMatch = /\/course\/([^/?]+)/.exec(pageUrl);
|
|
391
|
+
if (pageOnePartMatch?.[1]) {
|
|
392
|
+
courseId = pageOnePartMatch[1];
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (!courseId) {
|
|
396
|
+
console.error("Could not extract course ID from URL:", courseUrl);
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// Extract URL slug for later URL construction
|
|
401
|
+
const slugMatch = /\/course\/([^/]+)\/[^/]+/.exec(courseUrl);
|
|
402
|
+
const courseSlug = slugMatch?.[1] ?? courseId;
|
|
403
|
+
// Extract course details from DOM
|
|
404
|
+
onProgress?.({ phase: "course" });
|
|
405
|
+
const courseInfo = await page.evaluate(() => {
|
|
406
|
+
// LearningSuite has the course title in a header section
|
|
407
|
+
// The structure is: "KURS" label followed by the course name
|
|
408
|
+
let title = "";
|
|
409
|
+
// Look for elements containing "KURS" text and get the nearby title
|
|
410
|
+
const allElements = document.querySelectorAll("*");
|
|
411
|
+
for (const el of Array.from(allElements)) {
|
|
412
|
+
const text = el.textContent?.trim() ?? "";
|
|
413
|
+
if (text === "KURS" || text === "Kurs" || text === "COURSE") {
|
|
414
|
+
// Found the label, now find the title (usually a sibling or nearby element)
|
|
415
|
+
const parent = el.parentElement;
|
|
416
|
+
if (parent) {
|
|
417
|
+
const siblings = Array.from(parent.children);
|
|
418
|
+
for (const sib of siblings) {
|
|
419
|
+
const sibText = sib.textContent?.trim() ?? "";
|
|
420
|
+
if (sibText.length > 5 && sibText !== text && !sibText.includes("KURS")) {
|
|
421
|
+
title = sibText;
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (title)
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// Alternative: find by URL slug
|
|
431
|
+
if (!title) {
|
|
432
|
+
const url = window.location.pathname;
|
|
433
|
+
const slugMatch = /\/course\/([^/]+)\//.exec(url);
|
|
434
|
+
if (slugMatch?.[1]) {
|
|
435
|
+
// Convert slug to title (replace hyphens with spaces, title case)
|
|
436
|
+
title = slugMatch[1].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// Final fallback: page title
|
|
440
|
+
if (!title) {
|
|
441
|
+
title = document.title.split(" - ")[0]?.trim() ?? "Unknown Course";
|
|
442
|
+
}
|
|
443
|
+
return { title, description: null };
|
|
444
|
+
});
|
|
445
|
+
const course = {
|
|
446
|
+
id: courseId,
|
|
447
|
+
title: courseInfo.title,
|
|
448
|
+
description: courseInfo.description,
|
|
449
|
+
thumbnailUrl: null,
|
|
450
|
+
moduleCount: 0,
|
|
451
|
+
lessonCount: 0,
|
|
452
|
+
};
|
|
453
|
+
onProgress?.({ phase: "course", courseName: course.title });
|
|
454
|
+
// Extract modules and lessons from DOM
|
|
455
|
+
onProgress?.({ phase: "modules" });
|
|
456
|
+
// First extract all modules from the course page
|
|
457
|
+
const modulesWithLessons = await extractModulesFromCoursePage(page, domain, courseSlug, courseId);
|
|
458
|
+
// Now iterate through each module to get its lessons
|
|
459
|
+
for (let i = 0; i < modulesWithLessons.length; i++) {
|
|
460
|
+
const module = modulesWithLessons[i];
|
|
461
|
+
if (!module)
|
|
462
|
+
continue;
|
|
463
|
+
// Skip locked modules
|
|
464
|
+
if (module.isLocked) {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
onProgress?.({
|
|
468
|
+
phase: "modules",
|
|
469
|
+
currentModuleIndex: i + 1,
|
|
470
|
+
totalModules: modulesWithLessons.length,
|
|
471
|
+
currentModule: module.title,
|
|
472
|
+
});
|
|
473
|
+
// Navigate to the module by clicking on its title text
|
|
474
|
+
const moduleTitle = page.locator(`text="${module.title}"`).first();
|
|
475
|
+
if (await moduleTitle.isVisible().catch(() => false)) {
|
|
476
|
+
await moduleTitle.click();
|
|
477
|
+
await page.waitForLoadState("domcontentloaded").catch(() => { });
|
|
478
|
+
await page.waitForTimeout(2000);
|
|
479
|
+
// Extract module ID from URL (format: /t/{moduleId})
|
|
480
|
+
const currentUrl = page.url();
|
|
481
|
+
const moduleIdMatch = /\/t\/([^/]+)/.exec(currentUrl);
|
|
482
|
+
if (moduleIdMatch?.[1]) {
|
|
483
|
+
module.id = moduleIdMatch[1];
|
|
484
|
+
}
|
|
485
|
+
// Extract lessons directly from the module page
|
|
486
|
+
// Lessons are listed as links with format: /{courseId}/{lessonId}
|
|
487
|
+
const lessonsData = await page.evaluate((cId) => {
|
|
488
|
+
const links = document.querySelectorAll("a");
|
|
489
|
+
const lessons = [];
|
|
490
|
+
const seenIds = new Set();
|
|
491
|
+
for (const link of Array.from(links)) {
|
|
492
|
+
const href = link.href;
|
|
493
|
+
// Check if this is a lesson link (contains courseId but not /t/)
|
|
494
|
+
if (!href.includes(`/${cId}/`) || href.includes("/t/"))
|
|
495
|
+
continue;
|
|
496
|
+
// Extract lesson ID from URL
|
|
497
|
+
const parts = href.split("/");
|
|
498
|
+
const lessonId = parts[parts.length - 1];
|
|
499
|
+
if (!lessonId || seenIds.has(lessonId))
|
|
500
|
+
continue;
|
|
501
|
+
seenIds.add(lessonId);
|
|
502
|
+
// Extract title and duration from link text
|
|
503
|
+
const text = link.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
|
504
|
+
if (text.length < 5)
|
|
505
|
+
continue;
|
|
506
|
+
// Parse title (before duration info)
|
|
507
|
+
let title = text;
|
|
508
|
+
let duration = "";
|
|
509
|
+
// Duration patterns: "X Minute(n)" or "X Sekunde(n)"
|
|
510
|
+
const durationMatch = /(\d+\s*(?:Minute|Sekunde)n?)/i.exec(text);
|
|
511
|
+
if (durationMatch) {
|
|
512
|
+
const durationIdx = text.indexOf(durationMatch[0]);
|
|
513
|
+
title = text.substring(0, durationIdx).trim();
|
|
514
|
+
duration = durationMatch[0];
|
|
515
|
+
}
|
|
516
|
+
// Check for completion checkmark
|
|
517
|
+
const hasCheckmark = link.querySelector('svg[data-icon="check"]') !== null;
|
|
518
|
+
if (title.length > 3) {
|
|
519
|
+
lessons.push({ title, lessonId, duration, isCompleted: hasCheckmark });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return lessons;
|
|
523
|
+
}, courseId);
|
|
524
|
+
if (lessonsData.length > 0) {
|
|
525
|
+
module.lessons = lessonsData.map((l, idx) => ({
|
|
526
|
+
id: l.lessonId,
|
|
527
|
+
title: l.title,
|
|
528
|
+
position: idx,
|
|
529
|
+
moduleId: module.id,
|
|
530
|
+
isLocked: false,
|
|
531
|
+
isCompleted: l.isCompleted,
|
|
532
|
+
}));
|
|
533
|
+
}
|
|
534
|
+
// Go back to the course page
|
|
535
|
+
await page.goto(courseUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
|
|
536
|
+
await page.waitForTimeout(1500);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
onProgress?.({ phase: "done" });
|
|
540
|
+
// Update totals
|
|
541
|
+
course.moduleCount = modulesWithLessons.length;
|
|
542
|
+
course.lessonCount = modulesWithLessons.reduce((sum, m) => sum + m.lessons.length, 0);
|
|
543
|
+
return {
|
|
544
|
+
course,
|
|
545
|
+
modules: modulesWithLessons,
|
|
546
|
+
tenantId,
|
|
547
|
+
domain,
|
|
548
|
+
courseSlug,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Extracts modules from the course page by analyzing text content.
|
|
553
|
+
* Modules are identified by their stats line: "X LEKTIONEN | Y MIN." or "ERSCHEINT BALD"
|
|
554
|
+
*/
|
|
555
|
+
async function extractModulesFromCoursePage(page, _domain, _courseSlug, _courseId) {
|
|
556
|
+
// Wait for content to load
|
|
557
|
+
await page.waitForTimeout(2000);
|
|
558
|
+
// Extract modules by analyzing text content
|
|
559
|
+
const modulesData = await page.evaluate(() => {
|
|
560
|
+
const text = document.body.innerText;
|
|
561
|
+
const lines = text
|
|
562
|
+
.split("\n")
|
|
563
|
+
.map((s) => s.trim())
|
|
564
|
+
.filter((s) => s.length > 0);
|
|
565
|
+
const modules = [];
|
|
566
|
+
for (let i = 0; i < lines.length; i++) {
|
|
567
|
+
const line = lines[i] ?? "";
|
|
568
|
+
// Check for stats line pattern
|
|
569
|
+
const statsMatch = /(\d+)\s*LEKTIONEN?\s*\|\s*(\d+)\s*MIN/i.exec(line);
|
|
570
|
+
const isLocked = /ERSCHEINT\s*BALD|COMING\s*SOON/i.test(line);
|
|
571
|
+
if (statsMatch || isLocked) {
|
|
572
|
+
// The title is usually the line before the stats
|
|
573
|
+
const prevLine = lines[i - 1]?.trim() ?? "";
|
|
574
|
+
// Validate title
|
|
575
|
+
if (prevLine &&
|
|
576
|
+
prevLine.length > 3 &&
|
|
577
|
+
prevLine.length < 100 &&
|
|
578
|
+
!/^(\d+%|START|FORTSETZEN)$/i.test(prevLine)) {
|
|
579
|
+
modules.push({
|
|
580
|
+
title: prevLine,
|
|
581
|
+
lessonCount: statsMatch ? parseInt(statsMatch[1] ?? "0", 10) : 0,
|
|
582
|
+
duration: statsMatch ? (statsMatch[2] ?? "0") + " Min." : "",
|
|
583
|
+
isLocked,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return modules;
|
|
589
|
+
});
|
|
590
|
+
const modules = [];
|
|
591
|
+
for (let i = 0; i < modulesData.length; i++) {
|
|
592
|
+
const mod = modulesData[i];
|
|
593
|
+
if (!mod)
|
|
594
|
+
continue;
|
|
595
|
+
const description = mod.isLocked
|
|
596
|
+
? "Erscheint bald"
|
|
597
|
+
: `${mod.lessonCount} Lektionen, ${mod.duration}`;
|
|
598
|
+
modules.push({
|
|
599
|
+
id: `module-${i}`, // Will be updated when we navigate to the module
|
|
600
|
+
title: mod.title,
|
|
601
|
+
description,
|
|
602
|
+
position: i,
|
|
603
|
+
isLocked: mod.isLocked,
|
|
604
|
+
lessons: [], // Will be populated when we enter the module
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
return modules;
|
|
608
|
+
}
|
|
609
|
+
/* v8 ignore stop */
|
|
610
|
+
// ============================================================================
|
|
611
|
+
// URL Utilities
|
|
612
|
+
// ============================================================================
|
|
613
|
+
/**
|
|
614
|
+
* Constructs the URL for a LearningSuite course.
|
|
615
|
+
*/
|
|
616
|
+
export function getLearningSuiteCourseUrl(domain, courseSlug, courseId) {
|
|
617
|
+
return `https://${domain}/student/course/${courseSlug}/${courseId}`;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Constructs the URL for a LearningSuite lesson.
|
|
621
|
+
* URL format: /student/course/{slug}/{courseId}/{topicId}
|
|
622
|
+
* Note: The topicId (lessonId from module page) is enough - the server redirects to the full URL.
|
|
623
|
+
*/
|
|
624
|
+
export function getLearningSuiteLessonUrl(domain, courseSlug, courseId, _moduleId, // Unused - kept for API compatibility
|
|
625
|
+
lessonId // This is actually the topicId
|
|
626
|
+
) {
|
|
627
|
+
return `https://${domain}/student/course/${courseSlug}/${courseId}/${lessonId}`;
|
|
628
|
+
}
|
|
629
|
+
// ============================================================================
|
|
630
|
+
// Lesson Completion
|
|
631
|
+
// ============================================================================
|
|
632
|
+
/* v8 ignore start */
|
|
633
|
+
/**
|
|
634
|
+
* Marks a lesson as completed by clicking the "Abschließen" button.
|
|
635
|
+
* This unlocks the next lesson in sequence.
|
|
636
|
+
*
|
|
637
|
+
* @returns true if successfully completed, false otherwise
|
|
638
|
+
*/
|
|
639
|
+
export async function markLessonComplete(page, lessonUrl) {
|
|
640
|
+
try {
|
|
641
|
+
// Navigate to the lesson if not already there
|
|
642
|
+
const currentUrl = page.url();
|
|
643
|
+
if (!currentUrl.includes(lessonUrl)) {
|
|
644
|
+
await page.goto(lessonUrl, { timeout: 30000 });
|
|
645
|
+
await page.waitForLoadState("networkidle").catch(() => { });
|
|
646
|
+
await page.waitForTimeout(2000);
|
|
647
|
+
}
|
|
648
|
+
// Find and click the complete button using evaluate
|
|
649
|
+
// (handles font rendering issues where "Abschließen" might appear as "Ab chließen")
|
|
650
|
+
const clicked = await page.evaluate(() => {
|
|
651
|
+
// Look for buttons with text containing variations of "Abschließen" or "Complete"
|
|
652
|
+
const buttons = Array.from(document.querySelectorAll("button"));
|
|
653
|
+
for (const button of buttons) {
|
|
654
|
+
const text = button.textContent?.toLowerCase().replace(/\s+/g, "") ?? "";
|
|
655
|
+
// Match: abschließen, abschliessen, complete, markascomplete
|
|
656
|
+
if (text.includes("abschließen") ||
|
|
657
|
+
text.includes("abschliessen") ||
|
|
658
|
+
text.includes("complete")) {
|
|
659
|
+
// Check if this is not already a "completed" state button
|
|
660
|
+
if (!text.includes("abgeschlossen") && !text.includes("completed")) {
|
|
661
|
+
button.click();
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return false;
|
|
667
|
+
});
|
|
668
|
+
if (!clicked) {
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
671
|
+
// Wait for the API call to complete
|
|
672
|
+
await page.waitForTimeout(1500);
|
|
673
|
+
// If we clicked the button, consider it successful
|
|
674
|
+
// The server tracks completion via submitEventsNew GraphQL mutation
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
catch (error) {
|
|
678
|
+
console.error("Error marking lesson as complete:", error);
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Auto-completes all accessible lessons in sequence to unlock subsequent content.
|
|
684
|
+
* This is useful when lessons are sequentially locked.
|
|
685
|
+
*
|
|
686
|
+
* @param page - Playwright page
|
|
687
|
+
* @param lessons - List of lessons to complete
|
|
688
|
+
* @param onProgress - Callback for progress updates
|
|
689
|
+
* @returns Number of lessons successfully completed
|
|
690
|
+
*/
|
|
691
|
+
export async function autoCompleteLessons(page, lessons, onProgress) {
|
|
692
|
+
let completedCount = 0;
|
|
693
|
+
const unlocked = lessons.filter((l) => !l.isLocked);
|
|
694
|
+
for (const lesson of unlocked) {
|
|
695
|
+
onProgress?.(completedCount, unlocked.length, lesson.title);
|
|
696
|
+
const success = await markLessonComplete(page, lesson.url);
|
|
697
|
+
if (success) {
|
|
698
|
+
completedCount++;
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
// If we can't complete a lesson, subsequent ones might still be locked
|
|
702
|
+
console.warn(`Could not complete lesson: ${lesson.title}`);
|
|
703
|
+
}
|
|
704
|
+
// Small delay between lessons
|
|
705
|
+
await page.waitForTimeout(500);
|
|
706
|
+
}
|
|
707
|
+
return completedCount;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Checks if a lesson page shows the lesson as completed.
|
|
711
|
+
*/
|
|
712
|
+
export async function isLessonCompleted(page) {
|
|
713
|
+
return page.evaluate(() => {
|
|
714
|
+
// Look for completion indicators
|
|
715
|
+
const indicators = [
|
|
716
|
+
'[class*="completed"]',
|
|
717
|
+
'[class*="done"]',
|
|
718
|
+
'[data-completed="true"]',
|
|
719
|
+
'button:has-text("Abgeschlossen")',
|
|
720
|
+
];
|
|
721
|
+
for (const selector of indicators) {
|
|
722
|
+
if (document.querySelector(selector))
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
// Check if "Abschließen" button is gone or disabled
|
|
726
|
+
const completeButton = document.querySelector('button:has-text("Abschließen"), button:has-text("Complete")');
|
|
727
|
+
if (completeButton) {
|
|
728
|
+
return completeButton.disabled;
|
|
729
|
+
}
|
|
730
|
+
return false;
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
/* v8 ignore stop */
|
|
734
|
+
// Re-export shared utilities
|
|
735
|
+
export { slugify, createFolderName } from "../../shared/slug.js";
|
|
736
|
+
//# sourceMappingURL=navigator.js.map
|