-- Leo's gemini proxy

-- Connecting to elpamplina.duckdns.org:1965...

-- Connected

-- Sending request

-- Meta line: 20 text/gemini

Generador de feed y conversor de Mastodon a RSS/Atom


elpamplinadecai@gmail.com

@ElPamplina@masto.es


Publicado el 20-1-2024


Cómo surge la idea


Me hice muy aficionado al carrusel de noticias que incorpora Phanpy (un cliente de Mastodon genial). Me parecía una manera muy buena de tener de un vistazo las noticias más compartidas y comentadas en esa red social.


Hasta tal punto me gusto, que empecé a echar en falta la posibilidad de ir más allá y obtener un feed RSS de esas noticias. Es algo distinto a suscribirse a un feed generalista, puesto que aquí las noticias están más seleccionadas por ser "tendencia" en la red.


Por otra parte, cada servidor de Mastodon (instancia) tiene sus propias tendencias y sus propias cámaras de eco, por lo que necesito mezclar las tendencias de varios servidores para tener una visión en conjunto.


Obtener las tendencias de varios servidores Mastodon y mezclarlas en un feed RSS... humm... no creo que eso exista...


Investigando por ahí


Una regla básica en programación, y muy especialmente en el mundo del FOSS (software libre y abierto), es nunca reinventar la rueda. Antes de meterte a programar tienes que buscar los recursos ya disponibles y ver si los puedes usar y cómo.


En este caso, ya que la idea me viene de una característica de Phanpy, voy a explorar un poco cómo lo hacen. No se trata de copiar a lo bruto, sino de entender lo que se puede hacer. Lo genial del open source es que siempre está publicado el código y se puede inspeccionar.


Repositorio de Phanpy en GitHub


A primera vista el código parece React o alguna de sus variantes. La estructura es fácil de seguir. Está claro que src/pages/trending.jsx debe ser la fuente que estoy buscando.


trending.jsx


He enlazado la revisión que estaba vigente al escribir estas notas (enero 2024).


Casi al principio vemos donde se cargan los enlaces de "trends":


const fetchLinks = pmem(
  (masto) => {
    return masto.v1.trends.links.list().next();
  },
  {
    // News last much longer
    maxAge: 10 * 60 * 1000, // 10 minutes
  },
);

Vemos que los enlaces se toman de un objeto "masto". Veo que ese objeto sale de una api:


const { masto, instance } = api({
    instance: props?.instance || params.instance,
  });

La api se importa de ../utils/api.js


=>https://github.com/cheeaun/phanpy/blob/fadfc6052d00ea97b18382586034f6e24ee44404/src/utils/api.js api.js


Aquí se construye un cliente REST con createRestAPIClient y no parece que se añada nada más a la información. Dicho de otro modo, Phanpy no hace nada especial con esos enlaces, que ya están en la api oficial de Mastodon.


Probamos a llamar directamente desde el navegador, por ejemplo en mastodon.social:


https://mastodon.social/api/v1/trends/links


¡Bingo! El feed de enlaces trending está ahí sin más, en formato JSON. No ha hecho falta ni autenticarse. Tiene incluso parte del contenido de cada noticia ya precargado.


Cómo es el algoritmo de Mastodon que extrae esos enlaces y como los selecciona se podría ver, porque también es open source, pero tampoco me importa mucho. Este feed es exactamente lo que necesito y está publicado libremente.


Estos enlaces son algo que suele aparecer en un rincón escondido de la interfaz, o directamente no aparece en muchas apps. Phanpy es el primer cliente que he visto que le saca partido. En todo caso, yo sí la voy a aprovechar.


Recursos necesarios


Ya tengo claro lo que necesito:


1. Escuchar peticiones HTTP GET con parámetros para seleccionar el feed, formato y otras opciones.

2. Leer el feed de la api REST de Mastodon.

3. Extraer la información relevante del JSON.

4. Generar el feed RSS con dicha información.


Voy a programarlo en Python, que me resulta muy cómodo para hacer un servicio web simple. El punto 1 lo puede cubrir bien el framework Falcon sobre Gunicorn, que ambos los lo he usado ya en varios proyectos con buenos resultados.


Página oficial de Falcon.

Página oficial de Gunicorn.


Para el punto 2 me viene bien la archiconocida librería requests. No me complico con nada adaptado específicamente a Mastodon, porque voy a hacer un GET HTTP básico.


El punto 3 lo soporta Python nativamente.


Para el 4, estoy seguro de que hay muchas librerías para RSS, al ser algo muy extendido. Echo un vistazo a ver qué me conviene más. Un poco de googleo y parece que la librería Python más usada y mantenida es feedgen:


Repositorio en GitHub


Esta librería parece fácil de usar, genera tanto RSS como Atom, y está bien mantenida y actualizada. Bravo.


Posiblemente haga también una pequeña interfaz web para obtener el feed cómodamente, pero eso no me ocupa ahora mismo, ni es materia para este tutorial.


Entorno


Preparamos un entorno virtual e instalamos las librerías necesarias:


virtualenv env-mastonewsfeed
. ./env-mastonewsfeed/bin/activate
pip install falcon requests feedgen gunicorn

Creo un directorio "mnf" y el archivo "mastonewsfeed.py" para el programa. También en un futuro podría haber un subdirectorio "cache" para guardar los feeds ya generados.


Estructura básica


El programa mínimo con Falcon es una clase simple con un método on_get(), que será invocado cuando se reciba una petición HTTP GET.


from wsgiref import simple_server
import falcon


class MastoNewsFeed:
    def on_get(self, req, resp):
        resp.text = 'Hola mundo'
        resp.content_type = falcon.MEDIA_TEXT


app = falcon.App()
app.add_route('/', MastoNewsFeed())

if __name__ == '__main__':
    httpd = simple_server.make_server('0.0.0.0', 8000, app)
    httpd.serve_forever()

No es mi intención hacer un tutorial de Falcon. Se puede ver cómo se rellena la respuesta con su contenido y tipo MIME, y después se asocia la URI "/" a la api recién creada.


Lo más truquero es el final, donde generamos un servidor HTTP simple para pruebas (en el puerto 8000). Eso solo se ejecutará cuando llamemos al programa directamente desde línea de comandos (eso se selecciona por el nombre mágico `__main__`). La razón para hacer esto es por pura comodidad, para no tener que estar con un gunicorn todo el rato levantado mientras programo.


Ejecutando directamente este programa, se queda esperando conexiones. Podemos desde otra línea de comandos invocar la URL con curl:


 curl -v "http://localhost:8000/"
*   Trying 127.0.0.1:8000...
* Connected to localhost (127.0.0.1) port 8000 (#0)
> GET / HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.84.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Date: Mon, 15 Jan 2024 18:45:42 GMT
< Server: WSGIServer/0.2 CPython/3.9.13
< content-type: text/plain; charset=utf-8
< content-length: 10
<
* Closing connection 0
Hola mundo

También podemos pegar la URL en la barra de un navegador web, pero no veríamos tan fácilmente las cabeceras y detalles de la conexión. Además, ¡que se note que somos programadores!


Viendo que funciona el "hola mundo", ya podemos sustituirlo por nuestro programa.


Lectura del feed de Mastodon


Vamos a usar un argumento "server" para identificar el servidor (instancia) de Mastodon que queremos consultar.


> NOTA: Voy a programar todo en inglés, aunque no es lo habitual en mí. La razón es que, si el programa queda molón, muy posiblemente publique este código en algún repositorio público, donde será más útil en inglés.


Tampoco quiero hacer un tutorial de requests, que los hay a patadas por ahí. Hacemos la llamada get() a la URI "/api/v1/trends/links" y obtenemos la respuesta en JSON, una vez comprobado que el status de la respuesta es 200 (OK). Si el argumento server no existe, devolvemos un 404 (Not Found). Si falla la llamada al servidor, devolvemos 500 (Internal Server Error).


def on_get(self, req, resp):
    server = req.params.get('server')
    if server:
        raw = requests.get(f"https://{server}/api/v1/trends/links")
        if raw.status_code == 200:
            feed = raw.json()
            resp.text = f"Got {len(feed)} items from {server}"
            resp.content_type = falcon.MEDIA_TEXT
        else:
            raise HTTPInternalServerError()
    else:
        raise HTTPNotFound()

Probamos y da gusto ver lo bien que funciona:


curl "http://localhost:8000/?server=mastodon.social"
Got 10 items from mastodon.social

Logging


Ningún servidor que se precie puede pasar sin generar un log de su actividad. Usaremos el logging estándar de Python, con distintos niveles de mensajes. Para no complicar más el tutorial, lo sacaremos a la salida estándar con el formato por defecto y en nivel DEBUG:


import logging
import sys
from wsgiref import simple_server
import falcon
import requests
from falcon import HTTPNotFound, HTTPInternalServerError
from requests import JSONDecodeError

logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
logger = logging.getLogger('mastonewsfeed')


class MastoNewsFeed:
    def on_get(self, req, resp):
        server = req.params.get('server')
        logger.info(f"Getting feed from {server}")
        if server:
            logger.debug('Making GET request')
            raw = requests.get(f"https://{server}/api/v1/trends/links")
            logger.debug(f"Response: {raw.status_code} {raw.reason}")
            if raw.status_code == 200:
                try:
                    feed = raw.json()
                    logger.debug("Valid JSON response")
                    resp.text = f"Got {len(feed)} items from {server}"
                    resp.content_type = falcon.MEDIA_TEXT
                except JSONDecodeError:
                    logger.error("Error decoding JSON response")
                    raise HTTPInternalServerError()
            else:
                raise HTTPInternalServerError()
        else:
            raise HTTPNotFound()


app = falcon.App()
app.add_route('/', MastoNewsFeed())

if __name__ == '__main__':
    httpd = simple_server.make_server('0.0.0.0', 8000, app)
    logger.warning(f"Server running on development mode on port {httpd.server_port}")
    httpd.serve_forever()

Lanzamos la misma petición de antes, y tenemos un log precioso:


WARNING:mastonewsfeed:Server running on development mode on port 8000
INFO:mastonewsfeed:Getting feed from mastodon.social
DEBUG:mastonewsfeed:Making GET request
DEBUG:urllib3.connectionpool:Starting new HTTPS connection (1): mastodon.social:443
DEBUG:urllib3.connectionpool:https://mastodon.social:443 "GET /api/v1/trends/links HTTP/1.1" 200 11391
DEBUG:mastonewsfeed:Response: 200 OK
DEBUG:mastonewsfeed:Valid JSON response

Procesando el feed


Vamos a echar un vistazo a una entrada del feed:


    {
        "author_name": "Tracey Tully",
        "author_url": "https://www.nytimes.com/by/tracey-tully",
        "blurhash": "UA8p}rDjEfNH~VM{Ipozx[M{t7j?R.ocoKWV",
        "description": "New Jersey’s largest city will allow 16- and 17-year-olds to vote. Supporters hope it’s the start of a statewide and national movement.",
        "embed_url": "",
        "height": 550,
        "history": [
            {
                "accounts": "155",
                "day": "1705276800",
                "uses": "156"
            },
            {
                "accounts": "0",
                "day": "1705190400",
                "uses": "0"
            }
        ],
        "html": "",
        "image": "https://files.mastodon.social/cache/preview_cards/images/084/668/357/original/b734cecaf4021170.jpg",
        "image_description": "",
        "language": "en",
        "provider_name": "Vox",
        "provider_url": "",
        "published_at": "2024-01-12T23:05:00.000Z",
        "title": "The Supreme Court will hear a case that shapes how cities respond to homeless tent encampments",
        "type": "link",
        "url": "https://www.vox.com/scotus/2024/1/12/24036307/supreme-court-scotus-tent-encampments-homeless",
        "width": 1200
    },

He recortado muchas entradas "history" para no hacer esto muy largo.


Muchas entradas no significan nada para nosotros, otras no parecen tener interés. A la hora de generar un RSS, nos interesan:


- Metadatos del texto:

- title

- description

- url

- published_at

- language

- Metadatos del autor:

- author_name

- author_url

- Metadatos de la imagen:

- image

- image_description

- width

- height


También usaremos el atributo "type" para discriminar todo lo que no sea "link".


Procesar entradas


Donde hasta ahora solo poníamos el mensaje en resp.text, ahora nos toca programar el procesamiento de las entradas RSS.


Para eso tenemos un problemita que suele darse más a menudo de lo que debería: la documentación de la librería feedgen es incompleta y bastante poco amigable. De las varias versiones de la documentación que se encuentran, la que parece más actualizada es la siguiente. Sin embargo, a pesar de estar fechada en 2013, falta por cubrir varios módulos y la información es poco manejable.


Feedgenerator versión 1.0.0


Finalmente, me he podido guiar como ejemplo por un código de tests en el archivo fuente `/feedgen/__main__.py`. Entre eso y lo que hay de documentación, me las puedo apañar para construir las entradas. Concretamente, hay que:


1. Construir el objeto FeedGenerator.

2. Insertarle los datos generales del feed.

3. Usar add_entry() para crear cada una de las entradas vacías.

4. Rellenar los atributos de cada entrada.

5. Usar rss_str() o atom_str() para volcar el resultado final.


Otro problema es encontrar por la web una especificación buena y completa del formato RSS. La mayoría de tutoriales que hay se refieren a cómo usar el RSS a nivel de usuario, pero con la información técnica muy justita. Después de dar unas cuantas vueltas, encuentro dos referencias usables:


Especificación bastante completa en "Harvard Law"

Especificación incompleta en "W3Schools"


Lo bueno de la primera es que distingue bien entre los atributos opcionales y obligatorios. La segunda explica bien los atributos, pero solo los más comunes.


Resumiendo, lo mínimo que necesito en los atributos generales del feed son:


- title (Título del feed)

- link (Enlace a la dirección del feed)

- description (Descripción libre)

- id (solo para Atom, usaré la propia URL, a falta de otro mejor).


Otros opcionales interesantes:


- lastBuildDate (Fecha de última actualización, la genera automáticamente)

- generator (El nombre y versión de nuestro programa)


La inicialización del feed, por tanto, queda así:


fg = FeedGenerator()
fg.title('Mastodon News Feed')
fg.link(href=req.uri)
fg.id(req.uri)
fg.description(f'News bundle from Mastodon server {server}')
fg.generator(f'MastoNewsFeed {VERSION}')
if format == 'ATOM':
    logger.debug('Generating Atom feed')
    resp.text = fg.atom_str(pretty=True)
else:
    logger.debug('Generating RSS feed')
    resp.text = fg.rss_str(pretty=True)
resp.content_type = falcon.MEDIA_XML

La opción "pretty" la usaré para ver como queda el resultado. Luego, si queremos usar el programa en serio, habrá que quitarla para que el feed sea lo más optimizado posible.


Resultado en Atom:

curl "http://localhost:8000/?server=mastodon.social&format=atom"
<?xml version='1.0' encoding='UTF-8'?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>http://localhost:8000/?server=mastodon.social&amp;format=atom</id>
  <title>Mastodon News Feed</title>
  <updated>2024-01-17T18:31:28.700865+00:00</updated>
  <link href="http://localhost:8000/?server=mastodon.social&amp;format=atom"/>
  <generator>MastoNewsFeed 0.0.1</generator>
  <subtitle>News bundle from Mastodon server mastodon.social</subtitle>
</feed>

Para cada entrada podemos rellenar:

- id (usaremos el link)

- title

- link

- summary

- author

- pubDate


for item in feed:
    if item['type'] == 'link':
        fe = fg.add_item()
        fe.id(item['url'])
        fe.title(item['title'])
        fe.summary(item['description'])
        fe.link(href=item['url'])
        fe.author(name=item['author_name'])
        fe.pubDate(item['published_at'])


Ampliar a varios servidores


Uno de los motivos para hacer este programita era poder combinar el feed de varios servidores. Lo que haremos será repetir la carga inicial del feed mediante un bucle, añadiendo en una lista todas las entradas.


El framework Falcon genera una lista de valores de un atributo cuando este se repite, por lo que bastará repetir el parámetro &server= para cada servidor. Si el parámetro no es de tipo lista, lo metemos en una lista para mantener el mismo formato.


server_param = req.params.get('server')
if type(server_param) == list:
    server_list = server_param
else:
    server_list = [server_param, ]

O, más *pitonesco*:


server_list = server_param if type(server_param) == list else [server_param, ]

Ahora, a iterar:


feed_unsorted = []
for server in server_list:
    if server:
        logger.debug(f'Making GET request for {server}')
        raw = requests.get(f"https://{server}/api/v1/trends/links")
        logger.debug(f"Response: {raw.status_code} {raw.reason}")
        if raw.status_code == 200:
            try:
                server_feed = raw.json()
                logger.debug("Valid JSON response")
                logger.info(f"Got {len(server_feed)} items from {server}")
                feed_unsorted.extend(server_feed)
            except JSONDecodeError:
                logger.error("Error decoding JSON response")
    else:
        raise HTTPInternalServerError()

Hemos tenido que refactorizar y sacar todo lo de generar el RSS al final, sacándolo del bloque try para poder repetirlo con cada servidor. Inicializamos una lista vacía y vamos metiendo todo a saco.


Después, bastará con asegurarnos de que hemos recolectado algo, y seguir con el resto del proceso:


if feed_unsorted:
    logger.debug('Inserting general info')
    fg = FeedGenerator()
    ...
    ...
    ...


Ordenar


Las entradas salen muy bonitas, pero no estaría mal ordenarlas con las más recientes primero. Al mezclar varias fuentes, salen juntas todas las del mismo servidor, y no me gusta así.


Las listas en Python se pueden ordenar con la función sorted(). Lo interesante es que podemos definir como clave de ordenación una función lambda que, dada una entrada, obtenga el atributo published_at. Por tanto, podemos ordenar la lista de entradas que tenemos con una sola línea:


feed = sorted(feed_unsorted, key=lambda entry: entry.get('published_at', '0'), reverse=True)

Ante la posibilidad de que alguna entrada no tenga fecha, la dejo al final con un "0" (menor que cualquier año). Con esto corro el riesgo de que ciertas noticias queden demasiado abajo, pero no veo otra manera de hacerlo más elegante.


Probando todo


Pruebo a pedir dos servidores:

curl "http://localhost:8000/?server=mastodon.social&server=masto.es&format=rss"

El resultado es demasiado largo para saber si es correcto. Le paso la URL a un lector de feeds, y veo el glorioso resultado:


Captura de pantalla del feed RSS en la extensión Feedbro de Firefox.


Por hacer


Voy a dejar aquí el tutorial, para que no se haga kilométrico. Dejo ideas de cosas que se podrían añadir:


- Identificar en cada entrada el servidor del que proviene cada noticia. Se podría meter, por ejemplo, en el campo comment.

- Montar una pequeña interfaz para construir la URL del feed más cómodamente.

- Incluir un filtro para excluir noticias de determinadas fuentes (por si le tenemos manía a algún periódico).

- Guardar los feeds generados en una caché, para no tener que estar repitiendo las peticiones a los servidores muy de seguido.


Lanzar en gunicorn


Gunicorn es muy sencillo de ejecutar. Se lanza como cualquier programa en línea de comandos, dándole la referencia del objeto application que debe ejecutar (en nuestro caso mastonewsfeed:app, porque se usan los dos puntos para referirse al objeto dentro del módulo). Las opciones que se pueden consultar en cualquier tutorial. Por ejemplo:


./env-mastonewsfeed/bin/gunicorn --chdir ./mnf -b 0.0.0.0:8888 mastonewsfeed:app

En este caso, la opción --chdir indica el directorio del programa y -b la dirección y puerto donde escuchar.


Fuente


Fuente del programa terminado (licencia GPLv3)


(Si vas a usar el programa en serio te recomiendo reducir el logging a nivel INFO y quitar el pretty printing al volcar el resultado)


@ElPamplina@masto.es

elpamplinadecai@gmail.com


Volver al índice

-- Response ended

-- Page fetched on Thu May 9 12:18:53 2024