Pasarela RSS -> Twitter que también sube imágenes asociadas

Hace un año publiqué un artículo sobre cómo enviar las actualizaciones de tu blog directamente a Twitter: Ten tu propia pasarela RSS -> Twitter. Ese artículo es imprescindible para entender este. Léelo primero.

El código que publiqué en Ten tu propia pasarela RSS -> Twitter ha funcionado bien durante este tiempo pero, consciente de que la gente reacciona mejor a los mensajes de Twitter cuando tienen imágenes asociadas, quería añadirle esa funcionalidad. Añadir la capacidad de publicar imágenes.

Hay varios ejemplos en Internet sobre cómo hacerlo pero usan el API antiguo de Twitter, marcado como obsoleto. La librería que utilizo soporta la versión nueva del API, pero su uso no estaba documentado por ningún sitio.

Esto está solucionado con un poquito de presión del pesado de siempre :-) :

Con una librería a la altura y la documentación actualizada, ya solo queda picar código.

 #!/usr/local/bin/python3

 # 20xx-2015 jcea@jcea.es - http://www.jcea.es/
 # Código liberado como Dominio Público.
 # Haz con él lo que quieras.

 import feedparser
 from bs4 import BeautifulSoup

 import urllib.request, urllib.parse
 import pickle
 import os, os.path
 import socket
 import fcntl
 import sys
 import functools

 path = '/home/blog-twitter/data/'
 persist = 'ya_publicados.pickle'

 def lock(f) :
     @functools.wraps(f)
     def wrapper() :
         # Si no puede, falla inmediatamente
         lock = open(persist+'.lock', 'a')
         fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)

         try :
             return f()
         finally :
             # El lock se libera automáticamente al terminar
             # pero así no dejamos basurilla en el directorio
             lock.close()
             os.remove(persist+".lock")
     return wrapper

 def drop_privileges() :
     os.chdir(path)
     os.setgroups([60001])  # Nobody
     os.setgid(60001)
     os.setuid(60001)

 def get_imgs_to_publish(url) :
     pagina = urllib.request.urlopen(url)
     if pagina.status != 200 :
         raise RuntimeError('La página %s devuelve el estado %s'
                 %(url, pagina.status))
     pagina = pagina.read()
     pagina = BeautifulSoup(pagina)
     pagina = pagina.select('.entry-content')
     if len(pagina) != 1 :
         raise RuntimeError('Formato inesperado')
     pagina = pagina[0]
     total_num_imgs = pagina('img')
     relevant_imgs = pagina('img', class_ = 'to-twitter')
     imgs = []
     if total_num_imgs :
         if len(relevant_imgs) == 0 :
             raise RuntimeError('Tenemos varias imágenes pero ninguna marcada '
                     'para ser publicada')
         for img in relevant_imgs :
             img_url = urllib.parse.urljoin(url, img['src'])
             imgs.append(img_url)
     return imgs

 def upload_images(images, auth) :
     import twitter  # Solo importa si lo vamos a necesitar

     # La imagen debe ser GIF, PNG o JPEG y medir menos de 3Mbytes.
     # XXX: Buscar una referencia online de esto.

     Twitter_upload = twitter.Twitter(domain='upload.twitter.com', auth=auth)

     ids = []
     for img in images :
         print('Subiendo imagen', img)
         img = urllib.request.urlopen(img)
         if img.status != 200 :
             raise RuntimeError('Intentar descargar la imagen %s devuelve el '
                 'estado %s' %(img.url, img.status))
         img = img.read()
         ids.append(str(Twitter_upload.media.upload(media=img)['media_id']))

     return ids

 def get_auth() :
     import twitter  # Solo importa si lo vamos a necesitar

     # Autenticacion OAUTH para TWITTER, segun los detalles de
     # http://jmillerinc.com/2010/05/31/twitter-from-the-command-line-in-python-using-oauth/
     # http://github.com/sixohsix/twitter/blob/master/twitter/ircbot.py
     consumer_key = "XXXXXX"
     consumer_secret = "XXXXXX"

     # Podemos crear un nuevo cliente en twitter y pedir que nos
     # genere unos token de acceso directamente, que podemos usar ya sin mas.
     if not os.path.exists('oauth_token_file') :
         from twitter.oauth_dance import oauth_dance
         oauth_dance('FEED RSS de http://blog.jcea.es/', consumer_key, consumer_secret,
                 "oauth_token_file")

     oauth_token, oauth_secret = \
         twitter.oauth.read_token_file('oauth_token_file')
     auth = twitter.OAuth(oauth_token, oauth_secret, consumer_key,
             consumer_secret)

     return auth

 @lock
 def do() :

     socket.setdefaulttimeout(60)

     ya_publicado = set()
     if os.path.exists(persist) :
         ya_publicado = pickle.load(open(persist, 'rb'))

     feed = feedparser.parse('https://blog.jcea.es/rss.xml')
     assert feed.status == 200
     assert feed.encoding == 'utf-8'

     limpieza = set([i.id for i in feed.entries])
     ya_publicado.intersection_update(limpieza)  # Limpieza de post antiguos
     a_publicar = [i for i in feed.entries if i.id not in ya_publicado]

     for entrada in a_publicar :
         import twitter  # Solo importa si lo vamos a necesitar

         auth = get_auth()

         twitter_API = twitter.Twitter(auth=auth)

         imgs = get_imgs_to_publish(entrada.link)
         if len(imgs) :
             imgs_ids = upload_images(imgs, auth = auth)
         else :
             imgs_ids = []

         # El 1 es por el espacio entre titulo y url
         # El acortamiento de t.co mide 22 bytes para HTTP y 23 bytes para HTTPS.
         # el 4 es por el ' #fb'
         l = 140-1-23-4

         params = {}
         if len(imgs_ids) :
             params['media_ids'] = ','.join(imgs_ids)
             # El enlace a las imágenes, aunque haya varias,
             # y el espacio previo.
             l -= 23+1

         titulo = entrada.title
         if len(titulo) > l :
             titulo = titulo[:l-3]+'...'

         # El acortamiento a 't.co' es automático y transparente, al
         # publicar en twitter.
         titulo = titulo+' '+entrada.link+' #fb'
         params['status'] = titulo

         try :
             twitter_API.statuses.update(**params)
         except twitter.api.TwitterHTTPError as e:
             import json
             e = json.loads(e.response_data.decode('utf-8'))
             if e['errors'][0]['message'] != 'Status is a duplicate.' :
                 raise

         print(titulo)

         ya_publicado.add(entrada.id)
         f = open(persist+'.new', 'wb')
         pickle.dump(ya_publicado, f)
         f.flush()
         os.fsync(f)
         f.close()
         os.rename(persist+'.new', persist)


 if __name__ == '__main__' :
     drop_privileges()
     do()

Este código mide el doble que el original. No voy a explicarlo entero; debes leer primero Ten tu propia pasarela RSS -> Twitter.

El código se ha reestructurado por completo para poder importarlo sin efectos colaterales [1]. En particular, se podría llamar varias veces en el mismo programa. La mayor complicación es la gestión del bloqueo para evitar lanzar varias actualizaciones simultáneas. Para ello creo un decorador en las líneas 21-35 y lo utilizo en la línea 109.

[1]

En realidad el programa sí tiene algunos efectos colaterales, como la dependencia de ser ejecutado en un directorio concreto, el cambio de usuario/grupo o el timeout por defecto.

Cambiaré el código cuando lo necesite.

Como en la versión anterior, repaso el feed RSS de este blog. Hay dos cambios a destacar.

El primero son las líneas 133-137. Deciden si la entrada del blog tiene imágenes a publicar.

El segundo cambio son las líneas 144-149 y la línea 158. Si no hay imágenes, publicamos como siempre. Pero si hay imágenes debemos prepararlas para subirlas y debemos tener en cuenta que subir imágenes consume 24 caracteres adicionales del Twitter (23 de la URL y uno del espacio previo). Es decir, si publicamos imágenes perdemos 24 caracteres del texto posible. Es importante destacar el hecho de que solo se añade una URL al Twitter, subamos una imágen o subamos cuatro.

Decidir si hay que publicar imágenes y hacerse con ellas es la parte novedosa e interesante.

La rutina get_imgs_to_publish() en las líneas 43-65 descarga un artículo del blog, analiza su contenido y localiza las imágenes en el cuerpo, si las hay. Para masticar el HTML utilizamos la magnífica librería Beautiful Soup 4. Si no hay imágenes, no hay nada que hacer. Si encontramos imágenes, al menos una de ellas debe tener asignada la clase CSS to-twitter. Esta clase no hace nada en absoluto a nivel de CSS, simplemente sirve como un marcador. Las imágenes marcadas con esa clase CSS se deben publicar en Twitter. Si hay imágenes, al menos una de ellas debe tener esa clase CSS. He hecho esto así para evitar equivocaciones. Por supuesto tú puedes cambiar el código como quieras.

Obtener las URLs de las imágenes que nos interesan no es trivial. Pueden ser referencias absolutas, relativas o estar incluso en otro servidor diferente. Nos olvidamos de todas estas complejidades utilizando la librería estándar urllib.parse (línea 62). Agradece poder contar con estas ayudas de serie.

Una vez que tenemos las imágenes a publicar las descargamos una a una y las subimos al servidor upload.twitter.com en la rutina upload_images() (líneas 66-84). Podemos subir hasta cuatro imágenes del tipo GIF, JPEG o PNG. Deben medir menos de 3MBytes. [2]

[2] Estas limitaciones las he leído por allí y por allá, en páginas sueltas. Me gustaría leer documentación oficial al respecto.

Si conseguimos subir las imágenes correctamente recogemos los identificadores que nos da Twitter, los añadimos a los datos que adjuntamos al subir el Twitter (línea 146) y tenemos en cuenta que Twitter añadirá -implícita y automáticamente- una URL a nuestro mensaje (líneas 147-149).

¿Cómo marcamos una imagen con la clase CSS to-twitter?. Dependerá de tu software. En mi caso, en este blog, uso Nikola y sería algo así:

.. image:: ocsp.jpg
   :align: center
   :class: to-twitter

Podemos indicar varias clases CSS separándolas con espacios así que podemos seguir usando la decoración CSS que queramos para esas imágenes. Bastará con añadir to-twitter a las clases CSS que estemos usando para esas imágenes.

Un último detalle: Twitter también permite publicar vídeo. El código no necesita apenas cambios, salvo la búsqueda de las etiquetas HTML relevantes y la extracción de sus datos. Si usamos la etiqueta HTML 5 video el cambio sería trivial. Esto queda como ejercicio para el lector :).