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:
+146
@@ -0,0 +1,146 @@
|
||||
#!/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 <slug> # fetch one post (draft OR published) via ghst-en
|
||||
python3 seo_validate.py --id <post_id> # same, by id
|
||||
python3 seo_validate.py --json <file> # 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()
|
||||
Reference in New Issue
Block a user