Files
ChemaVXandClaude Fable 5 bae1cc949c feat: check de colisión de tema (topic.collision) en rules + validate
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>
2026-07-10 11:06:39 +00:00

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()