[object Object]

← back to Allnewsdaily

allnewsdaily Short: auto-public + delete-canary (DTD verdict)

42d9caf4bd53e1dedd9454ea5eae3b556928048c · 2026-09-09 16:26:09 -0700 · Steve Abrams

DTD panel (3-3, broken to auto-public on argument weight + Cody's reframe): publish
public but guard it with automation, not a human click. Pipeline default → public;
records data/short/published/<id>.json per upload. New post-publish-canary.mjs re-verifies
each Short's sources a few hours after publish and auto-unlists any whose source was pulled
(definitive 404/410 only — never on a transient error), + flags channel abandonment. Widened
OAuth scope to youtube.force-ssl so the canary can unlist. New allnewsdaily-short-guard skill
(PASS/WARN/FAIL) + launchd (10/14/18h, not loaded). Delete-canary detection verified locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 42d9caf4bd53e1dedd9454ea5eae3b556928048c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 9 16:26:09 2026 -0700

    allnewsdaily Short: auto-public + delete-canary (DTD verdict)
    
    DTD panel (3-3, broken to auto-public on argument weight + Cody's reframe): publish
    public but guard it with automation, not a human click. Pipeline default → public;
    records data/short/published/<id>.json per upload. New post-publish-canary.mjs re-verifies
    each Short's sources a few hours after publish and auto-unlists any whose source was pulled
    (definitive 404/410 only — never on a transient error), + flags channel abandonment. Widened
    OAuth scope to youtube.force-ssl so the canary can unlist. New allnewsdaily-short-guard skill
    (PASS/WARN/FAIL) + launchd (10/14/18h, not loaded). Delete-canary detection verified locally.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 com.steve.allnewsdaily-short-guard.plist |  46 ++++++++++++
 scripts/short/make-daily-short.mjs       |  11 ++-
 scripts/short/post-publish-canary.mjs    | 116 +++++++++++++++++++++++++++++++
 scripts/short/upload-youtube.mjs         |  17 +++++
 scripts/short/youtube-auth.mjs           |   4 +-
 5 files changed, 191 insertions(+), 3 deletions(-)

diff --git a/com.steve.allnewsdaily-short-guard.plist b/com.steve.allnewsdaily-short-guard.plist
new file mode 100644
index 0000000..6d07691
--- /dev/null
+++ b/com.steve.allnewsdaily-short-guard.plist
@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<!-- allnewsdaily short-guard delete-canary (TK-11342). Runs a few hours after the 6am publish to
+     re-verify each published Short's sources and auto-unlist any whose source was retracted (404/410),
+     plus flag channel abandonment. Reversible (unlist ↔ public). NOT loaded automatically.
+     Load:   launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.allnewsdaily-short-guard.plist
+     Unload: launchctl bootout   gui/$(id -u) ~/Library/LaunchAgents/com.steve.allnewsdaily-short-guard.plist -->
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.steve.allnewsdaily-short-guard</string>
+
+  <key>ProgramArguments</key>
+  <array>
+    <string>/opt/homebrew/bin/node</string>
+    <string>/Users/macstudio3/Projects/allnewsdaily/scripts/short/post-publish-canary.mjs</string>
+  </array>
+
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
+    <key>HOME</key>
+    <string>/Users/macstudio3</string>
+  </dict>
+
+  <key>WorkingDirectory</key>
+  <string>/Users/macstudio3/Projects/allnewsdaily</string>
+
+  <!-- 10:00, 14:00, 18:00 local — several passes through the 24-48h algorithm/retraction window. -->
+  <key>StartCalendarInterval</key>
+  <array>
+    <dict><key>Hour</key><integer>10</integer><key>Minute</key><integer>0</integer></dict>
+    <dict><key>Hour</key><integer>14</integer><key>Minute</key><integer>0</integer></dict>
+    <dict><key>Hour</key><integer>18</integer><key>Minute</key><integer>0</integer></dict>
+  </array>
+
+  <key>RunAtLoad</key>
+  <false/>
+
+  <key>StandardOutPath</key>
+  <string>/Users/macstudio3/Projects/allnewsdaily/data/short/short-guard.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/macstudio3/Projects/allnewsdaily/data/short/short-guard.log</string>
+</dict>
+</plist>
diff --git a/scripts/short/make-daily-short.mjs b/scripts/short/make-daily-short.mjs
index fef8797..eef6dbc 100644
--- a/scripts/short/make-daily-short.mjs
+++ b/scripts/short/make-daily-short.mjs
@@ -31,7 +31,7 @@ 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', 'unlisted');
+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) {
@@ -147,6 +147,15 @@ const releaseLock = () => { try { fs.rmSync(LOCK); } catch {} };
 
     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,
diff --git a/scripts/short/post-publish-canary.mjs b/scripts/short/post-publish-canary.mjs
new file mode 100644
index 0000000..ff4d9e5
--- /dev/null
+++ b/scripts/short/post-publish-canary.mjs
@@ -0,0 +1,116 @@
+#!/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); });
diff --git a/scripts/short/upload-youtube.mjs b/scripts/short/upload-youtube.mjs
index a9e3711..680efb4 100644
--- a/scripts/short/upload-youtube.mjs
+++ b/scripts/short/upload-youtube.mjs
@@ -182,6 +182,23 @@ export async function uploadShort({ file, title, description, tags, privacyStatu
   throw new Error(`Byte upload failed after ${PUT_ATTEMPTS} attempts: ${lastErr && lastErr.message}`);
 }
 
+// Exposed for the post-publish delete-canary (needs a fresh access token + privacy update).
+export async function refreshedAccessToken() { return refreshAccessToken(loadCreds()); }
+
+// Change an EXISTING video's privacy (requires youtube.force-ssl scope). Used by the delete-canary
+// to auto-unlist a video whose source(s) got retracted. Reversible (set back to 'public').
+export async function setVideoPrivacy(videoId, privacyStatus, accessToken) {
+  const token = accessToken || (await refreshedAccessToken());
+  const res = await tfetch('https://www.googleapis.com/youtube/v3/videos?part=status', {
+    method: 'PUT',
+    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ id: videoId, status: { privacyStatus, selfDeclaredMadeForKids: false } }),
+  }, T_SHORT);
+  const j = await res.json().catch(() => ({}));
+  if (!res.ok) throw new Error(`setVideoPrivacy(${videoId}→${privacyStatus}) failed (${res.status}): ${JSON.stringify(j).slice(0, 300)}`);
+  return j.status ? j.status.privacyStatus : privacyStatus;
+}
+
 async function finalize(videoId, thumbnail, accessToken) {
   let thumb = { ok: false, note: 'skipped' };
   if (thumbnail) thumb = await setThumbnail(videoId, thumbnail, accessToken);
diff --git a/scripts/short/youtube-auth.mjs b/scripts/short/youtube-auth.mjs
index 94d4892..64198ee 100644
--- a/scripts/short/youtube-auth.mjs
+++ b/scripts/short/youtube-auth.mjs
@@ -27,8 +27,8 @@ const APP_ENV = join(homedir(), 'Projects', 'allnewsdaily', '.env');
 const PORT = 9964;
 const REDIRECT_URI = `http://localhost:${PORT}/oauth2callback`;
 const SCOPES = [
-  'https://www.googleapis.com/auth/youtube.upload',
-  'https://www.googleapis.com/auth/youtube.readonly',
+  'https://www.googleapis.com/auth/youtube.upload',    // resumable video insert
+  'https://www.googleapis.com/auth/youtube.force-ssl', // update privacy (delete-canary auto-unlist), thumbnails, read
 ];
 
 const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';

← 9eb9cfa allnewsdaily: daily YouTube Short pipeline (headlines→vertic  ·  back to Allnewsdaily  ·  allnewsdaily Short: Steve's cloned voice + natural model + s e68bd39 →