python-substack 0.1.27__py3-none-any.whl → 0.3.0__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,284 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-substack
3
+ Version: 0.3.0
4
+ Summary: Write and safely manage Substack drafts from Markdown with Python, CLI, and MCP.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: substack,substack-api,cli,newsletter,publishing,automation,mcp
8
+ Author: Paolo Mazza
9
+ Author-email: mazzapaolo2019@gmail.com
10
+ Requires-Python: >=3.10,<4.0
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Communications :: Email
21
+ Classifier: Topic :: Internet :: WWW/HTTP
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Provides-Extra: mcp
24
+ Requires-Dist: PyYAML (>=6.0,<7.0)
25
+ Requires-Dist: fastmcp (>=3.1.1,<4.0.0) ; extra == "mcp"
26
+ Requires-Dist: markdown-it-py (>=3.0,<4.0)
27
+ Requires-Dist: mdit-py-plugins (>=0.5,<0.7)
28
+ Requires-Dist: python-dotenv (>=1.2.1,<2.0.0)
29
+ Requires-Dist: requests (>=2.32.0,<3.0.0)
30
+ Project-URL: Changelog, https://github.com/ma2za/python-substack/blob/main/CHANGELOG.md
31
+ Project-URL: Documentation, https://ma2za.github.io/python-substack/
32
+ Project-URL: Homepage, https://github.com/ma2za/python-substack
33
+ Project-URL: Issues, https://github.com/ma2za/python-substack/issues
34
+ Project-URL: Repository, https://github.com/ma2za/python-substack
35
+ Description-Content-Type: text/markdown
36
+
37
+ # Python Substack
38
+
39
+ Write Substack posts in Markdown and safely create, inspect, schedule, and
40
+ publish them through Python, a command-line interface, or MCP.
41
+
42
+ [![PyPI](https://img.shields.io/pypi/v/python-substack)](https://pypi.org/project/python-substack/)
43
+ [![Python](https://img.shields.io/pypi/pyversions/python-substack)](https://pypi.org/project/python-substack/)
44
+ [![Tests](https://github.com/ma2za/python-substack/actions/workflows/ci.yml/badge.svg)](https://github.com/ma2za/python-substack/actions/workflows/ci.yml)
45
+ [![Release](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml/badge.svg)](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml)
46
+ [![License](https://img.shields.io/pypi/l/python-substack)](LICENSE)
47
+ [![Downloads](https://static.pepy.tech/badge/python-substack/month)](https://pepy.tech/project/python-substack)
48
+
49
+ [Documentation](https://ma2za.github.io/python-substack/) ·
50
+ [Getting started](https://ma2za.github.io/python-substack/getting-started.html) ·
51
+ [PyPI](https://pypi.org/project/python-substack/)
52
+
53
+ > [!IMPORTANT]
54
+ > Creating and publishing are separate operations. `substack drafts create`
55
+ > always creates an unpublished draft. It never schedules, sends, publishes,
56
+ > or deletes content.
57
+
58
+ ## From Markdown to a Substack draft
59
+
60
+ Install the package:
61
+
62
+ ```bash
63
+ pip install python-substack
64
+ ```
65
+
66
+ Check the selected account and publication:
67
+
68
+ ```bash
69
+ substack status
70
+ ```
71
+
72
+ Create a safe unpublished draft, then publish only when it is ready:
73
+
74
+ ```bash
75
+ substack drafts create post.md
76
+ substack drafts publish 12345 --no-send
77
+ ```
78
+
79
+ Publishing and deletion require confirmation. Noninteractive and JSON
80
+ workflows must pass `--yes` explicitly.
81
+
82
+ Markdown source:
83
+
84
+ ![Markdown before conversion](docs/before.png)
85
+
86
+ Substack result:
87
+
88
+ ![Substack after conversion](docs/after.png)
89
+
90
+ ## What it supports
91
+
92
+ - Create rich Substack drafts from Markdown.
93
+ - Upload local images referenced by Markdown.
94
+ - Set audience, comment permissions, SEO metadata, slug, sections, and tags.
95
+ - List and inspect publications and drafts.
96
+ - Schedule, unschedule, publish, and delete drafts with explicit safeguards.
97
+ - Use stable JSON envelopes in scripts and automation.
98
+ - Authenticate with browser cookies or email and password.
99
+ - Use the same publishing workflow from Python or an optional MCP server.
100
+
101
+ ## Setup
102
+
103
+ Copy `.env.example` to `.env` and configure one authentication method:
104
+
105
+ ```env
106
+ EMAIL=
107
+ PASSWORD=
108
+ PUBLICATION_URL=
109
+ COOKIES_PATH=
110
+ COOKIES_STRING=
111
+ ```
112
+
113
+ Cookie authentication is usually more reliable when Substack requires captcha
114
+ or magic-link sign-in. See
115
+ [Authentication](docs/authentication.md) for cookie export instructions and
116
+ account-selection details.
117
+
118
+ Verify the installation without authenticating:
119
+
120
+ ```bash
121
+ substack --version
122
+ substack --help
123
+ ```
124
+
125
+ ## CLI
126
+
127
+ Create a draft with metadata:
128
+
129
+ ```bash
130
+ substack --json drafts create post.md \
131
+ --title "My Post" \
132
+ --subtitle "Optional subtitle" \
133
+ --tag python \
134
+ --tag substack \
135
+ --slug my-post \
136
+ --search-engine-title "SEO title" \
137
+ --search-engine-description "SEO description"
138
+ ```
139
+
140
+ Inspect publications and drafts:
141
+
142
+ ```bash
143
+ substack publications list
144
+ substack drafts list --limit 10
145
+ substack drafts get 12345
146
+ substack --publication-url https://example.substack.com drafts list
147
+ ```
148
+
149
+ Manage scheduling:
150
+
151
+ ```bash
152
+ substack drafts schedule 12345 --at 2026-08-01T09:00:00+03:00
153
+ substack drafts unschedule 12345
154
+ ```
155
+
156
+ Publish or delete intentionally:
157
+
158
+ ```bash
159
+ substack drafts publish 12345 --no-send
160
+ substack drafts delete 12345 --yes
161
+ ```
162
+
163
+ Global options such as `--json`, `--cookies`, and `--publication-url` must
164
+ appear before the command:
165
+
166
+ ```bash
167
+ substack --json drafts list
168
+ substack --cookies cookies.json --json status
169
+ ```
170
+
171
+ The original standalone commands remain supported. See
172
+ [Legacy CLI commands](docs/legacy-cli.md).
173
+
174
+ ## Python
175
+
176
+ ```python
177
+ import os
178
+
179
+ from dotenv import load_dotenv
180
+ from substack import Api
181
+
182
+ load_dotenv()
183
+
184
+ api = Api(
185
+ email=os.getenv("EMAIL"),
186
+ password=os.getenv("PASSWORD"),
187
+ publication_url=os.getenv("PUBLICATION_URL"),
188
+ )
189
+
190
+ result = api.create_draft_from_markdown(
191
+ title="Shipping with Python",
192
+ subtitle="A short note from a script",
193
+ markdown="""
194
+ # Hello
195
+
196
+ This draft was created from **Markdown**.
197
+
198
+ ![Alt text](https://example.com/image.png "Image caption")
199
+ """,
200
+ tags=["python", "automation"],
201
+ slug="shipping-with-python",
202
+ )
203
+
204
+ print(result["draft"]["id"])
205
+ ```
206
+
207
+ `create_draft_from_markdown` creates a draft by default. It publishes only when
208
+ `publish=True` is passed.
209
+
210
+ For direct ProseMirror node construction, see the
211
+ [low-level Python API](docs/low-level-api.md). YAML workflows are documented in
212
+ [YAML drafts](docs/yaml.md).
213
+
214
+ ## Markdown
215
+
216
+ Supported Markdown includes headings, paragraphs, bold, italic, inline code,
217
+ strikethrough, superscript, subscript, links, images, linked images, image
218
+ captions, code blocks, blockquotes, ordered and unordered lists, horizontal
219
+ rules, footnotes, LaTeX math, pull quotes, and callouts.
220
+
221
+ ```python
222
+ from substack.post import Post
223
+
224
+ post = Post("Title", "Subtitle", user_id=1)
225
+ post.from_markdown(
226
+ """
227
+ # Heading
228
+
229
+ Paragraph with **bold**, *italic*, `code`, and [links](https://example.com).
230
+ """
231
+ )
232
+ ```
233
+
234
+ Pass `api=` to upload local images while rendering:
235
+
236
+ ```python
237
+ post.from_markdown(markdown_content, api=api)
238
+ ```
239
+
240
+ See the complete [Markdown reference](docs/markdown.md).
241
+
242
+ ## MCP
243
+
244
+ Install and run the optional MCP server:
245
+
246
+ ```bash
247
+ pip install "python-substack[mcp]"
248
+ substack-mcp
249
+ ```
250
+
251
+ The MCP tools use the same environment variables and SDK behavior as the CLI.
252
+ See [MCP server](docs/mcp.md) for the tool list and safety notes.
253
+
254
+ ## Project documentation
255
+
256
+ - [Documentation site](https://ma2za.github.io/python-substack/)
257
+ - [Installation and first draft](docs/getting-started.md)
258
+ - [Unified CLI](docs/cli.md)
259
+ - [Python SDK](docs/python-sdk.md)
260
+ - [Authentication](docs/authentication.md)
261
+ - [Markdown reference](docs/markdown.md)
262
+ - [Legacy CLI commands](docs/legacy-cli.md)
263
+ - [Low-level Python API](docs/low-level-api.md)
264
+ - [YAML drafts](docs/yaml.md)
265
+ - [MCP server](docs/mcp.md)
266
+ - [Safety and publishing behavior](docs/safety.md)
267
+ - [Troubleshooting](docs/troubleshooting.md)
268
+ - [Compatibility policy](docs/compatibility.md)
269
+ - [Contributing](CONTRIBUTING.md)
270
+ - [Security policy](SECURITY.md)
271
+ - [Changelog](CHANGELOG.md)
272
+
273
+ ## Compatibility
274
+
275
+ The project preserves existing Python APIs, console commands, CLI behavior,
276
+ environment variables, JSON keys, and MCP tool signatures through the 1.x
277
+ series. Additive capabilities may be introduced. See the
278
+ [compatibility policy](docs/compatibility.md).
279
+
280
+ ## Disclaimer
281
+
282
+ This project is not affiliated with Substack. It uses undocumented Substack
283
+ interfaces that may change without notice.
284
+
@@ -0,0 +1,13 @@
1
+ substack/__init__.py,sha256=wulIsJc0NOyRmhOrRSyATms453zZ1GoPCHCqk0Yef5k,437
2
+ substack/api.py,sha256=5dIijlGDZI5bPgX9skTzytDkY1IO6etvCv6PbNFUjyQ,23212
3
+ substack/cli.py,sha256=JBCsCdmthjzTeDpOYBLeVKOdNYjBrPuY1o_CIsAIekk,21712
4
+ substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
5
+ substack/mdrender.py,sha256=QGJkdp1isFmhVyTRMogIabAK8OO2xQh9CrjnXZvFTY4,9308
6
+ substack/nodes.py,sha256=fTsGO0-lTztcXiloIjXrHVnJes9EcltGcrX-mEV2ceQ,5004
7
+ substack/post.py,sha256=qDXu-xzO3nRXtahc-yG2EhxKAXB2hG_Z1nLa55UMBGg,19697
8
+ substack_mcp/mcp_server.py,sha256=gmevdc59XTBXXMTvVMpYoq66Umlqku1O_uzmw5tMy7o,8405
9
+ python_substack-0.3.0.dist-info/METADATA,sha256=oj9XGFEZB62J8QCUyhO0n2VUrZFPXzu_TQsgkt2L_4s,8349
10
+ python_substack-0.3.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
11
+ python_substack-0.3.0.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
12
+ python_substack-0.3.0.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
13
+ python_substack-0.3.0.dist-info/RECORD,,
substack/__init__.py CHANGED
@@ -3,9 +3,11 @@
3
3
  __author__ = "Paolo Mazza"
4
4
  __email__ = "mazzapaolo2019@gmail.com"
5
5
  __license__ = "MIT License"
6
- __version__ = "0.1.27"
6
+ __version__ = "0.3.0"
7
7
  __url__ = "https://github.com/ma2za/python-substack"
8
8
  __download_url__ = "https://pypi.python.org/pypi/python-substack"
9
- __description__ = "A Python SDK and CLI for managing Substack publications and drafts"
9
+ __description__ = (
10
+ "Write and safely manage Substack drafts from Markdown with Python, CLI, and MCP."
11
+ )
10
12
 
11
13
  from .api import Api
substack/api.py CHANGED
@@ -348,7 +348,6 @@ class Api:
348
348
  return Api._handle_response(response=response)
349
349
 
350
350
  def get_publication_subscriber_count(self):
351
-
352
351
  """
353
352
  Get subscriber count.
354
353
 
substack/post.py CHANGED
@@ -10,8 +10,8 @@ from typing import Dict, List
10
10
 
11
11
  __all__ = ["Post", "parse_inline", "tokens_to_text_nodes"]
12
12
 
13
- from substack.exceptions import SectionNotExistsException
14
13
  from substack import nodes
14
+ from substack.exceptions import SectionNotExistsException
15
15
 
16
16
 
17
17
  def tokens_to_text_nodes(tokens: List[Dict]) -> List[Dict]:
@@ -61,12 +61,12 @@ def parse_inline(text: str) -> List[Dict]:
61
61
  tokens = []
62
62
 
63
63
  # Pattern order matters: code > links > bold+italic > bold > italic > strikethrough
64
- code_pattern = r'`([^`]+)`'
65
- link_pattern = r'\[([^\]]+)\]\(([^)]+)\)'
66
- bold_italic_pattern = r'\*\*\*([^*]+)\*\*\*'
67
- bold_pattern = r'\*\*([^*]+)\*\*'
68
- italic_pattern = r'(?<!\*)\*([^*]+)\*(?!\*)' # Not preceded or followed by *
69
- strikethrough_pattern = r'~~([^~]+)~~'
64
+ code_pattern = r"`([^`]+)`"
65
+ link_pattern = r"\[([^\]]+)\]\(([^)]+)\)"
66
+ bold_italic_pattern = r"\*\*\*([^*]+)\*\*\*"
67
+ bold_pattern = r"\*\*([^*]+)\*\*"
68
+ italic_pattern = r"(?<!\*)\*([^*]+)\*(?!\*)" # Not preceded or followed by *
69
+ strikethrough_pattern = r"~~([^~]+)~~"
70
70
 
71
71
  # Find all matches with their positions
72
72
  matches = []
@@ -79,14 +79,18 @@ def parse_inline(text: str) -> List[Dict]:
79
79
  for match in re.finditer(link_pattern, text):
80
80
  # Skip if it's an image link (starts with ![)
81
81
  # But do NOT skip normal links at position 0.
82
- if match.start() == 0 or text[match.start()-1:match.start()+1] != "![":
82
+ if match.start() == 0 or text[match.start() - 1 : match.start() + 1] != "![":
83
83
  if not any(start <= match.start() < end for start, end, _, _, _ in matches):
84
- matches.append((match.start(), match.end(), "link", match.group(1), match.group(2)))
84
+ matches.append(
85
+ (match.start(), match.end(), "link", match.group(1), match.group(2))
86
+ )
85
87
 
86
88
  # Bold+italic combo
87
89
  for match in re.finditer(bold_italic_pattern, text):
88
90
  if not any(start <= match.start() < end for start, end, _, _, _ in matches):
89
- matches.append((match.start(), match.end(), "bold_italic", match.group(1), None))
91
+ matches.append(
92
+ (match.start(), match.end(), "bold_italic", match.group(1), None)
93
+ )
90
94
 
91
95
  # Bold
92
96
  for match in re.finditer(bold_pattern, text):
@@ -101,7 +105,9 @@ def parse_inline(text: str) -> List[Dict]:
101
105
  # Strikethrough
102
106
  for match in re.finditer(strikethrough_pattern, text):
103
107
  if not any(start <= match.start() < end for start, end, _, _, _ in matches):
104
- matches.append((match.start(), match.end(), "strikethrough", match.group(1), None))
108
+ matches.append(
109
+ (match.start(), match.end(), "strikethrough", match.group(1), None)
110
+ )
105
111
 
106
112
  # Sort matches by position
107
113
  matches.sort(key=lambda x: x[0])
@@ -115,35 +121,24 @@ def parse_inline(text: str) -> List[Dict]:
115
121
 
116
122
  # Add the formatted content
117
123
  if match_type == "code":
118
- tokens.append({
119
- "content": content,
120
- "marks": [{"type": "code"}]
121
- })
124
+ tokens.append({"content": content, "marks": [{"type": "code"}]})
122
125
  elif match_type == "link":
123
- tokens.append({
124
- "content": content,
125
- "marks": [{"type": "link", "attrs": {"href": url}}]
126
- })
126
+ tokens.append(
127
+ {
128
+ "content": content,
129
+ "marks": [{"type": "link", "attrs": {"href": url}}],
130
+ }
131
+ )
127
132
  elif match_type == "bold_italic":
128
- tokens.append({
129
- "content": content,
130
- "marks": [{"type": "strong"}, {"type": "em"}]
131
- })
133
+ tokens.append(
134
+ {"content": content, "marks": [{"type": "strong"}, {"type": "em"}]}
135
+ )
132
136
  elif match_type == "bold":
133
- tokens.append({
134
- "content": content,
135
- "marks": [{"type": "strong"}]
136
- })
137
+ tokens.append({"content": content, "marks": [{"type": "strong"}]})
137
138
  elif match_type == "italic":
138
- tokens.append({
139
- "content": content,
140
- "marks": [{"type": "em"}]
141
- })
139
+ tokens.append({"content": content, "marks": [{"type": "em"}]})
142
140
  elif match_type == "strikethrough":
143
- tokens.append({
144
- "content": content,
145
- "marks": [{"type": "strikethrough"}]
146
- })
141
+ tokens.append({"content": content, "marks": [{"type": "strikethrough"}]})
147
142
 
148
143
  last_pos = end
149
144
 
@@ -583,7 +578,9 @@ class Post:
583
578
  for chunk in re.split(r"\n\s*\n", content):
584
579
  chunk = chunk.strip()
585
580
  if chunk:
586
- paragraphs.append(nodes.paragraph(tokens_to_text_nodes(parse_inline(chunk))))
581
+ paragraphs.append(
582
+ nodes.paragraph(tokens_to_text_nodes(parse_inline(chunk)))
583
+ )
587
584
  elif isinstance(content, list):
588
585
  # Accept either parse_inline tokens ({"content": ...}) or text nodes.
589
586
  if content and content[0].get("type") == "text":
@@ -1,479 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: python-substack
3
- Version: 0.1.27
4
- Summary: A Python SDK and CLI for managing Substack publications and drafts.
5
- License: MIT
6
- License-File: LICENSE
7
- Keywords: substack,substack-api,cli,newsletter,publishing,automation,mcp
8
- Author: Paolo Mazza
9
- Author-email: mazzapaolo2019@gmail.com
10
- Requires-Python: >=3.10,<4.0
11
- Classifier: Development Status :: 3 - Alpha
12
- Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: MIT License
14
- Classifier: Programming Language :: Python :: 3
15
- Classifier: Programming Language :: Python :: 3.10
16
- Classifier: Programming Language :: Python :: 3.11
17
- Classifier: Programming Language :: Python :: 3.12
18
- Classifier: Programming Language :: Python :: 3.13
19
- Classifier: Programming Language :: Python :: 3.14
20
- Classifier: Topic :: Communications :: Email
21
- Classifier: Topic :: Internet :: WWW/HTTP
22
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
- Provides-Extra: mcp
24
- Requires-Dist: PyYAML (>=6.0,<7.0)
25
- Requires-Dist: fastmcp (>=3.1.1,<4.0.0) ; extra == "mcp"
26
- Requires-Dist: markdown-it-py (>=3.0,<4.0)
27
- Requires-Dist: mdit-py-plugins (>=0.5,<0.7)
28
- Requires-Dist: python-dotenv (>=1.2.1,<2.0.0)
29
- Requires-Dist: requests (>=2.32.0,<3.0.0)
30
- Project-URL: Changelog, https://github.com/ma2za/python-substack/blob/main/CHANGELOG.md
31
- Project-URL: Homepage, https://github.com/ma2za/python-substack
32
- Project-URL: Issues, https://github.com/ma2za/python-substack/issues
33
- Project-URL: Repository, https://github.com/ma2za/python-substack
34
- Description-Content-Type: text/markdown
35
-
36
- # Python Substack
37
-
38
- An unofficial Python SDK and CLI for managing [Substack](https://substack.com/) publications and drafts.
39
-
40
- [![PyPI](https://img.shields.io/pypi/v/python-substack)](https://pypi.org/project/python-substack/)
41
- [![Python](https://img.shields.io/pypi/pyversions/python-substack)](https://pypi.org/project/python-substack/)
42
- [![Tests](https://github.com/ma2za/python-substack/actions/workflows/ci.yml/badge.svg)](https://github.com/ma2za/python-substack/actions/workflows/ci.yml)
43
- [![Release](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml/badge.svg)](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml)
44
- [![License](https://img.shields.io/pypi/l/python-substack)](LICENSE)
45
- [![Downloads](https://static.pepy.tech/badge/python-substack/month)](https://pepy.tech/project/python-substack)
46
-
47
- ## Features
48
-
49
- - Inspect authentication and publication status from the terminal.
50
- - List publications and inspect, schedule, publish, or delete drafts.
51
- - Use stable JSON output in scripts and automation.
52
- - Create drafts and publish posts from Python.
53
- - Convert Markdown into Substack's editor document format.
54
- - Upload local images while rendering Markdown.
55
- - Set audience, comment permissions, SEO title, SEO description, slug, sections, and tags.
56
- - Publish now, schedule drafts, or keep drafts unpublished by default.
57
- - Authenticate with email/password, cookies JSON, or a browser cookie string.
58
- - Run a FastMCP server for AI-assisted publishing workflows.
59
-
60
- ## Installation
61
-
62
- ```bash
63
- pip install python-substack
64
- ```
65
-
66
- Install the MCP server extra:
67
-
68
- ```bash
69
- pip install "python-substack[mcp]"
70
- ```
71
-
72
- ## Setup
73
-
74
- Copy `.env.example` to `.env` and fill in one authentication method:
75
-
76
- ```env
77
- EMAIL=
78
- PASSWORD=
79
- PUBLICATION_URL=
80
- COOKIES_PATH=
81
- COOKIES_STRING=
82
- ```
83
-
84
- Use either `EMAIL` and `PASSWORD`, or cookie-based authentication with `COOKIES_PATH` or `COOKIES_STRING`. Cookie authentication is usually the better option if Substack prompts for captcha or magic-link sign-in.
85
-
86
- Newer Substack accounts may only have magic-link sign-in enabled. To set a password, sign out of Substack, choose "Sign in with password", then choose "Set a new password".
87
-
88
- ## CLI Operations
89
-
90
- Create a draft from Markdown without publishing it:
91
-
92
- ```bash
93
- substack drafts create post.md
94
- ```
95
-
96
- Set metadata and repeat `--tag` to attach multiple tags:
97
-
98
- ```bash
99
- substack --json drafts create post.md \
100
- --title "My Post" \
101
- --subtitle "Optional subtitle" \
102
- --tag python \
103
- --tag substack \
104
- --slug my-post \
105
- --search-engine-title "SEO title" \
106
- --search-engine-description "SEO description"
107
- ```
108
-
109
- Creation and publishing are intentionally separate. Use the returned draft ID
110
- when the draft is ready:
111
-
112
- ```bash
113
- substack drafts create post.md
114
- substack drafts publish 12345 --no-send
115
- ```
116
-
117
- Check authentication, the selected publication, and subscriber count:
118
-
119
- ```bash
120
- substack status
121
- ```
122
-
123
- List available publications or target one without changing `.env`:
124
-
125
- ```bash
126
- substack publications list
127
- substack --publication-url https://example.substack.com drafts list
128
- ```
129
-
130
- List and inspect drafts:
131
-
132
- ```bash
133
- substack drafts list --limit 10
134
- substack drafts get 12345
135
- ```
136
-
137
- Schedule with a timezone-aware ISO 8601 timestamp, or remove a schedule:
138
-
139
- ```bash
140
- substack drafts schedule 12345 --at 2026-08-01T09:00:00+03:00
141
- substack drafts unschedule 12345
142
- ```
143
-
144
- Publishing and deletion prompt for confirmation. Use `--yes` for intentional non-interactive execution:
145
-
146
- ```bash
147
- substack drafts publish 12345 --no-send
148
- substack drafts delete 12345 --yes
149
- ```
150
-
151
- Global options must appear before the command. `--json` returns stable envelopes containing the raw Substack responses:
152
-
153
- ```bash
154
- substack --json drafts list
155
- substack --cookies cookies.json --json status
156
- ```
157
-
158
- ## Quickstart
159
-
160
- ```python
161
- import os
162
-
163
- from dotenv import load_dotenv
164
- from substack import Api
165
-
166
- load_dotenv()
167
-
168
- api = Api(
169
- email=os.getenv("EMAIL"),
170
- password=os.getenv("PASSWORD"),
171
- publication_url=os.getenv("PUBLICATION_URL"),
172
- )
173
-
174
- result = api.create_draft_from_markdown(
175
- title="Shipping with Python",
176
- subtitle="A short note from a script",
177
- markdown="""
178
- # Hello
179
-
180
- This draft was created from **Markdown**.
181
-
182
- ![Alt text](https://example.com/image.png "Image caption")
183
- """,
184
- tags=["python", "automation"],
185
- slug="shipping-with-python",
186
- )
187
-
188
- print(result["draft"]["id"])
189
- ```
190
-
191
- `create_draft_from_markdown` creates a draft by default. It only publishes when `publish=True` is passed.
192
-
193
- ## Legacy Content Publishing CLI
194
-
195
- The existing standalone commands remain supported for compatibility.
196
-
197
- Check authentication without creating a draft:
198
-
199
- ```bash
200
- substack-auth-check
201
- ```
202
-
203
- With a cookies JSON file:
204
-
205
- ```bash
206
- substack-auth-check --cookies cookies.json
207
- ```
208
-
209
- Publish a Markdown file as a draft:
210
-
211
- ```bash
212
- substack-publish-markdown post.md --title "My Post"
213
- ```
214
-
215
- Create and publish:
216
-
217
- ```bash
218
- substack-publish-markdown post.md --title "My Post" --publish
219
- ```
220
-
221
- Publish from YAML:
222
-
223
- ```bash
224
- substack-publish-yaml draft.yaml
225
- ```
226
-
227
- Useful options:
228
-
229
- ```bash
230
- substack-publish-markdown post.md \
231
- --title "My Post" \
232
- --subtitle "Optional subtitle" \
233
- --tag python \
234
- --tag substack \
235
- --slug my-post \
236
- --search-engine-title "SEO title" \
237
- --search-engine-description "SEO description"
238
- ```
239
-
240
- ## Cookie Authentication
241
-
242
- Cookie authentication avoids logging in with email/password on every run and helps when Substack requires captcha or magic-link sign-in.
243
-
244
- Use a cookies JSON file:
245
-
246
- ```python
247
- import os
248
-
249
- from dotenv import load_dotenv
250
- from substack import Api
251
-
252
- load_dotenv()
253
-
254
- api = Api(
255
- cookies_path=os.getenv("COOKIES_PATH"),
256
- publication_url=os.getenv("PUBLICATION_URL"),
257
- )
258
- ```
259
-
260
- Or paste a browser cookie header into `COOKIES_STRING`:
261
-
262
- ```python
263
- import os
264
-
265
- from dotenv import load_dotenv
266
- from substack import Api
267
-
268
- load_dotenv()
269
-
270
- api = Api(
271
- cookies_string=os.getenv("COOKIES_STRING"),
272
- publication_url=os.getenv("PUBLICATION_URL"),
273
- )
274
- ```
275
-
276
- To get a cookie string:
277
-
278
- 1. Sign in to Substack in your browser.
279
- 2. Open developer tools.
280
- 3. Go to the network tab and refresh Substack.
281
- 4. Select a request such as `subscription/unred/subscriptions`.
282
- 5. Copy the full `cookie` request header value into `COOKIES_STRING`.
283
-
284
- To export a working session to a cookies JSON file:
285
-
286
- ```python
287
- api.export_cookies("cookies.json")
288
- ```
289
-
290
- Then set:
291
-
292
- ```env
293
- COOKIES_PATH=cookies.json
294
- ```
295
-
296
- The CLI also accepts a cookie JSON path:
297
-
298
- ```bash
299
- substack-publish-markdown post.md --cookies cookies.json
300
- ```
301
-
302
- ## Low-Level Post Builder
303
-
304
- ```python
305
- import os
306
-
307
- from dotenv import load_dotenv
308
- from substack import Api
309
- from substack.post import Post
310
-
311
- load_dotenv()
312
-
313
- api = Api(
314
- email=os.getenv("EMAIL"),
315
- password=os.getenv("PASSWORD"),
316
- publication_url=os.getenv("PUBLICATION_URL"),
317
- )
318
-
319
- user_id = api.get_user_id()
320
-
321
- post = Post(
322
- title="How to publish a Substack post using Python",
323
- subtitle="Created with python-substack",
324
- user_id=user_id,
325
- audience="everyone",
326
- write_comment_permissions="everyone",
327
- )
328
-
329
- post.paragraph("This is a paragraph.")
330
- post.add(
331
- {
332
- "type": "paragraph",
333
- "content": [
334
- {"content": "A link to "},
335
- {
336
- "content": "Substack",
337
- "marks": [{"type": "link", "href": "https://substack.com"}],
338
- },
339
- ],
340
- }
341
- )
342
- post.add({"type": "paywall"})
343
- post.add({"type": "captionedImage", "src": "https://example.com/image.png"})
344
-
345
- draft = api.post_draft(post.get_draft())
346
- api.prepublish_draft(draft.get("id"))
347
- api.publish_draft(draft.get("id"))
348
- ```
349
-
350
- ## Markdown Support
351
-
352
- ```python
353
- from substack.post import Post
354
-
355
- post = Post("Title", "Subtitle", user_id=1)
356
- post.from_markdown(
357
- """
358
- # Heading
359
-
360
- Paragraph with **bold**, *italic*, `code`, [links](https://example.com), and footnotes.[^1]
361
-
362
- - Lists
363
- - Images
364
-
365
- ![Alt](local-image.png "Caption")
366
-
367
- [^1]: Footnote text.
368
- """
369
- )
370
- ```
371
-
372
- Supported Markdown includes headings, paragraphs, bold, italic, inline code, strikethrough, superscript, subscript, links, images, linked images, image captions, code blocks, blockquotes, ordered lists, unordered lists, horizontal rules, footnotes, LaTeX math, pull quotes, and callouts. See [docs/markdown.md](docs/markdown.md) for the full reference with examples.
373
-
374
- When an `Api` instance is passed to `from_markdown`, local image paths are uploaded before the draft is created:
375
-
376
- ```python
377
- post.from_markdown(markdown_content, api=api)
378
- ```
379
-
380
- ## YAML Drafts
381
-
382
- ```yaml
383
- title: "My Post Title"
384
- subtitle: "My Post Subtitle"
385
- audience: "everyone"
386
- write_comment_permissions: "everyone"
387
- search_engine_title: "SEO title"
388
- search_engine_description: "SEO description"
389
- slug: "my-post-title"
390
- tags:
391
- - python
392
- - substack
393
- markdown: |
394
- # Introduction
395
-
396
- This post body is Markdown.
397
- ```
398
-
399
- The lower-level node format is also supported:
400
-
401
- ```yaml
402
- title: "My Post Title"
403
- subtitle: "My Post Subtitle"
404
- body:
405
- 0:
406
- type: "heading"
407
- level: 1
408
- content: "Introduction"
409
- 1:
410
- type: "paragraph"
411
- content: "This is a paragraph."
412
- 2:
413
- type: "captionedImage"
414
- src: "local_image.jpg"
415
- ```
416
-
417
- ## MCP Server
418
-
419
- Install the MCP extra:
420
-
421
- ```bash
422
- pip install "python-substack[mcp]"
423
- ```
424
-
425
- Run the server over stdio:
426
-
427
- ```bash
428
- substack-mcp
429
- ```
430
-
431
- Equivalent Python entry point:
432
-
433
- ```bash
434
- python -c "from substack_mcp.mcp_server import main; main()"
435
- ```
436
-
437
- Available tools:
438
-
439
- - `post_draft_from_markdown(...)`
440
- - `put_draft(draft_id, update_payload)`
441
- - `add_tags(draft_id, tags)`
442
- - `prepublish_draft(draft_id)`
443
- - `publish_draft(draft_id, send=True, share_automatically=False)`
444
-
445
- ## Development
446
-
447
- ```bash
448
- pip install pre-commit
449
- pre-commit install
450
- pytest
451
- ```
452
-
453
- Run the offline suite with:
454
-
455
- ```bash
456
- pytest -m "not live"
457
- ```
458
-
459
- Live Substack tests are not part of normal CI. They are opt-in and require
460
- configured credentials:
461
-
462
- ```bash
463
- RUN_SUBSTACK_E2E=1 pytest -m live
464
- ```
465
-
466
- The CLI operations smoke tests are separately opt-in. They create, inspect, and
467
- delete disposable drafts but never publish them:
468
-
469
- ```bash
470
- RUN_SUBSTACK_CLI_E2E=1 pytest -m live tests/substack/test_cli_end_to_end.py
471
- ```
472
-
473
- Release changes are tracked in [CHANGELOG.md](CHANGELOG.md).
474
- The maintainer release process is documented in [docs/releasing.md](docs/releasing.md).
475
-
476
- ## Disclaimer
477
-
478
- This project is not affiliated with Substack.
479
-
@@ -1,13 +0,0 @@
1
- substack/__init__.py,sha256=Wd16zqqfA6bYLntSyq_dnRIFxosqgzjbyIVcJigGQ_A,416
2
- substack/api.py,sha256=tuxefoBuOvBn4Ww2VCbqkWCuX6qPOg7YM6EsLheBpV4,23213
3
- substack/cli.py,sha256=JBCsCdmthjzTeDpOYBLeVKOdNYjBrPuY1o_CIsAIekk,21712
4
- substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
5
- substack/mdrender.py,sha256=QGJkdp1isFmhVyTRMogIabAK8OO2xQh9CrjnXZvFTY4,9308
6
- substack/nodes.py,sha256=fTsGO0-lTztcXiloIjXrHVnJes9EcltGcrX-mEV2ceQ,5004
7
- substack/post.py,sha256=nXeZMAZ6-lx1Qf4yuAlrEQ0lcWFYF2afai1_TxVEl0Q,19704
8
- substack_mcp/mcp_server.py,sha256=gmevdc59XTBXXMTvVMpYoq66Umlqku1O_uzmw5tMy7o,8405
9
- python_substack-0.1.27.dist-info/METADATA,sha256=-yH6Uq4uhgO8tUYONxf3EkMMpvXd7--dxEAMkYsHTMc,11738
10
- python_substack-0.1.27.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
11
- python_substack-0.1.27.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
12
- python_substack-0.1.27.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
13
- python_substack-0.1.27.dist-info/RECORD,,