#!/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 [--site es] # un post (borrador o publicado) python3 seo_validate.py --id # same, by id python3 seo_validate.py --json # 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()