python-substack 0.8.0__tar.gz → 0.9.1__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,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-substack
3
- Version: 0.8.0
3
+ Version: 0.9.1
4
4
  Summary: Write and safely manage Substack drafts from Markdown with Python, CLI, and MCP.
5
- License: MIT
5
+ License-Expression: MIT
6
6
  License-File: LICENSE
7
7
  Keywords: substack,substack-api,cli,newsletter,publishing,automation,mcp
8
8
  Author: Paolo Mazza
@@ -10,13 +10,11 @@ Author-email: mazzapaolo2019@gmail.com
10
10
  Requires-Python: >=3.10,<4.0
11
11
  Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: MIT License
14
13
  Classifier: Programming Language :: Python :: 3
15
14
  Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
17
16
  Classifier: Programming Language :: Python :: 3.12
18
17
  Classifier: Programming Language :: Python :: 3.13
19
- Classifier: Programming Language :: Python :: 3.14
20
18
  Classifier: Topic :: Communications :: Email
21
19
  Classifier: Topic :: Internet :: WWW/HTTP
22
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
@@ -94,6 +92,7 @@ Substack result:
94
92
  - Set audience, comment permissions, SEO metadata, slug, sections, and tags.
95
93
  - List and inspect publications and drafts.
96
94
  - Export drafts to loss-aware Markdown backups without server writes.
95
+ - Safely update drafts from Markdown, preserving unsupported editor widgets by default.
97
96
  - Schedule, unschedule, publish, and delete drafts with explicit safeguards.
98
97
  - Use stable JSON envelopes in scripts and automation.
99
98
  - Authenticate with browser cookies or email and password.
@@ -145,6 +144,7 @@ substack publications list
145
144
  substack drafts list --limit 10
146
145
  substack drafts get 12345
147
146
  substack drafts export 12345 --output backup.md
147
+ substack drafts update 12345 revised-post.md --dry-run --yes
148
148
  substack --publication-url https://example.substack.com drafts list
149
149
  ```
150
150
 
@@ -217,6 +217,17 @@ print(backup["markdown"])
217
217
  print(backup["unsupported_nodes"])
218
218
  ```
219
219
 
220
+ Review a Markdown update before writing it, then update deliberately:
221
+
222
+ ```python
223
+ result = api.update_draft_from_markdown(12345, "# Revised draft", dry_run=True)
224
+ result = api.update_draft_from_markdown(12345, "# Revised draft")
225
+ ```
226
+
227
+ Unsupported editor widgets exported as `python-substack-node:v1` markers are
228
+ preserved by default. An update refuses to remove or alter them unless
229
+ `allow_unsupported_change=True` is explicitly passed.
230
+
220
231
  For direct ProseMirror node construction, see the
221
232
  [low-level Python API](docs/low-level-api.md). YAML workflows are documented in
222
233
  [YAML drafts](docs/yaml.md).
@@ -269,11 +280,12 @@ See [MCP server](docs/mcp.md) for the tool list and safety notes.
269
280
  - [Python SDK](docs/python-sdk.md)
270
281
  - [Authentication](docs/authentication.md)
271
282
  - [Markdown reference](docs/markdown.md)
283
+ - [Draft backup and safe updates](docs/cli.md)
272
284
  - [Legacy CLI commands](docs/legacy-cli.md)
273
285
  - [Low-level Python API](docs/low-level-api.md)
274
286
  - [YAML drafts](docs/yaml.md)
275
287
  - [MCP server](docs/mcp.md)
276
- - [Gemini CLI with Vertex AI](docs/gemini-vertex-ai.md)
288
+ - [Agent workflow](docs/agents.md)
277
289
  - [Safety and publishing behavior](docs/safety.md)
278
290
  - [Troubleshooting](docs/troubleshooting.md)
279
291
  - [Compatibility policy](docs/compatibility.md)
@@ -58,6 +58,7 @@ Substack result:
58
58
  - Set audience, comment permissions, SEO metadata, slug, sections, and tags.
59
59
  - List and inspect publications and drafts.
60
60
  - Export drafts to loss-aware Markdown backups without server writes.
61
+ - Safely update drafts from Markdown, preserving unsupported editor widgets by default.
61
62
  - Schedule, unschedule, publish, and delete drafts with explicit safeguards.
62
63
  - Use stable JSON envelopes in scripts and automation.
63
64
  - Authenticate with browser cookies or email and password.
@@ -109,6 +110,7 @@ substack publications list
109
110
  substack drafts list --limit 10
110
111
  substack drafts get 12345
111
112
  substack drafts export 12345 --output backup.md
113
+ substack drafts update 12345 revised-post.md --dry-run --yes
112
114
  substack --publication-url https://example.substack.com drafts list
113
115
  ```
114
116
 
@@ -181,6 +183,17 @@ print(backup["markdown"])
181
183
  print(backup["unsupported_nodes"])
182
184
  ```
183
185
 
186
+ Review a Markdown update before writing it, then update deliberately:
187
+
188
+ ```python
189
+ result = api.update_draft_from_markdown(12345, "# Revised draft", dry_run=True)
190
+ result = api.update_draft_from_markdown(12345, "# Revised draft")
191
+ ```
192
+
193
+ Unsupported editor widgets exported as `python-substack-node:v1` markers are
194
+ preserved by default. An update refuses to remove or alter them unless
195
+ `allow_unsupported_change=True` is explicitly passed.
196
+
184
197
  For direct ProseMirror node construction, see the
185
198
  [low-level Python API](docs/low-level-api.md). YAML workflows are documented in
186
199
  [YAML drafts](docs/yaml.md).
@@ -233,11 +246,12 @@ See [MCP server](docs/mcp.md) for the tool list and safety notes.
233
246
  - [Python SDK](docs/python-sdk.md)
234
247
  - [Authentication](docs/authentication.md)
235
248
  - [Markdown reference](docs/markdown.md)
249
+ - [Draft backup and safe updates](docs/cli.md)
236
250
  - [Legacy CLI commands](docs/legacy-cli.md)
237
251
  - [Low-level Python API](docs/low-level-api.md)
238
252
  - [YAML drafts](docs/yaml.md)
239
253
  - [MCP server](docs/mcp.md)
240
- - [Gemini CLI with Vertex AI](docs/gemini-vertex-ai.md)
254
+ - [Agent workflow](docs/agents.md)
241
255
  - [Safety and publishing behavior](docs/safety.md)
242
256
  - [Troubleshooting](docs/troubleshooting.md)
243
257
  - [Compatibility policy](docs/compatibility.md)
@@ -1,19 +1,13 @@
1
- [tool.poetry]
1
+ [project]
2
2
  name = "python-substack"
3
- version = "0.8.0"
3
+ version = "0.9.1"
4
4
  description = "Write and safely manage Substack drafts from Markdown with Python, CLI, and MCP."
5
- authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
6
- license = "MIT"
7
- packages = [
8
- { include = "substack" },
9
- { include = "substack_mcp" }
5
+ authors = [
6
+ { name = "Paolo Mazza", email = "mazzapaolo2019@gmail.com" }
10
7
  ]
11
-
8
+ license = "MIT"
12
9
  readme = "README.md"
13
-
14
- repository = "https://github.com/ma2za/python-substack"
15
- homepage = "https://github.com/ma2za/python-substack"
16
-
10
+ requires-python = ">=3.10,<4.0"
17
11
  keywords = ["substack", "substack-api", "cli", "newsletter", "publishing", "automation", "mcp"]
18
12
  classifiers = [
19
13
  "Development Status :: 4 - Beta",
@@ -27,24 +21,38 @@ classifiers = [
27
21
  "Topic :: Internet :: WWW/HTTP",
28
22
  "Topic :: Software Development :: Libraries :: Python Modules"
29
23
  ]
24
+ dependencies = [
25
+ "requests>=2.32.0,<3.0.0",
26
+ "python-dotenv>=1.2.1,<2.0.0",
27
+ "PyYAML>=6.0,<7.0",
28
+ "markdown-it-py>=3,<5",
29
+ "mdit-py-plugins>=0.5,<0.7"
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ mcp = [
34
+ "fastmcp>=3.1.1,<5.0.0"
35
+ ]
30
36
 
31
- [tool.poetry.urls]
37
+ [project.urls]
32
38
  "Documentation" = "https://ma2za.github.io/python-substack/"
33
39
  "Changelog" = "https://github.com/ma2za/python-substack/blob/main/CHANGELOG.md"
34
40
  "Issues" = "https://github.com/ma2za/python-substack/issues"
41
+ "Homepage" = "https://github.com/ma2za/python-substack"
42
+ "Repository" = "https://github.com/ma2za/python-substack"
35
43
 
36
- [tool.poetry.dependencies]
37
- python = "<4.0,>=3.10"
38
-
39
- requests = "^2.32.0"
40
- python-dotenv = "^1.2.1"
41
- PyYAML = "^6.0"
42
- markdown-it-py = ">=3,<5"
43
- mdit-py-plugins = ">=0.5,<0.7"
44
- fastmcp = { version = ">=3.1.1,<5.0.0", optional = true }
44
+ [project.scripts]
45
+ substack = "substack.cli:main"
46
+ substack-auth-check = "substack.cli:auth_check"
47
+ substack-publish-markdown = "substack.cli:publish_markdown"
48
+ substack-publish-yaml = "substack.cli:publish_yaml"
49
+ substack-mcp = "substack_mcp.mcp_server:main"
45
50
 
46
- [tool.poetry.extras]
47
- mcp = ["fastmcp"]
51
+ [tool.poetry]
52
+ packages = [
53
+ { include = "substack" },
54
+ { include = "substack_mcp" }
55
+ ]
48
56
 
49
57
  [tool.poetry.group.dev.dependencies]
50
58
  pytest = "^9.1.1"
@@ -56,13 +64,6 @@ markers = [
56
64
  "live: opt-in tests that call the live Substack service",
57
65
  ]
58
66
 
59
- [tool.poetry.scripts]
60
- substack = "substack.cli:main"
61
- substack-auth-check = "substack.cli:auth_check"
62
- substack-publish-markdown = "substack.cli:publish_markdown"
63
- substack-publish-yaml = "substack.cli:publish_yaml"
64
- substack-mcp = "substack_mcp.mcp_server:main"
65
-
66
67
  [build-system]
67
- requires = ["poetry-core>=1.0.0"]
68
+ requires = ["poetry-core>=2.0.0"]
68
69
  build-backend = "poetry.core.masonry.api"
@@ -3,7 +3,7 @@
3
3
  __author__ = "Paolo Mazza"
4
4
  __email__ = "mazzapaolo2019@gmail.com"
5
5
  __license__ = "MIT License"
6
- __version__ = "0.8.0"
6
+ __version__ = "0.9.1"
7
7
  __url__ = "https://github.com/ma2za/python-substack"
8
8
  __download_url__ = "https://pypi.python.org/pypi/python-substack"
9
9
  __description__ = (
@@ -21,6 +21,19 @@ logger = logging.getLogger(__name__)
21
21
  __all__ = ["Api"]
22
22
 
23
23
 
24
+ def _image_attrs(document):
25
+ images = []
26
+ if isinstance(document, dict):
27
+ if document.get("type") == "image2" and isinstance(document.get("attrs"), dict):
28
+ images.append(document["attrs"])
29
+ for value in document.values():
30
+ images.extend(_image_attrs(value))
31
+ elif isinstance(document, list):
32
+ for value in document:
33
+ images.extend(_image_attrs(value))
34
+ return images
35
+
36
+
24
37
  class Api:
25
38
  """
26
39
 
@@ -432,7 +445,10 @@ class Api:
432
445
  f"{self.publication_url}/drafts",
433
446
  params={"filter": filter, "offset": offset, "limit": limit},
434
447
  )
435
- return Api._handle_response(response=response)
448
+ drafts = Api._handle_response(response=response)
449
+ if isinstance(drafts, dict) and isinstance(drafts.get("posts"), list):
450
+ return drafts["posts"]
451
+ return drafts
436
452
 
437
453
  def get_draft(self, draft_id):
438
454
  """
@@ -479,10 +495,12 @@ class Api:
479
495
  tags=None,
480
496
  dry_run: bool = False,
481
497
  allow_unsupported_change: bool = False,
498
+ allow_image_replacement: bool = False,
482
499
  ) -> dict:
483
500
  """
484
501
  Update an existing draft body from Markdown, with optional metadata changes.
485
502
  """
503
+ from substack import mdrender
486
504
  from substack.mdexport import document_to_markdown
487
505
  from substack.post import Post
488
506
 
@@ -500,6 +518,7 @@ class Api:
500
518
 
501
519
  _, remote_unsupported = document_to_markdown(draft_body)
502
520
 
521
+ image_markers = []
503
522
  post = Post(
504
523
  title=draft.get("title", ""),
505
524
  subtitle=(
@@ -509,7 +528,88 @@ class Api:
509
528
  audience=audience,
510
529
  write_comment_permissions=write_comment_permissions,
511
530
  )
512
- post.from_markdown(markdown, api=self)
531
+ rendered = mdrender.markdown_to_doc(
532
+ markdown, api=self, track_markers=image_markers
533
+ )
534
+ post.draft_body["content"] = rendered
535
+
536
+ remote_images = _image_attrs(draft_body)
537
+ submitted_images = _image_attrs(post.draft_body)
538
+
539
+ consumed_remote_indices = set()
540
+
541
+ for submitted, marker in zip(submitted_images, image_markers):
542
+ if marker is not None and marker.get("version") == 2:
543
+ bound_src = marker["src"]
544
+ matched_idx = None
545
+ for idx, r_img in enumerate(remote_images):
546
+ if (
547
+ idx not in consumed_remote_indices
548
+ and r_img.get("src") == bound_src
549
+ ):
550
+ matched_idx = idx
551
+ break
552
+
553
+ if matched_idx is None:
554
+ all_with_src = [
555
+ idx
556
+ for idx, r_img in enumerate(remote_images)
557
+ if r_img.get("src") == bound_src
558
+ ]
559
+ if not all_with_src:
560
+ raise ValueError(
561
+ f"Refusing to update: stale image marker bound to '{bound_src}'."
562
+ )
563
+ else:
564
+ raise ValueError(
565
+ f"Refusing to update: reused or duplicate image marker for '{bound_src}'."
566
+ )
567
+
568
+ consumed_remote_indices.add(matched_idx)
569
+ remote_img = remote_images[matched_idx]
570
+ source, alt, href = (
571
+ submitted.get("src"),
572
+ submitted.get("alt"),
573
+ submitted.get("href"),
574
+ )
575
+ submitted.update(remote_img)
576
+ submitted.update(marker["attrs"])
577
+ submitted.update(
578
+ {"src": source, "alt": alt, "href": href, "isProcessing": False}
579
+ )
580
+
581
+ elif marker is not None and marker.get("version") == 1:
582
+ sub_src = submitted.get("src")
583
+ matches = [
584
+ idx
585
+ for idx, r_img in enumerate(remote_images)
586
+ if r_img.get("src") == sub_src
587
+ and idx not in consumed_remote_indices
588
+ ]
589
+ if len(matches) == 1:
590
+ matched_idx = matches[0]
591
+ consumed_remote_indices.add(matched_idx)
592
+ remote_img = remote_images[matched_idx]
593
+ source, alt, href = (
594
+ submitted.get("src"),
595
+ submitted.get("alt"),
596
+ submitted.get("href"),
597
+ )
598
+ submitted.update(remote_img)
599
+ submitted.update(marker["attrs"])
600
+ submitted.update(
601
+ {"src": source, "alt": alt, "href": href, "isProcessing": False}
602
+ )
603
+ elif not allow_image_replacement:
604
+ raise ValueError(
605
+ "Refusing to update: image cannot be matched uniquely; set allow_image_replacement=True to proceed."
606
+ )
607
+
608
+ else:
609
+ if not allow_image_replacement:
610
+ raise ValueError(
611
+ "Refusing to update: image cannot be matched uniquely; set allow_image_replacement=True to proceed."
612
+ )
513
613
 
514
614
  _, submitted_unsupported = document_to_markdown(post.draft_body)
515
615
 
@@ -312,6 +312,8 @@ def _drafts_create(api, args):
312
312
  def _drafts_update(api, args):
313
313
  if args.allow_unsupported_change and not args.yes:
314
314
  raise CLIUsageError("--yes is required when using --allow-unsupported-change")
315
+ if args.allow_image_replacement and not args.yes:
316
+ raise CLIUsageError("--yes is required when using --allow-image-replacement")
315
317
 
316
318
  if not args.yes and (args.json_output or not sys.stdin.isatty()):
317
319
  raise CLIUsageError("--yes is required in non-interactive or JSON mode")
@@ -351,6 +353,7 @@ def _drafts_update(api, args):
351
353
  tags=args.tags,
352
354
  dry_run=args.dry_run,
353
355
  allow_unsupported_change=args.allow_unsupported_change,
356
+ allow_image_replacement=args.allow_image_replacement,
354
357
  )
355
358
 
356
359
  if args.json_output:
@@ -513,6 +516,7 @@ def _build_parser():
513
516
  drafts_update.add_argument("--tag", action="append", dest="tags", metavar="TAG")
514
517
  drafts_update.add_argument("--dry-run", action="store_true")
515
518
  drafts_update.add_argument("--allow-unsupported-change", action="store_true")
519
+ drafts_update.add_argument("--allow-image-replacement", action="store_true")
516
520
  drafts_update.add_argument("--yes", action="store_true")
517
521
  drafts_update.set_defaults(handler=_drafts_update)
518
522
 
@@ -57,6 +57,22 @@ def _marker(node, unsupported):
57
57
  return f"<!-- python-substack-node:v1 {encoded} -->"
58
58
 
59
59
 
60
+ def _image_marker(attrs):
61
+ payload_data = {
62
+ "src": attrs["src"],
63
+ **{
64
+ key: value
65
+ for key, value in attrs.items()
66
+ if key not in {"src", "alt", "href", "isProcessing"}
67
+ },
68
+ }
69
+ payload = json.dumps(
70
+ payload_data, ensure_ascii=False, separators=(",", ":"), sort_keys=True
71
+ ).encode("utf-8")
72
+ encoded = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
73
+ return f"\n<!-- python-substack-image:v2 {encoded} -->"
74
+
75
+
60
76
  def _require_dict(node, context):
61
77
  if not isinstance(node, dict):
62
78
  raise ValueError(f"Malformed draft body: {context} must be an object")
@@ -214,7 +230,6 @@ def _render_image(node, unsupported):
214
230
  image["type"] != "image2"
215
231
  or _has_unknown_keys(node, {"type", "content"})
216
232
  or _has_unknown_keys(image, {"type", "attrs"})
217
- or _has_unknown_keys(attrs, _IMAGE_ATTRS)
218
233
  or not isinstance(attrs.get("src"), str)
219
234
  ):
220
235
  return _marker(node, unsupported)
@@ -247,7 +262,7 @@ def _render_image(node, unsupported):
247
262
  if not isinstance(href, str):
248
263
  return _marker(node, unsupported)
249
264
  image_markdown = f"[{image_markdown}]({_escape_destination(href)})"
250
- return image_markdown
265
+ return image_markdown + _image_marker(attrs)
251
266
 
252
267
 
253
268
  def _render_footnote(node, unsupported):
@@ -21,7 +21,9 @@ import base64
21
21
  import copy
22
22
  import json
23
23
  import re
24
+ from pathlib import Path
24
25
  from typing import Dict, List, Optional
26
+ from urllib.parse import unquote, urlsplit
25
27
 
26
28
  from markdown_it import MarkdownIt
27
29
  from markdown_it.tree import SyntaxTreeNode
@@ -46,19 +48,21 @@ _MARK_FOR = {
46
48
  def parse_node_marker(comment_content: str) -> dict | None:
47
49
  """
48
50
  Parse a python-substack-node:v1 comment marker and return the parsed JSON dictionary.
49
-
51
+
50
52
  If it is not a python-substack-node:v1 marker, returns None.
51
53
  If it is an attempted marker but is corrupt/malformed, raises ValueError.
52
54
  """
53
55
  clean = comment_content.strip()
54
- match = re.match(r"^<!--\s*python-substack-node:v1\s+([A-Za-z0-9_-]+=*)\s*-->$", clean)
56
+ match = re.match(
57
+ r"^<!--\s*python-substack-node:v1\s+([A-Za-z0-9_-]+=*)\s*-->$", clean
58
+ )
55
59
  if not match:
56
60
  if "python-substack-node:v1" in clean:
57
61
  raise ValueError("Corrupt marker format")
58
62
  return None
59
-
63
+
60
64
  encoded = match.group(1)
61
-
65
+
62
66
  try:
63
67
  padding = len(encoded) % 4
64
68
  if padding:
@@ -87,6 +91,44 @@ def parse_node_marker(comment_content: str) -> dict | None:
87
91
  return data
88
92
 
89
93
 
94
+ def parse_image_marker(comment_content: str) -> dict | None:
95
+ clean = comment_content.strip()
96
+ match = re.match(
97
+ r"^<!--\s*python-substack-image:(v1|v2)\s+([A-Za-z0-9_-]+=*)\s*-->$", clean
98
+ )
99
+ if not match:
100
+ if "python-substack-image:" in clean:
101
+ raise ValueError("Corrupt image marker format")
102
+ return None
103
+ version_str, encoded = match.group(1), match.group(2)
104
+ encoded += "=" * (-len(encoded) % 4)
105
+ try:
106
+ decoded_bytes = base64.urlsafe_b64decode(encoded.encode("ascii"))
107
+ except Exception as exc:
108
+ raise ValueError("Invalid image marker") from exc
109
+ try:
110
+ attrs = json.loads(decoded_bytes.decode("utf-8"))
111
+ except Exception as exc:
112
+ raise ValueError("Invalid image marker") from exc
113
+ if not isinstance(attrs, dict):
114
+ raise ValueError("Invalid image marker attributes")
115
+
116
+ if version_str == "v1":
117
+ if any(key in {"src", "alt", "href", "isProcessing"} for key in attrs):
118
+ raise ValueError("Invalid image marker attributes")
119
+ return {"version": 1, "src": None, "attrs": attrs}
120
+
121
+ if version_str == "v2":
122
+ if "src" not in attrs or not isinstance(attrs["src"], str):
123
+ raise ValueError("Invalid image marker attributes")
124
+ if any(key in {"alt", "href", "isProcessing"} for key in attrs):
125
+ raise ValueError("Invalid image marker attributes")
126
+ preserved = {k: v for k, v in attrs.items() if k != "src"}
127
+ return {"version": 2, "src": attrs["src"], "attrs": preserved}
128
+
129
+ return None
130
+
131
+
90
132
  def _make_parser() -> MarkdownIt:
91
133
  return (
92
134
  MarkdownIt("commonmark")
@@ -151,6 +193,8 @@ def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[D
151
193
  if alt:
152
194
  out.append(nodes.text(alt, marks))
153
195
  elif t == "html_inline":
196
+ if "python-substack-image:" in child.content:
197
+ raise ValueError("Image marker must immediately follow an image")
154
198
  marker_data = parse_node_marker(child.content)
155
199
  if marker_data is not None:
156
200
  out.append(marker_data)
@@ -173,13 +217,28 @@ def _only_image(inline: SyntaxTreeNode) -> Optional[SyntaxTreeNode]:
173
217
 
174
218
  def _captioned_image(img: SyntaxTreeNode, api) -> Dict:
175
219
  src = img.attrs.get("src", "")
176
- if src.startswith("/"):
177
- src = src[1:]
178
- if api is not None and not src.startswith("http"):
220
+ if api is not None and (
221
+ urlsplit(src).scheme.lower() not in ("http", "https")
222
+ and not src.startswith("//")
223
+ ):
224
+ # Markdown destinations encode spaces and non-ASCII characters as URLs.
225
+ path = Path(unquote(src)).expanduser()
226
+ if not path.is_file() and src.startswith("/"):
227
+ # Preserve legacy root-relative asset paths only when they resolve
228
+ # to a real file; an existing absolute path always takes precedence.
229
+ relative = Path(unquote(src[1:]))
230
+ if relative.is_file():
231
+ path = relative
232
+ if not path.is_file():
233
+ raise FileNotFoundError(f"Local image file not found: {path}")
179
234
  try:
180
- src = api.get_image(src).get("url")
181
- except Exception:
182
- pass
235
+ uploaded = api.get_image(str(path))
236
+ url = uploaded.get("url")
237
+ if not isinstance(url, str) or not url.startswith(("https://", "http://")):
238
+ raise ValueError("Upload did not return an HTTP image URL")
239
+ except Exception as exc:
240
+ raise ValueError(f"Failed to upload local image: {path}") from exc
241
+ src = url
183
242
  # markdown-it stores the image alt text as the node's content, not in attrs.
184
243
  alt = img.content or img.attrs.get("alt") or None
185
244
  # Standard markdown image title `![alt](src "caption")` maps to Substack's caption node.
@@ -284,7 +343,9 @@ def _footnote_definitions(tree: SyntaxTreeNode, api) -> Dict[int, List[Dict]]:
284
343
  return definitions
285
344
 
286
345
 
287
- def markdown_to_doc(markdown_content: str, api=None) -> List[Dict]:
346
+ def markdown_to_doc(
347
+ markdown_content: str, api=None, track_markers: Optional[List] = None
348
+ ) -> List[Dict]:
288
349
  """Convert Markdown into a list of Substack ProseMirror block nodes."""
289
350
  tree = SyntaxTreeNode(_make_parser().parse(markdown_content))
290
351
 
@@ -292,10 +353,39 @@ def markdown_to_doc(markdown_content: str, api=None) -> List[Dict]:
292
353
 
293
354
  ctx: Dict = {"order": []}
294
355
  out: List[Dict] = []
356
+ last_was_image = False
357
+ last_image_idx: Optional[int] = None
358
+
295
359
  for node in tree.children:
296
360
  if node.type == "footnote_block":
361
+ last_was_image = False
297
362
  continue
298
- out.extend(_render_block(node, api, ctx))
363
+ if node.type == "html_block":
364
+ marker = parse_image_marker(node.content)
365
+ if marker is not None:
366
+ if not last_was_image or last_image_idx is None:
367
+ raise ValueError("Image marker must immediately follow an image")
368
+ last_was_image = False
369
+ out[last_image_idx]["content"][0]["attrs"].update(marker["attrs"])
370
+ out[last_image_idx]["content"][0]["attrs"]["isProcessing"] = False
371
+ if track_markers is not None:
372
+ track_markers[-1] = marker
373
+ continue
374
+ last_was_image = False
375
+
376
+ rendered = _render_block(node, api, ctx)
377
+ if rendered:
378
+ for blk in rendered:
379
+ out.append(blk)
380
+ if blk.get("type") == NodeType.CAPTIONED_IMAGE:
381
+ last_was_image = True
382
+ last_image_idx = len(out) - 1
383
+ if track_markers is not None:
384
+ track_markers.append(None)
385
+ else:
386
+ last_was_image = False
387
+ else:
388
+ last_was_image = False
299
389
 
300
390
  # Emit one footnote block per reference, in anchor order, numbered to match.
301
391
  for number, footnote_id in enumerate(ctx["order"], start=1):
File without changes