Skip to content

Commit 2119f2c

Browse files
committed
Add posts, drafts, scheduling and Notes
1 parent e8bec10 commit 2119f2c

8 files changed

Lines changed: 302 additions & 0 deletions

File tree

‎create_draft.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env python3
2+
"""Turn a Markdown file into a Substack draft, keeping headings, bold, links and lists.
3+
4+
python create_draft.py --file essay.md
5+
python create_draft.py --file essay.md --audience only_paid
6+
7+
The first "# " heading becomes the title and a "## " or _italic_ line right
8+
under it the subtitle. Images must be URLs; local paths are reported, not
9+
uploaded. Nothing is sent: schedule it with schedule_post.py or open the edit link.
10+
"""
11+
import argparse
12+
import json
13+
import re
14+
import sys
15+
16+
from cotlet_client import call, read_markdown, run
17+
18+
19+
def main() -> None:
20+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
21+
parser.add_argument("--file", required=True, help="Markdown file")
22+
parser.add_argument("--title", help="Override the title taken from the file")
23+
parser.add_argument("--subtitle", help="Override the subtitle taken from the file")
24+
parser.add_argument("--audience", choices=["everyone", "only_paid", "founding"], default="everyone")
25+
args = parser.parse_args()
26+
27+
title, subtitle, body = read_markdown(args.file)
28+
local = [src for src in re.findall(r"!\[[^\]]*\]\(([^)\s]+)", body) if not src.startswith(("http://", "https://"))]
29+
for src in local:
30+
print(f"Warning: image {src} is a local path; replace it with a hosted URL", file=sys.stderr)
31+
32+
draft = call("create_draft", {
33+
"title": args.title or title,
34+
"subtitle": args.subtitle or subtitle,
35+
"body_markdown": body,
36+
"audience": args.audience,
37+
})
38+
print(json.dumps(draft, indent=2))
39+
40+
41+
if __name__ == "__main__":
42+
run(main)

‎export_posts.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env python3
2+
"""Export your Substack posts as Markdown files, one per post, including the paywalled part of your own posts.
3+
4+
python export_posts.py --dir posts
5+
python export_posts.py --dir posts --since 2026-01-01
6+
python export_posts.py --dir drafts --status draft
7+
8+
Each file is named after the post URL slug and starts with the title and subtitle.
9+
Images stay as URLs. Works for the publication your Substack account publishes to.
10+
"""
11+
import argparse
12+
import re
13+
import sys
14+
from pathlib import Path
15+
16+
from cotlet_client import call, paginate, run
17+
18+
19+
def slug(post: dict) -> str:
20+
source = (post.get("url") or "").rstrip("/").rsplit("/", 1)[-1] or post.get("title") or post["post_id"]
21+
return re.sub(r"[^a-z0-9]+", "-", source.lower()).strip("-") or post["post_id"]
22+
23+
24+
def main() -> None:
25+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
26+
parser.add_argument("--dir", required=True, help="Folder to write the .md files to")
27+
parser.add_argument("--status", choices=["published", "draft", "scheduled"], default="published")
28+
parser.add_argument("--since", help="Only posts published on or after this date (YYYY-MM-DD)")
29+
parser.add_argument("--max", type=int, help="Stop after this many posts")
30+
args = parser.parse_args()
31+
32+
out = Path(args.dir)
33+
out.mkdir(parents=True, exist_ok=True)
34+
count = 0
35+
for item in paginate("list_posts", {"status": args.status, "limit": 50}, "posts", args.max):
36+
if args.since and item.get("published_at") and item["published_at"][:10] < args.since:
37+
break # newest first: everything after this is older
38+
post = call("get_post", {"post_id": item["post_id"]})
39+
head = f"# {post['title']}\n\n" + (f"_{post['subtitle']}_\n\n" if post.get("subtitle") else "")
40+
path = out / f"{slug(post)}.md"
41+
path.write_text(head + post.get("body_markdown", "") + "\n", encoding="utf-8")
42+
print(f" {path}", file=sys.stderr)
43+
count += 1
44+
print(f"Exported {count} posts to {out}", file=sys.stderr)
45+
46+
47+
if __name__ == "__main__":
48+
run(main)

‎get_post.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#!/usr/bin/env python3
2+
"""Get one Substack post with its full body as Markdown, audience, URL and dates.
3+
4+
python get_post.py --post-id 148213977
5+
python get_post.py --post-id 148213977 --markdown > the-quiet-launch.md
6+
"""
7+
import argparse
8+
import json
9+
10+
from cotlet_client import call, run
11+
12+
13+
def main() -> None:
14+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
15+
parser.add_argument("--post-id", required=True, help="Post ID from list_posts.py")
16+
parser.add_argument("--markdown", action="store_true", help="Print only the post as Markdown")
17+
args = parser.parse_args()
18+
19+
post = call("get_post", {"post_id": args.post_id})
20+
if args.markdown:
21+
print(f"# {post['title']}\n")
22+
if post.get("subtitle"):
23+
print(f"_{post['subtitle']}_\n")
24+
print(post.get("body_markdown", ""))
25+
else:
26+
print(json.dumps(post, indent=2, ensure_ascii=False))
27+
28+
29+
if __name__ == "__main__":
30+
run(main)

‎list_posts.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/env python3
2+
"""List the posts of your Substack publication: published, drafts or scheduled, newest first.
3+
4+
python list_posts.py
5+
python list_posts.py --status scheduled
6+
python list_posts.py --status draft --out drafts.csv
7+
8+
Scheduled posts include their publish time. Pages are followed automatically.
9+
"""
10+
import argparse
11+
12+
from cotlet_client import paginate, run, write_rows
13+
14+
15+
def main() -> None:
16+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
17+
parser.add_argument("--status", choices=["published", "draft", "scheduled"], default="published")
18+
parser.add_argument("--max", type=int, help="Stop after this many posts")
19+
parser.add_argument("--out", help="Save to .csv or .json instead of printing")
20+
args = parser.parse_args()
21+
22+
rows = list(paginate("list_posts", {"status": args.status, "limit": 50}, "posts", args.max))
23+
write_rows(rows, args.out)
24+
25+
26+
if __name__ == "__main__":
27+
run(main)

‎publish_note.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env python3
2+
"""Post a Substack Note now or at a set time, optionally with a link card to one of your posts.
3+
4+
python publish_note.py --body "Tuesday's essay is out." --link-latest
5+
python publish_note.py --body "Part two is coming." --link-post-id 148213977 --at 2026-10-14T12:00:00-04:00
6+
7+
Notes are up to 1,000 characters and are not emailed to subscribers.
8+
"""
9+
import argparse
10+
import json
11+
12+
from cotlet_client import call, run
13+
14+
15+
def main() -> None:
16+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
17+
parser.add_argument("--body", required=True, help="Note text, up to 1,000 characters")
18+
link = parser.add_mutually_exclusive_group()
19+
link.add_argument("--link-post-id", help="Attach this post as a card")
20+
link.add_argument("--link-latest", action="store_true", help="Attach your latest published post")
21+
parser.add_argument("--at", help="ISO 8601 time with offset. Omit to post now")
22+
args = parser.parse_args()
23+
if len(args.body) > 1000:
24+
parser.error(f"Note is {len(args.body)} characters, the limit is 1,000")
25+
26+
post_id = args.link_post_id
27+
if args.link_latest:
28+
posts = call("list_posts", {"status": "published", "limit": 1}).get("posts", [])
29+
post_id = posts[0]["post_id"] if posts else None
30+
31+
print(json.dumps(call("publish_note", {"body": args.body, "link_post_id": post_id, "publish_at": args.at}), indent=2))
32+
33+
34+
if __name__ == "__main__":
35+
run(main)

‎publish_post.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#!/usr/bin/env python3
2+
"""Publish a Substack draft now, on the web and by email unless you pass --web-only.
3+
4+
python publish_post.py --post-id 148420031
5+
python publish_post.py --post-id 148420031 --web-only
6+
7+
A sent email cannot be recalled. By default Cotlet asks you to confirm first.
8+
"""
9+
import argparse
10+
import json
11+
12+
from cotlet_client import call, run
13+
14+
15+
def main() -> None:
16+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
17+
parser.add_argument("--post-id", required=True, help="Draft ID")
18+
parser.add_argument("--web-only", action="store_true", help="Publish on the web without emailing subscribers")
19+
args = parser.parse_args()
20+
21+
print(json.dumps(call("publish_post", {"post_id": args.post_id, "send_email": not args.web_only}), indent=2))
22+
23+
24+
if __name__ == "__main__":
25+
run(main)

‎schedule_post.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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)

‎update_draft.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env python3
2+
"""Change the title, subtitle, body or audience of a Substack draft. Published posts are not changed.
3+
4+
python update_draft.py --post-id 148420031 --file essay.md
5+
python update_draft.py --post-id 148420031 --title "Notes on pricing, part 2"
6+
"""
7+
import argparse
8+
import json
9+
10+
from cotlet_client import call, read_markdown, run
11+
12+
13+
def main() -> None:
14+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
15+
parser.add_argument("--post-id", required=True, help="Draft ID from create_draft.py or list_posts.py --status draft")
16+
parser.add_argument("--file", help="Replace title, subtitle and body from this Markdown file")
17+
parser.add_argument("--title")
18+
parser.add_argument("--subtitle")
19+
parser.add_argument("--audience", choices=["everyone", "only_paid", "founding"])
20+
args = parser.parse_args()
21+
22+
fields = {}
23+
if args.file:
24+
title, subtitle, body = read_markdown(args.file)
25+
fields.update({"title": title, "subtitle": subtitle, "body_markdown": body})
26+
fields.update({k: v for k, v in {"title": args.title, "subtitle": args.subtitle, "audience": args.audience}.items() if v})
27+
fields = {k: v for k, v in fields.items() if v is not None}
28+
if not fields:
29+
parser.error("pass --file or at least one of --title, --subtitle, --audience")
30+
31+
print(json.dumps(call("update_draft", {"post_id": args.post_id, "fields": fields}), indent=2))
32+
33+
34+
if __name__ == "__main__":
35+
run(main)

0 commit comments

Comments
 (0)