¿Cuándo hablaron de _______ en el pleno municipal de Bilbao?

0. ¿De verdad no hay una forma sencilla de buscar en las actas de los plenos municipales?

Te ha pasado. Bueno, hagamos como si te hubiera pasado.

Quieres saber cuándo, en el pleno de tu ayuntamiento, han hablado de tal o cuál tema. Vas a la web del ayuntamiento y tras un rato navegando encuentras la página ¡bingo!

Una URL maravillosamente larga: https://www.bilbao.eus/cs/Satellite?c=Page&cid=3000015482&language=es&pageid=3000015482&pagename=Bilbaonet%2FPage%2FBIO_ListadoSesionesPlenarias.
Puedes acceder a las actas en PDF. Todo bien.

Basta ahora con descargarlas una a una, abrir cada documento y buscar. Puede resultarte algo tedioso. Lo haces para el 2023, pero cuando llegas a 2022 ya te cansas ¿no existe una manera mejor para poder buscar en todas las actas? Y si las tuvieras descargadas ¿cómo buscar en todas ellas?

En la web del ayuntamiento hay disponibles actas de los plenos desde noviembre de 2007, pero descargarlas todas te llevaría más tiempo del que dispones. Son 193 a día de hoy (y eso sin contar con los extractos de las actas, que están disponibles desde 2002).

Las actas están ahí. Están publicadas. Cualquiera puede acceder a elllas. Otra cosa es que alguien tenga el tiempo para descargarlas y analizarlas.

¡Este es un caso para Abrir Datos Abiertos!

No es la primera vez que me pasa. Tener la información al alcance y no poder procesarla, porque no está publicada de una forma que pueda ser fácilmente consumida. Requiere demasiado trabajo.

Así que me puse manos a la obra.

Lo primero es 1) obtener la lista completa de actas; luego 2) descargar todos los PDF; y por último3) procesar todos los textos para poder hacer búsquedas.

1. Scraping

Para lo primero hace falta “escrapear” (de scraping, en inglés), esto es, descargar sistemáticamente la información de la web. Para ello le pregunté a Ekaitz si se le ocurría algo, porque el escrapeado no era imposible, pero tampoco trivial. En unas horas me mandó este código de python, que sirve para genera un archivo JSON que contiene la lista y URL de todos los documentos para poder descargarlos:

# Copyright 2023 Ekaitz Zárraga <ekaitz@elenq.tech>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import requests
from bs4 import BeautifulSoup
from urllib.parse import urlparse, urljoin

import json


if __name__ == "__main__":
    base_url = urlparse("https://www.bilbao.eus/cs/Satellite?c=Page&cid=3000015482&language=es&pageid=3000015482&pagename=Bilbaonet%2FPage%2FBIO_ListadoSesionesPlenarias")
    r = requests.get(base_url.geturl())
    soup = BeautifulSoup(r.text, "html.parser")
    years = { i.text: i["value"] for i in soup.select("select#anioId option[value]") if i.text.isdigit()}
    data = []

    for year, id in years.items():
        r = requests.post(base_url.geturl(), {"anioId": id})
        soup = BeautifulSoup(r.text, "html.parser")

        table = soup.find('table', class_='tablalistados')

        headers = [ th.text.strip() for th in table.find("tr").find_all("th") ]

        data_rows = table.find_all("tr")[1:]
        
        print(year)
        
        for data_row in data_rows:
            line  = { k: v for k, v in zip(headers, data_row.find_all("td"))}
            line["Fecha"] = line["Fecha"].get_text().strip()
            line["Número"] = line["Número"].get_text().strip()
            line["Sesión"] = line["Sesión"].get_text().strip()

            # Los que tienen archivo: guardar enlace (luego se puede hacer un GET)
            for field in ["Orden del día", "Actas", "Resumen sesión", "Extractos", "Vídeos"]:
                link = line[field].find("a")
                url = urlparse(urljoin(base_url.geturl(), link["href"])) if link else None
                line[field] = url.geturl() if url else None
            data.append(line)

    with open("plenos.json", "w") as f:
        f.write(json.dumps(data))¡

2) Descargar los PDF

Para eso me fui a R, que es donde me encuentro más cómodo para trastear. Este archivo de R lee el JSON descargado, descarga todos los PDF y genera un archivo CSV en el que en cada línea guarda: el texto contenido en una página de cada PDF, el número de página, la URL al PDF original del acta y la fecha del pleno municipal.

# Cargar librerías
library(tidyverse)
library(pdftools)
library(tm)
library(rjson)


# Genera archivo .json con el código de plenos.py
# Archivo descargado plenos_230823.json


# Segundo archivo, en vista de que han cambiado las URL
data <- fromJSON(file= paste0("data/original/plenos_230823.json") )

# Apana (flat) el archivo json para operar más fácilmente  --------
for( i in 1:length(data) ) {
  print(i)
# for( i in 1:2 ) {
  fecha <- data[[i]]$Fecha
  num <- data[[i]]$Número
  sesion <- data[[i]]$Sesión
  orden <- data[[i]]$"Orden del día"
  extractos <- data[[i]]$Extractos
  actas <- data[[i]]$Actas
  resumen <- data[[i]]$"Resumen sesión"
  video <- data[[i]]$Videos
  
  if( is.null(orden) ) { orden = NA }
  if( is.null(extractos) ) { extractos = NA }
  if( is.null(actas) ) { actas = NA }
  if( is.null(resumen) ) { resumen = NA }
  if( is.null(video) ) { video = NA }
  
  if ( i == 1 ) {
    
    plenos <- data.frame(fecha = fecha, num = num, sesion = sesion, orden =orden, extractos = extractos, actas = actas, resumen = resumen, video =video)
  } else{
    
    plenos <- rbind( plenos,
                     data.frame(fecha = fecha, num = num, sesion = sesion, orden =orden, extractos = extractos, actas = actas, resumen = resumen, video =video)
    )
  }
}

# Format date  (pon en formato fecha)
plenos <- plenos %>% mutate(
  fecha = as.Date(fecha, format="%d/%m/%Y")
)


# Descarga los PDF - Download ----
for( i in 1:nrow(plenos) ) {
# for( i in 1:22 ) {
  print(plenos$actas[i])
  
  if ( !is.na(plenos$actas[i]) ) {
  
    print(plenos$fecha[i])

    #Descarga el archivo
    download.file(plenos$actas[i],
                  paste0("data/output/actas_230823/",plenos$fecha[i],"_acta_pleno-municipal-bilbao.pdf"))
  }
}

# Read pdf -------

# Guarda el resultado de cada página en una celda, junto con fecha y número de página
for( i in 1:nrow(plenos) ) {
  print( paste(i,"fila"))
  print(plenos$fecha[i])
  
  if ( !is.na(plenos$actas[i]) ) { # Que exista el acta
    
    text <- pdf_text(paste0("data/output/actas/",plenos$fecha[i],"_acta_pleno-municipal-bilbao.pdf"))
    
     if ( i == 5 ) {  # TODO: Para el primer pleno que tiene acta (metido a mano, mejorar!) en este caso el 5
       
       
      for( j in 1:length(text)) { # itera por todas las páginas de cada pdf
        print( paste("row:", j, " ----------------------------------"))
        
        if( j == 1) { # Para la primera iteración
          print("j es 1")
          all_pages <- text[j] %>% as.data.frame() %>% rename( txt = 1) %>% mutate(
            pag = j,
            fecha = plenos$fecha[i],
            actas = plenos$actas[i]
            )
          
        } else (
          page = as.data.frame(text[j]) %>% rename( txt = 1) %>% mutate(
            pag = j,
            fecha = plenos$fecha[i],
            actas = plenos$actas[i]
          )
        )
        
        if( j != 1) {
          
          all_pages = rbind(all_pages, page)
          
        }
      }
      
    } else {
      
      for( j in 1:length(text)) { # itera por todas las páginas de cada pdf
        if( j == 1) {
          print("4")
          all_pages_temp <- text[j] %>% as.data.frame() %>% rename( txt = 1) %>% mutate(
            pag = j,
            fecha = plenos$fecha[i],
            actas = plenos$actas[i]
          )
        } else (
          
          page = as.data.frame(text[j]) %>% rename( txt = 1) %>% mutate(
            pag = j,
            fecha = plenos$fecha[i],
            actas = plenos$actas[i]
          )
        )
        
        if( j != 1) {
          
          all_pages_temp = rbind(all_pages_temp, page)
        }
      }
            
      all_pages = rbind(all_pages, all_pages_temp)
      
    }
  } else { 
    print("No existe acta")
  }
}

# salvar archivo como CSV
write.csv(all_pages, "data/output/paginas-actas-plenos_230823.csv")

3. Página para buscar

A partir del CSV generado en el paso anterior monte un buscador básico (sólo se puede buscar por una palabra) desarrollado en PHP (ver código):

lab.montera34.com/plenosbilbao

¿Qué le sobra, qué le pasa, qué le falta a esta web? ¿os resulta útil? ¿cómo podíais vivir sin ella? ¿encontrais algo interesante? Encantados de escucharos.

Bola extra: si has llegado, quizás te interesa la web que he montado para buscar en las ordenanzas del ayuntamiento de Bilbao u otros proyectos de abrir datos abiertos que hemos hecho desde Montera34.

Multa por ir en bici por doña Casilda (y publicar ordenanzas)

El 3 de mayo de 2023 me pasó lo siguiente:

Me acaban de poner multa por ir con mis hijos en bici por el parque Doña Casilda (Bilbao) el agujero negro para las bicicletas. Parque semi vacío, dos agentes a la espera del ciclistas ¿alguien sabe si hay posibilidades al recurrir la multa?

Me ha pedido la documentación y empezado a escribir la multa sin decir nada más, sin siquiera explicar qué es lo que estaba haciendo mal. Esa es la imagen que mis hijos se llevan de la policía municipal

Tras pregutarle, el agente municipal citaba la ordenanza de bicis (no existe como tal) y no era capaz de indicar qué normativa en concreto había incumplido más alla de “la ordenanza de Bilbao” y el código de circulación. Aquí las ordenanzas municipales de Bilbao. Fuente del mapa.

Apatruyando la ciudad
Por el parque con su coche
Apatruya la ciudad.
(Así estaban los policías minutos después de multarme)

A raíz de la multa por ir en bici por un parque (y ante la imposibilidad de buscar en la web del Ayto. de Bilbao qué normativa trata sobre bicicletas y/o zonas peatonales) he montado esta web para buscar en las ordenanzas. Es un ejercicio rápido de transparencia para abrir datos (que deberían) ser abiertos.

Tuve que pasar las ordenanzas de PDF a html. En un caso tuve que hacer OCR (reconocimiento de caracteres en imágenes) porque la ordenanza estaba publicada como imágenes escaneadas. En otro casono pude usarla porque el link al PDF de la ordenanza daba error.

Como prueba de concepto de lo que debería ser una web municipal creo que vale.

Dejo anotado aquí el cómo se hizo, casi todo desde línea de comandos:

  1. Un script en R para descargar todos los PDF.
  2. Con el comando pdftohtml convierto los PDF en html.
    Algunos los tengo que limpiar ya que tienen demasiadas imágenes repetidas. Además, en la página no usaré imágenes. Lo hago con “sed -e ‘s/]*>//g’ input.html > output.html”
  3. En el caso de la ordenanza del Casco Viejo no se puede copiar el texto, son imágenes:
    3.1 Convertir PDF a imágenes con pdftoppm
    3.2 OCR con tesseract con loop “for i in casco-??.png; do tesseract “$i” “text-$i” -l eng; done;”
    3.3 Unir los textos “cat text-casco-*.png.txt > fin.txt”
    3.4 Sustituyo los múltiples espacios juntos que genera tesseract “&nbsp;” por ” “.
  4. Abro el html generado de cada ordenanza en navegador y copio el contenido y lo pego en una página de wordpress (una página por ordenanza). Cambio de fecha de la página a fecha de aprobación.
  5. Retoques en wordpress mínimos y resaltar buscador.

Publiqué en Twitter todo esto y con las sugerencias recibidas (y la ayuda de Bizi Bizi Bilbao) publiqué estas alegaciones. Pronto publicaré las alegaciones finales y actualizaré este post. El documento compartido también incluye una recopilación de toda la normativa aplicable en mi caso.

Esto no lo hago por mi solamente, para no pagar la multa, sino para que se reconozca que las bicis sí podemos circular por espacios peatonales.

Por mi y por todos mis compañero/as ciclistas.

Data gathering and analysis techniques for each mass media channel and public opinion data used in the present research.

Read my thesis (chapter by chapter)

Well, I know you are not going to read the entire PhD thesis and annexes, so I am publishing it little by little, chapter by chapter. I am transforming the content to html, so I can use all the hyperlinks features and make it easier to navigate. It will take months, but I’ll complete the job.

I’ve created a page that is a summary of all the extra content of the dissertation: Color of corruption. Visual evidence of agenda-setting in a complex mass media ecosystem submitted on December 2022 and defended successfully on June 2023.

I’ve already uploaded all the code for the data gathering and analysis as well as the data bases that I’ve used.

I haven’t posted as much as I wanted to tell what I was doing, but I hope this page will help introduce what I’ve been doing. It’s been a long very long journey.

Páginas y capítulos de la tesis Color of corruption.
A visual analysis of all the pages of the PhD dissertation.

PS: In the past I’ve also published my research online, see these two examples. The goal was to publish the process, not only the final results, and make them available for everyone else:


Puedes leer en castellano un resumen sobre la evolución del proyecto en Montera34.

What can you do to reduce viral transmision through indoor aerosols?

You can do nothing:

If you want to reduce viral transmission through indoor aerosols, of SARS-Cov-2 and other viruses, three methods can be applied to the air to reduce the chance of infection:

You can ventilate. With ventilation you expel the air with the aerosols outdoors, and introduce outdoor virus-free air. This can be achieved by opening doors and windows or adjusting the HVAC system to introduce more outdoor air. Note that moving air around with a recirculating forced air system, window air conditioner or minisplit air conditioner unit, or with a fan, is not ventilation in this sense, only air mixing.

You can filter. With filtration you keep the air indoors, but remove the floating aerosols. In environments where air is recirculated, or there is inadequate flow of outside air, stationary HVAC systems or portable air filters (e.g. HEPA filters or Corsi-Rosenthal boxes) can remove the virus and other contaminants from the air.

You can disinfect. With disinfection you keep the air and floating aerosols indoors, but “kill” (inactivate) the virus.

A summary of the four situations:

Update (2023-07-24): I’ve made a version in blue-red for people with color blindness:

These are all drawings, in real life you’ll not see things floating in the air:

Made in collaboration with Jose Luis Jiménez.

Imprime “En el aire”

En vista de la nueva ola y de que todavía las cosas no están muy claras ahí fuera, he decidido publicar en papel las viñetas de En el Aire que he ido publicando este año.

Pensé en imprimir y distribuir personalmente en mi entorno más cercano, pero iba a ser imposible gestionarlo en otros lugares, así que os dejo con el PDF por si queréis imprimirlo y regalarlo por ahí. Si lo imprimís, me encantará que mandéis una foto. Lo regalo o lo vendo por el costo de impresión, para producir más.

  1. Imprime el PDF en A4 a doble cara. Importante: no ajustar al imprimir.
  2. Corta en 4 por la mitad.
  3. Ordena los 4 trozos.
  4. Dobla.
  5. Si puedes, grapa.

El resultado es un librito de 16 páginas en formato A7.