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:
+32
-14
@@ -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)
|
||||
|
||||
+15
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user