Files
Omair Saleh 056c47581f 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>
2026-06-02 18:50:09 +08:00

123 lines
4.6 KiB
JavaScript

// 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}`);
}