feat(seo): aviso de colisión de tema en /generate blog en
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
Al publicar el draft EN en Ghost, el título propuesto se compara contra los posts published+scheduled del sitio (corpus vía Admin API — incluye la cola programada, justo el caso del doble Kecksburg del 2026-07-10) con el topic_collision vendorizado. Si colisiona, el notice de Telegram lleva un bloque "🚨 Posible colisión de tema" en las tres rutas (live, dryrun, bare). Nunca bloquea: el draft se crea igual, el humano decide (fusionar, retitular o enlazar a propósito). Solo lang=en (stopwords inglesas). Títulos ajenos saneados de entidades Markdown (regla de _safe_send). Aislamiento: cualquier fallo del check → notice sin bloque y warning en logs, jamás rompe la publicación. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0e9a2e5b36
commit
dadc030d16
@@ -650,3 +650,62 @@ def insert_internal_links(
|
||||
phrase=phrase, slug=slug)
|
||||
|
||||
return "".join(tokens), inserted_pairs
|
||||
|
||||
# ─── 4. Topic collision (aviso pre-publish) ──────────────────────────────────
|
||||
|
||||
_MD_UNSAFE = re.compile(r"[*_`\[\]]")
|
||||
|
||||
|
||||
def _slugify_title(title: str) -> str:
|
||||
"""Aproximación del slug que Ghost generará del título — el draft aún no
|
||||
tiene slug real, y topic_collision usa el slug como una de sus señales."""
|
||||
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
|
||||
|
||||
|
||||
async def fetch_collision_corpus(lang: str) -> list[dict]:
|
||||
"""Posts published+scheduled del sitio (id, slug, title, status) para el
|
||||
check de colisión de tema. A diferencia de fetch_published_menu incluye
|
||||
los PROGRAMADOS: chocar con la cola de vacaciones es justo el caso a
|
||||
cazar (2026-07-10: segundo Kecksburg publicado con otro ya en cola).
|
||||
Aislamiento total: cualquier fallo → [] y log, nunca raise.
|
||||
"""
|
||||
try:
|
||||
# Lazy import to avoid a heavy/circular import at module load.
|
||||
from src.generator.generator import GhostPublisher
|
||||
|
||||
pub = GhostPublisher(lang=lang)
|
||||
if not pub.is_configured():
|
||||
return []
|
||||
data = await pub._admin_get(
|
||||
"posts/?filter=status:[published,scheduled]"
|
||||
"&fields=id,slug,title,status&limit=all",
|
||||
timeout=30,
|
||||
)
|
||||
if data is None:
|
||||
return []
|
||||
corpus = [p for p in data.get("posts", []) if p.get("slug")]
|
||||
logger.info("seo.collision: corpus fetched", lang=lang, count=len(corpus))
|
||||
return corpus
|
||||
except Exception as e: # noqa: BLE001 — isolation guarantee
|
||||
logger.warning("seo.collision: corpus fetch failed", lang=lang, error=str(e))
|
||||
return []
|
||||
|
||||
|
||||
def collision_notice(title: str, corpus: list[dict]) -> str | None:
|
||||
"""Aviso (Markdown seguro para Telegram) si el título propuesto colisiona
|
||||
con un post existente, vía el motor vendorizado R.topic_collision.
|
||||
None si no hay colisión. Los títulos ajenos se sanean de entidades
|
||||
Markdown para no romper el parseo del mensaje (ver regla de _safe_send).
|
||||
"""
|
||||
if not corpus:
|
||||
return None
|
||||
candidate = {"id": None, "title": title, "slug": _slugify_title(title)}
|
||||
violations = R.topic_collision(candidate, corpus)
|
||||
if not violations:
|
||||
return None
|
||||
lines = [_MD_UNSAFE.sub("", v.message) for v in violations[:3]]
|
||||
return (
|
||||
"\n\n🚨 *Posible colisión de tema* — el draft se ha creado igualmente:\n"
|
||||
+ "\n".join(f"• {line}" for line in lines)
|
||||
+ "\nAntes de publicar: fusionar, retitular a otro ángulo o enlazar a propósito."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user