@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.
- package/LICENSE +32 -0
- package/README.md +117 -0
- package/dist/auth/session.d.ts +26 -0
- package/dist/auth/session.js +70 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +123 -0
- package/dist/discover/communities.d.ts +22 -0
- package/dist/discover/communities.js +92 -0
- package/dist/discover/skool.d.ts +17 -0
- package/dist/discover/skool.js +91 -0
- package/dist/fetch/browser.d.ts +16 -0
- package/dist/fetch/browser.js +58 -0
- package/dist/fetch/chromeSetup.d.ts +54 -0
- package/dist/fetch/chromeSetup.js +126 -0
- package/dist/fetch/http.d.ts +17 -0
- package/dist/fetch/http.js +64 -0
- package/dist/fetch/nextdata.d.ts +9 -0
- package/dist/fetch/nextdata.js +23 -0
- package/dist/fetch/resilient.d.ts +12 -0
- package/dist/fetch/resilient.js +68 -0
- package/dist/media/index.d.ts +4 -0
- package/dist/media/index.js +11 -0
- package/dist/media/loom.d.ts +5 -0
- package/dist/media/loom.js +60 -0
- package/dist/normalize/vtt.d.ts +12 -0
- package/dist/normalize/vtt.js +109 -0
- package/dist/store/db.d.ts +15 -0
- package/dist/store/db.js +125 -0
- package/dist/store/markdown.d.ts +7 -0
- package/dist/store/markdown.js +41 -0
- package/dist/sync.d.ts +30 -0
- package/dist/sync.js +178 -0
- package/dist/tui/App.d.ts +22 -0
- package/dist/tui/App.js +285 -0
- package/dist/tui/SelectList.d.ts +14 -0
- package/dist/tui/SelectList.js +25 -0
- package/dist/tui/browserLifecycle.d.ts +71 -0
- package/dist/tui/browserLifecycle.js +114 -0
- package/dist/tui/chromeSetup.d.ts +50 -0
- package/dist/tui/chromeSetup.js +52 -0
- package/dist/tui/flow.d.ts +53 -0
- package/dist/tui/flow.js +30 -0
- package/dist/tui/progress.d.ts +21 -0
- package/dist/tui/progress.js +31 -0
- package/dist/tui/reveal.d.ts +17 -0
- package/dist/tui/reveal.js +47 -0
- package/dist/tui/run.d.ts +12 -0
- package/dist/tui/run.js +96 -0
- package/dist/tui/slug.d.ts +7 -0
- package/dist/tui/slug.js +18 -0
- package/dist/tui/summary.d.ts +7 -0
- package/dist/tui/summary.js +29 -0
- package/dist/types.d.ts +33 -0
- package/dist/types.js +1 -0
- 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
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
|
+
}
|