@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,125 @@
1
+ import Database from 'better-sqlite3';
2
+ import { createHash } from 'node:crypto';
3
+ import { dirname } from 'node:path';
4
+ import { mkdirSync } from 'node:fs';
5
+ const SCHEMA = `
6
+ CREATE TABLE IF NOT EXISTS communities (
7
+ slug TEXT PRIMARY KEY,
8
+ name TEXT,
9
+ last_synced_at TEXT
10
+ );
11
+ CREATE TABLE IF NOT EXISTS items (
12
+ id TEXT PRIMARY KEY,
13
+ community TEXT NOT NULL,
14
+ type TEXT NOT NULL CHECK (type IN ('lesson','call','post')),
15
+ native_id TEXT NOT NULL,
16
+ section TEXT,
17
+ course TEXT,
18
+ position INTEGER NOT NULL DEFAULT 0,
19
+ title TEXT NOT NULL,
20
+ url TEXT,
21
+ video_url TEXT,
22
+ duration_ms INTEGER NOT NULL DEFAULT 0,
23
+ has_access INTEGER NOT NULL DEFAULT 1,
24
+ published_at TEXT,
25
+ body_text TEXT,
26
+ content_hash TEXT NOT NULL,
27
+ updated_at TEXT NOT NULL
28
+ );
29
+ CREATE INDEX IF NOT EXISTS items_community_type ON items(community, type);
30
+ CREATE TABLE IF NOT EXISTS transcripts (
31
+ item_id TEXT PRIMARY KEY,
32
+ provider TEXT NOT NULL,
33
+ status TEXT NOT NULL CHECK (status IN ('ok','unavailable','failed')),
34
+ reason TEXT,
35
+ text TEXT,
36
+ word_count INTEGER,
37
+ source_url TEXT,
38
+ fetched_at TEXT NOT NULL,
39
+ FOREIGN KEY (item_id) REFERENCES items(id)
40
+ );
41
+ `;
42
+ export function contentHash(item) {
43
+ // Use \0 as separator to prevent field boundary collisions.
44
+ // Nullable fields are collapsed to '' to ensure stable hashing.
45
+ const salient = [
46
+ item.nativeId, item.title, item.section ?? '', item.course ?? '',
47
+ item.videoUrl ?? '', String(item.durationMs), item.bodyText ?? '',
48
+ String(item.hasAccess),
49
+ ].join('\0');
50
+ return createHash('sha256').update(salient).digest('hex');
51
+ }
52
+ export function openDb(path) {
53
+ // Create parent directories for file-based databases (not :memory:)
54
+ if (path !== ':memory:') {
55
+ const dir = dirname(path);
56
+ if (dir && dir !== '.') {
57
+ mkdirSync(dir, { recursive: true });
58
+ }
59
+ }
60
+ const db = new Database(path);
61
+ db.pragma('journal_mode = WAL');
62
+ db.pragma('foreign_keys = ON');
63
+ db.exec(SCHEMA);
64
+ return {
65
+ upsertCommunity(slug, name) {
66
+ db.prepare(`INSERT INTO communities (slug, name) VALUES (?, ?)
67
+ ON CONFLICT(slug) DO UPDATE SET name = excluded.name`).run(slug, name);
68
+ },
69
+ upsertItem(community, item) {
70
+ const id = `${community}:${item.type}:${item.nativeId}`;
71
+ const hash = contentHash(item);
72
+ const existing = db.prepare('SELECT content_hash FROM items WHERE id = ?').get(id);
73
+ db.prepare(`INSERT INTO items (id, community, type, native_id, section, course, position, title,
74
+ url, video_url, duration_ms, has_access, published_at, body_text,
75
+ content_hash, updated_at)
76
+ VALUES (@id, @community, @type, @nativeId, @section, @course, @position, @title,
77
+ @url, @videoUrl, @durationMs, @hasAccess, @publishedAt, @bodyText,
78
+ @hash, @updatedAt)
79
+ ON CONFLICT(id) DO UPDATE SET
80
+ section = excluded.section, course = excluded.course, position = excluded.position,
81
+ title = excluded.title, url = excluded.url, video_url = excluded.video_url,
82
+ duration_ms = excluded.duration_ms, has_access = excluded.has_access,
83
+ published_at = excluded.published_at, body_text = excluded.body_text,
84
+ content_hash = excluded.content_hash, updated_at = excluded.updated_at`).run({
85
+ id, community, type: item.type, nativeId: item.nativeId,
86
+ section: item.section, course: item.course, position: item.index,
87
+ title: item.title, url: item.url, videoUrl: item.videoUrl,
88
+ durationMs: item.durationMs, hasAccess: item.hasAccess ? 1 : 0,
89
+ publishedAt: item.publishedAt, bodyText: item.bodyText,
90
+ hash, updatedAt: new Date().toISOString(),
91
+ });
92
+ return { id, changed: existing?.content_hash !== hash };
93
+ },
94
+ saveTranscript(itemId, result) {
95
+ db.prepare(`INSERT INTO transcripts (item_id, provider, status, reason, text, word_count, source_url, fetched_at)
96
+ VALUES (@itemId, @provider, @status, @reason, @text, @wordCount, @sourceUrl, @fetchedAt)
97
+ ON CONFLICT(item_id) DO UPDATE SET
98
+ provider = excluded.provider, status = excluded.status, reason = excluded.reason,
99
+ text = excluded.text, word_count = excluded.word_count,
100
+ source_url = excluded.source_url, fetched_at = excluded.fetched_at`).run({
101
+ itemId,
102
+ provider: result.provider,
103
+ status: result.status,
104
+ reason: result.status === 'ok' ? null : result.reason,
105
+ text: result.status === 'ok' ? result.text : null,
106
+ wordCount: result.status === 'ok' ? result.wordCount : null,
107
+ sourceUrl: result.status === 'ok' ? result.sourceUrl : null,
108
+ fetchedAt: new Date().toISOString(),
109
+ });
110
+ },
111
+ getTranscriptStatus(itemId) {
112
+ const row = db.prepare('SELECT status FROM transcripts WHERE item_id = ?').get(itemId);
113
+ return row?.status ?? null;
114
+ },
115
+ getTranscriptReason(itemId) {
116
+ const row = db.prepare('SELECT reason FROM transcripts WHERE item_id = ?').get(itemId);
117
+ return row?.reason ?? null;
118
+ },
119
+ markSynced(slug) {
120
+ db.prepare('UPDATE communities SET last_synced_at = ? WHERE slug = ?')
121
+ .run(new Date().toISOString(), slug);
122
+ },
123
+ close() { db.close(); },
124
+ };
125
+ }
@@ -0,0 +1,7 @@
1
+ import type { ContentItem } from '../types.js';
2
+ export declare function slugify(text: string, maxLength?: number): string;
3
+ export declare function transcriptPath(outDir: string, item: ContentItem, padWidth?: number): string;
4
+ export declare function writeTranscript(outDir: string, item: ContentItem, body: string, meta: {
5
+ wordCount: number;
6
+ sourceUrl: string;
7
+ }, padWidth?: number): Promise<string>;
@@ -0,0 +1,41 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ export function slugify(text, maxLength = 60) {
4
+ const cleaned = text
5
+ .normalize('NFKD')
6
+ .replace(/[^\p{L}\p{N}\s-]/gu, '')
7
+ .trim()
8
+ .toLowerCase()
9
+ .replace(/[\s_-]+/g, '-')
10
+ .replace(/^-+|-+$/g, '')
11
+ .slice(0, maxLength)
12
+ .replace(/-+$/g, '');
13
+ return cleaned || 'untitled';
14
+ }
15
+ export function transcriptPath(outDir, item, padWidth = 2) {
16
+ const course = slugify(item.course ?? 'uncategorized');
17
+ const width = Math.max(2, padWidth);
18
+ const name = `${String(item.index).padStart(width, '0')}-${slugify(item.title)}.md`;
19
+ return join(outDir, 'transcripts', course, name);
20
+ }
21
+ export async function writeTranscript(outDir, item, body, meta, padWidth = 2) {
22
+ const path = transcriptPath(outDir, item, padWidth);
23
+ await mkdir(dirname(path), { recursive: true });
24
+ const minutes = Math.round(item.durationMs / 60_000);
25
+ const cleanTitle = item.title.replace(/\s+/g, ' ');
26
+ const cleanCourse = (item.course ?? '—').replace(/\s+/g, ' ');
27
+ const cleanSection = item.section?.replace(/\s+/g, ' ');
28
+ const header = [
29
+ `# ${cleanTitle}`,
30
+ '',
31
+ `> Course: ${cleanCourse}` +
32
+ (cleanSection ? ` | Section: ${cleanSection}` : '') +
33
+ ` | ${minutes} min | ${meta.wordCount.toLocaleString('en-US')} words`,
34
+ `> Source: ${meta.sourceUrl}`,
35
+ '',
36
+ '---',
37
+ '',
38
+ ].join('\n');
39
+ await writeFile(path, `${header}${body}\n`, 'utf-8');
40
+ return path;
41
+ }
package/dist/sync.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { Db } from './store/db.js';
2
+ import type { Fetcher } from './types.js';
3
+ export type Outcome = 'ok' | 'skipped' | 'no-video' | 'no-access' | 'unavailable' | 'failed';
4
+ export interface Problem {
5
+ outcome: Outcome;
6
+ course: string;
7
+ title: string;
8
+ reason: string;
9
+ }
10
+ export interface ProgressEvent {
11
+ outcome: Outcome;
12
+ course: string;
13
+ title: string;
14
+ done: number;
15
+ total: number;
16
+ }
17
+ export interface SyncSummary {
18
+ counts: Record<Outcome, number>;
19
+ problems: Problem[];
20
+ totalWords: number;
21
+ }
22
+ export interface SyncOptions {
23
+ slug: string;
24
+ outDir: string;
25
+ db: Db;
26
+ fetcher: Fetcher;
27
+ concurrency?: number;
28
+ onProgress?: (event: ProgressEvent) => void;
29
+ }
30
+ export declare function syncClassroom(options: SyncOptions): Promise<SyncSummary>;
package/dist/sync.js ADDED
@@ -0,0 +1,178 @@
1
+ import { listCourses, listLessons } from './discover/skool.js';
2
+ import { getTranscript } from './media/index.js';
3
+ import { writeTranscript } from './store/markdown.js';
4
+ /** Run tasks with a bounded number in flight. */
5
+ async function pooled(tasks, limit) {
6
+ const results = new Array(tasks.length);
7
+ let cursor = 0;
8
+ // Clamp to at least 1 worker: a caller passing 0, a negative number, or NaN must never
9
+ // produce a zero-worker pool that silently processes nothing.
10
+ const safeLimit = Number.isFinite(limit) && limit >= 1 ? Math.floor(limit) : 1;
11
+ const workers = Array.from({ length: Math.min(safeLimit, tasks.length) }, async () => {
12
+ while (cursor < tasks.length) {
13
+ const index = cursor++;
14
+ results[index] = await tasks[index]();
15
+ }
16
+ });
17
+ await Promise.all(workers);
18
+ return results;
19
+ }
20
+ /** Extract a readable message from any thrown value, not just Error instances. */
21
+ function errorMessage(error) {
22
+ if (error instanceof Error)
23
+ return error.message;
24
+ if (typeof error === 'string')
25
+ return error;
26
+ try {
27
+ return JSON.stringify(error);
28
+ }
29
+ catch {
30
+ return String(error);
31
+ }
32
+ }
33
+ export async function syncClassroom(options) {
34
+ const { slug, outDir, db, fetcher, concurrency = 4, onProgress } = options;
35
+ const counts = {
36
+ ok: 0, skipped: 0, 'no-video': 0, 'no-access': 0, unavailable: 0, failed: 0,
37
+ };
38
+ const problems = [];
39
+ let totalWords = 0;
40
+ db.upsertCommunity(slug, slug);
41
+ const courses = await listCourses(slug, fetcher);
42
+ const accessible = courses.filter((course) => course.hasAccess);
43
+ for (const locked of courses.filter((course) => !course.hasAccess)) {
44
+ counts['no-access']++;
45
+ problems.push({
46
+ outcome: 'no-access', course: locked.title, title: '(entire course)',
47
+ reason: 'hasAccess is 0 — not entitled, skipped without probing',
48
+ });
49
+ }
50
+ // One course's listing failing (network blip, malformed payload) must not take down the whole
51
+ // sync. Each course's fetch is isolated in its own try/catch so Promise.all can never reject —
52
+ // a failure here is recorded as a course-level 'failed' problem and the rest of the run
53
+ // continues.
54
+ const courseResults = await Promise.all(accessible.map(async (course) => {
55
+ try {
56
+ const { items, skipped } = await listLessons(slug, course, fetcher);
57
+ return { course, items, skipped, error: null };
58
+ }
59
+ catch (error) {
60
+ return { course, items: [], skipped: [], error: errorMessage(error) };
61
+ }
62
+ }));
63
+ const lessonEntries = [];
64
+ for (const { course, items, skipped, error } of courseResults) {
65
+ if (error !== null) {
66
+ counts.failed++;
67
+ problems.push({
68
+ outcome: 'failed', course: course.title, title: '(entire course)',
69
+ reason: `could not list lessons: ${error}`,
70
+ });
71
+ continue;
72
+ }
73
+ // Deviation from brief (see task-10-brief.md DEVIATION note 1): writeTranscript now takes an
74
+ // optional padWidth so filename index prefixes sort lexicographically past 99 items. Compute
75
+ // a width per course, based on that course's own lesson count, and carry it alongside each
76
+ // lesson so it's never looked up by a course-title key that two courses could share.
77
+ const padWidth = Math.max(2, String(items.length).length);
78
+ for (const item of items)
79
+ lessonEntries.push({ item, padWidth });
80
+ // A node that parsed but had no usable title (or was a null/non-object tombstone) must not
81
+ // vanish uncounted — the acceptance criterion is a lesson count. Fold it into 'failed' rather
82
+ // than adding a new Outcome member, which would ripple through the CLI and summary.
83
+ for (const skip of skipped) {
84
+ counts.failed++;
85
+ problems.push({ outcome: 'failed', course: course.title, title: '(skipped node)', reason: skip.reason });
86
+ }
87
+ }
88
+ let done = 0;
89
+ const total = lessonEntries.length;
90
+ const record = (outcome, item, reason) => {
91
+ counts[outcome]++;
92
+ if (reason) {
93
+ problems.push({ outcome, course: item.course ?? '—', title: item.title, reason });
94
+ }
95
+ done++;
96
+ // Progress reporting is cosmetic. A caller's callback throwing must never fail the sync.
97
+ try {
98
+ onProgress?.({ outcome, course: item.course ?? '—', title: item.title, done, total });
99
+ }
100
+ catch {
101
+ // swallow — the caller's callback is not our concern
102
+ }
103
+ };
104
+ // A better-sqlite3 error from any of the three store calls below (disk full, locked db, FK
105
+ // violation) must become a per-lesson 'failed' outcome, not a run-ending rejection: a Promise.all
106
+ // over the worker pool would reject on the first such error while sibling workers are still in
107
+ // flight, and the CLI's finally-block browser.close() would then race a live worker into
108
+ // launching a brand-new Chrome context that nothing ever closes. Wrap every db call here.
109
+ const safeSaveTranscript = (id, result) => {
110
+ try {
111
+ db.saveTranscript(id, result);
112
+ return null;
113
+ }
114
+ catch (error) {
115
+ return `could not save transcript status: ${errorMessage(error)}`;
116
+ }
117
+ };
118
+ await pooled(lessonEntries.map(({ item, padWidth }) => async () => {
119
+ let id;
120
+ try {
121
+ ({ id } = db.upsertItem(slug, item));
122
+ }
123
+ catch (error) {
124
+ // No item id was ever created, so there's nothing further to key a transcript row to —
125
+ // still record the lesson so it isn't silently dropped from the counts.
126
+ return record('failed', item, `could not save item: ${errorMessage(error)}`);
127
+ }
128
+ if (!item.hasAccess)
129
+ return record('no-access', item, 'lesson locked');
130
+ if (!item.videoUrl)
131
+ return record('no-video', item, 'lesson has no video attached');
132
+ let status;
133
+ try {
134
+ status = db.getTranscriptStatus(id);
135
+ }
136
+ catch (error) {
137
+ return record('failed', item, `could not read transcript status: ${errorMessage(error)}`);
138
+ }
139
+ if (status === 'ok')
140
+ return record('skipped', item);
141
+ let result;
142
+ try {
143
+ result = await getTranscript(item.videoUrl, fetcher);
144
+ }
145
+ catch (error) {
146
+ // A provider should never throw, but one bad lesson must not end the run.
147
+ result = { status: 'failed', reason: errorMessage(error), provider: 'unknown' };
148
+ }
149
+ if (result.status === 'ok') {
150
+ // Write the file BEFORE committing the DB row. If the write fails (disk full, permission
151
+ // denied) and the DB already said 'ok', resumability would skip this lesson forever on
152
+ // every future run — a permanent silent data loss. Writing first means the only failure
153
+ // mode is a DB save failing after a successful write, which is harmless: the next run
154
+ // just redoes the lesson and overwrites an identical file.
155
+ try {
156
+ await writeTranscript(outDir, item, result.text, {
157
+ wordCount: result.wordCount, sourceUrl: result.sourceUrl,
158
+ }, padWidth);
159
+ }
160
+ catch (error) {
161
+ const reason = `could not write transcript file: ${errorMessage(error)}`;
162
+ safeSaveTranscript(id, { status: 'failed', reason, provider: result.provider });
163
+ return record('failed', item, reason);
164
+ }
165
+ const saveError = safeSaveTranscript(id, result);
166
+ if (saveError)
167
+ return record('failed', item, saveError);
168
+ totalWords += result.wordCount;
169
+ return record('ok', item);
170
+ }
171
+ const saveError = safeSaveTranscript(id, result);
172
+ if (saveError)
173
+ return record('failed', item, saveError);
174
+ return record(result.status, item, result.reason);
175
+ }), concurrency);
176
+ db.markSynced(slug);
177
+ return { counts, problems, totalWords };
178
+ }
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+ import type { CommunityRef } from '../discover/communities.js';
3
+ import type { ProgressEvent, SyncSummary } from '../sync.js';
4
+ export interface AppControllers {
5
+ /** Output root directory, e.g. './out' — the community slug is appended. */
6
+ outRoot: string;
7
+ checkLoggedIn: () => Promise<boolean>;
8
+ login: () => Promise<void>;
9
+ discoverCommunities: () => Promise<CommunityRef[] | null>;
10
+ countAccessibleCourses: (slug: string) => Promise<number>;
11
+ runSync: (slug: string, outDir: string, onProgress: (event: ProgressEvent) => void) => Promise<SyncSummary>;
12
+ }
13
+ /**
14
+ * Caps error text shown in the TUI. Some failures (e.g. a raw Playwright
15
+ * browser-launch error) can be a multi-hundred-line dump — that's useless in
16
+ * a terminal UI and pushes everything else off screen, so keep only the
17
+ * first few lines and a hard character cap.
18
+ */
19
+ export declare function truncateErrorText(message: string): string;
20
+ export declare function App({ controllers }: {
21
+ controllers: AppControllers;
22
+ }): React.JSX.Element;