python-substack 0.1.23__py3-none-any.whl → 0.1.25__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,328 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: python-substack
3
- Version: 0.1.23
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: python-dotenv (>=1.2.1,<2.0.0)
20
- Requires-Dist: requests (>=2.32.0,<3.0.0)
21
- Project-URL: Homepage, https://github.com/ma2za/python-substack
22
- Project-URL: Repository, https://github.com/ma2za/python-substack
23
- Description-Content-Type: text/markdown
24
-
25
- # Python Substack
26
-
27
- This is an unofficial library providing a Python interface for [Substack](https://substack.com/).
28
- I am in no way affiliated with Substack.
29
-
30
- [![Downloads](https://static.pepy.tech/badge/python-substack/month)](https://pepy.tech/project/python-substack)
31
- ![Release Build](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml/badge.svg)
32
- ---
33
-
34
- # Installation
35
-
36
- You can install python-substack using:
37
-
38
- $ pip install python-substack
39
-
40
- For the MCP server tools, install the extra dependency set:
41
-
42
- $ poetry install --with mcp
43
-
44
- > 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).
45
-
46
- ---
47
-
48
- # Setup
49
-
50
- Set the following environment variables by creating a **.env** file:
51
-
52
- EMAIL=
53
- PASSWORD=
54
- PUBLICATION_URL= # Optional: your publication URL
55
- COOKIES_PATH= # Optional: path to cookies JSON file
56
- COOKIES_STRING= # Optional: cookie string for authentication
57
-
58
- ## If you don't have a password
59
-
60
- Recently Substack has been setting up new accounts without a password. If you sign out and sign back in, it just uses
61
- your email address with a "magic" link.
62
-
63
- Set a password:
64
-
65
- - Sign out of Substack
66
- - At the sign-in page, click "Sign in with password" under the `Email` text box
67
- - Then choose, "Set a new password"
68
-
69
- The .env file will be ignored by git but always be careful.
70
-
71
- ---
72
-
73
- # Usage
74
-
75
- Check out the examples folder for some examples 😃 🚀
76
-
77
- ## Basic Authentication
78
-
79
- ```python
80
- import os
81
- from dotenv import load_dotenv
82
-
83
- from substack import Api
84
- from substack.post import Post
85
-
86
- load_dotenv()
87
-
88
- # Authenticate with email and password
89
- api = Api(
90
- email=os.getenv("EMAIL"),
91
- password=os.getenv("PASSWORD"),
92
- publication_url=os.getenv("PUBLICATION_URL"),
93
- )
94
- ```
95
-
96
- ## Cookie-based Authentication
97
-
98
- You can also authenticate using cookies instead of email/password:
99
-
100
- ```python
101
- import os
102
- from dotenv import load_dotenv
103
-
104
- from substack import Api
105
-
106
- load_dotenv()
107
-
108
- # Authenticate with cookies (alternative to email/password)
109
- api = Api(
110
- cookies_path=os.getenv("COOKIES_PATH"), # Path to cookies JSON file
111
- # OR
112
- cookies_string=os.getenv("COOKIES_STRING"), # Cookie string
113
- publication_url=os.getenv("PUBLICATION_URL"),
114
- )
115
- ```
116
-
117
- ## Creating and Publishing Posts
118
-
119
- ```python
120
- user_id = api.get_user_id()
121
-
122
- # 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.
123
-
124
- # primary publication
125
- user_publication = api.get_user_primary_publication()
126
- # all publications
127
- user_publications = api.get_user_publications()
128
-
129
- # This step is only necessary if you are not using your primary publication
130
- # api.change_publication(user_publication)
131
-
132
- # Create a post with basic settings
133
- post = Post(
134
- title="How to publish a Substack post using the Python API",
135
- subtitle="This post was published using the Python API",
136
- user_id=user_id
137
- )
138
-
139
- # Create a post with audience and comment permissions
140
- post = Post(
141
- title="My Post Title",
142
- subtitle="My Post Subtitle",
143
- user_id=user_id,
144
- audience="everyone", # Options: "everyone", "only_paid", "founding", "only_free"
145
- write_comment_permissions="everyone" # Options: "none", "only_paid", "everyone"
146
- )
147
-
148
- post.add({'type': 'paragraph', 'content': 'This is how you add a new paragraph to your post!'})
149
-
150
- # bolden text
151
- post.add({'type': "paragraph",
152
- 'content': [{'content': "This is how you "}, {'content': "bolden ", 'marks': [{'type': "strong"}]},
153
- {'content': "a word."}]})
154
-
155
- # add hyperlink to text
156
- post.add({'type': 'paragraph', 'content': [
157
- {'content': "View Link", 'marks': [{'type': "link", 'href': 'https://whoraised.substack.com/'}]}]})
158
-
159
- # set paywall boundary
160
- post.add({'type': 'paywall'})
161
-
162
- # add image
163
- post.add({'type': 'captionedImage', 'src': "https://media.tenor.com/7B4jMa-a7bsAAAAC/i-am-batman.gif"})
164
-
165
- # add local image
166
- image = api.get_image('image.png')
167
- post.add({"type": "captionedImage", "src": image.get("url")})
168
-
169
- # embed publication
170
- embedded = api.publication_embed("https://jackio.substack.com/")
171
- post.add({"type": "embeddedPublication", "url": embedded})
172
-
173
- # create post from Markdown
174
- markdown_content = """
175
- # My Heading
176
-
177
- This is a paragraph with **bold** and *italic* text.
178
-
179
- ![Image Alt](https://example.com/image.jpg)
180
- """
181
- post.from_markdown(markdown_content, api=api)
182
-
183
- # Markdown footnotes are supported too. References become inline anchors and
184
- # definitions become footnote blocks, numbered by order of first appearance.
185
- # Labels can be numbers or names (e.g. [^1] or [^source]).
186
- footnote_markdown = """
187
- A claim that needs support.[^1] Another, with a named label.[^source]
188
-
189
- [^1]: The supporting detail, with a [link](https://example.com).
190
- [^source]: Author, *Title* (2025).
191
- """
192
- post.from_markdown(footnote_markdown, api=api)
193
-
194
- # Or build footnotes manually:
195
- post.paragraph(content=[{"content": "Some claim."}]).footnote_anchor(1)
196
- post.footnote(1, "The note text, with **formatting** allowed.")
197
-
198
- draft = api.post_draft(post.get_draft())
199
-
200
- # set section (can only be done after first posting the draft)
201
- # post.set_section("rick rolling", api.get_sections())
202
- # api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)
203
-
204
- api.prepublish_draft(draft.get("id"))
205
-
206
- api.publish_draft(draft.get("id"))
207
- ```
208
-
209
- ## Loading Posts from YAML Files
210
-
211
- You can define your posts in YAML files for easier management:
212
-
213
- ```python
214
- import yaml
215
- import os
216
- from dotenv import load_dotenv
217
-
218
- from substack import Api
219
- from substack.post import Post
220
-
221
- load_dotenv()
222
-
223
- # Load post data from YAML file
224
- with open("draft.yaml", "r") as fp:
225
- post_data = yaml.safe_load(fp)
226
-
227
- # Authenticate (using cookies or email/password)
228
- cookies_path = os.getenv("COOKIES_PATH")
229
- cookies_string = os.getenv("COOKIES_STRING")
230
-
231
- api = Api(
232
- email=os.getenv("EMAIL") if not cookies_path and not cookies_string else None,
233
- password=os.getenv("PASSWORD") if not cookies_path and not cookies_string else None,
234
- cookies_path=cookies_path,
235
- cookies_string=cookies_string,
236
- publication_url=os.getenv("PUBLICATION_URL"),
237
- )
238
-
239
- user_id = api.get_user_id()
240
-
241
- # Create post from YAML data
242
- post = Post(
243
- post_data.get("title"),
244
- post_data.get("subtitle", ""),
245
- user_id,
246
- audience=post_data.get("audience", "everyone"),
247
- write_comment_permissions=post_data.get("write_comment_permissions", "everyone"),
248
- )
249
-
250
- # Add body content from YAML
251
- body = post_data.get("body", {})
252
- for _, item in body.items():
253
- # Handle local images - upload them first
254
- if item.get("type") == "captionedImage" and not item.get("src").startswith("http"):
255
- image = api.get_image(item.get("src"))
256
- item.update({"src": image.get("url")})
257
- post.add(item)
258
-
259
- draft = api.post_draft(post.get_draft())
260
- put_draft_kwargs = {
261
- "draft_section_id": post.draft_section_id,
262
- "search_engine_title": post_data.get("search_engine_title"),
263
- "search_engine_description": post_data.get("search_engine_description"),
264
- "slug": post_data.get("slug"),
265
- }
266
- put_draft_kwargs = {k: v for k, v in put_draft_kwargs.items() if v is not None}
267
- api.put_draft(draft.get("id"), **put_draft_kwargs)
268
-
269
- # Publish the draft
270
- api.prepublish_draft(draft.get("id"))
271
- api.publish_draft(draft.get("id"))
272
- ```
273
-
274
- Example YAML structure:
275
-
276
- ```yaml
277
- title: "My Post Title"
278
- subtitle: "My Post Subtitle"
279
- audience: "everyone" # everyone, only_paid, founding, only_free
280
- write_comment_permissions: "everyone" # none, only_paid, everyone
281
- section: "my-section"
282
- body:
283
- 0:
284
- type: "heading"
285
- level: 1
286
- content: "Introduction"
287
- 1:
288
- type: "paragraph"
289
- content: "This is a paragraph."
290
- 2:
291
- type: "captionedImage"
292
- src: "local_image.jpg" # Local images will be uploaded automatically
293
- ```
294
-
295
- ## MCP FastMCP server
296
-
297
- This package now includes a FastMCP server in `substack/mcp_fastmcp.py` with the following tools:
298
-
299
- - `post_draft_from_markdown(...)`: create draft from markdown, optional tag/add/prepublish/publish, and control send/share_automatically.
300
- - `put_draft(draft_id, update_payload)`: update draft fields.
301
- - `add_tags(draft_id, tags)`: add tags to a draft/post.
302
- - `prepublish_draft(draft_id)`: prepublish a draft.
303
- - `publish_draft(draft_id, send=True, share_automatically=False)`: publish a draft.
304
-
305
- Use via stdio transport:
306
-
307
- ```bash
308
- python -c "from substack.mcp_fastmcp import main; main()"
309
- ```
310
-
311
- # Contributing
312
-
313
- Install pre-commit:
314
-
315
- ```shell
316
- pip install pre-commit
317
- ```
318
-
319
- Set up pre-commit
320
-
321
- ```shell
322
- pre-commit install
323
- ```
324
-
325
- ## Cookie Help
326
-
327
- 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!
328
-
@@ -1,8 +0,0 @@
1
- substack/__init__.py,sha256=I3u5zUvxfPpbPYQTCV0VR3lGS4gyQQISC_0_GPN3FGk,390
2
- substack/api.py,sha256=VPzQ_bpqGcE4ZXWD2lUppnvWsuLRjvtBN7bHmW4Bgww,20422
3
- substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
4
- substack/post.py,sha256=ZObXe62J7iXhSI_XBpadVTMXpN6xqLNHXUrOHSidkgM,39440
5
- python_substack-0.1.23.dist-info/METADATA,sha256=90wvACIgduBlYv9RyB6euQq59V0ZJXgl39mB-isWWb8,9780
6
- python_substack-0.1.23.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
7
- python_substack-0.1.23.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
8
- python_substack-0.1.23.dist-info/RECORD,,