python-substack 0.1.25__py3-none-any.whl → 0.1.27__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.
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-substack
3
- Version: 0.1.25
4
- Summary: A Python wrapper around the Substack API.
3
+ Version: 0.1.27
4
+ Summary: A Python SDK and CLI for managing Substack publications and drafts.
5
5
  License: MIT
6
6
  License-File: LICENSE
7
- Keywords: substack
7
+ Keywords: substack,substack-api,cli,newsletter,publishing,automation,mcp
8
8
  Author: Paolo Mazza
9
9
  Author-email: mazzapaolo2019@gmail.com
10
10
  Requires-Python: >=3.10,<4.0
@@ -24,7 +24,7 @@ Provides-Extra: mcp
24
24
  Requires-Dist: PyYAML (>=6.0,<7.0)
25
25
  Requires-Dist: fastmcp (>=3.1.1,<4.0.0) ; extra == "mcp"
26
26
  Requires-Dist: markdown-it-py (>=3.0,<4.0)
27
- Requires-Dist: mdit-py-plugins (>=0.4,<0.5)
27
+ Requires-Dist: mdit-py-plugins (>=0.5,<0.7)
28
28
  Requires-Dist: python-dotenv (>=1.2.1,<2.0.0)
29
29
  Requires-Dist: requests (>=2.32.0,<3.0.0)
30
30
  Project-URL: Changelog, https://github.com/ma2za/python-substack/blob/main/CHANGELOG.md
@@ -35,13 +35,20 @@ Description-Content-Type: text/markdown
35
35
 
36
36
  # Python Substack
37
37
 
38
- Unofficial Python tools for publishing to [Substack](https://substack.com/).
38
+ An unofficial Python SDK and CLI for managing [Substack](https://substack.com/) publications and drafts.
39
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)
40
45
  [![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
46
 
43
47
  ## Features
44
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.
45
52
  - Create drafts and publish posts from Python.
46
53
  - Convert Markdown into Substack's editor document format.
47
54
  - Upload local images while rendering Markdown.
@@ -78,6 +85,76 @@ Use either `EMAIL` and `PASSWORD`, or cookie-based authentication with `COOKIES_
78
85
 
79
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".
80
87
 
88
+ ## CLI Operations
89
+
90
+ Create a draft from Markdown without publishing it:
91
+
92
+ ```bash
93
+ substack drafts create post.md
94
+ ```
95
+
96
+ Set metadata and repeat `--tag` to attach multiple tags:
97
+
98
+ ```bash
99
+ substack --json drafts create post.md \
100
+ --title "My Post" \
101
+ --subtitle "Optional subtitle" \
102
+ --tag python \
103
+ --tag substack \
104
+ --slug my-post \
105
+ --search-engine-title "SEO title" \
106
+ --search-engine-description "SEO description"
107
+ ```
108
+
109
+ Creation and publishing are intentionally separate. Use the returned draft ID
110
+ when the draft is ready:
111
+
112
+ ```bash
113
+ substack drafts create post.md
114
+ substack drafts publish 12345 --no-send
115
+ ```
116
+
117
+ Check authentication, the selected publication, and subscriber count:
118
+
119
+ ```bash
120
+ substack status
121
+ ```
122
+
123
+ List available publications or target one without changing `.env`:
124
+
125
+ ```bash
126
+ substack publications list
127
+ substack --publication-url https://example.substack.com drafts list
128
+ ```
129
+
130
+ List and inspect drafts:
131
+
132
+ ```bash
133
+ substack drafts list --limit 10
134
+ substack drafts get 12345
135
+ ```
136
+
137
+ Schedule with a timezone-aware ISO 8601 timestamp, or remove a schedule:
138
+
139
+ ```bash
140
+ substack drafts schedule 12345 --at 2026-08-01T09:00:00+03:00
141
+ substack drafts unschedule 12345
142
+ ```
143
+
144
+ Publishing and deletion prompt for confirmation. Use `--yes` for intentional non-interactive execution:
145
+
146
+ ```bash
147
+ substack drafts publish 12345 --no-send
148
+ substack drafts delete 12345 --yes
149
+ ```
150
+
151
+ Global options must appear before the command. `--json` returns stable envelopes containing the raw Substack responses:
152
+
153
+ ```bash
154
+ substack --json drafts list
155
+ substack --cookies cookies.json --json status
156
+ ```
157
+
81
158
  ## Quickstart
82
159
 
83
160
  ```python
@@ -113,7 +190,9 @@ print(result["draft"]["id"])
113
190
 
114
191
  `create_draft_from_markdown` creates a draft by default. It only publishes when `publish=True` is passed.
115
192
 
116
- ## CLI
193
+ ## Legacy Content Publishing CLI
194
+
195
+ The existing standalone commands remain supported for compatibility.
117
196
 
118
197
  Check authentication without creating a draft:
119
198
 
@@ -290,7 +369,7 @@ Paragraph with **bold**, *italic*, `code`, [links](https://example.com), and foo
290
369
  )
291
370
  ```
292
371
 
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.
372
+ Supported Markdown includes headings, paragraphs, bold, italic, inline code, strikethrough, superscript, subscript, links, images, linked images, image captions, code blocks, blockquotes, ordered lists, unordered lists, horizontal rules, footnotes, LaTeX math, pull quotes, and callouts. See [docs/markdown.md](docs/markdown.md) for the full reference with examples.
294
373
 
295
374
  When an `Api` instance is passed to `from_markdown`, local image paths are uploaded before the draft is created:
296
375
 
@@ -371,9 +450,28 @@ pre-commit install
371
450
  pytest
372
451
  ```
373
452
 
374
- Live Substack tests are opt-in. Set `RUN_SUBSTACK_E2E=1` and configure credentials before running them.
453
+ Run the offline suite with:
454
+
455
+ ```bash
456
+ pytest -m "not live"
457
+ ```
458
+
459
+ Live Substack tests are not part of normal CI. They are opt-in and require
460
+ configured credentials:
461
+
462
+ ```bash
463
+ RUN_SUBSTACK_E2E=1 pytest -m live
464
+ ```
465
+
466
+ The CLI operations smoke tests are separately opt-in. They create, inspect, and
467
+ delete disposable drafts but never publish them:
468
+
469
+ ```bash
470
+ RUN_SUBSTACK_CLI_E2E=1 pytest -m live tests/substack/test_cli_end_to_end.py
471
+ ```
375
472
 
376
473
  Release changes are tracked in [CHANGELOG.md](CHANGELOG.md).
474
+ The maintainer release process is documented in [docs/releasing.md](docs/releasing.md).
377
475
 
378
476
  ## Disclaimer
379
477
 
@@ -0,0 +1,13 @@
1
+ substack/__init__.py,sha256=Wd16zqqfA6bYLntSyq_dnRIFxosqgzjbyIVcJigGQ_A,416
2
+ substack/api.py,sha256=tuxefoBuOvBn4Ww2VCbqkWCuX6qPOg7YM6EsLheBpV4,23213
3
+ substack/cli.py,sha256=JBCsCdmthjzTeDpOYBLeVKOdNYjBrPuY1o_CIsAIekk,21712
4
+ substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
5
+ substack/mdrender.py,sha256=QGJkdp1isFmhVyTRMogIabAK8OO2xQh9CrjnXZvFTY4,9308
6
+ substack/nodes.py,sha256=fTsGO0-lTztcXiloIjXrHVnJes9EcltGcrX-mEV2ceQ,5004
7
+ substack/post.py,sha256=nXeZMAZ6-lx1Qf4yuAlrEQ0lcWFYF2afai1_TxVEl0Q,19704
8
+ substack_mcp/mcp_server.py,sha256=gmevdc59XTBXXMTvVMpYoq66Umlqku1O_uzmw5tMy7o,8405
9
+ python_substack-0.1.27.dist-info/METADATA,sha256=-yH6Uq4uhgO8tUYONxf3EkMMpvXd7--dxEAMkYsHTMc,11738
10
+ python_substack-0.1.27.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
11
+ python_substack-0.1.27.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
12
+ python_substack-0.1.27.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
13
+ python_substack-0.1.27.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.4.0
2
+ Generator: poetry-core 2.3.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,4 +1,5 @@
1
1
  [console_scripts]
2
+ substack=substack.cli:main
2
3
  substack-auth-check=substack.cli:auth_check
3
4
  substack-mcp=substack_mcp.mcp_server:main
4
5
  substack-publish-markdown=substack.cli:publish_markdown
substack/__init__.py CHANGED
@@ -3,9 +3,9 @@
3
3
  __author__ = "Paolo Mazza"
4
4
  __email__ = "mazzapaolo2019@gmail.com"
5
5
  __license__ = "MIT License"
6
- __version__ = "0.1.25"
6
+ __version__ = "0.1.27"
7
7
  __url__ = "https://github.com/ma2za/python-substack"
8
8
  __download_url__ = "https://pypi.python.org/pypi/python-substack"
9
- __description__ = "A Python wrapper around the Substack API"
9
+ __description__ = "A Python SDK and CLI for managing Substack publications and drafts"
10
10
 
11
11
  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
@@ -346,7 +359,10 @@ class Api:
346
359
  f"{self.publication_url}/publication_launch_checklist"
347
360
  )
348
361
 
349
- return Api._handle_response(response=response)["subscriberCount"]
362
+ data = Api._handle_response(response=response)
363
+ if "subscriberCount" in data:
364
+ return data["subscriberCount"]
365
+ return len(data["subscribers"])
350
366
 
351
367
  def get_published_posts(
352
368
  self, offset=0, limit=25, order_by="post_date", order_direction="desc"
@@ -553,8 +569,8 @@ class Api:
553
569
 
554
570
  """
555
571
  response = self._session.post(
556
- f"{self.publication_url}/drafts/{draft}/schedule",
557
- json={"post_date": draft_datetime.isoformat()},
572
+ f"{self.publication_url}/drafts/{draft}/scheduled_release",
573
+ json={"trigger_at": draft_datetime.isoformat()},
558
574
  )
559
575
  return Api._handle_response(response=response)
560
576
 
@@ -567,8 +583,8 @@ class Api:
567
583
  Returns:
568
584
 
569
585
  """
570
- response = self._session.post(
571
- f"{self.publication_url}/drafts/{draft}/schedule", json={"post_date": None}
586
+ response = self._session.delete(
587
+ f"{self.publication_url}/drafts/{draft}/scheduled_release"
572
588
  )
573
589
  return Api._handle_response(response=response)
574
590
 
substack/cli.py CHANGED
@@ -1,32 +1,41 @@
1
1
  import argparse
2
2
  import json
3
3
  import os
4
+ import sys
5
+ from datetime import datetime
4
6
  from pathlib import Path
7
+ from urllib.parse import urljoin
5
8
 
6
9
  import yaml
7
10
  from dotenv import load_dotenv
8
11
 
9
- from substack import Api
12
+ from substack import Api, __version__
13
+ from substack.exceptions import SubstackAPIException, SubstackRequestException
10
14
  from substack.post import Post
11
15
 
12
16
 
13
- def _api_from_env(cookies_path=None):
17
+ class CLIUsageError(Exception):
18
+ pass
19
+
20
+
21
+ def _api_from_env(cookies_path=None, publication_url=None):
14
22
  load_dotenv()
15
23
 
16
24
  cookies_path = cookies_path or os.getenv("COOKIES_PATH")
17
25
  cookies_string = os.getenv("COOKIES_STRING")
26
+ publication_url = publication_url or os.getenv("PUBLICATION_URL")
18
27
 
19
28
  if cookies_path or cookies_string:
20
29
  return Api(
21
30
  cookies_path=cookies_path,
22
31
  cookies_string=cookies_string,
23
- publication_url=os.getenv("PUBLICATION_URL"),
32
+ publication_url=publication_url,
24
33
  )
25
34
 
26
35
  return Api(
27
36
  email=os.getenv("EMAIL"),
28
37
  password=os.getenv("PASSWORD"),
29
- publication_url=os.getenv("PUBLICATION_URL"),
38
+ publication_url=publication_url,
30
39
  )
31
40
 
32
41
 
@@ -56,6 +65,402 @@ def _print_result(result):
56
65
  print(json.dumps({"draft_id": draft.get("id"), "draft": draft}, indent=2))
57
66
 
58
67
 
68
+ def _print_json(value, stream=None):
69
+ print(json.dumps(value, indent=2, default=str), file=stream)
70
+
71
+
72
+ def _display(value):
73
+ if value is None or value == "":
74
+ return "-"
75
+ return str(value)
76
+
77
+
78
+ def _print_rows(headers, rows):
79
+ rows = [[_display(value) for value in row] for row in rows]
80
+ widths = [len(header) for header in headers]
81
+ for row in rows:
82
+ for index, value in enumerate(row):
83
+ widths[index] = max(widths[index], len(value))
84
+
85
+ print(
86
+ " ".join(header.ljust(widths[index]) for index, header in enumerate(headers))
87
+ )
88
+ print(" ".join("-" * width for width in widths))
89
+ for row in rows:
90
+ print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
91
+
92
+
93
+ def _identity(profile):
94
+ return (
95
+ profile.get("email")
96
+ or profile.get("name")
97
+ or profile.get("handle")
98
+ or str(profile.get("id", "unknown"))
99
+ )
100
+
101
+
102
+ def _selected_publication(api, publications):
103
+ for publication in publications:
104
+ publication_url = publication.get("publication_url")
105
+ if (
106
+ publication_url
107
+ and urljoin(publication_url, "api/v1") == api.publication_url
108
+ ):
109
+ return publication
110
+ return {"publication_url": api.publication_url.removesuffix("/api/v1")}
111
+
112
+
113
+ def _parse_schedule(value):
114
+ normalized = value[:-1] + "+00:00" if value.endswith(("Z", "z")) else value
115
+ try:
116
+ scheduled_at = datetime.fromisoformat(normalized)
117
+ except ValueError as exc:
118
+ raise CLIUsageError("--at must be a valid ISO 8601 timestamp") from exc
119
+ if scheduled_at.utcoffset() is None:
120
+ raise CLIUsageError("--at must include a timezone offset or Z")
121
+ return scheduled_at
122
+
123
+
124
+ def _confirm(action, target, yes, json_output):
125
+ if yes:
126
+ return
127
+ if json_output or not sys.stdin.isatty():
128
+ raise CLIUsageError(f"{action} requires --yes in non-interactive or JSON mode")
129
+ try:
130
+ confirmed = input(f"{action} draft {target}? [y/N] ").strip().lower()
131
+ except EOFError as exc:
132
+ raise CLIUsageError(f"{action} requires confirmation or --yes") from exc
133
+ if confirmed not in {"y", "yes"}:
134
+ raise CLIUsageError("Cancelled")
135
+
136
+
137
+ def _redact(message):
138
+ redacted = str(message)
139
+ for name in ("EMAIL", "PASSWORD", "COOKIES_STRING"):
140
+ secret = os.getenv(name)
141
+ if secret:
142
+ redacted = redacted.replace(secret, "[redacted]")
143
+ return redacted
144
+
145
+
146
+ def _error_payload(exc):
147
+ if isinstance(exc, SubstackAPIException):
148
+ return {
149
+ "error": {
150
+ "type": "api_error",
151
+ "message": _redact(exc.message),
152
+ "status_code": exc.status_code,
153
+ }
154
+ }
155
+ if isinstance(exc, SubstackRequestException):
156
+ return {
157
+ "error": {
158
+ "type": "request_error",
159
+ "message": _redact(exc.message),
160
+ }
161
+ }
162
+ if isinstance(exc, OSError):
163
+ return {"error": {"type": "io_error", "message": _redact(exc)}}
164
+ return {"error": {"type": "configuration_error", "message": _redact(exc)}}
165
+
166
+
167
+ def _print_error(exc, json_output):
168
+ payload = _error_payload(exc)
169
+ if json_output:
170
+ _print_json(payload, stream=sys.stderr)
171
+ else:
172
+ print(f"Error: {payload['error']['message']}", file=sys.stderr)
173
+
174
+
175
+ def _print_usage_error(exc, json_output):
176
+ if json_output:
177
+ _print_json(
178
+ {"error": {"type": "usage_error", "message": str(exc)}},
179
+ stream=sys.stderr,
180
+ )
181
+ else:
182
+ print(f"Error: {exc}", file=sys.stderr)
183
+
184
+
185
+ def _status(api, args):
186
+ profile = api.get_user_profile()
187
+ publications = api.get_user_publications()
188
+ selected = _selected_publication(api, publications)
189
+ subscriber_count = api.get_publication_subscriber_count()
190
+ result = {
191
+ "status": {
192
+ "identity": _identity(profile),
193
+ "auth_method": _auth_method(args.cookies),
194
+ "subscriber_count": subscriber_count,
195
+ },
196
+ "profile": profile,
197
+ "publication": selected,
198
+ "publications": publications,
199
+ }
200
+ if args.json_output:
201
+ _print_json(result)
202
+ else:
203
+ print(f"Authenticated as: {result['status']['identity']}")
204
+ print(f"Auth method: {result['status']['auth_method']}")
205
+ print(f"Publication: {_display(selected.get('name'))}")
206
+ print(f"Publication URL: {_display(selected.get('publication_url'))}")
207
+ print(f"Subscribers: {subscriber_count}")
208
+ print(f"Available publications: {len(publications)}")
209
+
210
+
211
+ def _publications_list(api, args):
212
+ publications = api.get_user_publications()
213
+ if args.json_output:
214
+ _print_json({"publications": publications, "count": len(publications)})
215
+ else:
216
+ _print_rows(
217
+ ["ID", "NAME", "SUBDOMAIN", "URL"],
218
+ [
219
+ [
220
+ publication.get("id"),
221
+ publication.get("name"),
222
+ publication.get("subdomain"),
223
+ publication.get("publication_url"),
224
+ ]
225
+ for publication in publications
226
+ ],
227
+ )
228
+
229
+
230
+ def _drafts_list(api, args):
231
+ if args.offset < 0:
232
+ raise CLIUsageError("--offset must be zero or greater")
233
+ if args.limit < 1:
234
+ raise CLIUsageError("--limit must be greater than zero")
235
+ drafts = api.get_drafts(filter=args.filter, offset=args.offset, limit=args.limit)
236
+ if args.json_output:
237
+ _print_json(
238
+ {
239
+ "drafts": drafts,
240
+ "count": len(drafts),
241
+ "filter": args.filter,
242
+ "offset": args.offset,
243
+ "limit": args.limit,
244
+ }
245
+ )
246
+ else:
247
+ _print_rows(
248
+ ["ID", "TITLE", "STATUS", "SCHEDULED"],
249
+ [
250
+ [
251
+ draft.get("id"),
252
+ draft.get("draft_title") or draft.get("title"),
253
+ draft.get("type") or draft.get("status") or "draft",
254
+ draft.get("post_date"),
255
+ ]
256
+ for draft in drafts
257
+ ],
258
+ )
259
+
260
+
261
+ def _drafts_get(api, args):
262
+ draft = api.get_draft(args.draft_id)
263
+ if args.json_output:
264
+ _print_json({"draft": draft})
265
+ else:
266
+ fields = [
267
+ ("ID", draft.get("id")),
268
+ ("Title", draft.get("draft_title") or draft.get("title")),
269
+ ("Subtitle", draft.get("draft_subtitle") or draft.get("subtitle")),
270
+ ("Status", draft.get("type") or draft.get("status") or "draft"),
271
+ ("Slug", draft.get("slug")),
272
+ ("Scheduled", draft.get("post_date")),
273
+ ("Audience", draft.get("audience")),
274
+ ]
275
+ for label, value in fields:
276
+ print(f"{label}: {_display(value)}")
277
+
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
+
310
+ def _drafts_schedule(api, args):
311
+ scheduled_at = _parse_schedule(args.at)
312
+ result = api.schedule_draft(args.draft_id, scheduled_at)
313
+ payload = {
314
+ "action": "schedule",
315
+ "draft_id": args.draft_id,
316
+ "scheduled_at": scheduled_at.isoformat(),
317
+ "result": result,
318
+ }
319
+ if args.json_output:
320
+ _print_json(payload)
321
+ else:
322
+ print(f"Scheduled draft {args.draft_id} for {scheduled_at.isoformat()}")
323
+
324
+
325
+ def _drafts_unschedule(api, args):
326
+ result = api.unschedule_draft(args.draft_id)
327
+ payload = {"action": "unschedule", "draft_id": args.draft_id, "result": result}
328
+ if args.json_output:
329
+ _print_json(payload)
330
+ else:
331
+ print(f"Unscheduled draft {args.draft_id}")
332
+
333
+
334
+ def _drafts_publish(api, args):
335
+ _confirm("Publish", args.draft_id, args.yes, args.json_output)
336
+ prepublish = api.prepublish_draft(args.draft_id)
337
+ result = api.publish_draft(
338
+ args.draft_id,
339
+ send=args.send,
340
+ share_automatically=args.share_automatically,
341
+ )
342
+ payload = {
343
+ "action": "publish",
344
+ "draft_id": args.draft_id,
345
+ "prepublish": prepublish,
346
+ "result": result,
347
+ }
348
+ if args.json_output:
349
+ _print_json(payload)
350
+ else:
351
+ delivery = "without email" if not args.send else "with email"
352
+ print(f"Published draft {args.draft_id} {delivery}")
353
+
354
+
355
+ def _drafts_delete(api, args):
356
+ _confirm("Delete", args.draft_id, args.yes, args.json_output)
357
+ result = api.delete_draft(args.draft_id)
358
+ payload = {"action": "delete", "draft_id": args.draft_id, "result": result}
359
+ if args.json_output:
360
+ _print_json(payload)
361
+ else:
362
+ print(f"Deleted draft {args.draft_id}")
363
+
364
+
365
+ def _build_parser():
366
+ parser = argparse.ArgumentParser(
367
+ prog="substack", description="Manage Substack publications and drafts."
368
+ )
369
+ parser.add_argument("--cookies", help="Path to a cookies JSON file.")
370
+ parser.add_argument(
371
+ "--publication-url", help="Override PUBLICATION_URL for this command."
372
+ )
373
+ parser.add_argument("--json", action="store_true", dest="json_output")
374
+ parser.add_argument("--version", action="version", version=__version__)
375
+
376
+ commands = parser.add_subparsers(dest="command", required=True)
377
+ status = commands.add_parser(
378
+ "status", help="Show authentication and publication status."
379
+ )
380
+ status.set_defaults(handler=_status)
381
+
382
+ publications = commands.add_parser("publications", help="Manage publications.")
383
+ publication_commands = publications.add_subparsers(
384
+ dest="publication_command", required=True
385
+ )
386
+ publications_list = publication_commands.add_parser(
387
+ "list", help="List available publications."
388
+ )
389
+ publications_list.set_defaults(handler=_publications_list)
390
+
391
+ drafts = commands.add_parser("drafts", help="Manage drafts.")
392
+ draft_commands = drafts.add_subparsers(dest="draft_command", required=True)
393
+
394
+ drafts_list = draft_commands.add_parser("list", help="List drafts.")
395
+ drafts_list.add_argument("--filter", default="draft")
396
+ drafts_list.add_argument("--offset", type=int, default=0)
397
+ drafts_list.add_argument("--limit", type=int, default=25)
398
+ drafts_list.set_defaults(handler=_drafts_list)
399
+
400
+ drafts_get = draft_commands.add_parser("get", help="Inspect a draft.")
401
+ drafts_get.add_argument("draft_id", type=int)
402
+ drafts_get.set_defaults(handler=_drafts_get)
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
+
419
+ drafts_schedule = draft_commands.add_parser("schedule", help="Schedule a draft.")
420
+ drafts_schedule.add_argument("draft_id", type=int)
421
+ drafts_schedule.add_argument("--at", required=True)
422
+ drafts_schedule.set_defaults(handler=_drafts_schedule)
423
+
424
+ drafts_unschedule = draft_commands.add_parser(
425
+ "unschedule", help="Remove a draft schedule."
426
+ )
427
+ drafts_unschedule.add_argument("draft_id", type=int)
428
+ drafts_unschedule.set_defaults(handler=_drafts_unschedule)
429
+
430
+ drafts_publish = draft_commands.add_parser("publish", help="Publish a draft.")
431
+ drafts_publish.add_argument("draft_id", type=int)
432
+ drafts_publish.add_argument(
433
+ "--no-send", action="store_false", dest="send", default=True
434
+ )
435
+ drafts_publish.add_argument("--share-automatically", action="store_true")
436
+ drafts_publish.add_argument("--yes", action="store_true")
437
+ drafts_publish.set_defaults(handler=_drafts_publish)
438
+
439
+ drafts_delete = draft_commands.add_parser("delete", help="Delete a draft.")
440
+ drafts_delete.add_argument("draft_id", type=int)
441
+ drafts_delete.add_argument("--yes", action="store_true")
442
+ drafts_delete.set_defaults(handler=_drafts_delete)
443
+ return parser
444
+
445
+
446
+ def main(argv=None):
447
+ parser = _build_parser()
448
+ args = parser.parse_args(argv)
449
+ try:
450
+ api = _api_from_env(
451
+ cookies_path=args.cookies,
452
+ publication_url=args.publication_url,
453
+ )
454
+ args.handler(api, args)
455
+ except CLIUsageError as exc:
456
+ _print_usage_error(exc, args.json_output)
457
+ return 2
458
+ except (SubstackAPIException, SubstackRequestException, OSError, ValueError) as exc:
459
+ _print_error(exc, args.json_output)
460
+ return 1
461
+ return 0
462
+
463
+
59
464
  def publish_markdown(argv=None):
60
465
  parser = argparse.ArgumentParser()
61
466
  parser.add_argument("markdown", nargs="?", default="README.md")
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
+ }
@@ -1,13 +0,0 @@
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,,