Compare commits
2 commits
9cc8ab9344
...
d6f049b08b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6f049b08b | ||
|
|
6a3b93267d |
2 changed files with 189 additions and 56 deletions
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
name: weekly-review
|
||||
description: Use this skill when the user asks to generate a "weekly review", create a "review article", run the "weekly review" process, or mentions generating content for AI, Intelligence Augmentation, Longevity, Resource Abundance, Energy, or Space subjects on their scheduled days.
|
||||
version: 1.3.1
|
||||
description: Use this skill when the user asks to generate a "weekly review", create a "review article", run the "weekly review" process, or mentions generating content for AI, Intelligence Augmentation, Longevity, Resource Abundance, Energy, Space, or Robotics on their scheduled days. The skill generates the article AND publishes it to Ghost — publishing is part of the skill, not a separate step the user must request.
|
||||
version: 1.4.4
|
||||
---
|
||||
|
||||
# Weekly Review Article Generator
|
||||
|
|
@ -84,7 +84,7 @@ Generate weekly review articles covering the best 10 items from the past week in
|
|||
| `resource-abundance/` | Resource Abundance |
|
||||
| `energy/` | Energy |
|
||||
| `space/` | Space |
|
||||
| 'robotics/' | Robotics |
|
||||
| `robotics/` | Robotics |
|
||||
|
||||
|
||||
|
||||
|
|
@ -128,7 +128,8 @@ Source: [Publication Name](url)
|
|||
|
||||
7. **Save the article**
|
||||
- Write to the appropriate subject directory
|
||||
- Confirm completion with the filename
|
||||
|
||||
8. **Publish to Ghost** — run the script described in "Publish via Ghost Admin API" below. This step is not optional. Do not ask the user for confirmation; do not stop after step 7. The skill is not complete until the script has exited successfully. If the script exits non-zero, report the error to the user; otherwise report the published URL.
|
||||
|
||||
## Writing Style
|
||||
|
||||
|
|
@ -139,62 +140,23 @@ Source: [Publication Name](url)
|
|||
- Connect items to broader trends and implications
|
||||
- When a claim depends on a specific number or date, attribute it to a source rather than presenting it as established fact
|
||||
|
||||
## Future: Publishing via Ghost Admin API
|
||||
## Publish via Ghost Admin API
|
||||
|
||||
This skill's scope ends at "file saved to disk." A separate publisher script handles the Ghost API integration. Documenting the contract here so the invariant doesn't drift.
|
||||
Publishing is handled by `publish_to_ghost.py` in this skill directory. It mints a short-lived JWT from the Ghost Admin API key (returned by `pass show fulfillment/api-key` in `id:secret` form), strips the leading H1 from the article, converts the rest to HTML with pandoc, and runs Ghost's required two-step draft-then-publish flow so the newsletter email is sent.
|
||||
|
||||
### The invariant
|
||||
|
||||
The filename-to-title chain established above extends naturally into publishing:
|
||||
|
||||
- Filename stem (minus `.md`) → Ghost `title` field in the API body
|
||||
- First H1 of the markdown → same string, duplicated for local readability
|
||||
- Everything after the first H1 → Ghost post body (HTML-converted, first H1 stripped)
|
||||
|
||||
The Ghost newsletter slug is a separate piece of configuration — a single value identifying which newsletter in Ghost Admin the post should be emailed through. Pipeline-level config, not per-file. Each newsletter must be created once in Ghost Admin (Settings → Email newsletter → Newsletters) before the first automated publish; the API cannot create newsletters.
|
||||
|
||||
### Two-step publish pattern
|
||||
|
||||
Ghost's Admin API requires a draft-then-publish flow to trigger newsletter email send. Single POST with `status: published` and a `newsletter` query parameter silently publishes without emailing. Confirmed pattern:
|
||||
|
||||
**Step 1 — create draft:**
|
||||
```
|
||||
POST /admin/posts/?source=html
|
||||
Body: {
|
||||
"posts": [{
|
||||
"title": "<filename stem>",
|
||||
"html": "<body HTML, first H1 stripped>",
|
||||
"status": "draft",
|
||||
"newsletter": "<newsletter-slug>"
|
||||
}]
|
||||
}
|
||||
```
|
||||
Response contains `id` and `updated_at` — capture both.
|
||||
|
||||
**Step 2 — publish and send:**
|
||||
```
|
||||
PUT /admin/posts/<id>/?newsletter=<newsletter-slug>&email_segment=all
|
||||
Body: {
|
||||
"posts": [{
|
||||
"updated_at": "<from step 1 response>",
|
||||
"status": "published"
|
||||
}]
|
||||
}
|
||||
```
|
||||
The `newsletter` query parameter must be on both requests. `email_segment=all` sends to all subscribers; `status:free` or `status:-free` target free-only or paid-only.
|
||||
|
||||
Do NOT set `email_only: true` — that suppresses the public blog post and sends email only. Weekly reviews should appear on both the blog and in email.
|
||||
|
||||
### Body transformation
|
||||
|
||||
The markdown on disk starts with an H1 that duplicates the Ghost `title`. Strip it before converting to HTML and sending, otherwise Ghost renders the title twice:
|
||||
Invoke it with the article path:
|
||||
|
||||
```bash
|
||||
title=$(basename "$file" .md)
|
||||
body=$(tail -n +2 "$file" | sed '/./,$!d') # drop first line, then leading blanks
|
||||
html=$(echo "$body" | pandoc -f markdown -t html)
|
||||
python3 <skill-dir>/publish_to_ghost.py <subject-directory>/<article.md>
|
||||
```
|
||||
|
||||
### Validation
|
||||
The newsletter slug (`default-newsletter`) is a constant at the top of the script. There is one newsletter for the whole publication. If you ever split into per-subject or per-section-frame newsletters, change `NEWSLETTER_SLUG` in the script — or promote it back to a CLI argument and add a per-subject mapping here.
|
||||
|
||||
Pipeline invariants the script enforces:
|
||||
|
||||
- The filename stem becomes the Ghost `title` field. The first H1 of the markdown is the same string, duplicated for local readability, and is stripped before HTML conversion so Ghost doesn't render the title twice.
|
||||
- Draft-then-publish is required to trigger newsletter send. A single POST with `status: "published"` silently publishes without emailing.
|
||||
- `email_only: true` is deliberately NOT set — weekly reviews appear on both the blog and in email.
|
||||
|
||||
The script exits non-zero with the HTTP body on any API failure.
|
||||
|
||||
Before publishing, the publisher should verify that the first H1 of the file matches `basename "$file" .md`. This catches rename/edit drift — cases where the filename was changed but the H1 wasn't, or vice versa. If the check fails, refuse to publish and surface the mismatch.
|
||||
|
|
|
|||
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