Dos posts sobre el mismo caso se canibalizan en la SERP (2026-07-10: se publicó un segundo Kecksburg con otro ya programado, y había un título casi gemelo del de Malmstrom en la cola). Nueva función pura topic_collision(post, corpus) en seo_rules.py — corpus-aware, fuera de RULES — que dispara por: caso+año compartidos (años < 2020; los de la era de noticias no son identidad de caso), hooks de título ≥70% similares (calibrado: el par nuclear da 0.742, el par legítimo más cercano 0.65), o slugs ≥50% solapados. seo_validate.py obtiene el corpus via ghst-en (--limit all) y lo reporta como grupo topic_collision; --no-collision lo omite para follow-ups intencionados. Calibrado contra los 36 posts reales: dispara solo el duplicado Kecksburg (HIGH) y el par Grusch preview/evento (MED, intencionado). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
175 lines
6.3 KiB
Python
175 lines
6.3 KiB
Python
#!/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.",)),
|
|
("topic_collision", ("topic.",)),
|
|
]
|
|
|
|
|
|
def fetch_corpus():
|
|
"""All published+scheduled posts (id, title, slug, status) via ghst-en, for
|
|
the topic-collision check. Returns [] (with a warning) if the fetch fails —
|
|
the validator still runs the per-post rules."""
|
|
fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_corpus_")
|
|
os.close(fd)
|
|
try:
|
|
cmd = (f"ghst-en --json post list --limit all "
|
|
f"--fields id,title,slug,status > {path}")
|
|
r = subprocess.run(cmd, shell=True, stderr=subprocess.PIPE, text=True)
|
|
if r.returncode != 0:
|
|
print(f" [warn] corpus fetch failed — topic collision NOT checked:\n{r.stderr}",
|
|
file=sys.stderr)
|
|
return []
|
|
data = json.load(open(path))
|
|
posts = data.get("posts", data) if isinstance(data, dict) else data
|
|
return [p for p in posts if p.get("status") in ("published", "scheduled")]
|
|
finally:
|
|
os.unlink(path)
|
|
|
|
|
|
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, corpus=None):
|
|
"""Run the shared engine (plus the corpus-aware topic-collision check when a
|
|
corpus is given) and print a per-group PASS/FAIL report.
|
|
Returns the number of actionable (non-INFO) violations."""
|
|
violations = R.check_post(post)
|
|
if corpus:
|
|
violations.extend(R.topic_collision(post, corpus))
|
|
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")
|
|
ap.add_argument("--no-collision", action="store_true",
|
|
help="skip the topic-collision check (intentional follow-up piece)")
|
|
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)
|
|
|
|
corpus = [] if args.no_collision else fetch_corpus()
|
|
n = validate(post, corpus)
|
|
sys.exit(1 if n else 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|