DEV Community

Jorge Eψ=Ĥψ
Jorge Eψ=Ĥψ

Posted on • Originally published at jorge.aguilera.soy on

Colaboración Calendario Científico Escolar

El Calendario Científico Escolar (https://twitter.com/CalCientifico) es un proyecto dedicado a publicar una efeméride científica cada día del año. Actualmente van por su segunda edición. Además de publicar la efeméride proponen una serie de actividades para ser realizadas en las aulas .

Aunque creo recordar que el proyecto me sonaba de algo de la edición pasada, la verdad es que no habría reparado en este proyecto sino fuera porque un amigo de Twitter (@ArbolIdeas) me mencionó por si había alguna manera de pasar un fichero plano a formato calendario ics

1

Y evidentemente piqué el anzuelo

2

Así que tras unos mensajitos por Twitter y otros por Telegram llegamos a "concretar" una lista de funcionalidades que se podrían hacer de forma fácil:

  • tuitear todos los días la efeméride que tocara. Preferiblemente un tweet por cada idioma y si el texto era más largo de lo que permite Twitter enviarlo en forma de hilo. Obviamente adjuntando la imagen de la efeméride

  • un canal de Telegram donde enviar todos los días un mensaje con todos los idiomas (en lugar de varios canales por nombre)

  • unos ficheros iCal para ser importados en un calendario. Se crearía además un calendario público en Google por cada idioma

  • una web mínima donde tener las imágenes de forma aislada para poder ser adjuntada en cada mensaje accesible desde internet.

Para ello la lista de requisitos en realidad no era muy exigente: los ficheros con los textos y las imágenes de los personajes de forma individual. Ninguna de las dos cosas era problema pues los ficheros estaban publicados de forma abierta (ya les había echado un ojo antes para ver dónde me metía) y las imágenes las proporcionaría la diseñadora.

Por otra parte indagué sobre los sistemas que se estaban empleando en ese momento siendo en realidad bastante simples: sólo se utilizaba la web del organismo patrocinador y cualquier enlace a descargas debería apuntar a esta web pues es donde se encuentra el sistema de estadística. Por lo demás teníamos barra libre.

INFO

aunque pueda parecer a priori una lista con bastante cosas a hacer en realidad casi todas ellas ya las tenía de una forma u otra hechas de otros proyectos como enviar la calidad del aire de Madrid todos los días a Twitter, publicar actividades de las bibliotecas en Telegram y cosas así.

Origen de datos

Como documentos origen contábamos principalmente con unos PDFs (uno por cada 5 idiomas) enmaquedaos con la imagen de la efeméride y el texto del día, así como una serie de Docx con los textos de cada día en párrafos. Existen ficheros planos pero como no los tenía al alcance y los documentos Docx estaban bien estructurados decidí usarlos como los textos origen a utilizar.

Ficheros CSV

En primer lugar deberíamos convertir los textos a un formato más manejable tipo CSV. Como quería incluir la imagen del día y los ficheros planos tenían otro formato, decidí crear unos nuevos.

Mediante GoogleSheet definí un documento que tuviera las columnas:

  • dia -mes -año -personaje -frase

Básicamente asigné un identificador correlativo a cada personaje en función del día que le tocaba puesto que en principio cada día sería uno diferente (tal vez el personaje coincidiera pero no la imagen)

Formatear los documentos de párrafos a lineas en el GoogleSheet fue una tarea simple que una vez pillado el truco no me llevo más de una hora importar todos los idiomas.

Al comprobar que muchos de los textos contenían caracteres como la coma, o el pipe opté por volcar cada tab en ficheros TSV (es decir usar el tabulador).

Así mismo, mientras esperaba que me pasaran las imágenes separadas, opté por hacer algunos recortes de pantalla y generarme unas cuantas imágenes de personajes para probar la idea.

Primeros scripts

Como la motivación inicial de @ArbolIdeas era disponer de las efemérides en su calendario (supongo que para que su agenda automática le avise) lo primero que hice fue un script que "convirtiera" estos csv a iCal (ficheros de eventos de calendarios). Para ello existe una librería en Java muy fácil de usar y el script queda tan sencillo como:

@Grab(group='org.mnode.ical4j', module='ical4j', version='3.0.21')

import net.fortuna.ical4j.model.*
import net.fortuna.ical4j.model.property.DtStamp

if( args.length != 3){
    println "necesito lang, entrada, salida"
    return
}

new File(args[1]).withReader{ r->
    r.readLine()

    def builder = new ContentBuilder()
    def calendar = builder.calendar() {
        prodid '-//Ben Fortuna//iCal4j 1.0//EN'
        version '2.0'
        def line
        while( (line=r.readLine())!= null){
            def fields = line.split('\t')
            def title = fields[4].split('\\.').first()
            vevent {
                uid String.format('%04d%02d%02d-%s', fields[2] as int, fields[1] as int, fields[0] as int, args[0])
                dtstamp new DtStamp()
                dtstart String.format('%04d%02d%02d', fields[2] as int, fields[1] as int, fields[0] as int), parameters: parameters {
                    value('DATE')
                }
                summary title
                description fields[4]
                action 'DISPLAY'
            }
        }
    }
    new File(args[2]).text = calendar.toString()
}
Enter fullscreen mode Exit fullscreen mode

Básicamente le das un lenguaje, un csv de entrada y un ical de salida y va leyendo línea a línea el fichero de entrada generando al final el fichero ical

$ groovy scripts/ical.groovy es static/data/csv/2021_es.csv static/data/ical/2021_es.ical

Ejecutando este script por cada idioma dispuse al momento de un calendario por idioma. Importarlo a tu agenda es tan fácil (al menos con Google Calendar que es con lo que lo he probado) como ir a tu cuenta, crear un calendario e importar el fichero de tu interés.

Así por ejemplo un colegio, organización o grupo de interés puede crear un calendario, o añadir a uno existente, con estos eventos y todos tener su notificación.

Telegram

Para poder visualizar la idea que tenía me decanté en primer lugar por publicar un mensaje en un canal privado de Telegram, principalmente porque es super sencillo y rápido. Puedes crear el canal, tanto público como privado, en cuestión de segundos así como obtener un token para publicar en él de forma desatendida.

El primer script con #groovy fue a grandes rasgos algo como leer uno de los csv y buscar el día, mes y año en curso y realizar un http-post usando las claves de Telegram. Al final el script ha quedado en algo como este:

@Grab('io.github.http-builder-ng:http-builder-ng-core:1.0.4')

import static groovyx.net.http.HttpBuilder.configure
import static groovyx.net.http.ContentTypes.JSON
import groovyx.net.http.*
import static java.util.Calendar.*

year = args.length > 0 ? args[0] as int : new Date()[YEAR]
month = args.length > 1 ? args[1] as int : new Date()[MONTH]+1
day = args.length > 2 ? args[2] as int : new Date()[DAY_OF_MONTH]

println "Processing $year/$month/$day"

TELEGRAM_CHANNEL=System.getenv("TELEGRAM_CHANNEL")
TELEGRAM_TOKEN=System.getenv("TELEGRAM_TOKEN")

if( !TELEGRAM_CHANNEL || !TELEGRAM_TOKEN ){
    println "Necesito la configuracion de telegram"
    return
}

http = configure{
    request.uri = "https://api.telegram.org"
    request.contentType = JSON[0]
}

html = ''

['es','astu','cat','eus','gal','en'].each{ lang ->

    String[]found

    new File("static/data/csv/${year}_${lang}.tsv").withReader{ reader ->
        reader.readLine()
        String line
        while( (line=reader.readLine()) != null){
            def fields = line.split('\t')
            if( fields.length != 5)
                continue
            if( fields[0] as int == day && fields[1] as int == month && fields[2] as int == year){
                found = fields
                break
            }
        }
    }

    if(!found){
        println "not found $year/$month/$day"
        return
    }

    if( !html ){
        html = """Tal día como hoy

        <a href="https://calendario-cientifico-escolar.github.io/images/personajes-min/${found[3]}.png"> </a>
        """
    }

    html +="""${found[4]}
    -------
    """
}

html += """
<i>Proyecto FECYT FTC-2019-15288</i>
<a href="http://www.igm.ule-csic.es/calendario-cientifico">Puedes descargar el calendario y la guía didáctica en nuestra web</a>
"""

http.post{
    request.uri.path = "/bot$TELEGRAM_TOKEN/sendMessage"
    request.body = [
        chat_id: TELEGRAM_CHANNEL,
        text: html,
        parse_mode: 'HTML',
        disable_web_page_preview: false,
    ]
}
Enter fullscreen mode Exit fullscreen mode

Que seguramente se pueda hacer más bonito y óptimo pero este sea sencillo y funciona. Simplemente para cada idioma, recorre las líneas del fichero buscando las del dia que se le haya pasado por argumento y va concatenando en un string las diferentes traducciones. Una vez completados todos los idiomas añade un pie de mensaje y realiza un http post.

La "gracia" aquí es adjuntar una imagen en un mensaje de telegram. En principio el api NO lo permite pero existe un truco añadiendo un href con la imagen y habilitando el preview de las páginas web

El resultado final es algo como:

3

(Seguiré buscando un diseño más atractivo para el mensaje)

Como todavía no tenía un repositorio decente donde poner las imágenes utilicé un ngrok mediante el cual puedes servir páginas en tu local a través del túnel que te crea. Como solución temporal para hacer unas pruebas no está mal y funciona.

INFO

Como puedes ver en el script anterior ya contamos con un repositorio en Github donde poder acceder a las imágenes.

Repositorio Git

Una vez validada la idea y mostrado el resultado procedimos a crear el repositorio público git. Gracias a la predisposición por compartir el proyecto la verdad es que fue fácil el convencer de los beneficios que puede aportar un repositorio así. Además las operaciones que íbamos a realizar sobre este no iban a ser tan contínuas como en un proyecto típico de software y con el interface web que ofrecen estos servicios consideramos que sería suficiente para los miembros menos técnicos del equipo.

Así pues la "decisión" se centraba entre usar Gitlab (el cual vengo usando desde hace años con muy buenos resultados) o Github, más conocido y usado por la gente. Github me ofrecía la oportunidad de tener un caso real para probar el tema de Github Actions, sobre los que había leído pero no probado, así que de forma totalmente egoísta decidí usarlo como repositorio.

Github

INFO

Como ya he mencionado, para mí Gitlab no es sólo una alternativa mejor "filosóficamente" hablando, sino que tiene funcionalidades que Github no ha tenido hasta hace poco, como Github Actions que viene siendo el Pipeline de Gitlab y que llevo usando ya desde hace varios años.

Github es un servicio que permite el trabajo colaborativo con control de versiones git integrado muy popular y que ya es usado por ambientes muy diferentes al del desarrollo de software. Así pues, y tras comentar las ventajas de disponer de un repositorio en dicho servicio lo creé y añadí como owners a las personas interesadas.

La intención inicial del repositorio era alojar en él tantos los csv como las imágenes (y así lo use al principio) aunque luego hemos añadido un site estático basado en Hugo.

Simplemente creé una organización "calendario-cientifico-escolar.github" y un repositorio "calendario-cientifico-escolar.github.io" en dicha organización. Al coincidir el nombre del repositorio con la cuenta y terminarlo con "github.io" Github te permite publicar un static site bajo ese nombre. De esta forma ya tenemos un site en Internet en https://calendario-cientifico-escolar.github.io/

Tras crear la organización y repositorio, un miembro del proyecto se creó una cuenta y le añadí como owner del mismo. De esta forma según fueran recibiendo nuevas actualizaciones de las imágenes podrían subirlas directamente sin necesidad de mi intermediación (además de que el proyecto es suyo, yo sólo estoy colaborando)

Con este repositorio en marcha pudimos empezar a "jugar" con la idea de ejecutar tareas de forma programada usando Github Actions

Github ACtions

Como ya he comentado, Github Actions es la nueva propuesta de Github para ejecutar pipelines cuando subes un cambio o también de forma planificada. Por ejemplo, si tienes un static site puedes planificar un action para que cuando actualizas el contenido se ejecute una tarea que publique el site o que a cierta hora ejecute un "algo", en nuestro caso los scripts para enviar a las redes sociales la efeméride del día.

INFO

Gitlab tiene esta funcionalidad desde hace muchos años y en mi opinión mucho mejor integrada.

Más allá de los detalles técnicos que puedes encontrar en la docu, algunas notas mías:

  • recuerda que las actions se definen en el directorio .github/workflows

  • puedes tener muchas actions y las defines cada una en un fichero

  • en Gitlab puedes usar cualquier imágen de Docker, aquí hasta donde he visto sólo puedes usar las que tienen, por ejemplo ubuntu-18.04

  • existen un monton de actions (pasos a ejecutar en tu pipeline) predefinidas que te pueden simplificar la vida. Por aquí es por donde va el tema, saber cúales hay cómo crearte la tuya.

  • el schedule se define en UTC (vamos que NO planifiques según tu horario local) y en base a mi experiencia, no programes una operación a corazón abierto con él (vamos, que más o menos se ejecuta a esa hora pero…​)

Además de forma desatendida puedes configurar un action para que se ejecute manualmente incluso pasándole parámetros vía web, lo cual fue muy útil para hacer las primeras pruebas. Por ejemplo, el script de envío de Telegram (y luego el de Twitter) permiten pasar por argumentos un día, mes y año y si no se pasan el script toma los del día actual.

Así por ejemplo el action de Telegram se configuraría:

name: telegram
on:
  schedule:
    # * is a special character in YAML so you have to quote this string
    - cron: '0 7 * * *'
  workflow_dispatch:
    inputs:
      year:
        description: 'Year'
        required: true
        default: '2021'
      month:
        description: 'Month'
        required: true
      day:
        description: 'Day'
        required: true
jobs:
  check-groovy:
    runs-on: ubuntu-latest
    env:
      GROOVY_VERSION: 3.0.5
      TELEGRAM_CHANNEL: ${{ secrets.TELEGRAM_CHANNEL }}
      TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
    steps:
      - uses: actions/checkout@v2
      - id: install
        shell: bash
        run: |
          curl -s "https://get.sdkman.io" | bash
          source "$HOME/.sdkman/bin/sdkman-init.sh"
          sdk install groovy $GROOVY_VERSION
          groovy scripts/telegram.groovy ${{ github.event.inputs.year }} ${{ github.event.inputs.month }} ${{ github.event.inputs.day }}
Enter fullscreen mode Exit fullscreen mode

Como ves, defino dos formas de ejecutarlo, programada y manual (con parámetros introducidos por el usuario). Uso la última imagen de ubuntu e instalo sobre ella Groovy para poder ejecutar el script.

INFO

en Gitlab sería más simple pues puede indicar que use la imagen groovy:3.0.5 por ejemplo sin necesidad de tener que hacer todos esos pasos. Hasta donde yo sé no existe (aún) un action adecuado para Groovy

Cuando queremos enviar el telegram de un día concreto simplemente es ir a la sección de actions y proporcionar los parámetros:

4

Github Secrets

Para poder autentificar a los scripts vamos a tener que proporcionar en la mayoría de los casos (siempre) las credenciales correspondientes en cada servicio. En el caso de Telegram por ejemplo necesitamos el canal y el token del bot que va a realizar la acción.

Crear un canal de Telegram es instantáneo y sólo requiere que no exista uno con ese nombre. Así mismo el canal puede ser privado o público (el primero se identifica con un número y el segundo con el nombre que le des). Por otra parte crear un bot para publicar en este canal es igualmente fácil y utilizando el bot BotFather de Telegram puedes crearlo y obtener su token al momento.

Para las primeras pruebas de integración utilicé un canal privado así como un bot que tengo de Puravida Software por lo que sólo falta hacerle "llegar" al script estas credenciales de forma segura para lo que se usa los secrets de Github

Puedes crear diferentes secretos por entornos (entorno de desarrollo, test, blablabbla) pero en nuestro caso no lo necesitamos así que tenemos simplemente secretos de repositorio.

INFO

Obviamente una vez probado y validado el script sobre el canal privado simplemente creamos un canal público @CalendarioCientifico y actualizamos el secret correspondiente

Twitter

Con toda esta infraestructura probada y funcionando introducir un nuevo script junto con su action y secrets para Twitter es realmente fácil.

Como queríamos tuitear desde la cuenta oficial, la persona que la lleva rellenó el formulario para crear una app y obtener las ApiKey y Secrets de twitter (el proceso llevo sólo un par de horas de espera hasta obtenerlas)

Este script va a ser muy similar al de Telegram pero con algunas diferencias:

un tweet por idioma (en lugar de un sólo mensaje)

adjuntar la imagen en el primer tweet de cada idioma

controlar si el texto excede el límite de un tweet y si es así enviarlo como hilo.

un hashtag diferente por cada idioma

Por lo demás usará los mismos ficheros csv, imágenes, parámetros etc. quedando el script:

@Grab(group='org.twitter4j', module='twitter4j-core', version='4.0.6')

import twitter4j.TwitterFactory
import twitter4j.StatusUpdate
import static java.util.Calendar.*

year = args.length > 0 ? args[0] as int : new Date()[YEAR]
month = args.length > 1 ? args[1] as int : new Date()[MONTH]+1
day = args.length > 2 ? args[2] as int : new Date()[DAY_OF_MONTH]

println "Processing $year/$month/$day"

[
    'es':'#CalendarioCientifico',
    'astu':'#CalendariuCientificu',
    'cat':'#CalendariCientífic',
    'eus':'#ZientziaEskolaEgutegia',
    'gal':'#CalendarioCientifico',
    'en':'#ScientificCalendar',
].each{ kv ->
    String lang = kv.key
    String hashtag = kv.value

    String[]found

    new File("static/data/csv/${year}_${lang}.tsv").withReader{ reader ->
        reader.readLine()
        String line
        while( (line=reader.readLine()) != null){
            def fields = line.split('\t')
            if( fields.length != 5)
                continue
            if( fields[0] as int == day && fields[1] as int == month && fields[2] as int == year){
                found = fields
                break
            }
        }
    }

    if(!found){
        println "not found $year/$month/$day"
        return
    }

    String title= found[4].split('\\.').first()
    String body= found[4].split('\\.').drop(1).join(' ')
    String link = ""
    String hashtags = "${hashtag}"

    long inReply = 0
    def tweets = splitText("$title\n$body", "$link\n$hashtags")
    tweets.eachWithIndex{ str, i ->
        String page = tweets.size() == 1 ? "" : "${i+1}/${tweets.size()}"
        StatusUpdate status = new StatusUpdate("$str\n$page").inReplyToStatusId(inReply)
        if( i == 0 ){
            def bytes = "https://calendario-cientifico-escolar.github.io/images/personajes/${found[3]}.png".toURL().bytes
            println "image con $bytes.length"
            status.media "${found[3]}", new ByteArrayInputStream(bytes)
        }
        inReply = TwitterFactory.singleton.updateStatus(status).id
        println status.status
    }

}

def splitText( String text, String suffix ){
    def ret = []
    def words = text.split(' ')
    def current = ''
    words.eachWithIndex{ w, i ->
        if( current.length() > 180 ){
            ret.add current
            current = ''
        }
        current+= "$w "
    }
    current += "\n$suffix"
    ret.add current
    ret
}
Enter fullscreen mode Exit fullscreen mode

Y su action de forma muy parecida al de Telegram cambiando simplemente cúando ejecutarlo y los tokens que este requiere. De esta forma podemos, de forma manual, enviar un tweet de un día cualquiera

name: twitter
on:
  schedule:
    # * is a special character in YAML so you have to quote this string
    - cron: '0 5 * * *'
  workflow_dispatch:
    inputs:
      year:
        description: 'Year'
        required: true
        default: '2021'
      month:
        description: 'Month'
        required: true
      day:
        description: 'Day'
        required: true
jobs:
  check-groovy:
    runs-on: ubuntu-latest
    env:
      GROOVY_VERSION: 3.0.5
      CONSUMERKEY: ${{ secrets.CONSUMERKEY }}
      CONSUMERSECRET: ${{ secrets.CONSUMERSECRET }}
      ACCESSTOKEN: ${{ secrets.ACCESSTOKEN }}
      ACCESSTOKENSECRET: ${{ secrets.ACCESSTOKENSECRET }}
    steps:
      - uses: actions/checkout@v2
      - id: install
        shell: bash
        run: |
          curl -s "https://get.sdkman.io" | bash
          source "$HOME/.sdkman/bin/sdkman-init.sh"
          sdk install groovy $GROOVY_VERSION
          groovy \
            -Dtwitter4j.oauth.consumerKey=${CONSUMERKEY} \
            -Dtwitter4j.oauth.consumerSecret=${CONSUMERSECRET} \
            -Dtwitter4j.oauth.accessToken=${ACCESSTOKEN} \
            -Dtwitter4j.oauth.accessTokenSecret=${ACCESSTOKENSECRET} \
            scripts/twitter.groovy ${{ github.event.inputs.year }} ${{ github.event.inputs.month }} ${{ github.event.inputs.day }}
Enter fullscreen mode Exit fullscreen mode

Static Site

Por último (por ahora) y teniendo todo funcionando decidímos crear un site sobre el mismo repositorio que ayude en la localización de los ficheros.

Básicamente sobre el proyecto mismo creé un site con Hugo el cual permite usar ficheros Markdown para editar el contenido de las páginas y él se encarga de renderizarlo. La elección de este generador fue por su cuasi-simplicidad y cómo no por tener una excusa para probarlo (yo uso para mi blog JBake pero el diseño por defecto requiere mucho tuneo y así investigaba otros generadores).

Una vez creado el site y editadas las páginas de inicio y enlaces, la labor fue conseguir un action para generar y publicar el site de forma desatendida.

El action al final ha quedado tal que:

name: site
on:
  push:
    branches:
    - main
jobs:
  deploy:
    runs-on: ubuntu-18.04
    steps:
      - name: Git checkout
        uses: actions/checkout@v2

      - name: Update theme
        run: git submodule update --init --recursive

      - name: Setup hugo
        uses: peaceiris/actions-hugo@v2
        with:
          hugo-version: "0.70.0"

      - name: Build
        run: hugo --minify

      - name: Deploy
        uses: peaceiris/actions-gh-pages@v3
        with:
          deploy_key: ${{secrets.ACTIONS_DEPLOY_KEY}}
          external_repository: calendario-cientifico-escolar/calendario-cientifico-escolar.github.io
          publish_dir: ./public
          user_name: Jorge Aguilera
          user_email: jagedn@gmail.com
          publish_branch: gh-pages
Enter fullscreen mode Exit fullscreen mode

Lo único de interés en este action es que hay que crear una clave pública/privada para poder autentificar a este action en el proyecto y que pueda publicar el directorio public generado por Hugo en el branch gh-pages que es el que hemos configurado en la consola de Github como rama donde reside el site a publicar.

Con esto, ante cualquier commit en main (anteriormente conocido como master) el action genera y publica el site en Internet.

Utilizando el directorio de Hugo static podemos ubicar los ficheros que los script (o cualquiera con otra idea) usaran para su ejecución.

Conclusiones

Aunque parezca mucho trabajo en realidad no es tanto, y más si tenemos en cuenta que ya tenía los scripts casi de otros proyectos. Simplemente era tener clara la idea de lo que se pretendía, a saber:

  • disponer de un repositorio donde ubicar ficheros a consumir

  • probar unos scripts en local

  • jugar con la automatización de tareas de Github Actions

  • pelearse con la configuración de los tokens para no publicarlos y mantenerlos secretos

  • como bola extra jugar con la idea de tener un site o blog para poder poner los enlaces sin más pretensiones (el site actual es feo como él solo)

Por mi parte la excusa, autoimpuesta, de tener que usar Github para automatizar tareas o probar Hugo (creo que migraré el blog del chaval a Hugo) me ha sido muy gratificante. Si le añadimos el poder colaborar en proyectos de divulgación científica (mira mamá, soy casi divulgador científico!!!) pues mayor placer aún.

Top comments (0)