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:
@@ -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({
|
||||||
|
|||||||
@@ -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]
|
||||||
Reference in New Issue
Block a user