Fix Ghost publish target and newsletter slug
ADMIN_BASE pointed at thefulfillment.org (a parked domain) instead of the actual Ghost host blog.the-fulfillment.org, and NEWSLETTER_SLUG was "the-fulfillment" instead of Ghost's auto-generated "default-newsletter" (the display name "The Fulfillment" doesn't determine the slug). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9cc8ab9344
commit
6a3b93267d
1 changed files with 171 additions and 0 deletions
171
.claude/skills/weekly-review/publish_to_ghost.py
Normal file
171
.claude/skills/weekly-review/publish_to_ghost.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Publish a weekly review article to Ghost via the Admin API.
|
||||
|
||||
Usage:
|
||||
publish_to_ghost.py <article.md>
|
||||
|
||||
Reads the Ghost Admin API key from `pass show fulfillment/api-key`.
|
||||
Title is derived from the filename stem. The first H1 of the markdown
|
||||
is stripped before conversion (Ghost renders the title field itself).
|
||||
The newsletter slug is hardcoded below — change NEWSLETTER_SLUG if the
|
||||
publication is ever split into multiple newsletters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ADMIN_BASE = "https://blog.the-fulfillment.org/ghost/api/admin"
|
||||
API_VERSION = "v5.0"
|
||||
NEWSLETTER_SLUG = "default-newsletter"
|
||||
|
||||
|
||||
def die(msg: str, code: int = 1) -> None:
|
||||
print(f"error: {msg}", file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def b64url(b: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def mint_jwt(key_id: str, secret_hex: str) -> str:
|
||||
"""Mint an HS256 JWT for the Ghost Admin API (5-minute expiry)."""
|
||||
header = b64url(
|
||||
json.dumps(
|
||||
{"alg": "HS256", "typ": "JWT", "kid": key_id}, separators=(",", ":")
|
||||
).encode()
|
||||
)
|
||||
now = int(time.time())
|
||||
payload = b64url(
|
||||
json.dumps(
|
||||
{"iat": now, "exp": now + 300, "aud": "/admin/"}, separators=(",", ":")
|
||||
).encode()
|
||||
)
|
||||
signing_input = f"{header}.{payload}".encode()
|
||||
sig = b64url(
|
||||
hmac.new(bytes.fromhex(secret_hex), signing_input, hashlib.sha256).digest()
|
||||
)
|
||||
return f"{header}.{payload}.{sig}"
|
||||
|
||||
|
||||
def get_api_key() -> tuple[str, str]:
|
||||
"""Read `pass show fulfillment/api-key`, split on colon."""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["pass", "show", "fulfillment/api-key"], text=True
|
||||
).strip()
|
||||
except FileNotFoundError:
|
||||
die("`pass` not found on PATH")
|
||||
except subprocess.CalledProcessError as e:
|
||||
die(f"`pass show fulfillment/api-key` failed: {e}")
|
||||
if ":" not in out:
|
||||
die("API key from pass is not in the expected id:secret form")
|
||||
key_id, secret_hex = out.split(":", 1)
|
||||
return key_id, secret_hex
|
||||
|
||||
|
||||
def markdown_to_html(md: str) -> str:
|
||||
"""Convert markdown body to HTML via pandoc."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pandoc", "-f", "markdown", "-t", "html"],
|
||||
input=md,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
die("`pandoc` not found on PATH")
|
||||
except subprocess.CalledProcessError as e:
|
||||
die(f"pandoc failed: {e.stderr}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def strip_leading_h1(md: str) -> str:
|
||||
"""Drop the first non-blank line if it's an H1, then any leading blanks."""
|
||||
lines = md.splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == "":
|
||||
continue
|
||||
if line.startswith("# "):
|
||||
lines = lines[i + 1 :]
|
||||
break
|
||||
# drop leading blanks
|
||||
while lines and lines[0].strip() == "":
|
||||
lines.pop(0)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def ghost_request(method: str, url: str, jwt: str, body: dict | None) -> dict:
|
||||
headers = {
|
||||
"Authorization": f"Ghost {jwt}",
|
||||
"Accept-Version": API_VERSION,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode(errors="replace")
|
||||
die(f"{method} {url} → HTTP {e.code}\n{detail}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) != 2:
|
||||
die("usage: publish_to_ghost.py <article.md>", code=2)
|
||||
|
||||
article_path = Path(sys.argv[1])
|
||||
|
||||
if not article_path.is_file():
|
||||
die(f"not a file: {article_path}")
|
||||
|
||||
title = article_path.stem
|
||||
raw = article_path.read_text(encoding="utf-8")
|
||||
body_md = strip_leading_h1(raw)
|
||||
html = markdown_to_html(body_md)
|
||||
|
||||
key_id, secret_hex = get_api_key()
|
||||
jwt = mint_jwt(key_id, secret_hex)
|
||||
|
||||
# Step 1: create draft
|
||||
draft = ghost_request(
|
||||
"POST",
|
||||
f"{ADMIN_BASE}/posts/?source=html",
|
||||
jwt,
|
||||
{"posts": [{"title": title, "html": html, "status": "draft"}]},
|
||||
)
|
||||
post = draft["posts"][0]
|
||||
post_id = post["id"]
|
||||
updated_at = post["updated_at"]
|
||||
print(f"draft created: id={post_id} updated_at={updated_at}")
|
||||
|
||||
# Step 2: publish + send newsletter
|
||||
# Mint a fresh JWT in case step 1 took a while (5-min budget is plenty,
|
||||
# but free).
|
||||
jwt = mint_jwt(key_id, secret_hex)
|
||||
published = ghost_request(
|
||||
"PUT",
|
||||
f"{ADMIN_BASE}/posts/{post_id}/"
|
||||
f"?newsletter={NEWSLETTER_SLUG}&email_segment=all",
|
||||
jwt,
|
||||
{"posts": [{"updated_at": updated_at, "status": "published"}]},
|
||||
)
|
||||
p = published["posts"][0]
|
||||
email_status = (p.get("email") or {}).get("status", "none")
|
||||
print(f"published: {p.get('url')} (email: {email_status})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue