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