← 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);
}
})();