python-substack 0.8.0__py3-none-any.whl → 0.9.1__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.
- {python_substack-0.8.0.dist-info → python_substack-0.9.1.dist-info}/METADATA +17 -5
- python_substack-0.9.1.dist-info/RECORD +14 -0
- substack/__init__.py +1 -1
- substack/api.py +102 -2
- substack/cli.py +4 -0
- substack/mdexport.py +17 -2
- substack/mdrender.py +102 -12
- python_substack-0.8.0.dist-info/RECORD +0 -14
- {python_substack-0.8.0.dist-info → python_substack-0.9.1.dist-info}/WHEEL +0 -0
- {python_substack-0.8.0.dist-info → python_substack-0.9.1.dist-info}/entry_points.txt +0 -0
- {python_substack-0.8.0.dist-info → python_substack-0.9.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-substack
|
|
3
|
-
Version: 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
|
-
- [
|
|
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)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
substack/__init__.py,sha256=FNVZvkchlc56ph-fm0NUca2FHQpLFmwV_o2Umz_nNjQ,450
|
|
2
|
+
substack/api.py,sha256=hnzIHC_t6VOjEzOCSngg9uaTQacyLtBYUlS3uQfbrJk,34830
|
|
3
|
+
substack/cli.py,sha256=56BTWxfr3gpAaJOLJsKRG7k6PPT7xSgLdp632H4mezA,26292
|
|
4
|
+
substack/exceptions.py,sha256=Y61XSPyxDxXPbVlioFvKhb94MRY0tpD389F5YdL1rGM,1390
|
|
5
|
+
substack/mdexport.py,sha256=Tzkav3mnF9eIUVHUqUzEQkOTiJsLYde1Xi2AcE_cUdk,14017
|
|
6
|
+
substack/mdrender.py,sha256=xRJIO4_6gcQ9jwLFm32KByi7bRmOJyowqF41GmYCA7A,15450
|
|
7
|
+
substack/nodes.py,sha256=fTsGO0-lTztcXiloIjXrHVnJes9EcltGcrX-mEV2ceQ,5004
|
|
8
|
+
substack/post.py,sha256=qDXu-xzO3nRXtahc-yG2EhxKAXB2hG_Z1nLa55UMBGg,19697
|
|
9
|
+
substack_mcp/mcp_server.py,sha256=M5XH3C4iWjzvCw3uOPbY2NFljTQAl8FLt14RKaTzka0,11324
|
|
10
|
+
python_substack-0.9.1.dist-info/METADATA,sha256=pDBPPtbe1r5CKfn2dC-qAsH0l0SuCGwGtFd9sPl3jmM,9208
|
|
11
|
+
python_substack-0.9.1.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
12
|
+
python_substack-0.9.1.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
|
|
13
|
+
python_substack-0.9.1.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
14
|
+
python_substack-0.9.1.dist-info/RECORD,,
|
substack/__init__.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
__author__ = "Paolo Mazza"
|
|
4
4
|
__email__ = "mazzapaolo2019@gmail.com"
|
|
5
5
|
__license__ = "MIT License"
|
|
6
|
-
__version__ = "0.
|
|
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__ = (
|
substack/api.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
substack/cli.py
CHANGED
|
@@ -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
|
|
substack/mdexport.py
CHANGED
|
@@ -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):
|
substack/mdrender.py
CHANGED
|
@@ -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(
|
|
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
|
|
177
|
-
src
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 `` 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(
|
|
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
|
-
|
|
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):
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
substack/__init__.py,sha256=WVrmiMIx9EAaWFyqP7Bd9794hRFTK9-Hh0d3YsyU1Ps,450
|
|
2
|
-
substack/api.py,sha256=-qJiMrgnYTWuBpM6RdGJQLstuNGSPPaiO6VjH6z0cyg,30585
|
|
3
|
-
substack/cli.py,sha256=L0loIUXhL7d9yZRc8N8xJ7xuudLp4D0ToIyrAbFeVK0,26009
|
|
4
|
-
substack/exceptions.py,sha256=Y61XSPyxDxXPbVlioFvKhb94MRY0tpD389F5YdL1rGM,1390
|
|
5
|
-
substack/mdexport.py,sha256=_pSjioPJIA1wuJ6hYpvODZVswPSUROuzEndVoYnv-yU,13541
|
|
6
|
-
substack/mdrender.py,sha256=BsuwNv0UhXLDBrSN5riLqpCjsGz59ezhv8QwyVSgXh0,11470
|
|
7
|
-
substack/nodes.py,sha256=fTsGO0-lTztcXiloIjXrHVnJes9EcltGcrX-mEV2ceQ,5004
|
|
8
|
-
substack/post.py,sha256=qDXu-xzO3nRXtahc-yG2EhxKAXB2hG_Z1nLa55UMBGg,19697
|
|
9
|
-
substack_mcp/mcp_server.py,sha256=M5XH3C4iWjzvCw3uOPbY2NFljTQAl8FLt14RKaTzka0,11324
|
|
10
|
-
python_substack-0.8.0.dist-info/METADATA,sha256=EctUjZG8YTJove4oWmeaAo1c_Z3L9WmLiZAytrQBdD4,8689
|
|
11
|
-
python_substack-0.8.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
12
|
-
python_substack-0.8.0.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
|
|
13
|
-
python_substack-0.8.0.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
14
|
-
python_substack-0.8.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|