From cc2853850c18bad83356ea3d3816de7711f467da Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Tue, 28 Jul 2026 07:21:32 +0000 Subject: [PATCH] =?UTF-8?q?auditor:=20auditar=20tambi=C3=A9n=20el=20ES,=20?= =?UTF-8?q?y=20dejar=20de=20llamar=20publicado=20a=20lo=20programado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El blog ES nunca se había auditado entero: seo_audit era EN-only aunque seo_exceptions ya estuviera indexado por sitio desde el principio. Ahora acepta --site es. Resultado del estreno: 40 de 40 limpios. Para que eso signifique algo hubo que arreglar SITE_HOST, que era una constante con el dominio del EN y decide qué href cuenta como enlace interno. Auditando el ES con el host del EN salían 21 posts marcados por enlaces internos; con el host correcto, cero. Un informe entero de hallazgos falsos que nadie habría podido arreglar. Y `ghst post list --status published` NO filtra: devuelve published Y scheduled. El encabezado llevaba desde siempre diciendo "42 published posts" cuando eran 33 publicados y 9 programados. Se pide sin filtro y se separa en el código, que además deja explícito que los programados también se auditan — que está bien, pero hay que decirlo. --- seo_audit.py | 46 ++++++++++++++++++++++++++++++++-------------- seo_rules.py | 16 +++++++++++++++- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/seo_audit.py b/seo_audit.py index 3fc818c..7d98a03 100755 --- a/seo_audit.py +++ b/seo_audit.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Tool A — site-wide SEO auditor for theexclusionzone.com (EN, Ghost). +Tool A — site-wide SEO auditor para los dos blogs (Ghost). READ-ONLY. Fetches every published post via the ghst-en wrapper (Ghost Admin API, staff token pulled on-demand from the pod — nothing written to disk), runs the @@ -10,7 +10,8 @@ shared rule engine (seo_rules.py), and prints: 3. a ranked "fix this first" list. Usage: - python3 seo_audit.py # fetch live, full report + python3 seo_audit.py # EN (por defecto), en vivo + python3 seo_audit.py --site es # el blog ES python3 seo_audit.py --json cache.json # audit a saved posts JSON instead of fetching python3 seo_audit.py --save cache.json # fetch live AND save the raw JSON @@ -28,7 +29,7 @@ import seo_rules as R import seo_exceptions as X -def partition(post, violations): +def partition(post, violations, site="en"): """Split one post's violations into (actionable, accepted, info). 'accepted' = an actionable-severity violation whose (rule, slug) is a @@ -40,15 +41,19 @@ def partition(post, violations): for v in violations: if v.severity == R.INFO: info.append(v) - elif X.accepted_reason(v.rule, slug): + elif X.accepted_reason(v.rule, slug, site): accepted.append(v) else: actionable.append(v) return actionable, accepted, info -def fetch_posts(): - """Pull all published posts (with html) via ghst-en. Returns list[dict]. +SITIOS = {"en": {"cli": "ghst-en", "host": "theexclusionzone.com"}, + "es": {"cli": "ghst-es", "host": "zonadeexclusion.com"}} + + +def fetch_posts(cli="ghst-en"): + """Pull all published posts (with html) via the site's ghst wrapper. We redirect ghst's stdout to a temp file rather than capturing the pipe: ghst's output is ~750 KB and a captured PIPE truncates at the 64 KB pipe @@ -57,13 +62,17 @@ def fetch_posts(): fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_audit_") os.close(fd) try: - cmd = ("ghst-en post list --status published --limit 100 " + # ⚠️ `--status published` NO filtra: ghst devuelve published Y scheduled + # (medido el 2026-07-28: 33 + 9 = los 42 que el informe llamaba + # "published"). Se pide sin filtro y se separa aquí, que es lo honesto. + cmd = (f"{cli} post list --limit all " "--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:\n{r.stderr}") + sys.exit(f"{cli} fetch failed:\n{r.stderr}") data = json.load(open(path)) - return data["posts"] if isinstance(data, dict) and "posts" in data else data + posts = data["posts"] if isinstance(data, dict) and "posts" in data else data + return [x for x in posts if x.get("status") in ("published", "scheduled")] finally: os.unlink(path) @@ -75,6 +84,7 @@ def load_posts(path): def main(): ap = argparse.ArgumentParser() + ap.add_argument("--site", choices=("en", "es"), default="en") ap.add_argument("--json", help="audit a saved posts JSON instead of fetching live") ap.add_argument("--save", help="save fetched raw posts JSON to this path") args = ap.parse_args() @@ -83,15 +93,19 @@ def main(): posts = load_posts(args.json) src = f"file {args.json}" else: - posts = fetch_posts() - src = "live (ghst-en, Ghost Admin API)" + posts = fetch_posts(SITIOS[args.site]["cli"]) + src = f"live ({SITIOS[args.site]['cli']}, Ghost Admin API)" if args.save: json.dump({"posts": posts}, open(args.save, "w"), indent=2) + # El motor decide qué href es interno con R.SITE_HOST: sin esto, auditar + # el ES contaría cero enlaces internos en los 31 posts. + R.usar_sitio(args.site) + results = [] # (post, actionable, accepted, info, score) for p in posts: v = R.check_post(p) - actionable, accepted, info = partition(p, v) + actionable, accepted, info = partition(p, v, args.site) # score ranks worst-first on ACTIONABLE severity only — accepted waivers # and INFO don't push a post up the list. results.append((p, actionable, accepted, info, R.score(actionable))) @@ -111,8 +125,12 @@ def main(): # ---- header ---- print("=" * 78) - print(f" SEO AUDIT — theexclusionzone.com source: {src}") - print(f" {total_posts} published posts | {clean} clean (no actionable issues)" + print(f" SEO AUDIT — {SITIOS[args.site]['host']} source: {src}") + n_pub = sum(1 for p in posts if p.get("status") == "published") + n_prog = total_posts - n_pub + print(f" {n_pub} publicados" + + (f" + {n_prog} programados" if n_prog else "") + + f" | {clean} clean (no actionable issues)" + (f" | {accepted_total} accepted exception(s) waived" if accepted_total else "")) print(f" JSON-LD: handled globally by Edition-main theme — not flagged per post") print("=" * 78) diff --git a/seo_rules.py b/seo_rules.py index 6653762..7bbcfe7 100644 --- a/seo_rules.py +++ b/seo_rules.py @@ -16,7 +16,21 @@ import re import unicodedata from collections import namedtuple -SITE_HOST = "theexclusionzone.com" +# Host del sitio que se está auditando. Decide qué href cuenta como enlace +# INTERNO, así que auditar el ES con el host del EN daría cero enlaces internos +# en los 31 posts: un informe entero de hallazgos falsos. Sigue siendo el EN por +# defecto para no cambiarle el comportamiento a nadie que ya lo use. +SITE_HOSTS = {"en": "theexclusionzone.com", "es": "zonadeexclusion.com"} +SITE_HOST = SITE_HOSTS["en"] + + +def usar_sitio(site): + """Apunta el motor de reglas a uno de los dos blogs. Devuelve el host.""" + global SITE_HOST + if site not in SITE_HOSTS: + raise ValueError(f"sitio desconocido: {site!r}") + SITE_HOST = SITE_HOSTS[site] + return SITE_HOST # ---- thresholds (single source of truth, reused by validator) ------------- META_TITLE_MAX = 60