python-substack 0.9.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.9.0.dist-info → python_substack-0.9.1.dist-info}/METADATA +2 -4
- python_substack-0.9.1.dist-info/RECORD +14 -0
- substack/__init__.py +1 -1
- substack/api.py +73 -13
- substack/mdexport.py +9 -9
- substack/mdrender.py +78 -37
- python_substack-0.9.0.dist-info/RECORD +0 -14
- {python_substack-0.9.0.dist-info → python_substack-0.9.1.dist-info}/WHEEL +0 -0
- {python_substack-0.9.0.dist-info → python_substack-0.9.1.dist-info}/entry_points.txt +0 -0
- {python_substack-0.9.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.9.
|
|
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
|
|
@@ -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.9.
|
|
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
|
@@ -500,6 +500,7 @@ class Api:
|
|
|
500
500
|
"""
|
|
501
501
|
Update an existing draft body from Markdown, with optional metadata changes.
|
|
502
502
|
"""
|
|
503
|
+
from substack import mdrender
|
|
503
504
|
from substack.mdexport import document_to_markdown
|
|
504
505
|
from substack.post import Post
|
|
505
506
|
|
|
@@ -517,6 +518,7 @@ class Api:
|
|
|
517
518
|
|
|
518
519
|
_, remote_unsupported = document_to_markdown(draft_body)
|
|
519
520
|
|
|
521
|
+
image_markers = []
|
|
520
522
|
post = Post(
|
|
521
523
|
title=draft.get("title", ""),
|
|
522
524
|
subtitle=(
|
|
@@ -526,30 +528,88 @@ class Api:
|
|
|
526
528
|
audience=audience,
|
|
527
529
|
write_comment_permissions=write_comment_permissions,
|
|
528
530
|
)
|
|
529
|
-
|
|
531
|
+
rendered = mdrender.markdown_to_doc(
|
|
532
|
+
markdown, api=self, track_markers=image_markers
|
|
533
|
+
)
|
|
534
|
+
post.draft_body["content"] = rendered
|
|
530
535
|
|
|
531
536
|
remote_images = _image_attrs(draft_body)
|
|
532
537
|
submitted_images = _image_attrs(post.draft_body)
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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]
|
|
540
570
|
source, alt, href = (
|
|
541
571
|
submitted.get("src"),
|
|
542
572
|
submitted.get("alt"),
|
|
543
573
|
submitted.get("href"),
|
|
544
574
|
)
|
|
545
|
-
submitted.update(
|
|
575
|
+
submitted.update(remote_img)
|
|
576
|
+
submitted.update(marker["attrs"])
|
|
546
577
|
submitted.update(
|
|
547
578
|
{"src": source, "alt": alt, "href": href, "isProcessing": False}
|
|
548
579
|
)
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
+
)
|
|
553
613
|
|
|
554
614
|
_, submitted_unsupported = document_to_markdown(post.draft_body)
|
|
555
615
|
|
substack/mdexport.py
CHANGED
|
@@ -58,18 +58,19 @@ def _marker(node, unsupported):
|
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
def _image_marker(attrs):
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
},
|
|
65
68
|
}
|
|
66
|
-
if not preserved:
|
|
67
|
-
return ""
|
|
68
69
|
payload = json.dumps(
|
|
69
|
-
|
|
70
|
+
payload_data, ensure_ascii=False, separators=(",", ":"), sort_keys=True
|
|
70
71
|
).encode("utf-8")
|
|
71
72
|
encoded = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
|
|
72
|
-
return f"\n<!-- python-substack-image:
|
|
73
|
+
return f"\n<!-- python-substack-image:v2 {encoded} -->"
|
|
73
74
|
|
|
74
75
|
|
|
75
76
|
def _require_dict(node, context):
|
|
@@ -229,7 +230,6 @@ def _render_image(node, unsupported):
|
|
|
229
230
|
image["type"] != "image2"
|
|
230
231
|
or _has_unknown_keys(node, {"type", "content"})
|
|
231
232
|
or _has_unknown_keys(image, {"type", "attrs"})
|
|
232
|
-
or _has_unknown_keys(attrs, _IMAGE_ATTRS)
|
|
233
233
|
or not isinstance(attrs.get("src"), str)
|
|
234
234
|
):
|
|
235
235
|
return _marker(node, unsupported)
|
substack/mdrender.py
CHANGED
|
@@ -45,7 +45,7 @@ _MARK_FOR = {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
def parse_node_marker(comment_content: str) -> dict | None:
|
|
48
|
+
def parse_node_marker(comment_content: str) -> dict | None:
|
|
49
49
|
"""
|
|
50
50
|
Parse a python-substack-node:v1 comment marker and return the parsed JSON dictionary.
|
|
51
51
|
|
|
@@ -88,29 +88,45 @@ def parse_node_marker(comment_content: str) -> dict | None:
|
|
|
88
88
|
if not isinstance(node_type, str) or not node_type:
|
|
89
89
|
raise ValueError("Marker payload has missing or empty 'type'")
|
|
90
90
|
|
|
91
|
-
return data
|
|
92
|
-
|
|
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\s+([A-Za-z0-9_-]+=*)\s*-->$", clean
|
|
98
|
-
)
|
|
99
|
-
if not match:
|
|
100
|
-
if "python-substack-image:
|
|
101
|
-
raise ValueError("Corrupt image marker format")
|
|
102
|
-
return None
|
|
103
|
-
encoded = match.group(1)
|
|
104
|
-
encoded += "=" * (-len(encoded) % 4)
|
|
105
|
-
try:
|
|
106
|
-
|
|
107
|
-
except Exception as exc:
|
|
108
|
-
raise ValueError("Invalid image marker") from exc
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
raise ValueError("Invalid image marker
|
|
113
|
-
|
|
91
|
+
return data
|
|
92
|
+
|
|
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
|
|
114
130
|
|
|
115
131
|
|
|
116
132
|
def _make_parser() -> MarkdownIt:
|
|
@@ -177,6 +193,8 @@ def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[D
|
|
|
177
193
|
if alt:
|
|
178
194
|
out.append(nodes.text(alt, marks))
|
|
179
195
|
elif t == "html_inline":
|
|
196
|
+
if "python-substack-image:" in child.content:
|
|
197
|
+
raise ValueError("Image marker must immediately follow an image")
|
|
180
198
|
marker_data = parse_node_marker(child.content)
|
|
181
199
|
if marker_data is not None:
|
|
182
200
|
out.append(marker_data)
|
|
@@ -325,7 +343,9 @@ def _footnote_definitions(tree: SyntaxTreeNode, api) -> Dict[int, List[Dict]]:
|
|
|
325
343
|
return definitions
|
|
326
344
|
|
|
327
345
|
|
|
328
|
-
def markdown_to_doc(
|
|
346
|
+
def markdown_to_doc(
|
|
347
|
+
markdown_content: str, api=None, track_markers: Optional[List] = None
|
|
348
|
+
) -> List[Dict]:
|
|
329
349
|
"""Convert Markdown into a list of Substack ProseMirror block nodes."""
|
|
330
350
|
tree = SyntaxTreeNode(_make_parser().parse(markdown_content))
|
|
331
351
|
|
|
@@ -333,18 +353,39 @@ def markdown_to_doc(markdown_content: str, api=None) -> List[Dict]:
|
|
|
333
353
|
|
|
334
354
|
ctx: Dict = {"order": []}
|
|
335
355
|
out: List[Dict] = []
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
356
|
+
last_was_image = False
|
|
357
|
+
last_image_idx: Optional[int] = None
|
|
358
|
+
|
|
359
|
+
for node in tree.children:
|
|
360
|
+
if node.type == "footnote_block":
|
|
361
|
+
last_was_image = False
|
|
362
|
+
continue
|
|
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
|
|
348
389
|
|
|
349
390
|
# Emit one footnote block per reference, in anchor order, numbered to match.
|
|
350
391
|
for number, footnote_id in enumerate(ctx["order"], start=1):
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
substack/__init__.py,sha256=W0VG-klGuBHRwwQxQWEdvxB68z6YVQMvEAWO3yNn8bY,449
|
|
2
|
-
substack/api.py,sha256=k6ympBEJnwE3Gy0n2qGYTM4d26As1--SZTgZQWl7PzU,32176
|
|
3
|
-
substack/cli.py,sha256=56BTWxfr3gpAaJOLJsKRG7k6PPT7xSgLdp632H4mezA,26292
|
|
4
|
-
substack/exceptions.py,sha256=Y61XSPyxDxXPbVlioFvKhb94MRY0tpD389F5YdL1rGM,1390
|
|
5
|
-
substack/mdexport.py,sha256=lg2DCTHWRa_w7O2eO6ks1Iif2A7_tphDIayI0w_yxKk,14037
|
|
6
|
-
substack/mdrender.py,sha256=Bq0VXs0B3cC2fpKzyRNxApM-ZbK_-pC9wPvn1HIanHw,13666
|
|
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.0.dist-info/METADATA,sha256=1S7eMKYmbI8AMKG5rruRyRcUWESxELT6XStsQQeRCsY,9299
|
|
11
|
-
python_substack-0.9.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
12
|
-
python_substack-0.9.0.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
|
|
13
|
-
python_substack-0.9.0.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
14
|
-
python_substack-0.9.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|