056c47581f
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>
123 lines
4.6 KiB
JavaScript
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}`);
|
|
}
|