@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
|
@@ -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,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(/&/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;
|