@mogulmoretti/skrape 0.1.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 (55) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +117 -0
  3. package/dist/auth/session.d.ts +26 -0
  4. package/dist/auth/session.js +70 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +123 -0
  7. package/dist/discover/communities.d.ts +22 -0
  8. package/dist/discover/communities.js +92 -0
  9. package/dist/discover/skool.d.ts +17 -0
  10. package/dist/discover/skool.js +91 -0
  11. package/dist/fetch/browser.d.ts +16 -0
  12. package/dist/fetch/browser.js +58 -0
  13. package/dist/fetch/chromeSetup.d.ts +54 -0
  14. package/dist/fetch/chromeSetup.js +126 -0
  15. package/dist/fetch/http.d.ts +17 -0
  16. package/dist/fetch/http.js +64 -0
  17. package/dist/fetch/nextdata.d.ts +9 -0
  18. package/dist/fetch/nextdata.js +23 -0
  19. package/dist/fetch/resilient.d.ts +12 -0
  20. package/dist/fetch/resilient.js +68 -0
  21. package/dist/media/index.d.ts +4 -0
  22. package/dist/media/index.js +11 -0
  23. package/dist/media/loom.d.ts +5 -0
  24. package/dist/media/loom.js +60 -0
  25. package/dist/normalize/vtt.d.ts +12 -0
  26. package/dist/normalize/vtt.js +109 -0
  27. package/dist/store/db.d.ts +15 -0
  28. package/dist/store/db.js +125 -0
  29. package/dist/store/markdown.d.ts +7 -0
  30. package/dist/store/markdown.js +41 -0
  31. package/dist/sync.d.ts +30 -0
  32. package/dist/sync.js +178 -0
  33. package/dist/tui/App.d.ts +22 -0
  34. package/dist/tui/App.js +285 -0
  35. package/dist/tui/SelectList.d.ts +14 -0
  36. package/dist/tui/SelectList.js +25 -0
  37. package/dist/tui/browserLifecycle.d.ts +71 -0
  38. package/dist/tui/browserLifecycle.js +114 -0
  39. package/dist/tui/chromeSetup.d.ts +50 -0
  40. package/dist/tui/chromeSetup.js +52 -0
  41. package/dist/tui/flow.d.ts +53 -0
  42. package/dist/tui/flow.js +30 -0
  43. package/dist/tui/progress.d.ts +21 -0
  44. package/dist/tui/progress.js +31 -0
  45. package/dist/tui/reveal.d.ts +17 -0
  46. package/dist/tui/reveal.js +47 -0
  47. package/dist/tui/run.d.ts +12 -0
  48. package/dist/tui/run.js +96 -0
  49. package/dist/tui/slug.d.ts +7 -0
  50. package/dist/tui/slug.js +18 -0
  51. package/dist/tui/summary.d.ts +7 -0
  52. package/dist/tui/summary.js +29 -0
  53. package/dist/types.d.ts +33 -0
  54. package/dist/types.js +1 -0
  55. package/package.json +57 -0
@@ -0,0 +1,30 @@
1
+ export const MENU_ITEMS = [
2
+ { label: 'Sync another community', value: 'sync-another' },
3
+ { label: 'Sync this community again', value: 'sync-again' },
4
+ { label: 'Open output folder', value: 'open-folder' },
5
+ { label: 'Quit', value: 'quit' },
6
+ ];
7
+ /**
8
+ * Given the state of the sync that just completed and the menu choice the
9
+ * user made, returns the next step transition — or `null` when the choice
10
+ * doesn't move to a new screen at all: `'open-folder'` is a side effect
11
+ * performed in place (the menu stays up), and `'quit'` exits instead of
12
+ * transitioning to another step.
13
+ */
14
+ export function nextStepForMenuChoice(done, choice) {
15
+ switch (choice) {
16
+ case 'sync-another':
17
+ return { kind: 'discovering' };
18
+ case 'sync-again':
19
+ return {
20
+ kind: 'confirming',
21
+ slug: done.slug,
22
+ name: done.name,
23
+ courseCount: done.courseCount,
24
+ outDir: done.outDir,
25
+ };
26
+ case 'open-folder':
27
+ case 'quit':
28
+ return null;
29
+ }
30
+ }
@@ -0,0 +1,21 @@
1
+ import type { Outcome, ProgressEvent } from '../sync.js';
2
+ export interface ProgressState {
3
+ total: number;
4
+ done: number;
5
+ counts: Record<Outcome, number>;
6
+ current: {
7
+ course: string;
8
+ title: string;
9
+ } | null;
10
+ }
11
+ export declare function createProgressState(total: number): ProgressState;
12
+ /**
13
+ * Pure reducer folding one onProgress event into state. Events arrive out of
14
+ * order from concurrent workers, so `done`/`total` are taken straight from the
15
+ * event (sync.ts already owns and increments that counter atomically) rather
16
+ * than re-derived here, and `current` is simply whichever event was folded in
17
+ * most recently — a stable "last seen" snapshot, not a claim about true
18
+ * completion order.
19
+ */
20
+ export declare function applyProgress(state: ProgressState, event: ProgressEvent): ProgressState;
21
+ export declare function formatProgressBar(done: number, total: number, width?: number): string;
@@ -0,0 +1,31 @@
1
+ export function createProgressState(total) {
2
+ return {
3
+ total,
4
+ done: 0,
5
+ counts: { ok: 0, skipped: 0, 'no-video': 0, 'no-access': 0, unavailable: 0, failed: 0 },
6
+ current: null,
7
+ };
8
+ }
9
+ /**
10
+ * Pure reducer folding one onProgress event into state. Events arrive out of
11
+ * order from concurrent workers, so `done`/`total` are taken straight from the
12
+ * event (sync.ts already owns and increments that counter atomically) rather
13
+ * than re-derived here, and `current` is simply whichever event was folded in
14
+ * most recently — a stable "last seen" snapshot, not a claim about true
15
+ * completion order.
16
+ */
17
+ export function applyProgress(state, event) {
18
+ return {
19
+ total: event.total,
20
+ done: event.done,
21
+ counts: { ...state.counts, [event.outcome]: state.counts[event.outcome] + 1 },
22
+ current: { course: event.course, title: event.title },
23
+ };
24
+ }
25
+ export function formatProgressBar(done, total, width = 24) {
26
+ const safeTotal = total > 0 ? total : 1;
27
+ const ratio = Math.min(1, Math.max(0, done / safeTotal));
28
+ const filled = Math.round(ratio * width);
29
+ const bar = '#'.repeat(filled) + '-'.repeat(width - filled);
30
+ return `[${bar}] ${done}/${total}`;
31
+ }
@@ -0,0 +1,17 @@
1
+ export interface RevealDeps {
2
+ /** Injectable so tests never spawn a real process. Defaults to
3
+ * node:child_process's execFile, promisified. */
4
+ run: (command: string, args: string[]) => Promise<void>;
5
+ platform: NodeJS.Platform;
6
+ /** Where to print the path when reveal isn't possible/fails. Defaults to
7
+ * console.log. */
8
+ log: (message: string) => void;
9
+ }
10
+ export declare function defaultRevealDeps(): RevealDeps;
11
+ /**
12
+ * Reveals `outDir` in the OS file manager. Tries the platform-appropriate
13
+ * command; if there isn't one for this platform, or the command fails (not
14
+ * installed, headless environment, etc.), this never throws — it just prints
15
+ * the path instead, so a failed reveal can never crash the TUI.
16
+ */
17
+ export declare function revealOutputFolder(outDir: string, deps?: RevealDeps): Promise<void>;
@@ -0,0 +1,47 @@
1
+ import { execFile } from 'node:child_process';
2
+ function defaultRun(command, args) {
3
+ return new Promise((resolve, reject) => {
4
+ execFile(command, args, (error) => {
5
+ if (error)
6
+ reject(error);
7
+ else
8
+ resolve();
9
+ });
10
+ });
11
+ }
12
+ export function defaultRevealDeps() {
13
+ return { run: defaultRun, platform: process.platform, log: (message) => console.log(message) };
14
+ }
15
+ /** Maps a platform to the OS command that reveals a folder in the file
16
+ * manager, or null when there's no known one to try. */
17
+ function revealCommandFor(platform) {
18
+ switch (platform) {
19
+ case 'darwin':
20
+ return { command: 'open', args: (path) => [path] };
21
+ case 'win32':
22
+ return { command: 'explorer', args: (path) => [path] };
23
+ case 'linux':
24
+ return { command: 'xdg-open', args: (path) => [path] };
25
+ default:
26
+ return null;
27
+ }
28
+ }
29
+ /**
30
+ * Reveals `outDir` in the OS file manager. Tries the platform-appropriate
31
+ * command; if there isn't one for this platform, or the command fails (not
32
+ * installed, headless environment, etc.), this never throws — it just prints
33
+ * the path instead, so a failed reveal can never crash the TUI.
34
+ */
35
+ export async function revealOutputFolder(outDir, deps = defaultRevealDeps()) {
36
+ const reveal = revealCommandFor(deps.platform);
37
+ if (!reveal) {
38
+ deps.log(outDir);
39
+ return;
40
+ }
41
+ try {
42
+ await deps.run(reveal.command, reveal.args(outDir));
43
+ }
44
+ catch {
45
+ deps.log(outDir);
46
+ }
47
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Runs the guided, no-arguments flow: session check, community picker,
3
+ * confirmation, live sync progress, and a result summary. Owns the db and
4
+ * the browser lifecycle for the whole flow and guarantees both are closed
5
+ * on every exit path — normal completion, an in-flow error, or Ctrl+C.
6
+ *
7
+ * Browser lifetime is delegated to `createBrowserLifecycle` — see the
8
+ * invariant documented there: only one Chrome process may ever hold the
9
+ * profile directory at a time. That's what let the login step below run
10
+ * "for free" here without any direct knowledge of when browsers open or close.
11
+ */
12
+ export declare function runGuidedFlow(): Promise<void>;
@@ -0,0 +1,96 @@
1
+ import React from 'react';
2
+ import { render } from 'ink';
3
+ import { HttpFetcher } from '../fetch/http.js';
4
+ import { BrowserFetcher } from '../fetch/browser.js';
5
+ import { isChromeMarkedInstalled, installChromeViaCli, markChromeInstalled, probeChromeLaunchable } from '../fetch/chromeSetup.js';
6
+ import { openDb } from '../store/db.js';
7
+ import { syncClassroom } from '../sync.js';
8
+ import { chromeMarkerPath, dbPath, ensureRoot, isLoggedIn, login, profileDir } from '../auth/session.js';
9
+ import { listCourses } from '../discover/skool.js';
10
+ import { listUserCommunities } from '../discover/communities.js';
11
+ import { App } from './App.js';
12
+ import { createBrowserLifecycle } from './browserLifecycle.js';
13
+ import { ensureChromeReady } from './chromeSetup.js';
14
+ const OUT_ROOT = './out';
15
+ /**
16
+ * Runs the guided, no-arguments flow: session check, community picker,
17
+ * confirmation, live sync progress, and a result summary. Owns the db and
18
+ * the browser lifecycle for the whole flow and guarantees both are closed
19
+ * on every exit path — normal completion, an in-flow error, or Ctrl+C.
20
+ *
21
+ * Browser lifetime is delegated to `createBrowserLifecycle` — see the
22
+ * invariant documented there: only one Chrome process may ever hold the
23
+ * profile directory at a time. That's what let the login step below run
24
+ * "for free" here without any direct knowledge of when browsers open or close.
25
+ */
26
+ export async function runGuidedFlow() {
27
+ await ensureRoot();
28
+ // First-run browser setup: must complete before the session check below,
29
+ // and before the db/browser lifecycle are created — a failure here leaves
30
+ // nothing to clean up and is safe to retry on the next invocation.
31
+ await ensureChromeReady({
32
+ isConfirmedInstalled: () => isChromeMarkedInstalled(chromeMarkerPath()),
33
+ probeLaunchable: probeChromeLaunchable,
34
+ markInstalled: (executablePath) => markChromeInstalled(chromeMarkerPath(), executablePath),
35
+ installChrome: async (onProgress) => {
36
+ console.log('\nSetting up the browser skrape needs — this happens once, ~150MB.\n');
37
+ await installChromeViaCli(onProgress);
38
+ console.log('\nBrowser setup complete.\n');
39
+ const executablePath = await probeChromeLaunchable();
40
+ if (!executablePath) {
41
+ throw new Error('Chrome installed, but still could not be launched. This usually means a platform-specific ' +
42
+ 'dependency is missing — see the Playwright install output above for details.');
43
+ }
44
+ return executablePath;
45
+ },
46
+ }, (elapsedSeconds) => {
47
+ process.stdout.write(`\r installing… ${elapsedSeconds}s elapsed`);
48
+ });
49
+ const db = openDb(dbPath());
50
+ const lifecycle = createBrowserLifecycle({
51
+ http: new HttpFetcher(),
52
+ isLoggedIn,
53
+ makeBrowserFetcher: () => new BrowserFetcher(profileDir()),
54
+ performLogin: (onContext) => login(onContext),
55
+ });
56
+ let closed = false;
57
+ const cleanup = async () => {
58
+ if (closed)
59
+ return;
60
+ closed = true;
61
+ try {
62
+ await lifecycle.cleanup();
63
+ }
64
+ catch (error) {
65
+ console.error(`\nWarning: failed to close browser cleanly: ${error.message}`);
66
+ }
67
+ finally {
68
+ db.close();
69
+ }
70
+ };
71
+ const onSigint = () => {
72
+ void cleanup().finally(() => process.exit(130));
73
+ };
74
+ process.on('SIGINT', onSigint);
75
+ const controllers = {
76
+ outRoot: OUT_ROOT,
77
+ checkLoggedIn: async () => lifecycle.checkLoggedIn(),
78
+ login: async () => {
79
+ await lifecycle.login();
80
+ },
81
+ discoverCommunities: async () => listUserCommunities(lifecycle.getFetcher()),
82
+ countAccessibleCourses: async (slug) => {
83
+ const courses = await listCourses(slug, lifecycle.getFetcher());
84
+ return courses.filter((course) => course.hasAccess).length;
85
+ },
86
+ runSync: async (slug, outDir, onProgress) => syncClassroom({ slug, outDir, db, fetcher: lifecycle.getFetcher(), concurrency: 4, onProgress }),
87
+ };
88
+ const { waitUntilExit } = render(React.createElement(App, { controllers }));
89
+ try {
90
+ await waitUntilExit();
91
+ }
92
+ finally {
93
+ process.off('SIGINT', onSigint);
94
+ await cleanup();
95
+ }
96
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Strips a pasted skool.com URL (or a path within one) down to the bare slug,
3
+ * and trims whitespace. Users are told to enter "the part after skool.com/",
4
+ * but pasting the whole URL is common enough to normalize rather than reject.
5
+ */
6
+ export declare function normalizeSlug(input: string): string;
7
+ export declare function isValidSlug(input: string): boolean;
@@ -0,0 +1,18 @@
1
+ const SLUG_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,98}[a-zA-Z0-9])?$/;
2
+ /**
3
+ * Strips a pasted skool.com URL (or a path within one) down to the bare slug,
4
+ * and trims whitespace. Users are told to enter "the part after skool.com/",
5
+ * but pasting the whole URL is common enough to normalize rather than reject.
6
+ */
7
+ export function normalizeSlug(input) {
8
+ let value = input.trim();
9
+ value = value.replace(/^https?:\/\//i, '');
10
+ value = value.replace(/^(www\.)?skool\.com\//i, '');
11
+ const slashIndex = value.indexOf('/');
12
+ if (slashIndex !== -1)
13
+ value = value.slice(0, slashIndex);
14
+ return value;
15
+ }
16
+ export function isValidSlug(input) {
17
+ return SLUG_RE.test(input);
18
+ }
@@ -0,0 +1,7 @@
1
+ import type { SyncSummary } from '../sync.js';
2
+ export declare function formatCountLines(summary: SyncSummary): string[];
3
+ export declare function formatProblemLines(summary: SyncSummary): string[];
4
+ /** True when nothing was transcribed at all — the case that must never be
5
+ * rendered as a cheerful, empty-looking summary. */
6
+ export declare function nothingTranscribed(summary: SyncSummary): boolean;
7
+ export declare function formatSummary(summary: SyncSummary, outDir: string): string[];
@@ -0,0 +1,29 @@
1
+ export function formatCountLines(summary) {
2
+ const lines = [];
3
+ for (const [outcome, count] of Object.entries(summary.counts)) {
4
+ if (count > 0)
5
+ lines.push(` ${outcome.padEnd(14)} ${count}`);
6
+ }
7
+ return lines;
8
+ }
9
+ export function formatProblemLines(summary) {
10
+ return summary.problems.map((problem) => ` [${problem.outcome}] ${problem.course} / ${problem.title}: ${problem.reason}`);
11
+ }
12
+ /** True when nothing was transcribed at all — the case that must never be
13
+ * rendered as a cheerful, empty-looking summary. */
14
+ export function nothingTranscribed(summary) {
15
+ return summary.counts.ok === 0 && summary.totalWords === 0;
16
+ }
17
+ export function formatSummary(summary, outDir) {
18
+ const lines = [];
19
+ if (nothingTranscribed(summary)) {
20
+ lines.push('Nothing was transcribed.', '');
21
+ }
22
+ lines.push('--- summary ---', ...formatCountLines(summary));
23
+ lines.push(` ${'words'.padEnd(14)} ${summary.totalWords.toLocaleString('en-US')}`);
24
+ if (summary.problems.length > 0) {
25
+ lines.push('', '--- not transcribed ---', ...formatProblemLines(summary));
26
+ }
27
+ lines.push('', `Output: ${outDir}`);
28
+ return lines;
29
+ }
@@ -0,0 +1,33 @@
1
+ export type ItemType = 'lesson' | 'call' | 'post';
2
+ export interface ContentItem {
3
+ nativeId: string;
4
+ type: ItemType;
5
+ title: string;
6
+ index: number;
7
+ course: string | null;
8
+ section: string | null;
9
+ url: string | null;
10
+ videoUrl: string | null;
11
+ durationMs: number;
12
+ hasAccess: boolean;
13
+ publishedAt: string | null;
14
+ bodyText: string | null;
15
+ }
16
+ export type TranscriptResult = {
17
+ status: 'ok';
18
+ text: string;
19
+ wordCount: number;
20
+ provider: string;
21
+ sourceUrl: string;
22
+ } | {
23
+ status: 'unavailable';
24
+ reason: string;
25
+ provider: string;
26
+ } | {
27
+ status: 'failed';
28
+ reason: string;
29
+ provider: string;
30
+ };
31
+ export interface Fetcher {
32
+ getPage(url: string): Promise<string>;
33
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@mogulmoretti/skrape",
3
+ "version": "0.1.0",
4
+ "description": "Read Skool course content instead of watching it — pulls the classroom of a community you belong to into clean, readable transcripts.",
5
+ "keywords": [
6
+ "skool",
7
+ "cli",
8
+ "transcript",
9
+ "video-to-text",
10
+ "loom",
11
+ "course"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/slyngg/skrape.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/slyngg/skrape/issues"
20
+ },
21
+ "homepage": "https://github.com/slyngg/skrape#readme",
22
+ "type": "module",
23
+ "bin": {
24
+ "skrape": "dist/cli.js"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "scripts": {
32
+ "build": "tsc && chmod +x dist/cli.js",
33
+ "prepublishOnly": "npm run build && npm test",
34
+ "test": "vitest run",
35
+ "typecheck": "tsc --noEmit"
36
+ },
37
+ "engines": {
38
+ "node": ">=22"
39
+ },
40
+ "dependencies": {
41
+ "better-sqlite3": "^11.5.0",
42
+ "commander": "^12.1.0",
43
+ "ink": "^7.1.1",
44
+ "react": "^19.2.8",
45
+ "zod": "^3.23.8"
46
+ },
47
+ "devDependencies": {
48
+ "@types/better-sqlite3": "^7.6.11",
49
+ "@types/node": "^22.9.0",
50
+ "@types/react": "^19.2.18",
51
+ "typescript": "^5.6.3",
52
+ "vitest": "^2.1.4"
53
+ },
54
+ "optionalDependencies": {
55
+ "playwright": "^1.48.2"
56
+ }
57
+ }