#!/usr/bin/env python3 """ Tool C — pre-publish SEO validator (theexclusionzone.com, Ghost). A single-post GATE around the SHARED rule engine in seo_rules.py. It does NOT re-implement any rule — it calls check_post(), the exact same engine Tool A (seo_audit.py) runs site-wide, so a threshold change in seo_rules.py changes both tools at once. Run it on a draft (or a freshly generated article) BEFORE publishing to catch the issues we just cleaned up site-wide: long meta_description, empty OG/Twitter, missing alt, too-few internal links. Modes: python3 seo_validate.py --slug # fetch one post (draft OR published) via ghst-en python3 seo_validate.py --id # same, by id python3 seo_validate.py --json # validate a post object from a JSON file # (raw post dict, or {"posts":[{...}]}) Output: PASS/FAIL per rule group with the specific issue + fix hint. Exit code: 0 if clean (no actionable violations), 1 if any — so it can gate a publish workflow. READ-ONLY: never writes to Ghost. """ import argparse import json import os import subprocess import sys import tempfile import seo_rules as R # Display groups, in report order. Each maps to the rule-id prefixes it owns. GROUPS = [ ("meta_title", ("meta_title.",)), ("meta_description", ("meta_description.",)), ("custom_excerpt", ("custom_excerpt.",)), ("OG / Twitter", ("og_title.", "og_description.", "twitter_title.", "twitter_description.")), ("feature_image", ("feature_image.",)), ("feature_image_alt", ("feature_image_alt.",)), ("internal_links", ("internal_links.",)), ] def fetch_one(selector, value): """Fetch a single post (draft or published) via ghst-en → post dict. selector is 'slug' or 'id'. Redirect to a temp file (a post with html+lexical can approach the 64 KB pipe-buffer limit).""" fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_validate_") os.close(fd) try: flag = "--slug" if selector == "slug" else "" target = value if selector == "slug" else value cmd = (f"ghst-en post get {flag} {target} --formats html,lexical --json > {path}" if selector == "slug" else f"ghst-en post get {target} --formats html,lexical --json > {path}") r = subprocess.run(cmd, shell=True, stderr=subprocess.PIPE, text=True) if r.returncode != 0: sys.exit(f"ghst-en fetch failed for {selector}={value}:\n{r.stderr}") data = json.load(open(path)) return _unwrap(data) finally: os.unlink(path) def _unwrap(data): if isinstance(data, dict) and "posts" in data: return data["posts"][0] if isinstance(data, list): return data[0] return data def load_json(path): return _unwrap(json.load(open(path))) def validate(post): """Run the shared engine and print a per-group PASS/FAIL report. Returns the number of actionable (non-INFO) violations.""" violations = R.check_post(post) actionable = [v for v in violations if v.severity > R.INFO] info = [v for v in violations if v.severity == R.INFO] slug = post.get("slug", "(no slug)") status = post.get("status", "?") title = post.get("title", "") print("=" * 74) print(f" PRE-PUBLISH SEO CHECK — {slug} [status: {status}]") print(f" {title}") print("=" * 74) by_prefix = {} for v in violations: for name, prefixes in GROUPS: if any(v.rule.startswith(p) for p in prefixes): by_prefix.setdefault(name, []).append(v) break for name, _ in GROUPS: vs = [v for v in by_prefix.get(name, []) if v.severity > R.INFO] if not vs: print(f" [PASS] {name}") else: for v in sorted(vs, key=lambda x: -x.severity): hint = f" → {v.fix}" if v.fix else "" print(f" [FAIL] {name:18} {v.message}{hint}") # JSON-LD is handled globally by the Edition-main theme (see seo_rules). print(f" [PASS] JSON-LD (BlogPosting injected globally by theme)" if R.THEME_HANDLES_JSONLD else " [WARN] JSON-LD not theme-handled") for v in info: print(f" [note] {v.message}") print("-" * 74) if actionable: print(f" RESULT: FAIL — {len(actionable)} issue(s) to fix before publishing.") else: print(" RESULT: PASS — no actionable SEO issues. Clear to publish.") print() return len(actionable) def main(): ap = argparse.ArgumentParser(description="Pre-publish SEO validator (single post).") g = ap.add_mutually_exclusive_group(required=True) g.add_argument("--slug") g.add_argument("--id") g.add_argument("--json", metavar="FILE") args = ap.parse_args() if args.json: post = load_json(args.json) elif args.slug: post = fetch_one("slug", args.slug) else: post = fetch_one("id", args.id) n = validate(post) sys.exit(1 if n else 0) if __name__ == "__main__": main()