Initial commit: SEO rule engine, auditor, validator, exceptions, mirror
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>
This commit is contained in:
Executable
+216
@@ -0,0 +1,216 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user