python-substack 0.5.0__tar.gz → 0.6.0__tar.gz

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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-substack
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Write and safely manage Substack drafts from Markdown with Python, CLI, and MCP.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -93,6 +93,7 @@ Substack result:
93
93
  - Upload local images referenced by Markdown.
94
94
  - Set audience, comment permissions, SEO metadata, slug, sections, and tags.
95
95
  - List and inspect publications and drafts.
96
+ - Export drafts to loss-aware Markdown backups without server writes.
96
97
  - Schedule, unschedule, publish, and delete drafts with explicit safeguards.
97
98
  - Use stable JSON envelopes in scripts and automation.
98
99
  - Authenticate with browser cookies or email and password.
@@ -143,6 +144,7 @@ Inspect publications and drafts:
143
144
  substack publications list
144
145
  substack drafts list --limit 10
145
146
  substack drafts get 12345
147
+ substack drafts export 12345 --output backup.md
146
148
  substack --publication-url https://example.substack.com drafts list
147
149
  ```
148
150
 
@@ -207,6 +209,14 @@ print(result["draft"]["id"])
207
209
  `create_draft_from_markdown` creates a draft by default. It publishes only when
208
210
  `publish=True` is passed.
209
211
 
212
+ Back up an existing draft without modifying it:
213
+
214
+ ```python
215
+ backup = api.export_draft_to_markdown(12345)
216
+ print(backup["markdown"])
217
+ print(backup["unsupported_nodes"])
218
+ ```
219
+
210
220
  For direct ProseMirror node construction, see the
211
221
  [low-level Python API](docs/low-level-api.md). YAML workflows are documented in
212
222
  [YAML drafts](docs/yaml.md).
@@ -57,6 +57,7 @@ Substack result:
57
57
  - Upload local images referenced by Markdown.
58
58
  - Set audience, comment permissions, SEO metadata, slug, sections, and tags.
59
59
  - List and inspect publications and drafts.
60
+ - Export drafts to loss-aware Markdown backups without server writes.
60
61
  - Schedule, unschedule, publish, and delete drafts with explicit safeguards.
61
62
  - Use stable JSON envelopes in scripts and automation.
62
63
  - Authenticate with browser cookies or email and password.
@@ -107,6 +108,7 @@ Inspect publications and drafts:
107
108
  substack publications list
108
109
  substack drafts list --limit 10
109
110
  substack drafts get 12345
111
+ substack drafts export 12345 --output backup.md
110
112
  substack --publication-url https://example.substack.com drafts list
111
113
  ```
112
114
 
@@ -171,6 +173,14 @@ print(result["draft"]["id"])
171
173
  `create_draft_from_markdown` creates a draft by default. It publishes only when
172
174
  `publish=True` is passed.
173
175
 
176
+ Back up an existing draft without modifying it:
177
+
178
+ ```python
179
+ backup = api.export_draft_to_markdown(12345)
180
+ print(backup["markdown"])
181
+ print(backup["unsupported_nodes"])
182
+ ```
183
+
174
184
  For direct ProseMirror node construction, see the
175
185
  [low-level Python API](docs/low-level-api.md). YAML workflows are documented in
176
186
  [YAML drafts](docs/yaml.md).
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-substack"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "Write and safely manage Substack drafts from Markdown with Python, CLI, and MCP."
5
5
  authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
6
6
  license = "MIT"
@@ -3,7 +3,7 @@
3
3
  __author__ = "Paolo Mazza"
4
4
  __email__ = "mazzapaolo2019@gmail.com"
5
5
  __license__ = "MIT License"
6
- __version__ = "0.5.0"
6
+ __version__ = "0.6.0"
7
7
  __url__ = "https://github.com/ma2za/python-substack"
8
8
  __download_url__ = "https://pypi.python.org/pypi/python-substack"
9
9
  __description__ = (
@@ -442,6 +442,28 @@ class Api:
442
442
  response = self._session.get(f"{self.publication_url}/drafts/{draft_id}")
443
443
  return Api._handle_response(response=response)
444
444
 
445
+ def export_draft_to_markdown(self, draft_id):
446
+ from substack.mdexport import document_to_markdown
447
+
448
+ draft = self.get_draft(draft_id)
449
+ draft_body = draft.get("draft_body")
450
+ if isinstance(draft_body, str):
451
+ try:
452
+ draft_body = json.loads(draft_body)
453
+ except json.JSONDecodeError as exc:
454
+ raise ValueError(
455
+ "Malformed draft body: draft_body is not valid JSON"
456
+ ) from exc
457
+ if not isinstance(draft_body, dict):
458
+ raise ValueError("Malformed draft body: draft_body must be a JSON object")
459
+
460
+ markdown, unsupported_nodes = document_to_markdown(draft_body)
461
+ return {
462
+ "draft": draft,
463
+ "markdown": markdown,
464
+ "unsupported_nodes": unsupported_nodes,
465
+ }
466
+
445
467
  def delete_draft(self, draft_id):
446
468
  """
447
469
 
@@ -309,6 +309,32 @@ def _drafts_create(api, args):
309
309
  print(f"Created draft {draft.get('id')}: {title}")
310
310
 
311
311
 
312
+ def _drafts_export(api, args):
313
+ output_path = Path(args.output) if args.output else None
314
+ if output_path is not None and output_path.exists() and not args.force:
315
+ raise CLIUsageError(
316
+ f"Output file already exists: {output_path}; use --force to overwrite"
317
+ )
318
+
319
+ result = api.export_draft_to_markdown(args.draft_id)
320
+ markdown = result["markdown"]
321
+ if output_path is not None:
322
+ output_path.write_text(markdown, encoding="utf-8")
323
+
324
+ payload = {
325
+ "action": "export",
326
+ "draft_id": args.draft_id,
327
+ "markdown": markdown,
328
+ "unsupported_nodes": result["unsupported_nodes"],
329
+ }
330
+ if args.json_output:
331
+ _print_json(payload)
332
+ elif output_path is not None:
333
+ print(f"Exported draft {args.draft_id} to {output_path}")
334
+ else:
335
+ print(markdown, end="")
336
+
337
+
312
338
  def _drafts_schedule(api, args):
313
339
  scheduled_at = _parse_schedule(args.at)
314
340
  result = api.schedule_draft(args.draft_id, scheduled_at)
@@ -421,6 +447,14 @@ def _build_parser():
421
447
  drafts_create.add_argument("--tag", action="append", dest="tags", metavar="TAG")
422
448
  drafts_create.set_defaults(handler=_drafts_create)
423
449
 
450
+ drafts_export = draft_commands.add_parser(
451
+ "export", help="Export a draft to Markdown without modifying it."
452
+ )
453
+ drafts_export.add_argument("draft_id", type=int)
454
+ drafts_export.add_argument("--output", metavar="PATH")
455
+ drafts_export.add_argument("--force", action="store_true")
456
+ drafts_export.set_defaults(handler=_drafts_export)
457
+
424
458
  drafts_schedule = draft_commands.add_parser("schedule", help="Schedule a draft.")
425
459
  drafts_schedule.add_argument("draft_id", type=int)
426
460
  drafts_schedule.add_argument("--at", required=True)
@@ -0,0 +1,381 @@
1
+ import base64
2
+ import copy
3
+ import json
4
+ import re
5
+
6
+ _BLOCK_TYPES = {
7
+ "paragraph",
8
+ "heading",
9
+ "blockquote",
10
+ "codeBlock",
11
+ "horizontal_rule",
12
+ "bullet_list",
13
+ "ordered_list",
14
+ "captionedImage",
15
+ "footnote",
16
+ "latex_block",
17
+ "pullquote",
18
+ "calloutBlock",
19
+ }
20
+ _MARK_TYPES = {
21
+ "strong",
22
+ "em",
23
+ "code",
24
+ "strikethrough",
25
+ "superscript",
26
+ "subscript",
27
+ "link",
28
+ }
29
+ _IMAGE_ATTRS = {
30
+ "src",
31
+ "srcNoWatermark",
32
+ "fullscreen",
33
+ "imageSize",
34
+ "height",
35
+ "width",
36
+ "resizeWidth",
37
+ "bytes",
38
+ "alt",
39
+ "title",
40
+ "type",
41
+ "href",
42
+ "belowTheFold",
43
+ "topImage",
44
+ "internalRedirect",
45
+ "isProcessing",
46
+ "align",
47
+ "offset",
48
+ }
49
+
50
+
51
+ def _marker(node, unsupported):
52
+ unsupported.append(copy.deepcopy(node))
53
+ payload = json.dumps(
54
+ node, ensure_ascii=False, separators=(",", ":"), sort_keys=True
55
+ ).encode("utf-8")
56
+ encoded = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
57
+ return f"<!-- python-substack-node:v1 {encoded} -->"
58
+
59
+
60
+ def _require_dict(node, context):
61
+ if not isinstance(node, dict):
62
+ raise ValueError(f"Malformed draft body: {context} must be an object")
63
+ if not isinstance(node.get("type"), str) or not node["type"]:
64
+ raise ValueError(f"Malformed draft body: {context} has no node type")
65
+
66
+
67
+ def _content(node, context):
68
+ content = node.get("content", [])
69
+ if not isinstance(content, list):
70
+ raise ValueError(f"Malformed draft body: {context} content must be a list")
71
+ return content
72
+
73
+
74
+ def _attrs(node, context):
75
+ attrs = node.get("attrs", {})
76
+ if not isinstance(attrs, dict):
77
+ raise ValueError(f"Malformed draft body: {context} attrs must be an object")
78
+ return attrs
79
+
80
+
81
+ def _has_unknown_keys(value, allowed):
82
+ return bool(set(value) - set(allowed))
83
+
84
+
85
+ def _escape_text(value):
86
+ return re.sub(r"([\\`*_{}\[\]()<>#+\-.!|])", r"\\\1", value)
87
+
88
+
89
+ def _escape_destination(value):
90
+ return str(value).replace("\\", "\\\\").replace(")", "\\)")
91
+
92
+
93
+ def _escape_title(value):
94
+ return value.replace("\\", "\\\\").replace('"', '\\"')
95
+
96
+
97
+ def _code_span(value):
98
+ runs = [len(match.group(0)) for match in re.finditer(r"`+", value)]
99
+ delimiter = "`" * max(1, (max(runs) + 1) if runs else 1)
100
+ padding = " " if value.startswith(("`", " ")) or value.endswith(("`", " ")) else ""
101
+ return f"{delimiter}{padding}{value}{padding}{delimiter}"
102
+
103
+
104
+ def _render_marked_text(value, marks, node, unsupported):
105
+ if not isinstance(value, str) or not isinstance(marks, list):
106
+ raise ValueError("Malformed draft body: text and marks have invalid types")
107
+ for mark in marks:
108
+ _require_dict(mark, "mark")
109
+ if mark["type"] not in _MARK_TYPES:
110
+ return _marker(node, unsupported)
111
+ allowed = {"type", "attrs"} if mark["type"] == "link" else {"type"}
112
+ if _has_unknown_keys(mark, allowed):
113
+ return _marker(node, unsupported)
114
+
115
+ rendered = (
116
+ _code_span(value)
117
+ if any(mark["type"] == "code" for mark in marks)
118
+ else _escape_text(value)
119
+ )
120
+ for mark in marks:
121
+ mark_type = mark["type"]
122
+ if mark_type == "code":
123
+ continue
124
+ if mark_type == "strong":
125
+ rendered = f"**{rendered}**"
126
+ elif mark_type == "em":
127
+ rendered = f"*{rendered}*"
128
+ elif mark_type == "strikethrough":
129
+ rendered = f"~~{rendered}~~"
130
+ elif mark_type == "superscript":
131
+ rendered = f"^{rendered}^"
132
+ elif mark_type == "subscript":
133
+ rendered = f"~{rendered}~"
134
+ elif mark_type == "link":
135
+ attrs = _attrs(mark, "link mark")
136
+ if _has_unknown_keys(attrs, {"href"}) or not isinstance(
137
+ attrs.get("href"), str
138
+ ):
139
+ return _marker(node, unsupported)
140
+ rendered = f"[{rendered}]({_escape_destination(attrs['href'])})"
141
+ return rendered
142
+
143
+
144
+ def _render_inline(nodes, unsupported):
145
+ if not isinstance(nodes, list):
146
+ raise ValueError("Malformed draft body: inline content must be a list")
147
+ rendered = []
148
+ for node in nodes:
149
+ _require_dict(node, "inline node")
150
+ node_type = node["type"]
151
+ if node_type == "text":
152
+ if _has_unknown_keys(node, {"type", "text", "marks"}):
153
+ rendered.append(_marker(node, unsupported))
154
+ continue
155
+ rendered.append(
156
+ _render_marked_text(
157
+ node.get("text"), node.get("marks", []), node, unsupported
158
+ )
159
+ )
160
+ elif node_type == "footnoteAnchor":
161
+ attrs = _attrs(node, "footnote anchor")
162
+ number = attrs.get("number")
163
+ if (
164
+ _has_unknown_keys(node, {"type", "attrs"})
165
+ or _has_unknown_keys(attrs, {"number"})
166
+ or not isinstance(number, int)
167
+ ):
168
+ rendered.append(_marker(node, unsupported))
169
+ else:
170
+ rendered.append(f"[^{number}]")
171
+ elif node_type == "latex":
172
+ attrs = _attrs(node, "inline math")
173
+ expression = attrs.get("expression", attrs.get("persistentExpression"))
174
+ if (
175
+ _has_unknown_keys(node, {"type", "attrs"})
176
+ or _has_unknown_keys(attrs, {"expression", "persistentExpression"})
177
+ or not isinstance(expression, str)
178
+ ):
179
+ rendered.append(_marker(node, unsupported))
180
+ else:
181
+ rendered.append(f"${expression}$")
182
+ else:
183
+ rendered.append(_marker(node, unsupported))
184
+ return "".join(rendered)
185
+
186
+
187
+ def _render_list(node, unsupported, ordered=False):
188
+ lines = []
189
+ for index, item in enumerate(_content(node, "list"), start=1):
190
+ _require_dict(item, "list item")
191
+ if item["type"] != "list_item" or _has_unknown_keys(item, {"type", "content"}):
192
+ item_text = _marker(item, unsupported)
193
+ else:
194
+ item_blocks = [
195
+ _render_block(child, unsupported)
196
+ for child in _content(item, "list item")
197
+ ]
198
+ item_text = "\n\n".join(item_blocks)
199
+ prefix = f"{index}. " if ordered else "- "
200
+ item_lines = item_text.splitlines() or [""]
201
+ lines.append(prefix + item_lines[0])
202
+ lines.extend(" " + line if line else "" for line in item_lines[1:])
203
+ return "\n".join(lines)
204
+
205
+
206
+ def _render_image(node, unsupported):
207
+ content = _content(node, "captioned image")
208
+ if not content:
209
+ raise ValueError("Malformed draft body: captioned image has no image")
210
+ image = content[0]
211
+ _require_dict(image, "image")
212
+ attrs = _attrs(image, "image")
213
+ if (
214
+ image["type"] != "image2"
215
+ or _has_unknown_keys(node, {"type", "content"})
216
+ or _has_unknown_keys(image, {"type", "attrs"})
217
+ or _has_unknown_keys(attrs, _IMAGE_ATTRS)
218
+ or not isinstance(attrs.get("src"), str)
219
+ ):
220
+ return _marker(node, unsupported)
221
+
222
+ alt = str(attrs.get("alt") or "").replace("\\", "\\\\").replace("]", "\\]")
223
+ image_markdown = f"![{alt}]({_escape_destination(attrs['src'])}"
224
+ if len(content) > 2:
225
+ return _marker(node, unsupported)
226
+ if len(content) == 2:
227
+ caption = content[1]
228
+ _require_dict(caption, "image caption")
229
+ caption_nodes = _content(caption, "image caption")
230
+ if (
231
+ caption["type"] != "caption"
232
+ or _has_unknown_keys(caption, {"type", "content"})
233
+ or any(
234
+ not isinstance(child, dict)
235
+ or child.get("type") != "text"
236
+ or child.get("marks")
237
+ or not isinstance(child.get("text"), str)
238
+ for child in caption_nodes
239
+ )
240
+ ):
241
+ return _marker(node, unsupported)
242
+ caption_text = "".join(child.get("text", "") for child in caption_nodes)
243
+ image_markdown += f' "{_escape_title(caption_text)}"'
244
+ image_markdown += ")"
245
+ href = attrs.get("href")
246
+ if href:
247
+ if not isinstance(href, str):
248
+ return _marker(node, unsupported)
249
+ image_markdown = f"[{image_markdown}]({_escape_destination(href)})"
250
+ return image_markdown
251
+
252
+
253
+ def _render_footnote(node, unsupported):
254
+ attrs = _attrs(node, "footnote")
255
+ number = attrs.get("number")
256
+ if (
257
+ _has_unknown_keys(node, {"type", "attrs", "content"})
258
+ or _has_unknown_keys(attrs, {"number"})
259
+ or not isinstance(number, int)
260
+ ):
261
+ return _marker(node, unsupported)
262
+ body = "\n\n".join(
263
+ _render_block(child, unsupported) for child in _content(node, "footnote")
264
+ )
265
+ lines = body.splitlines() or [""]
266
+ return f"[^{number}]: {lines[0]}" + "".join(
267
+ f"\n {line}" if line else "\n" for line in lines[1:]
268
+ )
269
+
270
+
271
+ def _render_block(node, unsupported):
272
+ _require_dict(node, "block node")
273
+ node_type = node["type"]
274
+ if node_type not in _BLOCK_TYPES:
275
+ return _marker(node, unsupported)
276
+
277
+ if node_type == "paragraph":
278
+ if _has_unknown_keys(node, {"type", "content"}):
279
+ return _marker(node, unsupported)
280
+ return _render_inline(_content(node, "paragraph"), unsupported)
281
+ if node_type == "heading":
282
+ attrs = _attrs(node, "heading")
283
+ level = attrs.get("level")
284
+ if (
285
+ _has_unknown_keys(node, {"type", "content", "attrs"})
286
+ or _has_unknown_keys(attrs, {"level"})
287
+ or not isinstance(level, int)
288
+ or not 1 <= level <= 6
289
+ ):
290
+ return _marker(node, unsupported)
291
+ return f"{'#' * level} {_render_inline(_content(node, 'heading'), unsupported)}"
292
+ if node_type == "horizontal_rule":
293
+ return (
294
+ "---"
295
+ if not _has_unknown_keys(node, {"type"})
296
+ else _marker(node, unsupported)
297
+ )
298
+ if node_type == "codeBlock":
299
+ attrs = _attrs(node, "code block")
300
+ if _has_unknown_keys(node, {"type", "content", "attrs"}) or _has_unknown_keys(
301
+ attrs, {"language"}
302
+ ):
303
+ return _marker(node, unsupported)
304
+ code_nodes = _content(node, "code block")
305
+ if any(
306
+ not isinstance(child, dict)
307
+ or child.get("type") != "text"
308
+ or _has_unknown_keys(child, {"type", "text"})
309
+ or not isinstance(child.get("text"), str)
310
+ for child in code_nodes
311
+ ):
312
+ raise ValueError("Malformed draft body: invalid code block content")
313
+ code = "".join(child["text"] for child in code_nodes)
314
+ runs = [len(match.group(0)) for match in re.finditer(r"`+", code)]
315
+ fence = "`" * max(3, (max(runs) + 1) if runs else 3)
316
+ language = attrs.get("language") or ""
317
+ if not isinstance(language, str) or "\n" in language:
318
+ return _marker(node, unsupported)
319
+ return f"{fence}{language}\n{code}\n{fence}"
320
+ if node_type == "blockquote":
321
+ if _has_unknown_keys(node, {"type", "content"}):
322
+ return _marker(node, unsupported)
323
+ body = "\n\n".join(
324
+ _render_block(child, unsupported) for child in _content(node, "blockquote")
325
+ )
326
+ return "\n".join(">" if not line else f"> {line}" for line in body.splitlines())
327
+ if node_type in {"bullet_list", "ordered_list"}:
328
+ if _has_unknown_keys(node, {"type", "content"}):
329
+ return _marker(node, unsupported)
330
+ return _render_list(node, unsupported, ordered=node_type == "ordered_list")
331
+ if node_type == "captionedImage":
332
+ return _render_image(node, unsupported)
333
+ if node_type == "footnote":
334
+ return _render_footnote(node, unsupported)
335
+ if node_type == "latex_block":
336
+ attrs = _attrs(node, "math block")
337
+ expression = attrs.get("persistentExpression", attrs.get("expression"))
338
+ if (
339
+ _has_unknown_keys(node, {"type", "attrs"})
340
+ or _has_unknown_keys(attrs, {"persistentExpression", "expression", "dirty"})
341
+ or not isinstance(expression, str)
342
+ ):
343
+ return _marker(node, unsupported)
344
+ return f"$$\n{expression}\n$$"
345
+ if node_type in {"pullquote", "calloutBlock"}:
346
+ allowed = (
347
+ {"type", "content", "attrs"}
348
+ if node_type == "pullquote"
349
+ else {"type", "content"}
350
+ )
351
+ if _has_unknown_keys(node, allowed):
352
+ return _marker(node, unsupported)
353
+ if node_type == "pullquote":
354
+ attrs = _attrs(node, "pull quote")
355
+ if _has_unknown_keys(attrs, {"align", "color"}) or any(
356
+ attrs.get(key) is not None for key in ("align", "color")
357
+ ):
358
+ return _marker(node, unsupported)
359
+ body = "\n\n".join(
360
+ _render_block(child, unsupported) for child in _content(node, node_type)
361
+ )
362
+ name = "pullquote" if node_type == "pullquote" else "callout"
363
+ return f"::: {name}\n{body}\n:::"
364
+ raise AssertionError(node_type)
365
+
366
+
367
+ def document_to_markdown(document):
368
+ _require_dict(document, "document")
369
+ if document["type"] != "doc":
370
+ raise ValueError("Malformed draft body: root node must have type 'doc'")
371
+ if _has_unknown_keys(document, {"type", "content"}):
372
+ raise ValueError("Malformed draft body: document has unsupported root fields")
373
+
374
+ unsupported = []
375
+ blocks = [
376
+ _render_block(node, unsupported) for node in _content(document, "document")
377
+ ]
378
+ markdown = "\n\n".join(blocks)
379
+ if markdown:
380
+ markdown += "\n"
381
+ return markdown, unsupported
File without changes