-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathvisual_helper.py
More file actions
424 lines (364 loc) · 16.1 KB
/
Copy pathvisual_helper.py
File metadata and controls
424 lines (364 loc) · 16.1 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
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
150
151
152
153
154
155
156
157
158
159
160
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
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
#!/usr/bin/env python3
"""
Deterministic helper for the Substack visual publishing pipeline.
Subcommands:
peek --lang es|en Pick next approved queue item with [VISUAL:PX] tag. Print JSON.
mark --lang --queue-number --note-id --pillar --figure-used
Mark queue entry published plus append to publishing log.
rotate-figure --pillar P1 Return next figure ID from the rotation library.
All Canva and Substack work is done by the caller (Claude session) via MCP tools.
This script stays deterministic: queue parsing, figure rotation, log writes.
"""
import argparse
import json
import os
import re
import sys
from datetime import datetime
from pathlib import Path
CONFIG_PATH = Path(__file__).parent / "config.json"
ROTATION_STATE_PATH = Path(__file__).parent / "figure_rotation_state.json"
QUEUE_PATH = Path(os.environ.get("SUBSTACK_QUEUE_PATH", "~/substack-queue/Notes Review Queue.md")).expanduser()
# Optionally auto-resolve the queue path by scanning a vault root.
# Set SUBSTACK_VAULT_ROOT to the folder containing a "Writing" subfolder and a
# "Substack General/Notes Review Queue.md" inside it.
_VAULT_ROOT = Path(os.environ.get("SUBSTACK_VAULT_ROOT", "")).expanduser()
if _VAULT_ROOT and _VAULT_ROOT.exists():
for _sg in _VAULT_ROOT.iterdir():
if _sg.is_dir() and "Writing" in _sg.name:
_candidate = _sg / "Substack General" / "Notes Review Queue.md"
if _candidate.exists():
QUEUE_PATH = _candidate
break
LOG_PATH = QUEUE_PATH.parent / "Substack Publishing Log.md"
def load_config():
return json.loads(CONFIG_PATH.read_text())
def _pillar_config(cfg: dict) -> dict:
"""Read pillar config. Checks image_generator.canva.pillars first, falls back to canva_pillars."""
ig = cfg.get("image_generator", {})
canva_ig = ig.get("canva", {})
return canva_ig.get("pillars") or cfg.get("canva_pillars", {})
def _figure_library_path(cfg: dict) -> str:
"""Read figure library path. Checks image_generator.canva first, falls back to canva_figure_library_path."""
ig = cfg.get("image_generator", {})
canva_ig = ig.get("canva", {})
return canva_ig.get("figure_library_path") or cfg.get("canva_figure_library_path", "")
def parse_queue(queue_text: str, lang: str):
"""
Return list of sections with: number, title, text per lang, approval state, visual tag.
Approval line formats supported:
- [ ] EN
- [x] EN
- [x] EN [VISUAL:P1]
- [x] EN [VISUAL:P1] PUBLISHED 2026-04-17 id:12345 visual:P1/fig_03
- [x] EN SKIP
"""
lines = queue_text.split("\n")
sections = []
current = None
block_lang = None
block_lines = []
for i, line in enumerate(lines):
m_head = re.match(r"^## (\d+(?:-\d+)?)\. (.+)", line)
if m_head:
if current:
if block_lang and block_lines:
current["text"][block_lang] = "\n".join(block_lines).strip()
sections.append(current)
current = {
"header_idx": i,
"title": m_head.group(2).strip(),
"number": m_head.group(1),
"text": {"en": "", "es": ""},
"approval_idx": {"en": None, "es": None},
"status": {"en": "none", "es": "none"},
"visual_tag": {"en": None, "es": None},
"context_line": {"en": None, "es": None},
}
block_lang = None
block_lines = []
continue
if current is None:
continue
m_lang = re.match(r"^\s*-\s*\[([ xX])\]\s*(EN|ES)\b(.*)", line)
if m_lang:
if block_lang and block_lines:
current["text"][block_lang] = "\n".join(block_lines).strip()
block_lines = []
block_lang = None
checked = m_lang.group(1).lower() == "x"
which = m_lang.group(2).lower()
rest = m_lang.group(3)
current["approval_idx"][which] = i
# Parse visual tag
vm = re.search(r"\[VISUAL:(P[123])\]", rest)
if vm:
current["visual_tag"][which] = vm.group(1)
# Parse context line (optional: CONTEXT: "..." after the visual tag)
cm = re.search(r'CONTEXT:\s*"([^"]*)"', rest)
if cm:
current["context_line"][which] = cm.group(1)
# Parse explicit short quote for the visual card (optional, overrides body text)
qm = re.search(r'QUOTE:\s*"([^"]*)"', rest)
if qm:
current["visual_quote"] = current.get("visual_quote") or {"en": None, "es": None}
current["visual_quote"][which] = qm.group(1)
if "PUBLISHED" in rest.upper():
current["status"][which] = "published"
elif "SKIP" in rest.upper():
current["status"][which] = "skip"
elif checked:
current["status"][which] = "approved"
else:
current["status"][which] = "unapproved"
continue
if re.match(r"^\s*\*\*EN:?\*\*\s*$", line):
if block_lang and block_lines:
current["text"][block_lang] = "\n".join(block_lines).strip()
block_lang = "en"
block_lines = []
continue
if re.match(r"^\s*\*\*ES:?\*\*\s*$", line):
if block_lang and block_lines:
current["text"][block_lang] = "\n".join(block_lines).strip()
block_lang = "es"
block_lines = []
continue
if line.strip() == "---":
if block_lang and block_lines:
current["text"][block_lang] = "\n".join(block_lines).strip()
block_lines = []
block_lang = None
continue
if block_lang:
block_lines.append(line)
if current:
if block_lang and block_lines:
current["text"][block_lang] = "\n".join(block_lines).strip()
sections.append(current)
return sections
def rotate_figure(pillar: str) -> str:
"""Return the next figure filename for the given pillar, cycling through the library."""
cfg = load_config()
pillar_cfg = _pillar_config(cfg).get(pillar, {})
if not pillar_cfg.get("figure_rotation"):
return ""
raw_path = _figure_library_path(cfg)
if not raw_path:
return ""
lib_path = Path(raw_path).expanduser()
if not lib_path.exists():
return ""
figures = sorted([f.name for f in lib_path.iterdir() if f.suffix.lower() in (".png", ".jpg", ".jpeg")])
if not figures:
return ""
state = {}
if ROTATION_STATE_PATH.exists():
state = json.loads(ROTATION_STATE_PATH.read_text())
last_used_idx = state.get(pillar, -1)
next_idx = (last_used_idx + 1) % len(figures)
state[pillar] = next_idx
ROTATION_STATE_PATH.write_text(json.dumps(state, indent=2))
return figures[next_idx]
def peek(lang: str):
"""Find next approved, unpublished visual queue entry."""
cfg = load_config()
pillars = _pillar_config(cfg)
if not QUEUE_PATH.exists():
print(json.dumps({"status": "error", "message": f"Queue not found at {QUEUE_PATH}"}))
sys.exit(1)
sections = parse_queue(QUEUE_PATH.read_text(), lang)
for s in sections:
if s["status"][lang] != "approved":
continue
if s["visual_tag"][lang] is None:
continue
if not s["text"][lang].strip():
continue
pillar = s["visual_tag"][lang]
pillar_cfg = pillars.get(pillar)
if not pillar_cfg:
continue
figure_id = rotate_figure(pillar) if pillar_cfg.get("figure_rotation") else ""
# Prefer explicit QUOTE: override for visual card; fall back to stripped body text.
vq = s.get("visual_quote", {}).get(lang) if s.get("visual_quote") else None
if vq:
card_quote = vq
else:
# Strip [[wikilinks|alias]] to alias; [[wikilink]] to wikilink
card_quote = re.sub(r"\[\[([^\]|]+)\|([^\]]+)\]\]", r"\2", s["text"][lang])
card_quote = re.sub(r"\[\[([^\]]+)\]\]", r"\1", card_quote)
result = {
"status": "ok",
"queue_number": s["number"],
"title": s["title"],
"pillar": pillar,
"template_design_id": pillar_cfg["design_id"],
"template_edit_url": pillar_cfg.get("edit_url"),
"template_view_url": pillar_cfg.get("edit_url", "").replace("/edit", "/view"),
"quote_text": card_quote,
"full_body_text": s["text"][lang],
"context_line": s["context_line"][lang] or "",
"figure_id": figure_id,
"figure_rotation": bool(pillar_cfg.get("figure_rotation")),
}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
print(json.dumps({"status": "empty", "message": f"No approved unpublished visual {lang.upper()} items."}, indent=2))
return 1
def mark(lang: str, queue_number: str, note_id: str, pillar: str, figure_used: str):
"""Mark a queue entry published and append to the log."""
queue_text = QUEUE_PATH.read_text()
sections = parse_queue(queue_text, lang)
target = None
for s in sections:
if s["number"] == queue_number:
target = s
break
if target is None:
print(json.dumps({"status": "error", "message": f"Queue entry {queue_number} not found"}))
return 1
lines = queue_text.split("\n")
idx = target["approval_idx"][lang]
date_str = datetime.now().strftime("%Y-%m-%d")
marker_suffix = f" PUBLISHED {date_str} id:{note_id} visual:{pillar}"
if figure_used:
marker_suffix += f"/{figure_used}"
if idx is None:
new_line = f"- [x] {lang.upper()} [VISUAL:{pillar}]{marker_suffix}"
lines.insert(target["header_idx"] + 1, new_line)
else:
old = lines[idx]
# Strip any prior PUBLISHED marker, keep the checkbox + lang + visual tag, append new marker
stripped = re.sub(r"\s*PUBLISHED\s+\S+\s+id:\S+(?:\s+visual:\S+)?", "", old)
lines[idx] = stripped.rstrip() + marker_suffix
QUEUE_PATH.write_text("\n".join(lines))
# Append log
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
if not LOG_PATH.exists():
LOG_PATH.write_text(
"---\ntype: publishing-log\ncreationDate: 2026-04-17\n---\n\n"
"# Substack Publishing Log\n\n"
"Auto-generated. Each row: timestamp, lang, status, note_id, section, preview, pillar, figure.\n\n"
"| Timestamp | Lang | Status | Note ID | Section | Pillar | Figure |\n"
"|---|---|---|---|---|---|---|\n"
)
with LOG_PATH.open("a") as f:
ts = datetime.utcnow().isoformat() + "Z"
row = f"| {ts} | {lang} | OK_VISUAL | {note_id} | #{queue_number} {target['title']} | {pillar} | {figure_used or 'default'} |\n"
f.write(row)
print(json.dumps({"status": "ok", "queue_number": queue_number, "note_id": note_id, "pillar": pillar}))
return 0
def publish_visual(lang: str, png_path: str, note_body: str, publication_name: str | None = None):
"""Upload a PNG to Substack, create a note image attachment, publish a Note with it. Returns {note_id, url}."""
import base64
import urllib.parse
import urllib.request
import urllib.error
cfg = load_config()
# Map lang to publication
target = "main" if lang == "en" else "secondary"
if publication_name:
target = publication_name
pub = next((p for p in cfg["publications"] if p["name"] == target), None)
if not pub:
return {"error": f"Publication {target} not found in config"}
cookie = pub["cookie"]
if not cookie.startswith("substack.sid="):
cookie = f"substack.sid={cookie}"
headers = {"Cookie": cookie, "Accept": "application/json", "User-Agent": "SubstackVisual/1.0"}
subdomain = pub["subdomain"]
pub_base = f"https://{subdomain}.substack.com/api/v1"
global_base = "https://substack.com/api/v1"
# 1. Upload image to Substack CDN
from PIL import Image as PILImage
with PILImage.open(png_path) as im:
width, height = im.size
img_data = base64.b64encode(open(png_path, "rb").read()).decode("utf-8")
data_uri = f"data:image/png;base64,{img_data}"
form_data = urllib.parse.urlencode({"image": data_uri}).encode("utf-8")
req = urllib.request.Request(
f"{pub_base}/image",
data=form_data,
headers={**headers, "Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as r:
upload_result = json.loads(r.read())
except urllib.error.HTTPError as e:
return {"error": f"upload HTTP {e.code}", "detail": e.read().decode("utf-8", "replace")[:500]}
uploaded_url = upload_result.get("url")
if not uploaded_url:
return {"error": "CDN upload returned no URL", "detail": upload_result}
# 2. Create note attachment.
# Endpoint REQUIRES trailing slash and field name is "url" (not imageUrl).
attach_headers = {**headers, "Content-Type": "application/json"}
attach_payload = json.dumps({
"type": "image",
"url": uploaded_url,
}).encode("utf-8")
req = urllib.request.Request(f"{global_base}/comment/attachment/", data=attach_payload, headers=attach_headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as r:
attach_result = json.loads(r.read())
except urllib.error.HTTPError as e:
return {"error": f"attachment HTTP {e.code}", "detail": e.read().decode("utf-8", "replace")[:500]}
attachment_id = attach_result.get("id")
if not attachment_id:
return {"error": "No attachment_id returned", "detail": attach_result}
# 3. Publish Note with attachment
body_json = {
"type": "doc",
"attrs": {"schemaVersion": "v1"},
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": (note_body or " ").strip()}]}
],
}
note_payload = json.dumps({
"bodyJson": body_json,
"tabId": "for-you",
"surface": "feed",
"replyMinimumRole": "everyone",
"attachmentIds": [attachment_id],
}).encode("utf-8")
req = urllib.request.Request(f"{global_base}/comment/feed/", data=note_payload, headers=attach_headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as r:
publish_result = json.loads(r.read())
except urllib.error.HTTPError as e:
return {"error": f"publish HTTP {e.code}", "detail": e.read().decode("utf-8", "replace")[:500]}
note_id = publish_result.get("id")
return {
"status": "ok",
"note_id": note_id,
"url": f"https://substack.com/notes/post/p-{note_id}",
"attachment_id": attachment_id,
"cdn_url": uploaded_url,
"width": width,
"height": height,
}
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
sp_peek = sub.add_parser("peek", help="Find next approved visual queue entry")
sp_peek.add_argument("--lang", choices=["en", "es"], required=True)
sp_mark = sub.add_parser("mark", help="Mark a queue entry published")
sp_mark.add_argument("--lang", choices=["en", "es"], required=True)
sp_mark.add_argument("--queue-number", required=True)
sp_mark.add_argument("--note-id", required=True)
sp_mark.add_argument("--pillar", choices=["P1", "P2", "P3"], required=True)
sp_mark.add_argument("--figure-used", default="")
sp_rot = sub.add_parser("rotate-figure", help="Get next figure for a pillar")
sp_rot.add_argument("--pillar", choices=["P1", "P2", "P3"], required=True)
args = ap.parse_args()
if args.cmd == "peek":
sys.exit(peek(args.lang))
elif args.cmd == "mark":
sys.exit(mark(args.lang, args.queue_number, args.note_id, args.pillar, args.figure_used))
elif args.cmd == "rotate-figure":
fig = rotate_figure(args.pillar)
print(json.dumps({"figure_id": fig, "pillar": args.pillar}))
sys.exit(0 if fig else 1)
if __name__ == "__main__":
main()