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.
@@ -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
- [![Downloads](https://static.pepy.tech/badge/python-substack/month)](https://pepy.tech/project/python-substack)
33
- ![Release Build](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml/badge.svg)
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
- ![Image Alt](https://example.com/image.jpg)
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,,