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.
Files changed (284) hide show
  1. package/.github/workflows/ci.yml +50 -0
  2. package/.husky/commit-msg +2 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.husky/pre-push +3 -0
  5. package/.prettierrc +8 -0
  6. package/.release-it.json +23 -0
  7. package/ARCHITECTURE.md +233 -0
  8. package/CHANGELOG.md +78 -0
  9. package/README.md +256 -16
  10. package/commitlint.config.js +4 -0
  11. package/dist/ai/openRouter.d.ts +47 -0
  12. package/dist/ai/openRouter.d.ts.map +1 -0
  13. package/dist/ai/openRouter.js +116 -0
  14. package/dist/ai/openRouter.js.map +1 -0
  15. package/dist/ai/transcriptPolisher.d.ts +24 -0
  16. package/dist/ai/transcriptPolisher.d.ts.map +1 -0
  17. package/dist/ai/transcriptPolisher.js +89 -0
  18. package/dist/ai/transcriptPolisher.js.map +1 -0
  19. package/dist/cli/commands/config.d.ts +13 -0
  20. package/dist/cli/commands/config.d.ts.map +1 -0
  21. package/dist/cli/commands/config.js +66 -0
  22. package/dist/cli/commands/config.js.map +1 -0
  23. package/dist/cli/commands/enrich.d.ts +14 -0
  24. package/dist/cli/commands/enrich.d.ts.map +1 -0
  25. package/dist/cli/commands/enrich.js +271 -0
  26. package/dist/cli/commands/enrich.js.map +1 -0
  27. package/dist/cli/commands/inspect.d.ts +11 -0
  28. package/dist/cli/commands/inspect.d.ts.map +1 -0
  29. package/dist/cli/commands/inspect.js +365 -0
  30. package/dist/cli/commands/inspect.js.map +1 -0
  31. package/dist/cli/commands/login.d.ts +12 -0
  32. package/dist/cli/commands/login.d.ts.map +1 -0
  33. package/dist/cli/commands/login.js +55 -0
  34. package/dist/cli/commands/login.js.map +1 -0
  35. package/dist/cli/commands/status.d.ts +15 -0
  36. package/dist/cli/commands/status.d.ts.map +1 -0
  37. package/dist/cli/commands/status.js +118 -0
  38. package/dist/cli/commands/status.js.map +1 -0
  39. package/dist/cli/commands/sync.d.ts +16 -0
  40. package/dist/cli/commands/sync.d.ts.map +1 -0
  41. package/dist/cli/commands/sync.js +922 -0
  42. package/dist/cli/commands/sync.js.map +1 -0
  43. package/dist/cli/commands/syncGhl.d.ts +20 -0
  44. package/dist/cli/commands/syncGhl.d.ts.map +1 -0
  45. package/dist/cli/commands/syncGhl.js +483 -0
  46. package/dist/cli/commands/syncGhl.js.map +1 -0
  47. package/dist/cli/commands/syncHighLevel.d.ts +24 -0
  48. package/dist/cli/commands/syncHighLevel.d.ts.map +1 -0
  49. package/dist/cli/commands/syncHighLevel.js +483 -0
  50. package/dist/cli/commands/syncHighLevel.js.map +1 -0
  51. package/dist/cli/commands/syncHighLevel.test.d.ts +2 -0
  52. package/dist/cli/commands/syncHighLevel.test.d.ts.map +1 -0
  53. package/dist/cli/commands/syncHighLevel.test.js +102 -0
  54. package/dist/cli/commands/syncHighLevel.test.js.map +1 -0
  55. package/dist/cli/index.d.ts +3 -0
  56. package/dist/cli/index.d.ts.map +1 -0
  57. package/dist/cli/index.js +106 -0
  58. package/dist/cli/index.js.map +1 -0
  59. package/dist/config/configManager.d.ts +31 -0
  60. package/dist/config/configManager.d.ts.map +1 -0
  61. package/dist/config/configManager.js +64 -0
  62. package/dist/config/configManager.js.map +1 -0
  63. package/dist/config/paths.d.ts +21 -0
  64. package/dist/config/paths.d.ts.map +1 -0
  65. package/dist/config/paths.js +33 -0
  66. package/dist/config/paths.js.map +1 -0
  67. package/dist/config/paths.test.d.ts +2 -0
  68. package/dist/config/paths.test.d.ts.map +1 -0
  69. package/dist/config/paths.test.js +70 -0
  70. package/dist/config/paths.test.js.map +1 -0
  71. package/dist/config/schema.d.ts +60 -0
  72. package/dist/config/schema.d.ts.map +1 -0
  73. package/dist/config/schema.js +50 -0
  74. package/dist/config/schema.js.map +1 -0
  75. package/dist/config/schema.test.d.ts +2 -0
  76. package/dist/config/schema.test.d.ts.map +1 -0
  77. package/dist/config/schema.test.js +151 -0
  78. package/dist/config/schema.test.js.map +1 -0
  79. package/dist/downloader/hlsDownloader.d.ts +58 -0
  80. package/dist/downloader/hlsDownloader.d.ts.map +1 -0
  81. package/dist/downloader/hlsDownloader.js +254 -0
  82. package/dist/downloader/hlsDownloader.js.map +1 -0
  83. package/dist/downloader/hlsDownloader.test.d.ts +2 -0
  84. package/dist/downloader/hlsDownloader.test.d.ts.map +1 -0
  85. package/dist/downloader/hlsDownloader.test.js +116 -0
  86. package/dist/downloader/hlsDownloader.test.js.map +1 -0
  87. package/dist/downloader/hlsValidator.d.ts +35 -0
  88. package/dist/downloader/hlsValidator.d.ts.map +1 -0
  89. package/dist/downloader/hlsValidator.js +148 -0
  90. package/dist/downloader/hlsValidator.js.map +1 -0
  91. package/dist/downloader/index.d.ts +26 -0
  92. package/dist/downloader/index.d.ts.map +1 -0
  93. package/dist/downloader/index.js +52 -0
  94. package/dist/downloader/index.js.map +1 -0
  95. package/dist/downloader/loomDownloader.d.ts +56 -0
  96. package/dist/downloader/loomDownloader.d.ts.map +1 -0
  97. package/dist/downloader/loomDownloader.js +559 -0
  98. package/dist/downloader/loomDownloader.js.map +1 -0
  99. package/dist/downloader/loomDownloader.test.d.ts +2 -0
  100. package/dist/downloader/loomDownloader.test.d.ts.map +1 -0
  101. package/dist/downloader/loomDownloader.test.js +36 -0
  102. package/dist/downloader/loomDownloader.test.js.map +1 -0
  103. package/dist/downloader/queue.d.ts +56 -0
  104. package/dist/downloader/queue.d.ts.map +1 -0
  105. package/dist/downloader/queue.js +88 -0
  106. package/dist/downloader/queue.js.map +1 -0
  107. package/dist/downloader/queue.test.d.ts +2 -0
  108. package/dist/downloader/queue.test.d.ts.map +1 -0
  109. package/dist/downloader/queue.test.js +158 -0
  110. package/dist/downloader/queue.test.js.map +1 -0
  111. package/dist/downloader/videoDownloader.d.ts +32 -0
  112. package/dist/downloader/videoDownloader.d.ts.map +1 -0
  113. package/dist/downloader/videoDownloader.js +173 -0
  114. package/dist/downloader/videoDownloader.js.map +1 -0
  115. package/dist/downloader/vimeoDownloader.d.ts +52 -0
  116. package/dist/downloader/vimeoDownloader.d.ts.map +1 -0
  117. package/dist/downloader/vimeoDownloader.js +565 -0
  118. package/dist/downloader/vimeoDownloader.js.map +1 -0
  119. package/dist/downloader/vimeoDownloader.test.d.ts +2 -0
  120. package/dist/downloader/vimeoDownloader.test.d.ts.map +1 -0
  121. package/dist/downloader/vimeoDownloader.test.js +51 -0
  122. package/dist/downloader/vimeoDownloader.test.js.map +1 -0
  123. package/dist/scraper/auth.d.ts +29 -0
  124. package/dist/scraper/auth.d.ts.map +1 -0
  125. package/dist/scraper/auth.js +115 -0
  126. package/dist/scraper/auth.js.map +1 -0
  127. package/dist/scraper/extractor.d.ts +49 -0
  128. package/dist/scraper/extractor.d.ts.map +1 -0
  129. package/dist/scraper/extractor.js +627 -0
  130. package/dist/scraper/extractor.js.map +1 -0
  131. package/dist/scraper/extractor.test.d.ts +2 -0
  132. package/dist/scraper/extractor.test.d.ts.map +1 -0
  133. package/dist/scraper/extractor.test.js +65 -0
  134. package/dist/scraper/extractor.test.js.map +1 -0
  135. package/dist/scraper/ghl/auth.d.ts +25 -0
  136. package/dist/scraper/ghl/auth.d.ts.map +1 -0
  137. package/dist/scraper/ghl/auth.js +187 -0
  138. package/dist/scraper/ghl/auth.js.map +1 -0
  139. package/dist/scraper/ghl/extractor.d.ts +96 -0
  140. package/dist/scraper/ghl/extractor.d.ts.map +1 -0
  141. package/dist/scraper/ghl/extractor.js +345 -0
  142. package/dist/scraper/ghl/extractor.js.map +1 -0
  143. package/dist/scraper/ghl/index.d.ts +4 -0
  144. package/dist/scraper/ghl/index.d.ts.map +1 -0
  145. package/dist/scraper/ghl/index.js +4 -0
  146. package/dist/scraper/ghl/index.js.map +1 -0
  147. package/dist/scraper/ghl/navigator.d.ts +93 -0
  148. package/dist/scraper/ghl/navigator.d.ts.map +1 -0
  149. package/dist/scraper/ghl/navigator.js +447 -0
  150. package/dist/scraper/ghl/navigator.js.map +1 -0
  151. package/dist/scraper/highlevel/auth.d.ts +25 -0
  152. package/dist/scraper/highlevel/auth.d.ts.map +1 -0
  153. package/dist/scraper/highlevel/auth.js +189 -0
  154. package/dist/scraper/highlevel/auth.js.map +1 -0
  155. package/dist/scraper/highlevel/extractor.d.ts +97 -0
  156. package/dist/scraper/highlevel/extractor.d.ts.map +1 -0
  157. package/dist/scraper/highlevel/extractor.js +386 -0
  158. package/dist/scraper/highlevel/extractor.js.map +1 -0
  159. package/dist/scraper/highlevel/extractor.test.d.ts +2 -0
  160. package/dist/scraper/highlevel/extractor.test.d.ts.map +1 -0
  161. package/dist/scraper/highlevel/extractor.test.js +101 -0
  162. package/dist/scraper/highlevel/extractor.test.js.map +1 -0
  163. package/dist/scraper/highlevel/index.d.ts +3 -0
  164. package/dist/scraper/highlevel/index.d.ts.map +1 -0
  165. package/dist/scraper/highlevel/index.js +3 -0
  166. package/dist/scraper/highlevel/index.js.map +1 -0
  167. package/dist/scraper/highlevel/navigator.d.ts +93 -0
  168. package/dist/scraper/highlevel/navigator.d.ts.map +1 -0
  169. package/dist/scraper/highlevel/navigator.js +492 -0
  170. package/dist/scraper/highlevel/navigator.js.map +1 -0
  171. package/dist/scraper/highlevel/navigator.test.d.ts +2 -0
  172. package/dist/scraper/highlevel/navigator.test.d.ts.map +1 -0
  173. package/dist/scraper/highlevel/navigator.test.js +78 -0
  174. package/dist/scraper/highlevel/navigator.test.js.map +1 -0
  175. package/dist/scraper/navigator.d.ts +65 -0
  176. package/dist/scraper/navigator.d.ts.map +1 -0
  177. package/dist/scraper/navigator.js +300 -0
  178. package/dist/scraper/navigator.js.map +1 -0
  179. package/dist/scraper/navigator.test.d.ts +2 -0
  180. package/dist/scraper/navigator.test.d.ts.map +1 -0
  181. package/dist/scraper/navigator.test.js +63 -0
  182. package/dist/scraper/navigator.test.js.map +1 -0
  183. package/dist/scraper/skoolApi.d.ts +17 -0
  184. package/dist/scraper/skoolApi.d.ts.map +1 -0
  185. package/dist/scraper/skoolApi.js +72 -0
  186. package/dist/scraper/skoolApi.js.map +1 -0
  187. package/dist/scraper/videoInterceptor.d.ts +19 -0
  188. package/dist/scraper/videoInterceptor.d.ts.map +1 -0
  189. package/dist/scraper/videoInterceptor.js +315 -0
  190. package/dist/scraper/videoInterceptor.js.map +1 -0
  191. package/dist/shared/auth.d.ts +58 -0
  192. package/dist/shared/auth.d.ts.map +1 -0
  193. package/dist/shared/auth.js +211 -0
  194. package/dist/shared/auth.js.map +1 -0
  195. package/dist/shared/fs.d.ts +31 -0
  196. package/dist/shared/fs.d.ts.map +1 -0
  197. package/dist/shared/fs.js +73 -0
  198. package/dist/shared/fs.js.map +1 -0
  199. package/dist/shared/http.d.ts +15 -0
  200. package/dist/shared/http.d.ts.map +1 -0
  201. package/dist/shared/http.js +31 -0
  202. package/dist/shared/http.js.map +1 -0
  203. package/dist/shared/index.d.ts +4 -0
  204. package/dist/shared/index.d.ts.map +1 -0
  205. package/dist/shared/index.js +4 -0
  206. package/dist/shared/index.js.map +1 -0
  207. package/dist/state/database.d.ts +245 -0
  208. package/dist/state/database.d.ts.map +1 -0
  209. package/dist/state/database.js +676 -0
  210. package/dist/state/database.js.map +1 -0
  211. package/dist/state/database.test.d.ts +2 -0
  212. package/dist/state/database.test.d.ts.map +1 -0
  213. package/dist/state/database.test.js +34 -0
  214. package/dist/state/database.test.js.map +1 -0
  215. package/dist/state/index.d.ts +2 -0
  216. package/dist/state/index.d.ts.map +1 -0
  217. package/dist/state/index.js +2 -0
  218. package/dist/state/index.js.map +1 -0
  219. package/dist/storage/fileSystem.d.ts +56 -0
  220. package/dist/storage/fileSystem.d.ts.map +1 -0
  221. package/dist/storage/fileSystem.js +121 -0
  222. package/dist/storage/fileSystem.js.map +1 -0
  223. package/dist/transcription/whisperService.d.ts +27 -0
  224. package/dist/transcription/whisperService.d.ts.map +1 -0
  225. package/dist/transcription/whisperService.js +102 -0
  226. package/dist/transcription/whisperService.js.map +1 -0
  227. package/eslint.config.js +55 -0
  228. package/package.json +68 -11
  229. package/src/__fixtures__/highlevel-post-response.json +68 -0
  230. package/src/__fixtures__/hls-master-playlist.m3u8 +24 -0
  231. package/src/cli/commands/__snapshots__/syncHighLevel.test.ts.snap +38 -0
  232. package/src/cli/commands/config.ts +74 -0
  233. package/src/cli/commands/inspect.ts +441 -0
  234. package/src/cli/commands/login.ts +68 -0
  235. package/src/cli/commands/status.ts +147 -0
  236. package/src/cli/commands/sync.ts +1235 -0
  237. package/src/cli/commands/syncHighLevel.test.ts +144 -0
  238. package/src/cli/commands/syncHighLevel.ts +639 -0
  239. package/src/cli/index.ts +121 -0
  240. package/src/config/configManager.ts +75 -0
  241. package/src/config/paths.test.ts +83 -0
  242. package/src/config/paths.ts +36 -0
  243. package/src/config/schema.test.ts +173 -0
  244. package/src/config/schema.ts +65 -0
  245. package/src/downloader/hlsDownloader.test.ts +148 -0
  246. package/src/downloader/hlsDownloader.ts +327 -0
  247. package/src/downloader/hlsValidator.ts +196 -0
  248. package/src/downloader/index.ts +122 -0
  249. package/src/downloader/loomDownloader.test.ts +43 -0
  250. package/src/downloader/loomDownloader.ts +742 -0
  251. package/src/downloader/queue.test.ts +199 -0
  252. package/src/downloader/queue.ts +118 -0
  253. package/src/downloader/vimeoDownloader.test.ts +62 -0
  254. package/src/downloader/vimeoDownloader.ts +722 -0
  255. package/src/scraper/extractor.test.ts +124 -0
  256. package/src/scraper/extractor.ts +757 -0
  257. package/src/scraper/highlevel/__snapshots__/extractor.test.ts.snap +41 -0
  258. package/src/scraper/highlevel/extractor.test.ts +134 -0
  259. package/src/scraper/highlevel/extractor.ts +537 -0
  260. package/src/scraper/highlevel/index.ts +2 -0
  261. package/src/scraper/highlevel/navigator.test.ts +110 -0
  262. package/src/scraper/highlevel/navigator.ts +668 -0
  263. package/src/scraper/highlevel/schemas.ts +183 -0
  264. package/src/scraper/navigator.test.ts +122 -0
  265. package/src/scraper/navigator.ts +355 -0
  266. package/src/scraper/schemas.ts +177 -0
  267. package/src/scraper/videoInterceptor.ts +435 -0
  268. package/src/shared/auth.test.ts +58 -0
  269. package/src/shared/auth.ts +251 -0
  270. package/src/shared/firebase.ts +151 -0
  271. package/src/shared/fs.ts +80 -0
  272. package/src/shared/http.ts +34 -0
  273. package/src/shared/index.ts +6 -0
  274. package/src/shared/slug.ts +26 -0
  275. package/src/shared/url.test.ts +122 -0
  276. package/src/shared/url.ts +57 -0
  277. package/src/state/database.test.ts +49 -0
  278. package/src/state/database.ts +919 -0
  279. package/src/state/index.ts +14 -0
  280. package/src/storage/fileSystem.test.ts +64 -0
  281. package/src/storage/fileSystem.ts +175 -0
  282. package/tsconfig.json +28 -0
  283. package/vitest.config.ts +29 -0
  284. package/cli.js +0 -45
@@ -0,0 +1,639 @@
1
+ import chalk from "chalk";
2
+ import cliProgress from "cli-progress";
3
+ import ora from "ora";
4
+ import { join } from "node:path";
5
+ import { loadConfig } from "../../config/configManager.js";
6
+ import { downloadVideo, type VideoDownloadTask } from "../../downloader/index.js";
7
+ import {
8
+ getAuthenticatedSession,
9
+ hasValidFirebaseToken,
10
+ isHighLevelLoginPage,
11
+ } from "../../shared/auth.js";
12
+ import {
13
+ buildHighLevelCourseStructure,
14
+ createFolderName,
15
+ extractHighLevelPostContent,
16
+ getHighLevelPostUrl,
17
+ type HighLevelCourseStructure,
18
+ type HighLevelScanProgress,
19
+ } from "../../scraper/highlevel/index.js";
20
+ import {
21
+ createCourseDirectory,
22
+ createModuleDirectory,
23
+ getVideoPath,
24
+ saveMarkdown,
25
+ isLessonSynced,
26
+ downloadFile,
27
+ } from "../../storage/fileSystem.js";
28
+ import { slugify as createSlug } from "../../scraper/highlevel/navigator.js";
29
+
30
+ /**
31
+ * Tracks if shutdown has been requested (Ctrl+C).
32
+ */
33
+ let isShuttingDown = false;
34
+
35
+ /**
36
+ * Resources to clean up on shutdown.
37
+ */
38
+ interface CleanupResources {
39
+ browser?: import("playwright").Browser;
40
+ }
41
+
42
+ const cleanupResources: CleanupResources = {};
43
+
44
+ /**
45
+ * Graceful shutdown handler.
46
+ */
47
+ function setupShutdownHandlers(): void {
48
+ const shutdown = async (signal: string) => {
49
+ if (isShuttingDown) {
50
+ console.log(chalk.red("\n\nāš ļø Force exit"));
51
+ process.exit(1);
52
+ }
53
+
54
+ isShuttingDown = true;
55
+ console.log(chalk.yellow(`\n\nā¹ļø ${signal} received, shutting down gracefully...`));
56
+
57
+ try {
58
+ if (cleanupResources.browser) {
59
+ await cleanupResources.browser.close();
60
+ }
61
+ console.log(chalk.gray(" Cleanup complete."));
62
+ } catch {
63
+ // Ignore cleanup errors
64
+ }
65
+
66
+ process.exit(0);
67
+ };
68
+
69
+ process.on("SIGINT", () => void shutdown("SIGINT"));
70
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
71
+ }
72
+
73
+ /**
74
+ * Check if we should continue processing or stop due to shutdown.
75
+ */
76
+ function shouldContinue(): boolean {
77
+ return !isShuttingDown;
78
+ }
79
+
80
+ export interface SyncHighLevelOptions {
81
+ skipVideos?: boolean;
82
+ skipContent?: boolean;
83
+ dryRun?: boolean;
84
+ limit?: number;
85
+ visible?: boolean;
86
+ quality?: string;
87
+ courseName?: string;
88
+ }
89
+
90
+ /**
91
+ * Extracts the domain from a HighLevel portal URL.
92
+ */
93
+ function extractDomain(url: string): string {
94
+ try {
95
+ return new URL(url).hostname;
96
+ } catch {
97
+ return url;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Detects if a URL is a HighLevel portal (HighLevel, ClientClub, etc.).
103
+ */
104
+ export function isHighLevelPortal(url: string): boolean {
105
+ // Known HighLevel portal patterns
106
+ const portalPatterns = [
107
+ /member\.[^/]+\.com/,
108
+ /portal\.[^/]+\.com/,
109
+ /courses\.[^/]+\.com/,
110
+ /clientclub\.net/,
111
+ /\.highlevel\.io/,
112
+ /\.leadconnectorhq\.com/,
113
+ ];
114
+
115
+ // Check URL patterns
116
+ if (portalPatterns.some((p) => p.test(url))) {
117
+ return true;
118
+ }
119
+
120
+ // Check if URL contains Memberships course path pattern
121
+ if (/\/courses\/(products|library|classroom)/i.test(url)) {
122
+ return true;
123
+ }
124
+
125
+ return false;
126
+ }
127
+
128
+ /**
129
+ * Handles the sync-memberships command.
130
+ * Downloads all content from a HighLevel portal (HighLevel, ClientClub, etc.).
131
+ */
132
+ export async function syncHighLevelCommand(
133
+ url: string,
134
+ options: SyncHighLevelOptions
135
+ ): Promise<void> {
136
+ setupShutdownHandlers();
137
+
138
+ console.log(chalk.blue("\nšŸ“š HighLevel Course Sync\n"));
139
+
140
+ const config = loadConfig();
141
+ const domain = extractDomain(url);
142
+
143
+ console.log(chalk.gray(` Portal: ${domain}`));
144
+
145
+ // Determine portal URL - use the course URL to trigger login if needed
146
+ const portalUrl = url;
147
+
148
+ // Get authenticated session
149
+ const useHeadless = options.visible ? false : config.headless;
150
+ const spinner = ora("Connecting to portal...").start();
151
+
152
+ let browser;
153
+ let session;
154
+
155
+ try {
156
+ const result = await getAuthenticatedSession(
157
+ {
158
+ domain,
159
+ loginUrl: portalUrl,
160
+ isLoginPage: isHighLevelLoginPage,
161
+ verifySession: hasValidFirebaseToken,
162
+ },
163
+ { headless: useHeadless }
164
+ );
165
+ browser = result.browser;
166
+ session = result.session;
167
+ cleanupResources.browser = browser;
168
+ spinner.succeed("Connected to portal");
169
+ } catch (error) {
170
+ spinner.fail("Failed to connect");
171
+ console.log(chalk.red("\nāŒ Authentication failed.\n"));
172
+ console.log(chalk.gray(` Tried to authenticate with: ${portalUrl}`));
173
+ if (error instanceof Error) {
174
+ console.log(chalk.gray(` Error: ${error.message}`));
175
+ }
176
+ process.exit(1);
177
+ }
178
+
179
+ try {
180
+ // Check if shutdown was requested
181
+ if (!shouldContinue()) {
182
+ return;
183
+ }
184
+
185
+ console.log(chalk.blue("\nšŸ“– Scanning course structure...\n"));
186
+
187
+ // Build course structure (handles navigation internally to capture API responses)
188
+ let courseStructure: HighLevelCourseStructure | null = null;
189
+ let progressBar: cliProgress.SingleBar | undefined;
190
+
191
+ try {
192
+ courseStructure = await buildHighLevelCourseStructure(
193
+ session.page,
194
+ url,
195
+ (progress: HighLevelScanProgress) => {
196
+ if (progress.phase === "course" && progress.courseName) {
197
+ console.log(chalk.white(` Course: ${progress.courseName}`));
198
+ } else if (progress.phase === "categories" && progress.totalCategories) {
199
+ progressBar = new cliProgress.SingleBar(
200
+ {
201
+ format: " {bar} {percentage}% | {value}/{total} | {status}",
202
+ barCompleteChar: "ā–ˆ",
203
+ barIncompleteChar: "ā–‘",
204
+ barsize: 30,
205
+ hideCursor: true,
206
+ },
207
+ cliProgress.Presets.shades_grey
208
+ );
209
+ progressBar.start(progress.totalCategories, 0, { status: "Scanning categories..." });
210
+ } else if (progress.phase === "posts") {
211
+ if (progress.skippedLocked) {
212
+ progressBar?.increment({ status: `šŸ”’ ${progress.currentCategory ?? "Locked"}` });
213
+ } else if (progress.postsFound !== undefined) {
214
+ progressBar?.increment({
215
+ status: `${progress.currentCategory ?? "Category"} (${progress.postsFound} lessons)`,
216
+ });
217
+ } else {
218
+ const categoryName = progress.currentCategory ?? "";
219
+ const shortName =
220
+ categoryName.length > 35 ? categoryName.substring(0, 32) + "..." : categoryName;
221
+ progressBar?.update(progress.currentCategoryIndex ?? 0, { status: shortName });
222
+ }
223
+ } else if (progress.phase === "done") {
224
+ progressBar?.stop();
225
+ }
226
+ }
227
+ );
228
+ } catch (error) {
229
+ progressBar?.stop();
230
+ console.log(chalk.red(" Failed to scan course structure"));
231
+ if (error instanceof Error) {
232
+ console.log(chalk.gray(` Error: ${error.message}`));
233
+ }
234
+ throw error;
235
+ }
236
+
237
+ if (!courseStructure) {
238
+ console.log(chalk.red("\nāŒ Could not extract course structure"));
239
+ console.log(chalk.gray(" This might mean:"));
240
+ console.log(chalk.gray(" - The portal is not a supported HighLevel portal"));
241
+ console.log(chalk.gray(" - You don't have access to this course"));
242
+ console.log(chalk.gray(" - The portal structure has changed"));
243
+ await browser.close();
244
+ process.exit(1);
245
+ }
246
+
247
+ // Override course name if provided
248
+ if (options.courseName) {
249
+ courseStructure.course.title = options.courseName;
250
+ }
251
+
252
+ // Print summary
253
+ const totalLessons = courseStructure.categories.reduce((sum, cat) => sum + cat.posts.length, 0);
254
+ const lockedCategories = courseStructure.categories.filter((c) => c.isLocked).length;
255
+
256
+ console.log();
257
+ const parts: string[] = [];
258
+ parts.push(`${courseStructure.categories.length} modules`);
259
+ parts.push(`${totalLessons} lessons`);
260
+ if (lockedCategories > 0) parts.push(chalk.yellow(`${lockedCategories} locked`));
261
+ console.log(` Found: ${parts.join(", ")}`);
262
+
263
+ if (options.dryRun) {
264
+ printCourseStructure(courseStructure);
265
+ await browser.close();
266
+ return;
267
+ }
268
+
269
+ // Create course directory
270
+ const courseSlug = createSlug(courseStructure.course.title);
271
+ const courseDir = await createCourseDirectory(config.outputDir, courseSlug);
272
+ console.log(chalk.gray(`\nšŸ“ Output: ${courseDir}\n`));
273
+
274
+ // Process lessons
275
+ const videoTasks: VideoDownloadTask[] = [];
276
+ let contentExtracted = 0;
277
+ let skipped = 0;
278
+ let processed = 0;
279
+
280
+ // Apply limit
281
+ const lessonLimit = options.limit;
282
+ let totalToProcess = totalLessons;
283
+ if (lessonLimit) {
284
+ totalToProcess = Math.min(totalLessons, lessonLimit);
285
+ console.log(chalk.yellow(` Limiting to ${totalToProcess} lessons\n`));
286
+ }
287
+
288
+ // Phase 2: Extract content and queue downloads
289
+ console.log(chalk.blue(`\nšŸ“ Extracting content for ${totalToProcess} lessons...\n`));
290
+
291
+ const contentProgressBar = new cliProgress.SingleBar(
292
+ {
293
+ format: " {bar} {percentage}% | {value}/{total} | {status}",
294
+ barCompleteChar: "ā–ˆ",
295
+ barIncompleteChar: "ā–‘",
296
+ barsize: 30,
297
+ hideCursor: true,
298
+ },
299
+ cliProgress.Presets.shades_grey
300
+ );
301
+
302
+ contentProgressBar.start(totalToProcess, 0, { status: "Starting..." });
303
+
304
+ for (const [catIndex, category] of courseStructure.categories.entries()) {
305
+ if (!shouldContinue()) break;
306
+ if (lessonLimit && processed >= lessonLimit) break;
307
+
308
+ if (category.isLocked) {
309
+ continue;
310
+ }
311
+
312
+ const moduleDir = await createModuleDirectory(courseDir, catIndex, category.title);
313
+
314
+ for (const [postIndex, post] of category.posts.entries()) {
315
+ if (!shouldContinue()) break;
316
+ if (lessonLimit && processed >= lessonLimit) break;
317
+
318
+ const shortName = post.title.length > 40 ? post.title.substring(0, 37) + "..." : post.title;
319
+ contentProgressBar.update(processed, { status: shortName });
320
+
321
+ // Check if already synced
322
+ const syncStatus = await isLessonSynced(moduleDir, postIndex, post.title);
323
+
324
+ if (!options.skipContent && !syncStatus.content) {
325
+ try {
326
+ // Get full post URL
327
+ const postUrl = getHighLevelPostUrl(
328
+ courseStructure.domain,
329
+ courseStructure.course.id,
330
+ category.id,
331
+ post.id
332
+ );
333
+
334
+ // Extract content
335
+ const content = await extractHighLevelPostContent(
336
+ session.page,
337
+ postUrl,
338
+ courseStructure.locationId,
339
+ courseStructure.course.id,
340
+ post.id,
341
+ category.id
342
+ );
343
+
344
+ if (content) {
345
+ // Save markdown
346
+ const markdown = formatHighLevelMarkdown(
347
+ content.title,
348
+ content.description,
349
+ content.htmlContent,
350
+ content.video?.url
351
+ );
352
+
353
+ await saveMarkdown(
354
+ moduleDir,
355
+ createFolderName(postIndex, post.title) + ".md",
356
+ markdown
357
+ );
358
+
359
+ // Download attachments
360
+ for (const attachment of content.attachments) {
361
+ if (attachment.url) {
362
+ const attachmentPath = join(
363
+ moduleDir,
364
+ `${createFolderName(postIndex, post.title)}-${attachment.name}`
365
+ );
366
+ await downloadFile(attachment.url, attachmentPath);
367
+ }
368
+ }
369
+
370
+ // Queue video download
371
+ if (!options.skipVideos && !syncStatus.video && content.video?.url) {
372
+ videoTasks.push({
373
+ lessonId: post.id as unknown as number, // Using string ID
374
+ lessonName: post.title,
375
+ videoUrl: content.video.url,
376
+ videoType:
377
+ content.video.type === "hls"
378
+ ? "highlevel"
379
+ : (content.video.type as VideoDownloadTask["videoType"]),
380
+ outputPath: getVideoPath(moduleDir, postIndex, post.title),
381
+ preferredQuality: options.quality,
382
+ });
383
+ }
384
+
385
+ contentExtracted++;
386
+ }
387
+ } catch (error) {
388
+ console.error(`\nError extracting ${post.title}:`, error);
389
+ }
390
+ } else {
391
+ skipped++;
392
+
393
+ // Still queue video if content was skipped but video not downloaded
394
+ if (!options.skipVideos && !syncStatus.video) {
395
+ // We need to get the video URL
396
+ try {
397
+ const postUrl = getHighLevelPostUrl(
398
+ courseStructure.domain,
399
+ courseStructure.course.id,
400
+ category.id,
401
+ post.id
402
+ );
403
+
404
+ const content = await extractHighLevelPostContent(
405
+ session.page,
406
+ postUrl,
407
+ courseStructure.locationId,
408
+ courseStructure.course.id,
409
+ post.id,
410
+ category.id
411
+ );
412
+
413
+ if (content?.video?.url) {
414
+ videoTasks.push({
415
+ lessonId: post.id as unknown as number,
416
+ lessonName: post.title,
417
+ videoUrl: content.video.url,
418
+ videoType:
419
+ content.video.type === "hls"
420
+ ? "highlevel"
421
+ : (content.video.type as VideoDownloadTask["videoType"]),
422
+ outputPath: getVideoPath(moduleDir, postIndex, post.title),
423
+ preferredQuality: options.quality,
424
+ });
425
+ }
426
+ } catch {
427
+ // Skip if we can't get video URL
428
+ }
429
+ }
430
+ }
431
+
432
+ processed++;
433
+ contentProgressBar.update(processed, { status: shortName });
434
+ }
435
+ }
436
+
437
+ contentProgressBar.stop();
438
+
439
+ // Print content summary
440
+ console.log();
441
+ const contentParts: string[] = [];
442
+ if (contentExtracted > 0) contentParts.push(chalk.green(`${contentExtracted} extracted`));
443
+ if (skipped > 0) contentParts.push(chalk.gray(`${skipped} cached`));
444
+ console.log(` Content: ${contentParts.join(", ")}`);
445
+
446
+ // Phase 3: Download videos
447
+ if (!options.skipVideos && videoTasks.length > 0) {
448
+ await downloadVideos(videoTasks, config);
449
+ }
450
+
451
+ console.log(chalk.green("\nāœ… Sync complete!\n"));
452
+ console.log(chalk.gray(` Output: ${courseDir}\n`));
453
+ } finally {
454
+ await browser.close();
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Downloads videos with progress display.
460
+ */
461
+ async function downloadVideos(
462
+ videoTasks: VideoDownloadTask[],
463
+ config: { concurrency: number }
464
+ ): Promise<void> {
465
+ const total = videoTasks.length;
466
+ console.log(chalk.blue(`\nšŸŽ¬ Downloading ${total} videos...\n`));
467
+
468
+ const multibar = new cliProgress.MultiBar(
469
+ {
470
+ clearOnComplete: true,
471
+ hideCursor: true,
472
+ format: " {typeTag} {bar} {percentage}% | {lessonName}",
473
+ barCompleteChar: "ā–ˆ",
474
+ barIncompleteChar: "ā–‘",
475
+ barsize: 25,
476
+ autopadding: true,
477
+ },
478
+ cliProgress.Presets.shades_grey
479
+ );
480
+
481
+ const overallBar = multibar.create(total, 0, {
482
+ typeTag: "[TOTAL]".padEnd(8),
483
+ lessonName: `0/${total} completed`,
484
+ });
485
+
486
+ let completed = 0;
487
+ let failed = 0;
488
+ const errors: { name: string; error: string }[] = [];
489
+
490
+ const activeBars = new Map<string, cliProgress.SingleBar>();
491
+ const taskQueue = [...videoTasks];
492
+ const activePromises = new Set<Promise<void>>();
493
+
494
+ const processTask = async (task: VideoDownloadTask): Promise<void> => {
495
+ const typeTag = task.videoType ? `[${task.videoType.toUpperCase()}]` : "[VIDEO]";
496
+ const shortName =
497
+ task.lessonName.length > 40 ? task.lessonName.substring(0, 37) + "..." : task.lessonName;
498
+
499
+ const bar = multibar.create(100, 0, {
500
+ typeTag: typeTag.padEnd(8),
501
+ lessonName: shortName,
502
+ });
503
+ activeBars.set(task.lessonName, bar);
504
+
505
+ try {
506
+ const result = await downloadVideo(task, (progress) => {
507
+ bar.update(Math.round(progress.percent));
508
+ });
509
+
510
+ if (!result.success) {
511
+ errors.push({ name: task.lessonName, error: result.error ?? "Download failed" });
512
+ failed++;
513
+ } else {
514
+ completed++;
515
+ }
516
+ } catch (error) {
517
+ const errorMsg = error instanceof Error ? error.message : String(error);
518
+ errors.push({ name: task.lessonName, error: errorMsg });
519
+ failed++;
520
+ } finally {
521
+ multibar.remove(bar);
522
+ activeBars.delete(task.lessonName);
523
+
524
+ const done = completed + failed;
525
+ overallBar.update(done, {
526
+ lessonName: `${done}/${total} completed (${failed} failed)`,
527
+ });
528
+ }
529
+ };
530
+
531
+ while (taskQueue.length > 0 || activePromises.size > 0) {
532
+ while (taskQueue.length > 0 && activePromises.size < config.concurrency) {
533
+ const task = taskQueue.shift();
534
+ if (task) {
535
+ const promise = processTask(task).finally(() => {
536
+ activePromises.delete(promise);
537
+ });
538
+ activePromises.add(promise);
539
+ }
540
+ }
541
+
542
+ if (activePromises.size > 0) {
543
+ await Promise.race(activePromises);
544
+ }
545
+ }
546
+
547
+ multibar.stop();
548
+
549
+ // Print summary
550
+ console.log();
551
+ if (failed === 0) {
552
+ console.log(chalk.green(` āœ“ ${completed} videos downloaded successfully`));
553
+ } else {
554
+ console.log(chalk.yellow(` Videos: ${completed} downloaded, ${failed} failed`));
555
+ }
556
+
557
+ if (errors.length > 0) {
558
+ console.log(chalk.yellow("\n Failed downloads:"));
559
+ for (const error of errors) {
560
+ console.log(chalk.red(` - ${error.name}: ${error.error}`));
561
+ }
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Format markdown content for HighLevel posts.
567
+ */
568
+ export function formatHighLevelMarkdown(
569
+ title: string,
570
+ description: string | null,
571
+ htmlContent: string | null,
572
+ videoUrl?: string
573
+ ): string {
574
+ const lines: string[] = [];
575
+
576
+ lines.push(`# ${title}`);
577
+ lines.push("");
578
+
579
+ if (description) {
580
+ lines.push(description);
581
+ lines.push("");
582
+ }
583
+
584
+ if (videoUrl) {
585
+ lines.push("## Video");
586
+ lines.push("");
587
+ lines.push(`Video URL: ${videoUrl}`);
588
+ lines.push("");
589
+ }
590
+
591
+ if (htmlContent) {
592
+ lines.push("---");
593
+ lines.push("");
594
+ // Simple HTML to text conversion
595
+ const text = htmlContent
596
+ .replace(/<br\s*\/?>/gi, "\n")
597
+ .replace(/<\/p>/gi, "\n\n")
598
+ .replace(/<\/div>/gi, "\n")
599
+ .replace(/<\/li>/gi, "\n")
600
+ .replace(/<li>/gi, "- ")
601
+ .replace(/<[^>]+>/g, "")
602
+ .replace(/&nbsp;/g, " ")
603
+ .replace(/&amp;/g, "&")
604
+ .replace(/&lt;/g, "<")
605
+ .replace(/&gt;/g, ">")
606
+ .replace(/&quot;/g, '"')
607
+ .trim();
608
+
609
+ lines.push(text);
610
+ lines.push("");
611
+ }
612
+
613
+ return lines.join("\n");
614
+ }
615
+
616
+ /**
617
+ * Print course structure (for dry-run mode).
618
+ */
619
+ function printCourseStructure(structure: HighLevelCourseStructure): void {
620
+ console.log(chalk.cyan("\nšŸ“‹ Course Structure\n"));
621
+ console.log(chalk.white(` ${structure.course.title}`));
622
+ console.log(chalk.gray(` Location: ${structure.locationId}`));
623
+ console.log(chalk.gray(` Domain: ${structure.domain}`));
624
+ console.log();
625
+
626
+ for (const [i, category] of structure.categories.entries()) {
627
+ const lockedTag = category.isLocked ? chalk.yellow(" [LOCKED]") : "";
628
+ console.log(chalk.white(` ${String(i + 1).padStart(2)}. ${category.title}${lockedTag}`));
629
+
630
+ for (const [j, post] of category.posts.slice(0, 5).entries()) {
631
+ console.log(chalk.gray(` ${String(j + 1).padStart(2)}. ${post.title}`));
632
+ }
633
+
634
+ if (category.posts.length > 5) {
635
+ console.log(chalk.gray(` ... and ${category.posts.length - 5} more`));
636
+ }
637
+ console.log();
638
+ }
639
+ }