offcourse 1.0.0 → 1.0.1

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.
Files changed (258) hide show
  1. package/dist/cli/commands/config.js.map +1 -1
  2. package/dist/cli/commands/inspect.js +1 -1
  3. package/dist/cli/commands/inspect.js.map +1 -1
  4. package/dist/cli/commands/sync.d.ts +1 -2
  5. package/dist/cli/commands/sync.d.ts.map +1 -1
  6. package/dist/cli/commands/sync.js +13 -14
  7. package/dist/cli/commands/sync.js.map +1 -1
  8. package/dist/cli/commands/syncHighLevel.d.ts +1 -2
  9. package/dist/cli/commands/syncHighLevel.d.ts.map +1 -1
  10. package/dist/cli/commands/syncHighLevel.js +4 -8
  11. package/dist/cli/commands/syncHighLevel.js.map +1 -1
  12. package/dist/cli/index.js +1 -1
  13. package/dist/cli/index.js.map +1 -1
  14. package/dist/config/configManager.d.ts.map +1 -1
  15. package/dist/config/configManager.js +4 -0
  16. package/dist/config/configManager.js.map +1 -1
  17. package/dist/downloader/hlsDownloader.d.ts.map +1 -1
  18. package/dist/downloader/hlsDownloader.js +23 -14
  19. package/dist/downloader/hlsDownloader.js.map +1 -1
  20. package/dist/downloader/hlsValidator.d.ts.map +1 -1
  21. package/dist/downloader/hlsValidator.js +6 -2
  22. package/dist/downloader/hlsValidator.js.map +1 -1
  23. package/dist/downloader/index.d.ts +3 -0
  24. package/dist/downloader/index.d.ts.map +1 -1
  25. package/dist/downloader/index.js +3 -0
  26. package/dist/downloader/index.js.map +1 -1
  27. package/dist/downloader/loomDownloader.d.ts.map +1 -1
  28. package/dist/downloader/loomDownloader.js +23 -20
  29. package/dist/downloader/loomDownloader.js.map +1 -1
  30. package/dist/downloader/queue.d.ts +4 -4
  31. package/dist/downloader/queue.d.ts.map +1 -1
  32. package/dist/downloader/queue.js.map +1 -1
  33. package/dist/downloader/vimeoDownloader.d.ts.map +1 -1
  34. package/dist/downloader/vimeoDownloader.js +7 -3
  35. package/dist/downloader/vimeoDownloader.js.map +1 -1
  36. package/dist/scraper/extractor.d.ts +4 -0
  37. package/dist/scraper/extractor.d.ts.map +1 -1
  38. package/dist/scraper/extractor.js +79 -79
  39. package/dist/scraper/extractor.js.map +1 -1
  40. package/dist/scraper/highlevel/extractor.d.ts +11 -19
  41. package/dist/scraper/highlevel/extractor.d.ts.map +1 -1
  42. package/dist/scraper/highlevel/extractor.js +72 -85
  43. package/dist/scraper/highlevel/extractor.js.map +1 -1
  44. package/dist/scraper/highlevel/navigator.d.ts +3 -10
  45. package/dist/scraper/highlevel/navigator.d.ts.map +1 -1
  46. package/dist/scraper/highlevel/navigator.js +140 -127
  47. package/dist/scraper/highlevel/navigator.js.map +1 -1
  48. package/dist/scraper/highlevel/schemas.d.ts +188 -0
  49. package/dist/scraper/highlevel/schemas.d.ts.map +1 -0
  50. package/dist/scraper/highlevel/schemas.js +139 -0
  51. package/dist/scraper/highlevel/schemas.js.map +1 -0
  52. package/dist/scraper/navigator.d.ts +14 -11
  53. package/dist/scraper/navigator.d.ts.map +1 -1
  54. package/dist/scraper/navigator.js +61 -104
  55. package/dist/scraper/navigator.js.map +1 -1
  56. package/dist/scraper/schemas.d.ts +57 -0
  57. package/dist/scraper/schemas.d.ts.map +1 -0
  58. package/dist/scraper/schemas.js +135 -0
  59. package/dist/scraper/schemas.js.map +1 -0
  60. package/dist/scraper/videoInterceptor.d.ts +4 -0
  61. package/dist/scraper/videoInterceptor.d.ts.map +1 -1
  62. package/dist/scraper/videoInterceptor.js +66 -51
  63. package/dist/scraper/videoInterceptor.js.map +1 -1
  64. package/dist/shared/auth.d.ts +9 -9
  65. package/dist/shared/auth.d.ts.map +1 -1
  66. package/dist/shared/auth.js +24 -38
  67. package/dist/shared/auth.js.map +1 -1
  68. package/dist/shared/firebase.d.ts +60 -0
  69. package/dist/shared/firebase.d.ts.map +1 -0
  70. package/dist/shared/firebase.js +102 -0
  71. package/dist/shared/firebase.js.map +1 -0
  72. package/dist/shared/fs.d.ts.map +1 -1
  73. package/dist/shared/fs.js +4 -0
  74. package/dist/shared/fs.js.map +1 -1
  75. package/dist/shared/index.d.ts +3 -0
  76. package/dist/shared/index.d.ts.map +1 -1
  77. package/dist/shared/index.js +3 -0
  78. package/dist/shared/index.js.map +1 -1
  79. package/dist/shared/slug.d.ts +11 -0
  80. package/dist/shared/slug.d.ts.map +1 -0
  81. package/{src/shared/slug.ts → dist/shared/slug.js} +10 -11
  82. package/dist/shared/slug.js.map +1 -0
  83. package/dist/shared/url.d.ts +43 -0
  84. package/dist/shared/url.d.ts.map +1 -0
  85. package/{src/shared/url.ts → dist/shared/url.js} +12 -15
  86. package/dist/shared/url.js.map +1 -0
  87. package/dist/state/database.d.ts +1 -0
  88. package/dist/state/database.d.ts.map +1 -1
  89. package/dist/state/database.js +3 -0
  90. package/dist/state/database.js.map +1 -1
  91. package/dist/storage/fileSystem.d.ts +17 -17
  92. package/dist/storage/fileSystem.d.ts.map +1 -1
  93. package/dist/storage/fileSystem.js +39 -31
  94. package/dist/storage/fileSystem.js.map +1 -1
  95. package/package.json +5 -2
  96. package/.github/workflows/ci.yml +0 -50
  97. package/.husky/commit-msg +0 -2
  98. package/.husky/pre-commit +0 -1
  99. package/.husky/pre-push +0 -3
  100. package/.prettierrc +0 -8
  101. package/.release-it.json +0 -23
  102. package/ARCHITECTURE.md +0 -233
  103. package/CHANGELOG.md +0 -78
  104. package/commitlint.config.js +0 -4
  105. package/dist/ai/openRouter.d.ts +0 -47
  106. package/dist/ai/openRouter.d.ts.map +0 -1
  107. package/dist/ai/openRouter.js +0 -116
  108. package/dist/ai/openRouter.js.map +0 -1
  109. package/dist/ai/transcriptPolisher.d.ts +0 -24
  110. package/dist/ai/transcriptPolisher.d.ts.map +0 -1
  111. package/dist/ai/transcriptPolisher.js +0 -89
  112. package/dist/ai/transcriptPolisher.js.map +0 -1
  113. package/dist/cli/commands/enrich.d.ts +0 -14
  114. package/dist/cli/commands/enrich.d.ts.map +0 -1
  115. package/dist/cli/commands/enrich.js +0 -271
  116. package/dist/cli/commands/enrich.js.map +0 -1
  117. package/dist/cli/commands/syncGhl.d.ts +0 -20
  118. package/dist/cli/commands/syncGhl.d.ts.map +0 -1
  119. package/dist/cli/commands/syncGhl.js +0 -483
  120. package/dist/cli/commands/syncGhl.js.map +0 -1
  121. package/dist/cli/commands/syncHighLevel.test.d.ts +0 -2
  122. package/dist/cli/commands/syncHighLevel.test.d.ts.map +0 -1
  123. package/dist/cli/commands/syncHighLevel.test.js +0 -102
  124. package/dist/cli/commands/syncHighLevel.test.js.map +0 -1
  125. package/dist/config/paths.test.d.ts +0 -2
  126. package/dist/config/paths.test.d.ts.map +0 -1
  127. package/dist/config/paths.test.js +0 -70
  128. package/dist/config/paths.test.js.map +0 -1
  129. package/dist/config/schema.test.d.ts +0 -2
  130. package/dist/config/schema.test.d.ts.map +0 -1
  131. package/dist/config/schema.test.js +0 -151
  132. package/dist/config/schema.test.js.map +0 -1
  133. package/dist/downloader/hlsDownloader.test.d.ts +0 -2
  134. package/dist/downloader/hlsDownloader.test.d.ts.map +0 -1
  135. package/dist/downloader/hlsDownloader.test.js +0 -116
  136. package/dist/downloader/hlsDownloader.test.js.map +0 -1
  137. package/dist/downloader/loomDownloader.test.d.ts +0 -2
  138. package/dist/downloader/loomDownloader.test.d.ts.map +0 -1
  139. package/dist/downloader/loomDownloader.test.js +0 -36
  140. package/dist/downloader/loomDownloader.test.js.map +0 -1
  141. package/dist/downloader/queue.test.d.ts +0 -2
  142. package/dist/downloader/queue.test.d.ts.map +0 -1
  143. package/dist/downloader/queue.test.js +0 -158
  144. package/dist/downloader/queue.test.js.map +0 -1
  145. package/dist/downloader/videoDownloader.d.ts +0 -32
  146. package/dist/downloader/videoDownloader.d.ts.map +0 -1
  147. package/dist/downloader/videoDownloader.js +0 -173
  148. package/dist/downloader/videoDownloader.js.map +0 -1
  149. package/dist/downloader/vimeoDownloader.test.d.ts +0 -2
  150. package/dist/downloader/vimeoDownloader.test.d.ts.map +0 -1
  151. package/dist/downloader/vimeoDownloader.test.js +0 -51
  152. package/dist/downloader/vimeoDownloader.test.js.map +0 -1
  153. package/dist/scraper/auth.d.ts +0 -29
  154. package/dist/scraper/auth.d.ts.map +0 -1
  155. package/dist/scraper/auth.js +0 -115
  156. package/dist/scraper/auth.js.map +0 -1
  157. package/dist/scraper/extractor.test.d.ts +0 -2
  158. package/dist/scraper/extractor.test.d.ts.map +0 -1
  159. package/dist/scraper/extractor.test.js +0 -65
  160. package/dist/scraper/extractor.test.js.map +0 -1
  161. package/dist/scraper/ghl/auth.d.ts +0 -25
  162. package/dist/scraper/ghl/auth.d.ts.map +0 -1
  163. package/dist/scraper/ghl/auth.js +0 -187
  164. package/dist/scraper/ghl/auth.js.map +0 -1
  165. package/dist/scraper/ghl/extractor.d.ts +0 -96
  166. package/dist/scraper/ghl/extractor.d.ts.map +0 -1
  167. package/dist/scraper/ghl/extractor.js +0 -345
  168. package/dist/scraper/ghl/extractor.js.map +0 -1
  169. package/dist/scraper/ghl/index.d.ts +0 -4
  170. package/dist/scraper/ghl/index.d.ts.map +0 -1
  171. package/dist/scraper/ghl/index.js +0 -4
  172. package/dist/scraper/ghl/index.js.map +0 -1
  173. package/dist/scraper/ghl/navigator.d.ts +0 -93
  174. package/dist/scraper/ghl/navigator.d.ts.map +0 -1
  175. package/dist/scraper/ghl/navigator.js +0 -447
  176. package/dist/scraper/ghl/navigator.js.map +0 -1
  177. package/dist/scraper/highlevel/auth.d.ts +0 -25
  178. package/dist/scraper/highlevel/auth.d.ts.map +0 -1
  179. package/dist/scraper/highlevel/auth.js +0 -189
  180. package/dist/scraper/highlevel/auth.js.map +0 -1
  181. package/dist/scraper/highlevel/extractor.test.d.ts +0 -2
  182. package/dist/scraper/highlevel/extractor.test.d.ts.map +0 -1
  183. package/dist/scraper/highlevel/extractor.test.js +0 -101
  184. package/dist/scraper/highlevel/extractor.test.js.map +0 -1
  185. package/dist/scraper/highlevel/navigator.test.d.ts +0 -2
  186. package/dist/scraper/highlevel/navigator.test.d.ts.map +0 -1
  187. package/dist/scraper/highlevel/navigator.test.js +0 -78
  188. package/dist/scraper/highlevel/navigator.test.js.map +0 -1
  189. package/dist/scraper/navigator.test.d.ts +0 -2
  190. package/dist/scraper/navigator.test.d.ts.map +0 -1
  191. package/dist/scraper/navigator.test.js +0 -63
  192. package/dist/scraper/navigator.test.js.map +0 -1
  193. package/dist/scraper/skoolApi.d.ts +0 -17
  194. package/dist/scraper/skoolApi.d.ts.map +0 -1
  195. package/dist/scraper/skoolApi.js +0 -72
  196. package/dist/scraper/skoolApi.js.map +0 -1
  197. package/dist/state/database.test.d.ts +0 -2
  198. package/dist/state/database.test.d.ts.map +0 -1
  199. package/dist/state/database.test.js +0 -34
  200. package/dist/state/database.test.js.map +0 -1
  201. package/dist/transcription/whisperService.d.ts +0 -27
  202. package/dist/transcription/whisperService.d.ts.map +0 -1
  203. package/dist/transcription/whisperService.js +0 -102
  204. package/dist/transcription/whisperService.js.map +0 -1
  205. package/eslint.config.js +0 -55
  206. package/src/__fixtures__/highlevel-post-response.json +0 -68
  207. package/src/__fixtures__/hls-master-playlist.m3u8 +0 -24
  208. package/src/cli/commands/__snapshots__/syncHighLevel.test.ts.snap +0 -38
  209. package/src/cli/commands/config.ts +0 -74
  210. package/src/cli/commands/inspect.ts +0 -441
  211. package/src/cli/commands/login.ts +0 -68
  212. package/src/cli/commands/status.ts +0 -147
  213. package/src/cli/commands/sync.ts +0 -1235
  214. package/src/cli/commands/syncHighLevel.test.ts +0 -144
  215. package/src/cli/commands/syncHighLevel.ts +0 -639
  216. package/src/cli/index.ts +0 -121
  217. package/src/config/configManager.ts +0 -75
  218. package/src/config/paths.test.ts +0 -83
  219. package/src/config/paths.ts +0 -36
  220. package/src/config/schema.test.ts +0 -173
  221. package/src/config/schema.ts +0 -65
  222. package/src/downloader/hlsDownloader.test.ts +0 -148
  223. package/src/downloader/hlsDownloader.ts +0 -327
  224. package/src/downloader/hlsValidator.ts +0 -196
  225. package/src/downloader/index.ts +0 -122
  226. package/src/downloader/loomDownloader.test.ts +0 -43
  227. package/src/downloader/loomDownloader.ts +0 -742
  228. package/src/downloader/queue.test.ts +0 -199
  229. package/src/downloader/queue.ts +0 -118
  230. package/src/downloader/vimeoDownloader.test.ts +0 -62
  231. package/src/downloader/vimeoDownloader.ts +0 -722
  232. package/src/scraper/extractor.test.ts +0 -124
  233. package/src/scraper/extractor.ts +0 -757
  234. package/src/scraper/highlevel/__snapshots__/extractor.test.ts.snap +0 -41
  235. package/src/scraper/highlevel/extractor.test.ts +0 -134
  236. package/src/scraper/highlevel/extractor.ts +0 -537
  237. package/src/scraper/highlevel/index.ts +0 -2
  238. package/src/scraper/highlevel/navigator.test.ts +0 -110
  239. package/src/scraper/highlevel/navigator.ts +0 -668
  240. package/src/scraper/highlevel/schemas.ts +0 -183
  241. package/src/scraper/navigator.test.ts +0 -122
  242. package/src/scraper/navigator.ts +0 -355
  243. package/src/scraper/schemas.ts +0 -177
  244. package/src/scraper/videoInterceptor.ts +0 -435
  245. package/src/shared/auth.test.ts +0 -58
  246. package/src/shared/auth.ts +0 -251
  247. package/src/shared/firebase.ts +0 -151
  248. package/src/shared/fs.ts +0 -80
  249. package/src/shared/http.ts +0 -34
  250. package/src/shared/index.ts +0 -6
  251. package/src/shared/url.test.ts +0 -122
  252. package/src/state/database.test.ts +0 -49
  253. package/src/state/database.ts +0 -919
  254. package/src/state/index.ts +0 -14
  255. package/src/storage/fileSystem.test.ts +0 -64
  256. package/src/storage/fileSystem.ts +0 -175
  257. package/tsconfig.json +0 -28
  258. package/vitest.config.ts +0 -29
@@ -1,183 +0,0 @@
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
- }
@@ -1,122 +0,0 @@
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
- });
@@ -1,355 +0,0 @@
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";