He creado una pequeña utilidad que sincroniza continuamente eventos de un feed iCalendar (ICS) en una categoría de Discourse a través de la API REST.
Esto no es un plugin completo de Discourse — se ejecuta junto a tu instalación de Discourse — así que pertenece aquí en #extras. Si quieres mostrar eventos del calendario de una fuente externa (por ejemplo, Google Calendar, feeds de horarios universitarios, etc.) dentro de temas de Discourse, esto te será útil.
Repositorio
Cómo funciona
Lee eventos de un feed ICS dado
Los compara con temas existentes (por UID o como último recurso por hora/ubicación)
Crea o actualiza temas en tu categoría elegida
Puede ejecutarse continuamente como un servicio systemd (seguro contra ejecución duplicada mediante flock)
Requisitos
Ubuntu 24.04 LTS (probado)
Python 3 (ya incluido en Ubuntu 24.04 LTS)
Una clave API de Discourse
Un ID de categoría para dirigir los temas de eventos
Ejemplo de salida
Así es como se ve al sincronizar un feed ICS de horarios universitarios en Discourse:
Gracias de nuevo por compartir, este calendario evoluciona cada vez más, obteniendo nuevas funcionalidades gracias a personas como tú. Me pregunto cómo será en 3-5 años
¡Genial! Gracias por probarlo. ¿Alguien más que quiera intentar sincronizar un feed ICS en Discourse, me encantaría recibir comentarios sobre si sus feeds se comportan de la misma manera?
Si tuviera tiempo, probablemente intentaría convertir esto en un plugin adecuado. Creo que no debería ser muy difícil crear algunas configuraciones, convertir el Python a Ruby y ponerlo en un trabajo.
Otra idea, que podría ser útil para las personas que están alojadas y quieren usar esto, sería convertir la tarea en una acción de GitHub y hacer que ejecute la tarea diariamente. Hice esto para algunos scripts que un cliente alojado necesitaba ejecutar diariamente hace un tiempo y está funcionando bastante bien. Es a la vez más difícil (requiere aprender flujos de trabajo de GitHub y cómo lidiar con secretos en lugar de un buen trabajo cron) y más fácil (no tienes que aprender a meterte con la instalación de cosas en una máquina a través de una interfaz de línea de comandos).
Notas de comportamiento de las pruebas de ics_to_discourse.py
He estado ejecutando una serie de pruebas en este script (con y sin --time-only-dedupe) y pensé que sería útil documentar el flujo de actualización/adopción en detalle.
1. Cómo se determina la unicidad
Modo predeterminado: la adopción requiere que inicio + fin + ubicación coincidan exactamente.
Con --time-only-dedupe: la adopción requiere solo inicio + fin; la ubicación se trata como “suficientemente cercana”.
Si ningún tema existente coincide con estas reglas, se crea un nuevo tema.
2. El papel del marcador UID
Cada tema de evento recibe un marcador HTML oculto en la primera publicación:
<!-- ICSUID:xxxxxxxxxxxxxxxx -->
En ejecuciones posteriores, el script busca primero ese marcador.
Si se encuentra, el tema se considera una coincidencia de UID y se actualiza directamente, independientemente de lo ruidoso o obsoleto que sea el texto de DESCRIPCIÓN.
Esto convierte al UID en la clave de identidad real. Los campos de descripción visibles no afectan la coincidencia.
3. Flujo de actualización con coincidencia de UID
El script recupera la primera publicación y elimina el marcador:
Si old_clean == fresh_clean: no hay actualización (evita cambios innecesarios).
Si difieren: comprueba si el cambio es “significativo”:
meaningful = (
_norm_time(old_attrs.get("start")) != _norm_time(new_attrs.get("start"))
or _norm_time(old_attrs.get("end")) != _norm_time(new_attrs.get("end"))
or _norm_loc(old_attrs.get("location")) != _norm_loc(new_attrs.get("location"))
)
Si meaningful = True → actualizar con un impulso (el tema aparece en Últimos).
Si meaningful = False → actualizar silenciosamente (bypass_bump=True → solo revisión, sin impulso).
Las etiquetas se fusionan (asegura que las etiquetas estáticas/predeterminadas estén presentes, nunca elimina las de moderador/manuales).
El título y la categoría nunca se cambian al actualizar.
Flujo de actualización sin coincidencia de UID
El script intenta la adopción:
• Construye tuplas candidatas de inicio/fin/ubicación (o solo inicio/fin con --time-only-dedupe).
• Busca en /search.json y /latest.json un evento existente con atributos coincidentes.
• Si se encuentra → adopta ese tema, adapta el marcador UID + etiquetas (el cuerpo se deja sin cambios en esta etapa).
• Si no se encuentra → crea un tema completamente nuevo con el marcador y las etiquetas.
Una vez adoptado o creado, todas las sincronizaciones futuras se resolverán directamente por UID.
⸻
Consecuencias prácticas
• Cambios de hora
• Predeterminado: la adopción falla (las horas difieren) → se crea un nuevo tema.
• Con --time-only-dedupe: la adopción falla de la misma manera; se crea un nuevo tema.
• Cambios de ubicación
• Predeterminado: la adopción falla (la ubicación difiere) → se crea un nuevo tema.
• Con --time-only-dedupe: la adopción tiene éxito (las horas coinciden), pero la diferencia de ubicación se marca como “significativa” → actualizar con impulso.
• Cambios de descripción
• Si el texto de DESCRIPCIÓN cambia pero el inicio/fin/ubicación no:
• El cuerpo se actualiza silenciosamente (bypass_bump=True).
• Se crea una revisión del tema, pero sin impulso en Últimos.
• Si la DESCRIPCIÓN no cambia (o solo ruido como Última actualización: que se normaliza), no se realiza ninguna actualización.
• Marcador UID
• Asegura una coincidencia confiable en sincronizaciones futuras.
• Significa que los campos de DESCRIPCIÓN ruidosos no afectan si se encuentra el tema correcto.
⸻
Por qué la DESCRIPCIÓN a veces “permanece igual”
El script compara todo el cuerpo (menos el marcador UID).
Si solo una línea volátil como Última actualización: es diferente, pero se normaliza (por ejemplo, espacios en blanco, finales de línea, Unicode), old_clean y fresh_clean parecen idénticos → no se realiza ninguna actualización.
Esto es intencional, para evitar cambios innecesarios por ruido en la fuente.
⸻
Resumen
• La hora define la unicidad (siempre crea un nuevo tema cuando cambian las horas).
• Los cambios de ubicación → impulso visible (para que los usuarios noten las actualizaciones del lugar).
• Los cambios de descripción → actualización silenciosa (revisión pero sin impulso).
• Marcador UID = clave de identidad confiable, asegura que siempre se encuentre el tema correcto, incluso si la DESCRIPCIÓN está obsoleta o es ruidosa.
Esto logra un buen equilibrio: los cambios importantes aparecen en Últimos, la rotación sin importancia permanece invisible.
Mirando hacia atrás, es casi gracioso cómo se desarrolló toda esta saga. El script de importación en sí mismo es ahora sólido como una roca: marcadores de UID, lógica de deduplicación, actualizaciones significativas vs. silenciosas, espacios de nombres de etiquetas… todo lo que realmente querrías en producción. Los comportamientos se alinean perfectamente con las notas que publiqué: los tiempos definen la unicidad, las ubicaciones desencadenan un aumento, las descripciones se actualizan silenciosamente y los marcadores de UID mantienen todo anclado. Es elegante, es predecible, está hecho.
Mientras tanto, el pobre tema de Meta que lo albergaba todo estaba… bueno, condenado. Comenzó su vida respondiendo como un títere (un gran comienzo ), se infló hasta convertirse en un hilo de Frankenstein de volcados de código y capturas de pantalla, luego evolucionó hasta convertirse en un pseudo-registro de cambios con más commits que el propio repositorio. ¿Y justo cuando el script finalmente se volvió estable? Programado para su eliminación.
Honestamente, es poético. El propósito del script es evitar que los eventos duplicados saturen tu foro. ¿El tema en sí? Visto como un duplicado, marcado silenciosamente para la recolección de basura. El mismo destino que fue construido para prevenir se convirtió en su destino.
Así que un brindis por el tema condenado: No aumentaste lo último, pero aumentaste nuestros corazones.
¿Cómo te fue con la migración a un plugin de Discourse? ¿O mejor aún, como una PR en el plugin existente Discourse Calendar (and Event)?
Soy reacio a meterme en la configuración y el mantenimiento necesarios para ejecutar tu script, que tiene una pinta estupenda, tal como está (y sospecho que muchos autoalojadores estarían en la misma situación).
Estado rápido: Actualmente estoy ejecutando tres instancias de mi importador de Python ICS → Discourse (horario universitario, reservas del centro deportivo y un calendario de Outlook). Comencé a envolverlo como un plugin de Discourse, pero la versión del plugin no alcanzó el conjunto de características del script, principalmente porque cada feed necesita un manejo a medida (peculiaridades de UID, actualizaciones parciales, cancelaciones, revisiones ruidosas, etc.). El plugin de Angus es excelente para muchos casos; mis casos de uso parecen más “específicos del feed”.
También tengo una PR abierta contra el núcleo destinada a reducir el ruido del botón azul “Más reciente” durante las actualizaciones de ICS grandes/repentinas. Con feeds ocupados (como los horarios universitarios), un lote de ediciones de bajo valor puede mantener “Más reciente” rebotando; la PR efectivamente anula el botón “Nuevos temas” cuando “Más reciente” ha permanecido abierto mientras se ejecuta un lote automatizado. Estaré encantado de enlazar esa PR aquí si es útil.
A más largo plazo: Actualmente estoy en IONOS autoalojado. Si me mudo a alojamiento oficial más adelante, todavía me encantaría tener una forma de mantener el flujo de Python (o un equivalente) sin necesidad de funciones empresariales, si existe ICS entrante allí. Sospecho que una solución genérica de núcleo/plugin podría funcionar si permitiera “adaptadores” enchufables por feed, manteniendo una fuerte idempotencia (UID de ICS), manejo de cancelaciones y semántica de edición sin aumento.
Si hay interés, puedo esbozar una interfaz de adaptador mínima y una ruta de migración de mi script de Python a un trabajo de Ruby, o contribuir con piezas agnósticas al feed (mapeo de UID, debounce/actualizaciones sin aumento, lógica de cancelación) al plugin de calendario/eventos.
Esa es una buena pregunta, Nathan, y creo que definitivamente hay espacio para un enfoque mínimo e independiente del feed que podría vivir como una pequeña extensión del plugin Calendar/Event o como un trabajo central ligero.
Para que una PR sea útil en general, la clave parece ser hacer que el importador esté basado en adaptadores en lugar de ser específico del feed. Algo como:
Cada feed define un pequeño adaptador (podría ser Python, YAML o Ruby) que mapea los campos ICS → campos del tema de Discourse (title, body, tags, start, end, location, etc.).
El núcleo maneja la idempotencia (mapeo UID ↔ ID de tema), la cancelación (STATUS:CANCELLED) y las ediciones silenciosas (actualización sin afectar a Latest).
Los plugins o la configuración del sitio podrían configurar el intervalo de sondeo, los mapeos de etiquetas y la política de actualización (always, never, on major change).
De esa manera, las instituciones con feeds ruidosos o complejos (horarios universitarios, reservas de salas, calendarios de Outlook, etc.) pueden proporcionar un adaptador adecuado a sus datos sin codificar nada en el núcleo.
Si hay interés, estaría encantado de esbozar esa interfaz de adaptador o prototipar la ayuda central de “ICS upsert” como un trabajo de Ruby sobre el que otros puedan construir, para que esto pueda evolucionar gradualmente de scripts independientes de Python a algo mantenible y genérico dentro del ecosistema de Discourse.