@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,54 @@
1
+ /**
2
+ * All the real, non-pure IO Playwright's Chrome install needs: checking a
3
+ * cheap on-disk marker, probing whether Chrome is actually launchable, and
4
+ * running the installer. Kept separate from `../tui/chromeSetup.ts`, which
5
+ * holds the decision logic and is unit-tested against fakes of the
6
+ * functions here — none of *this* file is meaningfully unit-testable
7
+ * (it launches a real browser / spawns a real child process / hits disk).
8
+ */
9
+ /**
10
+ * True when a past run confirmed Chrome installed *and* the executable path
11
+ * recorded then still exists now. The marker stores that resolved path
12
+ * (see `markChromeInstalled`) rather than a bare flag, so an uninstall,
13
+ * move, or OS package-manager removal after the marker was written is
14
+ * caught here — cheaply, via `existsSync`, never by launching a browser.
15
+ * A missing/unreadable/empty marker is just treated as "not installed".
16
+ */
17
+ export declare function isChromeMarkedInstalled(markerPath: string): boolean;
18
+ /** Records the confirmed Chrome executable's path, so later runs can skip
19
+ * the probe by cheaply checking that path still exists on disk. */
20
+ export declare function markChromeInstalled(markerPath: string, executablePath: string): Promise<void>;
21
+ /**
22
+ * Real "is Chrome actually launchable" probe: Playwright exposes no public
23
+ * API to ask "is the 'chrome' channel installed" without launching it —
24
+ * `chromium.executablePath()` takes no channel argument and always returns
25
+ * the bundled Chromium's default path regardless, whether or not anything
26
+ * is actually installed there. So the reliable check is a real launch.
27
+ *
28
+ * This launches a throwaway, headless Chrome via `browserType.launchServer()`
29
+ * (no persistent context, no profile directory at all) and closes it
30
+ * immediately — it can never touch the user's real login profile, and nothing
31
+ * survives the call either way. `launchServer()` (unlike `launch()`) exposes
32
+ * the spawned process via `.process()`, whose Node `spawnfile` is the actual
33
+ * resolved Chrome executable path Playwright launched — that's what gets
34
+ * returned so callers can persist a marker that verifies something real,
35
+ * rather than a bare boolean. Playwright itself is imported dynamically so
36
+ * a user who already has a confirmed marker (the common case after the first
37
+ * run) never pays Playwright's module-load cost at all.
38
+ */
39
+ export declare function probeChromeLaunchable(): Promise<string | undefined>;
40
+ /**
41
+ * Runs the equivalent of `npx playwright install chrome` in-process, by
42
+ * spawning Playwright's own bundled CLI script directly. Preferred over
43
+ * `npx playwright install chrome` because the CLI path is resolved from
44
+ * node_modules up front (no npm package resolution / registry round trip
45
+ * just to find the command), and preferred over trying to call an in-process
46
+ * "install" function because Playwright doesn't export one from its public
47
+ * API surface — the installer lives inside its bundled, unexported CLI.
48
+ *
49
+ * `onProgress` fires roughly once a second with the elapsed seconds so
50
+ * callers can render a simple "still working…" indicator — Playwright's own
51
+ * download progress bars are written directly to the child's TTY and aren't
52
+ * easily piped through as structured progress.
53
+ */
54
+ export declare function installChromeViaCli(onProgress?: (elapsedSeconds: number) => void): Promise<void>;
@@ -0,0 +1,126 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { spawn } from 'node:child_process';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ /**
7
+ * All the real, non-pure IO Playwright's Chrome install needs: checking a
8
+ * cheap on-disk marker, probing whether Chrome is actually launchable, and
9
+ * running the installer. Kept separate from `../tui/chromeSetup.ts`, which
10
+ * holds the decision logic and is unit-tested against fakes of the
11
+ * functions here — none of *this* file is meaningfully unit-testable
12
+ * (it launches a real browser / spawns a real child process / hits disk).
13
+ */
14
+ /**
15
+ * True when a past run confirmed Chrome installed *and* the executable path
16
+ * recorded then still exists now. The marker stores that resolved path
17
+ * (see `markChromeInstalled`) rather than a bare flag, so an uninstall,
18
+ * move, or OS package-manager removal after the marker was written is
19
+ * caught here — cheaply, via `existsSync`, never by launching a browser.
20
+ * A missing/unreadable/empty marker is just treated as "not installed".
21
+ */
22
+ export function isChromeMarkedInstalled(markerPath) {
23
+ if (!existsSync(markerPath))
24
+ return false;
25
+ let storedPath;
26
+ try {
27
+ storedPath = readFileSync(markerPath, 'utf8').trim();
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ return storedPath.length > 0 && existsSync(storedPath);
33
+ }
34
+ /** Records the confirmed Chrome executable's path, so later runs can skip
35
+ * the probe by cheaply checking that path still exists on disk. */
36
+ export async function markChromeInstalled(markerPath, executablePath) {
37
+ await writeFile(markerPath, executablePath, 'utf8');
38
+ }
39
+ /**
40
+ * Real "is Chrome actually launchable" probe: Playwright exposes no public
41
+ * API to ask "is the 'chrome' channel installed" without launching it —
42
+ * `chromium.executablePath()` takes no channel argument and always returns
43
+ * the bundled Chromium's default path regardless, whether or not anything
44
+ * is actually installed there. So the reliable check is a real launch.
45
+ *
46
+ * This launches a throwaway, headless Chrome via `browserType.launchServer()`
47
+ * (no persistent context, no profile directory at all) and closes it
48
+ * immediately — it can never touch the user's real login profile, and nothing
49
+ * survives the call either way. `launchServer()` (unlike `launch()`) exposes
50
+ * the spawned process via `.process()`, whose Node `spawnfile` is the actual
51
+ * resolved Chrome executable path Playwright launched — that's what gets
52
+ * returned so callers can persist a marker that verifies something real,
53
+ * rather than a bare boolean. Playwright itself is imported dynamically so
54
+ * a user who already has a confirmed marker (the common case after the first
55
+ * run) never pays Playwright's module-load cost at all.
56
+ */
57
+ export async function probeChromeLaunchable() {
58
+ try {
59
+ const { chromium } = await import('playwright');
60
+ const server = await chromium.launchServer({ channel: 'chrome', headless: true });
61
+ try {
62
+ return server.process().spawnfile;
63
+ }
64
+ finally {
65
+ await server.close();
66
+ }
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
72
+ /** Resolves the on-disk path to Playwright's own bundled CLI script, via the
73
+ * package's exported `./package.json` subpath (not `./cli.js`, which isn't
74
+ * in Playwright's `exports` map and so can't be resolved directly) — this
75
+ * needs no network or npm resolution, unlike shelling out to `npx`. */
76
+ async function resolvePlaywrightCliPath() {
77
+ const pkgJsonUrl = import.meta.resolve('playwright/package.json');
78
+ return join(dirname(fileURLToPath(pkgJsonUrl)), 'cli.js');
79
+ }
80
+ /**
81
+ * Runs the equivalent of `npx playwright install chrome` in-process, by
82
+ * spawning Playwright's own bundled CLI script directly. Preferred over
83
+ * `npx playwright install chrome` because the CLI path is resolved from
84
+ * node_modules up front (no npm package resolution / registry round trip
85
+ * just to find the command), and preferred over trying to call an in-process
86
+ * "install" function because Playwright doesn't export one from its public
87
+ * API surface — the installer lives inside its bundled, unexported CLI.
88
+ *
89
+ * `onProgress` fires roughly once a second with the elapsed seconds so
90
+ * callers can render a simple "still working…" indicator — Playwright's own
91
+ * download progress bars are written directly to the child's TTY and aren't
92
+ * easily piped through as structured progress.
93
+ */
94
+ export async function installChromeViaCli(onProgress) {
95
+ const cliPath = await resolvePlaywrightCliPath();
96
+ await new Promise((resolve, reject) => {
97
+ const child = spawn(process.execPath, [cliPath, 'install', 'chrome'], {
98
+ stdio: ['ignore', 'pipe', 'pipe'],
99
+ });
100
+ let stdout = '';
101
+ let stderr = '';
102
+ child.stdout?.on('data', (chunk) => {
103
+ stdout += chunk.toString();
104
+ });
105
+ child.stderr?.on('data', (chunk) => {
106
+ stderr += chunk.toString();
107
+ });
108
+ const startedAt = Date.now();
109
+ const interval = setInterval(() => {
110
+ onProgress?.(Math.round((Date.now() - startedAt) / 1000));
111
+ }, 1000);
112
+ child.on('error', (error) => {
113
+ clearInterval(interval);
114
+ reject(error);
115
+ });
116
+ child.on('exit', (code) => {
117
+ clearInterval(interval);
118
+ if (code === 0) {
119
+ resolve();
120
+ return;
121
+ }
122
+ const detail = (stderr || stdout).trim().split('\n').slice(-20).join('\n');
123
+ reject(new Error(`\`playwright install chrome\` exited with code ${code}${detail ? `:\n${detail}` : ''}`));
124
+ });
125
+ });
126
+ }
@@ -0,0 +1,17 @@
1
+ import type { Fetcher } from '../types.js';
2
+ export interface HttpFetcherOptions {
3
+ cookie?: string;
4
+ retries?: number;
5
+ }
6
+ export declare class HttpFetcher implements Fetcher {
7
+ private readonly options;
8
+ constructor(options?: HttpFetcherOptions);
9
+ getPage(url: string): Promise<string>;
10
+ /**
11
+ * Follows redirects manually, one hop at a time, so the cookie decision is
12
+ * re-evaluated against the CURRENT hop's hostname rather than the original
13
+ * URL. This prevents a skool.com -> off-domain redirect from carrying the
14
+ * session cookie to a non-Skool host.
15
+ */
16
+ private fetchFollowingRedirects;
17
+ }
@@ -0,0 +1,64 @@
1
+ const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
2
+ '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
3
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
+ const MAX_REDIRECTS = 5;
5
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
6
+ export class HttpFetcher {
7
+ options;
8
+ constructor(options = {}) {
9
+ this.options = options;
10
+ }
11
+ async getPage(url) {
12
+ const retries = this.options.retries ?? 3;
13
+ let lastError = new Error('no attempt made');
14
+ for (let attempt = 0; attempt < retries; attempt++) {
15
+ try {
16
+ const response = await this.fetchFollowingRedirects(url);
17
+ if (response.status === 429) {
18
+ const retryAfter = Number(response.headers.get('retry-after') ?? 0);
19
+ await sleep(retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 1000);
20
+ lastError = new Error('rate limited (429)');
21
+ continue;
22
+ }
23
+ if (!response.ok)
24
+ throw new Error(`HTTP ${response.status} for ${url}`);
25
+ return await response.text();
26
+ }
27
+ catch (error) {
28
+ lastError = error;
29
+ if (attempt < retries - 1)
30
+ await sleep(2 ** attempt * 500);
31
+ }
32
+ }
33
+ throw lastError;
34
+ }
35
+ /**
36
+ * Follows redirects manually, one hop at a time, so the cookie decision is
37
+ * re-evaluated against the CURRENT hop's hostname rather than the original
38
+ * URL. This prevents a skool.com -> off-domain redirect from carrying the
39
+ * session cookie to a non-Skool host.
40
+ */
41
+ async fetchFollowingRedirects(initialUrl) {
42
+ let currentUrl = initialUrl;
43
+ for (let hop = 0; hop < MAX_REDIRECTS; hop++) {
44
+ const headers = { 'user-agent': UA, accept: 'text/html,*/*' };
45
+ // Only send the Skool session to Skool. Loom must stay unauthenticated.
46
+ // Recomputed on every hop: see comment above.
47
+ const { hostname } = new URL(currentUrl);
48
+ const isSkool = hostname === 'skool.com' || hostname.endsWith('.skool.com');
49
+ if (this.options.cookie && isSkool) {
50
+ headers.cookie = this.options.cookie;
51
+ }
52
+ const response = await fetch(currentUrl, { headers, redirect: 'manual' });
53
+ if (!REDIRECT_STATUSES.has(response.status)) {
54
+ return response;
55
+ }
56
+ const location = response.headers.get('location');
57
+ if (!location) {
58
+ throw new Error(`Redirect response (${response.status}) missing Location header for ${currentUrl}`);
59
+ }
60
+ currentUrl = new URL(location, currentUrl).toString();
61
+ }
62
+ throw new Error(`Too many redirects: exceeded limit of ${MAX_REDIRECTS} hops for ${initialUrl}`);
63
+ }
64
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Signals that a page did not yield a parseable payload — the trigger for
3
+ * escalating a route from HTTP to a real browser. Distinct from network errors,
4
+ * which must NOT trigger escalation.
5
+ */
6
+ export declare class PayloadParseError extends Error {
7
+ constructor(message: string);
8
+ }
9
+ export declare function extractNextData(html: string): unknown;
@@ -0,0 +1,23 @@
1
+ const NEXT_DATA_RE = /<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/;
2
+ /**
3
+ * Signals that a page did not yield a parseable payload — the trigger for
4
+ * escalating a route from HTTP to a real browser. Distinct from network errors,
5
+ * which must NOT trigger escalation.
6
+ */
7
+ export class PayloadParseError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = 'PayloadParseError';
11
+ }
12
+ }
13
+ export function extractNextData(html) {
14
+ const match = html.match(NEXT_DATA_RE);
15
+ if (!match)
16
+ throw new PayloadParseError('no __NEXT_DATA__ script tag (session expired or markup changed?)');
17
+ try {
18
+ return JSON.parse(match[1]);
19
+ }
20
+ catch (error) {
21
+ throw new PayloadParseError(`malformed __NEXT_DATA__ JSON: ${error.message}`);
22
+ }
23
+ }
@@ -0,0 +1,12 @@
1
+ import type { Fetcher } from '../types.js';
2
+ export declare class ResilientFetcher implements Fetcher {
3
+ private readonly http;
4
+ private readonly makeBrowser;
5
+ private readonly isAuthenticated?;
6
+ private readonly escalated;
7
+ private browserPromise;
8
+ private browser;
9
+ constructor(http: Fetcher, makeBrowser: () => Promise<Fetcher>, isAuthenticated?: ((html: string) => boolean) | undefined);
10
+ get escalatedRoutes(): ReadonlySet<string>;
11
+ getPage(url: string): Promise<string>;
12
+ }
@@ -0,0 +1,68 @@
1
+ import { extractNextData, PayloadParseError } from './nextdata.js';
2
+ /** Group urls by hostname and first two path segments to distinguish different hosts. */
3
+ function routeKey(url) {
4
+ try {
5
+ const parsed = new URL(url);
6
+ const pathPart = parsed.pathname.split('/').slice(0, 3).join('/');
7
+ return `${parsed.hostname}${pathPart}`;
8
+ }
9
+ catch {
10
+ return url;
11
+ }
12
+ }
13
+ function needsPayload(url) {
14
+ try {
15
+ return new URL(url).hostname.endsWith('skool.com');
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ export class ResilientFetcher {
22
+ http;
23
+ makeBrowser;
24
+ isAuthenticated;
25
+ escalated = new Set();
26
+ browserPromise = null;
27
+ browser = null;
28
+ constructor(http, makeBrowser, isAuthenticated) {
29
+ this.http = http;
30
+ this.makeBrowser = makeBrowser;
31
+ this.isAuthenticated = isAuthenticated;
32
+ }
33
+ get escalatedRoutes() {
34
+ return this.escalated;
35
+ }
36
+ async getPage(url) {
37
+ const route = routeKey(url);
38
+ if (!this.escalated.has(route)) {
39
+ const html = await this.http.getPage(url); // network errors propagate, no escalation
40
+ if (!needsPayload(url))
41
+ return html;
42
+ try {
43
+ extractNextData(html);
44
+ // The payload parsed structurally, but a logged-out (or redirected) Skool page can
45
+ // still ship a valid __NEXT_DATA__ blob — Next.js renders one either way. A semantic
46
+ // check catches that case and escalates to the authenticated browser too.
47
+ if (!this.isAuthenticated || this.isAuthenticated(html))
48
+ return html;
49
+ this.escalated.add(route);
50
+ }
51
+ catch (error) {
52
+ if (!(error instanceof PayloadParseError))
53
+ throw error;
54
+ this.escalated.add(route);
55
+ }
56
+ }
57
+ try {
58
+ this.browserPromise ??= this.makeBrowser();
59
+ this.browser = await this.browserPromise;
60
+ }
61
+ catch (error) {
62
+ // Clear the promise memo on rejection to allow retries
63
+ this.browserPromise = null;
64
+ throw error;
65
+ }
66
+ return this.browser.getPage(url);
67
+ }
68
+ }
@@ -0,0 +1,4 @@
1
+ import { loomTranscript } from './loom.js';
2
+ import type { Fetcher, TranscriptResult } from '../types.js';
3
+ export declare function getTranscript(videoUrl: string, fetcher: Fetcher): Promise<TranscriptResult>;
4
+ export { loomTranscript };
@@ -0,0 +1,11 @@
1
+ import { loomTranscript } from './loom.js';
2
+ /** Host substring -> provider. Add YouTube/Whisper here without touching callers. */
3
+ const PROVIDERS = [['loom.com', loomTranscript]];
4
+ export async function getTranscript(videoUrl, fetcher) {
5
+ const entry = PROVIDERS.find(([host]) => videoUrl.includes(host));
6
+ if (!entry) {
7
+ return { status: 'unavailable', reason: `unsupported video host: ${videoUrl}`, provider: 'none' };
8
+ }
9
+ return entry[1](videoUrl, fetcher);
10
+ }
11
+ export { loomTranscript };
@@ -0,0 +1,5 @@
1
+ import type { Fetcher, TranscriptResult } from '../types.js';
2
+ export declare function loomIdFromUrl(url: string): string | null;
3
+ export declare function extractCaptionsUrl(html: string): string | null;
4
+ export declare function extractTranscriptionStatus(html: string): string | null;
5
+ export declare function loomTranscript(videoUrl: string, fetcher: Fetcher): Promise<TranscriptResult>;
@@ -0,0 +1,60 @@
1
+ import { parseVtt } from '../normalize/vtt.js';
2
+ const CAPTIONS_RE = /https:\/\/cdn\.loom\.com\/mediametadata\/captions\/[^"'\\\s]+(?:\\u0026[^"'\\\s]+)*/;
3
+ const STATUS_RE = /transcription_status\\?"\s*:\s*\\?"([a-z_]+)/;
4
+ const MIN_WORDS = 50;
5
+ export function loomIdFromUrl(url) {
6
+ if (!url.includes('loom.com'))
7
+ return null;
8
+ const match = url.match(/loom\.com\/share\/([A-Za-z0-9]+)/);
9
+ return match ? match[1] : null;
10
+ }
11
+ export function extractCaptionsUrl(html) {
12
+ const match = html.match(CAPTIONS_RE);
13
+ if (!match)
14
+ return null;
15
+ return match[0].replace(/\\u0026/g, '&').replace(/&amp;/g, '&');
16
+ }
17
+ export function extractTranscriptionStatus(html) {
18
+ const match = html.match(STATUS_RE);
19
+ return match ? match[1] : null;
20
+ }
21
+ export async function loomTranscript(videoUrl, fetcher) {
22
+ const id = loomIdFromUrl(videoUrl);
23
+ if (!id)
24
+ return { status: 'unavailable', reason: `not a loom share url: ${videoUrl}`, provider: 'loom' };
25
+ const shareUrl = `https://www.loom.com/share/${id}`;
26
+ let html;
27
+ try {
28
+ html = await fetcher.getPage(shareUrl);
29
+ }
30
+ catch (error) {
31
+ return { status: 'failed', reason: `share page: ${error.message}`, provider: 'loom' };
32
+ }
33
+ const captionsUrl = extractCaptionsUrl(html);
34
+ if (!captionsUrl) {
35
+ const status = extractTranscriptionStatus(html) ?? 'unknown';
36
+ return { status: 'unavailable', reason: `no captions (transcription_status=${status})`, provider: 'loom' };
37
+ }
38
+ let vtt;
39
+ try {
40
+ vtt = await fetcher.getPage(captionsUrl);
41
+ }
42
+ catch (error) {
43
+ return { status: 'failed', reason: `captions: ${error.message}`, provider: 'loom' };
44
+ }
45
+ const parsed = parseVtt(vtt);
46
+ if (parsed.wordCount < MIN_WORDS) {
47
+ return {
48
+ status: 'unavailable',
49
+ reason: `transcript too short to trust (${parsed.wordCount} words)`,
50
+ provider: 'loom',
51
+ };
52
+ }
53
+ return {
54
+ status: 'ok',
55
+ text: parsed.text,
56
+ wordCount: parsed.wordCount,
57
+ provider: 'loom',
58
+ sourceUrl: shareUrl,
59
+ };
60
+ }
@@ -0,0 +1,12 @@
1
+ export interface VttCue {
2
+ start: string | null;
3
+ text: string;
4
+ }
5
+ export interface ParsedTranscript {
6
+ text: string;
7
+ wordCount: number;
8
+ cueCount: number;
9
+ }
10
+ /** Split a VTT body into cues, dropping structural lines and consecutive duplicates. */
11
+ export declare function parseCues(vtt: string): VttCue[];
12
+ export declare function parseVtt(vtt: string): ParsedTranscript;
@@ -0,0 +1,109 @@
1
+ const PARAGRAPH_MIN_CHARS = 450;
2
+ /** Split a VTT body into cues, dropping structural lines and consecutive duplicates. */
3
+ export function parseCues(vtt) {
4
+ const cues = [];
5
+ let currentStart = null;
6
+ let inNoteBlock = false;
7
+ let currentCueLines = [];
8
+ const flushCue = () => {
9
+ if (currentCueLines.length === 0)
10
+ return;
11
+ const text = currentCueLines.join(' ');
12
+ // Rolling captions repeat the previous cue verbatim. Only consecutive
13
+ // repeats are dropped — a speaker genuinely repeating a line later is kept.
14
+ if (cues.length > 0 && cues[cues.length - 1].text === text) {
15
+ currentCueLines = [];
16
+ currentStart = null;
17
+ return;
18
+ }
19
+ cues.push({ start: currentStart, text });
20
+ currentCueLines = [];
21
+ currentStart = null;
22
+ };
23
+ for (const raw of vtt.split(/\r?\n/)) {
24
+ const line = raw.trim();
25
+ // Blank lines end NOTE blocks and cue blocks
26
+ if (!line) {
27
+ inNoteBlock = false;
28
+ flushCue();
29
+ continue;
30
+ }
31
+ // Skip WEBVTT header
32
+ if (line === 'WEBVTT')
33
+ continue;
34
+ // Skip cue numbers (pure digits)
35
+ if (/^\d+$/.test(line))
36
+ continue;
37
+ // Enter NOTE block (lines starting with NOTE are skipped until blank line)
38
+ if (line.startsWith('NOTE')) {
39
+ inNoteBlock = true;
40
+ continue;
41
+ }
42
+ // Skip lines inside NOTE blocks
43
+ if (inNoteBlock)
44
+ continue;
45
+ // Handle timestamp lines
46
+ if (line.includes('-->')) {
47
+ flushCue();
48
+ currentStart = line.split('-->')[0].trim();
49
+ continue;
50
+ }
51
+ // Strip speaker tags and collect cue text (accumulate multi-line cues)
52
+ const text = line.replace(/<[^>]+>/g, '').trim();
53
+ if (!text)
54
+ continue;
55
+ currentCueLines.push(text);
56
+ }
57
+ // Flush any remaining cue lines
58
+ flushCue();
59
+ return cues;
60
+ }
61
+ /** "01:02:03.456" -> "[62:03]". Hours fold into minutes so anchors never wrap. */
62
+ function anchor(ts) {
63
+ if (!ts)
64
+ return '';
65
+ const parts = ts.split(':');
66
+ if (parts.length === 2) {
67
+ // MM:SS.mmm format (no hours)
68
+ const minutes = Number(parts[0]);
69
+ const seconds = parts[1].split('.')[0];
70
+ if (!Number.isFinite(minutes))
71
+ return '';
72
+ return `[${minutes}:${seconds}] `;
73
+ }
74
+ if (parts.length === 3) {
75
+ // HH:MM:SS.mmm format
76
+ const hours = Number(parts[0]);
77
+ const minutes = Number(parts[1]);
78
+ const seconds = parts[2].split('.')[0];
79
+ if (!Number.isFinite(hours) || !Number.isFinite(minutes))
80
+ return '';
81
+ return `[${hours * 60 + minutes}:${seconds}] `;
82
+ }
83
+ return '';
84
+ }
85
+ export function parseVtt(vtt) {
86
+ const cues = parseCues(vtt);
87
+ const paragraphs = [];
88
+ let buffer = [];
89
+ let bufferStart = null;
90
+ let wordCount = 0;
91
+ const flush = () => {
92
+ if (buffer.length === 0)
93
+ return;
94
+ paragraphs.push(anchor(bufferStart) + buffer.join(' '));
95
+ buffer = [];
96
+ bufferStart = null;
97
+ };
98
+ for (const cue of cues) {
99
+ if (buffer.length === 0)
100
+ bufferStart = cue.start;
101
+ buffer.push(cue.text);
102
+ wordCount += cue.text.split(/\s+/).filter(Boolean).length;
103
+ const joined = buffer.join(' ');
104
+ if (joined.length > PARAGRAPH_MIN_CHARS && /[.!?]$/.test(cue.text))
105
+ flush();
106
+ }
107
+ flush();
108
+ return { text: paragraphs.join('\n\n'), wordCount, cueCount: cues.length };
109
+ }
@@ -0,0 +1,15 @@
1
+ import type { ContentItem, TranscriptResult } from '../types.js';
2
+ export declare function contentHash(item: ContentItem): string;
3
+ export interface Db {
4
+ upsertCommunity(slug: string, name: string): void;
5
+ upsertItem(community: string, item: ContentItem): {
6
+ id: string;
7
+ changed: boolean;
8
+ };
9
+ saveTranscript(itemId: string, result: TranscriptResult): void;
10
+ getTranscriptStatus(itemId: string): string | null;
11
+ getTranscriptReason(itemId: string): string | null;
12
+ markSynced(slug: string): void;
13
+ close(): void;
14
+ }
15
+ export declare function openDb(path: string): Db;