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,177 @@
1
+ /**
2
+ * Zod schemas for Skool/Next.js __NEXT_DATA__ responses.
3
+ * These validate only the fields we actually use, ignoring everything else.
4
+ */
5
+
6
+ import { z } from "zod";
7
+
8
+ // ============================================================================
9
+ // Skool Course Child (Module/Lesson)
10
+ // ============================================================================
11
+
12
+ const CourseMetadataSchema = z.looseObject({
13
+ title: z.string().optional(),
14
+ videoLink: z.string().optional(),
15
+ });
16
+
17
+ const CourseInfoSchema = z.looseObject({
18
+ id: z.string().optional(),
19
+ name: z.string().optional(), // 8-char hex slug
20
+ metadata: CourseMetadataSchema.optional(),
21
+ });
22
+
23
+ const CourseChildSchema = z.looseObject({
24
+ course: CourseInfoSchema.optional(),
25
+ hasAccess: z.boolean().optional(),
26
+ });
27
+
28
+ // ============================================================================
29
+ // Skool __NEXT_DATA__ PageProps
30
+ // ============================================================================
31
+
32
+ const SkoolCourseSchema = z.looseObject({
33
+ children: z.array(CourseChildSchema).optional(),
34
+ });
35
+
36
+ const SkoolPagePropsSchema = z.looseObject({
37
+ course: SkoolCourseSchema.optional(),
38
+ selectedModule: z.string().optional(),
39
+ });
40
+
41
+ export const SkoolNextDataSchema = z.looseObject({
42
+ props: z
43
+ .looseObject({
44
+ pageProps: SkoolPagePropsSchema.optional(),
45
+ })
46
+ .optional(),
47
+ });
48
+
49
+ export type SkoolNextData = z.infer<typeof SkoolNextDataSchema>;
50
+
51
+ // ============================================================================
52
+ // Extracted Types (clean types for use in the app)
53
+ // ============================================================================
54
+
55
+ export interface SkoolModule {
56
+ slug: string; // 8-char hex
57
+ title: string;
58
+ hasAccess: boolean;
59
+ }
60
+
61
+ export interface SkoolLesson {
62
+ id: string;
63
+ hasAccess: boolean;
64
+ }
65
+
66
+ export interface SkoolVideoInfo {
67
+ url: string;
68
+ type: "loom" | "vimeo" | "youtube" | "wistia" | "unknown";
69
+ }
70
+
71
+ // ============================================================================
72
+ // Helper Functions
73
+ // ============================================================================
74
+
75
+ /**
76
+ * Safely parses __NEXT_DATA__ JSON from a script element.
77
+ * Returns null if parsing fails.
78
+ */
79
+ export function parseNextData(json: string): SkoolNextData | null {
80
+ try {
81
+ const data: unknown = JSON.parse(json);
82
+ const result = SkoolNextDataSchema.safeParse(data);
83
+ if (result.success) {
84
+ return result.data;
85
+ }
86
+ console.warn("[parseNextData] Validation failed:", z.treeifyError(result.error));
87
+ return null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Extracts modules from parsed __NEXT_DATA__.
95
+ */
96
+ export function extractModulesFromNextData(data: SkoolNextData): SkoolModule[] {
97
+ const children = data.props?.pageProps?.course?.children;
98
+ if (!Array.isArray(children)) return [];
99
+
100
+ const modules: SkoolModule[] = [];
101
+ const seen = new Set<string>();
102
+
103
+ for (const child of children) {
104
+ const course = child.course;
105
+ if (!course?.name) continue;
106
+
107
+ // Skool module slugs are 8-char hex strings
108
+ if (!/^[a-f0-9]{8}$/.test(course.name)) continue;
109
+
110
+ if (seen.has(course.name)) continue;
111
+ seen.add(course.name);
112
+
113
+ modules.push({
114
+ slug: course.name,
115
+ title: course.metadata?.title ?? `Module ${modules.length + 1}`,
116
+ hasAccess: child.hasAccess !== false,
117
+ });
118
+ }
119
+
120
+ return modules;
121
+ }
122
+
123
+ /**
124
+ * Extracts lesson access info from parsed __NEXT_DATA__.
125
+ */
126
+ export function extractLessonAccessFromNextData(data: SkoolNextData): Map<string, boolean> {
127
+ const accessMap = new Map<string, boolean>();
128
+ const children = data.props?.pageProps?.course?.children;
129
+
130
+ if (!Array.isArray(children)) return accessMap;
131
+
132
+ for (const child of children) {
133
+ const id = child.course?.id;
134
+ const hasAccess = child.hasAccess;
135
+ if (id && typeof hasAccess === "boolean") {
136
+ accessMap.set(id, hasAccess);
137
+ }
138
+ }
139
+
140
+ return accessMap;
141
+ }
142
+
143
+ /**
144
+ * Extracts video URL from parsed __NEXT_DATA__ for a specific module.
145
+ */
146
+ export function extractVideoFromNextData(
147
+ data: SkoolNextData,
148
+ selectedModuleId: string
149
+ ): SkoolVideoInfo | null {
150
+ const children = data.props?.pageProps?.course?.children;
151
+ if (!Array.isArray(children)) return null;
152
+
153
+ for (const child of children) {
154
+ if (child.course?.id === selectedModuleId) {
155
+ const videoLink = child.course.metadata?.videoLink;
156
+ if (!videoLink) return null;
157
+
158
+ // Determine video type
159
+ if (videoLink.includes("loom.com")) {
160
+ const embedUrl = videoLink.replace("/share/", "/embed/").split("?")[0];
161
+ return { url: embedUrl ?? videoLink, type: "loom" };
162
+ }
163
+ if (videoLink.includes("vimeo.com")) {
164
+ return { url: videoLink, type: "vimeo" };
165
+ }
166
+ if (videoLink.includes("youtube.com") || videoLink.includes("youtu.be")) {
167
+ return { url: videoLink, type: "youtube" };
168
+ }
169
+ if (videoLink.includes("wistia")) {
170
+ return { url: videoLink, type: "wistia" };
171
+ }
172
+ return { url: videoLink, type: "unknown" };
173
+ }
174
+ }
175
+
176
+ return null;
177
+ }
@@ -0,0 +1,435 @@
1
+ /**
2
+ * Browser-based video URL interception - requires Playwright.
3
+ * Excluded from coverage via vitest.config.ts.
4
+ */
5
+ import type { Page } from "playwright";
6
+
7
+ // ============================================================================
8
+ // Type definitions for external browser APIs
9
+ // ============================================================================
10
+
11
+ /** Vimeo player configuration embedded in the page */
12
+ interface VimeoPlayerConfig {
13
+ request?: {
14
+ files?: {
15
+ hls?: {
16
+ cdns?: Record<string, { url?: string }>;
17
+ };
18
+ progressive?: { url?: string; height?: number }[];
19
+ };
20
+ };
21
+ }
22
+
23
+ /** Vimeo-related window properties */
24
+ interface VimeoWindow {
25
+ playerConfig?: VimeoPlayerConfig;
26
+ vimeo?: { config?: VimeoPlayerConfig };
27
+ __vimeo_player__?: { config?: VimeoPlayerConfig };
28
+ }
29
+
30
+ /** Loom video asset URLs */
31
+ interface LoomAssetUrls {
32
+ hls_url?: string;
33
+ }
34
+
35
+ /** Loom video data */
36
+ interface LoomVideo {
37
+ asset_urls?: LoomAssetUrls;
38
+ }
39
+
40
+ /** Loom SSR state embedded in the page */
41
+ interface LoomSSRState {
42
+ video?: LoomVideo;
43
+ }
44
+
45
+ /** Loom-related window properties */
46
+ interface LoomWindow {
47
+ __LOOM_SSR_STATE__?: LoomSSRState;
48
+ }
49
+
50
+ /** Next.js data for Loom pages */
51
+ interface LoomNextData {
52
+ props?: {
53
+ pageProps?: {
54
+ video?: LoomVideo;
55
+ };
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Captures Vimeo video URL by extracting it from the running player.
61
+ * The key insight: the video is ALREADY playing in the iframe - we just need to get the URL.
62
+ */
63
+ export async function captureVimeoConfig(
64
+ page: Page,
65
+ _videoId: string,
66
+ timeoutMs = 20000
67
+ ): Promise<{ hlsUrl: string | null; progressiveUrl: string | null; error?: string }> {
68
+ try {
69
+ // Step 1: Make sure we have a Vimeo iframe or video wrapper
70
+ // Skool wraps videos in a VideoPlayerWrapper - click it to ensure video loads
71
+ const videoWrapper = await page.$(
72
+ '[class*="VideoPlayerWrapper"], [class*="video-wrapper"], [class*="VideoPlayer"]'
73
+ );
74
+ if (videoWrapper) {
75
+ await videoWrapper.click().catch(() => {});
76
+ await page.waitForTimeout(1000);
77
+ }
78
+
79
+ // Step 2: Wait for Vimeo iframe to appear
80
+ let vimeoFrame = null;
81
+ const startTime = Date.now();
82
+
83
+ while (!vimeoFrame && Date.now() - startTime < timeoutMs) {
84
+ // Try to find the iframe
85
+ const iframe = await page.$('iframe[src*="vimeo.com"], iframe[src*="player.vimeo"]');
86
+ if (iframe) {
87
+ vimeoFrame = await iframe.contentFrame();
88
+ if (vimeoFrame) break;
89
+ }
90
+ await page.waitForTimeout(500);
91
+ }
92
+
93
+ if (!vimeoFrame) {
94
+ return { hlsUrl: null, progressiveUrl: null, error: "Vimeo iframe not found after waiting" };
95
+ }
96
+
97
+ // Step 3: Mute the video before playing (we don't want audio!)
98
+ await vimeoFrame
99
+ .evaluate(() => {
100
+ const video = document.querySelector("video");
101
+ if (video) {
102
+ video.muted = true;
103
+ video.volume = 0;
104
+ }
105
+ })
106
+ .catch(() => {});
107
+
108
+ // Step 4: Click play button in the iframe to start video
109
+ try {
110
+ // Multiple selectors for Vimeo's play button
111
+ await vimeoFrame
112
+ .click(
113
+ '.vp-controls button, .play-icon, [aria-label="Play"], .vp-big-play-button, button',
114
+ {
115
+ timeout: 2000,
116
+ }
117
+ )
118
+ .catch(() => {});
119
+ } catch {
120
+ // Video might auto-play
121
+ }
122
+
123
+ // Ensure video stays muted
124
+ await vimeoFrame
125
+ .evaluate(() => {
126
+ const video = document.querySelector("video");
127
+ if (video) {
128
+ video.muted = true;
129
+ video.volume = 0;
130
+ }
131
+ })
132
+ .catch(() => {});
133
+
134
+ // Step 4: Wait for video to actually start playing and get the URL
135
+ let hlsUrl: string | null = null;
136
+ let progressiveUrl: string | null = null;
137
+
138
+ const extractionStart = Date.now();
139
+ while (!hlsUrl && !progressiveUrl && Date.now() - extractionStart < timeoutMs - 5000) {
140
+ const urls = await vimeoFrame.evaluate(() => {
141
+ const result = {
142
+ hlsUrl: null as string | null,
143
+ progressiveUrl: null as string | null,
144
+ debug: [] as string[],
145
+ };
146
+
147
+ // Method 1: Get URL directly from video element
148
+ const video = document.querySelector("video");
149
+ if (video) {
150
+ result.debug.push(`Video element found, src length: ${video.src?.length ?? 0}`);
151
+
152
+ // Check currentSrc (what's actually playing)
153
+ if (video.currentSrc) {
154
+ result.debug.push(`currentSrc: ${video.currentSrc.substring(0, 80)}`);
155
+ if (video.currentSrc.includes(".m3u8")) {
156
+ result.hlsUrl = video.currentSrc;
157
+ } else if (video.currentSrc.includes(".mp4")) {
158
+ result.progressiveUrl = video.currentSrc;
159
+ }
160
+ }
161
+
162
+ // Also check src attribute
163
+ if (!result.hlsUrl && !result.progressiveUrl && video.src) {
164
+ if (video.src.includes(".m3u8")) {
165
+ result.hlsUrl = video.src;
166
+ } else if (video.src.includes(".mp4")) {
167
+ result.progressiveUrl = video.src;
168
+ }
169
+ }
170
+ }
171
+
172
+ // Method 2: Check source elements
173
+ if (!result.hlsUrl && !result.progressiveUrl) {
174
+ const sources = document.querySelectorAll("video source");
175
+ result.debug.push(`Found ${sources.length} source elements`);
176
+ for (const source of Array.from(sources)) {
177
+ const src = (source as HTMLSourceElement).src;
178
+ if (src?.includes(".m3u8")) {
179
+ result.hlsUrl = src;
180
+ break;
181
+ } else if (src?.includes(".mp4") && !result.progressiveUrl) {
182
+ result.progressiveUrl = src;
183
+ }
184
+ }
185
+ }
186
+
187
+ // Method 3: Extract from Vimeo's internal player state
188
+ if (!result.hlsUrl && !result.progressiveUrl) {
189
+ try {
190
+ const win = window as unknown as VimeoWindow;
191
+
192
+ // Try various Vimeo internal variables
193
+ const configPaths = [
194
+ win.playerConfig?.request?.files,
195
+ win.vimeo?.config?.request?.files,
196
+ win.__vimeo_player__?.config?.request?.files,
197
+ ];
198
+
199
+ for (const files of configPaths) {
200
+ if (!files) continue;
201
+
202
+ // HLS
203
+ if (files.hls?.cdns) {
204
+ const cdns = files.hls.cdns;
205
+ for (const cdn of Object.keys(cdns)) {
206
+ const cdnEntry = cdns[cdn];
207
+ if (cdnEntry?.url) {
208
+ result.hlsUrl = cdnEntry.url;
209
+ result.debug.push(`Found HLS in playerConfig.${cdn}`);
210
+ break;
211
+ }
212
+ }
213
+ }
214
+
215
+ // Progressive MP4
216
+ if (!result.progressiveUrl && files.progressive && files.progressive.length > 0) {
217
+ const sorted = [...files.progressive].sort(
218
+ (a, b) => (b.height ?? 0) - (a.height ?? 0)
219
+ );
220
+ result.progressiveUrl = sorted[0]?.url ?? null;
221
+ if (result.progressiveUrl) {
222
+ result.debug.push("Found progressive in playerConfig");
223
+ }
224
+ }
225
+
226
+ if (result.hlsUrl || result.progressiveUrl) break;
227
+ }
228
+ } catch (e) {
229
+ result.debug.push(
230
+ `Config extraction error: ${e instanceof Error ? e.message : String(e)}`
231
+ );
232
+ }
233
+ }
234
+
235
+ // Method 4: Network request URLs might be in DOM attributes
236
+ if (!result.hlsUrl && !result.progressiveUrl) {
237
+ const allElements = document.querySelectorAll("*");
238
+ for (const el of Array.from(allElements)) {
239
+ for (const attr of Array.from(el.attributes)) {
240
+ if (attr.value.includes("vimeocdn.com") && attr.value.includes(".m3u8")) {
241
+ result.hlsUrl = /https:\/\/[^\s"']+\.m3u8[^\s"']*/.exec(attr.value)?.[0] ?? null;
242
+ if (result.hlsUrl) {
243
+ result.debug.push("Found HLS in element attribute");
244
+ break;
245
+ }
246
+ }
247
+ }
248
+ if (result.hlsUrl) break;
249
+ }
250
+ }
251
+
252
+ return result;
253
+ });
254
+
255
+ hlsUrl = urls.hlsUrl;
256
+ progressiveUrl = urls.progressiveUrl;
257
+
258
+ if (!hlsUrl && !progressiveUrl) {
259
+ // Wait and try again
260
+ await new Promise((r) => setTimeout(r, 1000));
261
+ }
262
+ }
263
+
264
+ if (hlsUrl || progressiveUrl) {
265
+ return { hlsUrl, progressiveUrl };
266
+ }
267
+
268
+ return {
269
+ hlsUrl: null,
270
+ progressiveUrl: null,
271
+ error: "Could not extract video URL from Vimeo player",
272
+ };
273
+ } catch (error) {
274
+ return {
275
+ hlsUrl: null,
276
+ progressiveUrl: null,
277
+ error: `Vimeo extraction failed: ${error instanceof Error ? error.message : String(error)}`,
278
+ };
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Captures Loom HLS URL by navigating directly to the embed page.
284
+ * This works better than CDP because we can intercept all requests on that page.
285
+ */
286
+ export async function captureLoomHls(
287
+ page: Page,
288
+ videoId: string,
289
+ timeoutMs = 15000
290
+ ): Promise<{ hlsUrl: string | null; error?: string }> {
291
+ let capturedUrl: string | null = null;
292
+ const originalUrl = page.url();
293
+
294
+ try {
295
+ // Use CDP to intercept network responses
296
+ const client = await page.context().newCDPSession(page);
297
+ await client.send("Network.enable");
298
+
299
+ // Match HLS playlists from Loom's CDN
300
+ // Prefer master playlist (playlist.m3u8) over media playlists (mediaplaylist-*.m3u8)
301
+ const masterPattern = /luna\.loom\.com.*\/playlist\.m3u8/;
302
+ const anyHlsPattern = /luna\.loom\.com.*\.m3u8/;
303
+
304
+ // Set up listener before navigation
305
+ const responsePromise = new Promise<void>((resolve) => {
306
+ const timeout = setTimeout(() => {
307
+ resolve();
308
+ }, timeoutMs);
309
+ let hasMasterPlaylist = false;
310
+
311
+ client.on("Network.responseReceived", (event) => {
312
+ const url = event.response.url;
313
+
314
+ // Always prefer master playlist
315
+ if (masterPattern.test(url)) {
316
+ capturedUrl = url;
317
+ hasMasterPlaylist = true;
318
+ clearTimeout(timeout);
319
+ resolve();
320
+ } else if (!hasMasterPlaylist && anyHlsPattern.test(url)) {
321
+ // Capture any HLS as fallback, but keep listening for master
322
+ capturedUrl = url;
323
+ }
324
+ });
325
+ });
326
+
327
+ // Navigate directly to Loom embed with autoplay (muted)
328
+ const embedUrl = `https://www.loom.com/embed/${videoId}?autoplay=1`;
329
+ await page.goto(embedUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
330
+
331
+ // Mute the video immediately
332
+ await page
333
+ .evaluate(() => {
334
+ const video = document.querySelector("video");
335
+ if (video) {
336
+ video.muted = true;
337
+ video.volume = 0;
338
+ }
339
+ })
340
+ .catch(() => {});
341
+
342
+ // Try to click play button if video doesn't autoplay
343
+ try {
344
+ await page.waitForTimeout(2000);
345
+ const playButton = await page.$(
346
+ '[data-testid="play-button"], .PlayButton, [aria-label="Play"], button[class*="play"]'
347
+ );
348
+ if (playButton) {
349
+ // Mute again before clicking play
350
+ await page
351
+ .evaluate(() => {
352
+ const video = document.querySelector("video");
353
+ if (video) {
354
+ video.muted = true;
355
+ video.volume = 0;
356
+ }
357
+ })
358
+ .catch(() => {});
359
+ await playButton.click();
360
+ }
361
+ } catch {
362
+ // No play button or click failed
363
+ }
364
+
365
+ // Ensure video stays muted after play
366
+ await page
367
+ .evaluate(() => {
368
+ const video = document.querySelector("video");
369
+ if (video) {
370
+ video.muted = true;
371
+ video.volume = 0;
372
+ }
373
+ })
374
+ .catch(() => {});
375
+
376
+ // Wait for HLS to be captured
377
+ await responsePromise;
378
+
379
+ // Also try to extract from page JS if not found via network
380
+ if (!capturedUrl) {
381
+ const jsUrl = await page.evaluate(() => {
382
+ const win = window as unknown as LoomWindow;
383
+
384
+ // Check __LOOM_SSR_STATE__
385
+ if (win.__LOOM_SSR_STATE__?.video?.asset_urls?.hls_url) {
386
+ return win.__LOOM_SSR_STATE__.video.asset_urls.hls_url;
387
+ }
388
+
389
+ // Check for Next.js data
390
+ const nextData = document.getElementById("__NEXT_DATA__");
391
+ if (nextData?.textContent) {
392
+ try {
393
+ const data = JSON.parse(nextData.textContent) as LoomNextData;
394
+ const hlsUrl = data?.props?.pageProps?.video?.asset_urls?.hls_url;
395
+ if (hlsUrl) return hlsUrl;
396
+
397
+ // Try regex match in full data
398
+ const videoData = /hls_url['":\s]+['"]([^'"]+)['"]/.exec(JSON.stringify(data));
399
+ if (videoData?.[1]) return videoData[1];
400
+ } catch {
401
+ /* ignore parse errors */
402
+ }
403
+ }
404
+
405
+ // Scan scripts for HLS URL
406
+ const scripts = Array.from(document.querySelectorAll("script"));
407
+ for (const script of scripts) {
408
+ const match = /https:\/\/luna\.loom\.com[^"'\s]+\.m3u8[^"'\s]*/.exec(
409
+ script.textContent ?? ""
410
+ );
411
+ if (match) return match[0];
412
+ }
413
+
414
+ return null;
415
+ });
416
+
417
+ if (jsUrl) {
418
+ capturedUrl = jsUrl;
419
+ }
420
+ }
421
+
422
+ await client.detach();
423
+ } catch {
424
+ // Error during capture
425
+ }
426
+
427
+ // Navigate back to original page
428
+ try {
429
+ await page.goto(originalUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
430
+ } catch {
431
+ // Failed to navigate back
432
+ }
433
+
434
+ return capturedUrl ? { hlsUrl: capturedUrl } : { hlsUrl: null, error: "HLS URL not captured" };
435
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createLoginChecker, isSkoolLoginPage, isHighLevelLoginPage } from "./auth.js";
3
+
4
+ describe("auth", () => {
5
+ describe("createLoginChecker", () => {
6
+ it("creates checker that matches patterns", () => {
7
+ const checker = createLoginChecker([/\/login/, /\/signin/]);
8
+
9
+ expect(checker("https://example.com/login")).toBe(true);
10
+ expect(checker("https://example.com/signin")).toBe(true);
11
+ expect(checker("https://example.com/dashboard")).toBe(false);
12
+ });
13
+
14
+ it("uses default patterns when none provided", () => {
15
+ const checker = createLoginChecker();
16
+
17
+ expect(checker("https://example.com/login")).toBe(true);
18
+ expect(checker("https://accounts.google.com/auth")).toBe(true);
19
+ expect(checker("https://example.com/dashboard")).toBe(false);
20
+ });
21
+ });
22
+
23
+ describe("isSkoolLoginPage", () => {
24
+ it("detects Skool login page", () => {
25
+ expect(isSkoolLoginPage("https://www.skool.com/login")).toBe(true);
26
+ });
27
+
28
+ it("detects Google OAuth redirect", () => {
29
+ expect(isSkoolLoginPage("https://accounts.google.com/o/oauth2/auth?...")).toBe(true);
30
+ });
31
+
32
+ it("returns false for non-login pages", () => {
33
+ expect(isSkoolLoginPage("https://www.skool.com/my-community")).toBe(false);
34
+ expect(isSkoolLoginPage("https://www.skool.com/my-community/classroom")).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe("isHighLevelLoginPage", () => {
39
+ it("detects HighLevel SSO page", () => {
40
+ expect(isHighLevelLoginPage("https://sso.clientclub.net/login")).toBe(true);
41
+ });
42
+
43
+ it("detects various login paths", () => {
44
+ expect(isHighLevelLoginPage("https://portal.example.com/login")).toBe(true);
45
+ expect(isHighLevelLoginPage("https://portal.example.com/signin")).toBe(true);
46
+ expect(isHighLevelLoginPage("https://portal.example.com/auth/callback")).toBe(true);
47
+ });
48
+
49
+ it("detects Firebase auth", () => {
50
+ expect(isHighLevelLoginPage("https://example.firebaseapp.com/auth")).toBe(true);
51
+ });
52
+
53
+ it("returns false for content pages", () => {
54
+ expect(isHighLevelLoginPage("https://portal.example.com/courses")).toBe(false);
55
+ expect(isHighLevelLoginPage("https://portal.example.com/dashboard")).toBe(false);
56
+ });
57
+ });
58
+ });