Esta es una necesidad del proyecto Mapa de Afectos, de Zemos98 y Jartura. Se trataría de poder leer posts de Instagram con un determinado hashtag e importar automáticamente dichos contenidos a un Django.

Funcionamiento básico

A grandes rasgos la manera de hacer esto es mediante comandos de gestión de django que usen Selenium para hacer la interacción y que sean lanzados por un script de cron cada x tiempo, por ejemplo 10 minutos. Si se quiere hacer un poco más fancy en cliente, se puede hacer un script de long polling en JS que actualice el frontend mágicamente cada vez que se produzca una importación.

Instalar selenium

Selenium es un framework de automatización web. Se instala como cualquier otro paquete de python:

1
2
. ../env/bin/activate
pip install selenium

Tiene como dependencia los motores de navegación que se quieran emplear. Vamos a emplear por ejemplo firefox/gecko. Para ello visitamos https://github.com/mozilla/geckodriver/releases y descargamos la última versión. Le damos permisos de ejecución y la movemos a cualquier ubicación en el PATH de sistema:

1
2
3
4
cd ~/Descargas
tar -xvzf geckodriver-v0.27.0-linux64.tar.gz
sudo chmod +x geckodriver
sudo mv /usr/bin/geckodriver

Crear un comando de gestión de django

Los comandos de gestión de django se albergan en apps que han de estar en la lista de INSTALLED_APPS de settings.py. Creamos por ejemplo una app connectors y dentro de ella creamos una carpeta management con un __init__.py y otra carpeta dentro, commands:

1
2
3
4
. ../env/bin/activate
cd apps
../manage.py startapp connectors
mkdir -p connectors/management/commands && touch connectors/management/__init__.py

Dentro de esta creamos el comando en cuestión, por ejemplo instagram_import.py. Un comando es un objeto de la clase Command y tiene que tener un método handle, que es la secuencia lineal que ejecuta el mismo. También le podemos implementar un método add_arguments, de modo que pueda aceptar parámetros de ejecución. Le añadimos un par de ellos, que nos permitan especificar un hashtag y un número máximo de posts a ser importados:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from selenium import webdriver
# django
from django.core.management.base import (
    BaseCommand,
    CommandError
)
# project
from django.conf import settings

"""
A manage.py command to read posts from a Instagram hashtag
"""
class Command(BaseCommand):

    """
    Adds the path to project resource
    """
    def add_arguments(self, parser):
        parser.add_argument(
            '--hashtag',
            type=str,
            help='A valid hashtag',
        )
        parser.add_argument(
            '--limit',
            type=int,
            help='Maximum number of posts to be imported',
        )

    """
    Imports posts from Instagram
    """
    def handle(self, *args, **options): 
        pass

Ahora es cuestión de definir el comando que escrapea Instagram. Para ello usamos selenium, especificando que lo corremos correr de manera headless, es decir, sin interfaz gráfica:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def handle(self, *args, **options): 
        hashtag = options['hashtag']
        limit   = options['limit']

        if hashtag:
            try:
                options = webdriver.FirefoxOptions()
                options.set_headless()
                browser = webdriver.Firefox(firefox_options=options)
                browser.get('https://www.instagram.com/')
            except:
                print("There was a problem connecting to instagram in headless mode. Abort.")
                browser.close()
        else:
            raise CommandError('You have to provide a valid hashtag')

Lo primero que tenemos que sortear es el acceso usando una cuenta válida de Instagram. La creamos e introducimos estos datos en private_settings.py:

1
2
3
# private_settings.py
INSTAGRAM_USER = ''
INSTAGRAM_PASSWORD = ''

Una vez hemos accedido a la home de Instagram, introducimos los datos y hacemos login:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Login
try:
    username_input = browser.find_element_by_css_selector("input[name='username']")
    password_input = browser.find_element_by_css_selector("input[name='password']")
    username_input.send_keys(settings.INSTAGRAM_USER)
    password_input.send_keys(settings.INSTAGRAM_PASSWORD)
    login_button = browser.find_element_by_xpath("//button[@type='submit']")
    login_button.click()
except:
    print("Already logged-in")

Para ello usamos los selectores de selenium, browser.find_element_by_css_selector, que sería un análogo a un selector tipo querySelector, y find_element_by_xpath, que busca elementos descendiendo por el árbol de un nodo y donde '/' equivaldría a bajar un nivel y '//' equivaldría a bajar un número arbitrario de niveles, de modo que browser.find_element_by_xpath("//button[@type='submit']") sería equivalente a decir búscame en cualquier nivel de la DOM el primer botón de tipo submit. Una vez sorteado el login tenemos que hacer dismiss sobre los modales habituales con los que Instagram tortura a sus usuarias:

1
2
3
4
5
6
# If notifications modal click on dismiss
try:
    notifications_button = browser.find_element_by_xpath("//button[contains(text(), 'Not Now')]")
    notifications_button.click()
except:
    print("Notifications modal not dismissed")

Aquí el selector es algo más complejo porque Instagram como cualquier red social grande está muy protegida contra el escrapeo, para lo que usa una DOM ultra-hermética llena de clases e IDs generados al azar. La única manera es seleccionar el botón por su contenido de texto, para lo que usamos la función contains.

Una vez superadas ambos escollos lo que hacemos es parsear la DOM para obtener una lista con las urls de los posts más recientes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Visit hashtag
browser.get('https://www.instagram.com/explore/tags/%s/' % hashtag)
# Get posts
most_recent_anchor = "//h2[contains(text(), 'Most recent')]"
most_recent_links_anchor = most_recent_anchor + "/following-sibling::div//a"
links = browser.find_elements_by_xpath(most_recent_links_anchor)
urls = [ l.get_attribute('href') for l in links ]
print("List of posts: ", urls)
if limit:
    urls = urls[:limit]

Para ello usamos ahora el selector /following-sibling::div, que nos permite seleccionar a un sibling/hermano en la DOM, en este caso del H2 que encabeza la sección 'Most recent'. Una vez seleccionado dicho contenedor simplemente obtenemos todas las URLs que hay dentro, pillando sus elementos a y haciendo una lista por compresión de django con los contenidos de sus atributos href. Si hemos explicitado un límite lo aplicamos a dicha lista.

Ya por último vamos navegando por esa lista y vamos obteniendo las URLs de las imágenes asociadas a cada post y cerramos.

1
2
3
4
5
6
for url in urls:
    browser.get(url)
    image_anchor = '//article/header/following-sibling::div/following-sibling::div//img'
    image_src = browser.find_element_by_xpath(image_anchor).get_attribute('src')
    print(image_src)
browser.close()