Introduction à
Scrapy


web crawling et
extraction de données


Damien Accorsi — damien@accorsi.info

Qui suis-je ?

Damien Accorsi

Freelance architecture et dév. backend

Créateur de Tracim — http://tracim.fr

Contributeur LinuxFR — http://linuxfr.org/users/lebouquetin

Qu'est-ce que Scrapy ?

⚝ Le site web de Scrapy dit...

« An open source and collaborative framework
for extracting the data you need from websites.
In a fast, simple, yet extensible way.  »

⚝ Scrapy est donc...

  • un outil de crawling
  • un outil d'extraction de données

A quoi sert Scrapy ?

agréger des données

Exemple : comparateur de prix, agrégateurs d'annonces...

extraire et structurer des données web

Exemple : création de statistiques sur un site web...

synchroniser des données

Exemple : traitement de données distantes CSV, XML...

Scrapy — http://scrapy.org

⚝ Une large communauté

  • 7,246 et 2,029 forks sur Github,
  • 7900 questions sur Stackoverflow

⚝ Un projet mature

  • Premier commit le 26/06/2008,
  • Utilisé par de nombreux professionnels
  • un « framework » exhaustif : documentation, configuration, évolutivité

Principe de fonctionnement

  1. définition des urls initiales
  2. parsing des pages
  3. extraction des données
  4. extraction des urls à suivre
  5. traitement des données
  6. itération suivante...


«  write the rules to extract the data and let Scrapy do the rest »

Scrapy par l'exemple

Trois exemples pour illustrer le fonctionnement de scrapy

afpy — jobs

LinuxFR — dépêches

Apec — salaires « python »

Exemple #1 — Afpy jobs

Extraction de la liste des offres d'emploi du site web de l'AFPY — http://www.afpy.org/jobs/

<div class="jobitem">
  <a href="http://www.afpy.org/jobs/full-stack-developper-python-django-angular">
    <h2 class="tileHeadline">Full Stack Developper - Python/Django + Angular</h2>
  </a>
  <span class="discreet">
    Créé le 26/01/2015 21:28
    par <a href="http://www.labadens.eu/w">Labadens</a>
  </span>
  
  <p>If you are a Python Developer with Front-end development experience, please read on!</p>
  <div class="portletMore">
    <a href="http://www.afpy.org/jobs/full-stack-developper-python-django-angular">Lire l'offre</a>
  </div>
</div>
...
<div class="listingBar">
    <span class="next">
        <a href="http://www.afpy.org/jobs?b_start:int=10">
           10 éléments suivants »
        </a>
    </span>
    ...
</div>
from scrapy import Spider, Item, Field, Request

class Job(Item):
    title = Field()
    url = Field()

class AfpyJobSpider(Spider):

    name = 'afpy_jobs'
    start_urls = ['http://www.afpy.org/jobs']

    def parse(self, response):

        for job in response.xpath('//div[@class="jobitem"]'):
            title_xpath = './a/h2[@class="tileHeadline"]/text()'
            url_xpath = './a/@href'
            
            title = job.xpath(title_xpath)[0].extract()
            url = job.xpath(url_xpath)[0].extract()

            yield Job(title=title, url=url)


        next_page_url_xpath = '//div[@class="listingBar"]/span[@class="next"]/a/@href'
        next_page_url = response.xpath(next_page_url_xpath)[0].extract()
        yield Request(url=next_page_url)
$ scrapy runspider afpy_spider.py -o afpy_jobs.xml

Premiers enseignements

  • Besoin simple => code simple
  • Formats de sortie standard.
    XML, mais également CSV, JSON
  • Il faut aimer XPATH ;)

Exemple #2 - LinuxFR

Listing des dépêches de LinuxFR — http://www.linuxfr.org
prenant en compte le statut visité / non visité

Le processus d'exécution humain

  1. Aller sur la page de login
  2. Détecter visuellement le formulaire de login
  3. Remplir et validater le formulaire
  4. Vérifier que mon login a réussi
  5. Commencer le listing des dépêches et noter l'information

Le processus d'exécution Scrapy

  1. Crawling de la page de login
  2. Détection du formulaire via xpath
  3. Validation du formulaire
  4. Vérification de l'authentification
  5. Démarrage du crawling « normal »
<form id="new_account" class="new_account" action="/compte/connexion" accept-charset="UTF-8" method="post"><input name="utf8" type="hidden" value="✓" />
  <p>
    <label for="account_login">Identifiant</label>
    <input id="account_login" required="required" placeholder="Identifiant" size="20" type="text" name="account[login]" />
  </p>
  <p>
    <label for="account_password">Mot de passe</label>
    <input id="account_password" required="required" placeholder="Mot de passe" size="20" type="password" name="account[password]" />
  </p>
  <p>
    <input name="account[remember_me]" type="hidden" value="0" /><input id="account_remember_me" type="checkbox" value="1" name="account[remember_me]" />
    <label for="account_remember_me">Connexion automatique</label>
  </p>
  <p>
    <input type="submit" name="commit" value="Se connecter" id="account_submit" />
  </p>
</form>
class LinuxfrNewsSpider(Spider):

    name = ('linuxfr_news_spider')

    def __init__(self, login, password, page=0, *args, **kwargs):
        super(LinuxfrNewsSpider, self).__init__(*args, **kwargs)
        self.start_urls = ['https://linuxfr.org/compte/connexion']
        self.start_page_id = page
        self.login = login
        self.password = password

    def parse(self, response):
        return FormRequest.from_response(
            response,
            formxpath = '//form[@id="new_account"]',
            formdata = {
                'account[login]': self.login,
                'account[password]': self.password,
            },
            callback=self.after_login
        )

    def after_login(self, response):
        ...
    def parse(self, response):
        return FormRequest.from_response(
            response,
            formxpath = '//form[@id="new_account"]',
            formdata = {
                'account[login]': self.login,
                'account[password]': self.password,
            },
            callback = self.after_login
        )

    def after_login(self, response):
        if "Identifiant ou mot de passe invalide" in response.body:
            self.log("Login failed", level=log.ERROR)
            return

        print 'IDENTIFIED as '+response.xpath('//aside[@id="sidebar"]/div[@class="login box"]/h1/a/text()')[0].extract()
        print ''
        print 'waiting 5 seconds before continue...'
        time.sleep(5)
        yield Request('https://linuxfr.org/news?page=%s' % self.start_page_id, self.authenticated_parse)

    def authenticated_parse(self, response):
        # self.settings['DOWNLOAD_DELAY'] = 2
        ...
    def authenticated_parse(self, response):
        # self.settings['DOWNLOAD_DELAY'] = 2
        articles = response.xpath('//article')

        for article in articles:
            title = article.xpath('./header/h1/a/text()')[0].extract()
            path = article.xpath('./header/h1/a/@href')[0].extract()
            pub_date = article.xpath('./header/div[@class="meta"]/time/@datetime')[0].extract()
            score = article.xpath('.//figure[@class="score"]/text()')[0].extract()
            comment_nb = article.xpath('./footer//span[@class="nb_comments"]/text()').re(r'\d+')[0] ## HERE
            visited = article.xpath('./footer//span[@class="visit"]/text()').re(r', (.*)')[0] ## HERE
            yield LinuxNewsPost(
                title = title,
                url = 'https://linuxfr.org'+path,
                score = score,
                pub_date = pub_date,
                comment_nb = int(comment_nb),
                visited = visited
            )

        next_page_path = response.xpath('//nav[@class="toolbox"]/nav[@class="pagination"]/span[@class="next"]/a/@href')[0].extract()
        yield Request('https://linuxfr.org'+next_page_path, self.authenticated_parse)

Exécution via la commande suivante :

 scrapy runspider linuxfr_news_spider.py -a login=pyuggre -a password="mon_mot_de_passe" -a page=0

Ce qui donne, sous forme csv (un peu retraité)...

Nouveaux enseignements

  • Support de HTTPS
  • Support de l'authentification via un formulaire de login
  • Paramétrage de l'exécution
  • On peut aussi aimer les expressions régulières :)

Exemple #3 - APEC

Salaire moyen des offres d'emploi « python » sur le site de l'APEC ?

Problématiques :

  • Parser les résultats de recherche pour trouver les annonces
  • Naviguer dans la pagination des résultats de recherche
  • Crawler et parser les pages de présentation de chaque annonce
  • Extraire les informations de salaire (quand elles sont présentes)

Accessoirement : gérer la dette technique du site web de l'APEC

<table class="noFieldsTable">
    <tr>
        <th>Référence Apec :</th>
        <td>125496545W-5417-6876</td>
    </tr>
    ...
    <tr>
        <th>Lieu :</th>
        <td>BOULOGNE</td>
    </tr>
    <tr>
        <th>Salaire :</th>
        <td>De 45000 à 50000 EUR par an</td>
    </tr>
    ...
<table class="noFieldsTable">

Mais le texte peut aussi être absent, de la forme "aux alentours de 40K€", "à discuter"...

Solution technique :

  • Une méthode de parsing des « résultats de recherche »
  • Une méthode de parsing des page « annonces »
  • Utilisation des « ItemLoader » pour normaliser le contenu et gérer les différents cas

Architecture du code :

  • Un composant « spider » qui « crawle » les pages, extrait les liens et les informations
    • contrôleur dans une architecture MVC
  • Un composant « item » qui représente les données structurées finales
    • structure de données sérializable
  • Un composant « ItemLoader » qui transforme les données « brutes » issues du crawling en données structurées
    • flexibilité pour la mise au point de règles complexes

Résultats

Voir le code

Voir les résultats

Nouveaux enseignements

  • Scrapy est flexible et bien structuré
  • Scrapy est bien documenté
  • Les expressions régulières, c'est inévitable ;)

Points forts de Scrapy

  • Framework mature et hyper documenté
  • Simplicité et montée en compétences immédiate
  • Grande souplesse d'utilisation et extensibilité
  • Aussi bien adapté à du prototypage ou du code jetable qu'à la mise en place d'une infrastructure de crawling

Quand ne pas utiliser Scrapy ?

  • lorsque des api sont disponibles (les données sont déjà structurées)
  • encore plus s'il s'agit d'api REST — creuser du côté de Tortilla
  • quand on aime python 3 — Scrapy supporte python 2.7 uniquement
  • lorsqu'on cible des sites fortement dynamiques — regarder du côté de spynner (voire phantomjs — javascript)

Sujets à creuser par vous même ;)

Get the python source codes from Github