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,668 @@
1
+ import type { Page } from "playwright";
2
+ import {
3
+ FirebaseAuthTokenSchema,
4
+ PortalSettingsResponseSchema,
5
+ ProductResponseSchema,
6
+ CategoriesResponseSchema,
7
+ PostsResponseSchema,
8
+ safeParse,
9
+ type FirebaseAuthRaw,
10
+ } from "./schemas.js";
11
+
12
+ export interface HighLevelCourse {
13
+ id: string;
14
+ title: string;
15
+ description: string;
16
+ slug: string;
17
+ thumbnailUrl: string | null;
18
+ instructor: string | null;
19
+ totalLessons: number;
20
+ progress: number;
21
+ }
22
+
23
+ export interface HighLevelCategory {
24
+ id: string;
25
+ title: string;
26
+ description: string | null;
27
+ position: number;
28
+ postCount: number;
29
+ isLocked: boolean;
30
+ }
31
+
32
+ export interface HighLevelPost {
33
+ id: string;
34
+ title: string;
35
+ position: number;
36
+ categoryId: string;
37
+ isLocked: boolean;
38
+ isCompleted: boolean;
39
+ }
40
+
41
+ export interface HighLevelCourseStructure {
42
+ course: HighLevelCourse;
43
+ categories: (HighLevelCategory & { posts: HighLevelPost[] })[];
44
+ locationId: string;
45
+ domain: string;
46
+ }
47
+
48
+ export interface HighLevelScanProgress {
49
+ phase: "init" | "course" | "categories" | "posts" | "done";
50
+ courseName?: string;
51
+ totalCategories?: number;
52
+ currentCategory?: string;
53
+ currentCategoryIndex?: number;
54
+ postsFound?: number;
55
+ skippedLocked?: boolean;
56
+ }
57
+
58
+ // Browser/API automation - requires Playwright
59
+ /* v8 ignore start */
60
+
61
+ /**
62
+ * Extracts the location ID from the HighLevel portal.
63
+ * The location ID is used in all API calls.
64
+ */
65
+ export async function extractLocationId(page: Page): Promise<string | null> {
66
+ // Wait for API calls that contain the location ID
67
+ const locationId = await page.evaluate(() => {
68
+ // Try to find it in the URL of any API call
69
+ const scripts = Array.from(document.querySelectorAll("script"));
70
+ for (const script of scripts) {
71
+ const content = script.textContent ?? "";
72
+ // Look for location ID pattern in HighLevel (typically in API URLs)
73
+ const match = /locations\/([A-Za-z0-9]+)/.exec(content);
74
+ if (match?.[1] && match[1].length > 10) {
75
+ return match[1];
76
+ }
77
+ }
78
+
79
+ // Try to find it in localStorage or sessionStorage
80
+ for (const storage of [localStorage, sessionStorage]) {
81
+ for (let i = 0; i < storage.length; i++) {
82
+ const key = storage.key(i);
83
+ if (key) {
84
+ const value = storage.getItem(key);
85
+ if (value) {
86
+ const match = /"locationId":\s*"([A-Za-z0-9]+)"/.exec(value);
87
+ if (match?.[1]) return match[1];
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ return null;
94
+ });
95
+
96
+ return locationId;
97
+ }
98
+
99
+ /**
100
+ * Extracts portal settings including location ID from the API.
101
+ */
102
+ export async function extractPortalSettings(
103
+ page: Page,
104
+ domain: string
105
+ ): Promise<{ locationId: string; portalName: string } | null> {
106
+ try {
107
+ // Use page.request to make the API call
108
+ const response = await page.request.get(
109
+ `https://services.leadconnectorhq.com/clientclub/portal-settings?domain=${domain}`
110
+ );
111
+
112
+ if (!response.ok()) return null;
113
+
114
+ const data: unknown = await response.json();
115
+
116
+ // Validate response with Zod schema
117
+ const parsed = safeParse(PortalSettingsResponseSchema, data, "extractPortalSettings");
118
+ if (!parsed) return null;
119
+
120
+ return {
121
+ locationId: parsed.locationId,
122
+ portalName: parsed.portalName ?? parsed.name ?? "HighLevel Course",
123
+ };
124
+ } catch {
125
+ // Fall through
126
+ }
127
+
128
+ return null;
129
+ }
130
+
131
+ /**
132
+ * Extracts course list from the courses library page.
133
+ */
134
+ export async function extractCourses(page: Page): Promise<HighLevelCourse[]> {
135
+ // Wait for the course cards to load
136
+ await page.waitForTimeout(2000);
137
+
138
+ const courses = await page.evaluate(() => {
139
+ const results: HighLevelCourse[] = [];
140
+
141
+ // Find course cards - HighLevel uses various patterns
142
+ const courseCards = document.querySelectorAll(
143
+ '[class*="course-card"], [class*="CourseCard"], [data-product-id], [class*="product-card"]'
144
+ );
145
+
146
+ // If no specific cards found, try to find links to course pages
147
+ if (courseCards.length === 0) {
148
+ const courseLinks = document.querySelectorAll('a[href*="/courses/products/"]');
149
+ const seen = new Set<string>();
150
+
151
+ courseLinks.forEach((link) => {
152
+ const href = (link as HTMLAnchorElement).href;
153
+ const match = /\/courses\/products\/([a-f0-9-]+)/.exec(href);
154
+ if (match?.[1] && !seen.has(match[1])) {
155
+ seen.add(match[1]);
156
+ const title =
157
+ link.querySelector("h3, h4, [class*='title']")?.textContent?.trim() ??
158
+ link.textContent?.trim() ??
159
+ `Course ${results.length + 1}`;
160
+
161
+ results.push({
162
+ id: match[1],
163
+ title,
164
+ description: "",
165
+ slug: match[1],
166
+ thumbnailUrl: link.querySelector("img")?.src ?? null,
167
+ instructor: null,
168
+ totalLessons: 0,
169
+ progress: 0,
170
+ });
171
+ }
172
+ });
173
+ }
174
+
175
+ return results;
176
+ });
177
+
178
+ return courses;
179
+ }
180
+
181
+ /**
182
+ * Extracts course details from the course overview page via API.
183
+ */
184
+ export async function extractCourseDetails(
185
+ page: Page,
186
+ courseUrl: string,
187
+ locationId?: string
188
+ ): Promise<HighLevelCourse | null> {
189
+ // Extract product ID from provided courseUrl first
190
+ let productId: string | undefined;
191
+
192
+ const courseUrlMatch = /\/courses\/products\/([a-f0-9-]+)/.exec(courseUrl);
193
+ if (courseUrlMatch?.[1]) {
194
+ productId = courseUrlMatch[1];
195
+ }
196
+
197
+ // Fallback: try from current page URL
198
+ if (!productId) {
199
+ const pageUrlMatch = /\/courses\/products\/([a-f0-9-]+)/.exec(page.url());
200
+ productId = pageUrlMatch?.[1];
201
+ }
202
+
203
+ if (!productId) {
204
+ console.error("Could not extract product ID from URL:", courseUrl, "page:", page.url());
205
+ return null;
206
+ }
207
+
208
+ // Try direct API call first (most reliable)
209
+ if (locationId) {
210
+ try {
211
+ const apiUrl = `https://services.leadconnectorhq.com/membership/locations/${locationId}/products/${productId}`;
212
+
213
+ // Get auth token from the page context
214
+ const rawTokenData = await page.evaluate((): FirebaseAuthRaw | null => {
215
+ const tokenKey = Object.keys(localStorage).find((k) => k.includes("firebase:authUser"));
216
+ if (!tokenKey) return null;
217
+ try {
218
+ return JSON.parse(localStorage.getItem(tokenKey) ?? "{}") as FirebaseAuthRaw;
219
+ } catch {
220
+ return null;
221
+ }
222
+ });
223
+
224
+ const tokenParsed = rawTokenData
225
+ ? safeParse(FirebaseAuthTokenSchema, rawTokenData, "extractCourseDetails.token")
226
+ : null;
227
+ const authToken = tokenParsed?.stsTokenManager.accessToken;
228
+
229
+ if (authToken) {
230
+ // Use page.request to make the API call (bypasses CORS)
231
+ const response = await page.request.get(apiUrl, {
232
+ headers: {
233
+ Authorization: `Bearer ${authToken}`,
234
+ },
235
+ });
236
+
237
+ if (response.ok()) {
238
+ const data: unknown = await response.json();
239
+
240
+ // Validate response with Zod schema
241
+ const parsed = safeParse(ProductResponseSchema, data, "extractCourseDetails");
242
+ if (parsed) {
243
+ // The API returns the product directly, not wrapped in a "product" property
244
+ const product = parsed.product ?? parsed;
245
+ const title = product.title;
246
+ if (title && title !== "Unknown Course") {
247
+ return {
248
+ id: product.id ?? productId,
249
+ title,
250
+ description: product.description ?? "",
251
+ slug: product.id ?? productId,
252
+ thumbnailUrl: product.posterImage ?? null,
253
+ instructor: product.instructor ?? null,
254
+ totalLessons: product.postCount ?? 0,
255
+ progress: 0,
256
+ };
257
+ }
258
+ }
259
+ }
260
+ }
261
+ } catch {
262
+ // Continue to DOM fallback silently
263
+ }
264
+ }
265
+
266
+ // Fallback to DOM extraction if API fails
267
+ const domCourse = await page.evaluate(() => {
268
+ const urlMatch = /\/courses\/products\/([a-f0-9-]+)/.exec(window.location.href);
269
+ const id = urlMatch?.[1] ?? "";
270
+
271
+ // Look for the course title in various places
272
+ let title = "";
273
+
274
+ // Method 1: Look for a large heading that's not navigation
275
+ const headings = Array.from(document.querySelectorAll("h1, h2, h3"));
276
+ for (const h of headings) {
277
+ const text = h.textContent?.trim() ?? "";
278
+ const parent = h.closest("nav, header, [class*='nav'], [class*='Nav']");
279
+ // Skip if in navigation, or if it's a generic title
280
+ if (parent) continue;
281
+ if (text.length < 4) continue;
282
+ if (text.toLowerCase().includes("menu")) continue;
283
+ if (text.toLowerCase().includes("login")) continue;
284
+ if (text === "HighLevel") continue;
285
+ if (text === "Courses") continue;
286
+
287
+ // Found a good candidate
288
+ title = text;
289
+ break;
290
+ }
291
+
292
+ // Method 2: Look for text with "lesson" count indicator nearby
293
+ if (!title) {
294
+ const lessonIndicators = Array.from(
295
+ document.querySelectorAll("[class*='lesson'], [class*='Lesson']")
296
+ );
297
+ for (const indicator of lessonIndicators) {
298
+ const parent = indicator.closest(
299
+ "[class*='card'], [class*='Card'], [class*='product'], [class*='Product']"
300
+ );
301
+ if (parent) {
302
+ const heading = parent.querySelector("h1, h2, h3, h4");
303
+ if (heading?.textContent?.trim()) {
304
+ title = heading.textContent.trim();
305
+ break;
306
+ }
307
+ }
308
+ }
309
+ }
310
+
311
+ if (!title || title.length < 3) {
312
+ title = "Unknown Course";
313
+ }
314
+
315
+ return {
316
+ id,
317
+ title,
318
+ description: "",
319
+ slug: id,
320
+ thumbnailUrl: null,
321
+ instructor: null,
322
+ totalLessons: 0,
323
+ progress: 0,
324
+ };
325
+ });
326
+
327
+ return domCourse.id ? domCourse : null;
328
+ }
329
+
330
+ /**
331
+ * Extracts categories (modules) from a course page.
332
+ */
333
+ export async function extractCategories(
334
+ page: Page,
335
+ productId: string,
336
+ locationId: string
337
+ ): Promise<HighLevelCategory[]> {
338
+ try {
339
+ // Get auth token from the page context
340
+ const rawTokenData = await page.evaluate((): FirebaseAuthRaw | null => {
341
+ const tokenKey = Object.keys(localStorage).find((k) => k.includes("firebase:authUser"));
342
+ if (!tokenKey) return null;
343
+ try {
344
+ return JSON.parse(localStorage.getItem(tokenKey) ?? "{}") as FirebaseAuthRaw;
345
+ } catch {
346
+ return null;
347
+ }
348
+ });
349
+
350
+ const tokenParsed = rawTokenData
351
+ ? safeParse(FirebaseAuthTokenSchema, rawTokenData, "extractCategories.token")
352
+ : null;
353
+ const authToken = tokenParsed?.stsTokenManager.accessToken;
354
+
355
+ if (!authToken) {
356
+ console.warn("No auth token found");
357
+ return [];
358
+ }
359
+
360
+ // Use page.request to make the API call
361
+ const response = await page.request.get(
362
+ `https://services.leadconnectorhq.com/membership/locations/${locationId}/user-purchase/categories?product_id=${productId}&source=courses`,
363
+ {
364
+ headers: {
365
+ Authorization: `Bearer ${authToken}`,
366
+ },
367
+ }
368
+ );
369
+
370
+ if (!response.ok()) {
371
+ console.warn("Categories API returned", response.status());
372
+ return [];
373
+ }
374
+
375
+ const data: unknown = await response.json();
376
+
377
+ // Validate response with Zod schema
378
+ const parsed = safeParse(CategoriesResponseSchema, data, "extractCategories");
379
+ if (!parsed) return [];
380
+
381
+ return parsed.categories.map((cat) => ({
382
+ id: cat.id,
383
+ title: cat.title,
384
+ description: cat.description ?? null,
385
+ position: cat.position ?? 0,
386
+ postCount: cat.postCount ?? 0,
387
+ isLocked: cat.visibility === "locked",
388
+ }));
389
+ } catch (error) {
390
+ console.error("Failed to fetch categories:", error);
391
+ return [];
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Extracts posts (lessons) from a category.
397
+ */
398
+ export async function extractPosts(
399
+ page: Page,
400
+ productId: string,
401
+ categoryId: string,
402
+ locationId: string
403
+ ): Promise<HighLevelPost[]> {
404
+ try {
405
+ // Get auth token from the page context
406
+ const rawTokenData = await page.evaluate((): FirebaseAuthRaw | null => {
407
+ const tokenKey = Object.keys(localStorage).find((k) => k.includes("firebase:authUser"));
408
+ if (!tokenKey) return null;
409
+ try {
410
+ return JSON.parse(localStorage.getItem(tokenKey) ?? "{}") as FirebaseAuthRaw;
411
+ } catch {
412
+ return null;
413
+ }
414
+ });
415
+
416
+ const tokenParsed = rawTokenData
417
+ ? safeParse(FirebaseAuthTokenSchema, rawTokenData, "extractPosts.token")
418
+ : null;
419
+ const authToken = tokenParsed?.stsTokenManager.accessToken;
420
+
421
+ if (!authToken) {
422
+ return [];
423
+ }
424
+
425
+ // Use page.request to make the API call
426
+ const response = await page.request.get(
427
+ `https://services.leadconnectorhq.com/membership/locations/${locationId}/user-purchase/categories/${categoryId}?product_id=${productId}&visibility=published&published_posts=true&source=courses`,
428
+ {
429
+ headers: {
430
+ Authorization: `Bearer ${authToken}`,
431
+ },
432
+ }
433
+ );
434
+
435
+ if (!response.ok()) {
436
+ return [];
437
+ }
438
+
439
+ const data: unknown = await response.json();
440
+
441
+ // Validate response with Zod schema
442
+ const parsed = safeParse(PostsResponseSchema, data, "extractPosts");
443
+ if (!parsed?.category?.posts) return [];
444
+
445
+ return parsed.category.posts.map((post, index) => ({
446
+ id: post.id,
447
+ title: post.title,
448
+ position: post.indexPosition ?? index,
449
+ categoryId,
450
+ isLocked: post.visibility === "locked",
451
+ isCompleted: false,
452
+ }));
453
+ } catch (error) {
454
+ console.error("Failed to fetch posts:", error);
455
+ return [];
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Builds the complete course structure.
461
+ */
462
+ export async function buildHighLevelCourseStructure(
463
+ page: Page,
464
+ courseUrl: string,
465
+ onProgress?: (progress: HighLevelScanProgress) => void
466
+ ): Promise<HighLevelCourseStructure | null> {
467
+ // Extract domain and product ID from URL
468
+ const urlObj = new URL(courseUrl);
469
+ const domain = urlObj.hostname;
470
+ const productMatch = /\/courses\/products\/([a-f0-9-]+)/.exec(courseUrl);
471
+ const productId = productMatch?.[1];
472
+
473
+ // Get portal settings (includes location ID)
474
+ onProgress?.({ phase: "init" });
475
+
476
+ let locationId: string | null = null;
477
+
478
+ // Try to get location ID from portal settings API
479
+ const settings = await extractPortalSettings(page, domain);
480
+ if (settings) {
481
+ locationId = settings.locationId;
482
+ }
483
+
484
+ // Fallback: try to extract from page
485
+ locationId ??= await extractLocationId(page);
486
+
487
+ if (!locationId) {
488
+ console.error("Could not determine location ID");
489
+ return null;
490
+ }
491
+
492
+ // Set up response interception to capture product data BEFORE navigation
493
+ let capturedCourseTitle: string | null = null;
494
+
495
+ const responseHandler = async (response: import("playwright").Response) => {
496
+ const url = response.url();
497
+ if (
498
+ productId &&
499
+ url.includes(`/products/${productId}`) &&
500
+ url.includes("leadconnectorhq.com")
501
+ ) {
502
+ try {
503
+ const data: unknown = await response.json();
504
+ const parsed = safeParse(ProductResponseSchema, data, "responseHandler");
505
+ const title = parsed?.product?.title ?? parsed?.title;
506
+ if (title) {
507
+ capturedCourseTitle = title;
508
+ }
509
+ } catch {
510
+ // Ignore JSON parse errors
511
+ }
512
+ }
513
+ };
514
+
515
+ page.on("response", responseHandler);
516
+
517
+ // Navigate to course page (force reload to ensure we capture API responses)
518
+ // Using waitUntil: "networkidle" to ensure all API calls complete
519
+ await page.goto(courseUrl, {
520
+ timeout: 30000,
521
+ waitUntil: "networkidle",
522
+ });
523
+ await page.waitForTimeout(1000);
524
+
525
+ // Remove the handler
526
+ page.off("response", responseHandler);
527
+
528
+ // Extract course details
529
+ onProgress?.({ phase: "course" });
530
+ const course = await extractCourseDetails(page, courseUrl, locationId);
531
+
532
+ if (!course) {
533
+ console.error("Could not extract course details");
534
+ return null;
535
+ }
536
+
537
+ // Use captured title if available and course title is unknown
538
+ if (capturedCourseTitle && (course.title === "Unknown Course" || !course.title)) {
539
+ course.title = capturedCourseTitle;
540
+ }
541
+
542
+ // Fallback: Try to get title from DOM after page is fully loaded
543
+ if (course.title === "Unknown Course" || !course.title) {
544
+ const domTitle = await page.evaluate(() => {
545
+ // Look for product title in common HighLevel selectors
546
+ const selectors = [
547
+ "[class*='product-title']",
548
+ "[class*='ProductTitle']",
549
+ "[class*='course-title']",
550
+ "[class*='CourseTitle']",
551
+ "h1.title",
552
+ "h2.title",
553
+ "[data-testid='product-title']",
554
+ ".product-header h1",
555
+ ".product-header h2",
556
+ ];
557
+
558
+ for (const selector of selectors) {
559
+ const el = document.querySelector(selector);
560
+ const text = el?.textContent?.trim();
561
+ if (text && text.length > 2 && text.length < 200) {
562
+ return text;
563
+ }
564
+ }
565
+
566
+ // Try to find a heading that's not generic
567
+ const headings = Array.from(document.querySelectorAll("h1, h2, h3"));
568
+ for (const h of headings) {
569
+ const text = h.textContent?.trim() ?? "";
570
+ if (
571
+ text.length > 3 &&
572
+ text.length < 150 &&
573
+ !text.toLowerCase().includes("menu") &&
574
+ !text.toLowerCase().includes("login") &&
575
+ text !== "Memberships" &&
576
+ text !== "Courses" &&
577
+ text !== "Unknown Course"
578
+ ) {
579
+ return text;
580
+ }
581
+ }
582
+
583
+ return null;
584
+ });
585
+
586
+ if (domTitle) {
587
+ course.title = domTitle;
588
+ }
589
+ }
590
+
591
+ onProgress?.({ phase: "course", courseName: course.title });
592
+
593
+ // Extract categories
594
+ onProgress?.({ phase: "categories" });
595
+ const categories = await extractCategories(page, course.id, locationId);
596
+
597
+ onProgress?.({ phase: "categories", totalCategories: categories.length });
598
+
599
+ // Extract posts for each category
600
+ const categoriesWithPosts: HighLevelCourseStructure["categories"] = [];
601
+
602
+ for (const [i, category] of categories.entries()) {
603
+ if (category.isLocked) {
604
+ onProgress?.({
605
+ phase: "posts",
606
+ currentCategory: category.title,
607
+ currentCategoryIndex: i,
608
+ skippedLocked: true,
609
+ });
610
+ continue;
611
+ }
612
+
613
+ onProgress?.({
614
+ phase: "posts",
615
+ currentCategory: category.title,
616
+ currentCategoryIndex: i,
617
+ });
618
+
619
+ const posts = await extractPosts(page, course.id, category.id, locationId);
620
+
621
+ onProgress?.({
622
+ phase: "posts",
623
+ currentCategory: category.title,
624
+ currentCategoryIndex: i,
625
+ postsFound: posts.length,
626
+ });
627
+
628
+ categoriesWithPosts.push({
629
+ ...category,
630
+ posts,
631
+ });
632
+ }
633
+
634
+ onProgress?.({ phase: "done" });
635
+
636
+ // Update total lessons count
637
+ course.totalLessons = categoriesWithPosts.reduce((total, cat) => total + cat.posts.length, 0);
638
+
639
+ return {
640
+ course,
641
+ categories: categoriesWithPosts,
642
+ locationId,
643
+ domain,
644
+ };
645
+ }
646
+ /* v8 ignore stop */
647
+
648
+ // Re-export shared utilities for backwards compatibility
649
+ export { slugify, createFolderName } from "../../shared/slug.js";
650
+
651
+ /**
652
+ * Constructs the URL for a HighLevel course page.
653
+ */
654
+ export function getHighLevelCourseUrl(domain: string, productId: string): string {
655
+ return `https://${domain}/courses/products/${productId}?source=courses`;
656
+ }
657
+
658
+ /**
659
+ * Constructs the URL for a HighLevel lesson (post) page.
660
+ */
661
+ export function getHighLevelPostUrl(
662
+ domain: string,
663
+ productId: string,
664
+ categoryId: string,
665
+ postId: string
666
+ ): string {
667
+ return `https://${domain}/courses/products/${productId}/categories/${categoryId}/posts/${postId}?source=courses`;
668
+ }