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>
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user