#!/usr/bin/env python3 """ Ghost CMS Publisher for OurDigital Blog Publishes markdown content to Ghost CMS via Admin API. Usage: python ghost_publish.py --file post.md --draft python ghost_publish.py --file post.md --publish """ import argparse import os import re import jwt import time import requests from datetime import datetime from pathlib import Path from dotenv import load_dotenv # Load environment variables load_dotenv(os.path.expanduser("~/.env.ourdigital")) def create_ghost_token(admin_key: str) -> str: """Create JWT token for Ghost Admin API.""" key_id, secret = admin_key.split(":") iat = int(time.time()) header = {"alg": "HS256", "typ": "JWT", "kid": key_id} payload = { "iat": iat, "exp": iat + 5 * 60, # 5 minutes "aud": "/admin/" } token = jwt.encode(payload, bytes.fromhex(secret), algorithm="HS256", headers=header) return token def parse_frontmatter(content: str) -> tuple[dict, str]: """Parse YAML frontmatter from markdown content.""" import yaml pattern = r'^---\s*\n(.*?)\n---\s*\n(.*)$' match = re.match(pattern, content, re.DOTALL) if match: frontmatter = yaml.safe_load(match.group(1)) body = match.group(2) return frontmatter, body return {}, content def publish_to_ghost( file_path: str, ghost_url: str, admin_key: str, draft: bool = True ) -> dict: """Publish markdown file to Ghost CMS.""" # Read and parse file content = Path(file_path).read_text(encoding="utf-8") frontmatter, body = parse_frontmatter(content) # Create JWT token token = create_ghost_token(admin_key) # Prepare post data post_data = { "posts": [{ "title": frontmatter.get("title", "Untitled"), "slug": frontmatter.get("slug"), "html": markdown_to_html(body), "meta_description": frontmatter.get("meta_description", ""), "tags": [{"name": tag} for tag in frontmatter.get("tags", [])], "status": "draft" if draft else "published", "authors": [{"email": "andrew.yim@ourdigital.org"}] }] } # Add feature image if provided if frontmatter.get("featured_image"): post_data["posts"][0]["feature_image"] = frontmatter["featured_image"] # Make API request api_url = f"{ghost_url}/ghost/api/admin/posts/" headers = { "Authorization": f"Ghost {token}", "Content-Type": "application/json" } response = requests.post(api_url, json=post_data, headers=headers) response.raise_for_status() return response.json() def markdown_to_html(markdown_text: str) -> str: """Convert markdown to HTML.""" try: import markdown return markdown.markdown( markdown_text, extensions=["tables", "fenced_code", "codehilite"] ) except ImportError: # Basic conversion if markdown library not available html = markdown_text.replace("\n\n", "

") html = f"

{html}

" return html def main(): parser = argparse.ArgumentParser(description="Publish to Ghost CMS") parser.add_argument("--file", required=True, help="Markdown file to publish") parser.add_argument("--draft", action="store_true", help="Publish as draft") parser.add_argument("--publish", action="store_true", help="Publish immediately") parser.add_argument("--channel", default="blog", choices=["blog", "journal", "ourstory"], help="Ghost channel to publish to") args = parser.parse_args() # Get credentials based on channel channel_map = { "blog": ("GHOST_BLOG_URL", "GHOST_BLOG_ADMIN_KEY"), "journal": ("GHOST_JOURNAL_URL", "GHOST_JOURNAL_ADMIN_KEY"), "ourstory": ("GHOST_OURSTORY_URL", "GHOST_OURSTORY_ADMIN_KEY") } url_var, key_var = channel_map[args.channel] ghost_url = os.getenv(url_var) admin_key = os.getenv(key_var) if not ghost_url or not admin_key: print(f"Error: Missing credentials for {args.channel}") print(f"Set {url_var} and {key_var} in ~/.env.ourdigital") return 1 try: result = publish_to_ghost( args.file, ghost_url, admin_key, draft=not args.publish ) post = result["posts"][0] status = "Draft" if not args.publish else "Published" print(f"{status}: {post['title']}") print(f"URL: {ghost_url}/{post['slug']}/") print(f"Edit: {ghost_url}/ghost/#/editor/post/{post['id']}") return 0 except Exception as e: print(f"Error: {e}") return 1 if __name__ == "__main__": exit(main())