python-substack 0.1.24__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.
- python_substack-0.1.25.dist-info/METADATA +381 -0
- python_substack-0.1.25.dist-info/RECORD +13 -0
- python_substack-0.1.25.dist-info/entry_points.txt +6 -0
- substack/__init__.py +1 -1
- substack/api.py +93 -16
- substack/cli.py +224 -0
- substack_mcp/mcp_server.py +293 -0
- python_substack-0.1.24.dist-info/METADATA +0 -331
- python_substack-0.1.24.dist-info/RECORD +0 -10
- {python_substack-0.1.24.dist-info → python_substack-0.1.25.dist-info}/WHEEL +0 -0
- {python_substack-0.1.24.dist-info → python_substack-0.1.25.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-substack
|
|
3
|
+
Version: 0.1.25
|
|
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: 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
|
+
Unofficial Python tools for publishing to [Substack](https://substack.com/).
|
|
39
|
+
|
|
40
|
+
[](https://pepy.tech/project/python-substack)
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- Create drafts and publish posts from Python.
|
|
46
|
+
- Convert Markdown into Substack's editor document format.
|
|
47
|
+
- Upload local images while rendering Markdown.
|
|
48
|
+
- Set audience, comment permissions, SEO title, SEO description, slug, sections, and tags.
|
|
49
|
+
- Publish now, schedule drafts, or keep drafts unpublished by default.
|
|
50
|
+
- Authenticate with email/password, cookies JSON, or a browser cookie string.
|
|
51
|
+
- Run a FastMCP server for AI-assisted publishing workflows.
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install python-substack
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Install the MCP server extra:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install "python-substack[mcp]"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Setup
|
|
66
|
+
|
|
67
|
+
Copy `.env.example` to `.env` and fill in one authentication method:
|
|
68
|
+
|
|
69
|
+
```env
|
|
70
|
+
EMAIL=
|
|
71
|
+
PASSWORD=
|
|
72
|
+
PUBLICATION_URL=
|
|
73
|
+
COOKIES_PATH=
|
|
74
|
+
COOKIES_STRING=
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
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".
|
|
80
|
+
|
|
81
|
+
## Quickstart
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
import os
|
|
85
|
+
|
|
86
|
+
from dotenv import load_dotenv
|
|
87
|
+
from substack import Api
|
|
88
|
+
|
|
89
|
+
load_dotenv()
|
|
90
|
+
|
|
91
|
+
api = Api(
|
|
92
|
+
email=os.getenv("EMAIL"),
|
|
93
|
+
password=os.getenv("PASSWORD"),
|
|
94
|
+
publication_url=os.getenv("PUBLICATION_URL"),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
result = api.create_draft_from_markdown(
|
|
98
|
+
title="Shipping with Python",
|
|
99
|
+
subtitle="A short note from a script",
|
|
100
|
+
markdown="""
|
|
101
|
+
# Hello
|
|
102
|
+
|
|
103
|
+
This draft was created from **Markdown**.
|
|
104
|
+
|
|
105
|
+

|
|
106
|
+
""",
|
|
107
|
+
tags=["python", "automation"],
|
|
108
|
+
slug="shipping-with-python",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
print(result["draft"]["id"])
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`create_draft_from_markdown` creates a draft by default. It only publishes when `publish=True` is passed.
|
|
115
|
+
|
|
116
|
+
## CLI
|
|
117
|
+
|
|
118
|
+
Check authentication without creating a draft:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
substack-auth-check
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
With a cookies JSON file:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
substack-auth-check --cookies cookies.json
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Publish a Markdown file as a draft:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
substack-publish-markdown post.md --title "My Post"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Create and publish:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
substack-publish-markdown post.md --title "My Post" --publish
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Publish from YAML:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
substack-publish-yaml draft.yaml
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Useful options:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
substack-publish-markdown post.md \
|
|
152
|
+
--title "My Post" \
|
|
153
|
+
--subtitle "Optional subtitle" \
|
|
154
|
+
--tag python \
|
|
155
|
+
--tag substack \
|
|
156
|
+
--slug my-post \
|
|
157
|
+
--search-engine-title "SEO title" \
|
|
158
|
+
--search-engine-description "SEO description"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Cookie Authentication
|
|
162
|
+
|
|
163
|
+
Cookie authentication avoids logging in with email/password on every run and helps when Substack requires captcha or magic-link sign-in.
|
|
164
|
+
|
|
165
|
+
Use a cookies JSON file:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
import os
|
|
169
|
+
|
|
170
|
+
from dotenv import load_dotenv
|
|
171
|
+
from substack import Api
|
|
172
|
+
|
|
173
|
+
load_dotenv()
|
|
174
|
+
|
|
175
|
+
api = Api(
|
|
176
|
+
cookies_path=os.getenv("COOKIES_PATH"),
|
|
177
|
+
publication_url=os.getenv("PUBLICATION_URL"),
|
|
178
|
+
)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Or paste a browser cookie header into `COOKIES_STRING`:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
import os
|
|
185
|
+
|
|
186
|
+
from dotenv import load_dotenv
|
|
187
|
+
from substack import Api
|
|
188
|
+
|
|
189
|
+
load_dotenv()
|
|
190
|
+
|
|
191
|
+
api = Api(
|
|
192
|
+
cookies_string=os.getenv("COOKIES_STRING"),
|
|
193
|
+
publication_url=os.getenv("PUBLICATION_URL"),
|
|
194
|
+
)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
To get a cookie string:
|
|
198
|
+
|
|
199
|
+
1. Sign in to Substack in your browser.
|
|
200
|
+
2. Open developer tools.
|
|
201
|
+
3. Go to the network tab and refresh Substack.
|
|
202
|
+
4. Select a request such as `subscription/unred/subscriptions`.
|
|
203
|
+
5. Copy the full `cookie` request header value into `COOKIES_STRING`.
|
|
204
|
+
|
|
205
|
+
To export a working session to a cookies JSON file:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
api.export_cookies("cookies.json")
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Then set:
|
|
212
|
+
|
|
213
|
+
```env
|
|
214
|
+
COOKIES_PATH=cookies.json
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The CLI also accepts a cookie JSON path:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
substack-publish-markdown post.md --cookies cookies.json
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Low-Level Post Builder
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
import os
|
|
227
|
+
|
|
228
|
+
from dotenv import load_dotenv
|
|
229
|
+
from substack import Api
|
|
230
|
+
from substack.post import Post
|
|
231
|
+
|
|
232
|
+
load_dotenv()
|
|
233
|
+
|
|
234
|
+
api = Api(
|
|
235
|
+
email=os.getenv("EMAIL"),
|
|
236
|
+
password=os.getenv("PASSWORD"),
|
|
237
|
+
publication_url=os.getenv("PUBLICATION_URL"),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
user_id = api.get_user_id()
|
|
241
|
+
|
|
242
|
+
post = Post(
|
|
243
|
+
title="How to publish a Substack post using Python",
|
|
244
|
+
subtitle="Created with python-substack",
|
|
245
|
+
user_id=user_id,
|
|
246
|
+
audience="everyone",
|
|
247
|
+
write_comment_permissions="everyone",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
post.paragraph("This is a paragraph.")
|
|
251
|
+
post.add(
|
|
252
|
+
{
|
|
253
|
+
"type": "paragraph",
|
|
254
|
+
"content": [
|
|
255
|
+
{"content": "A link to "},
|
|
256
|
+
{
|
|
257
|
+
"content": "Substack",
|
|
258
|
+
"marks": [{"type": "link", "href": "https://substack.com"}],
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
}
|
|
262
|
+
)
|
|
263
|
+
post.add({"type": "paywall"})
|
|
264
|
+
post.add({"type": "captionedImage", "src": "https://example.com/image.png"})
|
|
265
|
+
|
|
266
|
+
draft = api.post_draft(post.get_draft())
|
|
267
|
+
api.prepublish_draft(draft.get("id"))
|
|
268
|
+
api.publish_draft(draft.get("id"))
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Markdown Support
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
from substack.post import Post
|
|
275
|
+
|
|
276
|
+
post = Post("Title", "Subtitle", user_id=1)
|
|
277
|
+
post.from_markdown(
|
|
278
|
+
"""
|
|
279
|
+
# Heading
|
|
280
|
+
|
|
281
|
+
Paragraph with **bold**, *italic*, `code`, [links](https://example.com), and footnotes.[^1]
|
|
282
|
+
|
|
283
|
+
- Lists
|
|
284
|
+
- Images
|
|
285
|
+
|
|
286
|
+

|
|
287
|
+
|
|
288
|
+
[^1]: Footnote text.
|
|
289
|
+
"""
|
|
290
|
+
)
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
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.
|
|
294
|
+
|
|
295
|
+
When an `Api` instance is passed to `from_markdown`, local image paths are uploaded before the draft is created:
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
post.from_markdown(markdown_content, api=api)
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## YAML Drafts
|
|
302
|
+
|
|
303
|
+
```yaml
|
|
304
|
+
title: "My Post Title"
|
|
305
|
+
subtitle: "My Post Subtitle"
|
|
306
|
+
audience: "everyone"
|
|
307
|
+
write_comment_permissions: "everyone"
|
|
308
|
+
search_engine_title: "SEO title"
|
|
309
|
+
search_engine_description: "SEO description"
|
|
310
|
+
slug: "my-post-title"
|
|
311
|
+
tags:
|
|
312
|
+
- python
|
|
313
|
+
- substack
|
|
314
|
+
markdown: |
|
|
315
|
+
# Introduction
|
|
316
|
+
|
|
317
|
+
This post body is Markdown.
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The lower-level node format is also supported:
|
|
321
|
+
|
|
322
|
+
```yaml
|
|
323
|
+
title: "My Post Title"
|
|
324
|
+
subtitle: "My Post Subtitle"
|
|
325
|
+
body:
|
|
326
|
+
0:
|
|
327
|
+
type: "heading"
|
|
328
|
+
level: 1
|
|
329
|
+
content: "Introduction"
|
|
330
|
+
1:
|
|
331
|
+
type: "paragraph"
|
|
332
|
+
content: "This is a paragraph."
|
|
333
|
+
2:
|
|
334
|
+
type: "captionedImage"
|
|
335
|
+
src: "local_image.jpg"
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## MCP Server
|
|
339
|
+
|
|
340
|
+
Install the MCP extra:
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
pip install "python-substack[mcp]"
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Run the server over stdio:
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
substack-mcp
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Equivalent Python entry point:
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
python -c "from substack_mcp.mcp_server import main; main()"
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Available tools:
|
|
359
|
+
|
|
360
|
+
- `post_draft_from_markdown(...)`
|
|
361
|
+
- `put_draft(draft_id, update_payload)`
|
|
362
|
+
- `add_tags(draft_id, tags)`
|
|
363
|
+
- `prepublish_draft(draft_id)`
|
|
364
|
+
- `publish_draft(draft_id, send=True, share_automatically=False)`
|
|
365
|
+
|
|
366
|
+
## Development
|
|
367
|
+
|
|
368
|
+
```bash
|
|
369
|
+
pip install pre-commit
|
|
370
|
+
pre-commit install
|
|
371
|
+
pytest
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Live Substack tests are opt-in. Set `RUN_SUBSTACK_E2E=1` and configure credentials before running them.
|
|
375
|
+
|
|
376
|
+
Release changes are tracked in [CHANGELOG.md](CHANGELOG.md).
|
|
377
|
+
|
|
378
|
+
## Disclaimer
|
|
379
|
+
|
|
380
|
+
This project is not affiliated with Substack.
|
|
381
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
substack/__init__.py,sha256=sm5s0r53o_oBjA_3yUob0L4VwXeT8bNuA20PnAjTflI,390
|
|
2
|
+
substack/api.py,sha256=QX9A_7PanQd_Q_QJwnxXM9auIHCtKd7rHbFTDATcm-U,22640
|
|
3
|
+
substack/cli.py,sha256=Hu9tBjyIW9zl7IE92VuZ-MOThS2cLpwXXAOR8Ixsu6o,7708
|
|
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.25.dist-info/METADATA,sha256=Ero48_tB_HIHbkccKxfKK24rvOJiqlO80SlXItRgpC4,8720
|
|
10
|
+
python_substack-0.1.25.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
11
|
+
python_substack-0.1.25.dist-info/entry_points.txt,sha256=la9TUtzyaVksmdSkdKZsg55XIRXx4KjkKW7J3pvBegc,209
|
|
12
|
+
python_substack-0.1.25.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
13
|
+
python_substack-0.1.25.dist-info/RECORD,,
|
substack/__init__.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
__author__ = "Paolo Mazza"
|
|
4
4
|
__email__ = "mazzapaolo2019@gmail.com"
|
|
5
5
|
__license__ = "MIT License"
|
|
6
|
-
__version__ = "0.1.
|
|
6
|
+
__version__ = "0.1.25"
|
|
7
7
|
__url__ = "https://github.com/ma2za/python-substack"
|
|
8
8
|
__download_url__ = "https://pypi.python.org/pypi/python-substack"
|
|
9
9
|
__description__ = "A Python wrapper around the Substack API"
|
substack/api.py
CHANGED
|
@@ -9,7 +9,7 @@ import json
|
|
|
9
9
|
import logging
|
|
10
10
|
import os
|
|
11
11
|
from datetime import datetime
|
|
12
|
-
from urllib.parse import
|
|
12
|
+
from urllib.parse import unquote, urljoin
|
|
13
13
|
|
|
14
14
|
import requests
|
|
15
15
|
|
|
@@ -111,20 +111,20 @@ class Api:
|
|
|
111
111
|
def _parse_cookies_string(cookies_string: str) -> dict:
|
|
112
112
|
"""
|
|
113
113
|
Parse a semicolon-separated cookie string into a dictionary.
|
|
114
|
-
|
|
114
|
+
|
|
115
115
|
Args:
|
|
116
116
|
cookies_string: A semicolon-separated string of cookies (e.g., "cookie1=value1; cookie2=value2")
|
|
117
|
-
|
|
117
|
+
|
|
118
118
|
Returns:
|
|
119
119
|
A dictionary of cookie name-value pairs
|
|
120
120
|
"""
|
|
121
121
|
cookies = {}
|
|
122
|
-
for cookie_pair in cookies_string.split(
|
|
122
|
+
for cookie_pair in cookies_string.split(";"):
|
|
123
123
|
cookie_pair = cookie_pair.strip()
|
|
124
124
|
if not cookie_pair:
|
|
125
125
|
continue
|
|
126
|
-
if
|
|
127
|
-
key, value = cookie_pair.split(
|
|
126
|
+
if "=" in cookie_pair:
|
|
127
|
+
key, value = cookie_pair.split("=", 1)
|
|
128
128
|
key = key.strip()
|
|
129
129
|
value = value.strip()
|
|
130
130
|
# URL decode the value (e.g., s%3A becomes s:)
|
|
@@ -132,6 +132,14 @@ class Api:
|
|
|
132
132
|
cookies[key] = value
|
|
133
133
|
return cookies
|
|
134
134
|
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _normalize_tags(tags):
|
|
137
|
+
if tags is None:
|
|
138
|
+
return []
|
|
139
|
+
if isinstance(tags, str):
|
|
140
|
+
return [tags]
|
|
141
|
+
return [str(tag) for tag in tags]
|
|
142
|
+
|
|
135
143
|
def login(self, email, password) -> dict:
|
|
136
144
|
"""
|
|
137
145
|
|
|
@@ -224,7 +232,7 @@ class Api:
|
|
|
224
232
|
publication:
|
|
225
233
|
"""
|
|
226
234
|
custom_domain = publication.get("custom_domain", None)
|
|
227
|
-
if not custom_domain and not publication.get(
|
|
235
|
+
if not custom_domain and not publication.get("custom_domain_optional", None):
|
|
228
236
|
publication_url = f"https://{publication['subdomain']}.substack.com"
|
|
229
237
|
else:
|
|
230
238
|
publication_url = f"https://{custom_domain}"
|
|
@@ -238,9 +246,12 @@ class Api:
|
|
|
238
246
|
|
|
239
247
|
profile = self.get_user_profile()
|
|
240
248
|
primary_publication = None
|
|
241
|
-
|
|
249
|
+
|
|
242
250
|
# Try old API format first (backward compatibility)
|
|
243
|
-
if
|
|
251
|
+
if (
|
|
252
|
+
"primaryPublication" in profile
|
|
253
|
+
and profile["primaryPublication"] is not None
|
|
254
|
+
):
|
|
244
255
|
primary_publication = profile["primaryPublication"]
|
|
245
256
|
else:
|
|
246
257
|
# New API format: look for primary publication in publicationUsers
|
|
@@ -252,16 +263,16 @@ class Api:
|
|
|
252
263
|
primary_publication = pub_user.get("publication")
|
|
253
264
|
if primary_publication:
|
|
254
265
|
break
|
|
255
|
-
|
|
266
|
+
|
|
256
267
|
# If no primary found, use the first publication
|
|
257
268
|
if primary_publication is None:
|
|
258
269
|
primary_publication = publication_users[0].get("publication")
|
|
259
|
-
|
|
270
|
+
|
|
260
271
|
if primary_publication is None:
|
|
261
272
|
raise SubstackRequestException(
|
|
262
273
|
"Could not find primary publication in profile"
|
|
263
274
|
)
|
|
264
|
-
|
|
275
|
+
|
|
265
276
|
primary_publication["publication_url"] = self.get_publication_url(
|
|
266
277
|
primary_publication
|
|
267
278
|
)
|
|
@@ -279,12 +290,12 @@ class Api:
|
|
|
279
290
|
# of dictionaries of "name", and "subdomain", and "id"
|
|
280
291
|
user_publications = []
|
|
281
292
|
publication_users = profile.get("publicationUsers")
|
|
282
|
-
|
|
293
|
+
|
|
283
294
|
if publication_users is None:
|
|
284
295
|
# If publicationUsers is None, return empty list or try to construct from other fields
|
|
285
296
|
# This maintains backward compatibility while handling new API format
|
|
286
297
|
return user_publications
|
|
287
|
-
|
|
298
|
+
|
|
288
299
|
for publication in publication_users:
|
|
289
300
|
pub = publication.get("publication")
|
|
290
301
|
if pub is not None:
|
|
@@ -414,6 +425,73 @@ class Api:
|
|
|
414
425
|
response = self._session.post(f"{self.publication_url}/drafts", json=body)
|
|
415
426
|
return Api._handle_response(response=response)
|
|
416
427
|
|
|
428
|
+
def create_draft_from_markdown(
|
|
429
|
+
self,
|
|
430
|
+
title: str,
|
|
431
|
+
markdown: str,
|
|
432
|
+
subtitle: str = "",
|
|
433
|
+
audience: str = "everyone",
|
|
434
|
+
write_comment_permissions: str = "everyone",
|
|
435
|
+
search_engine_title: str = None,
|
|
436
|
+
search_engine_description: str = None,
|
|
437
|
+
slug: str = None,
|
|
438
|
+
draft_section_id: int = None,
|
|
439
|
+
tags=None,
|
|
440
|
+
prepublish: bool = False,
|
|
441
|
+
publish: bool = False,
|
|
442
|
+
send: bool = True,
|
|
443
|
+
share_automatically: bool = False,
|
|
444
|
+
) -> dict:
|
|
445
|
+
from substack.post import Post
|
|
446
|
+
|
|
447
|
+
post = Post(
|
|
448
|
+
title=title,
|
|
449
|
+
subtitle=subtitle or "",
|
|
450
|
+
user_id=self.get_user_id(),
|
|
451
|
+
audience=audience,
|
|
452
|
+
write_comment_permissions=write_comment_permissions,
|
|
453
|
+
)
|
|
454
|
+
post.from_markdown(markdown, api=self)
|
|
455
|
+
|
|
456
|
+
draft = self.post_draft(post.get_draft())
|
|
457
|
+
draft_id = draft.get("id")
|
|
458
|
+
|
|
459
|
+
update_payload = {
|
|
460
|
+
"search_engine_title": search_engine_title,
|
|
461
|
+
"search_engine_description": search_engine_description,
|
|
462
|
+
"slug": slug,
|
|
463
|
+
"draft_section_id": draft_section_id,
|
|
464
|
+
}
|
|
465
|
+
update_payload = {
|
|
466
|
+
key: value for key, value in update_payload.items() if value is not None
|
|
467
|
+
}
|
|
468
|
+
if update_payload:
|
|
469
|
+
draft = self.put_draft(draft_id, **update_payload)
|
|
470
|
+
|
|
471
|
+
tags_result = None
|
|
472
|
+
tags_list = self._normalize_tags(tags)
|
|
473
|
+
if tags_list:
|
|
474
|
+
tags_result = self.add_tags_to_post(draft_id, tags_list)
|
|
475
|
+
|
|
476
|
+
prepublish_result = None
|
|
477
|
+
if prepublish:
|
|
478
|
+
prepublish_result = self.prepublish_draft(draft_id)
|
|
479
|
+
|
|
480
|
+
publish_result = None
|
|
481
|
+
if publish:
|
|
482
|
+
publish_result = self.publish_draft(
|
|
483
|
+
draft_id,
|
|
484
|
+
send=send,
|
|
485
|
+
share_automatically=share_automatically,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
"draft": draft,
|
|
490
|
+
"tags": tags_result,
|
|
491
|
+
"prepublish": prepublish_result,
|
|
492
|
+
"publish": publish_result,
|
|
493
|
+
}
|
|
494
|
+
|
|
417
495
|
def put_draft(self, draft, **kwargs) -> dict:
|
|
418
496
|
"""
|
|
419
497
|
|
|
@@ -514,7 +592,7 @@ class Api:
|
|
|
514
592
|
data={"image": image},
|
|
515
593
|
)
|
|
516
594
|
return Api._handle_response(response=response)
|
|
517
|
-
|
|
595
|
+
|
|
518
596
|
def add_tags_to_post(self, post_id: int, tag_names: list) -> dict:
|
|
519
597
|
"""
|
|
520
598
|
Add multiple tags to a post.
|
|
@@ -575,7 +653,6 @@ class Api:
|
|
|
575
653
|
)
|
|
576
654
|
return Api._handle_response(apply_tag_response)
|
|
577
655
|
|
|
578
|
-
|
|
579
656
|
def get_categories(self):
|
|
580
657
|
"""
|
|
581
658
|
|