Skip to content

Commit e8bec10

Browse files
committed
Add Substack API client
0 parents  commit e8bec10

6 files changed

Lines changed: 204 additions & 0 deletions

File tree

‎.env.example‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Your Cotlet API key. Get it at https://cotlet.dev/app/signup?app=substack
2+
COTLET_API_KEY=cotlet_...

‎.gitignore‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.env
2+
__pycache__/
3+
*.pyc
4+
.venv/
5+
*.csv
6+
*.json
7+
!.github/**/*.json
8+
!mcp.json

‎LICENSE‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Take Group sp. z o.o. (Cotlet)
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎assets/logo-white.svg‎

Lines changed: 1 addition & 0 deletions
Loading

‎assets/logo.svg‎

Lines changed: 1 addition & 0 deletions
Loading

‎cotlet_client.py‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Tiny client for the Substack API hosted by Cotlet (https://cotlet.dev).
2+
3+
Every script in this repo imports it. Standard library only, Python 3.9+.
4+
5+
from cotlet_client import call
6+
drafts = call("list_posts", {"status": "draft"})["posts"]
7+
8+
Set COTLET_API_KEY in your environment or in a .env file next to the scripts.
9+
Get a key at https://cotlet.dev/app/signup?app=substack
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import csv
15+
import json
16+
import os
17+
import sys
18+
import time
19+
import urllib.error
20+
import urllib.request
21+
from pathlib import Path
22+
from typing import Any, Iterator
23+
24+
API_BASE = os.environ.get("COTLET_API_BASE", "https://cotlet.dev/api/v1")
25+
APP = "substack"
26+
SIGNUP_URL = "https://cotlet.dev/app/signup?app=substack"
27+
DOCS_URL = "https://cotlet.dev/integrations/substack-api-mcp-server"
28+
USER_AGENT = "cotletdev-substack-api-mcp-server/1.0 (+https://github.com/cotletdev/substack-api-mcp-server)"
29+
30+
31+
class CotletError(Exception):
32+
"""An error returned by the API, with the code and the link to act on."""
33+
34+
def __init__(self, status: int, body: dict[str, Any]):
35+
self.status = status
36+
self.code = body.get("error", "unknown_error")
37+
self.body = body
38+
link = body.get("connect_url") or body.get("request_access_url") or body.get("docs_url")
39+
message = body.get("message", "The request failed.")
40+
super().__init__(f"{self.code}: {message}" + (f"\n -> {link}" if link else ""))
41+
42+
43+
def _load_dotenv() -> None:
44+
"""Read KEY=value lines from .env next to this file, without overriding the environment."""
45+
path = Path(__file__).with_name(".env")
46+
if not path.exists():
47+
return
48+
for line in path.read_text().splitlines():
49+
line = line.strip()
50+
if not line or line.startswith("#") or "=" not in line:
51+
continue
52+
key, value = line.split("=", 1)
53+
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
54+
55+
56+
def api_key() -> str:
57+
_load_dotenv()
58+
key = os.environ.get("COTLET_API_KEY", "").strip()
59+
if not key:
60+
sys.exit(
61+
"COTLET_API_KEY is not set.\n"
62+
f"1. Request access and get your key: {SIGNUP_URL}\n"
63+
"2. Connect your Substack account once in the Cotlet console.\n"
64+
"3. export COTLET_API_KEY=cotlet_... (or put it in .env)"
65+
)
66+
return key
67+
68+
69+
def _request(method: str, url: str, payload: dict[str, Any] | None = None) -> tuple[int, dict[str, Any]]:
70+
data = json.dumps(payload or {}).encode() if method == "POST" else None
71+
req = urllib.request.Request(url, data=data, method=method)
72+
req.add_header("Authorization", f"Bearer {api_key()}")
73+
req.add_header("Content-Type", "application/json")
74+
req.add_header("Accept", "application/json")
75+
req.add_header("User-Agent", USER_AGENT)
76+
for attempt in range(5):
77+
try:
78+
with urllib.request.urlopen(req, timeout=120) as resp:
79+
return resp.status, json.loads(resp.read() or b"{}")
80+
except urllib.error.HTTPError as err:
81+
body = json.loads(err.read() or b"{}")
82+
if err.code == 429 and attempt < 4:
83+
time.sleep(int(err.headers.get("Retry-After", "5")))
84+
continue
85+
raise CotletError(err.code, body) from None
86+
raise CotletError(429, {"error": "rate_limited", "message": "Still rate limited after 5 tries."})
87+
88+
89+
def wait_for_call(call_id: str, confirm_url: str, timeout: int = 900) -> dict[str, Any]:
90+
"""Write actions wait for your OK by default. Poll until you approve or decline."""
91+
print(f"Waiting for your OK: {confirm_url}", file=sys.stderr)
92+
deadline = time.time() + timeout
93+
while time.time() < deadline:
94+
time.sleep(5)
95+
_, body = _request("GET", f"{API_BASE}/calls/{call_id}")
96+
if body.get("status") not in ("awaiting_confirmation", "running", "queued"):
97+
return body.get("result", body)
98+
raise TimeoutError(f"No confirmation within {timeout}s for call {call_id}.")
99+
100+
101+
def call(endpoint: str, params: dict[str, Any] | None = None, wait: bool = True) -> dict[str, Any]:
102+
"""Call one Substack endpoint, e.g. call("list_posts", {"status": "draft"})."""
103+
clean = {k: v for k, v in (params or {}).items() if v is not None}
104+
status, body = _request("POST", f"{API_BASE}/{APP}/{endpoint}", clean)
105+
if status == 202 and body.get("status") == "awaiting_confirmation":
106+
return wait_for_call(body["call_id"], body["confirm_url"]) if wait else body
107+
return body
108+
109+
110+
def paginate(endpoint: str, params: dict[str, Any], key: str, max_items: int | None = None) -> Iterator[dict[str, Any]]:
111+
"""Yield every item of a list endpoint, following next_cursor."""
112+
params = dict(params)
113+
seen = 0
114+
while True:
115+
page = call(endpoint, params)
116+
for item in page.get(key, []):
117+
yield item
118+
seen += 1
119+
if max_items and seen >= max_items:
120+
return
121+
cursor = page.get("next_cursor")
122+
if not cursor:
123+
return
124+
params["cursor"] = cursor
125+
126+
127+
def write_rows(rows: list[dict[str, Any]], out: str | None) -> None:
128+
"""Print JSON to stdout, or save .csv / .json when a path is given."""
129+
if not out:
130+
print(json.dumps(rows, indent=2, ensure_ascii=False))
131+
return
132+
path = Path(out)
133+
if path.suffix.lower() == ".csv":
134+
fields: list[str] = []
135+
for row in rows:
136+
fields += [k for k in row if k not in fields]
137+
with path.open("w", newline="", encoding="utf-8") as fh:
138+
writer = csv.DictWriter(fh, fieldnames=fields)
139+
writer.writeheader()
140+
for row in rows:
141+
writer.writerow({k: json.dumps(v, ensure_ascii=False) if isinstance(v, (dict, list)) else v for k, v in row.items()})
142+
else:
143+
path.write_text(json.dumps(rows, indent=2, ensure_ascii=False), encoding="utf-8")
144+
print(f"Saved {len(rows)} rows to {path}", file=sys.stderr)
145+
146+
147+
def read_markdown(path: str) -> tuple[str, str | None, str]:
148+
"""Split a .md file into title (first # heading), optional subtitle (a ## or _italic_ line right after it) and body."""
149+
lines = Path(path).read_text(encoding="utf-8").splitlines()
150+
title, subtitle, start = Path(path).stem.replace("-", " ").capitalize(), None, 0
151+
for i, line in enumerate(lines):
152+
if line.strip():
153+
if line.startswith("# "):
154+
title, start = line[2:].strip(), i + 1
155+
break
156+
rest = lines[start:]
157+
while rest and not rest[0].strip():
158+
rest.pop(0)
159+
if rest and (rest[0].startswith("## ") or (rest[0].startswith("_") and rest[0].rstrip().endswith("_"))):
160+
subtitle = rest.pop(0).lstrip("#").strip().strip("_").strip()
161+
return title, subtitle, "\n".join(rest).strip() + "\n"
162+
163+
164+
def run(main) -> None:
165+
"""Run a script's main() and turn API errors into a readable message."""
166+
try:
167+
main()
168+
except CotletError as err:
169+
sys.exit(f"Error {err.status} {err}")
170+
except KeyboardInterrupt:
171+
sys.exit(130)

0 commit comments

Comments
 (0)