← back to Allnewsdaily

scripts/short/make-daily-short.mjs

176 lines

#!/usr/bin/env node
// make-daily-short.mjs — ORCHESTRATOR (integration/merge node) for the allnewsdaily daily Short. TK-11342.
// Chains: pick-stories → build-script → ElevenLabs VO → render-short → (unlisted) YouTube upload,
// then writes a run record + a FLEET-VISIBLE canary (PASS/WARN/FAIL).
//
// Cody/DTD red-team hardening (2026-09-09):
//  - #2 launchd-safe: shells out with process.execPath (absolute node), not bare 'node'.
//  - #3 canary ALSO written to ~/.claude/skills/allnewsdaily-daily-short/data/latest.json
//       so fleet-health-rollup (globs skills/*/data/latest.json) actually sees a FAIL.
//  - #7 same-day lock + "already published today" guard (no double-post / no race).
//  - #8 single clock: title/description date comes from stories.json (pick time), not new Date().
//  - freshness: a stale feed (pick-stories exit 2) → WARN "refused stale", not a crash.
//  - history: aired links appended to data/short/history.json only after a real upload.
//
// Flags: --no-vo  --no-upload  --dry-run  --force (ignore same-day guard)  --allow-stale  --privacy <v>
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';

const DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(DIR, '../..');
const DATA = path.join(ROOT, 'data', 'short');
const HISTORY = path.join(DATA, 'history.json');
const LOCK = path.join(DATA, '.lock');
const HOME = process.env.HOME || '/Users/macstudio3';
const SKILL_DATA = path.join(HOME, '.claude', 'skills', 'allnewsdaily-daily-short', 'data');
const LOCK_TTL_MS = 30 * 60 * 1000;

const has = (f) => process.argv.includes(f);
const argv = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : d; };
const NO_VO = has('--no-vo'), NO_UPLOAD = has('--no-upload'), DRY = has('--dry-run');
const FORCE = has('--force'), ALLOW_STALE = has('--allow-stale');
const PRIVACY = argv('--privacy', 'public'); // DTD verdict: auto-public, guarded by the post-publish delete-canary
const todayStamp = () => new Date().toISOString().slice(0, 10);

function readEnv(file, key) {
  try { const l = fs.readFileSync(file, 'utf8').split('\n').find((x) => x.startsWith(key + '=')); return l ? l.slice(key.length + 1).trim() : null; }
  catch { return null; }
}
// #2 — absolute node binary; launchd's stripped PATH has no 'node'.
function node(script) {
  const args = [path.join(DIR, script)];
  if (ALLOW_STALE && script === 'pick-stories.js') args.push('--allow-stale');
  execFileSync(process.execPath, args, { stdio: 'inherit', cwd: ROOT });
}

// #3 — write the run record locally AND a fleet-visible canary latest.json.
function writeStatus(status, detail, extra = {}) {
  const out = { skill: 'allnewsdaily-daily-short', ts: new Date().toISOString(), status, verdict: status, detail, ...extra };
  fs.mkdirSync(DATA, { recursive: true });
  fs.writeFileSync(path.join(DATA, 'latest-run.json'), JSON.stringify(out, null, 2));
  try { fs.mkdirSync(SKILL_DATA, { recursive: true }); fs.writeFileSync(path.join(SKILL_DATA, 'latest.json'), JSON.stringify(out, null, 2)); }
  catch (e) { console.error('[canary] could not write fleet latest.json:', e.message); }
  const icon = status === 'PASS' ? '✓' : status === 'WARN' ? '⚠' : '✗';
  console.log(`\n${icon} daily-short: ${status} — ${detail}`);
  return out;
}

// #8 — one clock: the briefing date is fixed at pick time (stories.json.date).
function buildMetadata(stories, dateStr) {
  const d = new Date((dateStr || todayStamp()) + 'T12:00:00Z');
  const monthDay = d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' });
  const lead = (stories[0]?.headline || 'Today’s headlines').replace(/\s+/g, ' ').trim();
  const leadShort = lead.length > 60 ? lead.slice(0, 57).trimEnd() + '…' : lead;
  const title = `All News Daily — ${monthDay}: ${leadShort}`.slice(0, 98);
  const lines = stories.map((s, i) => `${i + 1}. ${s.headline} — ${s.outlet}\n   ${s.link}`);
  const outlets = [...new Set(stories.map((s) => s.outlet))];
  const description = [
    `Your ${monthDay} headlines from All News Daily.`, '',
    ...lines, '',
    'Full stories + live coverage: https://allnewsdaily.com', '',
    `Sources: ${outlets.join(', ')}`,
    'Headlines are summarized with source attribution; all reporting belongs to the outlets linked above.', '',
    '#Shorts #news #headlines #dailynews #worldnews #currentevents',
  ].join('\n');
  const tags = ['news', 'headlines', 'daily news', 'world news', 'breaking news', 'current events', 'allnewsdaily', 'news brief', ...outlets].slice(0, 30);
  return { title, description, tags, privacyStatus: PRIVACY };
}

function appendHistory(dateStr, links) {
  let h = [];
  try { h = JSON.parse(fs.readFileSync(HISTORY, 'utf8')); if (!Array.isArray(h)) h = []; } catch {}
  h.push({ date: dateStr, ts: new Date().toISOString(), links });
  fs.writeFileSync(HISTORY, JSON.stringify(h.slice(-14), null, 2)); // keep ~2 weeks
}

// #7 — lock so a manual test can't collide with the cron.
function acquireLock() {
  try {
    const st = fs.statSync(LOCK);
    if (Date.now() - st.mtimeMs < LOCK_TTL_MS) return false; // fresh lock held
  } catch {}
  fs.mkdirSync(DATA, { recursive: true });
  fs.writeFileSync(LOCK, JSON.stringify({ pid: process.pid, ts: new Date().toISOString() }));
  return true;
}
const releaseLock = () => { try { fs.rmSync(LOCK); } catch {} };

(async () => {
  if (!acquireLock()) { console.error('[daily-short] another run holds the lock (< 30min old) — aborting.'); process.exit(0); }
  try {
    // #7 — min-gap guard: allow the scheduled 3x/day (runs ~6h apart) but block an accidental
    // rapid double-fire. (Different headlines each run are handled by the history.json dedupe.)
    if (!FORCE && !NO_UPLOAD && !DRY) {
      try {
        const prev = JSON.parse(fs.readFileSync(path.join(DATA, 'latest-run.json'), 'utf8'));
        const MIN_GAP_MIN = Number(process.env.AND_MIN_GAP_MIN || 120);
        if (prev.videoId && prev.ts) {
          const gap = (Date.now() - Date.parse(prev.ts)) / 60000;
          if (gap < MIN_GAP_MIN) { releaseLock(); return void writeStatus('PASS', `last upload ${gap.toFixed(0)}min ago (< ${MIN_GAP_MIN}min min-gap) — skipping to avoid double-post (use --force)`, prev); }
        }
      } catch {}
    }

    console.log('▸ 1/5 pick-stories');
    try { node('pick-stories.js'); }
    catch (e) {
      if (e.status === 2) { releaseLock(); return void writeStatus('WARN', 'feed stale/unverifiable — refused to publish stale news (wire-refresh loop likely dead). Use --allow-stale to override.', { costUSD: 0 }); }
      throw e;
    }
    console.log('▸ 2/5 build-script'); node('build-script.js');
    console.log('▸ stock imagery (Openverse CC0/public-domain)');
    try { node('fetch-stock.mjs'); } catch (e) { console.error('[daily-short] fetch-stock non-fatal:', e.message); }

    const storiesDoc = JSON.parse(fs.readFileSync(path.join(DATA, 'stories.json'), 'utf8'));
    const stories = storiesDoc.stories;
    const script = JSON.parse(fs.readFileSync(path.join(DATA, 'script.json'), 'utf8'));
    if (!stories.length) { releaseLock(); return void writeStatus('FAIL', 'no eligible stories selected — nothing to render', { costUSD: 0 }); }

    let cost = 0, voNote = 'silent (--no-vo)';
    if (!NO_VO) {
      console.log('▸ 3/5 ElevenLabs voiceover');
      const { synthesize } = await import('./tts-elevenlabs.mjs');
      const r = await synthesize({ text: script.narration, out: path.join(DATA, 'vo.mp3') });
      cost = r.costUSD; voNote = `${r.chars} chars, ~$${r.costUSD} (${r.voice}/${r.model})`;
      console.log(`  VO: ${voNote}`);
    } else { try { fs.rmSync(path.join(DATA, 'vo.mp3')); } catch {} }

    console.log('▸ 4/5 render'); node('render-short.js');
    const mp4 = path.join(DATA, 'out.mp4');
    const thumb = path.join(DATA, 'thumb.jpg');
    if (!fs.existsSync(mp4)) { releaseLock(); return void writeStatus('FAIL', 'render produced no out.mp4', { costUSD: cost }); }

    const meta = buildMetadata(stories, storiesDoc.date);
    console.log('▸ 5/5 upload');
    if (NO_UPLOAD) { releaseLock(); return void writeStatus('PASS', `rendered ${mp4} — upload skipped (--no-upload)`, { costUSD: cost, voNote, title: meta.title }); }

    const token = process.env.YOUTUBE_REFRESH_TOKEN || readEnv(path.join(ROOT, '.env'), 'YOUTUBE_REFRESH_TOKEN');
    if (!token && !DRY) { releaseLock(); return void writeStatus('WARN', 'rendered OK but NO YOUTUBE_REFRESH_TOKEN — run `node scripts/short/youtube-auth.mjs` (one-time login) then re-run', { costUSD: cost, voNote, title: meta.title }); }

    const { uploadShort } = await import('./upload-youtube.mjs');
    if (DRY) { console.log('DRY-RUN upload metadata:\n' + JSON.stringify(meta, null, 2)); releaseLock(); return void writeStatus('PASS', 'dry-run — rendered + metadata built, no upload', { costUSD: cost, voNote, title: meta.title }); }

    const res = await uploadShort({ file: mp4, thumbnail: fs.existsSync(thumb) ? thumb : undefined, ...meta });
    appendHistory(storiesDoc.date, stories.map((s) => s.link).filter(Boolean)); // record only after a real upload
    // Record what this video contains so the post-publish delete-canary can re-verify each source.
    try {
      const pubDir = path.join(DATA, 'published');
      fs.mkdirSync(pubDir, { recursive: true });
      fs.writeFileSync(path.join(pubDir, res.videoId + '.json'), JSON.stringify({
        videoId: res.videoId, url: res.url, airedDate: storiesDoc.date, publishedAt: new Date().toISOString(),
        privacyStatus: PRIVACY, stories: stories.map((s) => ({ n: s.n, headline: s.headline, outlet: s.outlet, link: s.link })),
      }, null, 2));
    } catch (e) { console.error('[daily-short] could not record published/<id>.json:', e.message); }
    releaseLock();
    return void writeStatus('PASS', `uploaded ${PRIVACY}: ${res.url} (thumbnail: ${res.thumbnail.note})`, {
      costUSD: cost, voNote, title: meta.title, videoId: res.videoId, url: res.url, airedDate: storiesDoc.date,
    });
  } catch (e) {
    releaseLock();
    writeStatus('FAIL', 'pipeline error: ' + e.message);
    process.exit(1);
  }
})();