← back to Crazy News Channel Shadowman
scripts/build-cartoon-batch.sh
195 lines
#!/bin/bash
# build-cartoon-batch.sh — the P24 Political Cartoon Desk's daily batch builder.
#
# NOT YET INSTALLED AS A SCHEDULED JOB. This script + its launchd plist are drafted
# to ~/.claude/yolo-queue/pending-approval/ for Steve's explicit sign-off, per
# CLAUDE.md's hard gate on "money/spend" + "autonomous claude -p execution" — a
# recurring unattended job that spawns paid Claude sessions every day is exactly
# the class that must stay Steve's yes, even after a live "go" on the concept.
#
# What it does when run:
# 1. node scripts/daily-cartoon-gen.mjs -> picks today's N briefs (default 6),
# writing cartoons/queue/<date>.json. $0, local, deterministic.
# 2. For each brief still 'queued', spawns ONE autonomous `claude -p` session
# scoped to that single brief: build a self-contained P24 cartoon strip
# (3 panels, click-to-advance, Plot Twist button, non-partisan invented
# satire, 390-1440px responsive, keyboard accessible, reduced-motion safe,
# zero console errors — same acceptance bar as 20260924-commission-turtles.html),
# e2e-test it with Playwright, add it to cartoons/manifest.js, and commit.
# HARD RULE (Steve, verbatim: "always link images cartoons to the real
# article", codified TK-12158) — the manifest entry's story_id must NOT be
# null. Every new cartoon must link to a real article, either an existing
# one in stories-data.js/real-news-data.js or a new stories-data.js entry
# written for it. See the requirement comment at the top of
# cartoons/manifest.js. Step 90-98 below enforces this: a built cartoon
# with story_id: null (or missing) is logged FAIL, not PASS.
# HARD RULE (TK-12165, 2026-09-24) — the manifest entry's tags array must
# also be non-empty (inherit the linked story's tags plus at most 1-2
# cartoon-specific tags, e.g. "editorial cartoon"), and if a NEW
# stories-data.js entry is written for this brief, IT must carry a
# non-empty tags array too (3-6 short, lowercase-ish, human-readable
# tags drawn from the vocabulary already in use there). Enforced the
# same way as story_id below: a built cartoon with tags: [] or missing
# is logged FAIL, not PASS.
# 3. Logs the whole run to yolo/cartoon-batch-log.jsonl.
#
# COST: each `claude -p` build is a real paid Anthropic API session. Today's single
# scoped cartoon (commission-turtles) took roughly a dozen tool calls end-to-end
# including one fix-and-retest cycle; a batch of 6/day run unattended, without a
# live contrarian pass on every single one (that would roughly double the cost),
# is the realistic ongoing shape. Steve should treat the first week as a
# cost-calibration period and check yolo/cartoon-batch-log.jsonl + his Anthropic
# usage dashboard before trusting a long-run estimate.
#
# Usage once approved: bash scripts/build-cartoon-batch.sh [--count=6] [--dry-run]
set -uo pipefail
# launchd runs with PATH=/usr/bin:/bin:/usr/sbin:/sbin — node and claude live elsewhere.
export PATH="/opt/homebrew/bin:$HOME/.npm-global/bin:$HOME/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
command -v node >/dev/null && command -v claude >/dev/null || { echo "$(/bin/date -u +%FT%TZ) FAIL missing node/claude on PATH" >>"$(dirname "$0")/../yolo/cartoon-batch-log.jsonl"; exit 127; }
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$DIR"
COUNT=6
DRY=0
for a in "$@"; do
case "$a" in
--count=*) COUNT="${a#--count=}" ;;
--dry-run) DRY=1 ;;
esac
done
TS="$(/bin/date -u +%Y-%m-%dT%H:%M:%SZ)"
DATE="$(/bin/date -u +%Y-%m-%d)"
LOG="$DIR/yolo/cartoon-batch-log.jsonl"
mkdir -p "$DIR/yolo"
node scripts/daily-cartoon-gen.mjs --count="$COUNT" --date="$DATE" || { echo "$TS generator FAILED" >>"$LOG"; exit 1; }
QFILE="$DIR/cartoons/queue/$DATE.json"
[ -f "$QFILE" ] || { echo "$TS no queue file for $DATE" >>"$LOG"; exit 1; }
python3 - "$QFILE" "$DRY" "$LOG" "$TS" "$DIR" <<'PYEOF'
# SEQUENTIAL build, one claude -p at a time (Steve 2026-09-24: "run in order" —
# no parallel sessions hitting Anthropic rate limits). ~8 min each, ~48 min for 6,
# so the plist starts at 5:15 ET to post by 6:30 ET; each build gets a hard
# 15-min timeout. Builders write ONLY their HTML + a meta sidecar; this parent
# alone appends manifest.js / stories-data.js and commits once at the end. Keeps the TK-12158 real-article
# story_id rule and the TK-12165 non-empty tags rule, now checked on the sidecar.
import json, sys, subprocess, os, datetime, re
qfile, dry, log, ts, root = sys.argv[1:6]
q = json.load(open(qfile))
todo = [b for b in q['briefs'] if b.get('status') == 'queued']
tools = "Read Write Edit Glob Grep Bash(node:*) Bash(tk:*) Bash(/Users/macstudio3/Projects/ticket-system/tk:*)"
def build(b):
meta = os.path.join(root, "cartoons", "queue", b["slug"] + ".meta.json")
prompt = (
f"Build one P24 (PANDEMONIUM-24) Cartoon Desk strip, ticket-tracked "
f"(export TK_AGENT=p24-cartoon-batch; tk new for this brief under project crazy-news-channel). "
f"TITLE: {b['title']}\nBRIEF: {b['brief']}\nSECTION: {b.get('category','politics')}\n\n"
f"Follow the exact style, acceptance bar, and file structure of "
f"~/Projects/crazy-news-channel/cartoons/20260924-commission-turtles.html "
f"(read it first as the reference implementation): 3-panel click-to-advance SVG strip, "
f"a Plot Twist button, non-partisan invented satire only (no real people/parties), "
f"390-1440px responsive, keyboard accessible with visible focus, prefers-reduced-motion "
f"respected, dark-mode toggle, zero console errors. Save to "
f"~/Projects/crazy-news-channel/cartoons/{b['slug']}.html. Playwright-test at 1440/1024/390 "
f"+ reduced-motion + dark (require playwright via ~/Projects/animals/node_modules/playwright, "
f"launch chromium with channel:'chrome'); keep test scripts/screenshots in /tmp, NOT the repo. "
f"Fix any real defects found.\n\n"
f"Then write {meta} as JSON: {{\"blurb\": one sentence, \"story_id\": id, \"tags\": [...], "
f"\"new_story\": object-or-null}}. HARD RULE — every cartoon links to a real article: story_id "
f"must be non-null. Reuse an existing id from ~/Projects/crazy-news-channel/stories-data.js or "
f"real-news-data.js if one covers this topic (new_story: null); otherwise put ONE new satirical "
f"story in new_story matching the exact schema of an existing window.P24_EXTRA_STORIES entry "
f"(id, category, categoryLabel, location, byline, image, intervalSec, offsetSec, stageIndex, "
f"article.dek, article.photoCaption, article.paragraphs, stages, tags) with new_story.id == story_id. "
f"HARD RULE (TK-12165) — the story's tags must be non-empty (3-6 short lowercase-ish tags reusing "
f"the shared vocabulary in stories-data.js, e.g. 'city council', 'bureaucracy', 'small town news'), "
f"and the sidecar tags = that story's tags plus at most 1-2 cartoon tags (e.g. 'editorial cartoon'). "
f"HARD LIMIT: finish within 12 minutes. Do NOT edit cartoons/manifest.js or stories-data.js and do "
f"NOT git commit — the batch process registers and commits your work. Do not deploy, push, or "
f"touch anything else."
)
try:
r = subprocess.run(["claude", "-p", prompt, "--permission-mode", "acceptEdits", "--allowedTools", tools],
cwd=root, capture_output=True, text=True, timeout=900)
rc = r.returncode
except subprocess.TimeoutExpired:
rc = 124
return b, rc, meta
if dry == "1":
for b in todo: print(f"[DRY RUN] would build: {b['id']} — {b['title']}")
sys.exit(0)
results = [build(b) for b in todo]
def load_js_array(path, var):
head, arr = open(path).read().split(f"window.{var} = ", 1)
return head, json.loads(arr.rstrip().rstrip(";"))
def save_js_array(path, var, head, data, indent):
tmp = path + ".tmp"
with open(tmp, "w") as f:
f.write(head + f"window.{var} = " + json.dumps(data, indent=indent, ensure_ascii=False) + ";\n")
os.replace(tmp, path)
man_path = os.path.join(root, "cartoons", "manifest.js")
sd_path = os.path.join(root, "stories-data.js")
m_head, entries = load_js_array(man_path, "P24_CARTOONS")
s_head, stories = load_js_array(sd_path, "P24_EXTRA_STORIES")
try: real_news = open(os.path.join(root, "real-news-data.js")).read()
except OSError: real_news = ""
now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
added, new_stories = [], 0
for b, rc, meta_path in results:
fname = b["slug"] + ".html"
has_file = os.path.isfile(os.path.join(root, "cartoons", fname))
try: meta = json.load(open(meta_path))
except Exception: meta = {}
sid, tags, ns = meta.get("story_id"), meta.get("tags") or [], meta.get("new_story")
story_ok = False
if sid and ns:
story_ok = ns.get("id") == sid and bool(ns.get("tags")) and not any(s.get("id") == sid for s in stories)
elif sid:
story_ok = any(s.get("id") == sid for s in stories) or f'"{sid}"' in real_news
ok = (rc == 0 and has_file and bool(meta.get("blurb")) and story_ok and bool(tags)
and not any(e.get("file") == fname for e in entries))
if ok:
if ns: stories.append(ns); new_stories += 1
sec = b.get("category", "politics")
entries.append({"id": b["id"], "title": b["title"], "file": fname, "created_at": now,
"category": "Political Cartoon" if sec == "politics" else "Editorial Cartoon",
"section": sec, "blurb": meta["blurb"], "thumb": None,
"story_id": sid, "tags": tags})
added.append(fname)
with open(log, "a") as f:
f.write(json.dumps({"ts": ts, "id": b["id"], "title": b["title"], "exit": rc,
"file": has_file, "blurb": bool(meta.get("blurb")), "story_id": story_ok,
"tags": bool(tags), "ok": ok, "verdict": "PASS" if ok else "FAIL"}) + "\n")
b['status'] = 'built' if ok else 'failed'
if added:
save_js_array(man_path, "P24_CARTOONS", m_head, entries, 2)
paths = ["cartoons/manifest.js"] + ["cartoons/" + a for a in added]
if new_stories:
save_js_array(sd_path, "P24_EXTRA_STORIES", s_head, stories, 1)
paths.append("stories-data.js")
subprocess.run(["git", "add", "--"] + paths, cwd=root)
subprocess.run(["git", "-c", "user.email=steve@designerwallcoverings.com", "commit", "-q", "-m",
f"P24 daily cartoons {q['date']}: {len(added)} built + registered"], cwd=root)
json.dump(q, open(qfile, 'w'), indent=2)
sys.exit(1 if any(b.get('status') == 'failed' for b in q['briefs']) else 0)
PYEOF
RC=$?
echo "$TS batch complete (count=$COUNT dry=$DRY rc=$RC)" >>"$LOG"
# TK-12191: post today's newly built cartoons as PUBLIC "Satire:" YouTube Shorts on the
# All News Daily channel (one per cartoon, with the linked article). Capped at 5 because
# the YouTube Data API default quota is ~6 uploads/day and the daily news Short needs one.
# The shorts ledger makes this idempotent, so re-runs never double-post.
if [ "$DRY" = "0" ]; then
node scripts/cartoon-shorts/make-cartoon-shorts.mjs --date="$DATE" --limit=5 >>"$DIR/yolo/cartoon-shorts-wrapper.log" 2>&1 \
|| echo "$TS cartoon-shorts FAILED (see yolo/cartoon-shorts-wrapper.log)" >>"$LOG"
fi
exit $RC