diff --git a/src/generator/generator.py b/src/generator/generator.py index 406b2ab..93dcd29 100644 --- a/src/generator/generator.py +++ b/src/generator/generator.py @@ -480,6 +480,47 @@ class GhostPublisher: return None 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, since: float | None = None) -> dict | None: """Busca entre los drafts recientes uno con título exacto. @@ -549,7 +590,7 @@ class GhostPublisher: "title": title, "mobiledoc": mobiledoc, "status": "draft", # NEVER "published" — draft only, always. - "tags": [{"name": t} for t in tag_names], + "tags": await self._resolve_tags(tag_names), } if seo: post_obj.update({ diff --git a/tests/test_ghost_tags.py b/tests/test_ghost_tags.py new file mode 100644 index 0000000..35298bf --- /dev/null +++ b/tests/test_ghost_tags.py @@ -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]