python-substack 0.1.26__py3-none-any.whl → 0.2.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.
- python_substack-0.2.0.dist-info/METADATA +273 -0
- python_substack-0.2.0.dist-info/RECORD +13 -0
- substack/__init__.py +4 -2
- substack/api.py +21 -6
- substack/cli.py +46 -0
- substack/mdrender.py +39 -1
- substack/nodes.py +35 -0
- substack/post.py +33 -36
- python_substack-0.1.26.dist-info/METADATA +0 -433
- python_substack-0.1.26.dist-info/RECORD +0 -13
- {python_substack-0.1.26.dist-info → python_substack-0.2.0.dist-info}/WHEEL +0 -0
- {python_substack-0.1.26.dist-info → python_substack-0.2.0.dist-info}/entry_points.txt +0 -0
- {python_substack-0.1.26.dist-info → python_substack-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-substack
|
|
3
|
+
Version: 0.2.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: 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
|
+
Write Substack posts in Markdown and safely create, inspect, schedule, and
|
|
39
|
+
publish them through Python, a command-line interface, or MCP.
|
|
40
|
+
|
|
41
|
+
[](https://pypi.org/project/python-substack/)
|
|
42
|
+
[](https://pypi.org/project/python-substack/)
|
|
43
|
+
[](https://github.com/ma2za/python-substack/actions/workflows/ci.yml)
|
|
44
|
+
[](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml)
|
|
45
|
+
[](LICENSE)
|
|
46
|
+
[](https://pepy.tech/project/python-substack)
|
|
47
|
+
|
|
48
|
+
> [!IMPORTANT]
|
|
49
|
+
> Creating and publishing are separate operations. `substack drafts create`
|
|
50
|
+
> always creates an unpublished draft. It never schedules, sends, publishes,
|
|
51
|
+
> or deletes content.
|
|
52
|
+
|
|
53
|
+
## From Markdown to a Substack draft
|
|
54
|
+
|
|
55
|
+
Install the package:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install python-substack
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Check the selected account and publication:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
substack status
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Create a safe unpublished draft, then publish only when it is ready:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
substack drafts create post.md
|
|
71
|
+
substack drafts publish 12345 --no-send
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Publishing and deletion require confirmation. Noninteractive and JSON
|
|
75
|
+
workflows must pass `--yes` explicitly.
|
|
76
|
+
|
|
77
|
+
Markdown source:
|
|
78
|
+
|
|
79
|
+

|
|
80
|
+
|
|
81
|
+
Substack result:
|
|
82
|
+
|
|
83
|
+

|
|
84
|
+
|
|
85
|
+
## What it supports
|
|
86
|
+
|
|
87
|
+
- Create rich Substack drafts from Markdown.
|
|
88
|
+
- Upload local images referenced by Markdown.
|
|
89
|
+
- Set audience, comment permissions, SEO metadata, slug, sections, and tags.
|
|
90
|
+
- List and inspect publications and drafts.
|
|
91
|
+
- Schedule, unschedule, publish, and delete drafts with explicit safeguards.
|
|
92
|
+
- Use stable JSON envelopes in scripts and automation.
|
|
93
|
+
- Authenticate with browser cookies or email and password.
|
|
94
|
+
- Use the same publishing workflow from Python or an optional MCP server.
|
|
95
|
+
|
|
96
|
+
## Setup
|
|
97
|
+
|
|
98
|
+
Copy `.env.example` to `.env` and configure one authentication method:
|
|
99
|
+
|
|
100
|
+
```env
|
|
101
|
+
EMAIL=
|
|
102
|
+
PASSWORD=
|
|
103
|
+
PUBLICATION_URL=
|
|
104
|
+
COOKIES_PATH=
|
|
105
|
+
COOKIES_STRING=
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Cookie authentication is usually more reliable when Substack requires captcha
|
|
109
|
+
or magic-link sign-in. See
|
|
110
|
+
[Authentication](docs/authentication.md) for cookie export instructions and
|
|
111
|
+
account-selection details.
|
|
112
|
+
|
|
113
|
+
Verify the installation without authenticating:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
substack --version
|
|
117
|
+
substack --help
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## CLI
|
|
121
|
+
|
|
122
|
+
Create a draft with metadata:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
substack --json drafts create post.md \
|
|
126
|
+
--title "My Post" \
|
|
127
|
+
--subtitle "Optional subtitle" \
|
|
128
|
+
--tag python \
|
|
129
|
+
--tag substack \
|
|
130
|
+
--slug my-post \
|
|
131
|
+
--search-engine-title "SEO title" \
|
|
132
|
+
--search-engine-description "SEO description"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Inspect publications and drafts:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
substack publications list
|
|
139
|
+
substack drafts list --limit 10
|
|
140
|
+
substack drafts get 12345
|
|
141
|
+
substack --publication-url https://example.substack.com drafts list
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Manage scheduling:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
substack drafts schedule 12345 --at 2026-08-01T09:00:00+03:00
|
|
148
|
+
substack drafts unschedule 12345
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Publish or delete intentionally:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
substack drafts publish 12345 --no-send
|
|
155
|
+
substack drafts delete 12345 --yes
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Global options such as `--json`, `--cookies`, and `--publication-url` must
|
|
159
|
+
appear before the command:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
substack --json drafts list
|
|
163
|
+
substack --cookies cookies.json --json status
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The original standalone commands remain supported. See
|
|
167
|
+
[Legacy CLI commands](docs/legacy-cli.md).
|
|
168
|
+
|
|
169
|
+
## Python
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
import os
|
|
173
|
+
|
|
174
|
+
from dotenv import load_dotenv
|
|
175
|
+
from substack import Api
|
|
176
|
+
|
|
177
|
+
load_dotenv()
|
|
178
|
+
|
|
179
|
+
api = Api(
|
|
180
|
+
email=os.getenv("EMAIL"),
|
|
181
|
+
password=os.getenv("PASSWORD"),
|
|
182
|
+
publication_url=os.getenv("PUBLICATION_URL"),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
result = api.create_draft_from_markdown(
|
|
186
|
+
title="Shipping with Python",
|
|
187
|
+
subtitle="A short note from a script",
|
|
188
|
+
markdown="""
|
|
189
|
+
# Hello
|
|
190
|
+
|
|
191
|
+
This draft was created from **Markdown**.
|
|
192
|
+
|
|
193
|
+

|
|
194
|
+
""",
|
|
195
|
+
tags=["python", "automation"],
|
|
196
|
+
slug="shipping-with-python",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
print(result["draft"]["id"])
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`create_draft_from_markdown` creates a draft by default. It publishes only when
|
|
203
|
+
`publish=True` is passed.
|
|
204
|
+
|
|
205
|
+
For direct ProseMirror node construction, see the
|
|
206
|
+
[low-level Python API](docs/low-level-api.md). YAML workflows are documented in
|
|
207
|
+
[YAML drafts](docs/yaml.md).
|
|
208
|
+
|
|
209
|
+
## Markdown
|
|
210
|
+
|
|
211
|
+
Supported Markdown includes headings, paragraphs, bold, italic, inline code,
|
|
212
|
+
strikethrough, superscript, subscript, links, images, linked images, image
|
|
213
|
+
captions, code blocks, blockquotes, ordered and unordered lists, horizontal
|
|
214
|
+
rules, footnotes, LaTeX math, pull quotes, and callouts.
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from substack.post import Post
|
|
218
|
+
|
|
219
|
+
post = Post("Title", "Subtitle", user_id=1)
|
|
220
|
+
post.from_markdown(
|
|
221
|
+
"""
|
|
222
|
+
# Heading
|
|
223
|
+
|
|
224
|
+
Paragraph with **bold**, *italic*, `code`, and [links](https://example.com).
|
|
225
|
+
"""
|
|
226
|
+
)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Pass `api=` to upload local images while rendering:
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
post.from_markdown(markdown_content, api=api)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
See the complete [Markdown reference](docs/markdown.md).
|
|
236
|
+
|
|
237
|
+
## MCP
|
|
238
|
+
|
|
239
|
+
Install and run the optional MCP server:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
pip install "python-substack[mcp]"
|
|
243
|
+
substack-mcp
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The MCP tools use the same environment variables and SDK behavior as the CLI.
|
|
247
|
+
See [MCP server](docs/mcp.md) for the tool list and safety notes.
|
|
248
|
+
|
|
249
|
+
## Project documentation
|
|
250
|
+
|
|
251
|
+
- [Authentication](docs/authentication.md)
|
|
252
|
+
- [Markdown reference](docs/markdown.md)
|
|
253
|
+
- [Legacy CLI commands](docs/legacy-cli.md)
|
|
254
|
+
- [Low-level Python API](docs/low-level-api.md)
|
|
255
|
+
- [YAML drafts](docs/yaml.md)
|
|
256
|
+
- [MCP server](docs/mcp.md)
|
|
257
|
+
- [Compatibility policy](docs/compatibility.md)
|
|
258
|
+
- [Contributing](CONTRIBUTING.md)
|
|
259
|
+
- [Security policy](SECURITY.md)
|
|
260
|
+
- [Changelog](CHANGELOG.md)
|
|
261
|
+
|
|
262
|
+
## Compatibility
|
|
263
|
+
|
|
264
|
+
The project preserves existing Python APIs, console commands, CLI behavior,
|
|
265
|
+
environment variables, JSON keys, and MCP tool signatures through the 1.x
|
|
266
|
+
series. Additive capabilities may be introduced. See the
|
|
267
|
+
[compatibility policy](docs/compatibility.md).
|
|
268
|
+
|
|
269
|
+
## Disclaimer
|
|
270
|
+
|
|
271
|
+
This project is not affiliated with Substack. It uses undocumented Substack
|
|
272
|
+
interfaces that may change without notice.
|
|
273
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
substack/__init__.py,sha256=sg29UCiHYlpsz2E_kYynKEuTpgLYEMUxvy4tUg4opBg,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.2.0.dist-info/METADATA,sha256=YfWB49RQARORPNqrOaDaqTBkNYkNs5ANpxyZWlXLE-8,7802
|
|
10
|
+
python_substack-0.2.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
11
|
+
python_substack-0.2.0.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
|
|
12
|
+
python_substack-0.2.0.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
13
|
+
python_substack-0.2.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.
|
|
6
|
+
__version__ = "0.2.0"
|
|
7
7
|
__url__ = "https://github.com/ma2za/python-substack"
|
|
8
8
|
__download_url__ = "https://pypi.python.org/pypi/python-substack"
|
|
9
|
-
__description__ =
|
|
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
|
@@ -12,6 +12,7 @@ from datetime import datetime
|
|
|
12
12
|
from urllib.parse import unquote, urljoin
|
|
13
13
|
|
|
14
14
|
import requests
|
|
15
|
+
from requests.adapters import HTTPAdapter, Retry
|
|
15
16
|
|
|
16
17
|
from substack.exceptions import SubstackAPIException, SubstackRequestException
|
|
17
18
|
|
|
@@ -65,6 +66,18 @@ class Api:
|
|
|
65
66
|
logging.getLogger().setLevel(logging.DEBUG)
|
|
66
67
|
|
|
67
68
|
self._session = requests.Session()
|
|
69
|
+
retry = Retry(
|
|
70
|
+
total=4,
|
|
71
|
+
status=4,
|
|
72
|
+
backoff_factor=1,
|
|
73
|
+
status_forcelist=(429,),
|
|
74
|
+
allowed_methods=frozenset({"GET", "DELETE"}),
|
|
75
|
+
respect_retry_after_header=True,
|
|
76
|
+
raise_on_status=False,
|
|
77
|
+
)
|
|
78
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
79
|
+
self._session.mount("http://", adapter)
|
|
80
|
+
self._session.mount("https://", adapter)
|
|
68
81
|
|
|
69
82
|
# Load cookies from file if provided
|
|
70
83
|
# Helps with Captcha errors by reusing cookies from "local" auth, then switching to running code in the cloud
|
|
@@ -335,7 +348,6 @@ class Api:
|
|
|
335
348
|
return Api._handle_response(response=response)
|
|
336
349
|
|
|
337
350
|
def get_publication_subscriber_count(self):
|
|
338
|
-
|
|
339
351
|
"""
|
|
340
352
|
Get subscriber count.
|
|
341
353
|
|
|
@@ -346,7 +358,10 @@ class Api:
|
|
|
346
358
|
f"{self.publication_url}/publication_launch_checklist"
|
|
347
359
|
)
|
|
348
360
|
|
|
349
|
-
|
|
361
|
+
data = Api._handle_response(response=response)
|
|
362
|
+
if "subscriberCount" in data:
|
|
363
|
+
return data["subscriberCount"]
|
|
364
|
+
return len(data["subscribers"])
|
|
350
365
|
|
|
351
366
|
def get_published_posts(
|
|
352
367
|
self, offset=0, limit=25, order_by="post_date", order_direction="desc"
|
|
@@ -553,8 +568,8 @@ class Api:
|
|
|
553
568
|
|
|
554
569
|
"""
|
|
555
570
|
response = self._session.post(
|
|
556
|
-
f"{self.publication_url}/drafts/{draft}/
|
|
557
|
-
json={"
|
|
571
|
+
f"{self.publication_url}/drafts/{draft}/scheduled_release",
|
|
572
|
+
json={"trigger_at": draft_datetime.isoformat()},
|
|
558
573
|
)
|
|
559
574
|
return Api._handle_response(response=response)
|
|
560
575
|
|
|
@@ -567,8 +582,8 @@ class Api:
|
|
|
567
582
|
Returns:
|
|
568
583
|
|
|
569
584
|
"""
|
|
570
|
-
response = self._session.
|
|
571
|
-
f"{self.publication_url}/drafts/{draft}/
|
|
585
|
+
response = self._session.delete(
|
|
586
|
+
f"{self.publication_url}/drafts/{draft}/scheduled_release"
|
|
572
587
|
)
|
|
573
588
|
return Api._handle_response(response=response)
|
|
574
589
|
|
substack/cli.py
CHANGED
|
@@ -276,6 +276,37 @@ def _drafts_get(api, args):
|
|
|
276
276
|
print(f"{label}: {_display(value)}")
|
|
277
277
|
|
|
278
278
|
|
|
279
|
+
def _drafts_create(api, args):
|
|
280
|
+
markdown_path = Path(args.markdown_file)
|
|
281
|
+
markdown = markdown_path.read_text(encoding="utf-8")
|
|
282
|
+
title = args.title or _title_from_markdown(markdown, markdown_path.stem)
|
|
283
|
+
result = api.create_draft_from_markdown(
|
|
284
|
+
title=title,
|
|
285
|
+
markdown=markdown,
|
|
286
|
+
subtitle=args.subtitle,
|
|
287
|
+
audience=args.audience,
|
|
288
|
+
write_comment_permissions=args.write_comment_permissions,
|
|
289
|
+
search_engine_title=args.search_engine_title,
|
|
290
|
+
search_engine_description=args.search_engine_description,
|
|
291
|
+
slug=args.slug,
|
|
292
|
+
draft_section_id=args.draft_section_id,
|
|
293
|
+
tags=args.tags,
|
|
294
|
+
prepublish=False,
|
|
295
|
+
publish=False,
|
|
296
|
+
)
|
|
297
|
+
draft = result["draft"]
|
|
298
|
+
payload = {
|
|
299
|
+
"action": "create",
|
|
300
|
+
"draft_id": draft.get("id"),
|
|
301
|
+
"draft": draft,
|
|
302
|
+
"tags": result.get("tags"),
|
|
303
|
+
}
|
|
304
|
+
if args.json_output:
|
|
305
|
+
_print_json(payload)
|
|
306
|
+
else:
|
|
307
|
+
print(f"Created draft {draft.get('id')}: {title}")
|
|
308
|
+
|
|
309
|
+
|
|
279
310
|
def _drafts_schedule(api, args):
|
|
280
311
|
scheduled_at = _parse_schedule(args.at)
|
|
281
312
|
result = api.schedule_draft(args.draft_id, scheduled_at)
|
|
@@ -370,6 +401,21 @@ def _build_parser():
|
|
|
370
401
|
drafts_get.add_argument("draft_id", type=int)
|
|
371
402
|
drafts_get.set_defaults(handler=_drafts_get)
|
|
372
403
|
|
|
404
|
+
drafts_create = draft_commands.add_parser(
|
|
405
|
+
"create", help="Create a draft from a Markdown file."
|
|
406
|
+
)
|
|
407
|
+
drafts_create.add_argument("markdown_file", metavar="MARKDOWN_FILE")
|
|
408
|
+
drafts_create.add_argument("--title")
|
|
409
|
+
drafts_create.add_argument("--subtitle", default="")
|
|
410
|
+
drafts_create.add_argument("--audience", default="everyone")
|
|
411
|
+
drafts_create.add_argument("--write-comment-permissions", default="everyone")
|
|
412
|
+
drafts_create.add_argument("--search-engine-title")
|
|
413
|
+
drafts_create.add_argument("--search-engine-description")
|
|
414
|
+
drafts_create.add_argument("--slug")
|
|
415
|
+
drafts_create.add_argument("--draft-section-id", type=int)
|
|
416
|
+
drafts_create.add_argument("--tag", action="append", dest="tags", metavar="TAG")
|
|
417
|
+
drafts_create.set_defaults(handler=_drafts_create)
|
|
418
|
+
|
|
373
419
|
drafts_schedule = draft_commands.add_parser("schedule", help="Schedule a draft.")
|
|
374
420
|
drafts_schedule.add_argument("draft_id", type=int)
|
|
375
421
|
drafts_schedule.add_argument("--at", required=True)
|
substack/mdrender.py
CHANGED
|
@@ -22,7 +22,11 @@ from typing import Dict, List, Optional
|
|
|
22
22
|
|
|
23
23
|
from markdown_it import MarkdownIt
|
|
24
24
|
from markdown_it.tree import SyntaxTreeNode
|
|
25
|
+
from mdit_py_plugins.container import container_plugin
|
|
26
|
+
from mdit_py_plugins.dollarmath import dollarmath_plugin
|
|
25
27
|
from mdit_py_plugins.footnote import footnote_plugin
|
|
28
|
+
from mdit_py_plugins.subscript import sub_plugin
|
|
29
|
+
from mdit_py_plugins.superscript import superscript_plugin
|
|
26
30
|
|
|
27
31
|
from substack import nodes
|
|
28
32
|
from substack.nodes import MarkType, NodeType
|
|
@@ -31,11 +35,25 @@ _MARK_FOR = {
|
|
|
31
35
|
"strong": {"type": MarkType.STRONG},
|
|
32
36
|
"em": {"type": MarkType.EM},
|
|
33
37
|
"s": {"type": MarkType.STRIKETHROUGH},
|
|
38
|
+
"sup": {"type": MarkType.SUPERSCRIPT},
|
|
39
|
+
"sub": {"type": MarkType.SUBSCRIPT},
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
|
|
37
43
|
def _make_parser() -> MarkdownIt:
|
|
38
|
-
return
|
|
44
|
+
return (
|
|
45
|
+
MarkdownIt("commonmark")
|
|
46
|
+
.use(footnote_plugin)
|
|
47
|
+
# Pandoc-style delimiters: no whitespace just inside the dollars and no
|
|
48
|
+
# digit just outside them, so paired currency amounts ("$5 ... $10")
|
|
49
|
+
# stay plain text instead of becoming math.
|
|
50
|
+
.use(dollarmath_plugin, allow_space=False, allow_digits=False)
|
|
51
|
+
.use(sub_plugin)
|
|
52
|
+
.use(superscript_plugin)
|
|
53
|
+
.use(container_plugin, name="pullquote")
|
|
54
|
+
.use(container_plugin, name="callout")
|
|
55
|
+
.enable("strikethrough")
|
|
56
|
+
)
|
|
39
57
|
|
|
40
58
|
|
|
41
59
|
def _coalesce(out_nodes: List[Dict]) -> List[Dict]:
|
|
@@ -64,6 +82,8 @@ def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[D
|
|
|
64
82
|
out.append(nodes.text(child.content, marks))
|
|
65
83
|
elif t == "code_inline":
|
|
66
84
|
out.append(nodes.text(child.content, marks + [nodes.code_mark()]))
|
|
85
|
+
elif t == "math_inline":
|
|
86
|
+
out.append(nodes.latex_inline(child.content.strip()))
|
|
67
87
|
elif t in _MARK_FOR:
|
|
68
88
|
out.extend(_render_inline(child, marks + [_MARK_FOR[t]], ctx))
|
|
69
89
|
elif t == "link":
|
|
@@ -159,10 +179,28 @@ def _render_block(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
|
|
|
159
179
|
if t == "ordered_list":
|
|
160
180
|
return [nodes.ordered_list(_render_list_items(node, api, ctx))]
|
|
161
181
|
|
|
182
|
+
# "$$...$$ (label)" tokenizes as math_block_label; Substack has no equation
|
|
183
|
+
# labels, so it renders like an unlabeled block.
|
|
184
|
+
if t in ("math_block", "math_block_label"):
|
|
185
|
+
return [nodes.latex_block(node.content.strip())]
|
|
186
|
+
|
|
187
|
+
if t == "container_pullquote":
|
|
188
|
+
return [nodes.pullquote(_render_container_body(node, api, ctx))]
|
|
189
|
+
|
|
190
|
+
if t == "container_callout":
|
|
191
|
+
return [nodes.callout_block(_render_container_body(node, api, ctx))]
|
|
192
|
+
|
|
162
193
|
# footnote_block is handled separately in markdown_to_doc; ignore it here.
|
|
163
194
|
return []
|
|
164
195
|
|
|
165
196
|
|
|
197
|
+
def _render_container_body(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
|
|
198
|
+
body: List[Dict] = []
|
|
199
|
+
for child in node.children:
|
|
200
|
+
body.extend(_render_block(child, api, ctx))
|
|
201
|
+
return body
|
|
202
|
+
|
|
203
|
+
|
|
166
204
|
def _render_list_items(list_node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
|
|
167
205
|
items = []
|
|
168
206
|
for li in list_node.children:
|
substack/nodes.py
CHANGED
|
@@ -33,6 +33,10 @@ class NodeType:
|
|
|
33
33
|
FOOTNOTE_ANCHOR = "footnoteAnchor"
|
|
34
34
|
CAPTIONED_IMAGE = "captionedImage"
|
|
35
35
|
CAPTION = "caption"
|
|
36
|
+
LATEX_BLOCK = "latex_block"
|
|
37
|
+
LATEX_INLINE = "latex"
|
|
38
|
+
PULLQUOTE = "pullquote"
|
|
39
|
+
CALLOUT_BLOCK = "calloutBlock"
|
|
36
40
|
|
|
37
41
|
|
|
38
42
|
class MarkType:
|
|
@@ -40,6 +44,8 @@ class MarkType:
|
|
|
40
44
|
EM = "em"
|
|
41
45
|
CODE = "code"
|
|
42
46
|
STRIKETHROUGH = "strikethrough"
|
|
47
|
+
SUPERSCRIPT = "superscript"
|
|
48
|
+
SUBSCRIPT = "subscript"
|
|
43
49
|
LINK = "link"
|
|
44
50
|
|
|
45
51
|
|
|
@@ -146,3 +152,32 @@ def footnote(number: int, paragraphs: List[Dict]) -> Dict:
|
|
|
146
152
|
"attrs": {"number": number},
|
|
147
153
|
"content": paragraphs or [paragraph()],
|
|
148
154
|
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def latex_block(expression: str) -> Dict:
|
|
158
|
+
return {
|
|
159
|
+
"type": NodeType.LATEX_BLOCK,
|
|
160
|
+
"attrs": {"persistentExpression": expression, "dirty": True},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def latex_inline(expression: str) -> Dict:
|
|
165
|
+
return {
|
|
166
|
+
"type": NodeType.LATEX_INLINE,
|
|
167
|
+
"attrs": {"expression": expression, "persistentExpression": expression},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def pullquote(paragraphs: List[Dict]) -> Dict:
|
|
172
|
+
return {
|
|
173
|
+
"type": NodeType.PULLQUOTE,
|
|
174
|
+
"attrs": {"align": None, "color": None},
|
|
175
|
+
"content": paragraphs or [paragraph()],
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def callout_block(paragraphs: List[Dict]) -> Dict:
|
|
180
|
+
return {
|
|
181
|
+
"type": NodeType.CALLOUT_BLOCK,
|
|
182
|
+
"content": paragraphs or [paragraph()],
|
|
183
|
+
}
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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(
|
|
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,433 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: python-substack
|
|
3
|
-
Version: 0.1.26
|
|
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.4,<0.5)
|
|
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
|
-
[](https://pypi.org/project/python-substack/)
|
|
41
|
-
[](https://pypi.org/project/python-substack/)
|
|
42
|
-
[](https://github.com/ma2za/python-substack/actions/workflows/ci.yml)
|
|
43
|
-
[](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml)
|
|
44
|
-
[](LICENSE)
|
|
45
|
-
[](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
|
-
Check authentication, the selected publication, and subscriber count:
|
|
91
|
-
|
|
92
|
-
```bash
|
|
93
|
-
substack status
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
List available publications or target one without changing `.env`:
|
|
97
|
-
|
|
98
|
-
```bash
|
|
99
|
-
substack publications list
|
|
100
|
-
substack --publication-url https://example.substack.com drafts list
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
List and inspect drafts:
|
|
104
|
-
|
|
105
|
-
```bash
|
|
106
|
-
substack drafts list --limit 10
|
|
107
|
-
substack drafts get 12345
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
Schedule with a timezone-aware ISO 8601 timestamp, or remove a schedule:
|
|
111
|
-
|
|
112
|
-
```bash
|
|
113
|
-
substack drafts schedule 12345 --at 2026-08-01T09:00:00+03:00
|
|
114
|
-
substack drafts unschedule 12345
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
Publishing and deletion prompt for confirmation. Use `--yes` for intentional non-interactive execution:
|
|
118
|
-
|
|
119
|
-
```bash
|
|
120
|
-
substack drafts publish 12345 --no-send
|
|
121
|
-
substack drafts delete 12345 --yes
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
Global options must appear before the command. `--json` returns stable envelopes containing the raw Substack responses:
|
|
125
|
-
|
|
126
|
-
```bash
|
|
127
|
-
substack --json drafts list
|
|
128
|
-
substack --cookies cookies.json --json status
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
## Quickstart
|
|
132
|
-
|
|
133
|
-
```python
|
|
134
|
-
import os
|
|
135
|
-
|
|
136
|
-
from dotenv import load_dotenv
|
|
137
|
-
from substack import Api
|
|
138
|
-
|
|
139
|
-
load_dotenv()
|
|
140
|
-
|
|
141
|
-
api = Api(
|
|
142
|
-
email=os.getenv("EMAIL"),
|
|
143
|
-
password=os.getenv("PASSWORD"),
|
|
144
|
-
publication_url=os.getenv("PUBLICATION_URL"),
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
result = api.create_draft_from_markdown(
|
|
148
|
-
title="Shipping with Python",
|
|
149
|
-
subtitle="A short note from a script",
|
|
150
|
-
markdown="""
|
|
151
|
-
# Hello
|
|
152
|
-
|
|
153
|
-
This draft was created from **Markdown**.
|
|
154
|
-
|
|
155
|
-

|
|
156
|
-
""",
|
|
157
|
-
tags=["python", "automation"],
|
|
158
|
-
slug="shipping-with-python",
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
print(result["draft"]["id"])
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
`create_draft_from_markdown` creates a draft by default. It only publishes when `publish=True` is passed.
|
|
165
|
-
|
|
166
|
-
## Content Publishing CLI
|
|
167
|
-
|
|
168
|
-
Check authentication without creating a draft:
|
|
169
|
-
|
|
170
|
-
```bash
|
|
171
|
-
substack-auth-check
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
With a cookies JSON file:
|
|
175
|
-
|
|
176
|
-
```bash
|
|
177
|
-
substack-auth-check --cookies cookies.json
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
Publish a Markdown file as a draft:
|
|
181
|
-
|
|
182
|
-
```bash
|
|
183
|
-
substack-publish-markdown post.md --title "My Post"
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
Create and publish:
|
|
187
|
-
|
|
188
|
-
```bash
|
|
189
|
-
substack-publish-markdown post.md --title "My Post" --publish
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
Publish from YAML:
|
|
193
|
-
|
|
194
|
-
```bash
|
|
195
|
-
substack-publish-yaml draft.yaml
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
Useful options:
|
|
199
|
-
|
|
200
|
-
```bash
|
|
201
|
-
substack-publish-markdown post.md \
|
|
202
|
-
--title "My Post" \
|
|
203
|
-
--subtitle "Optional subtitle" \
|
|
204
|
-
--tag python \
|
|
205
|
-
--tag substack \
|
|
206
|
-
--slug my-post \
|
|
207
|
-
--search-engine-title "SEO title" \
|
|
208
|
-
--search-engine-description "SEO description"
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
## Cookie Authentication
|
|
212
|
-
|
|
213
|
-
Cookie authentication avoids logging in with email/password on every run and helps when Substack requires captcha or magic-link sign-in.
|
|
214
|
-
|
|
215
|
-
Use a cookies JSON file:
|
|
216
|
-
|
|
217
|
-
```python
|
|
218
|
-
import os
|
|
219
|
-
|
|
220
|
-
from dotenv import load_dotenv
|
|
221
|
-
from substack import Api
|
|
222
|
-
|
|
223
|
-
load_dotenv()
|
|
224
|
-
|
|
225
|
-
api = Api(
|
|
226
|
-
cookies_path=os.getenv("COOKIES_PATH"),
|
|
227
|
-
publication_url=os.getenv("PUBLICATION_URL"),
|
|
228
|
-
)
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
Or paste a browser cookie header into `COOKIES_STRING`:
|
|
232
|
-
|
|
233
|
-
```python
|
|
234
|
-
import os
|
|
235
|
-
|
|
236
|
-
from dotenv import load_dotenv
|
|
237
|
-
from substack import Api
|
|
238
|
-
|
|
239
|
-
load_dotenv()
|
|
240
|
-
|
|
241
|
-
api = Api(
|
|
242
|
-
cookies_string=os.getenv("COOKIES_STRING"),
|
|
243
|
-
publication_url=os.getenv("PUBLICATION_URL"),
|
|
244
|
-
)
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
To get a cookie string:
|
|
248
|
-
|
|
249
|
-
1. Sign in to Substack in your browser.
|
|
250
|
-
2. Open developer tools.
|
|
251
|
-
3. Go to the network tab and refresh Substack.
|
|
252
|
-
4. Select a request such as `subscription/unred/subscriptions`.
|
|
253
|
-
5. Copy the full `cookie` request header value into `COOKIES_STRING`.
|
|
254
|
-
|
|
255
|
-
To export a working session to a cookies JSON file:
|
|
256
|
-
|
|
257
|
-
```python
|
|
258
|
-
api.export_cookies("cookies.json")
|
|
259
|
-
```
|
|
260
|
-
|
|
261
|
-
Then set:
|
|
262
|
-
|
|
263
|
-
```env
|
|
264
|
-
COOKIES_PATH=cookies.json
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
The CLI also accepts a cookie JSON path:
|
|
268
|
-
|
|
269
|
-
```bash
|
|
270
|
-
substack-publish-markdown post.md --cookies cookies.json
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
## Low-Level Post Builder
|
|
274
|
-
|
|
275
|
-
```python
|
|
276
|
-
import os
|
|
277
|
-
|
|
278
|
-
from dotenv import load_dotenv
|
|
279
|
-
from substack import Api
|
|
280
|
-
from substack.post import Post
|
|
281
|
-
|
|
282
|
-
load_dotenv()
|
|
283
|
-
|
|
284
|
-
api = Api(
|
|
285
|
-
email=os.getenv("EMAIL"),
|
|
286
|
-
password=os.getenv("PASSWORD"),
|
|
287
|
-
publication_url=os.getenv("PUBLICATION_URL"),
|
|
288
|
-
)
|
|
289
|
-
|
|
290
|
-
user_id = api.get_user_id()
|
|
291
|
-
|
|
292
|
-
post = Post(
|
|
293
|
-
title="How to publish a Substack post using Python",
|
|
294
|
-
subtitle="Created with python-substack",
|
|
295
|
-
user_id=user_id,
|
|
296
|
-
audience="everyone",
|
|
297
|
-
write_comment_permissions="everyone",
|
|
298
|
-
)
|
|
299
|
-
|
|
300
|
-
post.paragraph("This is a paragraph.")
|
|
301
|
-
post.add(
|
|
302
|
-
{
|
|
303
|
-
"type": "paragraph",
|
|
304
|
-
"content": [
|
|
305
|
-
{"content": "A link to "},
|
|
306
|
-
{
|
|
307
|
-
"content": "Substack",
|
|
308
|
-
"marks": [{"type": "link", "href": "https://substack.com"}],
|
|
309
|
-
},
|
|
310
|
-
],
|
|
311
|
-
}
|
|
312
|
-
)
|
|
313
|
-
post.add({"type": "paywall"})
|
|
314
|
-
post.add({"type": "captionedImage", "src": "https://example.com/image.png"})
|
|
315
|
-
|
|
316
|
-
draft = api.post_draft(post.get_draft())
|
|
317
|
-
api.prepublish_draft(draft.get("id"))
|
|
318
|
-
api.publish_draft(draft.get("id"))
|
|
319
|
-
```
|
|
320
|
-
|
|
321
|
-
## Markdown Support
|
|
322
|
-
|
|
323
|
-
```python
|
|
324
|
-
from substack.post import Post
|
|
325
|
-
|
|
326
|
-
post = Post("Title", "Subtitle", user_id=1)
|
|
327
|
-
post.from_markdown(
|
|
328
|
-
"""
|
|
329
|
-
# Heading
|
|
330
|
-
|
|
331
|
-
Paragraph with **bold**, *italic*, `code`, [links](https://example.com), and footnotes.[^1]
|
|
332
|
-
|
|
333
|
-
- Lists
|
|
334
|
-
- Images
|
|
335
|
-
|
|
336
|
-

|
|
337
|
-
|
|
338
|
-
[^1]: Footnote text.
|
|
339
|
-
"""
|
|
340
|
-
)
|
|
341
|
-
```
|
|
342
|
-
|
|
343
|
-
Supported Markdown includes headings, paragraphs, bold, italic, inline code, strikethrough, links, images, linked images, image captions, code blocks, blockquotes, ordered lists, unordered lists, horizontal rules, and footnotes.
|
|
344
|
-
|
|
345
|
-
When an `Api` instance is passed to `from_markdown`, local image paths are uploaded before the draft is created:
|
|
346
|
-
|
|
347
|
-
```python
|
|
348
|
-
post.from_markdown(markdown_content, api=api)
|
|
349
|
-
```
|
|
350
|
-
|
|
351
|
-
## YAML Drafts
|
|
352
|
-
|
|
353
|
-
```yaml
|
|
354
|
-
title: "My Post Title"
|
|
355
|
-
subtitle: "My Post Subtitle"
|
|
356
|
-
audience: "everyone"
|
|
357
|
-
write_comment_permissions: "everyone"
|
|
358
|
-
search_engine_title: "SEO title"
|
|
359
|
-
search_engine_description: "SEO description"
|
|
360
|
-
slug: "my-post-title"
|
|
361
|
-
tags:
|
|
362
|
-
- python
|
|
363
|
-
- substack
|
|
364
|
-
markdown: |
|
|
365
|
-
# Introduction
|
|
366
|
-
|
|
367
|
-
This post body is Markdown.
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
The lower-level node format is also supported:
|
|
371
|
-
|
|
372
|
-
```yaml
|
|
373
|
-
title: "My Post Title"
|
|
374
|
-
subtitle: "My Post Subtitle"
|
|
375
|
-
body:
|
|
376
|
-
0:
|
|
377
|
-
type: "heading"
|
|
378
|
-
level: 1
|
|
379
|
-
content: "Introduction"
|
|
380
|
-
1:
|
|
381
|
-
type: "paragraph"
|
|
382
|
-
content: "This is a paragraph."
|
|
383
|
-
2:
|
|
384
|
-
type: "captionedImage"
|
|
385
|
-
src: "local_image.jpg"
|
|
386
|
-
```
|
|
387
|
-
|
|
388
|
-
## MCP Server
|
|
389
|
-
|
|
390
|
-
Install the MCP extra:
|
|
391
|
-
|
|
392
|
-
```bash
|
|
393
|
-
pip install "python-substack[mcp]"
|
|
394
|
-
```
|
|
395
|
-
|
|
396
|
-
Run the server over stdio:
|
|
397
|
-
|
|
398
|
-
```bash
|
|
399
|
-
substack-mcp
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
Equivalent Python entry point:
|
|
403
|
-
|
|
404
|
-
```bash
|
|
405
|
-
python -c "from substack_mcp.mcp_server import main; main()"
|
|
406
|
-
```
|
|
407
|
-
|
|
408
|
-
Available tools:
|
|
409
|
-
|
|
410
|
-
- `post_draft_from_markdown(...)`
|
|
411
|
-
- `put_draft(draft_id, update_payload)`
|
|
412
|
-
- `add_tags(draft_id, tags)`
|
|
413
|
-
- `prepublish_draft(draft_id)`
|
|
414
|
-
- `publish_draft(draft_id, send=True, share_automatically=False)`
|
|
415
|
-
|
|
416
|
-
## Development
|
|
417
|
-
|
|
418
|
-
```bash
|
|
419
|
-
pip install pre-commit
|
|
420
|
-
pre-commit install
|
|
421
|
-
pytest
|
|
422
|
-
```
|
|
423
|
-
|
|
424
|
-
Live Substack tests are opt-in. Set `RUN_SUBSTACK_E2E=1` and configure credentials before running them.
|
|
425
|
-
|
|
426
|
-
The CLI operations smoke test is separately opt-in. Set `RUN_SUBSTACK_CLI_E2E=1` to create, schedule, unschedule, inspect, and delete a disposable draft. It never publishes the draft.
|
|
427
|
-
|
|
428
|
-
Release changes are tracked in [CHANGELOG.md](CHANGELOG.md).
|
|
429
|
-
|
|
430
|
-
## Disclaimer
|
|
431
|
-
|
|
432
|
-
This project is not affiliated with Substack.
|
|
433
|
-
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
substack/__init__.py,sha256=6wfj_pMsGW4d-y6wyswxGMMQtu0q41tmalHchGkzOyY,416
|
|
2
|
-
substack/api.py,sha256=QX9A_7PanQd_Q_QJwnxXM9auIHCtKd7rHbFTDATcm-U,22640
|
|
3
|
-
substack/cli.py,sha256=F6ba6mgrBzoHXpwNTgUv8JMRmSKgdP7AHpm89keC8EM,19911
|
|
4
|
-
substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
|
|
5
|
-
substack/mdrender.py,sha256=cB0fLFzOF7CmWHk-9eRvQormP8StqIgy9hYE6CSKPH0,7770
|
|
6
|
-
substack/nodes.py,sha256=eFVxoVwi684g_DLbz8BRHfU7nyHsJb-m8w17UHocXgY,4106
|
|
7
|
-
substack/post.py,sha256=nXeZMAZ6-lx1Qf4yuAlrEQ0lcWFYF2afai1_TxVEl0Q,19704
|
|
8
|
-
substack_mcp/mcp_server.py,sha256=gmevdc59XTBXXMTvVMpYoq66Umlqku1O_uzmw5tMy7o,8405
|
|
9
|
-
python_substack-0.1.26.dist-info/METADATA,sha256=mw0CiIItyXlZt5jODivLqwcHtMFILPpHZCEJKcsysR4,10702
|
|
10
|
-
python_substack-0.1.26.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
11
|
-
python_substack-0.1.26.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
|
|
12
|
-
python_substack-0.1.26.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
13
|
-
python_substack-0.1.26.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|