Files
chemavx-seo-tools/seo_validate.py
T
ChemaVX 7bc9b3e2aa validador: aceptar --site, que llevaba desde siempre clavado al EN
seo_validate solo sabía preguntar a ghst-en, así que un slug español daba
404 y el paso 5 del remate —validar hasta que quede limpio— NUNCA pudo
completarse en el blog ES, pese a que el checklist afirma que todas las
herramientas aceptan --site.

Y arrastraba el mismo fallo de SITE_HOST que ya apareció en el auditor:
validando el Varginha del ES con el host del EN cuenta 0 enlaces internos
donde hay 1, y pediría añadir los que ya tiene. Ahora llama a R.usar_sitio.

Peor de cara al futuro, y por eso queda escrito en el módulo: hoy no hay
ningún slug repetido entre los dos blogs, pero el día que lo hubiera el
validador habría validado el post equivocado sin decir una palabra.
2026-07-28 07:51:51 +00:00

191 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""
Tool C — pre-publish SEO validator para los dos blogs (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> [--site es] # un post (borrador o publicado)
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.",)),
]
# ⚠️ Era EN-only y el checklist del remate afirmaba que todas las herramientas
# aceptan --site: el paso 5 (validar hasta que quede limpio) NUNCA pudo
# completarse en el ES, porque preguntar por un slug español a ghst-en da 404.
# Peor de cara al futuro: si un slug llegara a existir en los dos blogs, habría
# validado el post EQUIVOCADO sin decir nada.
SITIOS = {"en": "ghst-en", "es": "ghst-es"}
def fetch_corpus(cli="ghst-en"):
"""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:
# canonical_url hace falta para NO marcar como colisión un par que ya
# está consolidado a propósito (ver topic_collision en seo_rules.py).
cmd = (f"{cli} --json post list --limit all "
f"--fields id,title,slug,status,canonical_url > {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, cli="ghst-en"):
"""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"{cli} post get {flag} {target} --formats html,lexical --json > {path}"
if selector == "slug"
else f"{cli} 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"{cli} 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("--site", choices=("en", "es"), default="en")
ap.add_argument("--no-collision", action="store_true",
help="skip the topic-collision check (intentional follow-up piece)")
args = ap.parse_args()
# El motor decide qué href es interno con R.SITE_HOST: sin esto, validar un
# post del ES contaría cero enlaces internos y pediría añadir los que ya tiene.
R.usar_sitio(args.site)
cli = SITIOS[args.site]
if args.json:
post = load_json(args.json)
elif args.slug:
post = fetch_one("slug", args.slug, cli)
else:
post = fetch_one("id", args.id, cli)
corpus = [] if args.no_collision else fetch_corpus(cli)
n = validate(post, corpus)
sys.exit(1 if n else 0)
if __name__ == "__main__":
main()