Repository navigation
Expand file tree
/
Copy pathmd2substack.py
More file actions
110 lines (97 loc) · 4.43 KB
/
Copy pathmd2substack.py
File metadata and controls
110 lines (97 loc) · 4.43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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
#!/usr/bin/env python3
"""Push a vault newsletter issue to Substack as a DRAFT (never publishes).
python md2substack.py <issue.md> [--dry-run]
- Parses the issue file produced by /newsletter (frontmatter + '# SUBJECT:' +
'PREVIEW:' + markdown body).
- Refuses to run if unresolved {{PLACEHOLDER}} markers remain in the body.
- Brand -> publication/section mapping and cookies path come from
the config file (template below). Cookies come from
your browser's cookie export (see README).
- Creates a Substack DRAFT via python-substack and prints the editor URL.
Publishing stays a human action in the Substack UI.
Config template (~/.config/md2substack/config.json):
{
"cookies_path": "~/.config/md2substack/cookies.json",
"publications": {
"myblog": {"url": "https://myblog.substack.com", "section": null},
"mybrand": {"url": "https://myblog.substack.com", "section": "My Section"}
}
}
"""
import json
import os
import re
import sys
CONFIG = os.path.expanduser(os.environ.get("MD2SUBSTACK_CONFIG", "~/.config/md2substack/config.json"))
def parse_issue(path):
text = open(path, encoding="utf-8").read()
m = re.match(r"^---\n(.*?)\n---\n", text, re.S)
if not m:
raise SystemExit("No frontmatter found.")
fm_raw, body = m.group(1), text[m.end():]
brand = re.search(r"^brand:\s*(\S+)", fm_raw, re.M)
if not brand:
raise SystemExit("No 'brand:' in frontmatter.")
hero = re.search(r"^hero_image:\s*(\S+)", fm_raw, re.M)
hero_path = None
if hero:
hero_path = os.path.expanduser(hero.group(1))
if not os.path.isabs(hero_path):
hero_path = os.path.join(os.path.dirname(os.path.abspath(path)), hero_path)
if not os.path.exists(hero_path):
raise SystemExit(f"hero_image not found: {hero_path}")
subj = re.search(r"^#\s*SUBJECT:\s*(.+)$", body, re.M)
prev = re.search(r"^PREVIEW:\s*(.+)$", body, re.M)
if not subj or not prev:
raise SystemExit("Missing '# SUBJECT:' or 'PREVIEW:' line.")
content = body[prev.end():].strip()
if re.search(r"\{\{[A-Z_]+\}\}", content):
raise SystemExit("Unresolved {{PLACEHOLDER}} markers in body — fill them first (see 'before_send' in frontmatter).")
return brand.group(1), subj.group(1).strip(), prev.group(1).strip(), content, hero_path
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
dry = "--dry-run" in sys.argv
if len(args) != 1:
raise SystemExit(__doc__)
brand, title, subtitle, content, hero_path = parse_issue(args[0])
print(f"brand={brand}\ntitle={title}\nsubtitle={subtitle}\nbody={len(content)} chars\nhero={hero_path or 'none'}")
if dry:
print("DRY-RUN OK (parsed, no network)")
return
if not os.path.exists(CONFIG):
raise SystemExit(f"Missing config {CONFIG} (template in this script's docstring).")
cfg = json.load(open(CONFIG))
pub = cfg["publications"].get(brand) or {}
if not pub.get("url"):
raise SystemExit(f"No Substack publication configured for brand '{brand}' in {CONFIG}.")
from substack import Api
from substack.post import Post
api = Api(
cookies_path=os.path.expanduser(cfg["cookies_path"]),
publication_url=pub["url"],
)
post = Post(title=title, subtitle=subtitle, user_id=api.get_user_id())
if hero_path:
up = api.get_image(hero_path)
hero_url = up.get("url")
if not hero_url:
raise SystemExit(f"hero upload failed: {up}")
content = f"\n\n{content}"
print(f"hero uploaded: {hero_url[:80]}")
post.from_markdown(content)
draft = api.post_draft(post.get_draft())
draft_id = draft.get("id")
if pub.get("section"):
# Assign section post-creation: the lib's get_sections()/pre-set path is
# unreliable; PUT /drafts/<id> {draft_section_id} works.
r = api._session.get(f"{pub['url']}/api/v1/publication/sections")
r.raise_for_status()
matches = [s for s in r.json() if s.get("name") == pub["section"]]
if not matches:
print(f"WARNING: section '{pub['section']}' not found — draft left in main feed.")
else:
api._session.put(f"{pub['url']}/api/v1/drafts/{draft_id}", json={"draft_section_id": matches[0]["id"]}).raise_for_status()
print(f"section set: {pub['section']}")
print(f"DRAFT CREATED (not published): {pub['url']}/publish/post/{draft_id}")
if __name__ == "__main__":
main()