offcourse 0.0.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +50 -0
- package/.husky/commit-msg +2 -0
- package/.husky/pre-commit +1 -0
- package/.husky/pre-push +3 -0
- package/.prettierrc +8 -0
- package/.release-it.json +23 -0
- package/ARCHITECTURE.md +233 -0
- package/CHANGELOG.md +78 -0
- package/README.md +256 -16
- package/commitlint.config.js +4 -0
- package/dist/ai/openRouter.d.ts +47 -0
- package/dist/ai/openRouter.d.ts.map +1 -0
- package/dist/ai/openRouter.js +116 -0
- package/dist/ai/openRouter.js.map +1 -0
- package/dist/ai/transcriptPolisher.d.ts +24 -0
- package/dist/ai/transcriptPolisher.d.ts.map +1 -0
- package/dist/ai/transcriptPolisher.js +89 -0
- package/dist/ai/transcriptPolisher.js.map +1 -0
- package/dist/cli/commands/config.d.ts +13 -0
- package/dist/cli/commands/config.d.ts.map +1 -0
- package/dist/cli/commands/config.js +66 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/enrich.d.ts +14 -0
- package/dist/cli/commands/enrich.d.ts.map +1 -0
- package/dist/cli/commands/enrich.js +271 -0
- package/dist/cli/commands/enrich.js.map +1 -0
- package/dist/cli/commands/inspect.d.ts +11 -0
- package/dist/cli/commands/inspect.d.ts.map +1 -0
- package/dist/cli/commands/inspect.js +365 -0
- package/dist/cli/commands/inspect.js.map +1 -0
- package/dist/cli/commands/login.d.ts +12 -0
- package/dist/cli/commands/login.d.ts.map +1 -0
- package/dist/cli/commands/login.js +55 -0
- package/dist/cli/commands/login.js.map +1 -0
- package/dist/cli/commands/status.d.ts +15 -0
- package/dist/cli/commands/status.d.ts.map +1 -0
- package/dist/cli/commands/status.js +118 -0
- package/dist/cli/commands/status.js.map +1 -0
- package/dist/cli/commands/sync.d.ts +16 -0
- package/dist/cli/commands/sync.d.ts.map +1 -0
- package/dist/cli/commands/sync.js +922 -0
- package/dist/cli/commands/sync.js.map +1 -0
- package/dist/cli/commands/syncGhl.d.ts +20 -0
- package/dist/cli/commands/syncGhl.d.ts.map +1 -0
- package/dist/cli/commands/syncGhl.js +483 -0
- package/dist/cli/commands/syncGhl.js.map +1 -0
- package/dist/cli/commands/syncHighLevel.d.ts +24 -0
- package/dist/cli/commands/syncHighLevel.d.ts.map +1 -0
- package/dist/cli/commands/syncHighLevel.js +483 -0
- package/dist/cli/commands/syncHighLevel.js.map +1 -0
- package/dist/cli/commands/syncHighLevel.test.d.ts +2 -0
- package/dist/cli/commands/syncHighLevel.test.d.ts.map +1 -0
- package/dist/cli/commands/syncHighLevel.test.js +102 -0
- package/dist/cli/commands/syncHighLevel.test.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +106 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/config/configManager.d.ts +31 -0
- package/dist/config/configManager.d.ts.map +1 -0
- package/dist/config/configManager.js +64 -0
- package/dist/config/configManager.js.map +1 -0
- package/dist/config/paths.d.ts +21 -0
- package/dist/config/paths.d.ts.map +1 -0
- package/dist/config/paths.js +33 -0
- package/dist/config/paths.js.map +1 -0
- package/dist/config/paths.test.d.ts +2 -0
- package/dist/config/paths.test.d.ts.map +1 -0
- package/dist/config/paths.test.js +70 -0
- package/dist/config/paths.test.js.map +1 -0
- package/dist/config/schema.d.ts +60 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +50 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/config/schema.test.d.ts +2 -0
- package/dist/config/schema.test.d.ts.map +1 -0
- package/dist/config/schema.test.js +151 -0
- package/dist/config/schema.test.js.map +1 -0
- package/dist/downloader/hlsDownloader.d.ts +58 -0
- package/dist/downloader/hlsDownloader.d.ts.map +1 -0
- package/dist/downloader/hlsDownloader.js +254 -0
- package/dist/downloader/hlsDownloader.js.map +1 -0
- package/dist/downloader/hlsDownloader.test.d.ts +2 -0
- package/dist/downloader/hlsDownloader.test.d.ts.map +1 -0
- package/dist/downloader/hlsDownloader.test.js +116 -0
- package/dist/downloader/hlsDownloader.test.js.map +1 -0
- package/dist/downloader/hlsValidator.d.ts +35 -0
- package/dist/downloader/hlsValidator.d.ts.map +1 -0
- package/dist/downloader/hlsValidator.js +148 -0
- package/dist/downloader/hlsValidator.js.map +1 -0
- package/dist/downloader/index.d.ts +26 -0
- package/dist/downloader/index.d.ts.map +1 -0
- package/dist/downloader/index.js +52 -0
- package/dist/downloader/index.js.map +1 -0
- package/dist/downloader/loomDownloader.d.ts +56 -0
- package/dist/downloader/loomDownloader.d.ts.map +1 -0
- package/dist/downloader/loomDownloader.js +559 -0
- package/dist/downloader/loomDownloader.js.map +1 -0
- package/dist/downloader/loomDownloader.test.d.ts +2 -0
- package/dist/downloader/loomDownloader.test.d.ts.map +1 -0
- package/dist/downloader/loomDownloader.test.js +36 -0
- package/dist/downloader/loomDownloader.test.js.map +1 -0
- package/dist/downloader/queue.d.ts +56 -0
- package/dist/downloader/queue.d.ts.map +1 -0
- package/dist/downloader/queue.js +88 -0
- package/dist/downloader/queue.js.map +1 -0
- package/dist/downloader/queue.test.d.ts +2 -0
- package/dist/downloader/queue.test.d.ts.map +1 -0
- package/dist/downloader/queue.test.js +158 -0
- package/dist/downloader/queue.test.js.map +1 -0
- package/dist/downloader/videoDownloader.d.ts +32 -0
- package/dist/downloader/videoDownloader.d.ts.map +1 -0
- package/dist/downloader/videoDownloader.js +173 -0
- package/dist/downloader/videoDownloader.js.map +1 -0
- package/dist/downloader/vimeoDownloader.d.ts +52 -0
- package/dist/downloader/vimeoDownloader.d.ts.map +1 -0
- package/dist/downloader/vimeoDownloader.js +565 -0
- package/dist/downloader/vimeoDownloader.js.map +1 -0
- package/dist/downloader/vimeoDownloader.test.d.ts +2 -0
- package/dist/downloader/vimeoDownloader.test.d.ts.map +1 -0
- package/dist/downloader/vimeoDownloader.test.js +51 -0
- package/dist/downloader/vimeoDownloader.test.js.map +1 -0
- package/dist/scraper/auth.d.ts +29 -0
- package/dist/scraper/auth.d.ts.map +1 -0
- package/dist/scraper/auth.js +115 -0
- package/dist/scraper/auth.js.map +1 -0
- package/dist/scraper/extractor.d.ts +49 -0
- package/dist/scraper/extractor.d.ts.map +1 -0
- package/dist/scraper/extractor.js +627 -0
- package/dist/scraper/extractor.js.map +1 -0
- package/dist/scraper/extractor.test.d.ts +2 -0
- package/dist/scraper/extractor.test.d.ts.map +1 -0
- package/dist/scraper/extractor.test.js +65 -0
- package/dist/scraper/extractor.test.js.map +1 -0
- package/dist/scraper/ghl/auth.d.ts +25 -0
- package/dist/scraper/ghl/auth.d.ts.map +1 -0
- package/dist/scraper/ghl/auth.js +187 -0
- package/dist/scraper/ghl/auth.js.map +1 -0
- package/dist/scraper/ghl/extractor.d.ts +96 -0
- package/dist/scraper/ghl/extractor.d.ts.map +1 -0
- package/dist/scraper/ghl/extractor.js +345 -0
- package/dist/scraper/ghl/extractor.js.map +1 -0
- package/dist/scraper/ghl/index.d.ts +4 -0
- package/dist/scraper/ghl/index.d.ts.map +1 -0
- package/dist/scraper/ghl/index.js +4 -0
- package/dist/scraper/ghl/index.js.map +1 -0
- package/dist/scraper/ghl/navigator.d.ts +93 -0
- package/dist/scraper/ghl/navigator.d.ts.map +1 -0
- package/dist/scraper/ghl/navigator.js +447 -0
- package/dist/scraper/ghl/navigator.js.map +1 -0
- package/dist/scraper/highlevel/auth.d.ts +25 -0
- package/dist/scraper/highlevel/auth.d.ts.map +1 -0
- package/dist/scraper/highlevel/auth.js +189 -0
- package/dist/scraper/highlevel/auth.js.map +1 -0
- package/dist/scraper/highlevel/extractor.d.ts +97 -0
- package/dist/scraper/highlevel/extractor.d.ts.map +1 -0
- package/dist/scraper/highlevel/extractor.js +386 -0
- package/dist/scraper/highlevel/extractor.js.map +1 -0
- package/dist/scraper/highlevel/extractor.test.d.ts +2 -0
- package/dist/scraper/highlevel/extractor.test.d.ts.map +1 -0
- package/dist/scraper/highlevel/extractor.test.js +101 -0
- package/dist/scraper/highlevel/extractor.test.js.map +1 -0
- package/dist/scraper/highlevel/index.d.ts +3 -0
- package/dist/scraper/highlevel/index.d.ts.map +1 -0
- package/dist/scraper/highlevel/index.js +3 -0
- package/dist/scraper/highlevel/index.js.map +1 -0
- package/dist/scraper/highlevel/navigator.d.ts +93 -0
- package/dist/scraper/highlevel/navigator.d.ts.map +1 -0
- package/dist/scraper/highlevel/navigator.js +492 -0
- package/dist/scraper/highlevel/navigator.js.map +1 -0
- package/dist/scraper/highlevel/navigator.test.d.ts +2 -0
- package/dist/scraper/highlevel/navigator.test.d.ts.map +1 -0
- package/dist/scraper/highlevel/navigator.test.js +78 -0
- package/dist/scraper/highlevel/navigator.test.js.map +1 -0
- package/dist/scraper/navigator.d.ts +65 -0
- package/dist/scraper/navigator.d.ts.map +1 -0
- package/dist/scraper/navigator.js +300 -0
- package/dist/scraper/navigator.js.map +1 -0
- package/dist/scraper/navigator.test.d.ts +2 -0
- package/dist/scraper/navigator.test.d.ts.map +1 -0
- package/dist/scraper/navigator.test.js +63 -0
- package/dist/scraper/navigator.test.js.map +1 -0
- package/dist/scraper/skoolApi.d.ts +17 -0
- package/dist/scraper/skoolApi.d.ts.map +1 -0
- package/dist/scraper/skoolApi.js +72 -0
- package/dist/scraper/skoolApi.js.map +1 -0
- package/dist/scraper/videoInterceptor.d.ts +19 -0
- package/dist/scraper/videoInterceptor.d.ts.map +1 -0
- package/dist/scraper/videoInterceptor.js +315 -0
- package/dist/scraper/videoInterceptor.js.map +1 -0
- package/dist/shared/auth.d.ts +58 -0
- package/dist/shared/auth.d.ts.map +1 -0
- package/dist/shared/auth.js +211 -0
- package/dist/shared/auth.js.map +1 -0
- package/dist/shared/fs.d.ts +31 -0
- package/dist/shared/fs.d.ts.map +1 -0
- package/dist/shared/fs.js +73 -0
- package/dist/shared/fs.js.map +1 -0
- package/dist/shared/http.d.ts +15 -0
- package/dist/shared/http.d.ts.map +1 -0
- package/dist/shared/http.js +31 -0
- package/dist/shared/http.js.map +1 -0
- package/dist/shared/index.d.ts +4 -0
- package/dist/shared/index.d.ts.map +1 -0
- package/dist/shared/index.js +4 -0
- package/dist/shared/index.js.map +1 -0
- package/dist/state/database.d.ts +245 -0
- package/dist/state/database.d.ts.map +1 -0
- package/dist/state/database.js +676 -0
- package/dist/state/database.js.map +1 -0
- package/dist/state/database.test.d.ts +2 -0
- package/dist/state/database.test.d.ts.map +1 -0
- package/dist/state/database.test.js +34 -0
- package/dist/state/database.test.js.map +1 -0
- package/dist/state/index.d.ts +2 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +2 -0
- package/dist/state/index.js.map +1 -0
- package/dist/storage/fileSystem.d.ts +56 -0
- package/dist/storage/fileSystem.d.ts.map +1 -0
- package/dist/storage/fileSystem.js +121 -0
- package/dist/storage/fileSystem.js.map +1 -0
- package/dist/transcription/whisperService.d.ts +27 -0
- package/dist/transcription/whisperService.d.ts.map +1 -0
- package/dist/transcription/whisperService.js +102 -0
- package/dist/transcription/whisperService.js.map +1 -0
- package/eslint.config.js +55 -0
- package/package.json +68 -11
- package/src/__fixtures__/highlevel-post-response.json +68 -0
- package/src/__fixtures__/hls-master-playlist.m3u8 +24 -0
- package/src/cli/commands/__snapshots__/syncHighLevel.test.ts.snap +38 -0
- package/src/cli/commands/config.ts +74 -0
- package/src/cli/commands/inspect.ts +441 -0
- package/src/cli/commands/login.ts +68 -0
- package/src/cli/commands/status.ts +147 -0
- package/src/cli/commands/sync.ts +1235 -0
- package/src/cli/commands/syncHighLevel.test.ts +144 -0
- package/src/cli/commands/syncHighLevel.ts +639 -0
- package/src/cli/index.ts +121 -0
- package/src/config/configManager.ts +75 -0
- package/src/config/paths.test.ts +83 -0
- package/src/config/paths.ts +36 -0
- package/src/config/schema.test.ts +173 -0
- package/src/config/schema.ts +65 -0
- package/src/downloader/hlsDownloader.test.ts +148 -0
- package/src/downloader/hlsDownloader.ts +327 -0
- package/src/downloader/hlsValidator.ts +196 -0
- package/src/downloader/index.ts +122 -0
- package/src/downloader/loomDownloader.test.ts +43 -0
- package/src/downloader/loomDownloader.ts +742 -0
- package/src/downloader/queue.test.ts +199 -0
- package/src/downloader/queue.ts +118 -0
- package/src/downloader/vimeoDownloader.test.ts +62 -0
- package/src/downloader/vimeoDownloader.ts +722 -0
- package/src/scraper/extractor.test.ts +124 -0
- package/src/scraper/extractor.ts +757 -0
- package/src/scraper/highlevel/__snapshots__/extractor.test.ts.snap +41 -0
- package/src/scraper/highlevel/extractor.test.ts +134 -0
- package/src/scraper/highlevel/extractor.ts +537 -0
- package/src/scraper/highlevel/index.ts +2 -0
- package/src/scraper/highlevel/navigator.test.ts +110 -0
- package/src/scraper/highlevel/navigator.ts +668 -0
- package/src/scraper/highlevel/schemas.ts +183 -0
- package/src/scraper/navigator.test.ts +122 -0
- package/src/scraper/navigator.ts +355 -0
- package/src/scraper/schemas.ts +177 -0
- package/src/scraper/videoInterceptor.ts +435 -0
- package/src/shared/auth.test.ts +58 -0
- package/src/shared/auth.ts +251 -0
- package/src/shared/firebase.ts +151 -0
- package/src/shared/fs.ts +80 -0
- package/src/shared/http.ts +34 -0
- package/src/shared/index.ts +6 -0
- package/src/shared/slug.ts +26 -0
- package/src/shared/url.test.ts +122 -0
- package/src/shared/url.ts +57 -0
- package/src/state/database.test.ts +49 -0
- package/src/state/database.ts +919 -0
- package/src/state/index.ts +14 -0
- package/src/storage/fileSystem.test.ts +64 -0
- package/src/storage/fileSystem.ts +175 -0
- package/tsconfig.json +28 -0
- package/vitest.config.ts +29 -0
- package/cli.js +0 -45
|
@@ -0,0 +1,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
|
+
}
|