seo_finish: soporte para el sitio ES (--site en|es)
El blog ES no tenía forma de llegar desde las herramientas: solo existía el wrapper ghst-en. Creado ghst-es (gemelo, namespace zona-exclusion, token efímero del pod, nada en disco) y parametrizado seo_finish. Ojo al canónico, que va al revés en cada sitio: EN es www, ES es el apex. seo_rules.SITE_HOST está fijado a EN y NO se parametriza ahí a propósito: ese fichero se vendoriza byte a byte dentro de ResearchOwl y la CI lo verifica. Se reasigna en tiempo de ejecución desde use_site(), que deja intacto el contrato compartido. Los ficheros de trabajo (imagen convertida, volcado de texto) y los backups llevan ahora prefijo de sitio, para que un mismo slug en ambos idiomas no se pise. Verificado contra los dos sitios: ES detecta las 8 violaciones del borrador nuevo (todas por dryrun, que ya no escribe SEO) y EN sigue dando 0 en el artículo belga publicado hoy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8c83fb81fc
commit
34263469e4
+38
-13
@@ -47,16 +47,37 @@ import seo_exceptions as X
|
|||||||
ALT_MAX = 191 # límite duro de Ghost para feature_image_alt
|
ALT_MAX = 191 # límite duro de Ghost para feature_image_alt
|
||||||
WORK = os.path.expanduser("~/.cache/seo-finish")
|
WORK = os.path.expanduser("~/.cache/seo-finish")
|
||||||
|
|
||||||
|
# Los dos sitios. Ojo al canónico: EN es www, ES es el APEX — al revés.
|
||||||
|
SITES = {
|
||||||
|
"en": {"cli": "ghst-en", "host": "theexclusionzone.com",
|
||||||
|
"url": "https://www.theexclusionzone.com/"},
|
||||||
|
"es": {"cli": "ghst-es", "host": "zonadeexclusion.com",
|
||||||
|
"url": "https://zonadeexclusion.com/"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def use_site(site):
|
||||||
|
"""Selecciona el sitio y adapta el motor de reglas.
|
||||||
|
|
||||||
|
seo_rules.SITE_HOST está fijado a EN y NO se puede parametrizar ahí: ese
|
||||||
|
fichero se vendoriza byte a byte dentro de ResearchOwl y la CI lo verifica.
|
||||||
|
Se reasigna aquí en tiempo de ejecución (el módulo lo lee en cada llamada),
|
||||||
|
que deja intacto el contrato compartido.
|
||||||
|
"""
|
||||||
|
cfg = SITES[site]
|
||||||
|
R.SITE_HOST = cfg["host"]
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
def sh(cmd, timeout=240):
|
def sh(cmd, timeout=240):
|
||||||
return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
def fetch(slug):
|
def fetch(slug, cli):
|
||||||
fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_finish_")
|
fd, path = tempfile.mkstemp(suffix=".json", prefix="seo_finish_")
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
try:
|
try:
|
||||||
sh(f"ghst-en post list --limit 100 --formats html --json > {path}")
|
sh(f"{cli} post list --limit 100 --formats html --json > {path}")
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
posts = data.get("posts", data) if isinstance(data, dict) else data
|
posts = data.get("posts", data) if isinstance(data, dict) else data
|
||||||
@@ -65,7 +86,7 @@ def fetch(slug):
|
|||||||
for p in posts:
|
for p in posts:
|
||||||
if p.get("slug") == slug:
|
if p.get("slug") == slug:
|
||||||
return p
|
return p
|
||||||
sys.exit(f"ERROR: no encuentro el post '{slug}'")
|
sys.exit(f"ERROR: no encuentro el post '{slug}' en {cli}")
|
||||||
|
|
||||||
|
|
||||||
def violations_of(post):
|
def violations_of(post):
|
||||||
@@ -99,11 +120,12 @@ def grab_image(url, slug):
|
|||||||
|
|
||||||
|
|
||||||
def cmd_prep(a):
|
def cmd_prep(a):
|
||||||
p = fetch(a.slug)
|
cfg = use_site(a.site)
|
||||||
|
p = fetch(a.slug, cfg["cli"])
|
||||||
text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", p.get("html") or "")).strip()
|
text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", p.get("html") or "")).strip()
|
||||||
img = grab_image(p.get("feature_image"), a.slug)
|
img = grab_image(p.get("feature_image"), f"{a.site}-{a.slug[:40]}")
|
||||||
|
|
||||||
print(f"═══ {a.slug}")
|
print(f"═══ [{a.site.upper()}] {a.slug}")
|
||||||
print(f" estado: {p.get('status')} | publica: {str(p.get('published_at'))[:10]} | {len(text.split())} palabras")
|
print(f" estado: {p.get('status')} | publica: {str(p.get('published_at'))[:10]} | {len(text.split())} palabras")
|
||||||
print(f" id: {p['id']}")
|
print(f" id: {p['id']}")
|
||||||
print("\n─── SEO actual ───")
|
print("\n─── SEO actual ───")
|
||||||
@@ -129,7 +151,7 @@ def cmd_prep(a):
|
|||||||
print(f" url: {p.get('feature_image')}")
|
print(f" url: {p.get('feature_image')}")
|
||||||
print(f" local (mírala para escribir el alt): {img or 'NO DISPONIBLE'}")
|
print(f" local (mírala para escribir el alt): {img or 'NO DISPONIBLE'}")
|
||||||
|
|
||||||
body = os.path.join(WORK, f"{a.slug}.txt")
|
body = os.path.join(WORK, f"{a.site}-{a.slug[:40]}.txt")
|
||||||
os.makedirs(WORK, exist_ok=True)
|
os.makedirs(WORK, exist_ok=True)
|
||||||
with open(body, "w") as f:
|
with open(body, "w") as f:
|
||||||
f.write(text)
|
f.write(text)
|
||||||
@@ -137,10 +159,11 @@ def cmd_prep(a):
|
|||||||
print(f" volcado en: {body}")
|
print(f" volcado en: {body}")
|
||||||
print(f" primeras líneas: {text[:400]}…")
|
print(f" primeras líneas: {text[:400]}…")
|
||||||
print(f"\nSiguiente paso: escribe el patch JSON y aplícalo con")
|
print(f"\nSiguiente paso: escribe el patch JSON y aplícalo con")
|
||||||
print(f" seo_finish.py apply {a.slug} --from-json <patch.json>")
|
print(f" seo_finish.py apply {a.slug} --site {a.site} --from-json <patch.json>")
|
||||||
|
|
||||||
|
|
||||||
def cmd_apply(a):
|
def cmd_apply(a):
|
||||||
|
cfg = use_site(a.site)
|
||||||
with open(a.from_json) as f:
|
with open(a.from_json) as f:
|
||||||
patch = json.load(f)
|
patch = json.load(f)
|
||||||
|
|
||||||
@@ -157,25 +180,25 @@ def cmd_apply(a):
|
|||||||
print(f"✗ {e}")
|
print(f"✗ {e}")
|
||||||
sys.exit("Abortado: corrige los límites antes de aplicar.")
|
sys.exit("Abortado: corrige los límites antes de aplicar.")
|
||||||
|
|
||||||
p = fetch(a.slug)
|
p = fetch(a.slug, cfg["cli"])
|
||||||
# Backup de los campos que vamos a tocar, con el mismo patrón que link-batch-backup.
|
# Backup de los campos que vamos a tocar, con el mismo patrón que link-batch-backup.
|
||||||
import datetime
|
import datetime
|
||||||
bdir = os.path.expanduser("~/link-batch-backup")
|
bdir = os.path.expanduser("~/link-batch-backup")
|
||||||
os.makedirs(bdir, exist_ok=True)
|
os.makedirs(bdir, exist_ok=True)
|
||||||
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
bpath = os.path.join(bdir, f"{a.slug}-seofinish-pristine-{ts}.json")
|
bpath = os.path.join(bdir, f"{a.site}-{a.slug[:40]}-seofinish-pristine-{ts}.json")
|
||||||
with open(bpath, "w") as f:
|
with open(bpath, "w") as f:
|
||||||
json.dump({"id": p["id"], "slug": a.slug,
|
json.dump({"id": p["id"], "slug": a.slug,
|
||||||
**{k: p.get(k) for k in patch}}, f, ensure_ascii=False, indent=1)
|
**{k: p.get(k) for k in patch}}, f, ensure_ascii=False, indent=1)
|
||||||
print(f"backup: {os.path.basename(bpath)}")
|
print(f"backup: {os.path.basename(bpath)}")
|
||||||
|
|
||||||
r = sh(f'ghst-en post update {p["id"]} --from-json "{a.from_json}"')
|
r = sh(f'{cfg["cli"]} post update {p["id"]} --from-json "{a.from_json}"')
|
||||||
if "Slug:" not in r.stdout:
|
if "Slug:" not in r.stdout:
|
||||||
print(r.stdout, r.stderr)
|
print(r.stdout, r.stderr)
|
||||||
sys.exit("✗ ghst-en no confirmó la actualización")
|
sys.exit(f"✗ {cfg['cli']} no confirmó la actualización")
|
||||||
print("✓ aplicado")
|
print("✓ aplicado")
|
||||||
|
|
||||||
after = fetch(a.slug)
|
after = fetch(a.slug, cfg["cli"])
|
||||||
viol = violations_of(after)
|
viol = violations_of(after)
|
||||||
if viol:
|
if viol:
|
||||||
print(f"\n⚠ el post SIGUE con {len(viol)} violación(es):")
|
print(f"\n⚠ el post SIGUE con {len(viol)} violación(es):")
|
||||||
@@ -191,9 +214,11 @@ def main():
|
|||||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||||
p1 = sub.add_parser("prep", help="paquete de revisión (no modifica nada)")
|
p1 = sub.add_parser("prep", help="paquete de revisión (no modifica nada)")
|
||||||
p1.add_argument("slug")
|
p1.add_argument("slug")
|
||||||
|
p1.add_argument("--site", choices=("en", "es"), default="en")
|
||||||
p1.set_defaults(func=cmd_prep)
|
p1.set_defaults(func=cmd_prep)
|
||||||
p2 = sub.add_parser("apply", help="aplica un patch y re-valida")
|
p2 = sub.add_parser("apply", help="aplica un patch y re-valida")
|
||||||
p2.add_argument("slug")
|
p2.add_argument("slug")
|
||||||
|
p2.add_argument("--site", choices=("en", "es"), default="en")
|
||||||
p2.add_argument("--from-json", required=True)
|
p2.add_argument("--from-json", required=True)
|
||||||
p2.set_defaults(func=cmd_apply)
|
p2.set_defaults(func=cmd_apply)
|
||||||
a = ap.parse_args()
|
a = ap.parse_args()
|
||||||
|
|||||||
Reference in New Issue
Block a user