@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
package/LICENSE ADDED
@@ -0,0 +1,32 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 the skrape contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to
7
+ deal in the Software without restriction, including without limitation the
8
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
9
+ sell copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21
+ IN THE SOFTWARE.
22
+
23
+ ---
24
+
25
+ Additionally, and without limiting the above: this software interacts with
26
+ third-party platforms (including Skool and Loom) on the user's behalf, using
27
+ the user's own authenticated session. It is the user's sole responsibility
28
+ to ensure their use complies with those platforms' terms of service and any
29
+ applicable law. The authors and copyright holders are not responsible for,
30
+ and disclaim all liability arising from, any account action, access
31
+ restriction, or other consequence a user experiences as a result of using
32
+ this software.
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # skrape
2
+
3
+ Read Skool course content instead of watching it.
4
+
5
+ `skrape` pulls the classroom of a Skool community **you already pay for and
6
+ belong to**, and writes clean, readable, timestamped transcripts to disk —
7
+ so you can read a lesson in two minutes instead of watching a 20-minute video.
8
+
9
+ It works because Skool ships the whole course tree as structured data inside
10
+ each page, and course videos are almost always hosted on Loom, which publishes
11
+ caption tracks. No audio download, no speech-to-text, no scraping of anything
12
+ that isn't already sitting on the page you're allowed to see.
13
+
14
+ ## Before you use this
15
+
16
+ **This is a personal tool for your own communities.** It signs in as *you*,
17
+ using *your* real browser session, and only ever touches courses your account
18
+ already has access to — it can't and won't bypass Skool's own paywall or
19
+ membership checks.
20
+
21
+ Automating access to a platform like this generally sits outside most
22
+ platforms' terms of service, even when you're a legitimate paying member and
23
+ even when nothing is being redistributed. Running this is a decision about
24
+ your own account, and it's yours to make — this tool doesn't make it for you,
25
+ and it doesn't hide what it's doing (see [How it works](#how-it-works)).
26
+ Don't use it on communities you don't belong to, don't share or republish
27
+ what it produces, and don't run it in a way designed to look like normal
28
+ browsing if you wouldn't be comfortable explaining what it does.
29
+
30
+ If a course creator you follow would rather you didn't do this, respect that.
31
+
32
+ ## Install
33
+
34
+ npm install -g @mogulmoretti/skrape
35
+ skrape
36
+
37
+ That's the whole setup. First run installs the browser skrape needs (a
38
+ one-time, ~150MB download) if it isn't already on your machine, then walks
39
+ you through everything:
40
+
41
+ 1. Checks whether you're signed in, and if not, opens a real Chrome window for
42
+ you to sign in yourself — nothing is typed on your behalf, and your
43
+ password never passes through this tool.
44
+ 2. Looks up the communities your account belongs to and lets you pick one
45
+ with the arrow keys (an "enter a slug manually" option is always there
46
+ too, in case discovery doesn't find one).
47
+ 3. Shows you what's about to happen — the community, how many courses were
48
+ found, where files will land — and waits for you to confirm before any
49
+ work starts.
50
+ 4. Runs the sync with a live progress bar. You can Ctrl+C at any point; it
51
+ cleans up after itself.
52
+ 5. Prints a summary: outcome counts, total words, the output path, and a
53
+ readable list of anything that wasn't transcribed and why — nothing is
54
+ silently dropped from the count.
55
+
56
+ From there you can sync another community, re-sync the same one (it's
57
+ incremental — already-fetched lessons are skipped), open the output folder,
58
+ or quit.
59
+
60
+ Output lands in `./out/<slug>/transcripts/<course>/NN-lesson.md`.
61
+
62
+ ### Scripting / advanced use
63
+
64
+ The underlying subcommands work directly too, without the guided flow:
65
+
66
+ skrape login # once — sign in by hand, session persists
67
+ skrape sync <slug> # slug is the part after skool.com/
68
+ skrape sync <slug> -o ./out -c 4 # custom output dir / concurrency
69
+
70
+ ### Building from source
71
+
72
+ git clone <this repo>
73
+ cd skrape
74
+ npm install
75
+ npm run build
76
+ npm link # or: npm install -g .
77
+
78
+ (Then `node dist/cli.js <command>` also works if you haven't `npm link`ed it.)
79
+
80
+ ## How it works
81
+
82
+ Skool ships the full course tree as JSON inside each classroom page — no
83
+ clicking through lesson by lesson. `skrape` reads that structure the way any
84
+ browser or crawler would: an authenticated request to a page your account
85
+ can already open.
86
+
87
+ Course videos are checked against known caption-providing hosts (Loom, at the
88
+ moment). Where a caption track exists, it's downloaded and cleaned into
89
+ readable prose with timestamp anchors. Where one doesn't, the lesson is
90
+ reported as not transcribed rather than silently skipped.
91
+
92
+ When a page doesn't come back with the data the tool expects — an expired
93
+ session, or Skool changing how a page is built — it falls back to driving an
94
+ actual browser window using your already-authenticated profile, rather than
95
+ guessing or failing silently.
96
+
97
+ Nothing about this hides its footprint: requests carry a normal browser user
98
+ agent, run at a modest, bounded concurrency, and only ever happen when you
99
+ run the command.
100
+
101
+ ## What this doesn't do
102
+
103
+ - Doesn't work on communities you're not a member of — there's no bypass for
104
+ Skool's own access checks, because none of the data is reachable without
105
+ them.
106
+ - Doesn't redistribute or publish anything. Output is written to your local
107
+ disk for you to read.
108
+ - Doesn't store your password. Sign-in happens in a real Chrome window that
109
+ only you interact with.
110
+ - Doesn't transcribe video hosts it doesn't recognize. Those lessons show up
111
+ in the summary as not transcribed, with a reason.
112
+
113
+ ## License
114
+
115
+ MIT — see [LICENSE](LICENSE). In short: do what you like with the code, no
116
+ warranty, use it at your own risk and your own judgment about the platforms
117
+ you point it at.
@@ -0,0 +1,26 @@
1
+ export declare function profileDir(): string;
2
+ export declare function dbPath(): string;
3
+ /**
4
+ * Marker file written once the one-time Chrome/Playwright browser install
5
+ * has been confirmed (either found already installed, or installed by us).
6
+ * Its presence lets later runs skip re-probing with a real browser launch.
7
+ */
8
+ export declare function chromeMarkerPath(): string;
9
+ export declare function ensureRoot(): Promise<void>;
10
+ export declare function isLoggedIn(html: string): boolean;
11
+ /** A handle callers can use to force-close a launched login context, e.g. on Ctrl+C. */
12
+ export interface LoginContextHandle {
13
+ close(): Promise<void>;
14
+ }
15
+ /**
16
+ * Opens a real, headed Chrome against a persistent profile and waits for the
17
+ * user to sign in by hand. Nothing is typed on their behalf and no credentials
18
+ * are read or stored — the session simply persists in the profile directory
19
+ * for later runs.
20
+ *
21
+ * `onContext`, if given, is invoked with a close() handle as soon as the
22
+ * context launches — callers that need to guarantee the profile lock is
23
+ * released on interruption (e.g. Ctrl+C mid-login) should register it there
24
+ * rather than only closing it at the end of this function.
25
+ */
26
+ export declare function login(onContext?: (context: LoginContextHandle) => void): Promise<void>;
@@ -0,0 +1,70 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { mkdir } from 'node:fs/promises';
4
+ import { extractNextData, PayloadParseError } from '../fetch/nextdata.js';
5
+ const ROOT = join(homedir(), '.skool-skrape');
6
+ export function profileDir() {
7
+ return join(ROOT, 'chrome-profile');
8
+ }
9
+ export function dbPath() {
10
+ return join(ROOT, 'skool.db');
11
+ }
12
+ /**
13
+ * Marker file written once the one-time Chrome/Playwright browser install
14
+ * has been confirmed (either found already installed, or installed by us).
15
+ * Its presence lets later runs skip re-probing with a real browser launch.
16
+ */
17
+ export function chromeMarkerPath() {
18
+ return join(ROOT, 'chrome-installed');
19
+ }
20
+ export async function ensureRoot() {
21
+ await mkdir(ROOT, { recursive: true });
22
+ }
23
+ export function isLoggedIn(html) {
24
+ try {
25
+ const payload = extractNextData(html);
26
+ return Boolean(payload?.props?.pageProps?.self);
27
+ }
28
+ catch (error) {
29
+ if (error instanceof PayloadParseError)
30
+ return false;
31
+ throw error;
32
+ }
33
+ }
34
+ /**
35
+ * Opens a real, headed Chrome against a persistent profile and waits for the
36
+ * user to sign in by hand. Nothing is typed on their behalf and no credentials
37
+ * are read or stored — the session simply persists in the profile directory
38
+ * for later runs.
39
+ *
40
+ * `onContext`, if given, is invoked with a close() handle as soon as the
41
+ * context launches — callers that need to guarantee the profile lock is
42
+ * released on interruption (e.g. Ctrl+C mid-login) should register it there
43
+ * rather than only closing it at the end of this function.
44
+ */
45
+ export async function login(onContext) {
46
+ await ensureRoot();
47
+ const { chromium } = await import('playwright');
48
+ const context = await chromium.launchPersistentContext(profileDir(), {
49
+ channel: 'chrome',
50
+ headless: false,
51
+ });
52
+ onContext?.(context);
53
+ const page = context.pages()[0] ?? (await context.newPage());
54
+ await page.goto('https://www.skool.com/login', { waitUntil: 'domcontentloaded' });
55
+ console.log('\nA browser window is open. Sign in to Skool there.');
56
+ console.log('Waiting for you to reach a logged-in page (Ctrl+C to cancel)...\n');
57
+ await page.waitForFunction(() => {
58
+ const el = document.getElementById('__NEXT_DATA__');
59
+ if (!el?.textContent)
60
+ return false;
61
+ try {
62
+ return Boolean(JSON.parse(el.textContent)?.props?.pageProps?.self);
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }, undefined, { timeout: 0 });
68
+ console.log('Signed in. Session saved — future runs will not need this.');
69
+ await context.close();
70
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { HttpFetcher } from './fetch/http.js';
4
+ import { BrowserFetcher } from './fetch/browser.js';
5
+ import { ResilientFetcher } from './fetch/resilient.js';
6
+ import { openDb } from './store/db.js';
7
+ import { syncClassroom } from './sync.js';
8
+ import { login, profileDir, dbPath, ensureRoot, isLoggedIn } from './auth/session.js';
9
+ const MARKS = {
10
+ ok: '+', skipped: '=', 'no-video': '.', 'no-access': '-', unavailable: '!', failed: 'x',
11
+ };
12
+ const MAX_CONCURRENCY = 16;
13
+ /** Parse the --concurrency flag to a positive integer, or return null if invalid. */
14
+ function parseConcurrency(raw) {
15
+ const n = Number(raw);
16
+ if (!Number.isInteger(n) || n < 1)
17
+ return null;
18
+ return Math.min(n, MAX_CONCURRENCY);
19
+ }
20
+ const program = new Command();
21
+ program.name('skrape').description('Read Skool content instead of watching it');
22
+ program
23
+ .command('login')
24
+ .description('Sign in to Skool once; the session is reused by later runs')
25
+ .action(async () => {
26
+ await login();
27
+ });
28
+ program
29
+ .command('sync')
30
+ .argument('<slug>', 'community slug, e.g. demo from skool.com/demo')
31
+ .option('-o, --out <dir>', 'output directory', './out')
32
+ .option('-c, --concurrency <n>', 'parallel requests', '4')
33
+ .description('Pull a community classroom to disk as transcripts')
34
+ .action(async (slug, options) => {
35
+ const concurrency = parseConcurrency(options.concurrency);
36
+ if (concurrency === null) {
37
+ console.error(`\nInvalid --concurrency value: "${options.concurrency}". ` +
38
+ `Must be a positive integer (capped at ${MAX_CONCURRENCY}).`);
39
+ process.exitCode = 1;
40
+ return;
41
+ }
42
+ await ensureRoot();
43
+ const outDir = `${options.out}/${slug}`;
44
+ const db = openDb(dbPath());
45
+ const browser = new BrowserFetcher(profileDir());
46
+ const fetcher = new ResilientFetcher(new HttpFetcher(), async () => browser, isLoggedIn);
47
+ try {
48
+ const summary = await syncClassroom({
49
+ slug,
50
+ outDir,
51
+ db,
52
+ fetcher,
53
+ concurrency,
54
+ onProgress: (event) => {
55
+ const mark = MARKS[event.outcome] ?? '?';
56
+ console.log(`${mark} [${event.done}/${event.total}] ${event.course.slice(0, 28).padEnd(28)} ${event.title.slice(0, 50)}`);
57
+ },
58
+ });
59
+ console.log('\n--- summary ---');
60
+ for (const [outcome, count] of Object.entries(summary.counts)) {
61
+ if (count > 0)
62
+ console.log(` ${outcome.padEnd(14)} ${count}`);
63
+ }
64
+ console.log(` ${'words'.padEnd(14)} ${summary.totalWords.toLocaleString('en-US')}`);
65
+ if (fetcher.escalatedRoutes.size > 0) {
66
+ console.log(`\n escalated to browser: ${[...fetcher.escalatedRoutes].join(', ')}`);
67
+ }
68
+ if (summary.problems.length > 0) {
69
+ console.log('\n--- not transcribed ---');
70
+ for (const problem of summary.problems) {
71
+ console.log(` [${problem.outcome}] ${problem.course} / ${problem.title}: ${problem.reason}`);
72
+ }
73
+ }
74
+ console.log(`\nOutput: ${outDir}`);
75
+ }
76
+ catch (error) {
77
+ console.error(`\nSync failed: ${error.message}`);
78
+ if (fetcher.escalatedRoutes.size > 0) {
79
+ console.error(`\n escalated to browser: ${[...fetcher.escalatedRoutes].join(', ')}`);
80
+ console.error('If this mentions an unexpected payload, the authenticated browser path was already tried ' +
81
+ "for the route(s) above, so Skool's page structure most likely changed rather than the " +
82
+ 'session being expired. A fresh `skool login` is still worth trying, but treat it as a ' +
83
+ 'secondary guess.');
84
+ }
85
+ else {
86
+ console.error('If this mentions an unexpected payload, your session may have expired — run: skool login. ' +
87
+ "It is also possible Skool's page structure changed; if a retry after login fails the same " +
88
+ 'way, that is more likely.');
89
+ }
90
+ process.exitCode = 1;
91
+ }
92
+ finally {
93
+ try {
94
+ await browser.close();
95
+ }
96
+ catch (closeError) {
97
+ // Cosmetic only — do not overwrite a real sync error or change the exit code.
98
+ console.error(`\nWarning: failed to close browser cleanly: ${closeError.message}`);
99
+ }
100
+ finally {
101
+ db.close();
102
+ }
103
+ }
104
+ });
105
+ /**
106
+ * With no subcommand, launch the guided Ink flow instead of requiring the user
107
+ * to already know a subcommand and a community slug. `skrape login` and
108
+ * `skrape sync <slug>` keep working unchanged as scriptable escape hatches.
109
+ * The TUI module is dynamically imported so plain subcommand invocations
110
+ * never pay for loading ink/react.
111
+ */
112
+ async function main() {
113
+ if (process.argv.length <= 2) {
114
+ const { runGuidedFlow } = await import('./tui/run.js');
115
+ await runGuidedFlow();
116
+ return;
117
+ }
118
+ program.parse();
119
+ }
120
+ main().catch((error) => {
121
+ console.error(error instanceof Error ? error.message : String(error));
122
+ process.exitCode = 1;
123
+ });
@@ -0,0 +1,22 @@
1
+ import type { Fetcher } from '../types.js';
2
+ export interface CommunityRef {
3
+ slug: string;
4
+ name: string;
5
+ }
6
+ /**
7
+ * Best-effort scan of the __NEXT_DATA__ payload's `self` object for the signed-in
8
+ * user's community memberships. Deliberately shape-tolerant rather than hardcoding
9
+ * one exact path: it walks `self` (bounded depth) looking for the first array whose
10
+ * every element matches a "group embed" shape (see asCommunityRef above). Returns
11
+ * null — never a guessed or partial list — when nothing matches, so callers know to
12
+ * fall back to manual slug entry instead of showing a fabricated list.
13
+ */
14
+ export declare function extractUserGroups(payload: unknown, maxDepth?: number): CommunityRef[] | null;
15
+ /**
16
+ * Fetches skool.com/ through the caller's authenticated fetcher stack and looks
17
+ * for the signed-in user's communities. Returns null (not an empty array) when
18
+ * the page didn't yield a parseable payload or no group-shaped list was found —
19
+ * both are treated the same way by callers: fall back to manual slug entry.
20
+ * A genuine network failure still propagates, distinct from a shape mismatch.
21
+ */
22
+ export declare function listUserCommunities(fetcher: Fetcher): Promise<CommunityRef[] | null>;
@@ -0,0 +1,92 @@
1
+ import { extractNextData, PayloadParseError } from '../fetch/nextdata.js';
2
+ function readSelf(payload) {
3
+ if (!payload || typeof payload !== 'object')
4
+ return undefined;
5
+ const props = payload['props'];
6
+ if (!props || typeof props !== 'object')
7
+ return undefined;
8
+ const pageProps = props['pageProps'];
9
+ if (!pageProps || typeof pageProps !== 'object')
10
+ return undefined;
11
+ return pageProps['self'];
12
+ }
13
+ /**
14
+ * Skool's public, logged-out discovery feed (skool.com/) embeds each community as
15
+ * `{ group: { name, metadata: { displayName } } }`, where `name` is the URL slug —
16
+ * confirmed by fetching that page live. Whether the authenticated `self` object
17
+ * reuses this exact shape for the signed-in user's own memberships could not be
18
+ * verified without real credentials, so this accepts either that wrapped shape or
19
+ * an unwrapped `{ name, metadata: { displayName } }` object.
20
+ */
21
+ function asCommunityRef(candidate) {
22
+ if (!candidate || typeof candidate !== 'object')
23
+ return null;
24
+ const record = candidate;
25
+ const groupField = record['group'];
26
+ const inner = groupField && typeof groupField === 'object'
27
+ ? groupField
28
+ : record;
29
+ const slug = inner['name'];
30
+ if (typeof slug !== 'string' || slug.length === 0)
31
+ return null;
32
+ const metadata = inner['metadata'];
33
+ const displayName = metadata && typeof metadata === 'object'
34
+ ? metadata['displayName']
35
+ : undefined;
36
+ const name = typeof displayName === 'string' && displayName.length > 0 ? displayName : slug;
37
+ return { slug, name };
38
+ }
39
+ /**
40
+ * Best-effort scan of the __NEXT_DATA__ payload's `self` object for the signed-in
41
+ * user's community memberships. Deliberately shape-tolerant rather than hardcoding
42
+ * one exact path: it walks `self` (bounded depth) looking for the first array whose
43
+ * every element matches a "group embed" shape (see asCommunityRef above). Returns
44
+ * null — never a guessed or partial list — when nothing matches, so callers know to
45
+ * fall back to manual slug entry instead of showing a fabricated list.
46
+ */
47
+ export function extractUserGroups(payload, maxDepth = 4) {
48
+ const self = readSelf(payload);
49
+ if (!self || typeof self !== 'object')
50
+ return null;
51
+ const visit = (node, depth) => {
52
+ if (depth > maxDepth || node === null || typeof node !== 'object')
53
+ return null;
54
+ if (Array.isArray(node) && node.length > 0) {
55
+ const refs = node.map(asCommunityRef);
56
+ if (refs.every((ref) => ref !== null)) {
57
+ const seen = new Set();
58
+ return refs.filter((ref) => {
59
+ if (seen.has(ref.slug))
60
+ return false;
61
+ seen.add(ref.slug);
62
+ return true;
63
+ });
64
+ }
65
+ }
66
+ for (const value of Object.values(node)) {
67
+ const found = visit(value, depth + 1);
68
+ if (found)
69
+ return found;
70
+ }
71
+ return null;
72
+ };
73
+ return visit(self, 0);
74
+ }
75
+ /**
76
+ * Fetches skool.com/ through the caller's authenticated fetcher stack and looks
77
+ * for the signed-in user's communities. Returns null (not an empty array) when
78
+ * the page didn't yield a parseable payload or no group-shaped list was found —
79
+ * both are treated the same way by callers: fall back to manual slug entry.
80
+ * A genuine network failure still propagates, distinct from a shape mismatch.
81
+ */
82
+ export async function listUserCommunities(fetcher) {
83
+ const html = await fetcher.getPage('https://www.skool.com/');
84
+ try {
85
+ return extractUserGroups(extractNextData(html));
86
+ }
87
+ catch (error) {
88
+ if (error instanceof PayloadParseError)
89
+ return null;
90
+ throw error;
91
+ }
92
+ }
@@ -0,0 +1,17 @@
1
+ import type { ContentItem, Fetcher } from '../types.js';
2
+ export interface CourseRef {
3
+ id: string;
4
+ title: string;
5
+ hasAccess: boolean;
6
+ }
7
+ export declare function parseCourseList(payload: unknown): CourseRef[];
8
+ export interface Skipped {
9
+ reason: string;
10
+ }
11
+ export interface CourseTreeResult {
12
+ items: ContentItem[];
13
+ skipped: Skipped[];
14
+ }
15
+ export declare function parseCourseTree(payload: unknown, courseTitle: string): CourseTreeResult;
16
+ export declare function listCourses(slug: string, fetcher: Fetcher): Promise<CourseRef[]>;
17
+ export declare function listLessons(slug: string, course: CourseRef, fetcher: Fetcher): Promise<CourseTreeResult>;
@@ -0,0 +1,91 @@
1
+ import { z } from 'zod';
2
+ import { extractNextData, PayloadParseError } from '../fetch/nextdata.js';
3
+ const MetadataSchema = z.object({
4
+ title: z.string().optional().catch(undefined),
5
+ hasAccess: z.number().optional().catch(undefined),
6
+ videoLink: z.string().optional().catch(undefined),
7
+ videoLenMs: z.number().optional().catch(undefined),
8
+ });
9
+ const CourseListSchema = z.object({
10
+ props: z.object({
11
+ pageProps: z.object({
12
+ allCourses: z.array(z.object({ id: z.string(), metadata: MetadataSchema })),
13
+ }),
14
+ }),
15
+ });
16
+ const TreeSchema = z.object({
17
+ props: z.object({
18
+ pageProps: z.object({
19
+ course: z.object({ children: z.array(z.unknown()).optional() }),
20
+ }),
21
+ }),
22
+ });
23
+ export function parseCourseList(payload) {
24
+ const parsed = CourseListSchema.safeParse(payload);
25
+ if (!parsed.success)
26
+ throw new PayloadParseError(`unexpected classroom payload: ${parsed.error.message}`);
27
+ return parsed.data.props.pageProps.allCourses.map((course) => ({
28
+ id: course.id,
29
+ title: course.metadata.title ?? '(untitled course)',
30
+ // Fail-safe: courses without explicit access flag default to no access, since we must never
31
+ // probe content the user may not be entitled to. Inside a course (see parseCourseTree),
32
+ // lessons default to accessible because they're already within an accessible parent.
33
+ hasAccess: (course.metadata.hasAccess ?? 0) > 0,
34
+ }));
35
+ }
36
+ export function parseCourseTree(payload, courseTitle) {
37
+ const parsed = TreeSchema.safeParse(payload);
38
+ if (!parsed.success)
39
+ throw new PayloadParseError(`unexpected course payload: ${parsed.error.message}`);
40
+ const items = [];
41
+ const skipped = [];
42
+ let index = 0;
43
+ const walk = (nodes, section) => {
44
+ for (const node of nodes ?? []) {
45
+ // Guard against null or non-object elements that may appear as tombstones/placeholders
46
+ // in real Skool data. These are visibly accounted for as skipped, not silently dropped.
47
+ if (node === null || typeof node !== 'object') {
48
+ skipped.push({ reason: 'node skipped: null or non-object node' });
49
+ continue;
50
+ }
51
+ const metadata = MetadataSchema.safeParse(node.course?.metadata);
52
+ const meta = metadata.success ? metadata.data : {};
53
+ const title = meta.title;
54
+ if (title) {
55
+ items.push({
56
+ nativeId: node.course?.id ?? `${courseTitle}:${index}`,
57
+ type: 'lesson',
58
+ title,
59
+ index: index++,
60
+ course: courseTitle,
61
+ section,
62
+ url: null,
63
+ videoUrl: meta.videoLink ?? null,
64
+ durationMs: meta.videoLenMs ?? 0,
65
+ // Fail-safe: lessons without explicit access flag default to accessible, since we're already
66
+ // inside a course the user has access to. Top-level courses default to no access (see parseCourseList).
67
+ hasAccess: (meta.hasAccess ?? 1) > 0,
68
+ publishedAt: null,
69
+ bodyText: null,
70
+ });
71
+ }
72
+ else {
73
+ // A missing or non-string title (easy to hit, since each field's .catch(undefined) maps a
74
+ // bad type to undefined) must not make the node vanish uncounted — record it as skipped so
75
+ // the acceptance-critical lesson count still balances.
76
+ skipped.push({ reason: 'node skipped: no usable title' });
77
+ }
78
+ walk(node.children, title ?? section);
79
+ }
80
+ };
81
+ walk(parsed.data.props.pageProps.course.children, null);
82
+ return { items, skipped };
83
+ }
84
+ export async function listCourses(slug, fetcher) {
85
+ const html = await fetcher.getPage(`https://www.skool.com/${slug}/classroom`);
86
+ return parseCourseList(extractNextData(html));
87
+ }
88
+ export async function listLessons(slug, course, fetcher) {
89
+ const html = await fetcher.getPage(`https://www.skool.com/${slug}/classroom/${course.id}`);
90
+ return parseCourseTree(extractNextData(html), course.title);
91
+ }
@@ -0,0 +1,16 @@
1
+ import type { Fetcher } from '../types.js';
2
+ /**
3
+ * Playwright-backed fallback. Playwright is imported dynamically so that users
4
+ * who never hit the fallback never pay its startup cost — and so the package
5
+ * still works if the optional dependency is absent.
6
+ */
7
+ export declare class BrowserFetcher implements Fetcher {
8
+ private readonly profileDir;
9
+ private context;
10
+ private contextPromise;
11
+ private closed;
12
+ constructor(profileDir: string);
13
+ private ensureContext;
14
+ getPage(url: string): Promise<string>;
15
+ close(): Promise<void>;
16
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Playwright-backed fallback. Playwright is imported dynamically so that users
3
+ * who never hit the fallback never pay its startup cost — and so the package
4
+ * still works if the optional dependency is absent.
5
+ */
6
+ export class BrowserFetcher {
7
+ profileDir;
8
+ context = null;
9
+ contextPromise = null;
10
+ closed = false;
11
+ constructor(profileDir) {
12
+ this.profileDir = profileDir;
13
+ }
14
+ async ensureContext() {
15
+ if (this.closed) {
16
+ throw new Error('BrowserFetcher is closed — cannot open a new context after close()');
17
+ }
18
+ if (!this.context) {
19
+ try {
20
+ this.contextPromise ??= (async () => {
21
+ const { chromium } = await import('playwright');
22
+ return await chromium.launchPersistentContext(this.profileDir, {
23
+ channel: 'chrome',
24
+ headless: true,
25
+ });
26
+ })();
27
+ this.context = await this.contextPromise;
28
+ }
29
+ catch (error) {
30
+ // Clear the promise memo on rejection to allow retries
31
+ this.contextPromise = null;
32
+ throw error;
33
+ }
34
+ }
35
+ return this.context;
36
+ }
37
+ async getPage(url) {
38
+ const context = await this.ensureContext();
39
+ const page = await context.newPage();
40
+ try {
41
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 });
42
+ return await page.content();
43
+ }
44
+ finally {
45
+ await page.close();
46
+ }
47
+ }
48
+ async close() {
49
+ this.closed = true;
50
+ if (this.context) {
51
+ await this.context.close();
52
+ }
53
+ // Clear both context and its promise memo — no fresh launch is possible after close()
54
+ // since ensureContext() now rejects once `closed` is set.
55
+ this.context = null;
56
+ this.contextPromise = null;
57
+ }
58
+ }