Scraping Twitter por diversión... y necesidad
Hace como una semana, después de leer un post en Reddit con algunas cuentas de Twitter para seguir y estar al tanto de las últimas noticias en seguridad informática, y decidí seguirlas.
Sin embargo, no pude encontrar ningún modo de obtener la información, como normalmente se hace con RSS para los blogs y páginas similares. Y no quería crearme una cuenta nueva y sólo recibir noticias, sin interactuar de ningún modo.
En resumen, sólo quería leer noticias, como hago con mi cuenta de Reddit, donde tengo un multireddit con todos los subreddits que creo que son útiles o interesantes.
Parecía que se me forzaba a crear una nueva cuenta, significando que me tendría que crear un nuevo usuario y contraseña (aunque eso tampoco es un gran problema, porque uso un password manager) y debería instala las herramientas oficiales (básicamente, la aplicación de Twitter) para estar al tanto de las noticias.
Y la verdad es que no me apetecía crearme una cuenta, ni usar su aplicación; así que creé mi propio script para obtener los datos que quería.
Reconocimiento
Como con cualquier proyecto, lo primero que hay que hacer es el diseño; y eso implica saber cómo la página Twitter (en escritorio, con JavaScript habilitado) carga más contenido cuando se alcanza el final de la página; y cómo sabe que hay nuevos tweets disponibles.
Para ello, sólo tenemos que inspeccionar el tráfico entre el navegador y el servidor; y las herramientas de desarrollo de Firefox son suficientes para esta tarea.
Obteniendo actualizaciones
La primera cosa que notamos inspeccionando el tráfico (la pestaña ‘network’, en las herramientas de desarrollo) es que, periódicamente (cada medio minuto, más o menos), hay algunas peticiones a lo que parece ser una página de actualización:
https://twitter.com/i/profiles/show/malwareunicorn/timeline/tweets?composed_count=0
&include_available_features=1
&include_entities=1
&include_new_items_bar=true
&interval=30000
&latent_count=0
&min_position=904803707652411392).
Y la respuesta es la siguiente:
$ curl -Ls "https://twitter.com/i/profiles/show/malwareunicorn/timeline/tweets?composed_count=0&include_available_features=1&include_entities=1&include_new_items_bar=true&interval=30000&latent_count=0&min_position=904803707652411392" 2>&1 | jq "."
{
"max_position": null,
"has_more_items": false,
"items_html": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n",
"new_latent_count": 0
}
Cuando hay una nueva actualización, items_html
contiene el HTML de los tweets nuevos,
listo para ser añadido al stream (una lista ordenada con id="stream-items-id"
).
No sé exactamente qué significa cada uno de los parámetros en la petición (aunque los nombres dan alguna pista); pero lo más importante a tener en cuenta es:
-
Username: Las peticiones de actualización se hacen a https://twitter.com/i /profiles/show/USUARIO/(…), así que estas peticiones se pueden hacer sin problema por cada usuario
-
Min_position: Probablemente, el ID del último tweet obtenido. Esta teoría se refuerza con el hecho de que el ID del primer tweet (el valor de
data-tweet-id
en el feed (obviando el tweet anclado) es, efectivamente, 904803707652411392. Además, en el contenedor de los tweets (el div conclass="stream-container"
) hay un par de parámetros que están seguramente relacionados con este:<div class="stream-container" data-max-position="904803707652411392" data-min-position="903449933658722305">
Para probar nuestra hipótesis sobre el significado de cada parámetro, vamos a crear una petición para obtener el primer tweet (el de ID 904803707652411392). Para ello, debemos obtener el ID del tweet anterior, que resulta ser 904803158697717760. Este es el resultado:
$ curl -Ls "https://twitter.com/i/profiles/show/malwareunicorn/timeline/tweets?composed_count=0&include_available_features=1&include_entities=1&include_new_items_bar=true&interval=30000&latent_count=0&min_position=904803158697717760" 2>&1 | jq "."
{
"max_position": "904803707652411392",
"has_more_items": false,
"items_html": "\n <li class=\"js-stream-item stream-item stream-item\n\" data-item-id=\"904803707652411392\"\nid=\"stream-item-tweet-904803707652411392\"\ndata-item-type=\"tweet\"\n data-suggestion-json=\"{"suggestion_details":{},"tweet_ids":"904803707652411392","scribe_component":"tweet"}\">\n \n\n\n\n <div class=\"tweet js-stream-tweet js-actionable-tweet js-profile-popup-actionable dismissible-content\n original-tweet js-original-tweet\n \n \n\"\n \ndata-tweet-id=\"904803707652411392\"\ndata-item-id=\"904803707652411392\"\ndata-permalink-path=\"/malwareunicorn/status/904803707652411392\"\ndata-conversation-id=\"904803158697717760\"\n data-is-reply-to=\"true\" \n data-has-parent-tweet=\"true\" \n\ndata-tweet-nonce=\"904803707652411392-e2263fa7-0890-4ac8-9258-116b952e8d04\"\ndata-tweet-stat-initialized=\"true\"\n\n\n\n\n\n\n data-screen-name=\"malwareunicorn\" data-name=\"Malware Unicorn\" data-user-id=\"2344060088\"\n data-you-follow=\"false\"\n data-follows-you=\"false\"\n data-you-block=\"false\"\n\n\ndata-reply-to-users-json=\"[{"id_str":"2344060088","screen_name":"malwareunicorn","name":"Malware Unicorn","emojified_name":{"text":"Malware Unicorn","emojified_text_as_html":"Malware Unicorn"}}]\"\n\n\n\n\n\n\n\ndata-disclosure-type=\"\"\n\n\n\n\n\n\n\n\n\n\n\n\n\n >\n\n <div class=\"context\">\n \n \n </div>\n\n <div class=\"content\">\n \n\n \n\n \n <div class=\"stream-item-header\">\n <a class=\"account-group js-account-group js-action-profile js-user-profile-link js-nav\" href=\"/malwareunicorn\" data-user-id=\"2344060088\">\n <img class=\"avatar js-action-profile-avatar\" src=\"https://pbs.twimg.com/profile_images/902049789587501056/TtjvBlud_bigger.jpg\" alt=\"\">\n <span class=\"FullNameGroup\">\n <strong class=\"fullname show-popup-with-id \" data-aria-label-part>Malware Unicorn</strong><span class=\"UserBadges\"></span><span class=\"UserNameBreak\"> </span></span><span class=\"username u-dir\" dir=\"ltr\" data-aria-label-part>@malwareunicorn</span></a>\n\n \n <small class=\"time\">\n <a href=\"/malwareunicorn/status/904803707652411392\" class=\"tweet-timestamp js-permalink js-nav js-tooltip\" title=\"13:29 - 4 sept. 2017\" data-conversation-id=\"904803158697717760\"><span class=\"_timestamp js-short-timestamp js-relative-timestamp\" data-time=\"1504556989\" data-time-ms=\"1504556989000\" data-long-form=\"true\" aria-hidden=\"true\">17 h</span><span class=\"u-hiddenVisually\" data-aria-label-part=\"last\">hace 17 horas</span></a>\n</small>\n\n <div class=\"ProfileTweet-action ProfileTweet-action--more js-more-ProfileTweet-actions\">\n <div class=\"dropdown\">\n <button class=\"ProfileTweet-actionButton u-textUserColorHover dropdown-toggle js-dropdown-toggle\" type=\"button\">\n <div class=\"IconContainer js-tooltip\" title=\"Más\">\n <span class=\"Icon Icon--caretDownLight Icon--small\"></span>\n <span class=\"u-hiddenVisually\">Más</span>\n </div>\n </button>\n <div class=\"dropdown-menu is-autoCentered\">\n <div class=\"dropdown-caret\">\n <div class=\"caret-outer\"></div>\n <div class=\"caret-inner\"></div>\n </div>\n
- \n \n <li class=\"copy-link-to-tweet js-actionCopyLinkToTweet\">\n <button type=\"button\" class=\"dropdown-link\">Copiar enlace del Tweet</button>\n </li>\n <li class=\"embed-link js-actionEmbedTweet\" data-nav=\"embed_tweet\">\n <button type=\"button\" class=\"dropdown-link\">Insertar Tweet</button>\n </li>\n
¡Bien! Tenemos el tweet deseado (sí, tiene un montón de código). Para comprobarlo, podemos filtrar el texto con grep, para ver si contiene “Was made with a PE and ELF binary with IDA” (el texto del tweet que queremos):
$ curl -Ls "https://twitter.com/i/profiles/show/malwareunicorn/timeline/tweets?composed_count=0&include_available_features=1&include_entities=1&include_new_items_bar=true&interval=30000&latent_count=0&min_position=904803158697717760" 2>&1 | grep -o "Was made with a PE and ELF binary with IDA"
Was made with a PE and ELF binary with IDA
Perfecto, ahora podemos obtener tweets. Ahora simplemente se trata de interpretar el HTML (yo usé BeautifulSoup para ello) y obtener todos los datos que queramos.
Página infinita
Con todo lo que hemos aprendido de cómo se obtienen los tweets nuevos, tenemos una tarea más fácil, puesto que tenemos mucha información interesante sobre la organización de la página.
Cuando bajamos al final de la página, vemos una nueva petición GET, similar a la primera, a la siguiente dirección:
https://twitter.com/i/profiles/show/malwareunicorn/timeline/tweets
?include_available_features=1
&include_entities=1
&max_position=903449933658722305
&reset_error_state=false
Y la respuesta es otro JSON con las siguientes claves y valores:
- min_position: 902568467332603904
- has_more_items: true
- items_html: (mucho HTML con los nuevos tweets)
- new_latent_count: 20
Ahora se ve claramente que las peticiones se hacen de acuerdo a unos límites, indicados
con los parámetros max_position
y min_position
, que son tomados por primera vez del
contenedor de los tweets y luego actualizados con las respuestas JSON.
Construyendo el scraper y notificando las actualizaciones
Tras obtener toda la información, es trivial construir un programa que pida las páginas e interprete el HTML (como ya dije antes, se puede usar BeautifulSoup con Python) para obtener la información deseada.
Luego, se pueden usar diferentes métodos para notificar los tweets, ya sea usando
subprocess.Popen
para llamar a notify-send
(al menos en sistemas tipo UNIX) o usando
una biblioteca de Python. Yo lo hice con notify2,
permitiéndome cargar fácilmente el texto del tweet en una notificación y obtener las
actualizaciones mientras hago otras cosas, como jugar a videojuegos o trabajar.
A veces a demasiadas actualizaciones y algunas no se muestran, así que debería intentar buscar otro método para obtener una herramienta más útil.
Usando el scraper con otro propósito
Aunque la idea inicial es simplemente obtener los tweets de la gente a la que “sigo” (realmente no les sigo con mi cuenta, porque no tengo…), este scraper puede resultarle más útil a otra gente sindo usado sólo como biblioteca.
Por supuesto, si el scraper te resulta útil, eres libre de usarlo y modificarlo (bajo los términos especificados en la licencia, si es que hay).
Por ejemplo, para obtener los 2 últimos tweets de una persona, se puede usar la función
get_tweets
, que recibe una lista con los nombres de las cuentas (se puede leer la
documentación de cada función para más información), como se ve a continuación:
>>> import scraper
>>> data = scraper.get_tweets (["mzbat"], max_count = 2)
>>> data
>>> data
{'mzbat': {'902887483704320004': {'permalink': u'/Rainmaker1973/status/902887483704320004', 'stats': {'likes': 6407, 'retweets': 3659, 'replies': 64}, 'conversation': 902887483704320004, 'text': u'A really cool visual explanation of how potential & kinetic energy are\nexchanged on a trampoline [http://buff.ly/2qhkllZ\xa0](https://t.co/a4NepKyZnj\n"http://buff.ly/2qhkllZ"\n)[pic.twitter.com/gAR1WWBHiu](https://t.co/gAR1WWBHiu)\n\n', 'tweet_age': 1504100125, 'pinned': False, 'retweet_info': {'retweet_id': u'904701461153681408', 'retweeter': u'mzbat'}, 'user': {'username': u'Rainmaker1973', 'displayname': u'Massimo', 'uid': 177101260, 'avatar': u'https://pbs.twimg.com/profile_images/686298118904786944/H4aoP8vA_bigger.jpg'}, 'tweet_id': '902887483704320004', 'retweet': True}, '720999941225738240': {'profile_pic': u'https://pbs.twimg.com/profile_images/683177128943337472/4CSt778e_400x400.jpg', 'permalink': u'/mzbat/status/720999941225738240', 'stats': {'likes': 3068, 'retweets': 854, 'replies': 67}, 'tweet_id': '720999941225738240', 'text': u'A dude told me I hacked like a girl. I told him if he popped shells a little\nfaster, he could too.[pic.twitter.com/PgiyYw41oo](https://t.co/PgiyYw41oo)\n\n', 'tweet_age': 1460734756, 'pinned': True, 'conversation': 720999941225738240, 'user': {'username': u'mzbat', 'displayname': u'b\u0360\u035d\u0344\u0350\u0310\u035d\u030a\u0341a\u030f\u0344\u0343\u0305\u0302\u0313\u030f\u0304t\u0352', 'uid': 253608265, 'avatar': u'https://pbs.twimg.com/profile_images/683177128943337472/4CSt778e_bigger.jpg'}, 'retweet': False}}}
>>> print json.dumps (data, indent=4)
{
"mzbat": {
"902887483704320004": {
"permalink": "/Rainmaker1973/status/902887483704320004",
"stats": {
"likes": 6407,
"retweets": 3659,
"replies": 64
},
"conversation": 902887483704320004,
"text": "A really cool visual explanation of how potential & kinetic energy are\nexchanged on a trampoline [http://buff.ly/2qhkllZ\u00a0](https://t.co/a4NepKyZnj\n\"http://buff.ly/2qhkllZ\"\n)[pic.twitter.com/gAR1WWBHiu](https://t.co/gAR1WWBHiu)\n\n",
"tweet_age": 1504100125,
"pinned": false,
"retweet_info": {
"retweet_id": "904701461153681408",
"retweeter": "mzbat"
},
"user": {
"username": "Rainmaker1973",
"displayname": "Massimo",
"uid": 177101260,
"avatar": "https://pbs.twimg.com/profile_images/686298118904786944/H4aoP8vA_bigger.jpg"
},
"tweet_id": "902887483704320004",
"retweet": true
},
"720999941225738240": {
"profile_pic": "https://pbs.twimg.com/profile_images/683177128943337472/4CSt778e_400x400.jpg",
"permalink": "/mzbat/status/720999941225738240",
"stats": {
"likes": 3068,
"retweets": 854,
"replies": 67
},
"tweet_id": "720999941225738240",
"text": "A dude told me I hacked like a girl. I told him if he popped shells a little\nfaster, he could too.[pic.twitter.com/PgiyYw41oo](https://t.co/PgiyYw41oo)\n\n",
"tweet_age": 1460734756,
"pinned": true,
"conversation": 720999941225738240,
"user": {
"username": "mzbat",
"displayname": "b\u0360\u035d\u0344\u0350\u0310\u035d\u030a\u0341a\u030f\u0344\u0343\u0305\u0302\u0313\u030f\u0304t\u0352",
"uid": 253608265,
"avatar": "https://pbs.twimg.com/profile_images/683177128943337472/4CSt778e_bigger.jpg"
},
"retweet": false
}
}
}
En ese ejemplo, dos tweets son obtenidos e impresos por pantalla usando json.dumps
.
Los datos obtenidos están en un diccionario con el siguiente formato:
{
<cuenta>: {
<tweet-id>: {
"profile_pic": <avatar de la cuenta>
, "permalink": <enlace al tweet>
, "stats": {
"likes": <número de 'likes'>
, "retweets": <número de retweets>
, "replies": <número de respuestas>
}
, "tweet_id": <id del tweet>
, "text": <texto del tweet>
, "tweet_age": <hora del tweet, con formato epoch de UNIX>
, "pinned": <indicación para saber si el tweet está anclado>
, "conversation": <id de la conversación>
, "user": {
# Información de la cuenta propietaria del tweet (importante si es un retweet)
"username": <nombre de la cuenta (twitter.com/nombre)>
, "displayname": <nickname de la cuenta>
, "uid": <id de la cuenta>
, "avatar": <imagen de la cuenta>
}
, "retweet": <indicación para saber si ha sido un tweet de otra persona>
# Sólo si "retweet" es True
, "retweet_info" {
"retweet_id": <id del retweet>
, "retweeter": <nombre de la cuenta que retweeteó (la misma de la que se están extrayendo los datos)>
}
}
# ... (más tweets en la cuenta)
}
# ... (más cuentas con sus tweets)
}
Probablemente algunas cosas se deben cambiar para exponer sólo los métodos necesarios
para obtener datos (de hecho, sólo get_tweets
debería ser público, haciendo al resto
métodos privados), pero por el momento no creo que sea necesario.
El proyecto entero explicado en este artículo está en Github, así que cualquiera puede usarlo y contribuir libremente, si quiere.