feat: editorial review dashboard + elite-grade pilot batch (5 SKUs)

Ships the second dashboard surface — a Pattern Library + Preview Theatre — that
presents the 4-section PDP pilot batch back to Umar, compliance, and the board
in an editorial format. Adds the full data layer that drives it: 5 source-backed
per-SKU drafts at QA 100/100, 15 competitor PDP semantic extracts, PubMed
evidence packs, EFSA claims library extension, JV brand voice guide, hand-curated
product FAQs, and the Matrixify-ready CSV exports for Lewis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Omair Saleh
2026-06-02 18:20:17 +08:00
parent e470e113ee
commit 056c47581f
3302 changed files with 391779 additions and 854 deletions
@@ -0,0 +1,222 @@
[
{
"frame": "frame-000.png",
"seconds": 0,
"mmss": "00:00"
},
{
"frame": "frame-001.png",
"seconds": 3,
"mmss": "00:03"
},
{
"frame": "frame-002.png",
"seconds": 6,
"mmss": "00:06"
},
{
"frame": "frame-003.png",
"seconds": 9,
"mmss": "00:09"
},
{
"frame": "frame-004.png",
"seconds": 12,
"mmss": "00:12"
},
{
"frame": "frame-005.png",
"seconds": 15,
"mmss": "00:15"
},
{
"frame": "frame-006.png",
"seconds": 18,
"mmss": "00:18"
},
{
"frame": "frame-007.png",
"seconds": 21,
"mmss": "00:21"
},
{
"frame": "frame-008.png",
"seconds": 24,
"mmss": "00:24"
},
{
"frame": "frame-009.png",
"seconds": 27,
"mmss": "00:27"
},
{
"frame": "frame-010.png",
"seconds": 30,
"mmss": "00:30"
},
{
"frame": "frame-011.png",
"seconds": 33,
"mmss": "00:33"
},
{
"frame": "frame-012.png",
"seconds": 36,
"mmss": "00:36"
},
{
"frame": "frame-013.png",
"seconds": 39,
"mmss": "00:39"
},
{
"frame": "frame-014.png",
"seconds": 42,
"mmss": "00:42"
},
{
"frame": "frame-015.png",
"seconds": 45,
"mmss": "00:45"
},
{
"frame": "frame-016.png",
"seconds": 48,
"mmss": "00:48"
},
{
"frame": "frame-017.png",
"seconds": 51,
"mmss": "00:51"
},
{
"frame": "frame-018.png",
"seconds": 54,
"mmss": "00:54"
},
{
"frame": "frame-019.png",
"seconds": 57,
"mmss": "00:57"
},
{
"frame": "frame-020.png",
"seconds": 60,
"mmss": "01:00"
},
{
"frame": "frame-021.png",
"seconds": 63,
"mmss": "01:03"
},
{
"frame": "frame-022.png",
"seconds": 66,
"mmss": "01:06"
},
{
"frame": "frame-023.png",
"seconds": 69,
"mmss": "01:09"
},
{
"frame": "frame-024.png",
"seconds": 72,
"mmss": "01:12"
},
{
"frame": "frame-025.png",
"seconds": 75,
"mmss": "01:15"
},
{
"frame": "frame-026.png",
"seconds": 78,
"mmss": "01:18"
},
{
"frame": "frame-027.png",
"seconds": 81,
"mmss": "01:21"
},
{
"frame": "frame-028.png",
"seconds": 84,
"mmss": "01:24"
},
{
"frame": "frame-029.png",
"seconds": 87,
"mmss": "01:27"
},
{
"frame": "frame-030.png",
"seconds": 90,
"mmss": "01:30"
},
{
"frame": "frame-031.png",
"seconds": 93,
"mmss": "01:33"
},
{
"frame": "frame-032.png",
"seconds": 96,
"mmss": "01:36"
},
{
"frame": "frame-033.png",
"seconds": 99,
"mmss": "01:39"
},
{
"frame": "frame-034.png",
"seconds": 102,
"mmss": "01:42"
},
{
"frame": "frame-035.png",
"seconds": 105,
"mmss": "01:45"
},
{
"frame": "frame-036.png",
"seconds": 108,
"mmss": "01:48"
},
{
"frame": "frame-037.png",
"seconds": 111,
"mmss": "01:51"
},
{
"frame": "frame-038.png",
"seconds": 114,
"mmss": "01:54"
},
{
"frame": "frame-039.png",
"seconds": 117,
"mmss": "01:57"
},
{
"frame": "frame-040.png",
"seconds": 120,
"mmss": "02:00"
},
{
"frame": "frame-041.png",
"seconds": 123,
"mmss": "02:03"
},
{
"frame": "frame-042.png",
"seconds": 126,
"mmss": "02:06"
},
{
"frame": "frame-043.png",
"seconds": 129,
"mmss": "02:09"
}
]
@@ -0,0 +1 @@
{"phrases":[],"schemaVersion":"1.1.3"}
@@ -0,0 +1,272 @@
[
{
"frame": "frame-000.png",
"seconds": 0,
"mmss": "00:00"
},
{
"frame": "frame-001.png",
"seconds": 3,
"mmss": "00:03"
},
{
"frame": "frame-002.png",
"seconds": 6,
"mmss": "00:06"
},
{
"frame": "frame-003.png",
"seconds": 9,
"mmss": "00:09"
},
{
"frame": "frame-004.png",
"seconds": 12,
"mmss": "00:12"
},
{
"frame": "frame-005.png",
"seconds": 15,
"mmss": "00:15"
},
{
"frame": "frame-006.png",
"seconds": 18,
"mmss": "00:18"
},
{
"frame": "frame-007.png",
"seconds": 21,
"mmss": "00:21"
},
{
"frame": "frame-008.png",
"seconds": 24,
"mmss": "00:24"
},
{
"frame": "frame-009.png",
"seconds": 27,
"mmss": "00:27"
},
{
"frame": "frame-010.png",
"seconds": 30,
"mmss": "00:30"
},
{
"frame": "frame-011.png",
"seconds": 33,
"mmss": "00:33"
},
{
"frame": "frame-012.png",
"seconds": 36,
"mmss": "00:36"
},
{
"frame": "frame-013.png",
"seconds": 39,
"mmss": "00:39"
},
{
"frame": "frame-014.png",
"seconds": 42,
"mmss": "00:42"
},
{
"frame": "frame-015.png",
"seconds": 45,
"mmss": "00:45"
},
{
"frame": "frame-016.png",
"seconds": 48,
"mmss": "00:48"
},
{
"frame": "frame-017.png",
"seconds": 51,
"mmss": "00:51"
},
{
"frame": "frame-018.png",
"seconds": 54,
"mmss": "00:54"
},
{
"frame": "frame-019.png",
"seconds": 57,
"mmss": "00:57"
},
{
"frame": "frame-020.png",
"seconds": 60,
"mmss": "01:00"
},
{
"frame": "frame-021.png",
"seconds": 63,
"mmss": "01:03"
},
{
"frame": "frame-022.png",
"seconds": 66,
"mmss": "01:06"
},
{
"frame": "frame-023.png",
"seconds": 69,
"mmss": "01:09"
},
{
"frame": "frame-024.png",
"seconds": 72,
"mmss": "01:12"
},
{
"frame": "frame-025.png",
"seconds": 75,
"mmss": "01:15"
},
{
"frame": "frame-026.png",
"seconds": 78,
"mmss": "01:18"
},
{
"frame": "frame-027.png",
"seconds": 81,
"mmss": "01:21"
},
{
"frame": "frame-028.png",
"seconds": 84,
"mmss": "01:24"
},
{
"frame": "frame-029.png",
"seconds": 87,
"mmss": "01:27"
},
{
"frame": "frame-030.png",
"seconds": 90,
"mmss": "01:30"
},
{
"frame": "frame-031.png",
"seconds": 93,
"mmss": "01:33"
},
{
"frame": "frame-032.png",
"seconds": 96,
"mmss": "01:36"
},
{
"frame": "frame-033.png",
"seconds": 99,
"mmss": "01:39"
},
{
"frame": "frame-034.png",
"seconds": 102,
"mmss": "01:42"
},
{
"frame": "frame-035.png",
"seconds": 105,
"mmss": "01:45"
},
{
"frame": "frame-036.png",
"seconds": 108,
"mmss": "01:48"
},
{
"frame": "frame-037.png",
"seconds": 111,
"mmss": "01:51"
},
{
"frame": "frame-038.png",
"seconds": 114,
"mmss": "01:54"
},
{
"frame": "frame-039.png",
"seconds": 117,
"mmss": "01:57"
},
{
"frame": "frame-040.png",
"seconds": 120,
"mmss": "02:00"
},
{
"frame": "frame-041.png",
"seconds": 123,
"mmss": "02:03"
},
{
"frame": "frame-042.png",
"seconds": 126,
"mmss": "02:06"
},
{
"frame": "frame-043.png",
"seconds": 129,
"mmss": "02:09"
},
{
"frame": "frame-044.png",
"seconds": 132,
"mmss": "02:12"
},
{
"frame": "frame-045.png",
"seconds": 135,
"mmss": "02:15"
},
{
"frame": "frame-046.png",
"seconds": 138,
"mmss": "02:18"
},
{
"frame": "frame-047.png",
"seconds": 141,
"mmss": "02:21"
},
{
"frame": "frame-048.png",
"seconds": 144,
"mmss": "02:24"
},
{
"frame": "frame-049.png",
"seconds": 147,
"mmss": "02:27"
},
{
"frame": "frame-050.png",
"seconds": 150,
"mmss": "02:30"
},
{
"frame": "frame-051.png",
"seconds": 153,
"mmss": "02:33"
},
{
"frame": "frame-052.png",
"seconds": 156,
"mmss": "02:36"
},
{
"frame": "frame-053.png",
"seconds": 159,
"mmss": "02:39"
}
]
@@ -0,0 +1 @@
{"phrases":[],"schemaVersion":"1.1.3"}
+63
View File
@@ -0,0 +1,63 @@
# Loom Capture Tool
Bun/Node script that captures Loom video walkthroughs as frame sequences when no transcript is available. Built for the JV project where Umar shares fast cursor-pointing demos via Loom.
## Captures stored here
| Folder | Loom | Date | Duration | Frames | Notes |
|---|---|---|---|---|---|
| `1-june-pdp-walkthrough/` | https://www.loom.com/share/ce4501e9de834cfdae628c60fe9e41d3 | 1 Jun 2026 | 2:12 | 45 | Umar walks through the JV "High Strength Calcium Citrate" PDP showing empty Results + FAQ sections; ends on a competitor reference. The direct brief on what content needs populating. |
| `21-may-uiux-walkthrough/` | https://www.loom.com/share/55449ee0013148e7936344b83caf9ba8 | 21 May 2026 | 2:42 | 54 | Broader UI/UX walkthrough showing reference patterns from Wild Nutrition (Scientific Studies), Spacegoods (Timeline), Grüns + Club EarlyBird (Comparison Tables), JV's own Figma (Results, FAQs). |
Each folder contains:
- `frame-000.png``frame-NNN.png` — full-resolution frames at 3-second intervals
- `contact-sheet.jpg` — 6-col grid of all frames for fast scanning
- `manifest.json` — frame index → seconds + mm:ss
- `transcript.txt` — Loom transcript JSON (both videos are **empty** — Umar didn't enable auto-transcription, returns `{"phrases":[],"schemaVersion":"1.1.3"}`)
## Re-capture a Loom
```sh
cd tools/loom-capture
node capture.mjs <loom-share-or-embed-url> ./out-folder [intervalSeconds]
```
Examples:
```sh
node capture.mjs https://www.loom.com/share/<id> ./my-capture 3
node capture.mjs https://www.loom.com/share/<id> ./my-capture 5 # less granular, faster
```
Notes:
- Script auto-converts `/share/<id>``/embed/<id>?autoplay=true` so the **real video** loads instead of Loom's 4-second preview clip.
- Cleans the target folder before re-running.
- Writes `manifest.json` mapping frame index → timestamp.
- Builds a `contact-sheet.jpg` (6 columns) via local ffmpeg using `%03d` sequence pattern (Windows-compatible — no `glob` support needed).
## Try to fetch the transcript
```sh
node transcript.mjs <loom-share-url> ./out/transcript.txt
```
Strategies it tries (in order):
1. Parse `__NEXT_DATA__` on the share page for embedded transcript URL
2. Intercept network responses during embed playback (`cdn.loom.com/mediametadata/transcription/<id>-2.json` + `captions/<id>-2.vtt`)
3. Toggle captions UI if present
4. Direct GET against well-known endpoints
For both JV Looms the endpoint returns an empty phrases array, confirming Umar didn't enable transcription. If a Loom returns a VTT, the script also writes a `.plain.txt` stripped version.
## Dependencies
- Node 18+ (tested on Node 24)
- `playwright` (installed locally in this folder via `npm i playwright` + `npx playwright install chromium`)
- `ffmpeg` on PATH (any modern build — globbing not required)
## Why frames-not-video?
Loom doesn't expose its mp4 publicly without auth, so direct `yt-dlp`-style download fails. Frame capture via Playwright `<video>` element seek + screenshot is the cleanest fallback — no auth, decent visual fidelity, fast to scan via the contact sheet.
## Where this fits in the project
These captures are the visual ground truth for the **4 PDP sections** Umar wants populated (Scientific Studies, Timeline/Results, Comparison Tables, FAQs). The plan referencing them is `../../PLAN_v2_2026-06-02.md`.
+116
View File
@@ -0,0 +1,116 @@
// Usage: node capture.mjs <loom-url> <out-dir> [intervalSeconds]
// Opens a Loom share/embed URL, plays it, captures a frame every N seconds,
// then builds a contact sheet via ffmpeg for fast scanning.
import { chromium } from 'playwright';
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const rawUrl = process.argv[2];
const outDir = path.resolve(process.argv[3] || './frames');
const intervalSec = parseFloat(process.argv[4] || '3');
if (!rawUrl) {
console.error('Usage: node capture.mjs <loom-url> <out-dir> [intervalSeconds]');
process.exit(1);
}
// Convert /share/<id> -> /embed/<id>?autoplay=true so the real video loads
// instead of the 4-second preview clip Loom shows on share pages.
const m = rawUrl.match(/loom\.com\/(?:share|embed)\/([a-f0-9]+)/i);
const url = m
? `https://www.loom.com/embed/${m[1]}?autoplay=true&hideEmbedTopBar=true&hide_owner=true`
: rawUrl;
fs.mkdirSync(outDir, { recursive: true });
// Clean old frames so the contact sheet doesn't pick them up
for (const f of fs.readdirSync(outDir)) {
if (f.startsWith('frame-') || f === 'contact-sheet.jpg') fs.unlinkSync(path.join(outDir, f));
}
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const page = await ctx.newPage();
console.log(`Opening ${url}`);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForSelector('video', { timeout: 60000 });
// Force-mute and play
await page.evaluate(async () => {
const v = document.querySelector('video');
v.muted = true;
try { await v.play(); } catch {}
});
// Poll until the real video metadata loads (preview clip is ~4s; real video >> 30s usually)
let realDuration = 0, realSrc = '';
for (let i = 0; i < 30; i++) {
await page.waitForTimeout(1000);
const { duration, src, readyState } = await page.evaluate(() => {
const v = document.querySelector('video');
return { duration: v.duration, src: v.currentSrc, readyState: v.readyState };
});
console.log(` poll ${i}: dur=${duration?.toFixed(1)}s rs=${readyState} src=...${(src || '').slice(-50)}`);
realDuration = duration;
realSrc = src;
if (Number.isFinite(duration) && duration > 15 && readyState >= 2) break;
}
if (!Number.isFinite(realDuration) || realDuration <= 0) {
console.error('Could not read video duration. Last src:', realSrc);
await browser.close();
process.exit(2);
}
console.log(`\nUsing duration: ${realDuration.toFixed(1)}s, src: ${realSrc}`);
const steps = Math.floor(realDuration / intervalSec);
console.log(`Capturing ${steps + 1} frames at ${intervalSec}s intervals -> ${outDir}`);
for (let i = 0; i <= steps; i++) {
const t = Math.min(i * intervalSec, realDuration - 0.1);
await page.evaluate(
(t) =>
new Promise((resolve) => {
const v = document.querySelector('video');
v.pause();
const onSeeked = () => { v.removeEventListener('seeked', onSeeked); resolve(); };
v.addEventListener('seeked', onSeeked);
v.currentTime = t;
}),
t,
);
await page.waitForTimeout(250);
const file = path.join(outDir, `frame-${String(i).padStart(3, '0')}.png`);
await page.locator('video').first().screenshot({ path: file });
if ((i + 1) % 5 === 0 || i === steps) console.log(` ${i + 1}/${steps + 1} (t=${t.toFixed(1)}s)`);
}
// Manifest mapping frame index -> seconds
const manifest = Array.from({ length: steps + 1 }, (_, i) => ({
frame: `frame-${String(i).padStart(3, '0')}.png`,
seconds: Math.min(i * intervalSec, realDuration - 0.1),
mmss: (() => {
const t = Math.min(i * intervalSec, realDuration - 0.1);
const mm = Math.floor(t / 60).toString().padStart(2, '0');
const ss = Math.floor(t % 60).toString().padStart(2, '0');
return `${mm}:${ss}`;
})(),
}));
fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
await browser.close();
// Contact sheet via ffmpeg using %03d sequence pattern (Windows-safe, no glob needed)
const cols = 6;
const rows = Math.ceil((steps + 1) / cols);
const sheet = path.join(outDir, 'contact-sheet.jpg');
const inPattern = path.join(outDir, 'frame-%03d.png').replace(/\\/g, '/');
console.log(`\nBuilding contact sheet (${cols}x${rows}): ${sheet}`);
execSync(
`ffmpeg -y -framerate 1 -i "${inPattern}" -vf "scale=480:-1,tile=${cols}x${rows}" "${sheet.replace(/\\/g, '/')}"`,
{ stdio: 'inherit' },
);
console.log('Done.');
+60
View File
@@ -0,0 +1,60 @@
{
"name": "loom-capture",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loom-capture",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"playwright": "^1.60.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "loom-capture",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"playwright": "^1.60.0"
}
}
+122
View File
@@ -0,0 +1,122 @@
// Usage: node transcript.mjs <loom-url> <out-file>
// Tries multiple strategies to extract a Loom transcript:
// 1. __NEXT_DATA__ from the share page (contains transcript URL if available)
// 2. Network interception for vtt/json transcript requests during playback
// 3. Direct fetch of well-known transcript endpoints
import { chromium } from 'playwright';
import fs from 'node:fs';
import path from 'node:path';
const url = process.argv[2];
const outFile = path.resolve(process.argv[3] || './transcript.txt');
if (!url) { console.error('Usage: node transcript.mjs <loom-url> <out-file>'); process.exit(1); }
const idMatch = url.match(/loom\.com\/(?:share|embed)\/([a-f0-9]+)/i);
const videoId = idMatch?.[1];
if (!videoId) { console.error('Could not extract Loom video ID from URL'); process.exit(1); }
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await ctx.newPage();
const candidates = [];
page.on('response', async (res) => {
const u = res.url();
const ct = (res.headers()['content-type'] || '').toLowerCase();
if (u.includes('transcript') || u.endsWith('.vtt') || u.includes('captions') || u.includes('/cc/')) {
try {
const body = await res.text();
candidates.push({ url: u, ct, len: body.length, body });
console.log(` ✓ candidate: ${u} (${body.length} bytes, ${ct})`);
} catch {}
}
});
console.log(`Opening share page for video ${videoId}...`);
await page.goto(`https://www.loom.com/share/${videoId}`, { waitUntil: 'domcontentloaded', timeout: 60000 });
// Try __NEXT_DATA__
const nextData = await page.evaluate(() => {
const el = document.getElementById('__NEXT_DATA__');
return el ? el.textContent : null;
});
let nextDataTranscriptUrl = null;
if (nextData) {
try {
const parsed = JSON.parse(nextData);
const json = JSON.stringify(parsed);
const m = json.match(/https?:[^"]*?(?:transcript|caption|\.vtt)[^"]*/gi);
if (m && m.length) {
console.log(` __NEXT_DATA__ hints: ${m.slice(0, 3).join(' , ')}`);
nextDataTranscriptUrl = m[0];
}
} catch {}
}
// Now load the embed and force playback to trigger any transcript fetch
console.log(`Opening embed and playing to trigger transcript request...`);
await page.goto(`https://www.loom.com/embed/${videoId}?autoplay=true&hideEmbedTopBar=true`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('video', { timeout: 60000 });
await page.evaluate(async () => {
const v = document.querySelector('video'); v.muted = true; try { await v.play(); } catch {}
});
// Let it run a few seconds so player loads captions if they exist
await page.waitForTimeout(8000);
// Also try toggling closed-captions UI button if present
try {
const ccBtn = page.locator('button[aria-label*="caption" i], button[aria-label*="subtitle" i], [data-test*="caption" i]').first();
if (await ccBtn.count() > 0) {
await ccBtn.click({ timeout: 3000 });
await page.waitForTimeout(4000);
}
} catch {}
// Direct GraphQL/REST guesses (most fail w/o auth but cheap to try)
const guesses = [
`https://www.loom.com/api/v1/videos/${videoId}/transcripts`,
`https://www.loom.com/api/v1/videos/${videoId}/captions`,
`https://www.loom.com/api/campaigns/sessions/${videoId}/transcript-text`,
`https://www.loom.com/api/campaigns/sessions/${videoId}/transcripts`,
];
for (const g of guesses) {
try {
const r = await page.request.get(g);
if (r.ok()) {
const body = await r.text();
if (body && body.length > 50 && !body.startsWith('<!DOCTYPE')) {
candidates.push({ url: g, ct: r.headers()['content-type'] || '', len: body.length, body });
console.log(` ✓ guess hit: ${g} (${body.length} bytes)`);
}
}
} catch {}
}
await browser.close();
if (candidates.length === 0) {
console.log('No transcript found via any strategy.');
fs.writeFileSync(outFile, '[no transcript available]\n');
process.exit(3);
}
// Pick best candidate (largest non-html)
candidates.sort((a, b) => b.len - a.len);
const best = candidates[0];
fs.writeFileSync(outFile, best.body);
console.log(`\nSaved: ${outFile} (${best.len} bytes from ${best.url})`);
// If it's VTT, also write a stripped plain-text version
if (best.ct.includes('vtt') || best.body.startsWith('WEBVTT')) {
const plain = best.body
.split('\n')
.filter((l) => l && !/^\d+$/.test(l) && !l.startsWith('WEBVTT') && !/^\d\d:\d\d/.test(l) && !l.startsWith('NOTE'))
.join(' ')
.replace(/\s+/g, ' ')
.trim();
const plainFile = outFile.replace(/\.(txt|vtt)$/i, '') + '.plain.txt';
fs.writeFileSync(plainFile, plain);
console.log(`Plain-text version: ${plainFile}`);
}