← back to Allnewsdaily
scripts/short/post-publish-canary.mjs
117 lines
#!/usr/bin/env node
// post-publish-canary.mjs — the DELETE-CANARY for the allnewsdaily daily Short (TK-11342).
// DTD verdict: publish auto-public, but make it SAFE with automation instead of a human click.
// A few hours after a Short goes public, re-verify each of its source headlines. If a source
// article was PULLED (definitive HTTP 404/410 — the strongest retraction signal), auto-UNLIST
// the video (reversible) so a retracted story doesn't keep airing publicly. Also flags channel
// ABANDONMENT (no publish in N days). Emits PASS/WARN/FAIL for fleet-health-rollup.
//
// SAFETY: only a definitive 404/410 counts as "dead". A timeout / network error / 5xx is
// "unknown" and NEVER triggers an unlist — a transient outage must not pull a good video.
//
// Flags: --dry-run (report what it WOULD unlist, never calls the API)
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(DIR, '../..');
const PUB_DIR = path.join(ROOT, 'data', 'short', 'published');
const HOME = process.env.HOME || '/Users/macstudio3';
const SKILL_DATA = path.join(HOME, '.claude', 'skills', 'allnewsdaily-short-guard', 'data');
const LATEST = path.join(SKILL_DATA, 'latest.json');
const CNCP = process.env.CNCP_URL || 'http://127.0.0.1:3333';
const CHECK_DAYS = Number(process.env.AND_GUARD_CHECK_DAYS || 2); // recheck videos published within N days
const DEAD_THRESHOLD = Number(process.env.AND_GUARD_DEAD_THRESHOLD || 2); // unlist if >= this many sources dead…
const STALE_PUBLISH_DAYS = Number(process.env.AND_GUARD_STALE_DAYS || 2); // …or the LEAD source is dead
const DRY = process.argv.includes('--dry-run');
const RANK = { PASS: 0, WARN: 1, FAIL: 2 };
async function linkStatus(url) {
// definitive dead = 404/410 only; everything else (ok / redirect / 5xx / timeout / error) is not "dead".
for (const method of ['HEAD', 'GET']) {
try {
const ac = new AbortController(); const to = setTimeout(() => ac.abort(), 12000);
const r = await fetch(url, { method, redirect: 'follow', signal: ac.signal, headers: { 'user-agent': 'allnewsdaily-short-guard' } });
clearTimeout(to);
if (r.status === 404 || r.status === 410) return 'dead';
if (r.ok || (r.status >= 300 && r.status < 400)) return 'ok';
if (method === 'GET') return 'unknown'; // 5xx/403/etc — inconclusive, do NOT unlist
} catch { if (method === 'GET') return 'unknown'; }
}
return 'unknown';
}
function loadRecent() {
let files = [];
try { files = fs.readdirSync(PUB_DIR).filter((f) => f.endsWith('.json')); } catch { return []; }
const cutoff = Date.now() - CHECK_DAYS * 86400000;
return files.map((f) => { try { return JSON.parse(fs.readFileSync(path.join(PUB_DIR, f), 'utf8')); } catch { return null; } })
.filter((v) => v && v.publishedAt && Date.parse(v.publishedAt) >= cutoff && !v.flagged);
}
async function main() {
const recent = loadRecent();
let status = 'PASS';
const actions = [];
let allPublished = [];
try { allPublished = fs.readdirSync(PUB_DIR).filter((f) => f.endsWith('.json')); } catch {}
// 1) source re-verification on each recent, not-yet-flagged video
for (const v of recent) {
const results = await Promise.all((v.stories || []).map(async (s) => ({ ...s, state: await linkStatus(s.link) })));
const dead = results.filter((r) => r.state === 'dead');
const leadDead = results.find((r) => r.n === 1 && r.state === 'dead');
if (dead.length >= DEAD_THRESHOLD || leadDead) {
const reason = `${dead.length} source(s) pulled (404/410)${leadDead ? ', including the LEAD story' : ''}: ${dead.map((d) => d.outlet).join(', ')}`;
if (DRY) { actions.push(`WOULD unlist ${v.videoId} — ${reason}`); if (RANK.WARN > RANK[status]) status = 'WARN'; continue; }
try {
const { setVideoPrivacy } = await import('./upload-youtube.mjs');
await setVideoPrivacy(v.videoId, 'unlisted');
// mark so we don't re-process
const p = path.join(PUB_DIR, v.videoId + '.json');
fs.writeFileSync(p, JSON.stringify({ ...v, flagged: true, flaggedAt: new Date().toISOString(), flagReason: reason, priorPrivacy: v.privacyStatus, privacyStatus: 'unlisted' }, null, 2));
actions.push(`UNLISTED ${v.videoId} (${v.url}) — ${reason}`);
if (RANK.WARN > RANK[status]) status = 'WARN';
} catch (e) {
actions.push(`FAILED to unlist ${v.videoId} (${v.url}) — STILL PUBLIC — ${reason} | err: ${e.message}`);
status = 'FAIL'; // a bad video is still public and we couldn't fix it — the dangerous state
}
}
}
// 2) abandonment: newest publish older than STALE_PUBLISH_DAYS
let newestTs = 0;
for (const f of allPublished) { try { const t = Date.parse(JSON.parse(fs.readFileSync(path.join(PUB_DIR, f), 'utf8')).publishedAt); if (t > newestTs) newestTs = t; } catch {} }
let abandonNote = '';
if (allPublished.length && newestTs) {
const daysSince = (Date.now() - newestTs) / 86400000;
if (daysSince > STALE_PUBLISH_DAYS) { abandonNote = `no publish in ${daysSince.toFixed(1)}d — pipeline may be dead`; if (RANK.WARN > RANK[status]) status = 'WARN'; }
} else if (!allPublished.length) {
abandonNote = 'no videos published yet';
}
const detail = [
`${recent.length} recent video(s) source-checked`,
actions.length ? actions.join(' | ') : 'all sources healthy',
abandonNote,
].filter(Boolean).join(' · ');
// 3) heartbeat + baseline-aware alert
let prev = null; try { prev = JSON.parse(fs.readFileSync(LATEST, 'utf8')); } catch {}
const worsening = prev ? RANK[status] > RANK[prev.status] : status !== 'PASS';
fs.mkdirSync(SKILL_DATA, { recursive: true });
const out = { skill: 'allnewsdaily-short-guard', ts: new Date().toISOString(), status, verdict: status, detail, actions, checked: recent.length, prevStatus: prev ? prev.status : null, worsening };
fs.writeFileSync(LATEST, JSON.stringify(out, null, 2));
if (worsening && status !== 'PASS' && !DRY) {
try { await fetch(`${CNCP}/api/parking-lot`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: 'file://' + ROOT, note: `[ALLNEWSDAILY SHORT-GUARD ${new Date().toISOString().slice(0, 10)}] ${status}: ${detail}` }) }); } catch {}
}
const icon = status === 'PASS' ? '✓' : status === 'WARN' ? '⚠' : '✗';
console.log(`${icon} short-guard: ${status} — ${detail}`);
process.exit(0);
}
main().catch((e) => { console.error('[short-guard] fatal:', e.message); process.exit(0); });