#!/usr/bin/env python3 """ Tool A — site-wide SEO auditor for theexclusionzone.com (EN, Ghost). READ-ONLY. Fetches every published post via the ghst-en wrapper (Ghost Admin API, staff token pulled on-demand from the pod — nothing written to disk), runs the shared rule engine (seo_rules.py), and prints: 1. a summary of aggregate violations, 2. a per-post table sorted worst-first, 3. a ranked "fix this first" list. Usage: python3 seo_audit.py # fetch live, full report python3 seo_audit.py --json cache.json # audit a saved posts JSON instead of fetching python3 seo_audit.py --save cache.json # fetch live AND save the raw JSON Changes NOTHING in Ghost. """ import argparse import json import os import subprocess import sys import tempfile from collections import Counter import seo_rules as R import seo_exceptions as X def partition(post, violations): """Split one post's violations into (actionable, accepted, info). 'accepted' = an actionable-severity violation whose (rule, slug) is a documented waiver in seo_exceptions — kept OUT of the to-fix count and shown in its own section. The rule still ran; this is a reporting-side filter only. """ slug = post.get("slug") actionable, accepted, info = [], [], [] for v in violations: if v.severity == R.INFO: info.append(v) elif X.accepted_reason(v.rule, slug): accepted.append(v) else: actionable.append(v) return actionable, accepted, info def fetch_posts(): """Pull all published posts (with html) via ghst-en. Returns list[dict]. We redirect ghst's stdout to a temp file rather than capturing the pipe: ghst's output is ~750 KB and a captured PIPE truncates at the 64 KB pipe buffer, whereas a file redirect gets the whole document. """ fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_audit_") os.close(fd) try: cmd = ("ghst-en post list --status published --limit 100 " "--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:\n{r.stderr}") data = json.load(open(path)) return data["posts"] if isinstance(data, dict) and "posts" in data else data finally: os.unlink(path) def load_posts(path): data = json.load(open(path)) return data["posts"] if isinstance(data, dict) and "posts" in data else data def main(): ap = argparse.ArgumentParser() ap.add_argument("--json", help="audit a saved posts JSON instead of fetching live") ap.add_argument("--save", help="save fetched raw posts JSON to this path") args = ap.parse_args() if args.json: posts = load_posts(args.json) src = f"file {args.json}" else: posts = fetch_posts() src = "live (ghst-en, Ghost Admin API)" if args.save: json.dump({"posts": posts}, open(args.save, "w"), indent=2) results = [] # (post, actionable, accepted, info, score) for p in posts: v = R.check_post(p) actionable, accepted, info = partition(p, v) # score ranks worst-first on ACTIONABLE severity only — accepted waivers # and INFO don't push a post up the list. results.append((p, actionable, accepted, info, R.score(actionable))) # worst-first: by actionable severity score, then actionable count, then slug results.sort(key=lambda t: (-t[4], -len(t[1]), t[0]["slug"])) total_posts = len(posts) # SUMMARY counts reflect ACTIONABLE violations only (waived items excluded). posts_with_rule = Counter() for _, actionable, _, _, _ in results: for rule in {v.rule for v in actionable}: posts_with_rule[rule] += 1 clean = sum(1 for _, actionable, _, _, _ in results if not actionable) accepted_total = sum(len(acc) for _, _, acc, _, _ in results) # ---- header ---- print("=" * 78) print(f" SEO AUDIT — theexclusionzone.com source: {src}") print(f" {total_posts} published posts | {clean} clean (no actionable issues)" + (f" | {accepted_total} accepted exception(s) waived" if accepted_total else "")) print(f" JSON-LD: handled globally by Edition-main theme — not flagged per post") print("=" * 78) # ---- summary ---- print("\nSUMMARY — violations by rule (most posts affected first):\n") LABEL = { "meta_description.missing": "meta_description MISSING", "meta_description.too_long": f"meta_description > {R.META_DESC_MAX}", "meta_title.missing": "meta_title missing (falls back to title)", "meta_title.too_long": f"meta_title > {R.META_TITLE_MAX}", "custom_excerpt.too_long": f"custom_excerpt > {R.CUSTOM_EXCERPT_MAX}", "og_title.empty": "og_title empty", "og_description.empty": "og_description empty", "twitter_title.empty": "twitter_title empty", "twitter_description.empty": "twitter_description empty", "feature_image.missing": "feature_image missing", "feature_image_alt.missing": "feature_image_alt missing", "internal_links.too_few": f"internal links < {R.MIN_INTERNAL_LINKS}", "title_eq_meta_title": "meta_title == title (info)", } # severity per rule (look one up from any occurrence, incl. waived ones) sev_of = {} for _, actionable, accepted, info, _ in results: for v in actionable + accepted + info: sev_of[v.rule] = v.severity for rule, _ in sorted(posts_with_rule.items(), key=lambda kv: (-sev_of.get(kv[0], 0), -kv[1])): sev = R.SEV_NAME[sev_of.get(rule, 0)] print(f" [{sev:4}] {posts_with_rule[rule]:2}/{total_posts} posts " f"{LABEL.get(rule, rule)}") # ---- per-post table ---- print("\n" + "-" * 78) print("PER-POST (worst first) — score = summed severity (HIGH 3 / MED 2 / LOW 1)\n") print(f"{'score':>5} {'slug':45} issues") print(f"{'-'*5} {'-'*45} {'-'*20}") for p, actionable, accepted, info, sc in results: if not actionable: note = "✓ clean" + (f" ({len(accepted)} accepted exception)" if accepted else "") print(f"{sc:5} {p['slug'][:45]:45} {note}") continue tags = ", ".join(sorted({v.rule for v in actionable})) print(f"{sc:5} {p['slug'][:45]:45} {tags}") # ---- detail per non-clean post ---- print("\n" + "-" * 78) print("DETAIL\n") for p, actionable, accepted, info, sc in results: if not actionable and not info: continue print(f"• {p['slug']} (score {sc})") for v in sorted(actionable, key=lambda x: -x.severity): print(f" [{R.SEV_NAME[v.severity]:4}] {v.message}" + (f" → {v.fix}" if v.fix else "")) for v in info: print(f" [INFO] {v.message}") print() # ---- accepted exceptions (won't fix — documented) ---- if accepted_total: print("-" * 78) print("ACCEPTED EXCEPTIONS (won't fix — documented decisions, NOT in the count)\n") for p, actionable, accepted, info, sc in sorted(results, key=lambda t: t[0]["slug"]): for v in accepted: reason = X.accepted_reason(v.rule, p["slug"]) print(f"• {p['slug']}") print(f" [{R.SEV_NAME[v.severity]:4}] {v.message}") print(f" waived: {reason}") print() # ---- ranked guidance ---- print("-" * 78) print("FIX FIRST (by impact / effort):\n") md_missing = posts_with_rule.get("meta_description.missing", 0) md_long = posts_with_rule.get("meta_description.too_long", 0) links = posts_with_rule.get("internal_links.too_few", 0) og = posts_with_rule.get("og_title.empty", 0) alt = posts_with_rule.get("feature_image_alt.missing", 0) rank = [] if md_missing: rank.append(f"meta_description MISSING on {md_missing} post(s) — HIGH impact, " f"controls the Google snippet. Write unique 140–145-char descriptions.") if md_long: rank.append(f"meta_description > {R.META_DESC_MAX} on {md_long} post(s) — trim so the " f"snippet isn't truncated (real ranking/CTR impact).") if links: rank.append(f"<{R.MIN_INTERNAL_LINKS} internal links on {links} post(s) — add 2–3 contextual " f"links to related articles (helps crawl + topical authority).") if og: rank.append(f"Empty OG/Twitter fields on {og} post(s) — SAFEST AUTO-FIX: mirror " f"meta_title/description into og_* and twitter_*. Highest count, lowest risk.") if alt: rank.append(f"Missing feature_image_alt on {alt} post(s) — quick accessibility + image-SEO win.") for i, line in enumerate(rank, 1): print(f" {i}. {line}") print() if __name__ == "__main__": main()