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