Skip to content

Commit b04d4fb

Browse files
committed
Add subscriber export and import
1 parent 2119f2c commit b04d4fb

2 files changed

Lines changed: 85 additions & 0 deletions

File tree

‎add_subscribers.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env python3
2+
"""Add emails from a CSV file (or the command line) to your Substack as free subscribers, or give them a complimentary paid plan.
3+
4+
python add_subscribers.py --csv workshop.csv --dry-run
5+
python add_subscribers.py --csv workshop.csv
6+
python add_subscribers.py --email maya@example.com --plan comp --comp-until 2026-12-31
7+
8+
The CSV needs an "email" column. Substack sends each new subscriber its usual
9+
welcome email. Add only people who agreed to hear from you.
10+
"""
11+
import argparse
12+
import csv
13+
14+
from cotlet_client import call, paginate, run
15+
16+
17+
def main() -> None:
18+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
19+
parser.add_argument("--csv", help="CSV file with an email column")
20+
parser.add_argument("--email", action="append", default=[], help="Email to add (repeatable)")
21+
parser.add_argument("--plan", choices=["free", "comp"], default="free")
22+
parser.add_argument("--comp-until", help="End of the complimentary plan (YYYY-MM-DD), with --plan comp")
23+
parser.add_argument("--dry-run", action="store_true", help="Only show who would be added")
24+
args = parser.parse_args()
25+
26+
emails = list(args.email)
27+
if args.csv:
28+
with open(args.csv, newline="", encoding="utf-8-sig") as fh:
29+
emails += [row["email"].strip() for row in csv.DictReader(fh) if row.get("email", "").strip()]
30+
if not emails:
31+
parser.error("pass --csv FILE or --email ADDRESS")
32+
33+
existing = {s["email"].lower() for s in paginate("list_subscribers", {"limit": 500}, "subscribers")}
34+
new = sorted({e.lower() for e in emails} - existing)
35+
print(f"{len(emails)} emails, {len(emails) - len(new)} already subscribed, {len(new)} to add")
36+
for email in new:
37+
if args.dry_run:
38+
print(f" would add {email} ({args.plan})")
39+
continue
40+
result = call("add_subscriber", {"email": email, "plan": args.plan, "comp_until": args.comp_until})
41+
print(f" {email}: {result.get('status')}")
42+
43+
44+
if __name__ == "__main__":
45+
run(main)

‎export_subscribers.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env python3
2+
"""Export your Substack subscribers to CSV: email, plan, join date and activity rating.
3+
4+
python export_subscribers.py --out subscribers.csv
5+
python export_subscribers.py --plan paid --out paid.csv
6+
python export_subscribers.py --since 2026-10-01 --out new-this-month.json
7+
8+
Without filters you get Substack's own full export (the same CSV as Export in
9+
the subscriber dashboard). With --plan or --since the list is paged through
10+
list_subscribers instead.
11+
"""
12+
import argparse
13+
import sys
14+
import urllib.request
15+
16+
from cotlet_client import USER_AGENT, call, paginate, run, write_rows
17+
18+
19+
def main() -> None:
20+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
21+
parser.add_argument("--plan", choices=["all", "free", "paid", "comp", "founding"], default="all")
22+
parser.add_argument("--since", help="Only subscribers who joined on or after this date (YYYY-MM-DD)")
23+
parser.add_argument("--max", type=int, help="Stop after this many subscribers")
24+
parser.add_argument("--out", help="Save to .csv or .json instead of printing")
25+
args = parser.parse_args()
26+
27+
if args.plan == "all" and not args.since and not args.max and args.out and args.out.lower().endswith(".csv"):
28+
export = call("export_subscribers")
29+
req = urllib.request.Request(export["download_url"], headers={"User-Agent": USER_AGENT})
30+
with urllib.request.urlopen(req, timeout=300) as resp, open(args.out, "wb") as fh:
31+
fh.write(resp.read())
32+
print(f"Saved {export['rows']} subscribers to {args.out}", file=sys.stderr)
33+
return
34+
35+
params = {"plan": args.plan, "since": args.since, "limit": 500}
36+
write_rows(list(paginate("list_subscribers", params, "subscribers", args.max)), args.out)
37+
38+
39+
if __name__ == "__main__":
40+
run(main)

0 commit comments

Comments
 (0)