ICS → Importador de Discourse vía API REST

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:

Inicio rápido

Clona el repositorio e instala los requisitos:

git clone https://github.com/Ethsim12/Discourse-ICS-importer-by-REST-API.git /opt/ics-sync
cd /opt/ics-sync
pip install -r requirements.txt

Ejecuta una sincronización una vez manualmente:

python3 ics_to_discourse.py \
  --ics "https://example.com/feed.ics" \
  --category-id 4 \
  --site-tz "Europe/London" \
  --static-tags "events,ics"

Configúralo como un servicio/temporizador systemd para sincronización continua (ejemplos de configuración en el repositorio).

3 Me gusta

las etiquetas me molestaban, así que me aseguré de que search.json buscara contenido indexado del evento: la primera publicación de cada tema/evento

1 me gusta

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 :slight_smile:

1 me gusta

¡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?

2 Me gusta

Un par de comentarios.

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).

2 Me gusta

No lo he probado últimamente, pero he incluido el análisis de bbcode de eventos en mi última confirmación en

Sí, aunque sería bueno si la configuración ics_feeds se desglosara, para que el administrador no introduzca un solo JSON en la interfaz de usuario.

1 me gusta

para ser honesto, ahora no uso cron, uso systemd en un Ubuntu Server 24.04 LTS.

1 me gusta

este es un lujo que tan pronto como tenga tiempo aprenderé a lograr :wink::face_exhaling:

¡No tener acceso a una línea de comandos, en mi humilde opinión, no es un lujo en absoluto! :rofl:

1 me gusta

Jaja, para ser claro, quise decir que la GUI es el verdadero lujo; la CLI es la habilidad hacia la que necesito trabajar.

1 me gusta

Supongo que @angus se te adelantó unos años

3 Me gusta

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

  1. El script recupera la primera publicación y elimina el marcador:
 old_clean = strip_marker(old_raw)
 fresh_clean = strip_marker(fresh_raw)
  1. Si old_clean == fresh_clean: no hay actualización (evita cambios innecesarios).
  2. 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).
  1. Las etiquetas se fusionan (asegura que las etiquetas estáticas/predeterminadas estén presentes, nunca elimina las de moderador/manuales).
  2. El título y la categoría nunca se cambian al actualizar.

  1. Flujo de actualización sin coincidencia de UID
  2. 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.
  3. Una vez adoptado o creado, todas las sincronizaciones futuras se resolverán directamente por UID.

  1. 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.

  1. 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. :white_check_mark:

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 :socks:), 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. :skull:

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. :wastebasket:

Así que un brindis por el tema condenado: No aumentaste lo último, pero aumentaste nuestros corazones. :heart:

2 Me gusta

¿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).

1 me gusta

¿Cómo es este script mejor que el plugin? (¿Oh, tal vez no puedes instalar plugins?) Si el plugin no hace lo que se requiere, ¿tal vez enviar una PR?

¡Gracias por el recordatorio!

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.

1 me gusta

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.

2 Me gusta

ya no con el siguiente commit, ¡Gracias Discourse!

3 Me gusta