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:
ChemaVX
2026-06-24 08:01:44 +00:00
co-authored by Claude Opus 4.8
commit beae7c154a
9 changed files with 844 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
*.pyo
.env
*.token
*cache*.json
+15
View File
@@ -0,0 +1,15 @@
# seo-tools
SEO rule engine + tooling for the Ghost blogs (EN: theexclusionzone.com).
- `seo_rules.py` — shared rule engine (pure functions, no I/O). Single source of truth
for thresholds + checks. Consumed by the auditor, the validator, and (vendored) by
ResearchOwl's blog pipeline.
- `seo_audit.py` — Tool A: site-wide auditor (reads all published posts via `ghst-en`).
- `seo_validate.py` — Tool C: single-post pre-publish validator (`seo-check` wraps this).
- `seo_exceptions.py` — documented, consciously-accepted waivers (auditor reporting filter).
- `seo_mirror.py` — OG/Twitter mirroring helper.
- `phase2_*.py/json` — one-off batch helpers (kept for provenance).
No secrets live here. Ghost access is via the `ghst-en` wrapper (`~/.local/bin`), which
fetches a staff token on demand from the pod and never writes it to disk.
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""
Phase 2 apply — trim 9 long meta_descriptions, keep og/twitter mirrored.
Reads new descriptions from phase2_desc.json (slug -> new meta_description).
For each post sets meta_description = og_description = twitter_description to the
SAME new value via a minimal ghst --from-json patch (+ updated_at for Ghost's
collision check). Touches nothing else. READ-ONLY against meta_title/body/etc.
"""
import json, os, subprocess, sys, tempfile
from seo_audit import fetch_posts
NEW = json.load(open(os.path.join(os.path.dirname(__file__), "phase2_desc.json")))
def apply_one(post, val):
body = {"posts": [{
"meta_description": val,
"og_description": val,
"twitter_description": val,
"updated_at": post["updated_at"],
}]}
fd, jp = tempfile.mkstemp(suffix=".json"); os.close(fd)
fd, op = tempfile.mkstemp(suffix=".json"); os.close(fd)
try:
json.dump(body, open(jp, "w"))
cmd = f"ghst-en post update {post['id']} --from-json {jp} --json > {op}"
r = subprocess.run(cmd, shell=True, stderr=subprocess.PIPE, text=True)
if r.returncode != 0:
return r.stderr.strip()
return None
finally:
os.unlink(jp); os.unlink(op)
def main():
posts = {p["slug"]: p for p in fetch_posts()}
ok, fail = [], []
for slug, val in NEW.items():
p = posts.get(slug)
if not p:
fail.append((slug, "slug not found in live posts")); print(f" FAIL {slug}: not found"); continue
err = apply_one(p, val)
if err:
fail.append((slug, err)); print(f" FAIL {slug}: {err[:90]}")
else:
ok.append(slug); print(f" OK {slug} (meta/og/twitter desc = {len(val)} chars)")
print(f"\nApplied: {len(ok)} | Failed: {len(fail)}")
for s, e in fail:
print(f" FAILED {s}: {e}")
if __name__ == "__main__":
main()
+11
View File
@@ -0,0 +1,11 @@
{
"rendlesham-forest-incident-1980": "Military witnesses, physical trace evidence, and official government files — the most thoroughly documented UFO case in British history.",
"david-grusch-capitol-2026-whistleblower-uap-disclosure": "Three years after his sworn testimony, Grusch returned to Capitol Hill demanding the declassification Congress still hasn't delivered.",
"malmstrom-1967-ufo-nuclear-missiles-incident": "In 1967, UFOs were seen over Malmstrom AFB as twenty nuclear missiles went offline at once. Boeing found no technical explanation.",
"spielberg-disclosure-day-2026-uap-film": "Spielberg's \"Disclosure Day\": how fifty years of research turned Hollywood's greatest UFO skeptic into a believer.",
"pursue-release-3-cia-confession-apollo-uap-files-2026": "The Pentagon's third UAP release: a CIA confession about spy planes, Apollo lunar anomalies, and 294 files spanning 82 years.",
"immaculate-constellation-secret-uap-program-congress": "A 2024 whistleblower document alleges a secret Pentagon UAP program. Nineteen months later, it's still neither confirmed nor denied.",
"aaro-ufo-investigation-eight-decades-us-government-research": "AARO found no alien technology after 80 years of investigation. What the Pentagon's UFO office discovered — and what it didn't.",
"gimbal-gofast-pentagon-analysis-uap-videos": "Five years after the Pentagon released GIMBAL and GOFAST, both remain unidentified. The data that would resolve them is still secret.",
"222-declassified-ufo-files-reveal-hide-unanswered-2026": "222 files, 82 years of records. What the Pentagon's largest UAP declassification reveals, what it hides, and what it leaves unanswered."
}
Executable
+216
View File
@@ -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 140145-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 23 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()
+60
View File
@@ -0,0 +1,60 @@
"""
Accepted SEO exceptions — consciously-waived findings for SPECIFIC posts.
This is an A-side (auditor) REPORTING filter, NOT a rule change. The shared rule
engine (seo_rules.py) still CHECKS every rule on every post; the auditor simply
moves a matching violation into a separate "Accepted exceptions (won't fix —
documented)" section so the headline "issues to fix" count reflects only
genuinely-actionable items. Each waiver carries its REASON (no silent skips).
Structure: EXCEPTIONS[rule_family][slug] = "documented reason"
Scope:
- A waiver applies ONLY to the named rule family for the named slug. Any OTHER
rule the same post violates still surfaces as a real issue.
- A waiver is matched against a Violation whose `rule` equals the family key or
starts with "<family>." — so "internal_links" covers "internal_links.too_few".
- If a post later stops violating the rule (e.g. a link is added and it clears
the threshold), there's simply no violation to waive — the waiver is moot.
Tool C (seo_validate.py, pre-publish validator) deliberately does NOT consult
this registry: exceptions are per-EXISTING-post decisions; a brand-new draft is
held to the full standard.
"""
EXCEPTIONS = {
# internal_links < 2 — these posts are intentionally left as-is because there
# is no honest 2nd internal-link target in their prose; forcing one would hurt
# quality. (Decided 2026-06-23.)
"internal_links": {
"roswell-1947-ufo-incident":
"1 link (Spielberg); no 2nd entity in prose maps to an article — "
"forcing a link would hurt quality.",
"manises-1979-spain-ufo-incident":
"1 link (Canary Islands); no 2nd entity in prose maps to an article.",
"betty-barney-hill-1961-abduction":
"1 link (alien abduction → travis-walton, thematic); no 2nd honest target.",
"malmstrom-1967-ufo-nuclear-missiles-incident":
"1 link (Grusch); the only entity named in the prose.",
"rendlesham-forest-incident-1980":
"0 links; genuinely isolated — no entity in prose maps to one of our articles.",
"phoenix-lights-1997":
"0 links; only thematic candidates are stretches — left as-is.",
"canary-islands-1976-ufo-sighting":
"1 link (reciprocal → manises); no honest 2nd entity in prose — "
"Spanish case, self-contained.",
},
}
def accepted_reason(rule, slug):
"""Return the documented waiver reason if (rule, slug) is an accepted
exception, else None.
Matches a rule-family key to a Violation whose `rule` is the key itself or
"<key>.<detail>" (e.g. family "internal_links" matches "internal_links.too_few").
"""
for family, slugs in EXCEPTIONS.items():
if (rule == family or rule.startswith(family + ".")) and slug in slugs:
return slugs[slug]
return None
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
Tool A — OG/Twitter auto-mirror write path (theexclusionzone.com, Ghost).
For each published post, mirror into EMPTY social fields only:
og_title <- meta_title (only if og_title empty)
twitter_title <- meta_title (only if twitter_title empty)
og_description <- meta_description (only if og_description empty)
twitter_description<- meta_description (only if twitter_description empty)
Never overwrites a non-empty field. Touches ONLY those 4 fields (+ updated_at,
required by Ghost's optimistic-concurrency check). Idempotent: re-running only
fills what is still empty.
Modes:
python3 seo_mirror.py --list # fetch live, show posts needing mirror
python3 seo_mirror.py --apply <slug> # apply to ONE post
python3 seo_mirror.py --apply-all [--skip a,b] # apply to all needing, minus skips
Reuses fetch + emptiness logic from the audit engine. READ-ONLY in --list.
"""
import argparse
import json
import os
import subprocess
import sys
import tempfile
import seo_rules as R
from seo_audit import fetch_posts
SOCIAL = {
"og_title": "meta_title",
"twitter_title": "meta_title",
"og_description": "meta_description",
"twitter_description": "meta_description",
}
def build_patch(post):
"""Return {field: source_value} for each EMPTY social field with a non-empty source."""
patch = {}
for field, src in SOCIAL.items():
if R._empty(post.get(field)):
srcval = post.get(src)
if not R._empty(srcval):
patch[field] = srcval
return patch
def needing(posts):
out = []
for p in posts:
patch = build_patch(p)
if patch:
out.append((p, patch))
return out
def apply_one(post, patch):
"""PATCH one post via ghst-en with a minimal --from-json body. Returns ghst JSON."""
body = {"posts": [dict(patch, updated_at=post["updated_at"])]}
fd, jpath = tempfile.mkstemp(suffix=".json", prefix="mirror_patch_")
os.close(fd)
fd, opath = tempfile.mkstemp(suffix=".json", prefix="mirror_out_")
os.close(fd)
try:
json.dump(body, open(jpath, "w"))
cmd = f"ghst-en post update {post['id']} --from-json {jpath} --json > {opath}"
r = subprocess.run(cmd, shell=True, stderr=subprocess.PIPE, text=True)
if r.returncode != 0:
return {"_error": r.stderr.strip()}
return json.load(open(opath))
finally:
os.unlink(jpath)
os.unlink(opath)
def fmt_patch(patch):
return ", ".join(f"{k}{('meta_title' if 'title' in k else 'meta_description')}" for k in patch)
def main():
ap = argparse.ArgumentParser()
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--list", action="store_true")
g.add_argument("--apply", metavar="SLUG")
g.add_argument("--apply-all", action="store_true")
ap.add_argument("--skip", default="", help="comma-separated slugs to skip")
args = ap.parse_args()
posts = fetch_posts()
todo = needing(posts)
skip = {s.strip() for s in args.skip.split(",") if s.strip()}
if args.list:
print(f"{len(todo)} post(s) need the OG/Twitter mirror "
f"(of {len(posts)} published):\n")
for p, patch in sorted(todo, key=lambda t: t[0]["slug"]):
print(f" {p['slug'][:50]:50} fills: {', '.join(patch.keys())}")
return
if args.apply:
match = [(p, pt) for p, pt in todo if p["slug"] == args.apply]
if not match:
sys.exit(f"'{args.apply}' not found among posts needing mirror "
f"(maybe already mirrored, or empty meta source).")
p, patch = match[0]
print(f"Applying to {p['slug']} ({p['id']}): {fmt_patch(patch)}")
res = apply_one(p, patch)
if "_error" in res:
sys.exit(f"FAILED: {res['_error']}")
print(" ghst returned OK.")
return
# --apply-all
applied, failed = [], []
for p, patch in sorted(todo, key=lambda t: t[0]["slug"]):
if p["slug"] in skip:
print(f" SKIP {p['slug']}")
continue
res = apply_one(p, patch)
if "_error" in res:
failed.append((p["slug"], res["_error"]))
print(f" FAIL {p['slug']}: {res['_error'][:80]}")
else:
applied.append(p["slug"])
print(f" OK {p['slug']} ({', '.join(patch.keys())})")
print(f"\nApplied: {len(applied)} | Failed: {len(failed)} | Skipped: {len(skip)}")
if failed:
for s, e in failed:
print(f" FAILED {s}: {e}")
if __name__ == "__main__":
main()
+203
View File
@@ -0,0 +1,203 @@
"""
Reusable SEO rule engine for The Exclusion Zone (EN) — theexclusionzone.com (Ghost).
Pure functions, NO I/O. Feed it a Ghost Admin API post dict (with `html` format
included) and it returns a list of Violation(rule, severity, message, fix).
Shared by:
- seo_audit.py — Tool A, site-wide auditor (this engine, run over all posts)
- (future) seo_validate.py — Tool C, pre-publish validator (same engine, one draft)
Design note: every check is an independent function registered in RULES. To add a
rule, write a function (post) -> list[Violation] and append it to RULES. The auditor
and the validator both just call check_post(); they never re-implement a check.
"""
import re
from collections import namedtuple
SITE_HOST = "theexclusionzone.com"
# ---- thresholds (single source of truth, reused by validator) -------------
META_TITLE_MAX = 60
META_DESC_MAX = 145
CUSTOM_EXCERPT_MAX = 300
MIN_INTERNAL_LINKS = 2
# ---- severity weights (used to rank "worst first") ------------------------
HIGH, MED, LOW, INFO = 3, 2, 1, 0
SEV_NAME = {HIGH: "HIGH", MED: "MED", LOW: "LOW", INFO: "INFO"}
# The Edition-main theme injects BlogPosting JSON-LD globally in default.hbs
# ({{#is "post"}} ... <script type="application/ld+json"> @type BlogPosting),
# plus Ghost's own {{ghost_head}}. So JSON-LD is handled site-wide and is NOT a
# per-post rule. Flip to False only if that theme block is ever removed.
THEME_HANDLES_JSONLD = True
Violation = namedtuple("Violation", "rule severity message fix")
def _s(v):
return v if isinstance(v, str) else ""
def _empty(v):
return not (isinstance(v, str) and v.strip())
# First path segments that are NOT article posts on a Ghost site (tags, authors,
# pagination, static content, etc.). Keeps root-relative link counting from
# treating /tag/foo or /content/images/... as an internal article link.
NON_POST_PREFIXES = {
"tag", "tags", "author", "page", "p", "content", "assets",
"rss", "ghost", "members", "404", "sitemap",
}
def _internal_slug(href, self_slug):
"""Return the article slug an href points to if it's an internal POST link,
else None. Accepts both absolute (contains SITE_HOST) and root-relative
("/slug/") forms; rejects protocol-relative ("//host"), anchors, mailto,
external links, static assets, and known non-post sections."""
if SITE_HOST in href:
path = re.sub(r"^https?://[^/]+/", "", href)
elif href.startswith("/") and not href.startswith("//"):
path = href[1:]
else:
return None
slug = path.strip("/").split("/")[0]
if not slug or slug == self_slug:
return None
if slug in NON_POST_PREFIXES or "." in slug: # section page or static asset
return None
return slug
def internal_links(post):
"""Distinct internal article slugs linked from the body, excluding self-links.
Counts BOTH absolute internal links (href containing SITE_HOST) and
root-relative links ("/slug/"), so a Ghost-relative link isn't miscounted as
"too few". Tool A (auditor) and Tool C (validator) share this, staying in sync.
"""
html = _s(post.get("html"))
self_slug = post.get("slug")
out = set()
for m in re.finditer(r'href="([^"#]+)"', html):
slug = _internal_slug(m.group(1), self_slug)
if slug:
out.add(slug)
return out
# --- individual rules -------------------------------------------------------
def r_meta_title(p):
mt = _s(p.get("meta_title"))
title = _s(p.get("title"))
if _empty(mt):
sev = MED if len(title) > META_TITLE_MAX else LOW
return [Violation("meta_title.missing", sev,
f"meta_title missing → falls back to title ({len(title)} chars"
+ (f", which is >{META_TITLE_MAX}!" if len(title) > META_TITLE_MAX else "") + ")",
"set meta_title")]
if len(mt) > META_TITLE_MAX:
return [Violation("meta_title.too_long", MED,
f"meta_title {len(mt)} chars > {META_TITLE_MAX}", "shorten meta_title")]
return []
def r_meta_description(p):
md = _s(p.get("meta_description"))
if _empty(md):
return [Violation("meta_description.missing", HIGH,
"meta_description MISSING (no SERP snippet control)", "write meta_description")]
if len(md) > META_DESC_MAX:
return [Violation("meta_description.too_long", MED,
f"meta_description {len(md)} chars > {META_DESC_MAX} (will be truncated in SERP)",
"shorten meta_description")]
return []
def r_custom_excerpt(p):
ce = _s(p.get("custom_excerpt"))
if ce and len(ce) > CUSTOM_EXCERPT_MAX:
return [Violation("custom_excerpt.too_long", LOW,
f"custom_excerpt {len(ce)} chars > {CUSTOM_EXCERPT_MAX}", "shorten custom_excerpt")]
return []
def r_social_fields(p):
"""OG/Twitter empties — the safest auto-fixable category (mirror meta_*)."""
out = []
fallbacks = {
"og_title": "meta_title",
"og_description": "meta_description",
"twitter_title": "meta_title",
"twitter_description": "meta_description",
}
for field, src in fallbacks.items():
if _empty(p.get(field)):
out.append(Violation(f"{field}.empty", LOW,
f"{field} empty", f"mirror from {src}"))
return out
def r_feature_image(p):
out = []
if _empty(p.get("feature_image")):
out.append(Violation("feature_image.missing", MED,
"feature_image missing (no social/share card image)", "add feature image"))
else:
if _empty(p.get("feature_image_alt")):
out.append(Violation("feature_image_alt.missing", LOW,
"feature_image_alt missing (a11y + image SEO)", "add feature image alt text"))
return out
def r_internal_links(p):
n = len(internal_links(p))
if n < MIN_INTERNAL_LINKS:
return [Violation("internal_links.too_few", MED,
f"{n} internal link(s) < {MIN_INTERNAL_LINKS} (weak interlinking)",
"add internal links to related articles")]
return []
def r_title_equals_meta(p):
mt = _s(p.get("meta_title"))
title = _s(p.get("title"))
if mt and mt == title:
return [Violation("title_eq_meta_title", INFO,
"meta_title identical to title (often fine; review)", "")]
return []
def r_jsonld(p):
if THEME_HANDLES_JSONLD:
return []
return [Violation("jsonld.missing", MED, "no BlogPosting JSON-LD", "add JSON-LD")]
RULES = [
r_meta_title,
r_meta_description,
r_custom_excerpt,
r_social_fields,
r_feature_image,
r_internal_links,
r_title_equals_meta,
r_jsonld,
]
def check_post(post):
"""Run every rule against one post dict → list[Violation]."""
out = []
for rule in RULES:
out.extend(rule(post))
return out
def score(violations):
"""Total severity weight (for ranking posts worst-first); INFO counts 0."""
return sum(v.severity for v in violations)
+146
View File
@@ -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()