Docs/Skills and code/src/
src/series-viewer/teardown-job.js
What the file says about itself
Generates a teardown for one video on demand: makes sure a transcript exists, builds the case folder the winning-video-teardown skill expects, runs a Sonnet analyst through the Claude Code CLI, and validates the result.
// Generates a teardown for one video on demand: makes sure a transcript exists, builds the case folder the
// winning-video-teardown skill expects, runs a Sonnet analyst through the Claude Code CLI, and validates the result.
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { openStore } from "../series-analysis/store.js";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const SKILL = path.join(ROOT, ".claude", "skills", "winning-video-teardown");
const seriesDir = series => path.join(ROOT, "transcripts", "series-analysis", series);
const nowIso = () => new Date().toISOString();
/** Where a video's teardown lives, whether it came from a batch run (manifest) or from the viewer button. */
export function teardownPaths(series, videoId) {
const base = path.join(seriesDir(series), "teardown");
try {
const manifest = JSON.parse(fs.readFileSync(path.join(base, "manifest.json"), "utf8"));
const c = manifest.cases.find(x => x.video_id === videoId);
if (c) return { analysis: path.join(base, "analyses", `${c.cid}.json`), caseDir: path.join(base, "cases", c.cid), batch: true, cid: c.cid };
} catch { /* no batch run yet */ }
const dir = path.join(base, "single", videoId);
return { analysis: path.join(dir, "analysis.json"), caseDir: dir, dir, batch: false };
}
export function readTeardown(series, videoId) {
if (jobFor(series, videoId)?.status === "running") return null; // the file may be a draft the analyst is still fixing
const p = teardownPaths(series, videoId);
if (!fs.existsSync(p.analysis)) return null;
try { return JSON.parse(fs.readFileSync(p.analysis, "utf8")); } catch { return null; }
}
/** Finds a saved transcript file for the video: the DB record first, then the cohort and teardown folders. */
export function findTranscriptFile(series, videoId, db) {
const row = db.prepare("SELECT file_path FROM series_transcripts WHERE series_id=? AND video_id=? AND status='ok'").get(series, videoId);
if (row?.file_path && fs.existsSync(row.file_path)) return row.file_path;
const base = seriesDir(series);
for (const group of ["cohort-new", "cohort-established"]) {
const dir = path.join(base, group);
if (!fs.existsSync(dir)) continue;
const f = fs.readdirSync(dir).find(x => x.endsWith(".txt") && x.includes(videoId));
if (f) return path.join(dir, f);
}
const viewerFile = path.join(base, "viewer", `${videoId}.txt`);
return fs.existsSync(viewerFile) ? viewerFile : null;
}
/** Parses a saved transcript file into { language, info, paragraphs: [{ts, text}] }. */
export function parseTranscriptFile(file) {
const raw = fs.readFileSync(file, "utf8");
const body = raw.split("=== TRANSCRIPT ===")[1] ?? "";
const lines = body.split(/\r?\n/);
const meta = lines.filter(l => /^(Language|Fetched with):/.test(l)).map(l => l.trim());
const paragraphs = [];
for (const l of lines) {
const m = l.match(/^\[(\d{1,2}(?::\d{2}){1,2})\]\s*(.*)$/);
if (m) paragraphs.push({ ts: m[1], text: m[2] });
else if (paragraphs.length && l.trim()) paragraphs[paragraphs.length - 1].text += " " + l.trim();
}
return { language: meta[0]?.replace(/^Language:\s*/, "") ?? null, info: meta[1]?.replace(/^Fetched with:\s*/, "") ?? null, paragraphs };
}
let sharedStore = null;
const getStore = () => (sharedStore ??= openStore());
async function ensureTranscript(series, videoId, row, log) {
const store = getStore();
const existing = findTranscriptFile(series, videoId, store);
if (existing) return existing;
log("Fetching the transcript from YouTube");
const { fetchOriginalTranscript, formatTranscriptText } = await import("../transcript-fetch.js");
const t = await fetchOriginalTranscript(videoId);
const dir = path.join(seriesDir(series), "viewer");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, `${videoId}.txt`);
const header = [
"=== VIDEO ===", `Title: ${row.title}`, `URL: https://www.youtube.com/watch?v=${videoId}`, `Video ID: ${videoId}`, `Channel: ${row.channel_name}`, "",
"=== TRANSCRIPT ===", `Language: ${t.languageName} [${t.lang}] (detected via ${t.detection})`, `Fetched with: ${t.library}, ${t.segments.length} caption segments`, "", ""
].join("\n");
fs.writeFileSync(file, header + formatTranscriptText(t.segments) + "\n", "utf8");
store.prepare("INSERT OR REPLACE INTO series_transcripts(series_id,video_id,status,error,language,segment_count,file_path,fetched_at) VALUES(?,?,?,?,?,?,?,?)")
.run(series, videoId, "ok", null, t.lang, t.segments.length, file, nowIso());
return file;
}
async function downloadThumbnail(videoId, dest) {
for (const name of ["maxresdefault", "hqdefault"]) {
try {
const res = await fetch(`https://i.ytimg.com/vi/${videoId}/${name}.jpg`);
if (!res.ok) continue;
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length < 4000) continue;
fs.writeFileSync(dest, buf);
return true;
} catch { /* try the next size */ }
}
return false;
}
const n = x => (x == null ? "unknown" : Number(x).toLocaleString("en-US"));
function buildCase(series, videoId, row, transcriptFile, log) {
const p = teardownPaths(series, videoId);
fs.mkdirSync(p.dir, { recursive: true });
const raw = fs.readFileSync(transcriptFile, "utf8");
fs.writeFileSync(path.join(p.dir, "transcript.txt"), raw.slice(raw.indexOf("=== TRANSCRIPT ===")), "utf8");
const d = row.detail ?? {};
const mins = row.duration_seconds ? `${Math.floor(row.duration_seconds / 60)}m ${row.duration_seconds % 60}s` : "unknown";
const meta = [
`# Case ${videoId}`, "",
`Title: ${row.title}`, `Channel: ${row.channel_name} (${d.subscribers != null ? n(d.subscribers) + " subscribers" : "subscribers unknown"})`,
`URL: https://www.youtube.com/watch?v=${videoId}`, `Published: ${row.published_at?.slice(0, 10) ?? "unknown"}`, `Duration: ${mins}`,
`Views: ${n(row.views ?? row.search_views)} | Likes: ${n(row.likes)}`,
`Channel's typical recent upload: ${n(d.baseline_median_views)} views | This video vs that: ${d.outlier_ratio != null ? d.outlier_ratio.toFixed(2) + "x" : "n/a"}`,
`Description (as uploaded): ${(row.description ?? "").replace(/\s+/g, " ")}`, "",
"Explain how this video works: what it does well and how. Do not assume it performed well and do not assume every choice in it is good; judge from the transcript and packaging.", "",
].join("\n");
fs.writeFileSync(path.join(p.dir, "case.md"), meta, "utf8");
return p;
}
const running = new Map(); // `${series}/${videoId}` -> { status, step, startedAt, finishedAt, error, tail }
export const jobFor = (series, videoId) => running.get(`${series}/${videoId}`) ?? null;
/** Starts a teardown in the background. Returns the job record immediately; poll jobFor() for progress. */
export function startTeardown(series, videoId, row) {
const key = `${series}/${videoId}`;
const prior = running.get(key);
if (prior?.status === "running") return prior;
const job = { status: "running", step: "Starting", startedAt: nowIso(), finishedAt: null, error: null, tail: "" };
running.set(key, job);
const log = s => { job.step = s; };
(async () => {
try {
const transcriptFile = await ensureTranscript(series, videoId, row, log);
log("Preparing the case");
const p = buildCase(series, videoId, row, transcriptFile, log);
const hasThumb = await downloadThumbnail(videoId, path.join(p.dir, "thumb.jpg"));
const validator = `node "${path.join(SKILL, "scripts", "validate.js")}" --file "${p.analysis}" --case "${p.dir}"`;
const task = [
`# Teardown task: ${videoId}`, "",
`1. Read the instructions: ${path.join(SKILL, "references", "analyst-prompt.md")}`,
`2. Read the mechanism vocabulary: ${path.join(SKILL, "references", "taxonomy.md")}`,
`3. Read the output schema: ${path.join(SKILL, "references", "schema.md")}`,
`4. Read the case metadata: ${path.join(p.dir, "case.md")}`,
hasThumb ? `5. Look at the thumbnail (use the Read tool on this image): ${path.join(p.dir, "thumb.jpg")}` : "5. No thumbnail could be downloaded; set thumbnail.seen to false.",
`6. Read the full transcript: ${path.join(p.dir, "transcript.txt")}`,
`7. Write your analysis JSON to: ${p.analysis}`,
`8. Validate with Bash (this is the only command you may run): ${validator}`,
" Fix the JSON and rewrite it until the validator prints OK.", "",
"Read only the files listed above. Do not list directories or open other cases.", "",
].join("\n");
const taskPath = path.join(p.dir, "task.md");
fs.writeFileSync(taskPath, task, "utf8");
log("Analyst is reading the transcript and thumbnail (usually 3 to 8 minutes)");
const prompt = `Read the task file ${taskPath} and follow it exactly. Read only the files it lists. Finish with one line: output path + validator result.`;
await new Promise((resolve, reject) => {
const child = spawn("claude", ["-p", "--model", "sonnet", "--permission-mode", "acceptEdits", "--allowedTools", "Read", "Write", "Bash(node:*)"],
{ cwd: ROOT, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
child.stdout.on("data", d => { job.tail = (job.tail + d).slice(-600); });
child.stderr.on("data", d => { job.tail = (job.tail + d).slice(-600); });
child.on("error", reject);
child.on("close", code => (code === 0 ? resolve() : reject(new Error(`claude exited with code ${code}: ${job.tail.slice(-300)}`))));
child.stdin.end(prompt);
});
log("Validating the quotes");
const result = await new Promise(resolve => {
const out = [];
const v = spawn("node", [path.join(SKILL, "scripts", "validate.js"), "--file", p.analysis, "--case", p.dir], { cwd: ROOT, windowsHide: true });
v.stdout.on("data", d => out.push(String(d))); v.stderr.on("data", d => out.push(String(d)));
v.on("close", code => resolve({ code, text: out.join("").trim() }));
});
if (!fs.existsSync(p.analysis)) throw new Error("The analyst finished without writing an analysis file.");
if (result.code !== 0) throw new Error(`Validation failed: ${result.text.slice(0, 400)}`);
job.status = "done"; job.step = "Done";
} catch (e) {
job.status = "error"; job.error = String(e.message ?? e).slice(0, 600); job.step = "Failed";
} finally {
job.finishedAt = nowIso();
}
})();
return job;
}
Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.