Skip to content

Commit 9fa0bb1

Browse files
committed
Add comments, stats, weekly report and a scheduled workflow example
1 parent b04d4fb commit 9fa0bb1

6 files changed

Lines changed: 224 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Example: run Substack automations on a schedule with GitHub Actions.
2+
# 1. Add COTLET_API_KEY under Settings > Secrets and variables > Actions.
3+
# 2. Uncomment `schedule`. Reading stats and comments needs no confirmation.
4+
name: Substack weekly
5+
6+
on:
7+
workflow_dispatch:
8+
# schedule:
9+
# - cron: "0 8 * * 1"
10+
11+
jobs:
12+
substack:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
- uses: actions/setup-python@v5
17+
with:
18+
python-version: "3.12"
19+
- name: Weekly subscriber and revenue report
20+
env:
21+
COTLET_API_KEY: ${{ secrets.COTLET_API_KEY }}
22+
run: python weekly_report.py
23+
- name: Comments waiting for a reply
24+
env:
25+
COTLET_API_KEY: ${{ secrets.COTLET_API_KEY }}
26+
run: python unanswered_comments.py --days 7

‎post_stats.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/usr/bin/env python3
2+
"""Opens, open rate, views, likes, restacks and new subscribers for your recent Substack posts, in one table.
3+
4+
python post_stats.py --last 10
5+
python post_stats.py --since 2026-09-01 --out post-stats.csv
6+
python post_stats.py --post-id 148213977
7+
8+
Numbers keep growing for days after a send, so a post sent yesterday is marked as early.
9+
"""
10+
import argparse
11+
from datetime import datetime, timedelta, timezone
12+
13+
from cotlet_client import call, paginate, run, write_rows
14+
15+
16+
def main() -> None:
17+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
18+
parser.add_argument("--post-id", help="Only this post")
19+
parser.add_argument("--last", type=int, default=10, help="Number of recent published posts. Default: 10")
20+
parser.add_argument("--since", help="Only posts published on or after this date (YYYY-MM-DD)")
21+
parser.add_argument("--out", help="Save to .csv or .json instead of printing")
22+
args = parser.parse_args()
23+
24+
if args.post_id:
25+
posts = [call("get_post", {"post_id": args.post_id})]
26+
else:
27+
posts = []
28+
for post in paginate("list_posts", {"status": "published", "limit": 50}, "posts"):
29+
if args.since and post["published_at"][:10] < args.since:
30+
break
31+
posts.append(post)
32+
if not args.since and len(posts) >= args.last:
33+
break
34+
35+
early = datetime.now(timezone.utc) - timedelta(days=2)
36+
rows = []
37+
for post in posts:
38+
stats = call("get_post_stats", {"post_id": post["post_id"]})
39+
published = post.get("published_at")
40+
rows.append({
41+
"title": post["title"],
42+
"published_at": published,
43+
**{k: v for k, v in stats.items() if k != "post_id"},
44+
"early": bool(published) and datetime.fromisoformat(published.replace("Z", "+00:00")) > early,
45+
"url": post.get("url"),
46+
})
47+
write_rows(rows, args.out)
48+
49+
50+
if __name__ == "__main__":
51+
run(main)

‎publication_stats.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/usr/bin/env python3
2+
"""Total, paid, new and churned Substack subscribers and revenue for the last 7, 30 or 90 days.
3+
4+
python publication_stats.py --period 7d
5+
"""
6+
import argparse
7+
8+
from cotlet_client import call, run
9+
10+
11+
def main() -> None:
12+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
13+
parser.add_argument("--period", choices=["7d", "30d", "90d"], default="30d")
14+
args = parser.parse_args()
15+
16+
s = call("get_publication_stats", {"period": args.period})
17+
paid = s.get("subscribers_paid") or 0
18+
churn = (s["subscribers_churned"] / paid * 100) if paid else 0
19+
print(f"Substack publication ({s['period']})")
20+
print(f"Subscribers: {s['subscribers_total']:,}")
21+
print(f"Paid: {paid:,}")
22+
print(f"New: +{s['subscribers_new']:,}")
23+
print(f"Churned: -{s['subscribers_churned']:,} ({churn:.1f}% of paid)")
24+
print(f"Revenue: ${s.get('revenue_usd', 0):,}")
25+
26+
27+
if __name__ == "__main__":
28+
run(main)

‎reply_to_comment.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/usr/bin/env python3
2+
"""Reply as you under a comment on one of your Substack posts.
3+
4+
python reply_to_comment.py --comment-id c_90417 --body "Thank you, Maya. Pricing is the whole of part two."
5+
6+
The commenter gets Substack's usual reply notification. By default Cotlet asks you to confirm first.
7+
"""
8+
import argparse
9+
import json
10+
11+
from cotlet_client import call, run
12+
13+
14+
def main() -> None:
15+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
16+
parser.add_argument("--comment-id", required=True, help="Comment ID from unanswered_comments.py")
17+
parser.add_argument("--body", required=True, help="Reply text")
18+
args = parser.parse_args()
19+
20+
print(json.dumps(call("reply_to_comment", {"comment_id": args.comment_id, "body": args.body}), indent=2))
21+
22+
23+
if __name__ == "__main__":
24+
run(main)

‎unanswered_comments.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+
"""Comments on your recent Substack posts that you have not answered yet.
3+
4+
python unanswered_comments.py --last 3
5+
python unanswered_comments.py --days 14 --out unanswered.csv
6+
7+
Reply with reply_to_comment.py, or let your AI agent draft the replies over MCP.
8+
"""
9+
import argparse
10+
from datetime import date, timedelta
11+
12+
from cotlet_client import call, paginate, run, write_rows
13+
14+
15+
def main() -> None:
16+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
17+
window = parser.add_mutually_exclusive_group()
18+
window.add_argument("--last", type=int, default=3, help="Check the last N published posts. Default: 3")
19+
window.add_argument("--days", type=int, help="Check every post published in the last N days")
20+
parser.add_argument("--out", help="Save to .csv or .json instead of printing")
21+
args = parser.parse_args()
22+
23+
since = (date.today() - timedelta(days=args.days)).isoformat() if args.days else None
24+
rows = []
25+
checked = 0
26+
for post in paginate("list_posts", {"status": "published", "limit": 50}, "posts"):
27+
if since and post["published_at"][:10] < since:
28+
break
29+
if not since and checked >= args.last:
30+
break
31+
checked += 1
32+
for c in call("list_comments", {"post_id": post["post_id"], "unanswered_only": True}).get("comments", []):
33+
rows.append({"post": post["title"], "comment_id": c["comment_id"], "author": c["author"],
34+
"body": c["body"], "created_at": c["created_at"], "post_url": post.get("url")})
35+
print(f"Checked {checked} posts, {len(rows)} comments waiting for you")
36+
write_rows(rows, args.out)
37+
38+
39+
if __name__ == "__main__":
40+
run(main)

‎weekly_report.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/usr/bin/env python3
2+
"""Weekly Substack report: new, paid and churned subscribers, revenue, and how each post of the week did.
3+
4+
python weekly_report.py
5+
python weekly_report.py --save last-week.json
6+
7+
With --save the totals are stored, and the next run with the same file adds a
8+
week-on-week line. Revenue is Substack's gross annualized figure, not cash received.
9+
"""
10+
import argparse
11+
import json
12+
from datetime import datetime, timedelta, timezone
13+
from pathlib import Path
14+
15+
from cotlet_client import call, paginate, run
16+
17+
18+
def main() -> None:
19+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
20+
parser.add_argument("--save", help="JSON file to compare with and update, e.g. last-week.json")
21+
args = parser.parse_args()
22+
23+
week = call("get_publication_stats", {"period": "7d"})
24+
month = call("get_publication_stats", {"period": "30d"})
25+
weekly_churn = month["subscribers_churned"] / 30 * 7
26+
27+
print(f"Subscribers: {week['subscribers_total']:,} total (+{week['subscribers_new']} new)")
28+
flag = " (flagged: above the 30-day weekly average)" if week["subscribers_churned"] > weekly_churn else ""
29+
print(f"Paid: {week['subscribers_paid']:,}, churned {week['subscribers_churned']} "
30+
f"(30-day weekly average: {weekly_churn:.0f}){flag}")
31+
print(f"Gross annualized revenue: ${week.get('revenue_usd', 0):,}")
32+
33+
now = datetime.now(timezone.utc)
34+
print("Posts this week:")
35+
for post in paginate("list_posts", {"status": "published", "limit": 50}, "posts"):
36+
published = datetime.fromisoformat(post["published_at"].replace("Z", "+00:00"))
37+
if published < now - timedelta(days=7):
38+
break
39+
s = call("get_post_stats", {"post_id": post["post_id"]})
40+
early = " (early, under 2 days of data)" if published > now - timedelta(days=2) else ""
41+
print(f' "{post["title"]}": open rate {s["open_rate"]:.1%}, {s["subscribers_gained"]} new subscribers{early}')
42+
43+
if args.save:
44+
path = Path(args.save)
45+
if path.exists():
46+
prev = json.loads(path.read_text())
47+
print(f"Week on week: total {week['subscribers_total'] - prev['subscribers_total']:+,}, "
48+
f"paid {week['subscribers_paid'] - prev['subscribers_paid']:+,}")
49+
else:
50+
print("Week on week: no stored copy yet, saved this week for next time")
51+
path.write_text(json.dumps(week, indent=2))
52+
53+
54+
if __name__ == "__main__":
55+
run(main)

0 commit comments

Comments
 (0)