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.
@@ -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
+ [![Downloads](https://static.pepy.tech/badge/python-substack/month)](https://pepy.tech/project/python-substack)
41
+ ![Release Build](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml/badge.svg)
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
+ ![Alt text](https://example.com/image.png "Image caption")
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
+ ![Alt](local-image.png "Caption")
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,,
@@ -0,0 +1,6 @@
1
+ [console_scripts]
2
+ substack-auth-check=substack.cli:auth_check
3
+ substack-mcp=substack_mcp.mcp_server:main
4
+ substack-publish-markdown=substack.cli:publish_markdown
5
+ substack-publish-yaml=substack.cli:publish_yaml
6
+
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.24"
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 urljoin, unquote
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 '=' in cookie_pair:
127
- key, value = cookie_pair.split('=', 1)
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('custom_domain_optional', None):
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 "primaryPublication" in profile and profile["primaryPublication"] is not None:
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