Files
ai-podcast/schedule_x_launch.py
T
lukeandClaude Opus 5 9d49b013c2 Track deploy, social, and Reaper scripts
Scripts already referenced from CLAUDE.md but never committed: deploy_searxng.sh
(which stands up the SearXNG instance Devon's web_search depends on),
post_milestone.py, generate_milestone_images.py, make_x_launch_assets.py,
schedule_x_launch.py, download_music.py, and the Reaper bleep-selection script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 04:23:53 -05:00

539 lines
18 KiB
Python

#!/usr/bin/env python3
"""Schedule the X/Twitter launch campaign posts via Postiz.
Schedules 2 weeks of posts from the growth strategy to @lukeattheroost.
All times are ET, converted to UTC for the Postiz API.
Usage:
python schedule_x_launch.py # schedule all posts
python schedule_x_launch.py --dry-run # preview without scheduling
python schedule_x_launch.py --week 1 # schedule week 1 only
"""
import argparse
import json
import os
import sys
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
import requests
# Load .env
env_path = Path(__file__).parent / ".env"
if env_path.exists():
for line in env_path.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY")
POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com")
SCRIPT_DIR = Path(__file__).parent
X_INTEGRATION_ID = "cmlk4hi880001k76wbqjo21s0"
# ET = UTC-4 (EDT) in March 2026
ET_OFFSET = timedelta(hours=-4)
def et_to_utc(year, month, day, hour, minute=0):
"""Convert ET datetime to UTC ISO string for Postiz."""
et = datetime(year, month, day, hour, minute, tzinfo=timezone(ET_OFFSET))
utc = et.astimezone(timezone.utc)
return utc.strftime("%Y-%m-%dT%H:%M:%S.000Z")
def get_api_url(path):
return f"{POSTIZ_URL.rstrip('/')}/api/public/v1{path}"
def api_headers():
return {"Authorization": POSTIZ_API_KEY, "Content-Type": "application/json"}
def upload_file(file_path):
headers = {"Authorization": POSTIZ_API_KEY}
suffix = file_path.suffix.lower()
if suffix == ".mp4":
mime = "video/mp4"
elif suffix in (".jpg", ".jpeg"):
mime = "image/jpeg"
else:
mime = "image/png"
with open(file_path, "rb") as f:
resp = requests.post(
get_api_url("/upload"),
headers=headers,
files={"file": (file_path.name, f, mime)},
timeout=120,
)
if resp.status_code not in (200, 201):
print(f" Upload failed: {resp.status_code} {resp.text[:200]}")
return {}
return resp.json()
def schedule_post(content, media, schedule_time, retries=3):
payload = {
"type": "schedule",
"date": schedule_time,
"shortLink": False,
"tags": [],
"posts": [
{
"integration": {"id": X_INTEGRATION_ID},
"value": [
{
"content": content,
"image": [media] if media else [],
}
],
"settings": {"__type": "x", "who_can_reply_post": "everyone"},
}
],
}
for attempt in range(retries):
resp = requests.post(
get_api_url("/posts"),
headers=api_headers(),
json=payload,
timeout=30,
)
if resp.status_code in (200, 201):
return resp.json()
if resp.status_code == 429 and attempt < retries - 1:
wait = 15 * (attempt + 1)
print(f"(rate limited, waiting {wait}s)...", end=" ", flush=True)
time.sleep(wait)
continue
print(f" Schedule failed: {resp.status_code} {resp.text[:300]}")
return {}
return {}
# ── POST DEFINITIONS ─────────────────────────────────────────────────
# Day 1 = Monday March 17, 2026
WEEK_1 = [
# Day 1 — Monday March 17
{
"label": "W1-Mon-AM (pinned intro)",
"time": et_to_utc(2026, 3, 17, 10),
"content": """Every caller on my show is AI-generated.
Every personality. Every voice. Every problem they call in about.
But the conversations are real, the advice is real, and the chaos is very real.
38 episodes. 200+ callers. A cult leader, a guy who opened paternity results live on air, and someone who faked cancer to skip a wedding.
This is Luke at the Roost.
📞 208-439-LUKE (real humans can call in too)
🔗 lukeattheroost.com""",
"media": "website/images/cover.png",
},
{
"label": "W1-Mon-PM (chili clip)",
"time": et_to_utc(2026, 3, 17, 14),
"content": """A guy called in to talk about chili contest cheaters.
Turns out he was really calling about his failing marriage.
#lukeattheroost #podcast #callinshow""",
"media": "clips/episode-37/clip-1-chili-contest-cheaters-marriage-troubles.mp4",
},
{
"label": "W1-Mon-EVE (intro thread)",
"time": et_to_utc(2026, 3, 17, 21),
"content": """People keep asking what Luke at the Roost is.
Short version:
→ AI characters call into my show with real problems
→ I give them actual advice
→ Everything goes off the rails
→ New episode every day
lukeattheroost.com""",
"media": None,
},
# Day 2 — Tuesday March 18
{
"label": "W1-Tue-AM (hospice clip)",
"time": et_to_utc(2026, 3, 18, 12),
"content": """A caller's mom is in hospice. The nurse asked her to think about final conversations.
Her only wish? To see her kids eat cake together one last time.
#lukeattheroost #podcast""",
"media": "clips/episode-30/clip-2-mom-s-dying-wish-just-eat-cake-together.mp4",
},
{
"label": "W1-Tue-PM (engagement)",
"time": et_to_utc(2026, 3, 18, 19),
"content": """What's the wildest thing you've ever called into a radio show about?
(Or wanted to but chickened out?)""",
"media": None,
},
# Day 3 — Wednesday March 19
{
"label": "W1-Wed-AM (cancer clip)",
"time": et_to_utc(2026, 3, 19, 11),
"content": """This caller faked having cancer to get out of going to a wedding.
Then his friends staged a coffee enema intervention.
I can't make this up. (Well, the AI did.)
#lukeattheroost #podcast""",
"media": "clips/episode-32/clip-1-i-faked-cancer-to-skip-a-wedding.mp4",
},
{
"label": "W1-Wed-PM (fakes clip)",
"time": et_to_utc(2026, 3, 19, 16),
"content": """"Everybody is a fake. We're all fakes. Nobody knows what's going on and none of us deserve a goddamn thing. We're lucky to be here at all."
— A caller on Episode 2. Still the hardest truth anyone's dropped on my show.
#lukeattheroost #podcast""",
"media": "clips/episode-2/clip-1-we-re-all-fakes-and-that-s-okay.mp4",
},
# Day 4 — Thursday March 20
{
"label": "W1-Thu-AM (BTS)",
"time": et_to_utc(2026, 3, 20, 12),
"content": """How my show works:
→ AI generates a caller with a full backstory, personality, and voice
→ They call in live
→ I have zero idea what they're going to say
→ I give them real advice
→ Post-production runs automatically
→ Episode publishes
38 episodes. Built the whole thing from scratch.""",
"media": None,
},
{
"label": "W1-Thu-PM (stakeout clip)",
"time": et_to_utc(2026, 3, 20, 20),
"content": """He spent four hours staking out his best friend at Starbucks.
What he found was worse than what he expected.
#lukeattheroost #podcast""",
"media": "clips/episode-28/clip-3-four-hours-spying-on-his-best-friend.mp4",
},
# Day 5 — Friday March 21
{
"label": "W1-Fri-AM (review ask)",
"time": et_to_utc(2026, 3, 21, 11),
"content": """If you've listened to Luke at the Roost and liked it — a review on Apple Podcasts or Spotify goes further than you'd think.
Not guilt-tripping. Just saying it helps a one-person show more than anything else.
🔗 lukeattheroost.com""",
"media": "social_posts/x_launch/leave_a_review_twitter.png",
},
{
"label": "W1-Fri-PM (wall clip)",
"time": et_to_utc(2026, 3, 21, 18),
"content": """She opened up a mystery wall in her house LIVE on the show.
There were stacks of cash inside.
#lukeattheroost #podcast""",
"media": "clips/episode-35/clip-3-woman-finds-cash-in-secret-wall.mp4",
},
# Day 6 — Saturday March 22
{
"label": "W1-Sat-AM (poll)",
"time": et_to_utc(2026, 3, 22, 12),
"content": """Which is wilder?
A) Woman hid her daughter from her husband for 8 years
B) Cult leader existential crisis on air
C) Paternity results opened live on the show
D) Faked cancer to skip a wedding""",
"media": None,
},
{
"label": "W1-Sat-PM (silence clip)",
"time": et_to_utc(2026, 3, 22, 20),
"content": """"I told my girlfriend my biggest fantasy and she went completely silent for 10 seconds."
The silence in this clip is brutal.
#lukeattheroost #podcast""",
"media": "clips/episode-30/clip-3-latex-fetish-confession-goes-silent.mp4",
},
# Day 7 — Sunday March 23
{
"label": "W1-Sun-PM (week recap)",
"time": et_to_utc(2026, 3, 23, 15),
"content": """One week on X. Dropped 38 episodes before making an account.
If any of these clips made you laugh, cringe, or feel something — the full episodes are even wilder.
📞 208-439-LUKE
🔗 lukeattheroost.com
🎧 Spotify · Apple · YouTube""",
"media": "website/images/cover.png",
},
]
WEEK_2 = [
# Day 8 — Monday March 24
{
"label": "W2-Mon-AM (second family clip)",
"time": et_to_utc(2026, 3, 24, 10),
"content": """A guy called in and found out his dad had a whole second family.
Three kids in Tucson who grew up calling his dad "Dad."
He found out via email from a stranger.
#lukeattheroost #podcast""",
"media": "clips/episode-20/clip-2-dad-s-secret-second-family-revealed.mp4",
},
{
"label": "W2-Mon-PM (engagement)",
"time": et_to_utc(2026, 3, 24, 19),
"content": """What would you do if you found out your dad had a whole second family?
Asking because a caller found out via email from a woman in Tucson.""",
"media": None,
},
# Day 9 — Tuesday March 25
{
"label": "W2-Tue-AM (spanish clip)",
"time": et_to_utc(2026, 3, 25, 11),
"content": """A caller pretended to speak Spanish at his job for 8 years.
Eight. Years.
#lukeattheroost #podcast""",
"media": "clips/episode-14/clip-1-i-lied-about-speaking-spanish-for-8-years.mp4",
},
{
"label": "W2-Tue-PM (quote)",
"time": et_to_utc(2026, 3, 25, 18),
"content": """"Middle management is plagiarism with a 401k."
AI callers drop better one-liners than most standup specials.
#lukeattheroost #podcast""",
"media": "social_posts/x_launch/quote_3_stoicism_backwards_twitter.png",
},
# Day 10 — Wednesday March 26
{
"label": "W2-Wed-AM (BTS)",
"time": et_to_utc(2026, 3, 26, 12),
"content": """People ask if I script the show.
I don't even know who's calling until they're on the line. The AI generates the caller, picks a unique voice, gives them a backstory, and dials in.
My job is just to be a good host. The chaos handles itself.""",
"media": None,
},
{
"label": "W2-Wed-PM (roomba clip)",
"time": et_to_utc(2026, 3, 26, 20),
"content": """His neighbor's Roomba broke into his kitchen at 2:30 AM.
This is the content you're here for.
#lukeattheroost #podcast""",
"media": "clips/episode-26/clip-2-neighbor-s-roomba-breaks-into-kitchen-at-2-30-am.mp4",
},
# Day 11 — Thursday March 27
{
"label": "W2-Thu-AM (stalking clip)",
"time": et_to_utc(2026, 3, 27, 11),
"content": """She sat in a Dairy Queen parking lot for 20 minutes watching her ex's truck at Sonic across the street.
We've all been there. (Right?)
#lukeattheroost #podcast""",
"media": "clips/episode-22/clip-2-stalking-your-ex-at-sonic.mp4",
},
{
"label": "W2-Thu-PM (engagement)",
"time": et_to_utc(2026, 3, 27, 19),
"content": """Be honest: what's a lie you've kept going for way too long?
A caller on my show pretended to speak Spanish at work for 8 years. You can't beat that.""",
"media": None,
},
# Day 12 — Friday March 28
{
"label": "W2-Fri-AM (hidden room clip)",
"time": et_to_utc(2026, 3, 28, 10),
"content": """A man found an impossible hidden room in a junkyard.
Inside? Beer that was still fresh after 12 years.
Two clips. One mystery.
#lukeattheroost #podcast""",
"media": "clips/episode-34/clip-1-man-finds-impossible-hidden-room-in-junkyard.mp4",
},
{
"label": "W2-Fri-PM (fix quote)",
"time": et_to_utc(2026, 3, 28, 18),
"content": """"You can't fix somebody who doesn't want to be fixed."
A caller said this about their partner and it hit like a truck.
#lukeattheroost #podcast""",
"media": "clips/episode-30/clip-1-you-can-t-fix-someone-who-won-t-be-fixed.mp4",
},
# Day 13 — Saturday March 29
{
"label": "W2-Sat-AM (BTS callers)",
"time": et_to_utc(2026, 3, 29, 12),
"content": """Each AI caller gets generated with:
• A name, age, job, and hometown
• A reason for calling
• A communication style + energy level
• An emotional state
• A "signature detail" that makes them unique
• A unique AI voice
None of it is scripted. They just... call in and talk.""",
"media": None,
},
{
"label": "W2-Sat-PM (check clip)",
"time": et_to_utc(2026, 3, 29, 20),
"content": """He deposited a $5,000 check instead of $500 three months ago.
Spent it all.
Now his company might find out.
#lukeattheroost #podcast""",
"media": "clips/episode-25/clip-2-accidentally-kept-4-500-from-work.mp4",
},
# Day 14 — Sunday March 30
{
"label": "W2-Sun-PM (week 2 recap + review)",
"time": et_to_utc(2026, 3, 30, 15),
"content": """Two weeks in. Thank you to everyone who's followed, listened, or dropped a comment.
This show started as a weird experiment — a guy giving life advice to AI-generated callers at 2 AM.
38 episodes later it's still weird. But now people are listening.
If you've been enjoying it, a rating on Apple or Spotify makes a real difference. 🙏
lukeattheroost.com""",
"media": None,
},
]
def main():
parser = argparse.ArgumentParser(description="Schedule X launch campaign via Postiz")
parser.add_argument("--dry-run", action="store_true", help="Preview without scheduling")
parser.add_argument("--week", type=int, choices=[1, 2], help="Schedule only week 1 or 2")
parser.add_argument("--skip", type=int, default=0, help="Skip first N posts (for retrying after partial run)")
parser.add_argument("--delay", type=int, default=10, help="Seconds between API calls (default 10)")
args = parser.parse_args()
if not POSTIZ_API_KEY:
print("Error: POSTIZ_API_KEY not set in .env")
sys.exit(1)
all_posts = []
if args.week != 2:
all_posts.extend(WEEK_1)
if args.week != 1:
all_posts.extend(WEEK_2)
posts = all_posts[args.skip:]
print(f"\n=== X Launch Campaign — {len(posts)} posts ===\n")
if args.dry_run:
for i, post in enumerate(posts, 1):
has_media = "📎" if post["media"] else " "
print(f" {i:2d}. {has_media} {post['label']}")
print(f" Schedule: {post['time']}")
preview = post["content"][:80].replace("\n", " ")
print(f" Content: {preview}...")
if post["media"]:
print(f" Media: {post['media']}")
print()
print(f"Dry run complete — {len(posts)} posts would be scheduled.")
return
# Upload media files first (deduplicate), with disk cache
cache_file = SCRIPT_DIR / "social_posts" / "x_launch" / ".upload_cache.json"
media_cache = {}
if cache_file.exists():
media_cache = json.loads(cache_file.read_text())
print(f"Loaded {len(media_cache)} cached uploads from previous run\n")
media_files = set(p["media"] for p in posts if p["media"])
to_upload = [m for m in sorted(media_files) if m not in media_cache]
if to_upload:
print(f"Uploading {len(to_upload)} new media files ({len(media_files) - len(to_upload)} cached)...\n")
for media_path in to_upload:
full_path = SCRIPT_DIR / media_path
if not full_path.exists():
print(f" ✗ {media_path} — FILE NOT FOUND, skipping")
continue
print(f" Uploading {media_path}...", end=" ", flush=True)
result = upload_file(full_path)
if result:
media_cache[media_path] = result
cache_file.write_text(json.dumps(media_cache, indent=2))
print("✓")
else:
print("✗ FAILED")
time.sleep(3)
else:
print(f"All {len(media_files)} media files already cached, skipping uploads\n")
# Schedule posts
print(f"\nScheduling {len(posts)} posts to X...\n")
success = 0
failed = 0
for i, post in enumerate(posts, 1):
media = media_cache.get(post["media"]) if post["media"] else None
if post["media"] and not media:
print(f" {i:2d}. ✗ {post['label']} — media upload missing, skipping")
failed += 1
continue
print(f" {i:2d}. Scheduling {post['label']}...", end=" ", flush=True)
result = schedule_post(post["content"], media, post["time"])
if result:
print("✓")
success += 1
else:
print("✗")
failed += 1
# Rate limit: pause between API calls
if i < len(posts):
time.sleep(5)
print(f"\n{'='*50}")
print(f"Scheduled: {success}/{len(posts)}")
if failed:
print(f"Failed: {failed}")
print(f"\nPosts will appear on @lukeattheroost starting Mon March 17")
if __name__ == "__main__":
main()