offcourse 0.0.1 → 1.0.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/.github/workflows/ci.yml +50 -0
- package/.husky/commit-msg +2 -0
- package/.husky/pre-commit +1 -0
- package/.husky/pre-push +3 -0
- package/.prettierrc +8 -0
- package/.release-it.json +23 -0
- package/ARCHITECTURE.md +233 -0
- package/CHANGELOG.md +78 -0
- package/README.md +256 -16
- package/commitlint.config.js +4 -0
- package/dist/ai/openRouter.d.ts +47 -0
- package/dist/ai/openRouter.d.ts.map +1 -0
- package/dist/ai/openRouter.js +116 -0
- package/dist/ai/openRouter.js.map +1 -0
- package/dist/ai/transcriptPolisher.d.ts +24 -0
- package/dist/ai/transcriptPolisher.d.ts.map +1 -0
- package/dist/ai/transcriptPolisher.js +89 -0
- package/dist/ai/transcriptPolisher.js.map +1 -0
- package/dist/cli/commands/config.d.ts +13 -0
- package/dist/cli/commands/config.d.ts.map +1 -0
- package/dist/cli/commands/config.js +66 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/enrich.d.ts +14 -0
- package/dist/cli/commands/enrich.d.ts.map +1 -0
- package/dist/cli/commands/enrich.js +271 -0
- package/dist/cli/commands/enrich.js.map +1 -0
- package/dist/cli/commands/inspect.d.ts +11 -0
- package/dist/cli/commands/inspect.d.ts.map +1 -0
- package/dist/cli/commands/inspect.js +365 -0
- package/dist/cli/commands/inspect.js.map +1 -0
- package/dist/cli/commands/login.d.ts +12 -0
- package/dist/cli/commands/login.d.ts.map +1 -0
- package/dist/cli/commands/login.js +55 -0
- package/dist/cli/commands/login.js.map +1 -0
- package/dist/cli/commands/status.d.ts +15 -0
- package/dist/cli/commands/status.d.ts.map +1 -0
- package/dist/cli/commands/status.js +118 -0
- package/dist/cli/commands/status.js.map +1 -0
- package/dist/cli/commands/sync.d.ts +16 -0
- package/dist/cli/commands/sync.d.ts.map +1 -0
- package/dist/cli/commands/sync.js +922 -0
- package/dist/cli/commands/sync.js.map +1 -0
- package/dist/cli/commands/syncGhl.d.ts +20 -0
- package/dist/cli/commands/syncGhl.d.ts.map +1 -0
- package/dist/cli/commands/syncGhl.js +483 -0
- package/dist/cli/commands/syncGhl.js.map +1 -0
- package/dist/cli/commands/syncHighLevel.d.ts +24 -0
- package/dist/cli/commands/syncHighLevel.d.ts.map +1 -0
- package/dist/cli/commands/syncHighLevel.js +483 -0
- package/dist/cli/commands/syncHighLevel.js.map +1 -0
- package/dist/cli/commands/syncHighLevel.test.d.ts +2 -0
- package/dist/cli/commands/syncHighLevel.test.d.ts.map +1 -0
- package/dist/cli/commands/syncHighLevel.test.js +102 -0
- package/dist/cli/commands/syncHighLevel.test.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +106 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/config/configManager.d.ts +31 -0
- package/dist/config/configManager.d.ts.map +1 -0
- package/dist/config/configManager.js +64 -0
- package/dist/config/configManager.js.map +1 -0
- package/dist/config/paths.d.ts +21 -0
- package/dist/config/paths.d.ts.map +1 -0
- package/dist/config/paths.js +33 -0
- package/dist/config/paths.js.map +1 -0
- package/dist/config/paths.test.d.ts +2 -0
- package/dist/config/paths.test.d.ts.map +1 -0
- package/dist/config/paths.test.js +70 -0
- package/dist/config/paths.test.js.map +1 -0
- package/dist/config/schema.d.ts +60 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +50 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/config/schema.test.d.ts +2 -0
- package/dist/config/schema.test.d.ts.map +1 -0
- package/dist/config/schema.test.js +151 -0
- package/dist/config/schema.test.js.map +1 -0
- package/dist/downloader/hlsDownloader.d.ts +58 -0
- package/dist/downloader/hlsDownloader.d.ts.map +1 -0
- package/dist/downloader/hlsDownloader.js +254 -0
- package/dist/downloader/hlsDownloader.js.map +1 -0
- package/dist/downloader/hlsDownloader.test.d.ts +2 -0
- package/dist/downloader/hlsDownloader.test.d.ts.map +1 -0
- package/dist/downloader/hlsDownloader.test.js +116 -0
- package/dist/downloader/hlsDownloader.test.js.map +1 -0
- package/dist/downloader/hlsValidator.d.ts +35 -0
- package/dist/downloader/hlsValidator.d.ts.map +1 -0
- package/dist/downloader/hlsValidator.js +148 -0
- package/dist/downloader/hlsValidator.js.map +1 -0
- package/dist/downloader/index.d.ts +26 -0
- package/dist/downloader/index.d.ts.map +1 -0
- package/dist/downloader/index.js +52 -0
- package/dist/downloader/index.js.map +1 -0
- package/dist/downloader/loomDownloader.d.ts +56 -0
- package/dist/downloader/loomDownloader.d.ts.map +1 -0
- package/dist/downloader/loomDownloader.js +559 -0
- package/dist/downloader/loomDownloader.js.map +1 -0
- package/dist/downloader/loomDownloader.test.d.ts +2 -0
- package/dist/downloader/loomDownloader.test.d.ts.map +1 -0
- package/dist/downloader/loomDownloader.test.js +36 -0
- package/dist/downloader/loomDownloader.test.js.map +1 -0
- package/dist/downloader/queue.d.ts +56 -0
- package/dist/downloader/queue.d.ts.map +1 -0
- package/dist/downloader/queue.js +88 -0
- package/dist/downloader/queue.js.map +1 -0
- package/dist/downloader/queue.test.d.ts +2 -0
- package/dist/downloader/queue.test.d.ts.map +1 -0
- package/dist/downloader/queue.test.js +158 -0
- package/dist/downloader/queue.test.js.map +1 -0
- package/dist/downloader/videoDownloader.d.ts +32 -0
- package/dist/downloader/videoDownloader.d.ts.map +1 -0
- package/dist/downloader/videoDownloader.js +173 -0
- package/dist/downloader/videoDownloader.js.map +1 -0
- package/dist/downloader/vimeoDownloader.d.ts +52 -0
- package/dist/downloader/vimeoDownloader.d.ts.map +1 -0
- package/dist/downloader/vimeoDownloader.js +565 -0
- package/dist/downloader/vimeoDownloader.js.map +1 -0
- package/dist/downloader/vimeoDownloader.test.d.ts +2 -0
- package/dist/downloader/vimeoDownloader.test.d.ts.map +1 -0
- package/dist/downloader/vimeoDownloader.test.js +51 -0
- package/dist/downloader/vimeoDownloader.test.js.map +1 -0
- package/dist/scraper/auth.d.ts +29 -0
- package/dist/scraper/auth.d.ts.map +1 -0
- package/dist/scraper/auth.js +115 -0
- package/dist/scraper/auth.js.map +1 -0
- package/dist/scraper/extractor.d.ts +49 -0
- package/dist/scraper/extractor.d.ts.map +1 -0
- package/dist/scraper/extractor.js +627 -0
- package/dist/scraper/extractor.js.map +1 -0
- package/dist/scraper/extractor.test.d.ts +2 -0
- package/dist/scraper/extractor.test.d.ts.map +1 -0
- package/dist/scraper/extractor.test.js +65 -0
- package/dist/scraper/extractor.test.js.map +1 -0
- package/dist/scraper/ghl/auth.d.ts +25 -0
- package/dist/scraper/ghl/auth.d.ts.map +1 -0
- package/dist/scraper/ghl/auth.js +187 -0
- package/dist/scraper/ghl/auth.js.map +1 -0
- package/dist/scraper/ghl/extractor.d.ts +96 -0
- package/dist/scraper/ghl/extractor.d.ts.map +1 -0
- package/dist/scraper/ghl/extractor.js +345 -0
- package/dist/scraper/ghl/extractor.js.map +1 -0
- package/dist/scraper/ghl/index.d.ts +4 -0
- package/dist/scraper/ghl/index.d.ts.map +1 -0
- package/dist/scraper/ghl/index.js +4 -0
- package/dist/scraper/ghl/index.js.map +1 -0
- package/dist/scraper/ghl/navigator.d.ts +93 -0
- package/dist/scraper/ghl/navigator.d.ts.map +1 -0
- package/dist/scraper/ghl/navigator.js +447 -0
- package/dist/scraper/ghl/navigator.js.map +1 -0
- package/dist/scraper/highlevel/auth.d.ts +25 -0
- package/dist/scraper/highlevel/auth.d.ts.map +1 -0
- package/dist/scraper/highlevel/auth.js +189 -0
- package/dist/scraper/highlevel/auth.js.map +1 -0
- package/dist/scraper/highlevel/extractor.d.ts +97 -0
- package/dist/scraper/highlevel/extractor.d.ts.map +1 -0
- package/dist/scraper/highlevel/extractor.js +386 -0
- package/dist/scraper/highlevel/extractor.js.map +1 -0
- package/dist/scraper/highlevel/extractor.test.d.ts +2 -0
- package/dist/scraper/highlevel/extractor.test.d.ts.map +1 -0
- package/dist/scraper/highlevel/extractor.test.js +101 -0
- package/dist/scraper/highlevel/extractor.test.js.map +1 -0
- package/dist/scraper/highlevel/index.d.ts +3 -0
- package/dist/scraper/highlevel/index.d.ts.map +1 -0
- package/dist/scraper/highlevel/index.js +3 -0
- package/dist/scraper/highlevel/index.js.map +1 -0
- package/dist/scraper/highlevel/navigator.d.ts +93 -0
- package/dist/scraper/highlevel/navigator.d.ts.map +1 -0
- package/dist/scraper/highlevel/navigator.js +492 -0
- package/dist/scraper/highlevel/navigator.js.map +1 -0
- package/dist/scraper/highlevel/navigator.test.d.ts +2 -0
- package/dist/scraper/highlevel/navigator.test.d.ts.map +1 -0
- package/dist/scraper/highlevel/navigator.test.js +78 -0
- package/dist/scraper/highlevel/navigator.test.js.map +1 -0
- package/dist/scraper/navigator.d.ts +65 -0
- package/dist/scraper/navigator.d.ts.map +1 -0
- package/dist/scraper/navigator.js +300 -0
- package/dist/scraper/navigator.js.map +1 -0
- package/dist/scraper/navigator.test.d.ts +2 -0
- package/dist/scraper/navigator.test.d.ts.map +1 -0
- package/dist/scraper/navigator.test.js +63 -0
- package/dist/scraper/navigator.test.js.map +1 -0
- package/dist/scraper/skoolApi.d.ts +17 -0
- package/dist/scraper/skoolApi.d.ts.map +1 -0
- package/dist/scraper/skoolApi.js +72 -0
- package/dist/scraper/skoolApi.js.map +1 -0
- package/dist/scraper/videoInterceptor.d.ts +19 -0
- package/dist/scraper/videoInterceptor.d.ts.map +1 -0
- package/dist/scraper/videoInterceptor.js +315 -0
- package/dist/scraper/videoInterceptor.js.map +1 -0
- package/dist/shared/auth.d.ts +58 -0
- package/dist/shared/auth.d.ts.map +1 -0
- package/dist/shared/auth.js +211 -0
- package/dist/shared/auth.js.map +1 -0
- package/dist/shared/fs.d.ts +31 -0
- package/dist/shared/fs.d.ts.map +1 -0
- package/dist/shared/fs.js +73 -0
- package/dist/shared/fs.js.map +1 -0
- package/dist/shared/http.d.ts +15 -0
- package/dist/shared/http.d.ts.map +1 -0
- package/dist/shared/http.js +31 -0
- package/dist/shared/http.js.map +1 -0
- package/dist/shared/index.d.ts +4 -0
- package/dist/shared/index.d.ts.map +1 -0
- package/dist/shared/index.js +4 -0
- package/dist/shared/index.js.map +1 -0
- package/dist/state/database.d.ts +245 -0
- package/dist/state/database.d.ts.map +1 -0
- package/dist/state/database.js +676 -0
- package/dist/state/database.js.map +1 -0
- package/dist/state/database.test.d.ts +2 -0
- package/dist/state/database.test.d.ts.map +1 -0
- package/dist/state/database.test.js +34 -0
- package/dist/state/database.test.js.map +1 -0
- package/dist/state/index.d.ts +2 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +2 -0
- package/dist/state/index.js.map +1 -0
- package/dist/storage/fileSystem.d.ts +56 -0
- package/dist/storage/fileSystem.d.ts.map +1 -0
- package/dist/storage/fileSystem.js +121 -0
- package/dist/storage/fileSystem.js.map +1 -0
- package/dist/transcription/whisperService.d.ts +27 -0
- package/dist/transcription/whisperService.d.ts.map +1 -0
- package/dist/transcription/whisperService.js +102 -0
- package/dist/transcription/whisperService.js.map +1 -0
- package/eslint.config.js +55 -0
- package/package.json +68 -11
- package/src/__fixtures__/highlevel-post-response.json +68 -0
- package/src/__fixtures__/hls-master-playlist.m3u8 +24 -0
- package/src/cli/commands/__snapshots__/syncHighLevel.test.ts.snap +38 -0
- package/src/cli/commands/config.ts +74 -0
- package/src/cli/commands/inspect.ts +441 -0
- package/src/cli/commands/login.ts +68 -0
- package/src/cli/commands/status.ts +147 -0
- package/src/cli/commands/sync.ts +1235 -0
- package/src/cli/commands/syncHighLevel.test.ts +144 -0
- package/src/cli/commands/syncHighLevel.ts +639 -0
- package/src/cli/index.ts +121 -0
- package/src/config/configManager.ts +75 -0
- package/src/config/paths.test.ts +83 -0
- package/src/config/paths.ts +36 -0
- package/src/config/schema.test.ts +173 -0
- package/src/config/schema.ts +65 -0
- package/src/downloader/hlsDownloader.test.ts +148 -0
- package/src/downloader/hlsDownloader.ts +327 -0
- package/src/downloader/hlsValidator.ts +196 -0
- package/src/downloader/index.ts +122 -0
- package/src/downloader/loomDownloader.test.ts +43 -0
- package/src/downloader/loomDownloader.ts +742 -0
- package/src/downloader/queue.test.ts +199 -0
- package/src/downloader/queue.ts +118 -0
- package/src/downloader/vimeoDownloader.test.ts +62 -0
- package/src/downloader/vimeoDownloader.ts +722 -0
- package/src/scraper/extractor.test.ts +124 -0
- package/src/scraper/extractor.ts +757 -0
- package/src/scraper/highlevel/__snapshots__/extractor.test.ts.snap +41 -0
- package/src/scraper/highlevel/extractor.test.ts +134 -0
- package/src/scraper/highlevel/extractor.ts +537 -0
- package/src/scraper/highlevel/index.ts +2 -0
- package/src/scraper/highlevel/navigator.test.ts +110 -0
- package/src/scraper/highlevel/navigator.ts +668 -0
- package/src/scraper/highlevel/schemas.ts +183 -0
- package/src/scraper/navigator.test.ts +122 -0
- package/src/scraper/navigator.ts +355 -0
- package/src/scraper/schemas.ts +177 -0
- package/src/scraper/videoInterceptor.ts +435 -0
- package/src/shared/auth.test.ts +58 -0
- package/src/shared/auth.ts +251 -0
- package/src/shared/firebase.ts +151 -0
- package/src/shared/fs.ts +80 -0
- package/src/shared/http.ts +34 -0
- package/src/shared/index.ts +6 -0
- package/src/shared/slug.ts +26 -0
- package/src/shared/url.test.ts +122 -0
- package/src/shared/url.ts +57 -0
- package/src/state/database.test.ts +49 -0
- package/src/state/database.ts +919 -0
- package/src/state/index.ts +14 -0
- package/src/storage/fileSystem.test.ts +64 -0
- package/src/storage/fileSystem.ts +175 -0
- package/tsconfig.json +28 -0
- package/vitest.config.ts +29 -0
- package/cli.js +0 -45
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schemas for HighLevel API responses.
|
|
3
|
+
* These provide runtime validation and type inference.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
// Re-export Firebase auth types (Firebase is used by HighLevel for auth)
|
|
9
|
+
export {
|
|
10
|
+
FirebaseAuthTokenSchema,
|
|
11
|
+
type FirebaseAuthToken,
|
|
12
|
+
type FirebaseAuthRaw,
|
|
13
|
+
} from "../../shared/firebase.js";
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Portal Settings API
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
export const PortalSettingsResponseSchema = z.object({
|
|
20
|
+
locationId: z.string(),
|
|
21
|
+
portalName: z.string().optional(),
|
|
22
|
+
name: z.string().optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export type PortalSettingsResponse = z.infer<typeof PortalSettingsResponseSchema>;
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// Video License API
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
export const VideoLicenseResponseSchema = z.object({
|
|
32
|
+
url: z.string(),
|
|
33
|
+
token: z.string(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export type VideoLicenseResponse = z.infer<typeof VideoLicenseResponseSchema>;
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Post Details API
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
const VideoAssetSchema = z.object({
|
|
43
|
+
id: z.string().optional(),
|
|
44
|
+
assetId: z.string().optional(),
|
|
45
|
+
assetsLicenseId: z.string().optional(),
|
|
46
|
+
url: z.string().optional(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const PosterImageSchema = z.object({
|
|
50
|
+
assetId: z.string().optional(),
|
|
51
|
+
url: z.string().optional(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const ContentBlockSchema = z.object({
|
|
55
|
+
type: z.string(),
|
|
56
|
+
id: z.string().optional(),
|
|
57
|
+
assetId: z.string().optional(),
|
|
58
|
+
assetsLicenseId: z.string().optional(),
|
|
59
|
+
url: z.string().optional(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const MaterialSchema = z.object({
|
|
63
|
+
id: z.string().optional(),
|
|
64
|
+
name: z.string().optional(),
|
|
65
|
+
url: z.string().optional(),
|
|
66
|
+
type: z.string().optional(),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const PostDetailsSchema = z.object({
|
|
70
|
+
title: z.string().optional(),
|
|
71
|
+
description: z.string().nullable().optional(),
|
|
72
|
+
video: VideoAssetSchema.nullable().optional(),
|
|
73
|
+
posterImage: PosterImageSchema.nullable().optional(),
|
|
74
|
+
contentBlock: z.array(ContentBlockSchema).optional(),
|
|
75
|
+
materials: z.array(MaterialSchema).optional(),
|
|
76
|
+
post_materials: z.array(MaterialSchema).optional(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Response can have data directly or wrapped in "post"
|
|
80
|
+
export const PostDetailsResponseSchema = z.object({
|
|
81
|
+
post: PostDetailsSchema.optional(),
|
|
82
|
+
// Also allow all post fields directly on root
|
|
83
|
+
title: z.string().optional(),
|
|
84
|
+
description: z.string().nullable().optional(),
|
|
85
|
+
video: VideoAssetSchema.nullable().optional(),
|
|
86
|
+
posterImage: PosterImageSchema.nullable().optional(),
|
|
87
|
+
contentBlock: z.array(ContentBlockSchema).optional(),
|
|
88
|
+
materials: z.array(MaterialSchema).optional(),
|
|
89
|
+
post_materials: z.array(MaterialSchema).optional(),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export type PostDetailsResponse = z.infer<typeof PostDetailsResponseSchema>;
|
|
93
|
+
|
|
94
|
+
// ============================================================================
|
|
95
|
+
// Categories API
|
|
96
|
+
// ============================================================================
|
|
97
|
+
|
|
98
|
+
export const CategorySchema = z.object({
|
|
99
|
+
id: z.string(),
|
|
100
|
+
title: z.string(),
|
|
101
|
+
description: z.string().nullable().optional(),
|
|
102
|
+
position: z.number().optional(),
|
|
103
|
+
postCount: z.number().optional(),
|
|
104
|
+
visibility: z.string().optional(),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export const CategoriesResponseSchema = z.object({
|
|
108
|
+
categories: z.array(CategorySchema),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
export type CategoriesResponse = z.infer<typeof CategoriesResponseSchema>;
|
|
112
|
+
export type Category = z.infer<typeof CategorySchema>;
|
|
113
|
+
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// Posts (Lessons) API
|
|
116
|
+
// ============================================================================
|
|
117
|
+
|
|
118
|
+
export const PostSchema = z.object({
|
|
119
|
+
id: z.string(),
|
|
120
|
+
title: z.string(),
|
|
121
|
+
indexPosition: z.number().optional(),
|
|
122
|
+
visibility: z.string().optional(),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export const PostsResponseSchema = z.object({
|
|
126
|
+
category: z
|
|
127
|
+
.object({
|
|
128
|
+
posts: z.array(PostSchema),
|
|
129
|
+
})
|
|
130
|
+
.optional(),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
export type PostsResponse = z.infer<typeof PostsResponseSchema>;
|
|
134
|
+
export type Post = z.infer<typeof PostSchema>;
|
|
135
|
+
|
|
136
|
+
// ============================================================================
|
|
137
|
+
// Product (Course) API
|
|
138
|
+
// ============================================================================
|
|
139
|
+
|
|
140
|
+
export const ProductSchema = z.object({
|
|
141
|
+
id: z.string().optional(),
|
|
142
|
+
title: z.string(),
|
|
143
|
+
description: z.string().optional(),
|
|
144
|
+
posterImage: z.string().nullable().optional(),
|
|
145
|
+
instructor: z.string().nullable().optional(),
|
|
146
|
+
postCount: z.number().optional(),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
export const ProductResponseSchema = z.object({
|
|
150
|
+
product: ProductSchema.optional(),
|
|
151
|
+
// Also allow fields directly on root
|
|
152
|
+
id: z.string().optional(),
|
|
153
|
+
title: z.string().optional(),
|
|
154
|
+
description: z.string().optional(),
|
|
155
|
+
posterImage: z.string().nullable().optional(),
|
|
156
|
+
instructor: z.string().nullable().optional(),
|
|
157
|
+
postCount: z.number().optional(),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export type ProductResponse = z.infer<typeof ProductResponseSchema>;
|
|
161
|
+
export type Product = z.infer<typeof ProductSchema>;
|
|
162
|
+
|
|
163
|
+
// ============================================================================
|
|
164
|
+
// Helper: Safe parse with logging
|
|
165
|
+
// ============================================================================
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Safely parses data with a Zod schema.
|
|
169
|
+
* Returns the parsed data or null if validation fails.
|
|
170
|
+
* Logs validation errors for debugging.
|
|
171
|
+
*/
|
|
172
|
+
export function safeParse<T>(schema: z.ZodType<T>, data: unknown, context?: string): T | null {
|
|
173
|
+
const result = schema.safeParse(data);
|
|
174
|
+
if (result.success) {
|
|
175
|
+
return result.data;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (context) {
|
|
179
|
+
console.warn(`[${context}] Validation failed:`, z.treeifyError(result.error));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createFolderName, slugify, isModuleUrl, getClassroomBaseUrl } from "./navigator.js";
|
|
3
|
+
|
|
4
|
+
describe("slugify", () => {
|
|
5
|
+
it("converts to lowercase", () => {
|
|
6
|
+
expect(slugify("Hello World")).toBe("hello-world");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("replaces spaces with hyphens", () => {
|
|
10
|
+
expect(slugify("hello world test")).toBe("hello-world-test");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("removes special characters", () => {
|
|
14
|
+
expect(slugify("Hello! World? Test.")).toBe("hello-world-test");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("handles German umlauts", () => {
|
|
18
|
+
expect(slugify("Größe")).toBe("groesse");
|
|
19
|
+
expect(slugify("Über")).toBe("ueber");
|
|
20
|
+
expect(slugify("Änderung")).toBe("aenderung");
|
|
21
|
+
expect(slugify("Straße")).toBe("strasse");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("collapses multiple hyphens", () => {
|
|
25
|
+
expect(slugify("hello world")).toBe("hello-world");
|
|
26
|
+
expect(slugify("hello---world")).toBe("hello-world");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("removes leading and trailing hyphens", () => {
|
|
30
|
+
expect(slugify(" hello world ")).toBe("hello-world");
|
|
31
|
+
expect(slugify("---hello---")).toBe("hello");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("truncates to 100 characters", () => {
|
|
35
|
+
const longString = "a".repeat(150);
|
|
36
|
+
const result = slugify(longString);
|
|
37
|
+
expect(result.length).toBeLessThanOrEqual(100);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("handles numbers", () => {
|
|
41
|
+
expect(slugify("Lesson 1: Introduction")).toBe("lesson-1-introduction");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("handles empty string", () => {
|
|
45
|
+
expect(slugify("")).toBe("");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("handles string with only special characters", () => {
|
|
49
|
+
// @sindresorhus/slugify converts & to "and"
|
|
50
|
+
expect(slugify("!@#$%^&*()")).toBe("and");
|
|
51
|
+
// Pure symbols without & become empty
|
|
52
|
+
expect(slugify("!@#$%^*()")).toBe("");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("createFolderName", () => {
|
|
57
|
+
it("creates folder name with zero-padded index", () => {
|
|
58
|
+
expect(createFolderName(0, "Introduction")).toBe("01-introduction");
|
|
59
|
+
expect(createFolderName(9, "Advanced Topics")).toBe("10-advanced-topics");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("handles double-digit indices", () => {
|
|
63
|
+
expect(createFolderName(99, "Last Module")).toBe("100-last-module");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("applies slugify to name", () => {
|
|
67
|
+
expect(createFolderName(0, "Hello World!")).toBe("01-hello-world");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("handles German characters in name", () => {
|
|
71
|
+
expect(createFolderName(0, "Einführung")).toBe("01-einfuehrung");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("handles empty name", () => {
|
|
75
|
+
expect(createFolderName(0, "")).toBe("01-");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("isModuleUrl", () => {
|
|
80
|
+
it("detects module URL with 8-char hex slug", () => {
|
|
81
|
+
const result = isModuleUrl("https://www.skool.com/community/classroom/a1b2c3d4");
|
|
82
|
+
expect(result).toEqual({ isModule: true, moduleSlug: "a1b2c3d4" });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("detects module URL with query params", () => {
|
|
86
|
+
const result = isModuleUrl("https://www.skool.com/community/classroom/deadbeef?md=abc");
|
|
87
|
+
expect(result).toEqual({ isModule: true, moduleSlug: "deadbeef" });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns false for classroom root", () => {
|
|
91
|
+
const result = isModuleUrl("https://www.skool.com/community/classroom");
|
|
92
|
+
expect(result).toEqual({ isModule: false, moduleSlug: null });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns false for non-classroom URLs", () => {
|
|
96
|
+
const result = isModuleUrl("https://www.skool.com/community/about");
|
|
97
|
+
expect(result).toEqual({ isModule: false, moduleSlug: null });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("only matches valid hex slugs", () => {
|
|
101
|
+
// "zzzzzzzz" is not hex
|
|
102
|
+
const result = isModuleUrl("https://www.skool.com/community/classroom/zzzzzzzz");
|
|
103
|
+
expect(result).toEqual({ isModule: false, moduleSlug: null });
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("getClassroomBaseUrl", () => {
|
|
108
|
+
it("removes module slug from URL", () => {
|
|
109
|
+
const result = getClassroomBaseUrl("https://www.skool.com/community/classroom/a1b2c3d4");
|
|
110
|
+
expect(result).toBe("https://www.skool.com/community/classroom");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("removes module slug and query params", () => {
|
|
114
|
+
const result = getClassroomBaseUrl("https://www.skool.com/community/classroom/a1b2c3d4?md=xyz");
|
|
115
|
+
expect(result).toBe("https://www.skool.com/community/classroom");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("keeps URL unchanged if no module slug", () => {
|
|
119
|
+
const result = getClassroomBaseUrl("https://www.skool.com/community/classroom");
|
|
120
|
+
expect(result).toBe("https://www.skool.com/community/classroom");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import {
|
|
3
|
+
parseNextData,
|
|
4
|
+
extractModulesFromNextData,
|
|
5
|
+
extractLessonAccessFromNextData,
|
|
6
|
+
} from "./schemas.js";
|
|
7
|
+
|
|
8
|
+
export interface CourseModule {
|
|
9
|
+
name: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
url: string;
|
|
12
|
+
isLocked: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Lesson {
|
|
16
|
+
name: string;
|
|
17
|
+
slug: string;
|
|
18
|
+
url: string;
|
|
19
|
+
index: number;
|
|
20
|
+
isLocked: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CourseStructure {
|
|
24
|
+
name: string;
|
|
25
|
+
url: string;
|
|
26
|
+
modules: (CourseModule & { lessons: Lesson[] })[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Browser automation - requires Playwright
|
|
30
|
+
/* v8 ignore start */
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extracts the course/community name from page data.
|
|
34
|
+
*/
|
|
35
|
+
export async function extractCourseName(page: Page): Promise<string> {
|
|
36
|
+
const title = await page.title();
|
|
37
|
+
// Title format: "Classroom · Community Name"
|
|
38
|
+
const match = /·\s*(.+)$/.exec(title);
|
|
39
|
+
return (match?.[1]?.trim() ?? title.replace("Classroom", "").trim()) || "Unknown Course";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extracts module data from the embedded JSON in the page.
|
|
44
|
+
* Skool embeds course structure as JSON in a script tag.
|
|
45
|
+
*/
|
|
46
|
+
export async function extractModulesFromJson(page: Page): Promise<CourseModule[]> {
|
|
47
|
+
// Get the raw JSON from the page
|
|
48
|
+
const nextDataJson = await page.evaluate(() => {
|
|
49
|
+
const nextDataScript = document.getElementById("__NEXT_DATA__");
|
|
50
|
+
return nextDataScript?.textContent ?? null;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Parse and validate with Zod schema (in Node context)
|
|
54
|
+
if (nextDataJson) {
|
|
55
|
+
const parsed = parseNextData(nextDataJson);
|
|
56
|
+
if (parsed) {
|
|
57
|
+
const skoolModules = extractModulesFromNextData(parsed);
|
|
58
|
+
if (skoolModules.length > 0) {
|
|
59
|
+
const baseUrl = page.url().split("/classroom")[0];
|
|
60
|
+
return skoolModules.map((m) => ({
|
|
61
|
+
name: m.title,
|
|
62
|
+
slug: m.slug,
|
|
63
|
+
url: `${baseUrl}/classroom/${m.slug}`,
|
|
64
|
+
isLocked: !m.hasAccess,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Fallback: Find script tags that contain course data (regex approach)
|
|
71
|
+
const modules = await page.evaluate(() => {
|
|
72
|
+
const scripts = Array.from(document.querySelectorAll("script"));
|
|
73
|
+
const results: CourseModule[] = [];
|
|
74
|
+
|
|
75
|
+
for (const script of scripts) {
|
|
76
|
+
const content = script.textContent ?? "";
|
|
77
|
+
|
|
78
|
+
// Look for module data pattern in the JSON
|
|
79
|
+
// Structure: "id":"...","name":"SLUG","metadata":{..."title":"TITLE"...}
|
|
80
|
+
// Pattern: "name":"8-char-hex" followed by "title":"..." within metadata
|
|
81
|
+
const modulePattern = /"name":"([a-f0-9]{8})","metadata":\{[^}]*"title":"([^"]+)"/g;
|
|
82
|
+
let match;
|
|
83
|
+
|
|
84
|
+
while ((match = modulePattern.exec(content)) !== null) {
|
|
85
|
+
const slug = match[1];
|
|
86
|
+
const title = match[2];
|
|
87
|
+
|
|
88
|
+
// Skip if already added
|
|
89
|
+
if (slug && title && !results.some((m) => m.slug === slug)) {
|
|
90
|
+
// Decode unicode escapes (e.g., \u0026 -> &)
|
|
91
|
+
const decodedTitle = title.replace(/\\u([0-9a-fA-F]{4})/g, (_, code: string) =>
|
|
92
|
+
String.fromCharCode(parseInt(code, 16))
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
results.push({
|
|
96
|
+
name: decodedTitle,
|
|
97
|
+
slug,
|
|
98
|
+
url: "", // Will be set later
|
|
99
|
+
isLocked: false,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return results;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Build URLs for each module
|
|
109
|
+
const baseUrl = page.url().split("/classroom")[0];
|
|
110
|
+
|
|
111
|
+
return modules.map((module) => ({
|
|
112
|
+
...module,
|
|
113
|
+
url: `${baseUrl}/classroom/${module.slug}`,
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Extracts lessons from a module page.
|
|
119
|
+
* Lessons are listed in the sidebar with links.
|
|
120
|
+
*/
|
|
121
|
+
export async function extractLessons(page: Page, moduleUrl: string): Promise<Lesson[]> {
|
|
122
|
+
const currentUrl = page.url();
|
|
123
|
+
|
|
124
|
+
const moduleBasePath = moduleUrl.split("?")[0] ?? moduleUrl;
|
|
125
|
+
if (!currentUrl.includes(moduleBasePath)) {
|
|
126
|
+
await page.goto(moduleUrl, { timeout: 30000 });
|
|
127
|
+
await page.waitForLoadState("domcontentloaded");
|
|
128
|
+
await page.waitForTimeout(2000);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Get __NEXT_DATA__ and parse it in Node context
|
|
132
|
+
const nextDataJson = await page.evaluate(() => {
|
|
133
|
+
const nextDataScript = document.getElementById("__NEXT_DATA__");
|
|
134
|
+
return nextDataScript?.textContent ?? null;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Build access map from validated data
|
|
138
|
+
let accessMap = new Map<string, boolean>();
|
|
139
|
+
if (nextDataJson) {
|
|
140
|
+
const parsed = parseNextData(nextDataJson);
|
|
141
|
+
if (parsed) {
|
|
142
|
+
accessMap = extractLessonAccessFromNextData(parsed);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Extract lesson links from DOM
|
|
147
|
+
const lessonData = await page.evaluate(() => {
|
|
148
|
+
const results: { name: string; slug: string; href: string }[] = [];
|
|
149
|
+
|
|
150
|
+
// Skool uses styled-components with "ChildrenLink" in the class name
|
|
151
|
+
const lessonLinks = document.querySelectorAll('a[class*="ChildrenLink"]');
|
|
152
|
+
|
|
153
|
+
lessonLinks.forEach((link, index) => {
|
|
154
|
+
const anchor = link as HTMLAnchorElement;
|
|
155
|
+
const href = anchor.href;
|
|
156
|
+
const name = anchor.textContent?.trim() ?? `Lesson ${index + 1}`;
|
|
157
|
+
|
|
158
|
+
// Extract lesson ID from URL (?md=...)
|
|
159
|
+
const urlParams = new URL(href).searchParams;
|
|
160
|
+
const lessonId = urlParams.get("md") ?? "";
|
|
161
|
+
|
|
162
|
+
if (lessonId && !results.some((l) => l.slug === lessonId)) {
|
|
163
|
+
results.push({ name, slug: lessonId, href });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return results;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Build final lesson list with access info
|
|
171
|
+
return lessonData.map((lesson, index) => {
|
|
172
|
+
let isLocked = false;
|
|
173
|
+
|
|
174
|
+
// Check access map from __NEXT_DATA__
|
|
175
|
+
if (accessMap.has(lesson.slug)) {
|
|
176
|
+
isLocked = !accessMap.get(lesson.slug);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
name: lesson.name,
|
|
181
|
+
slug: lesson.slug,
|
|
182
|
+
url: lesson.href,
|
|
183
|
+
index,
|
|
184
|
+
isLocked,
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Alternative: Extract modules from the classroom overview page links.
|
|
191
|
+
*/
|
|
192
|
+
export async function extractModulesFromPage(page: Page): Promise<CourseModule[]> {
|
|
193
|
+
await page.waitForTimeout(1000);
|
|
194
|
+
|
|
195
|
+
const modules = await page.evaluate(() => {
|
|
196
|
+
// Look for module cards - they're usually divs/links with course images
|
|
197
|
+
const moduleCards = document.querySelectorAll('a[href*="/classroom/"]');
|
|
198
|
+
const results: CourseModule[] = [];
|
|
199
|
+
const seen = new Set<string>();
|
|
200
|
+
|
|
201
|
+
moduleCards.forEach((card) => {
|
|
202
|
+
const anchor = card as HTMLAnchorElement;
|
|
203
|
+
const href = anchor.href;
|
|
204
|
+
|
|
205
|
+
// Extract slug from URL (8 character hex string)
|
|
206
|
+
const slugMatch = /\/classroom\/([a-f0-9]{8})(?:\?|$)/.exec(href);
|
|
207
|
+
if (!slugMatch?.[1]) return;
|
|
208
|
+
|
|
209
|
+
const slug = slugMatch[1];
|
|
210
|
+
if (seen.has(slug)) return;
|
|
211
|
+
seen.add(slug);
|
|
212
|
+
|
|
213
|
+
// Find title - could be in various child elements
|
|
214
|
+
const titleEl =
|
|
215
|
+
card.querySelector("h3, h4, [class*='title'], [class*='Title']") ??
|
|
216
|
+
card.querySelector("div > div > div");
|
|
217
|
+
const name = titleEl?.textContent?.trim() ?? `Module ${results.length + 1}`;
|
|
218
|
+
|
|
219
|
+
// Check for lock icon
|
|
220
|
+
const isLocked = card.querySelector('[class*="lock"], [class*="Lock"]') !== null;
|
|
221
|
+
|
|
222
|
+
results.push({
|
|
223
|
+
name,
|
|
224
|
+
slug,
|
|
225
|
+
url: href,
|
|
226
|
+
isLocked,
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
return results;
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
return modules;
|
|
234
|
+
}
|
|
235
|
+
/* v8 ignore stop */
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Checks if a URL points to a specific module (has 8-char hex slug).
|
|
239
|
+
*/
|
|
240
|
+
export function isModuleUrl(url: string): { isModule: boolean; moduleSlug: string | null } {
|
|
241
|
+
const match = /\/classroom\/([a-f0-9]{8})(?:\?|$)/.exec(url);
|
|
242
|
+
return {
|
|
243
|
+
isModule: !!match,
|
|
244
|
+
moduleSlug: match?.[1] ?? null,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Gets the classroom base URL (without module slug).
|
|
250
|
+
*/
|
|
251
|
+
export function getClassroomBaseUrl(url: string): string {
|
|
252
|
+
// Remove module slug and query params
|
|
253
|
+
return url.replace(/\/classroom\/[a-f0-9]{8}.*$/, "/classroom");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Progress callback for buildCourseStructure.
|
|
258
|
+
*/
|
|
259
|
+
export interface ScanProgress {
|
|
260
|
+
phase: "init" | "modules" | "lessons" | "done";
|
|
261
|
+
courseName?: string;
|
|
262
|
+
totalModules?: number;
|
|
263
|
+
currentModule?: string;
|
|
264
|
+
currentModuleIndex?: number;
|
|
265
|
+
lessonsFound?: number;
|
|
266
|
+
skippedLocked?: boolean;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* v8 ignore start */
|
|
270
|
+
/**
|
|
271
|
+
* Builds the complete course structure by crawling all modules and lessons.
|
|
272
|
+
*/
|
|
273
|
+
export async function buildCourseStructure(
|
|
274
|
+
page: Page,
|
|
275
|
+
classroomUrl: string,
|
|
276
|
+
onProgress?: (progress: ScanProgress) => void
|
|
277
|
+
): Promise<CourseStructure> {
|
|
278
|
+
const { isModule, moduleSlug } = isModuleUrl(classroomUrl);
|
|
279
|
+
|
|
280
|
+
// If URL points to a specific module, get the base classroom URL first
|
|
281
|
+
const baseClassroomUrl = isModule ? getClassroomBaseUrl(classroomUrl) : classroomUrl;
|
|
282
|
+
|
|
283
|
+
// Navigate to the classroom overview to get all modules
|
|
284
|
+
await page.goto(baseClassroomUrl, { timeout: 30000 });
|
|
285
|
+
await page.waitForLoadState("domcontentloaded");
|
|
286
|
+
await page.waitForTimeout(2000);
|
|
287
|
+
|
|
288
|
+
const courseName = await extractCourseName(page);
|
|
289
|
+
onProgress?.({ phase: "init", courseName });
|
|
290
|
+
|
|
291
|
+
// Try JSON extraction first (more reliable), fall back to page scraping
|
|
292
|
+
let modules = await extractModulesFromJson(page);
|
|
293
|
+
|
|
294
|
+
if (modules.length === 0) {
|
|
295
|
+
modules = await extractModulesFromPage(page);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// If user specified a specific module, filter to just that one
|
|
299
|
+
if (isModule && moduleSlug) {
|
|
300
|
+
const targetModule = modules.find((m) => m.slug === moduleSlug);
|
|
301
|
+
if (targetModule) {
|
|
302
|
+
modules = [targetModule];
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
onProgress?.({ phase: "modules", totalModules: modules.length });
|
|
307
|
+
|
|
308
|
+
const modulesWithLessons: CourseStructure["modules"] = [];
|
|
309
|
+
|
|
310
|
+
for (const [i, module] of modules.entries()) {
|
|
311
|
+
if (module.isLocked) {
|
|
312
|
+
onProgress?.({
|
|
313
|
+
phase: "lessons",
|
|
314
|
+
currentModule: module.name,
|
|
315
|
+
currentModuleIndex: i,
|
|
316
|
+
skippedLocked: true,
|
|
317
|
+
});
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
onProgress?.({
|
|
322
|
+
phase: "lessons",
|
|
323
|
+
currentModule: module.name,
|
|
324
|
+
currentModuleIndex: i,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
if (module.url) {
|
|
328
|
+
const lessons = await extractLessons(page, module.url);
|
|
329
|
+
|
|
330
|
+
onProgress?.({
|
|
331
|
+
phase: "lessons",
|
|
332
|
+
currentModule: module.name,
|
|
333
|
+
currentModuleIndex: i,
|
|
334
|
+
lessonsFound: lessons.length,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
modulesWithLessons.push({
|
|
338
|
+
...module,
|
|
339
|
+
lessons,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
onProgress?.({ phase: "done" });
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
name: courseName,
|
|
348
|
+
url: baseClassroomUrl,
|
|
349
|
+
modules: modulesWithLessons,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/* v8 ignore stop */
|
|
353
|
+
|
|
354
|
+
// Re-export shared utilities for backwards compatibility
|
|
355
|
+
export { slugify, createFolderName } from "../shared/slug.js";
|