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.
191 lines
7.2 KiB
Python
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()
|