Research-Stack/plugins/substack-connector/scripts/update_existing_post.py
2026-05-05 21:09:48 -05:00

107 lines
3.8 KiB
Python

#!/usr/bin/env python3
"""Update an existing Substack post from a prepared local Markdown bundle."""
from __future__ import annotations
import argparse
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
from substack import Api
from substack.post import Post
def markdown_body_without_title(markdown: str) -> tuple[str, str, str]:
lines = markdown.splitlines()
title = ""
subtitle = ""
body_start = 0
if lines and lines[0].startswith("# "):
title = lines[0][2:].strip()
body_start = 1
if len(lines) > 1 and lines[1].startswith("## "):
subtitle = lines[1][3:].strip()
body_start = 2
while body_start < len(lines) and not lines[body_start].strip():
body_start += 1
return title, subtitle, "\n".join(lines[body_start:]).strip() + "\n"
def main() -> int:
parser = argparse.ArgumentParser(description="Update an existing Substack post from local Markdown.")
parser.add_argument("markdown_path", type=Path)
parser.add_argument("--post-id", type=int, required=True)
parser.add_argument("--env-path", type=Path, default=Path.home() / ".substack.env")
parser.add_argument("--publication-url", default="https://froginnponds.substack.com")
parser.add_argument("--publish", action="store_true", help="Publish the updated draft without sending email.")
args = parser.parse_args()
load_dotenv(args.env_path)
cookies = os.getenv("COOKIES_STRING")
if not cookies:
raise SystemExit("COOKIES_STRING is missing from the Substack env file")
markdown_path = args.markdown_path.resolve()
markdown = markdown_path.read_text(encoding="utf-8")
title, subtitle, body_markdown = markdown_body_without_title(markdown)
if not title:
raise SystemExit("Markdown must start with a # title")
api = Api(cookies_string=cookies, publication_url=args.publication_url)
user_id = api.get_user_id()
existing = api.get_draft(args.post_id)
backup_dir = markdown_path.parent / "substack_backups"
backup_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
backup_path = backup_dir / f"post_{args.post_id}_{stamp}.json"
backup_path.write_text(json.dumps(existing, indent=2, sort_keys=True) + "\n", encoding="utf-8")
cwd = Path.cwd()
try:
os.chdir(markdown_path.parent)
post = Post(
title=title,
subtitle=subtitle,
user_id=user_id,
audience=existing.get("audience") or "everyone",
write_comment_permissions=existing.get("write_comment_permissions") or "everyone",
)
post.from_markdown(body_markdown, api=api)
finally:
os.chdir(cwd)
payload = post.get_draft()
payload["draft_bylines"] = existing.get("draft_bylines") or payload.get("draft_bylines")
payload["draft_section_id"] = existing.get("draft_section_id")
payload["section_chosen"] = existing.get("section_chosen", True)
updated = api.put_draft(args.post_id, **payload)
print(json.dumps({
"updated_post_id": args.post_id,
"title": title,
"subtitle": subtitle,
"backup_path": str(backup_path),
"draft_updated_at": updated.get("draft_updated_at"),
"is_published": updated.get("is_published"),
"published": False,
}, indent=2))
if args.publish:
api.prepublish_draft(args.post_id)
published = api.publish_draft(args.post_id, send=False, share_automatically=False)
print(json.dumps({
"published": True,
"post_id": args.post_id,
"slug": published.get("slug"),
"canonical_url": published.get("canonical_url") or published.get("canonicalUrl"),
}, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())