python-substack 0.7.0__py3-none-any.whl → 0.9.0__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.7.0.dist-info → python_substack-0.9.0.dist-info}/METADATA +16 -2
- python_substack-0.9.0.dist-info/RECORD +14 -0
- substack/__init__.py +1 -1
- substack/api.py +62 -7
- substack/cli.py +9 -0
- substack/mdexport.py +16 -1
- substack/mdrender.py +349 -243
- python_substack-0.7.0.dist-info/RECORD +0 -14
- {python_substack-0.7.0.dist-info → python_substack-0.9.0.dist-info}/WHEEL +0 -0
- {python_substack-0.7.0.dist-info → python_substack-0.9.0.dist-info}/entry_points.txt +0 -0
- {python_substack-0.7.0.dist-info → python_substack-0.9.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-substack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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
|
|
@@ -94,6 +94,7 @@ Substack result:
|
|
|
94
94
|
- Set audience, comment permissions, SEO metadata, slug, sections, and tags.
|
|
95
95
|
- List and inspect publications and drafts.
|
|
96
96
|
- Export drafts to loss-aware Markdown backups without server writes.
|
|
97
|
+
- Safely update drafts from Markdown, preserving unsupported editor widgets by default.
|
|
97
98
|
- Schedule, unschedule, publish, and delete drafts with explicit safeguards.
|
|
98
99
|
- Use stable JSON envelopes in scripts and automation.
|
|
99
100
|
- Authenticate with browser cookies or email and password.
|
|
@@ -145,6 +146,7 @@ substack publications list
|
|
|
145
146
|
substack drafts list --limit 10
|
|
146
147
|
substack drafts get 12345
|
|
147
148
|
substack drafts export 12345 --output backup.md
|
|
149
|
+
substack drafts update 12345 revised-post.md --dry-run --yes
|
|
148
150
|
substack --publication-url https://example.substack.com drafts list
|
|
149
151
|
```
|
|
150
152
|
|
|
@@ -217,6 +219,17 @@ print(backup["markdown"])
|
|
|
217
219
|
print(backup["unsupported_nodes"])
|
|
218
220
|
```
|
|
219
221
|
|
|
222
|
+
Review a Markdown update before writing it, then update deliberately:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
result = api.update_draft_from_markdown(12345, "# Revised draft", dry_run=True)
|
|
226
|
+
result = api.update_draft_from_markdown(12345, "# Revised draft")
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Unsupported editor widgets exported as `python-substack-node:v1` markers are
|
|
230
|
+
preserved by default. An update refuses to remove or alter them unless
|
|
231
|
+
`allow_unsupported_change=True` is explicitly passed.
|
|
232
|
+
|
|
220
233
|
For direct ProseMirror node construction, see the
|
|
221
234
|
[low-level Python API](docs/low-level-api.md). YAML workflows are documented in
|
|
222
235
|
[YAML drafts](docs/yaml.md).
|
|
@@ -269,11 +282,12 @@ See [MCP server](docs/mcp.md) for the tool list and safety notes.
|
|
|
269
282
|
- [Python SDK](docs/python-sdk.md)
|
|
270
283
|
- [Authentication](docs/authentication.md)
|
|
271
284
|
- [Markdown reference](docs/markdown.md)
|
|
285
|
+
- [Draft backup and safe updates](docs/cli.md)
|
|
272
286
|
- [Legacy CLI commands](docs/legacy-cli.md)
|
|
273
287
|
- [Low-level Python API](docs/low-level-api.md)
|
|
274
288
|
- [YAML drafts](docs/yaml.md)
|
|
275
289
|
- [MCP server](docs/mcp.md)
|
|
276
|
-
- [
|
|
290
|
+
- [Agent workflow](docs/agents.md)
|
|
277
291
|
- [Safety and publishing behavior](docs/safety.md)
|
|
278
292
|
- [Troubleshooting](docs/troubleshooting.md)
|
|
279
293
|
- [Compatibility policy](docs/compatibility.md)
|
|
@@ -0,0 +1,14 @@
|
|
|
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,,
|
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.0"
|
|
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
|
"""
|
|
@@ -478,6 +494,8 @@ class Api:
|
|
|
478
494
|
draft_section_id: int = None,
|
|
479
495
|
tags=None,
|
|
480
496
|
dry_run: bool = False,
|
|
497
|
+
allow_unsupported_change: bool = False,
|
|
498
|
+
allow_image_replacement: bool = False,
|
|
481
499
|
) -> dict:
|
|
482
500
|
"""
|
|
483
501
|
Update an existing draft body from Markdown, with optional metadata changes.
|
|
@@ -497,12 +515,7 @@ class Api:
|
|
|
497
515
|
if not isinstance(draft_body, dict):
|
|
498
516
|
raise ValueError("Malformed draft body: draft_body must be a JSON object")
|
|
499
517
|
|
|
500
|
-
_,
|
|
501
|
-
if unsupported_nodes:
|
|
502
|
-
raise ValueError(
|
|
503
|
-
"Refusing to update: remote draft contains unsupported Substack nodes. "
|
|
504
|
-
"Export it first or remove the nodes manually to avoid data loss."
|
|
505
|
-
)
|
|
518
|
+
_, remote_unsupported = document_to_markdown(draft_body)
|
|
506
519
|
|
|
507
520
|
post = Post(
|
|
508
521
|
title=draft.get("title", ""),
|
|
@@ -515,6 +528,48 @@ class Api:
|
|
|
515
528
|
)
|
|
516
529
|
post.from_markdown(markdown, api=self)
|
|
517
530
|
|
|
531
|
+
remote_images = _image_attrs(draft_body)
|
|
532
|
+
submitted_images = _image_attrs(post.draft_body)
|
|
533
|
+
for submitted in submitted_images:
|
|
534
|
+
matches = [
|
|
535
|
+
image
|
|
536
|
+
for image in remote_images
|
|
537
|
+
if image.get("src") == submitted.get("src")
|
|
538
|
+
]
|
|
539
|
+
if len(matches) == 1:
|
|
540
|
+
source, alt, href = (
|
|
541
|
+
submitted.get("src"),
|
|
542
|
+
submitted.get("alt"),
|
|
543
|
+
submitted.get("href"),
|
|
544
|
+
)
|
|
545
|
+
submitted.update(matches[0])
|
|
546
|
+
submitted.update(
|
|
547
|
+
{"src": source, "alt": alt, "href": href, "isProcessing": False}
|
|
548
|
+
)
|
|
549
|
+
elif not allow_image_replacement:
|
|
550
|
+
raise ValueError(
|
|
551
|
+
"Refusing to update: image cannot be matched uniquely; set allow_image_replacement=True to proceed."
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
_, submitted_unsupported = document_to_markdown(post.draft_body)
|
|
555
|
+
|
|
556
|
+
if not allow_unsupported_change:
|
|
557
|
+
remote_serialized = [
|
|
558
|
+
json.dumps(n, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
|
559
|
+
for n in remote_unsupported
|
|
560
|
+
]
|
|
561
|
+
submitted_serialized = [
|
|
562
|
+
json.dumps(n, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
|
563
|
+
for n in submitted_unsupported
|
|
564
|
+
]
|
|
565
|
+
if sorted(remote_serialized) != sorted(submitted_serialized):
|
|
566
|
+
raise ValueError(
|
|
567
|
+
"Refusing to update: remote unsupported nodes are missing, duplicated, stale, or altered. "
|
|
568
|
+
"Export the draft first or set allow_unsupported_change=True to proceed."
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
unsupported_nodes = remote_unsupported
|
|
572
|
+
|
|
518
573
|
update_payload = {"draft_body": json.dumps(post.draft_body)}
|
|
519
574
|
|
|
520
575
|
if subtitle is not None:
|
substack/cli.py
CHANGED
|
@@ -310,6 +310,11 @@ def _drafts_create(api, args):
|
|
|
310
310
|
|
|
311
311
|
|
|
312
312
|
def _drafts_update(api, args):
|
|
313
|
+
if args.allow_unsupported_change and not args.yes:
|
|
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")
|
|
317
|
+
|
|
313
318
|
if not args.yes and (args.json_output or not sys.stdin.isatty()):
|
|
314
319
|
raise CLIUsageError("--yes is required in non-interactive or JSON mode")
|
|
315
320
|
|
|
@@ -347,6 +352,8 @@ def _drafts_update(api, args):
|
|
|
347
352
|
draft_section_id=args.draft_section_id,
|
|
348
353
|
tags=args.tags,
|
|
349
354
|
dry_run=args.dry_run,
|
|
355
|
+
allow_unsupported_change=args.allow_unsupported_change,
|
|
356
|
+
allow_image_replacement=args.allow_image_replacement,
|
|
350
357
|
)
|
|
351
358
|
|
|
352
359
|
if args.json_output:
|
|
@@ -508,6 +515,8 @@ def _build_parser():
|
|
|
508
515
|
drafts_update.add_argument("--draft-section-id", type=int)
|
|
509
516
|
drafts_update.add_argument("--tag", action="append", dest="tags", metavar="TAG")
|
|
510
517
|
drafts_update.add_argument("--dry-run", action="store_true")
|
|
518
|
+
drafts_update.add_argument("--allow-unsupported-change", action="store_true")
|
|
519
|
+
drafts_update.add_argument("--allow-image-replacement", action="store_true")
|
|
511
520
|
drafts_update.add_argument("--yes", action="store_true")
|
|
512
521
|
drafts_update.set_defaults(handler=_drafts_update)
|
|
513
522
|
|
substack/mdexport.py
CHANGED
|
@@ -57,6 +57,21 @@ def _marker(node, unsupported):
|
|
|
57
57
|
return f"<!-- python-substack-node:v1 {encoded} -->"
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def _image_marker(attrs):
|
|
61
|
+
preserved = {
|
|
62
|
+
key: value
|
|
63
|
+
for key, value in attrs.items()
|
|
64
|
+
if key not in {"src", "alt", "href", "isProcessing"}
|
|
65
|
+
}
|
|
66
|
+
if not preserved:
|
|
67
|
+
return ""
|
|
68
|
+
payload = json.dumps(
|
|
69
|
+
preserved, ensure_ascii=False, separators=(",", ":"), sort_keys=True
|
|
70
|
+
).encode("utf-8")
|
|
71
|
+
encoded = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
|
|
72
|
+
return f"\n<!-- python-substack-image:v1 {encoded} -->"
|
|
73
|
+
|
|
74
|
+
|
|
60
75
|
def _require_dict(node, context):
|
|
61
76
|
if not isinstance(node, dict):
|
|
62
77
|
raise ValueError(f"Malformed draft body: {context} must be an object")
|
|
@@ -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
|
@@ -1,248 +1,354 @@
|
|
|
1
|
-
"""Markdown -> Substack ProseMirror via markdown-it-py.
|
|
2
|
-
|
|
3
|
-
Implements Post.from_markdown() using a real CommonMark parser (markdown-it-py)
|
|
4
|
-
plus the standard footnote plugin, with a small renderer that walks the syntax
|
|
5
|
-
tree into Substack's node schema.
|
|
6
|
-
|
|
7
|
-
Node construction goes through ``substack.nodes`` so the (undocumented) schema
|
|
8
|
-
lives in exactly one place.
|
|
9
|
-
|
|
10
|
-
Footnotes: Substack numbers footnote anchors by their position in the document
|
|
11
|
-
and pairs them one-to-one, in order, with the footnote blocks at the end (it
|
|
12
|
-
ignores any explicit number and does not support one block serving several
|
|
13
|
-
anchors). So each reference is emitted as its own sequentially-numbered anchor,
|
|
14
|
-
and a matching footnote block is appended for each -- a definition referenced
|
|
15
|
-
more than once is duplicated, which mirrors how Substack's own editor behaves.
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
from __future__ import annotations
|
|
19
|
-
|
|
20
|
-
import
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
from
|
|
25
|
-
from
|
|
26
|
-
from
|
|
27
|
-
|
|
28
|
-
from
|
|
29
|
-
from
|
|
30
|
-
|
|
31
|
-
from
|
|
32
|
-
from
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
1
|
+
"""Markdown -> Substack ProseMirror via markdown-it-py.
|
|
2
|
+
|
|
3
|
+
Implements Post.from_markdown() using a real CommonMark parser (markdown-it-py)
|
|
4
|
+
plus the standard footnote plugin, with a small renderer that walks the syntax
|
|
5
|
+
tree into Substack's node schema.
|
|
6
|
+
|
|
7
|
+
Node construction goes through ``substack.nodes`` so the (undocumented) schema
|
|
8
|
+
lives in exactly one place.
|
|
9
|
+
|
|
10
|
+
Footnotes: Substack numbers footnote anchors by their position in the document
|
|
11
|
+
and pairs them one-to-one, in order, with the footnote blocks at the end (it
|
|
12
|
+
ignores any explicit number and does not support one block serving several
|
|
13
|
+
anchors). So each reference is emitted as its own sequentially-numbered anchor,
|
|
14
|
+
and a matching footnote block is appended for each -- a definition referenced
|
|
15
|
+
more than once is duplicated, which mirrors how Substack's own editor behaves.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import copy
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Dict, List, Optional
|
|
26
|
+
from urllib.parse import unquote, urlsplit
|
|
27
|
+
|
|
28
|
+
from markdown_it import MarkdownIt
|
|
29
|
+
from markdown_it.tree import SyntaxTreeNode
|
|
30
|
+
from mdit_py_plugins.container import container_plugin
|
|
31
|
+
from mdit_py_plugins.dollarmath import dollarmath_plugin
|
|
32
|
+
from mdit_py_plugins.footnote import footnote_plugin
|
|
33
|
+
from mdit_py_plugins.subscript import sub_plugin
|
|
34
|
+
from mdit_py_plugins.superscript import superscript_plugin
|
|
35
|
+
|
|
36
|
+
from substack import nodes
|
|
37
|
+
from substack.nodes import MarkType, NodeType
|
|
38
|
+
|
|
39
|
+
_MARK_FOR = {
|
|
40
|
+
"strong": {"type": MarkType.STRONG},
|
|
41
|
+
"em": {"type": MarkType.EM},
|
|
42
|
+
"s": {"type": MarkType.STRIKETHROUGH},
|
|
43
|
+
"sup": {"type": MarkType.SUPERSCRIPT},
|
|
44
|
+
"sub": {"type": MarkType.SUBSCRIPT},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_node_marker(comment_content: str) -> dict | None:
|
|
49
|
+
"""
|
|
50
|
+
Parse a python-substack-node:v1 comment marker and return the parsed JSON dictionary.
|
|
51
|
+
|
|
52
|
+
If it is not a python-substack-node:v1 marker, returns None.
|
|
53
|
+
If it is an attempted marker but is corrupt/malformed, raises ValueError.
|
|
54
|
+
"""
|
|
55
|
+
clean = comment_content.strip()
|
|
56
|
+
match = re.match(
|
|
57
|
+
r"^<!--\s*python-substack-node:v1\s+([A-Za-z0-9_-]+=*)\s*-->$", clean
|
|
58
|
+
)
|
|
59
|
+
if not match:
|
|
60
|
+
if "python-substack-node:v1" in clean:
|
|
61
|
+
raise ValueError("Corrupt marker format")
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
encoded = match.group(1)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
padding = len(encoded) % 4
|
|
68
|
+
if padding:
|
|
69
|
+
encoded += "=" * (4 - padding)
|
|
70
|
+
decoded_bytes = base64.urlsafe_b64decode(encoded.encode("ascii"))
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
raise ValueError("Invalid URL-safe base64 in marker") from exc
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
decoded_str = decoded_bytes.decode("utf-8")
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
raise ValueError("Invalid UTF-8 in marker") from exc
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
data = json.loads(decoded_str)
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
raise ValueError("Invalid JSON in marker") from exc
|
|
83
|
+
|
|
84
|
+
if not isinstance(data, dict):
|
|
85
|
+
raise ValueError("Marker payload is not a top-level JSON object")
|
|
86
|
+
|
|
87
|
+
node_type = data.get("type")
|
|
88
|
+
if not isinstance(node_type, str) or not node_type:
|
|
89
|
+
raise ValueError("Marker payload has missing or empty 'type'")
|
|
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
|
|
56
98
|
)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
#
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
99
|
+
if not match:
|
|
100
|
+
if "python-substack-image:v1" in clean:
|
|
101
|
+
raise ValueError("Corrupt image marker format")
|
|
102
|
+
return None
|
|
103
|
+
encoded = match.group(1)
|
|
104
|
+
encoded += "=" * (-len(encoded) % 4)
|
|
105
|
+
try:
|
|
106
|
+
attrs = json.loads(base64.urlsafe_b64decode(encoded.encode("ascii")))
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
raise ValueError("Invalid image marker") from exc
|
|
109
|
+
if not isinstance(attrs, dict) or any(
|
|
110
|
+
key in {"src", "alt", "href", "isProcessing"} for key in attrs
|
|
111
|
+
):
|
|
112
|
+
raise ValueError("Invalid image marker attributes")
|
|
113
|
+
return attrs
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _make_parser() -> MarkdownIt:
|
|
117
|
+
return (
|
|
118
|
+
MarkdownIt("commonmark")
|
|
119
|
+
.use(footnote_plugin)
|
|
120
|
+
# Pandoc-style delimiters: no whitespace just inside the dollars and no
|
|
121
|
+
# digit just outside them, so paired currency amounts ("$5 ... $10")
|
|
122
|
+
# stay plain text instead of becoming math.
|
|
123
|
+
.use(dollarmath_plugin, allow_space=False, allow_digits=False)
|
|
124
|
+
.use(sub_plugin)
|
|
125
|
+
.use(superscript_plugin)
|
|
126
|
+
.use(container_plugin, name="pullquote")
|
|
127
|
+
.use(container_plugin, name="callout")
|
|
128
|
+
.enable("strikethrough")
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _coalesce(out_nodes: List[Dict]) -> List[Dict]:
|
|
133
|
+
"""Merge adjacent text nodes that carry identical marks (e.g. softbreaks)."""
|
|
134
|
+
merged: List[Dict] = []
|
|
135
|
+
for node in out_nodes:
|
|
136
|
+
if (
|
|
137
|
+
merged
|
|
138
|
+
and node.get("type") == NodeType.TEXT
|
|
139
|
+
and merged[-1].get("type") == NodeType.TEXT
|
|
140
|
+
and node.get("marks") == merged[-1].get("marks")
|
|
141
|
+
):
|
|
142
|
+
merged[-1]["text"] += node["text"]
|
|
143
|
+
else:
|
|
144
|
+
merged.append(node)
|
|
145
|
+
return merged
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[Dict]:
|
|
149
|
+
"""Render an inline subtree into a flat list of text / anchor nodes."""
|
|
150
|
+
out: List[Dict] = []
|
|
151
|
+
for child in node.children:
|
|
152
|
+
t = child.type
|
|
153
|
+
if t == "text":
|
|
154
|
+
if child.content:
|
|
155
|
+
out.append(nodes.text(child.content, marks))
|
|
156
|
+
elif t == "code_inline":
|
|
157
|
+
out.append(nodes.text(child.content, marks + [nodes.code_mark()]))
|
|
158
|
+
elif t == "math_inline":
|
|
159
|
+
out.append(nodes.latex_inline(child.content.strip()))
|
|
160
|
+
elif t in _MARK_FOR:
|
|
161
|
+
out.extend(_render_inline(child, marks + [_MARK_FOR[t]], ctx))
|
|
162
|
+
elif t == "link":
|
|
163
|
+
href = child.attrs.get("href", "")
|
|
164
|
+
out.extend(_render_inline(child, marks + [nodes.link_mark(href)], ctx))
|
|
165
|
+
elif t in ("softbreak", "hardbreak"):
|
|
166
|
+
out.append(nodes.text(" ", marks))
|
|
167
|
+
elif t == "footnote_ref":
|
|
168
|
+
# Number anchors by document position and record which definition each
|
|
169
|
+
# one points to, so matching blocks can be emitted 1:1 afterwards.
|
|
170
|
+
ctx["order"].append(child.meta["id"])
|
|
171
|
+
out.append(nodes.footnote_anchor(len(ctx["order"])))
|
|
172
|
+
elif t == "image":
|
|
173
|
+
# Inline images are rare in this schema; fall back to alt text.
|
|
174
|
+
alt = child.attrs.get("alt") or "".join(
|
|
175
|
+
c.content for c in child.children if c.type == "text"
|
|
176
|
+
)
|
|
177
|
+
if alt:
|
|
178
|
+
out.append(nodes.text(alt, marks))
|
|
179
|
+
elif t == "html_inline":
|
|
180
|
+
marker_data = parse_node_marker(child.content)
|
|
181
|
+
if marker_data is not None:
|
|
182
|
+
out.append(marker_data)
|
|
183
|
+
return _coalesce(out)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _only_image(inline: SyntaxTreeNode) -> Optional[SyntaxTreeNode]:
|
|
187
|
+
"""If an inline node is just an image (optionally wrapped in a link), return it."""
|
|
188
|
+
kids = [c for c in inline.children if c.type != "softbreak"]
|
|
189
|
+
if len(kids) == 1 and kids[0].type == "image":
|
|
190
|
+
return kids[0]
|
|
191
|
+
if len(kids) == 1 and kids[0].type == "link":
|
|
192
|
+
inner = [c for c in kids[0].children if c.type != "softbreak"]
|
|
193
|
+
if len(inner) == 1 and inner[0].type == "image":
|
|
194
|
+
img = inner[0]
|
|
195
|
+
img._link_href = kids[0].attrs.get("href") # type: ignore[attr-defined]
|
|
196
|
+
return img
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _captioned_image(img: SyntaxTreeNode, api) -> Dict:
|
|
201
|
+
src = img.attrs.get("src", "")
|
|
202
|
+
if api is not None and (
|
|
203
|
+
urlsplit(src).scheme.lower() not in ("http", "https")
|
|
204
|
+
and not src.startswith("//")
|
|
205
|
+
):
|
|
206
|
+
# Markdown destinations encode spaces and non-ASCII characters as URLs.
|
|
207
|
+
path = Path(unquote(src)).expanduser()
|
|
208
|
+
if not path.is_file() and src.startswith("/"):
|
|
209
|
+
# Preserve legacy root-relative asset paths only when they resolve
|
|
210
|
+
# to a real file; an existing absolute path always takes precedence.
|
|
211
|
+
relative = Path(unquote(src[1:]))
|
|
212
|
+
if relative.is_file():
|
|
213
|
+
path = relative
|
|
214
|
+
if not path.is_file():
|
|
215
|
+
raise FileNotFoundError(f"Local image file not found: {path}")
|
|
216
|
+
try:
|
|
217
|
+
uploaded = api.get_image(str(path))
|
|
218
|
+
url = uploaded.get("url")
|
|
219
|
+
if not isinstance(url, str) or not url.startswith(("https://", "http://")):
|
|
220
|
+
raise ValueError("Upload did not return an HTTP image URL")
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
raise ValueError(f"Failed to upload local image: {path}") from exc
|
|
223
|
+
src = url
|
|
224
|
+
# markdown-it stores the image alt text as the node's content, not in attrs.
|
|
225
|
+
alt = img.content or img.attrs.get("alt") or None
|
|
226
|
+
# Standard markdown image title `` maps to Substack's caption node.
|
|
227
|
+
title = img.attrs.get("title") or None
|
|
228
|
+
caption = [nodes.text(title)] if title else None
|
|
229
|
+
return nodes.captioned_image(
|
|
230
|
+
src,
|
|
231
|
+
alt=alt,
|
|
232
|
+
href=getattr(img, "_link_href", None),
|
|
233
|
+
caption=caption,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _render_block(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
|
|
238
|
+
"""Render a block-level node into zero or more Substack nodes."""
|
|
239
|
+
t = node.type
|
|
240
|
+
|
|
241
|
+
if t == "html_block":
|
|
242
|
+
marker_data = parse_node_marker(node.content)
|
|
243
|
+
if marker_data is not None:
|
|
244
|
+
return [marker_data]
|
|
245
|
+
return []
|
|
246
|
+
|
|
247
|
+
if t == "paragraph":
|
|
248
|
+
inline = node.children[0]
|
|
249
|
+
img = _only_image(inline)
|
|
250
|
+
if img is not None:
|
|
251
|
+
return [_captioned_image(img, api)]
|
|
252
|
+
return [nodes.paragraph(_render_inline(inline, [], ctx))]
|
|
253
|
+
|
|
254
|
+
if t == "heading":
|
|
255
|
+
level = int(node.tag[1])
|
|
256
|
+
return [nodes.heading(_render_inline(node.children[0], [], ctx), level=level)]
|
|
257
|
+
|
|
258
|
+
if t == "hr":
|
|
259
|
+
return [nodes.horizontal_rule()]
|
|
260
|
+
|
|
261
|
+
if t in ("fence", "code_block"):
|
|
262
|
+
return [
|
|
263
|
+
nodes.code_block(
|
|
264
|
+
node.content.rstrip("\n"), language=node.info.strip() or None
|
|
265
|
+
)
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
if t == "blockquote":
|
|
269
|
+
paras: List[Dict] = []
|
|
270
|
+
for child in node.children:
|
|
271
|
+
paras.extend(_render_block(child, api, ctx))
|
|
272
|
+
return [nodes.blockquote(paras)]
|
|
273
|
+
|
|
274
|
+
if t == "bullet_list":
|
|
275
|
+
return [nodes.bullet_list(_render_list_items(node, api, ctx))]
|
|
276
|
+
|
|
277
|
+
if t == "ordered_list":
|
|
278
|
+
return [nodes.ordered_list(_render_list_items(node, api, ctx))]
|
|
279
|
+
|
|
280
|
+
# "$$...$$ (label)" tokenizes as math_block_label; Substack has no equation
|
|
281
|
+
# labels, so it renders like an unlabeled block.
|
|
282
|
+
if t in ("math_block", "math_block_label"):
|
|
283
|
+
return [nodes.latex_block(node.content.strip())]
|
|
284
|
+
|
|
285
|
+
if t == "container_pullquote":
|
|
286
|
+
return [nodes.pullquote(_render_container_body(node, api, ctx))]
|
|
287
|
+
|
|
288
|
+
if t == "container_callout":
|
|
289
|
+
return [nodes.callout_block(_render_container_body(node, api, ctx))]
|
|
290
|
+
|
|
291
|
+
# footnote_block is handled separately in markdown_to_doc; ignore it here.
|
|
292
|
+
return []
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _render_container_body(node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
|
|
296
|
+
body: List[Dict] = []
|
|
297
|
+
for child in node.children:
|
|
298
|
+
body.extend(_render_block(child, api, ctx))
|
|
299
|
+
return body
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _render_list_items(list_node: SyntaxTreeNode, api, ctx: Dict) -> List[Dict]:
|
|
303
|
+
items = []
|
|
304
|
+
for li in list_node.children:
|
|
305
|
+
content: List[Dict] = []
|
|
306
|
+
for child in li.children:
|
|
307
|
+
content.extend(_render_block(child, api, ctx))
|
|
308
|
+
items.append({"type": NodeType.LIST_ITEM, "content": content})
|
|
309
|
+
return items
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _footnote_definitions(tree: SyntaxTreeNode, api) -> Dict[int, List[Dict]]:
|
|
313
|
+
"""Map each footnote id to its rendered block content."""
|
|
314
|
+
definitions: Dict[int, List[Dict]] = {}
|
|
315
|
+
for node in tree.children:
|
|
316
|
+
if node.type != "footnote_block":
|
|
317
|
+
continue
|
|
318
|
+
for fn in node.children:
|
|
319
|
+
# A footnote's own content should not register anchors of its own.
|
|
320
|
+
local_ctx = {"order": []}
|
|
321
|
+
content: List[Dict] = []
|
|
322
|
+
for child in fn.children:
|
|
323
|
+
content.extend(_render_block(child, api, local_ctx))
|
|
324
|
+
definitions[fn.meta["id"]] = content
|
|
325
|
+
return definitions
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def markdown_to_doc(markdown_content: str, api=None) -> List[Dict]:
|
|
329
|
+
"""Convert Markdown into a list of Substack ProseMirror block nodes."""
|
|
330
|
+
tree = SyntaxTreeNode(_make_parser().parse(markdown_content))
|
|
331
|
+
|
|
332
|
+
definitions = _footnote_definitions(tree, api)
|
|
333
|
+
|
|
334
|
+
ctx: Dict = {"order": []}
|
|
335
|
+
out: List[Dict] = []
|
|
238
336
|
for node in tree.children:
|
|
239
337
|
if node.type == "footnote_block":
|
|
240
338
|
continue
|
|
339
|
+
if node.type == "html_block":
|
|
340
|
+
attrs = parse_image_marker(node.content)
|
|
341
|
+
if attrs is not None:
|
|
342
|
+
if not out or out[-1].get("type") != "captionedImage":
|
|
343
|
+
raise ValueError("Image marker must immediately follow an image")
|
|
344
|
+
out[-1]["content"][0]["attrs"].update(attrs)
|
|
345
|
+
out[-1]["content"][0]["attrs"]["isProcessing"] = False
|
|
346
|
+
continue
|
|
241
347
|
out.extend(_render_block(node, api, ctx))
|
|
242
|
-
|
|
243
|
-
# Emit one footnote block per reference, in anchor order, numbered to match.
|
|
244
|
-
for number, footnote_id in enumerate(ctx["order"], start=1):
|
|
245
|
-
content = copy.deepcopy(definitions.get(footnote_id, []))
|
|
246
|
-
out.append(nodes.footnote(number, content))
|
|
247
|
-
|
|
248
|
-
return out
|
|
348
|
+
|
|
349
|
+
# Emit one footnote block per reference, in anchor order, numbered to match.
|
|
350
|
+
for number, footnote_id in enumerate(ctx["order"], start=1):
|
|
351
|
+
content = copy.deepcopy(definitions.get(footnote_id, []))
|
|
352
|
+
out.append(nodes.footnote(number, content))
|
|
353
|
+
|
|
354
|
+
return out
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
substack/__init__.py,sha256=soBT45w3E1XVhTYcV9Y_1D363e35S4_Q0mFrF13pHHM,450
|
|
2
|
-
substack/api.py,sha256=nBEnD2ipxSAWR99iwNgCfGD4-y8YYwKNmilq2y1VP8M,29903
|
|
3
|
-
substack/cli.py,sha256=L-nR45tJDTuvZrinj-dApXzw6-CgXcwaodyX7CpYFm4,25720
|
|
4
|
-
substack/exceptions.py,sha256=Y61XSPyxDxXPbVlioFvKhb94MRY0tpD389F5YdL1rGM,1390
|
|
5
|
-
substack/mdexport.py,sha256=_pSjioPJIA1wuJ6hYpvODZVswPSUROuzEndVoYnv-yU,13541
|
|
6
|
-
substack/mdrender.py,sha256=QGJkdp1isFmhVyTRMogIabAK8OO2xQh9CrjnXZvFTY4,9308
|
|
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.7.0.dist-info/METADATA,sha256=-_DF7Ln42oZYd1uPqaFQgAAZHsgka8-FqwP6VfbshVQ,8689
|
|
11
|
-
python_substack-0.7.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
12
|
-
python_substack-0.7.0.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
|
|
13
|
-
python_substack-0.7.0.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
14
|
-
python_substack-0.7.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|