python-substack 0.1.24__py3-none-any.whl → 0.1.26__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_substack-0.1.26.dist-info/METADATA +433 -0
- python_substack-0.1.26.dist-info/RECORD +13 -0
- {python_substack-0.1.24.dist-info → python_substack-0.1.26.dist-info}/WHEEL +1 -1
- python_substack-0.1.26.dist-info/entry_points.txt +7 -0
- substack/__init__.py +2 -2
- substack/api.py +93 -16
- substack/cli.py +583 -0
- substack_mcp/mcp_server.py +293 -0
- python_substack-0.1.24.dist-info/METADATA +0 -331
- python_substack-0.1.24.dist-info/RECORD +0 -10
- {python_substack-0.1.24.dist-info → python_substack-0.1.26.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
except ImportError:
|
|
9
|
+
load_dotenv = None
|
|
10
|
+
|
|
11
|
+
from mcp.server.fastmcp import FastMCP
|
|
12
|
+
|
|
13
|
+
from substack.api import Api
|
|
14
|
+
from substack.post import Post
|
|
15
|
+
|
|
16
|
+
if load_dotenv is not None:
|
|
17
|
+
load_dotenv()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_api() -> Api:
|
|
21
|
+
email = os.getenv("EMAIL")
|
|
22
|
+
password = os.getenv("PASSWORD")
|
|
23
|
+
cookies_path = os.getenv("COOKIES_PATH")
|
|
24
|
+
cookies_string = os.getenv("COOKIES_STRING")
|
|
25
|
+
publication_url = os.getenv("PUBLICATION_URL")
|
|
26
|
+
|
|
27
|
+
if cookies_path or cookies_string:
|
|
28
|
+
return Api(
|
|
29
|
+
cookies_path=cookies_path,
|
|
30
|
+
cookies_string=cookies_string,
|
|
31
|
+
publication_url=publication_url,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if email and password:
|
|
35
|
+
return Api(
|
|
36
|
+
email=email,
|
|
37
|
+
password=password,
|
|
38
|
+
publication_url=publication_url,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"Missing Substack auth configuration: set EMAIL/PASSWORD or COOKIES_PATH/COOKIES_STRING"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _normalize_tags(tags: Optional[Any]) -> List[str]:
|
|
47
|
+
if tags is None:
|
|
48
|
+
return []
|
|
49
|
+
if isinstance(tags, str):
|
|
50
|
+
return [tags]
|
|
51
|
+
if isinstance(tags, list):
|
|
52
|
+
return [str(tag) for tag in tags]
|
|
53
|
+
raise ValueError("tags must be a string or a list of strings")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
mcp = FastMCP("substack")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@mcp.tool()
|
|
60
|
+
async def post_draft_from_markdown(
|
|
61
|
+
title: str,
|
|
62
|
+
markdown: str,
|
|
63
|
+
subtitle: Optional[str] = "",
|
|
64
|
+
audience: str = "everyone",
|
|
65
|
+
write_comment_permissions: str = "everyone",
|
|
66
|
+
search_engine_title: Optional[str] = None,
|
|
67
|
+
search_engine_description: Optional[str] = None,
|
|
68
|
+
slug: Optional[str] = None,
|
|
69
|
+
draft_section_id: Optional[int] = None,
|
|
70
|
+
tags: Optional[Any] = None,
|
|
71
|
+
prepublish: bool = False,
|
|
72
|
+
publish: bool = False,
|
|
73
|
+
send: bool = True,
|
|
74
|
+
share_automatically: bool = False,
|
|
75
|
+
) -> Dict[str, Any]:
|
|
76
|
+
"""Create or update a Substack draft from Markdown.
|
|
77
|
+
|
|
78
|
+
This tool builds a Substack `Post` from markdown content and posts a draft.
|
|
79
|
+
It supports optional tag assignment, prepublish (setup check), and publishing.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
title: Draft title.
|
|
83
|
+
markdown: Markdown body content.
|
|
84
|
+
subtitle: Optional subtitle text.
|
|
85
|
+
audience: One of `everyone`, `only_paid`, `founding`, `only_free`.
|
|
86
|
+
write_comment_permissions: One of `none`, `only_paid`, `everyone`.
|
|
87
|
+
search_engine_title: Optional title for search engine optimization.
|
|
88
|
+
search_engine_description: Optional description for search engine optimization.
|
|
89
|
+
slug: Optional URL slug for the post.
|
|
90
|
+
draft_section_id: Optional section ID for the draft.
|
|
91
|
+
tags: Tag or list of tags to attach to the post.
|
|
92
|
+
prepublish: If true, calls `prepublish_draft` after creation.
|
|
93
|
+
publish: If true, calls `publish_draft` after creation (and optionally prepublish).
|
|
94
|
+
send: Passed to `publish_draft` for newsletter delivery.
|
|
95
|
+
share_automatically: Passed to `publish_draft`.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
dict containing drafted post (`draft`), optional `tags`, `prepublish`, `publish` results.
|
|
99
|
+
|
|
100
|
+
Examples:
|
|
101
|
+
With the YAML structure from the README, a caller can map fields like:
|
|
102
|
+
|
|
103
|
+
```yaml
|
|
104
|
+
title: "My Post Title"
|
|
105
|
+
subtitle: "My Post Subtitle"
|
|
106
|
+
audience: "everyone"
|
|
107
|
+
write_comment_permissions: "everyone"
|
|
108
|
+
markdown: |
|
|
109
|
+
# Hello
|
|
110
|
+
|
|
111
|
+
This is the body.
|
|
112
|
+
|
|
113
|
+
tags:
|
|
114
|
+
- python
|
|
115
|
+
- substack
|
|
116
|
+
prepublish: true
|
|
117
|
+
publish: true
|
|
118
|
+
send: false
|
|
119
|
+
share_automatically: true
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Then invoke via MCP directly:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from substack_mcp.mcp_server import post_draft_from_markdown
|
|
126
|
+
|
|
127
|
+
result = await post_draft_from_markdown(
|
|
128
|
+
title='My Post Title',
|
|
129
|
+
markdown='# Hello\n\nThis is the body.',
|
|
130
|
+
subtitle='My Post Subtitle',
|
|
131
|
+
audience='everyone',
|
|
132
|
+
write_comment_permissions='everyone',
|
|
133
|
+
tags=['python', 'substack'],
|
|
134
|
+
prepublish=True,
|
|
135
|
+
publish=False, # set true when ready
|
|
136
|
+
)
|
|
137
|
+
print(result)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
A longer process with manual prepublish/publish calls:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from substack_mcp.mcp_server import (
|
|
144
|
+
post_draft_from_markdown,
|
|
145
|
+
prepublish_draft,
|
|
146
|
+
publish_draft,
|
|
147
|
+
add_tags,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
d = await post_draft_from_markdown(
|
|
151
|
+
title='Long flow',
|
|
152
|
+
markdown='Content',
|
|
153
|
+
tags=['a','b'],
|
|
154
|
+
publish=False,
|
|
155
|
+
)
|
|
156
|
+
draft_id = d['draft']['id']
|
|
157
|
+
|
|
158
|
+
await add_tags(draft_id, ['post-tag', 'news'])
|
|
159
|
+
await prepublish_draft(draft_id)
|
|
160
|
+
await publish_draft(draft_id, send=True, share_automatically=True)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
This docstring example is meant to mirror the YAML-driven workflow and show how to decompose the same operations into explicit tool calls.
|
|
164
|
+
"""
|
|
165
|
+
client = get_api()
|
|
166
|
+
user_id = client.get_user_id()
|
|
167
|
+
|
|
168
|
+
post = Post(
|
|
169
|
+
title=title,
|
|
170
|
+
subtitle=subtitle or "",
|
|
171
|
+
user_id=user_id,
|
|
172
|
+
audience=audience,
|
|
173
|
+
write_comment_permissions=write_comment_permissions,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
post.from_markdown(markdown, api=client)
|
|
177
|
+
|
|
178
|
+
draft = client.post_draft(post.get_draft())
|
|
179
|
+
|
|
180
|
+
update_payload: Dict[str, Any] = {}
|
|
181
|
+
if search_engine_title:
|
|
182
|
+
update_payload["search_engine_title"] = search_engine_title
|
|
183
|
+
if search_engine_description:
|
|
184
|
+
update_payload["search_engine_description"] = search_engine_description
|
|
185
|
+
if slug:
|
|
186
|
+
update_payload["slug"] = slug
|
|
187
|
+
if draft_section_id is not None:
|
|
188
|
+
update_payload["draft_section_id"] = draft_section_id
|
|
189
|
+
|
|
190
|
+
if update_payload:
|
|
191
|
+
draft = client.put_draft(draft.get("id"), **update_payload)
|
|
192
|
+
|
|
193
|
+
tags_list = _normalize_tags(tags)
|
|
194
|
+
tags_result = None
|
|
195
|
+
if tags_list:
|
|
196
|
+
tags_result = client.add_tags_to_post(draft.get("id"), tags_list)
|
|
197
|
+
|
|
198
|
+
prepublish_result = None
|
|
199
|
+
if prepublish:
|
|
200
|
+
prepublish_result = client.prepublish_draft(draft.get("id"))
|
|
201
|
+
|
|
202
|
+
publish_result = None
|
|
203
|
+
if publish:
|
|
204
|
+
publish_result = client.publish_draft(
|
|
205
|
+
draft.get("id"), send=send, share_automatically=share_automatically
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
"draft": draft,
|
|
210
|
+
"tags": tags_result,
|
|
211
|
+
"prepublish": prepublish_result,
|
|
212
|
+
"publish": publish_result,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@mcp.tool()
|
|
217
|
+
async def put_draft(
|
|
218
|
+
draft_id: int,
|
|
219
|
+
update_payload: Dict[str, Any],
|
|
220
|
+
) -> Dict[str, Any]:
|
|
221
|
+
"""Update an existing draft by draft ID.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
draft_id: target draft identifier.
|
|
225
|
+
update_payload: dict of fields supported by Substack `put_draft` (e.g. `slug`, `draft_section_id`).
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
API response dict for the updated draft.
|
|
229
|
+
"""
|
|
230
|
+
client = get_api()
|
|
231
|
+
return client.put_draft(draft_id, **update_payload)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@mcp.tool()
|
|
235
|
+
async def add_tags(draft_id: int, tags: Any) -> Dict[str, Any]:
|
|
236
|
+
"""Add tags to a specific draft/post.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
draft_id: target draft identifier.
|
|
240
|
+
tags: string or list of tag names (e.g. `"tech"` or `["tech", "python"]`).
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Response from `add_tags_to_post` (tag IDs + names).
|
|
244
|
+
"""
|
|
245
|
+
client = get_api()
|
|
246
|
+
tags_list = _normalize_tags(tags)
|
|
247
|
+
if not tags_list:
|
|
248
|
+
raise ValueError("tags is required and cannot be empty")
|
|
249
|
+
return client.add_tags_to_post(draft_id, tags_list)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@mcp.tool()
|
|
253
|
+
async def prepublish_draft(draft_id: int) -> Dict[str, Any]:
|
|
254
|
+
"""Invoke prepublish checks for a draft.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
draft_id: target draft identifier.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Prepublish response dict from Substack API.
|
|
261
|
+
"""
|
|
262
|
+
client = get_api()
|
|
263
|
+
return client.prepublish_draft(draft_id)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@mcp.tool()
|
|
267
|
+
async def publish_draft(
|
|
268
|
+
draft_id: int,
|
|
269
|
+
send: bool = True,
|
|
270
|
+
share_automatically: bool = False,
|
|
271
|
+
) -> Dict[str, Any]:
|
|
272
|
+
"""Publish a draft to live post state.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
draft_id: target draft identifier.
|
|
276
|
+
send: if False then do not send email to subscribers.
|
|
277
|
+
share_automatically: whether to auto-share (e.g. social propagation).
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Response from Substack `publish_draft`.
|
|
281
|
+
"""
|
|
282
|
+
client = get_api()
|
|
283
|
+
return client.publish_draft(
|
|
284
|
+
draft_id, send=send, share_automatically=share_automatically
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def main() -> None:
|
|
289
|
+
mcp.run(transport="stdio")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
main()
|
|
@@ -1,331 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: python-substack
|
|
3
|
-
Version: 0.1.24
|
|
4
|
-
Summary: A Python wrapper around the Substack API.
|
|
5
|
-
License: MIT
|
|
6
|
-
License-File: LICENSE
|
|
7
|
-
Keywords: substack
|
|
8
|
-
Author: Paolo Mazza
|
|
9
|
-
Author-email: mazzapaolo2019@gmail.com
|
|
10
|
-
Requires-Python: >=3.10,<4.0
|
|
11
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
-
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
-
Requires-Dist: PyYAML (>=6.0,<7.0)
|
|
19
|
-
Requires-Dist: markdown-it-py (>=3.0,<4.0)
|
|
20
|
-
Requires-Dist: mdit-py-plugins (>=0.4,<0.5)
|
|
21
|
-
Requires-Dist: python-dotenv (>=1.2.1,<2.0.0)
|
|
22
|
-
Requires-Dist: requests (>=2.32.0,<3.0.0)
|
|
23
|
-
Project-URL: Homepage, https://github.com/ma2za/python-substack
|
|
24
|
-
Project-URL: Repository, https://github.com/ma2za/python-substack
|
|
25
|
-
Description-Content-Type: text/markdown
|
|
26
|
-
|
|
27
|
-
# Python Substack
|
|
28
|
-
|
|
29
|
-
This is an unofficial library providing a Python interface for [Substack](https://substack.com/).
|
|
30
|
-
I am in no way affiliated with Substack.
|
|
31
|
-
|
|
32
|
-
[](https://pepy.tech/project/python-substack)
|
|
33
|
-

|
|
34
|
-
---
|
|
35
|
-
|
|
36
|
-
# Installation
|
|
37
|
-
|
|
38
|
-
You can install python-substack using:
|
|
39
|
-
|
|
40
|
-
$ pip install python-substack
|
|
41
|
-
|
|
42
|
-
For the MCP server tools, install the extra dependency set:
|
|
43
|
-
|
|
44
|
-
$ poetry install --with mcp
|
|
45
|
-
|
|
46
|
-
> NOTE: We had to upgrade the package requirements to support Python 3.10 because 3.9 is basically vintage now. If you still run 3.9, please join us in the future (or bring snacks).
|
|
47
|
-
|
|
48
|
-
---
|
|
49
|
-
|
|
50
|
-
# Setup
|
|
51
|
-
|
|
52
|
-
Set the following environment variables by creating a **.env** file:
|
|
53
|
-
|
|
54
|
-
EMAIL=
|
|
55
|
-
PASSWORD=
|
|
56
|
-
PUBLICATION_URL= # Optional: your publication URL
|
|
57
|
-
COOKIES_PATH= # Optional: path to cookies JSON file
|
|
58
|
-
COOKIES_STRING= # Optional: cookie string for authentication
|
|
59
|
-
|
|
60
|
-
## If you don't have a password
|
|
61
|
-
|
|
62
|
-
Recently Substack has been setting up new accounts without a password. If you sign out and sign back in, it just uses
|
|
63
|
-
your email address with a "magic" link.
|
|
64
|
-
|
|
65
|
-
Set a password:
|
|
66
|
-
|
|
67
|
-
- Sign out of Substack
|
|
68
|
-
- At the sign-in page, click "Sign in with password" under the `Email` text box
|
|
69
|
-
- Then choose, "Set a new password"
|
|
70
|
-
|
|
71
|
-
The .env file will be ignored by git but always be careful.
|
|
72
|
-
|
|
73
|
-
---
|
|
74
|
-
|
|
75
|
-
# Usage
|
|
76
|
-
|
|
77
|
-
Check out the examples folder for some examples 😃 🚀
|
|
78
|
-
|
|
79
|
-
## Basic Authentication
|
|
80
|
-
|
|
81
|
-
```python
|
|
82
|
-
import os
|
|
83
|
-
from dotenv import load_dotenv
|
|
84
|
-
|
|
85
|
-
from substack import Api
|
|
86
|
-
from substack.post import Post
|
|
87
|
-
|
|
88
|
-
load_dotenv()
|
|
89
|
-
|
|
90
|
-
# Authenticate with email and password
|
|
91
|
-
api = Api(
|
|
92
|
-
email=os.getenv("EMAIL"),
|
|
93
|
-
password=os.getenv("PASSWORD"),
|
|
94
|
-
publication_url=os.getenv("PUBLICATION_URL"),
|
|
95
|
-
)
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
## Cookie-based Authentication
|
|
99
|
-
|
|
100
|
-
You can also authenticate using cookies instead of email/password:
|
|
101
|
-
|
|
102
|
-
```python
|
|
103
|
-
import os
|
|
104
|
-
from dotenv import load_dotenv
|
|
105
|
-
|
|
106
|
-
from substack import Api
|
|
107
|
-
|
|
108
|
-
load_dotenv()
|
|
109
|
-
|
|
110
|
-
# Authenticate with cookies (alternative to email/password)
|
|
111
|
-
api = Api(
|
|
112
|
-
cookies_path=os.getenv("COOKIES_PATH"), # Path to cookies JSON file
|
|
113
|
-
# OR
|
|
114
|
-
cookies_string=os.getenv("COOKIES_STRING"), # Cookie string
|
|
115
|
-
publication_url=os.getenv("PUBLICATION_URL"),
|
|
116
|
-
)
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
## Creating and Publishing Posts
|
|
120
|
-
|
|
121
|
-
```python
|
|
122
|
-
user_id = api.get_user_id()
|
|
123
|
-
|
|
124
|
-
# Switch Publications - The library defaults to your user's primary publication. You can retrieve all your publications and change which one you want to use.
|
|
125
|
-
|
|
126
|
-
# primary publication
|
|
127
|
-
user_publication = api.get_user_primary_publication()
|
|
128
|
-
# all publications
|
|
129
|
-
user_publications = api.get_user_publications()
|
|
130
|
-
|
|
131
|
-
# This step is only necessary if you are not using your primary publication
|
|
132
|
-
# api.change_publication(user_publication)
|
|
133
|
-
|
|
134
|
-
# Create a post with basic settings
|
|
135
|
-
post = Post(
|
|
136
|
-
title="How to publish a Substack post using the Python API",
|
|
137
|
-
subtitle="This post was published using the Python API",
|
|
138
|
-
user_id=user_id
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
# Create a post with audience and comment permissions
|
|
142
|
-
post = Post(
|
|
143
|
-
title="My Post Title",
|
|
144
|
-
subtitle="My Post Subtitle",
|
|
145
|
-
user_id=user_id,
|
|
146
|
-
audience="everyone", # Options: "everyone", "only_paid", "founding", "only_free"
|
|
147
|
-
write_comment_permissions="everyone" # Options: "none", "only_paid", "everyone"
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
post.add({'type': 'paragraph', 'content': 'This is how you add a new paragraph to your post!'})
|
|
151
|
-
|
|
152
|
-
# bolden text
|
|
153
|
-
post.add({'type': "paragraph",
|
|
154
|
-
'content': [{'content': "This is how you "}, {'content': "bolden ", 'marks': [{'type': "strong"}]},
|
|
155
|
-
{'content': "a word."}]})
|
|
156
|
-
|
|
157
|
-
# add hyperlink to text
|
|
158
|
-
post.add({'type': 'paragraph', 'content': [
|
|
159
|
-
{'content': "View Link", 'marks': [{'type': "link", 'href': 'https://whoraised.substack.com/'}]}]})
|
|
160
|
-
|
|
161
|
-
# set paywall boundary
|
|
162
|
-
post.add({'type': 'paywall'})
|
|
163
|
-
|
|
164
|
-
# add image
|
|
165
|
-
post.add({'type': 'captionedImage', 'src': "https://media.tenor.com/7B4jMa-a7bsAAAAC/i-am-batman.gif"})
|
|
166
|
-
|
|
167
|
-
# add local image
|
|
168
|
-
image = api.get_image('image.png')
|
|
169
|
-
post.add({"type": "captionedImage", "src": image.get("url")})
|
|
170
|
-
|
|
171
|
-
# embed publication
|
|
172
|
-
embedded = api.publication_embed("https://jackio.substack.com/")
|
|
173
|
-
post.add({"type": "embeddedPublication", "url": embedded})
|
|
174
|
-
|
|
175
|
-
# create post from Markdown
|
|
176
|
-
markdown_content = """
|
|
177
|
-
# My Heading
|
|
178
|
-
|
|
179
|
-
This is a paragraph with **bold** and *italic* text.
|
|
180
|
-
|
|
181
|
-

|
|
182
|
-
"""
|
|
183
|
-
post.from_markdown(markdown_content, api=api)
|
|
184
|
-
|
|
185
|
-
# Markdown footnotes are supported too. References become inline anchors and
|
|
186
|
-
# definitions become footnote blocks, numbered by order of first appearance.
|
|
187
|
-
# Labels can be numbers or names (e.g. [^1] or [^source]).
|
|
188
|
-
footnote_markdown = """
|
|
189
|
-
A claim that needs support.[^1] Another, with a named label.[^source]
|
|
190
|
-
|
|
191
|
-
[^1]: The supporting detail, with a [link](https://example.com).
|
|
192
|
-
[^source]: Author, *Title* (2025).
|
|
193
|
-
"""
|
|
194
|
-
post.from_markdown(footnote_markdown, api=api)
|
|
195
|
-
|
|
196
|
-
# Or build footnotes manually:
|
|
197
|
-
post.paragraph(content=[{"content": "Some claim."}]).footnote_anchor(1)
|
|
198
|
-
post.footnote(1, "The note text, with **formatting** allowed.")
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
draft = api.post_draft(post.get_draft())
|
|
202
|
-
|
|
203
|
-
# set section (can only be done after first posting the draft)
|
|
204
|
-
# post.set_section("rick rolling", api.get_sections())
|
|
205
|
-
# api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)
|
|
206
|
-
|
|
207
|
-
api.prepublish_draft(draft.get("id"))
|
|
208
|
-
|
|
209
|
-
api.publish_draft(draft.get("id"))
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
## Loading Posts from YAML Files
|
|
213
|
-
|
|
214
|
-
You can define your posts in YAML files for easier management:
|
|
215
|
-
|
|
216
|
-
```python
|
|
217
|
-
import yaml
|
|
218
|
-
import os
|
|
219
|
-
from dotenv import load_dotenv
|
|
220
|
-
|
|
221
|
-
from substack import Api
|
|
222
|
-
from substack.post import Post
|
|
223
|
-
|
|
224
|
-
load_dotenv()
|
|
225
|
-
|
|
226
|
-
# Load post data from YAML file
|
|
227
|
-
with open("draft.yaml", "r") as fp:
|
|
228
|
-
post_data = yaml.safe_load(fp)
|
|
229
|
-
|
|
230
|
-
# Authenticate (using cookies or email/password)
|
|
231
|
-
cookies_path = os.getenv("COOKIES_PATH")
|
|
232
|
-
cookies_string = os.getenv("COOKIES_STRING")
|
|
233
|
-
|
|
234
|
-
api = Api(
|
|
235
|
-
email=os.getenv("EMAIL") if not cookies_path and not cookies_string else None,
|
|
236
|
-
password=os.getenv("PASSWORD") if not cookies_path and not cookies_string else None,
|
|
237
|
-
cookies_path=cookies_path,
|
|
238
|
-
cookies_string=cookies_string,
|
|
239
|
-
publication_url=os.getenv("PUBLICATION_URL"),
|
|
240
|
-
)
|
|
241
|
-
|
|
242
|
-
user_id = api.get_user_id()
|
|
243
|
-
|
|
244
|
-
# Create post from YAML data
|
|
245
|
-
post = Post(
|
|
246
|
-
post_data.get("title"),
|
|
247
|
-
post_data.get("subtitle", ""),
|
|
248
|
-
user_id,
|
|
249
|
-
audience=post_data.get("audience", "everyone"),
|
|
250
|
-
write_comment_permissions=post_data.get("write_comment_permissions", "everyone"),
|
|
251
|
-
)
|
|
252
|
-
|
|
253
|
-
# Add body content from YAML
|
|
254
|
-
body = post_data.get("body", {})
|
|
255
|
-
for _, item in body.items():
|
|
256
|
-
# Handle local images - upload them first
|
|
257
|
-
if item.get("type") == "captionedImage" and not item.get("src").startswith("http"):
|
|
258
|
-
image = api.get_image(item.get("src"))
|
|
259
|
-
item.update({"src": image.get("url")})
|
|
260
|
-
post.add(item)
|
|
261
|
-
|
|
262
|
-
draft = api.post_draft(post.get_draft())
|
|
263
|
-
put_draft_kwargs = {
|
|
264
|
-
"draft_section_id": post.draft_section_id,
|
|
265
|
-
"search_engine_title": post_data.get("search_engine_title"),
|
|
266
|
-
"search_engine_description": post_data.get("search_engine_description"),
|
|
267
|
-
"slug": post_data.get("slug"),
|
|
268
|
-
}
|
|
269
|
-
put_draft_kwargs = {k: v for k, v in put_draft_kwargs.items() if v is not None}
|
|
270
|
-
api.put_draft(draft.get("id"), **put_draft_kwargs)
|
|
271
|
-
|
|
272
|
-
# Publish the draft
|
|
273
|
-
api.prepublish_draft(draft.get("id"))
|
|
274
|
-
api.publish_draft(draft.get("id"))
|
|
275
|
-
```
|
|
276
|
-
|
|
277
|
-
Example YAML structure:
|
|
278
|
-
|
|
279
|
-
```yaml
|
|
280
|
-
title: "My Post Title"
|
|
281
|
-
subtitle: "My Post Subtitle"
|
|
282
|
-
audience: "everyone" # everyone, only_paid, founding, only_free
|
|
283
|
-
write_comment_permissions: "everyone" # none, only_paid, everyone
|
|
284
|
-
section: "my-section"
|
|
285
|
-
body:
|
|
286
|
-
0:
|
|
287
|
-
type: "heading"
|
|
288
|
-
level: 1
|
|
289
|
-
content: "Introduction"
|
|
290
|
-
1:
|
|
291
|
-
type: "paragraph"
|
|
292
|
-
content: "This is a paragraph."
|
|
293
|
-
2:
|
|
294
|
-
type: "captionedImage"
|
|
295
|
-
src: "local_image.jpg" # Local images will be uploaded automatically
|
|
296
|
-
```
|
|
297
|
-
|
|
298
|
-
## MCP FastMCP server
|
|
299
|
-
|
|
300
|
-
This package now includes a FastMCP server in `substack/mcp_fastmcp.py` with the following tools:
|
|
301
|
-
|
|
302
|
-
- `post_draft_from_markdown(...)`: create draft from markdown, optional tag/add/prepublish/publish, and control send/share_automatically.
|
|
303
|
-
- `put_draft(draft_id, update_payload)`: update draft fields.
|
|
304
|
-
- `add_tags(draft_id, tags)`: add tags to a draft/post.
|
|
305
|
-
- `prepublish_draft(draft_id)`: prepublish a draft.
|
|
306
|
-
- `publish_draft(draft_id, send=True, share_automatically=False)`: publish a draft.
|
|
307
|
-
|
|
308
|
-
Use via stdio transport:
|
|
309
|
-
|
|
310
|
-
```bash
|
|
311
|
-
python -c "from substack.mcp_fastmcp import main; main()"
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
# Contributing
|
|
315
|
-
|
|
316
|
-
Install pre-commit:
|
|
317
|
-
|
|
318
|
-
```shell
|
|
319
|
-
pip install pre-commit
|
|
320
|
-
```
|
|
321
|
-
|
|
322
|
-
Set up pre-commit
|
|
323
|
-
|
|
324
|
-
```shell
|
|
325
|
-
pre-commit install
|
|
326
|
-
```
|
|
327
|
-
|
|
328
|
-
## Cookie Help
|
|
329
|
-
|
|
330
|
-
To get a cookie string, after login, go to dev tools (F12), network tab, refresh and find one of the requests like subscription/unred/subscriptions, right click and copy as fetch (Node.js), paste somewhere and get the entire cookie string assigned to the cookie header and put it in the env variables as COOKIES_STRING, et voila!
|
|
331
|
-
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
substack/__init__.py,sha256=esdUffBuSMESXcG4GDcHU81TIPXLyDosmBN3OCtAFY4,390
|
|
2
|
-
substack/api.py,sha256=VPzQ_bpqGcE4ZXWD2lUppnvWsuLRjvtBN7bHmW4Bgww,20422
|
|
3
|
-
substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
|
|
4
|
-
substack/mdrender.py,sha256=cB0fLFzOF7CmWHk-9eRvQormP8StqIgy9hYE6CSKPH0,7770
|
|
5
|
-
substack/nodes.py,sha256=eFVxoVwi684g_DLbz8BRHfU7nyHsJb-m8w17UHocXgY,4106
|
|
6
|
-
substack/post.py,sha256=nXeZMAZ6-lx1Qf4yuAlrEQ0lcWFYF2afai1_TxVEl0Q,19704
|
|
7
|
-
python_substack-0.1.24.dist-info/METADATA,sha256=0SKSa0SYQ9Ju5jy7br0JOwAKKIttnCmGd70qMrcQmDk,9868
|
|
8
|
-
python_substack-0.1.24.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
9
|
-
python_substack-0.1.24.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
10
|
-
python_substack-0.1.24.dist-info/RECORD,,
|
|
File without changes
|