python-substack 0.5.0__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_substack-0.5.0 → python_substack-0.7.0}/PKG-INFO +12 -2
- {python_substack-0.5.0 → python_substack-0.7.0}/README.md +10 -0
- {python_substack-0.5.0 → python_substack-0.7.0}/pyproject.toml +3 -2
- {python_substack-0.5.0 → python_substack-0.7.0}/substack/__init__.py +1 -1
- {python_substack-0.5.0 → python_substack-0.7.0}/substack/api.py +111 -0
- {python_substack-0.5.0 → python_substack-0.7.0}/substack/cli.py +98 -0
- python_substack-0.7.0/substack/mdexport.py +381 -0
- {python_substack-0.5.0 → python_substack-0.7.0}/substack_mcp/mcp_server.py +386 -386
- {python_substack-0.5.0 → python_substack-0.7.0}/LICENSE +0 -0
- {python_substack-0.5.0 → python_substack-0.7.0}/substack/exceptions.py +0 -0
- {python_substack-0.5.0 → python_substack-0.7.0}/substack/mdrender.py +0 -0
- {python_substack-0.5.0 → python_substack-0.7.0}/substack/nodes.py +0 -0
- {python_substack-0.5.0 → python_substack-0.7.0}/substack/post.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-substack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -22,7 +22,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
22
22
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
23
|
Provides-Extra: mcp
|
|
24
24
|
Requires-Dist: PyYAML (>=6.0,<7.0)
|
|
25
|
-
Requires-Dist: fastmcp (>=3.1.1,<
|
|
25
|
+
Requires-Dist: fastmcp (>=3.1.1,<5.0.0) ; extra == "mcp"
|
|
26
26
|
Requires-Dist: markdown-it-py (>=3,<5)
|
|
27
27
|
Requires-Dist: mdit-py-plugins (>=0.5,<0.7)
|
|
28
28
|
Requires-Dist: python-dotenv (>=1.2.1,<2.0.0)
|
|
@@ -93,6 +93,7 @@ Substack result:
|
|
|
93
93
|
- Upload local images referenced by Markdown.
|
|
94
94
|
- Set audience, comment permissions, SEO metadata, slug, sections, and tags.
|
|
95
95
|
- List and inspect publications and drafts.
|
|
96
|
+
- Export drafts to loss-aware Markdown backups without server writes.
|
|
96
97
|
- Schedule, unschedule, publish, and delete drafts with explicit safeguards.
|
|
97
98
|
- Use stable JSON envelopes in scripts and automation.
|
|
98
99
|
- Authenticate with browser cookies or email and password.
|
|
@@ -143,6 +144,7 @@ Inspect publications and drafts:
|
|
|
143
144
|
substack publications list
|
|
144
145
|
substack drafts list --limit 10
|
|
145
146
|
substack drafts get 12345
|
|
147
|
+
substack drafts export 12345 --output backup.md
|
|
146
148
|
substack --publication-url https://example.substack.com drafts list
|
|
147
149
|
```
|
|
148
150
|
|
|
@@ -207,6 +209,14 @@ print(result["draft"]["id"])
|
|
|
207
209
|
`create_draft_from_markdown` creates a draft by default. It publishes only when
|
|
208
210
|
`publish=True` is passed.
|
|
209
211
|
|
|
212
|
+
Back up an existing draft without modifying it:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
backup = api.export_draft_to_markdown(12345)
|
|
216
|
+
print(backup["markdown"])
|
|
217
|
+
print(backup["unsupported_nodes"])
|
|
218
|
+
```
|
|
219
|
+
|
|
210
220
|
For direct ProseMirror node construction, see the
|
|
211
221
|
[low-level Python API](docs/low-level-api.md). YAML workflows are documented in
|
|
212
222
|
[YAML drafts](docs/yaml.md).
|
|
@@ -57,6 +57,7 @@ Substack result:
|
|
|
57
57
|
- Upload local images referenced by Markdown.
|
|
58
58
|
- Set audience, comment permissions, SEO metadata, slug, sections, and tags.
|
|
59
59
|
- List and inspect publications and drafts.
|
|
60
|
+
- Export drafts to loss-aware Markdown backups without server writes.
|
|
60
61
|
- Schedule, unschedule, publish, and delete drafts with explicit safeguards.
|
|
61
62
|
- Use stable JSON envelopes in scripts and automation.
|
|
62
63
|
- Authenticate with browser cookies or email and password.
|
|
@@ -107,6 +108,7 @@ Inspect publications and drafts:
|
|
|
107
108
|
substack publications list
|
|
108
109
|
substack drafts list --limit 10
|
|
109
110
|
substack drafts get 12345
|
|
111
|
+
substack drafts export 12345 --output backup.md
|
|
110
112
|
substack --publication-url https://example.substack.com drafts list
|
|
111
113
|
```
|
|
112
114
|
|
|
@@ -171,6 +173,14 @@ print(result["draft"]["id"])
|
|
|
171
173
|
`create_draft_from_markdown` creates a draft by default. It publishes only when
|
|
172
174
|
`publish=True` is passed.
|
|
173
175
|
|
|
176
|
+
Back up an existing draft without modifying it:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
backup = api.export_draft_to_markdown(12345)
|
|
180
|
+
print(backup["markdown"])
|
|
181
|
+
print(backup["unsupported_nodes"])
|
|
182
|
+
```
|
|
183
|
+
|
|
174
184
|
For direct ProseMirror node construction, see the
|
|
175
185
|
[low-level Python API](docs/low-level-api.md). YAML workflows are documented in
|
|
176
186
|
[YAML drafts](docs/yaml.md).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "python-substack"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.0"
|
|
4
4
|
description = "Write and safely manage Substack drafts from Markdown with Python, CLI, and MCP."
|
|
5
5
|
authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -41,7 +41,7 @@ python-dotenv = "^1.2.1"
|
|
|
41
41
|
PyYAML = "^6.0"
|
|
42
42
|
markdown-it-py = ">=3,<5"
|
|
43
43
|
mdit-py-plugins = ">=0.5,<0.7"
|
|
44
|
-
fastmcp = { version = "
|
|
44
|
+
fastmcp = { version = ">=3.1.1,<5.0.0", optional = true }
|
|
45
45
|
|
|
46
46
|
[tool.poetry.extras]
|
|
47
47
|
mcp = ["fastmcp"]
|
|
@@ -49,6 +49,7 @@ mcp = ["fastmcp"]
|
|
|
49
49
|
[tool.poetry.group.dev.dependencies]
|
|
50
50
|
pytest = "^9.1.1"
|
|
51
51
|
pre-commit = "^4.6.1"
|
|
52
|
+
twine = "^7.0.0"
|
|
52
53
|
|
|
53
54
|
[tool.pytest.ini_options]
|
|
54
55
|
markers = [
|
|
@@ -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.7.0"
|
|
7
7
|
__url__ = "https://github.com/ma2za/python-substack"
|
|
8
8
|
__download_url__ = "https://pypi.python.org/pypi/python-substack"
|
|
9
9
|
__description__ = (
|
|
@@ -442,6 +442,117 @@ class Api:
|
|
|
442
442
|
response = self._session.get(f"{self.publication_url}/drafts/{draft_id}")
|
|
443
443
|
return Api._handle_response(response=response)
|
|
444
444
|
|
|
445
|
+
def export_draft_to_markdown(self, draft_id):
|
|
446
|
+
from substack.mdexport import document_to_markdown
|
|
447
|
+
|
|
448
|
+
draft = self.get_draft(draft_id)
|
|
449
|
+
draft_body = draft.get("draft_body")
|
|
450
|
+
if isinstance(draft_body, str):
|
|
451
|
+
try:
|
|
452
|
+
draft_body = json.loads(draft_body)
|
|
453
|
+
except json.JSONDecodeError as exc:
|
|
454
|
+
raise ValueError(
|
|
455
|
+
"Malformed draft body: draft_body is not valid JSON"
|
|
456
|
+
) from exc
|
|
457
|
+
if not isinstance(draft_body, dict):
|
|
458
|
+
raise ValueError("Malformed draft body: draft_body must be a JSON object")
|
|
459
|
+
|
|
460
|
+
markdown, unsupported_nodes = document_to_markdown(draft_body)
|
|
461
|
+
return {
|
|
462
|
+
"draft": draft,
|
|
463
|
+
"markdown": markdown,
|
|
464
|
+
"unsupported_nodes": unsupported_nodes,
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
def update_draft_from_markdown(
|
|
468
|
+
self,
|
|
469
|
+
draft_id: int,
|
|
470
|
+
markdown: str,
|
|
471
|
+
*,
|
|
472
|
+
subtitle: str = None,
|
|
473
|
+
audience: str = None,
|
|
474
|
+
write_comment_permissions: str = None,
|
|
475
|
+
search_engine_title: str = None,
|
|
476
|
+
search_engine_description: str = None,
|
|
477
|
+
slug: str = None,
|
|
478
|
+
draft_section_id: int = None,
|
|
479
|
+
tags=None,
|
|
480
|
+
dry_run: bool = False,
|
|
481
|
+
) -> dict:
|
|
482
|
+
"""
|
|
483
|
+
Update an existing draft body from Markdown, with optional metadata changes.
|
|
484
|
+
"""
|
|
485
|
+
from substack.mdexport import document_to_markdown
|
|
486
|
+
from substack.post import Post
|
|
487
|
+
|
|
488
|
+
draft = self.get_draft(draft_id)
|
|
489
|
+
draft_body = draft.get("draft_body")
|
|
490
|
+
if isinstance(draft_body, str):
|
|
491
|
+
try:
|
|
492
|
+
draft_body = json.loads(draft_body)
|
|
493
|
+
except json.JSONDecodeError as exc:
|
|
494
|
+
raise ValueError(
|
|
495
|
+
"Malformed draft body: draft_body is not valid JSON"
|
|
496
|
+
) from exc
|
|
497
|
+
if not isinstance(draft_body, dict):
|
|
498
|
+
raise ValueError("Malformed draft body: draft_body must be a JSON object")
|
|
499
|
+
|
|
500
|
+
_, unsupported_nodes = document_to_markdown(draft_body)
|
|
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
|
+
)
|
|
506
|
+
|
|
507
|
+
post = Post(
|
|
508
|
+
title=draft.get("title", ""),
|
|
509
|
+
subtitle=(
|
|
510
|
+
subtitle if subtitle is not None else (draft.get("subtitle") or "")
|
|
511
|
+
),
|
|
512
|
+
user_id=self.get_user_id(),
|
|
513
|
+
audience=audience,
|
|
514
|
+
write_comment_permissions=write_comment_permissions,
|
|
515
|
+
)
|
|
516
|
+
post.from_markdown(markdown, api=self)
|
|
517
|
+
|
|
518
|
+
update_payload = {"draft_body": json.dumps(post.draft_body)}
|
|
519
|
+
|
|
520
|
+
if subtitle is not None:
|
|
521
|
+
update_payload["subtitle"] = subtitle
|
|
522
|
+
if audience is not None:
|
|
523
|
+
update_payload["audience"] = audience
|
|
524
|
+
if write_comment_permissions is not None:
|
|
525
|
+
update_payload["write_comment_permissions"] = write_comment_permissions
|
|
526
|
+
if search_engine_title is not None:
|
|
527
|
+
update_payload["search_engine_title"] = search_engine_title
|
|
528
|
+
if search_engine_description is not None:
|
|
529
|
+
update_payload["search_engine_description"] = search_engine_description
|
|
530
|
+
if slug is not None:
|
|
531
|
+
update_payload["slug"] = slug
|
|
532
|
+
if draft_section_id is not None:
|
|
533
|
+
update_payload["draft_section_id"] = draft_section_id
|
|
534
|
+
|
|
535
|
+
tags_result = None
|
|
536
|
+
updated_draft = draft
|
|
537
|
+
|
|
538
|
+
if not dry_run:
|
|
539
|
+
updated_draft = self.put_draft(draft_id, **update_payload)
|
|
540
|
+
|
|
541
|
+
tags_list = Api._normalize_tags(tags)
|
|
542
|
+
if tags_list:
|
|
543
|
+
tags_result = self.add_tags_to_post(draft_id, tags_list)
|
|
544
|
+
|
|
545
|
+
return {
|
|
546
|
+
"action": "update",
|
|
547
|
+
"draft_id": draft_id,
|
|
548
|
+
"dry_run": dry_run,
|
|
549
|
+
"changed": not dry_run,
|
|
550
|
+
"payload": update_payload,
|
|
551
|
+
"draft": updated_draft,
|
|
552
|
+
"tags": tags_result,
|
|
553
|
+
"unsupported_nodes": unsupported_nodes,
|
|
554
|
+
}
|
|
555
|
+
|
|
445
556
|
def delete_draft(self, draft_id):
|
|
446
557
|
"""
|
|
447
558
|
|
|
@@ -309,6 +309,79 @@ def _drafts_create(api, args):
|
|
|
309
309
|
print(f"Created draft {draft.get('id')}: {title}")
|
|
310
310
|
|
|
311
311
|
|
|
312
|
+
def _drafts_update(api, args):
|
|
313
|
+
if not args.yes and (args.json_output or not sys.stdin.isatty()):
|
|
314
|
+
raise CLIUsageError("--yes is required in non-interactive or JSON mode")
|
|
315
|
+
|
|
316
|
+
markdown_file = Path(args.markdown_file)
|
|
317
|
+
if not markdown_file.exists():
|
|
318
|
+
raise CLIUsageError(f"File not found: {markdown_file}")
|
|
319
|
+
|
|
320
|
+
if not args.yes:
|
|
321
|
+
action = "update" if not args.dry_run else "dry-run update"
|
|
322
|
+
print(f"Ready to {action} draft {args.draft_id} from {markdown_file}")
|
|
323
|
+
print("Substack nodes that are not supported by Markdown export/import")
|
|
324
|
+
print("will cause the update to be refused to prevent data loss.")
|
|
325
|
+
print()
|
|
326
|
+
try:
|
|
327
|
+
response = input(f"Confirm {action}? [y/N]: ")
|
|
328
|
+
if response.lower() not in ["y", "yes"]:
|
|
329
|
+
print("Aborted.")
|
|
330
|
+
return
|
|
331
|
+
except EOFError as exc:
|
|
332
|
+
raise CLIUsageError(
|
|
333
|
+
f"Confirm {action} requires confirmation or --yes"
|
|
334
|
+
) from exc
|
|
335
|
+
|
|
336
|
+
markdown = markdown_file.read_text(encoding="utf-8")
|
|
337
|
+
|
|
338
|
+
result = api.update_draft_from_markdown(
|
|
339
|
+
args.draft_id,
|
|
340
|
+
markdown,
|
|
341
|
+
subtitle=args.subtitle,
|
|
342
|
+
audience=args.audience,
|
|
343
|
+
write_comment_permissions=args.write_comment_permissions,
|
|
344
|
+
search_engine_title=args.search_engine_title,
|
|
345
|
+
search_engine_description=args.search_engine_description,
|
|
346
|
+
slug=args.slug,
|
|
347
|
+
draft_section_id=args.draft_section_id,
|
|
348
|
+
tags=args.tags,
|
|
349
|
+
dry_run=args.dry_run,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
if args.json_output:
|
|
353
|
+
_print_json(result)
|
|
354
|
+
else:
|
|
355
|
+
status = "Dry-run updated" if args.dry_run else "Updated"
|
|
356
|
+
print(f"{status} draft {args.draft_id}")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _drafts_export(api, args):
|
|
360
|
+
output_path = Path(args.output) if args.output else None
|
|
361
|
+
if output_path is not None and output_path.exists() and not args.force:
|
|
362
|
+
raise CLIUsageError(
|
|
363
|
+
f"Output file already exists: {output_path}; use --force to overwrite"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
result = api.export_draft_to_markdown(args.draft_id)
|
|
367
|
+
markdown = result["markdown"]
|
|
368
|
+
if output_path is not None:
|
|
369
|
+
output_path.write_text(markdown, encoding="utf-8")
|
|
370
|
+
|
|
371
|
+
payload = {
|
|
372
|
+
"action": "export",
|
|
373
|
+
"draft_id": args.draft_id,
|
|
374
|
+
"markdown": markdown,
|
|
375
|
+
"unsupported_nodes": result["unsupported_nodes"],
|
|
376
|
+
}
|
|
377
|
+
if args.json_output:
|
|
378
|
+
_print_json(payload)
|
|
379
|
+
elif output_path is not None:
|
|
380
|
+
print(f"Exported draft {args.draft_id} to {output_path}")
|
|
381
|
+
else:
|
|
382
|
+
print(markdown, end="")
|
|
383
|
+
|
|
384
|
+
|
|
312
385
|
def _drafts_schedule(api, args):
|
|
313
386
|
scheduled_at = _parse_schedule(args.at)
|
|
314
387
|
result = api.schedule_draft(args.draft_id, scheduled_at)
|
|
@@ -421,6 +494,31 @@ def _build_parser():
|
|
|
421
494
|
drafts_create.add_argument("--tag", action="append", dest="tags", metavar="TAG")
|
|
422
495
|
drafts_create.set_defaults(handler=_drafts_create)
|
|
423
496
|
|
|
497
|
+
drafts_update = draft_commands.add_parser(
|
|
498
|
+
"update", help="Update a draft from a Markdown file."
|
|
499
|
+
)
|
|
500
|
+
drafts_update.add_argument("draft_id", type=int)
|
|
501
|
+
drafts_update.add_argument("markdown_file", metavar="MARKDOWN_FILE")
|
|
502
|
+
drafts_update.add_argument("--subtitle")
|
|
503
|
+
drafts_update.add_argument("--audience")
|
|
504
|
+
drafts_update.add_argument("--write-comment-permissions")
|
|
505
|
+
drafts_update.add_argument("--search-engine-title")
|
|
506
|
+
drafts_update.add_argument("--search-engine-description")
|
|
507
|
+
drafts_update.add_argument("--slug")
|
|
508
|
+
drafts_update.add_argument("--draft-section-id", type=int)
|
|
509
|
+
drafts_update.add_argument("--tag", action="append", dest="tags", metavar="TAG")
|
|
510
|
+
drafts_update.add_argument("--dry-run", action="store_true")
|
|
511
|
+
drafts_update.add_argument("--yes", action="store_true")
|
|
512
|
+
drafts_update.set_defaults(handler=_drafts_update)
|
|
513
|
+
|
|
514
|
+
drafts_export = draft_commands.add_parser(
|
|
515
|
+
"export", help="Export a draft to Markdown without modifying it."
|
|
516
|
+
)
|
|
517
|
+
drafts_export.add_argument("draft_id", type=int)
|
|
518
|
+
drafts_export.add_argument("--output", metavar="PATH")
|
|
519
|
+
drafts_export.add_argument("--force", action="store_true")
|
|
520
|
+
drafts_export.set_defaults(handler=_drafts_export)
|
|
521
|
+
|
|
424
522
|
drafts_schedule = draft_commands.add_parser("schedule", help="Schedule a draft.")
|
|
425
523
|
drafts_schedule.add_argument("draft_id", type=int)
|
|
426
524
|
drafts_schedule.add_argument("--at", required=True)
|