ghost: resolver los tags por ID, que mandarlos por nombre partía el ES en dos

ALLOWED_TAGS son SLUGS y Ghost casa los tags de un post por NOMBRE. Mandarlos
como {"name": slug} funcionaba en EN por casualidad — allí los tags se llaman
igual que su slug ("military-cases") — y en ES rompía: no hay ningún tag
llamado "casos-militares" (se llama "Casos Militares"), así que Ghost creaba
uno nuevo con ese nombre y, con el slug ya pillado, lo dejaba en
`casos-militares-2`.

Encontrado el 2026-07-29 al preparar el hub: 5 tags duplicados, 7 posts
repartidos entre dos archivos flacos cada uno, y los cinco `-2` ofrecidos a
Google en sitemap-tags.xml. Los datos ya están fusionados en Ghost; esto es
para que no vuelva.

Se resuelve el slug a ID contra la Admin API, que es lo único no ambiguo justo
en el punto donde falla la ambigüedad. Un slug que no exista se DESCARTA con
aviso en vez de crearse: la lista es cerrada, así que no existir significa que
está mal escrita, y crear el tag es exactamente el bug. Si no resuelve nada, o
si Ghost no contesta, se cae al comportamiento anterior antes que publicar un
post sin ninguna categoría.

7 tests nuevos, incluido uno para EN — donde el bug era invisible por la
coincidencia nombre==slug y el resolutor no debe depender de ella.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-07-29 16:22:14 +00:00
co-authored by Claude Opus 5
parent a43bbc752d
commit 3d8cba6567
2 changed files with 126 additions and 1 deletions
+42 -1
View File
@@ -480,6 +480,47 @@ class GhostPublisher:
return None return None
return await resp.json() return await resp.json()
async def _resolve_tags(self, slugs: list[str]) -> list[dict]:
"""ALLOWED_TAGS son SLUGS; Ghost casa los tags de un post por NOMBRE.
Mandarlos como `{"name": slug}` funciona por casualidad en EN, donde los
tags se llaman igual que su slug ("military-cases"), y rompe en ES,
donde se llaman "Casos Militares": Ghost no encuentra ninguno con ese
nombre y CREA uno nuevo llamado "casos-militares", que como ya tiene el
slug pillado acaba en `casos-militares-2`. Detectado el 2026-07-29 con
5 tags duplicados y 7 posts repartidos entre dos archivos flacos, uno
de ellos ofrecido a Google en el sitemap.
Se resuelve el slug a ID contra Ghost, que es lo único no ambiguo. Un
slug que no exista se descarta con aviso en vez de crearse: la lista es
cerrada, así que no existir significa que está mal escrita, y crear el
tag es precisamente el bug. Si no se resuelve nada (o Ghost no
responde) se cae al comportamiento anterior antes que dejar el post sin
ninguna categoría.
"""
data = await self._admin_get("tags/?limit=all")
if not data:
logger.warning("Ghost tags no legibles: se mandan por nombre",
slugs=slugs)
return [{"name": t} for t in slugs]
por_slug = {t.get("slug"): t.get("id") for t in data.get("tags", [])}
resueltos, perdidos = [], []
for s in slugs:
tid = por_slug.get(s)
if tid:
resueltos.append({"id": tid})
else:
perdidos.append(s)
if perdidos:
logger.warning("Ghost: slugs de tag inexistentes, descartados",
slugs=perdidos, lang=self.lang)
if not resueltos:
defecto = _DEFAULT_TAG.get(self.lang, "investigation")
tid = por_slug.get(defecto)
return [{"id": tid}] if tid else [{"name": defecto}]
return resueltos
async def find_draft_by_title(self, title: str, async def find_draft_by_title(self, title: str,
since: float | None = None) -> dict | None: since: float | None = None) -> dict | None:
"""Busca entre los drafts recientes uno con título exacto. """Busca entre los drafts recientes uno con título exacto.
@@ -549,7 +590,7 @@ class GhostPublisher:
"title": title, "title": title,
"mobiledoc": mobiledoc, "mobiledoc": mobiledoc,
"status": "draft", # NEVER "published" — draft only, always. "status": "draft", # NEVER "published" — draft only, always.
"tags": [{"name": t} for t in tag_names], "tags": await self._resolve_tags(tag_names),
} }
if seo: if seo:
post_obj.update({ post_obj.update({
+84
View File
@@ -0,0 +1,84 @@
"""El resolutor de tags de Ghost.
Regresión del 2026-07-29: ALLOWED_TAGS son SLUGS y Ghost casa los tags de un
post por NOMBRE. Mandarlos como `{"name": slug}` colaba en EN (los tags se
llaman igual que su slug) y en ES creaba duplicados `-2`, partiendo cinco
archivos de tag en dos páginas flacas cada uno.
"""
import asyncio
from src.generator.generator import GhostPublisher
class FakeGhost:
"""Solo lo que _resolve_tags toca: self.lang y self._admin_get."""
def __init__(self, respuesta, lang="es"):
self.respuesta = respuesta
self.lang = lang
self.pedido = []
async def _admin_get(self, query, timeout=15):
self.pedido.append(query)
return self.respuesta
resolve = GhostPublisher._resolve_tags
# Los nombres reales del ES: legibles y acentuados, NO iguales a su slug.
TAGS_ES = {"tags": [
{"id": "id-uap", "slug": "uap", "name": "UAP"},
{"id": "id-mil", "slug": "casos-militares", "name": "Casos Militares"},
{"id": "id-inv", "slug": "investigacion", "name": "Investigacion"},
]}
def corre(fake, slugs):
return asyncio.run(FakeGhost.resolve(fake, slugs))
def test_resuelve_slugs_a_id_y_no_manda_nombres():
fake = FakeGhost(TAGS_ES)
out = corre(fake, ["uap", "casos-militares"])
assert out == [{"id": "id-uap"}, {"id": "id-mil"}]
# lo que provocaba el bug: ningún `name` sale hacia Ghost
assert not any("name" in t for t in out)
def test_preserva_el_orden_porque_el_primero_es_el_primary_tag():
fake = FakeGhost(TAGS_ES)
assert corre(fake, ["casos-militares", "uap"]) == [{"id": "id-mil"}, {"id": "id-uap"}]
def test_slug_inexistente_se_descarta_en_vez_de_crearse():
fake = FakeGhost(TAGS_ES)
out = corre(fake, ["uap", "humanoides"])
assert out == [{"id": "id-uap"}]
def test_si_no_resuelve_nada_cae_al_tag_por_defecto_por_id():
fake = FakeGhost(TAGS_ES)
assert corre(fake, ["humanoides", "pentagono"]) == [{"id": "id-inv"}]
def test_ghost_mudo_no_deja_el_post_sin_tags():
fake = FakeGhost(None)
assert corre(fake, ["uap"]) == [{"name": "uap"}]
def test_en_tambien_resuelve_por_id_aunque_ahi_el_bug_no_se_notara():
# En EN nombre == slug, así que el bug era invisible; el resolutor debe
# comportarse igual en los dos idiomas y no depender de esa coincidencia.
tags_en = {"tags": [{"id": "id-inv-en", "slug": "investigation",
"name": "investigation"}]}
fake = FakeGhost(tags_en, lang="en")
assert corre(fake, ["investigation"]) == [{"id": "id-inv-en"}]
def test_pide_todos_los_tags_no_la_primera_pagina():
# Con 12 tags en ES y el límite por defecto de 15 de Ghost hoy cabría, pero
# el día que no quepa el fallo sería silencioso: tags que existen tratados
# como inexistentes y descartados.
fake = FakeGhost(TAGS_ES)
corre(fake, ["uap"])
assert "limit=all" in fake.pedido[0]