auditor: auditar también el ES, y dejar de llamar publicado a lo programado

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.
This commit is contained in:
ChemaVX
2026-07-28 07:21:32 +00:00
parent 1febcfe03b
commit cc2853850c
2 changed files with 47 additions and 15 deletions
+32 -14
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/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, 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 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. 3. a ranked "fix this first" list.
Usage: 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 --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 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 import seo_exceptions as X
def partition(post, violations): def partition(post, violations, site="en"):
"""Split one post's violations into (actionable, accepted, info). """Split one post's violations into (actionable, accepted, info).
'accepted' = an actionable-severity violation whose (rule, slug) is a 'accepted' = an actionable-severity violation whose (rule, slug) is a
@@ -40,15 +41,19 @@ def partition(post, violations):
for v in violations: for v in violations:
if v.severity == R.INFO: if v.severity == R.INFO:
info.append(v) info.append(v)
elif X.accepted_reason(v.rule, slug): elif X.accepted_reason(v.rule, slug, site):
accepted.append(v) accepted.append(v)
else: else:
actionable.append(v) actionable.append(v)
return actionable, accepted, info return actionable, accepted, info
def fetch_posts(): SITIOS = {"en": {"cli": "ghst-en", "host": "theexclusionzone.com"},
"""Pull all published posts (with html) via ghst-en. Returns list[dict]. "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: 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 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_") fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_audit_")
os.close(fd) os.close(fd)
try: 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) "--formats html,lexical --json > " + path)
r = subprocess.run(cmd, shell=True, stderr=subprocess.PIPE, text=True) r = subprocess.run(cmd, shell=True, stderr=subprocess.PIPE, text=True)
if r.returncode != 0: 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)) 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: finally:
os.unlink(path) os.unlink(path)
@@ -75,6 +84,7 @@ def load_posts(path):
def main(): def main():
ap = argparse.ArgumentParser() 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("--json", help="audit a saved posts JSON instead of fetching live")
ap.add_argument("--save", help="save fetched raw posts JSON to this path") ap.add_argument("--save", help="save fetched raw posts JSON to this path")
args = ap.parse_args() args = ap.parse_args()
@@ -83,15 +93,19 @@ def main():
posts = load_posts(args.json) posts = load_posts(args.json)
src = f"file {args.json}" src = f"file {args.json}"
else: else:
posts = fetch_posts() posts = fetch_posts(SITIOS[args.site]["cli"])
src = "live (ghst-en, Ghost Admin API)" src = f"live ({SITIOS[args.site]['cli']}, Ghost Admin API)"
if args.save: if args.save:
json.dump({"posts": posts}, open(args.save, "w"), indent=2) 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) results = [] # (post, actionable, accepted, info, score)
for p in posts: for p in posts:
v = R.check_post(p) 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 # score ranks worst-first on ACTIONABLE severity only — accepted waivers
# and INFO don't push a post up the list. # and INFO don't push a post up the list.
results.append((p, actionable, accepted, info, R.score(actionable))) results.append((p, actionable, accepted, info, R.score(actionable)))
@@ -111,8 +125,12 @@ def main():
# ---- header ---- # ---- header ----
print("=" * 78) print("=" * 78)
print(f" SEO AUDIT — theexclusionzone.com source: {src}") print(f" SEO AUDIT — {SITIOS[args.site]['host']} source: {src}")
print(f" {total_posts} published posts | {clean} clean (no actionable issues)" 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 "")) + (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(f" JSON-LD: handled globally by Edition-main theme — not flagged per post")
print("=" * 78) print("=" * 78)
+15 -1
View File
@@ -16,7 +16,21 @@ import re
import unicodedata import unicodedata
from collections import namedtuple 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) ------------- # ---- thresholds (single source of truth, reused by validator) -------------
META_TITLE_MAX = 60 META_TITLE_MAX = 60