← back to Allnewsdaily
scripts/short/build-script.js
113 lines
#!/usr/bin/env node
/**
* STAGE 2 — build-script.js (TK-11342)
* Read data/short/stories.json -> build a 45–55s FACTUAL news-brief narration
* for a vertical Short (no opinion/editorializing). Write data/short/script.json
* per CONTRACTS.md. Hard cap totalSec <= 58; if over, drop to 5 beats.
*
* Pace ~ 2.7 words/sec.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { editionFormat, wording, validateScript } = require('./formats');
const ROOT = path.resolve(__dirname, '..', '..');
const IN = path.join(ROOT, 'data', 'short', 'stories.json');
const OUT_DIR = path.join(ROOT, 'data', 'short');
const OUT = path.join(OUT_DIR, 'script.json');
const WPS = 2.7; // words per second
const HARD_CAP = 58; // seconds — Shorts must be < 60s
function wordCount(s) {
return clean(s).split(/\s+/).filter(Boolean).length;
}
function clean(s) {
return (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim();
}
function estSec(text) {
return Math.round((wordCount(text) / WPS) * 10) / 10; // 1 decimal
}
// "Month D" from a YYYY-MM-DD stamp (local-safe, no TZ shift).
function monthDay(dateStr) {
let d;
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr || '')) {
const [y, m, day] = dateStr.split('-').map(Number);
d = new Date(y, m - 1, day);
} else {
d = new Date();
}
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
}
// Ensure a headline sentence ends with terminal punctuation before "— via".
function normHeadline(h) {
const t = clean(h);
return /[.!?]$/.test(t) ? t : t + '.';
}
function buildBeats(stories) {
return stories.map((s, i) => {
const text = `${normHeadline(s.headline)} — via ${clean(s.outlet)}.`;
return { n: i + 1, headline: clean(s.headline), outlet: clean(s.outlet), text, estSec: estSec(text) };
});
}
function assemble(intro, beats, outro) {
const total = Math.round((intro.estSec + beats.reduce((a, b) => a + b.estSec, 0) + outro.estSec) * 10) / 10;
const narration = [intro.text, ...beats.map((b) => b.text), outro.text].join('\n');
return { intro, beats, outro, totalSec: total, narration };
}
function buildScript(data) {
const format = editionFormat(data.date);
const stories = Array.isArray(data.stories) ? data.stories : [];
if (!stories.length || stories.some(s => !s || typeof s.headline !== 'string' || !s.headline.trim() || typeof s.outlet !== 'string' || !s.outlet.trim())) {
throw new Error('Stories require a headline and outlet');
}
const [introText, outroText] = wording(format.id, monthDay(data.date));
const intro = { text: introText, estSec: estSec(introText) };
const outro = { text: outroText, estSec: estSec(outroText) };
let beats = buildBeats(stories);
let script = assemble(intro, beats, outro);
// Hard cap: if over 58s, drop to 5 beats (then keep trimming as a safety net).
if (script.totalSec > HARD_CAP && beats.length > 5) {
beats = beats.slice(0, 5);
script = assemble(intro, beats, outro);
console.warn(`[build-script] over ${HARD_CAP}s cap — dropped to ${beats.length} beats.`);
}
while (script.totalSec > HARD_CAP && beats.length > 3) {
beats = beats.slice(0, beats.length - 1);
script = assemble(intro, beats, outro);
console.warn(`[build-script] still over cap — trimmed to ${beats.length} beats.`);
}
script.format = format.id;
script.edition = data.date;
validateScript(script);
return script;
}
function main() {
const data = JSON.parse(fs.readFileSync(IN, 'utf8'));
const script = buildScript(data);
const beats = script.beats;
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.writeFileSync(OUT, JSON.stringify(script, null, 2));
console.log(`[build-script] wrote script.json -> ${path.relative(ROOT, OUT)}`);
console.log(` beats=${beats.length} totalSec=${script.totalSec} (target 45–55, cap ${HARD_CAP})`);
if (script.totalSec < 45) console.warn('[build-script] WARN: narration under 45s target.');
if (script.totalSec > 55) console.warn('[build-script] WARN: narration over 55s target (still under hard cap).');
return script;
}
if (require.main === module) {
try { main(); }
catch (e) { console.error('[build-script] FATAL:', e && e.stack || e); process.exit(1); }
}
module.exports = { main, buildScript };