← back to Crazy News Channel

daily-cartoons/approve.py

84 lines

#!/usr/bin/env python3
"""Approve a queued cartoon INTO THE LOCAL SITE TREE ONLY. Never deploys/rsyncs.

  python3 daily-cartoons/approve.py <slug> --date YYYY-MM-DD [--story-id <id>] [--section politics]

Copies poster/clip (clip is optional — poster-only cartoons render as a still image) to
cartoons/media/, writes cartoons/<date>-<slug>.html, and appends a manifest.js entry.
--story-id DEFAULTS to the article the cartoon was generated from (meta.json's story_id,
written by article-driven generate.py, TK-12237) and is still validated against
stories-data.js / real-news-data.js; pass --story-id to override. A story_id is REQUIRED
either way (standing rule TK-12158: every cartoon links to a real article).
Publishing to p24.agentabrams.com stays a separate, Steve-run deploy."""
import argparse, datetime, html, json, os, re, shutil, sys
import common as C
import build_review

CART = os.path.join(C.SITE, "cartoons")

TEMPLATE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cartoon-page.template.html")

def render_page(**fields):
    """Fill cartoon-page.template.html ({{key}} placeholders, single pass). Values must already be
    HTML-escaped (media/source are pre-built markup)."""
    tpl = open(TEMPLATE, encoding="utf-8").read().rstrip("\n")
    return re.sub(r"\{\{(\w+)\}\}", lambda mo: fields[mo.group(1)], tpl)

def story_exists(sid):
    for f in ("stories-data.js", "real-news-data.js"):
        p = os.path.join(C.SITE, f)
        if os.path.exists(p) and re.search(r"""["']?id["']?\s*:\s*["']%s["']""" % re.escape(sid), open(p).read()):
            return True
    return False

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("slug"); ap.add_argument("--date", default=datetime.date.today().isoformat())
    ap.add_argument("--story-id", default=None); ap.add_argument("--section", default="politics")
    a = ap.parse_args()
    q = os.path.join(C.QUEUE, a.date, a.slug); mp = os.path.join(q, "meta.json")
    if not os.path.exists(mp): sys.exit(f"not queued: {q}")
    m = json.load(open(mp))
    story_id = a.story_id or m.get("story_id")
    if not story_id:
        sys.exit("--story-id required (meta.json has no story_id — this cartoon predates the "
                 "article-driven pipeline; pass --story-id explicitly)")
    if not story_exists(story_id): sys.exit(f"story_id '{story_id}' not found in stories-data.js / real-news-data.js")
    a.story_id = story_id
    base = f"{a.date.replace('-','')}-{a.slug}"
    os.makedirs(os.path.join(CART, "media"), exist_ok=True)
    shutil.copy2(os.path.join(q, "poster.jpg"), os.path.join(CART, "media", base + ".jpg"))
    has_clip = os.path.exists(os.path.join(q, "clip.mp4"))
    if has_clip: shutil.copy2(os.path.join(q, "clip.mp4"), os.path.join(CART, "media", base + ".mp4"))
    e = html.escape
    media = (f'<video controls playsinline poster="media/{base}.jpg" src="media/{base}.mp4"></video>' if has_clip
             else f'<img src="media/{base}.jpg" alt="{e(m["title"])}">')
    # TK-12226: link ONLY the shared stylesheet — no per-page <style> block,
    # no locally hardcoded palette/font. Layout lives in assets/style.css
    # under body.cartoon-page-simple so every future approved cartoon page
    # renders in the site's one fixed palette + one font, same as every
    # other page, with nothing that changes on click.
    src_url, src_title = m.get("story_url"), m.get("story_title")
    src_html = (f'<p class="src"><a href="{e(src_url)}" target="_blank" rel="noopener noreferrer">Source: {e(src_title or "linked article")}</a></p>'
                if src_url else "")
    # TK-12247: the page markup lives in daily-cartoons/cartoon-page.template.html, shared with the
    # Inkwell Shadow Man export so every single-image cartoon page is rendered from ONE template.
    open(os.path.join(CART, base + ".html"), "w").write(render_page(
        title=e(m["title"]), media=media, caption=e(m["caption"]), source=src_html,
        credit="AI-generated editorial cartoon in classic editorial-ink style. Invented figures only."))
    mf = os.path.join(CART, "manifest.js"); src = open(mf).read()
    mm = re.search(r"(window\.P24_CARTOONS\s*=\s*)(\[[\s\S]*\])(\s*;\s*)$", src)
    arr = json.loads(mm.group(2)); arr = [x for x in arr if x.get("id") != a.slug]
    arr.append({"id": a.slug, "title": m["title"], "file": base + ".html", "created_at": m["created_at"],
                "category": "Political Cartoon", "section": a.section, "blurb": m["caption"],
                "thumb": f"media/{base}.jpg", "story_id": a.story_id, "ai_generated": True,
                "style_reference": "editorial-ink", "tags": ["editorial cartoon", "classic editorial ink", "daily cartoon"]})
    open(mf, "w").write(src[:mm.start(2)] + json.dumps(arr, indent=2) + mm.group(3))
    m["status"] = "approved"; m["approved_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
    m["site_file"] = f"cartoons/{base}.html"; json.dump(m, open(mp, "w"), indent=2)
    build_review.build()
    print(f"approved locally → cartoons/{base}.html (+ manifest.js). NOT deployed.")

if __name__ == "__main__":
    main()