Versioning ~/seo-tools as critical SEO infrastructure (was home-dir only). Canonical home of seo_rules.py — ResearchOwl will vendor a copy with a CI diff guard against this repo. No secrets; Ghost access is via the ghst-en wrapper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
217 lines
8.9 KiB
Python
Executable File
217 lines
8.9 KiB
Python
Executable File
#!/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()
|