|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Schedule a Substack post for a set time, on the web and by email, from a draft or straight from a Markdown file. |
| 3 | +
|
| 4 | + python schedule_post.py --post-id 148420031 --at 2026-10-14T08:00:00-04:00 |
| 5 | + python schedule_post.py --file essay.md --at 2026-10-14T08:00:00-04:00 --note "New essay: why we stopped shipping on Fridays" |
| 6 | + python schedule_post.py --post-id 148420031 --at 2026-10-17T07:00:00+01:00 --web-only |
| 7 | +
|
| 8 | +Always give the time with its offset. Substack schedules at most three months |
| 9 | +ahead and emails a post only once. The script stops if another post is already |
| 10 | +scheduled for the same day, unless you pass --allow-same-day. |
| 11 | +""" |
| 12 | +import argparse |
| 13 | +import json |
| 14 | +from datetime import datetime, timedelta, timezone |
| 15 | + |
| 16 | +from cotlet_client import call, paginate, read_markdown, run |
| 17 | + |
| 18 | + |
| 19 | +def main() -> None: |
| 20 | + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) |
| 21 | + src = parser.add_mutually_exclusive_group(required=True) |
| 22 | + src.add_argument("--post-id", help="Draft ID") |
| 23 | + src.add_argument("--file", help="Create the draft from this Markdown file first") |
| 24 | + parser.add_argument("--at", required=True, help="ISO 8601 time with offset, e.g. 2026-10-14T08:00:00-04:00") |
| 25 | + parser.add_argument("--audience", choices=["everyone", "only_paid", "founding"], default="everyone", help="With --file") |
| 26 | + parser.add_argument("--web-only", action="store_true", help="Publish on the web without emailing subscribers") |
| 27 | + parser.add_argument("--note", help="Also schedule a Note with a link card to the post") |
| 28 | + parser.add_argument("--note-delay", type=float, default=4, help="Hours after the post for the Note. Default: 4") |
| 29 | + parser.add_argument("--allow-same-day", action="store_true") |
| 30 | + args = parser.parse_args() |
| 31 | + |
| 32 | + at = datetime.fromisoformat(args.at) |
| 33 | + if at.tzinfo is None: |
| 34 | + parser.error("--at needs a time zone offset, e.g. -04:00 or +01:00") |
| 35 | + now = datetime.now(timezone.utc) |
| 36 | + if at <= now: |
| 37 | + parser.error("--at is in the past") |
| 38 | + if at > now + timedelta(days=92): |
| 39 | + parser.error("Substack schedules at most three months ahead") |
| 40 | + |
| 41 | + if not args.allow_same_day: |
| 42 | + for post in paginate("list_posts", {"status": "scheduled", "limit": 50}, "posts"): |
| 43 | + when = post.get("publish_at") or post.get("published_at") |
| 44 | + if when and datetime.fromisoformat(when.replace("Z", "+00:00")).astimezone(at.tzinfo).date() == at.date(): |
| 45 | + parser.exit(1, f"Already scheduled that day: {post['title']} ({post['post_id']}). Use --allow-same-day.\n") |
| 46 | + |
| 47 | + post_id = args.post_id |
| 48 | + if args.file: |
| 49 | + title, subtitle, body = read_markdown(args.file) |
| 50 | + post_id = call("create_draft", {"title": title, "subtitle": subtitle, "body_markdown": body, "audience": args.audience})["post_id"] |
| 51 | + |
| 52 | + scheduled = call("schedule_post", {"post_id": post_id, "publish_at": at.isoformat(), "send_email": not args.web_only}) |
| 53 | + print(json.dumps(scheduled, indent=2)) |
| 54 | + if args.note: |
| 55 | + note_at = (at + timedelta(hours=args.note_delay)).isoformat() |
| 56 | + print(json.dumps(call("publish_note", {"body": args.note, "link_post_id": post_id, "publish_at": note_at}), indent=2)) |
| 57 | + |
| 58 | + |
| 59 | +if __name__ == "__main__": |
| 60 | + run(main) |
0 commit comments