Docs/Skills and code/src/

src/series-viewer/teardown-job.js

Code186 lines10.5 KBsrc/

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.