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
|
#!/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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user