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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/python-substack)](https://pypi.org/project/python-substack/)
42
+ [![Python](https://img.shields.io/pypi/pyversions/python-substack)](https://pypi.org/project/python-substack/)
43
+ [![Tests](https://github.com/ma2za/python-substack/actions/workflows/ci.yml/badge.svg)](https://github.com/ma2za/python-substack/actions/workflows/ci.yml)
44
+ [![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)
45
+ [![License](https://img.shields.io/pypi/l/python-substack)](LICENSE)
46
+ [![Downloads](https://static.pepy.tech/badge/python-substack/month)](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
+ ![Markdown before conversion](docs/before.png)
80
+
81
+ Substack result:
82
+
83
+ ![Substack after conversion](docs/after.png)
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
+ ![Alt text](https://example.com/image.png "Image caption")
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.1.26"
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__ = "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
@@ -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
- return Api._handle_response(response=response)["subscriberCount"]
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}/schedule",
557
- json={"post_date": draft_datetime.isoformat()},
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.post(
571
- f"{self.publication_url}/drafts/{draft}/schedule", json={"post_date": None}
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 MarkdownIt("commonmark").use(footnote_plugin).enable("strikethrough")
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'(?<!\*)\*([^*]+)\*(?!\*)' # 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,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
- [![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
- 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
- ![Alt text](https://example.com/image.png "Image caption")
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
- ![Alt](local-image.png "Caption")
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,,