« Paie Ton Patch !™ » : Weboob

GNU/Linux Magazine n° 204 | mai 2017 | François Revol
Creative Commons
  • Actuellement 0 sur 5 étoiles
0
Merci d'avoir participé !
Vous avez déjà noté cette page, vous ne pouvez la noter qu'une fois !
Votre note a été changée, merci de votre participation !
Combien de fois vous êtes-vous dit « Pourquoi c’est pas corrigé ça ? » ou « faudrait patcher ce truc » sans oser le faire ? Voici une occasion !

Web Outside of Browsers [1] est un ensemble d'outils modulaires en ligne de commandes écrits en Python, ainsi que quelques applications graphiques Qt. Son but est de pouvoir utiliser des sites web comme l'on utilise d'autres ressources sous Unix, à l'aide d'outils simples composables et scriptables. Parmi les outils de scraping existants, il s'agit probablement du plus complet, et décrire ses possibilités nécessiterait plusieurs articles. Ses compétences vont de la récupération de vidéos de sites web (et non-web en Flash) à l’émission de virements bancaires, en passant par l'édition de tickets dans un bugtracker, ou la recherche d’emploi. C'est cette dernière fonction que nous testerons, en contribuant au support du site LinuxJobs.fr [2].

Sur le canal IRC [3] de Weboob, il est de tradition lorsque quelqu’un râle sur une fonctionnalité manquante de dire « PTP », c’est-à-dire « Paie Ton Patch », comme invitation à contribuer, certes pas toujours efficace. L’expression, consacrant le patch comme monnaie officielle de la do-ocratie, est donc toute désignée comme titre.

1. Architecture

Weboob est constitué de nombreux modules, chacun implémentant une ou plusieurs capacités (Capability) telles que CapVideo, CapMessages, CapJob, etc. et de nombreuses applications utilisant ces différentes capacités. Un module est composé de plusieurs fichiers, chacun déclarant une ou plusieurs classes :

- __init__.py : exporte la classe de base Module ;

- module.py : définit une sous-classe de Module indiquant son nom, ses capacités, etc. ;

- browser.py : dérive la classe Browser qui implémente l’interaction avec le site web ;

- pages.py : implémente plusieurs classes décrivant chaque type de page que le site retourne et comment y récupérer les informations désirées ;

- test.py : le truc que tout le monde oublie ;

- favicon.png : une icône PNG de 64x64 avec transparence. Les icônes dans Weboob sont volontairement caricaturales pour contourner un éventuel problème de droit des marques. Donc lâchez-vous !

Enfin, le framework propose tout ce qu’il faut pour gérer les connexions HTTP(S), et parser le HTML ou le JSON. Quelques modules datent encore des « heures les plus sombres de l’histoire de Weboob », avant l’avènement du Browser2. Un module important weboob.deprecated.browser est une invitation à contribuer à une mise à jour.

Le tout est documenté sur le site de développement [4]. Nous utiliserons l’application handjoob pour tester notre module.

2. Code source et installation

Weboob documente [5] plusieurs méthodes d'installation, suivant votre distribution et vos besoins. Pour tester notre code plus simplement, on peut installer en mode développeur, ou même lancer directement les commandes sans installation à l’aide d’un script.

Weboob a quelques dépendances, sur Debian par exemple :

# apt install python-html2text python-prettytable python-dateutil python-lxml python-mechanize python-yaml python-cssselect python-requests

Le code source officiel est disponible sur un serveur dédié [6], où les principaux contributeurs ont aussi leur propre dépôt. Récupérons le dépôt de développement, dans ~/src/ par exemple :

$ git clone git://git.symlink.me/pub/weboob/devel.git weboob

2.1 Installation « développeur »

Un script est proposé pour installer sans toucher au système (passez --deps au script si vous n’avez pas trouvé toutes les dépendances dans votre distro, il les installera par pip) :

$ mkdir ~/bin

$ tools/local_install.sh $HOME/bin

$ echo 'export PATH=$PATH:$HOME/bin' >> ~/.bashrc

Ensuite il nous faut déclarer notre dépôt local comme source de modules, puis forcer une mise à jour :

$ echo "file://$HOME/src/weboob/modules" >> ~/.config/weboob/sources.list
$ weboob-config update

2.2 Pas d’install

Le script local_run.sh dans le dépôt permet d’éviter l’installation :

$ tools/local_run.sh handjoob search python

Si vous optez pour cette méthode, il vous suffira donc de lancer les commandes par ce script.

3. Génération d’un squelette de module

Vous avez probablement maintenant déjà fouillé le code source et vous vous apprêtez à copier un module existant vers modules/linuxjobs suivi d’un coup de sed. Je sais, on l’a tous fait, mais pour une fois on vous propose une méthode plus propre [7]. Depuis la racine du dépôt, créons une branche, puis invoquons l’outil magique :

$ git checkout -b linuxjobs

$ tools/boilerplate.py cap "linux jobs" CapJobCreated ...modules/linuxjobs/__init__.py

...

Si vous avez déjà configuré git proprement, il utilisera même vos nom et email pour l’attribution. Forçons ensuite une mise à jour du cache des modules, et vérifions qu’il trouve bien notre bébé :

$ git checkout -b linuxjobs

$ weboob-config update
...
$ weboob-config info linuxjobs
...

| License         | AGPLv3+

| Description     | linuxjobs website

| Capabilities    | CapJob
...

4. Choisir la source des données

Avant de foncer tête baissée, il convient d’étudier un peu le site : comment se fait une recherche ? Les données sont-elles bien balisées ? Y a-t-il une source plus fiable et plus stable que le code HTML de la page, via des requêtes AJAX récupérant du XML ou du JSON ? Le scraping reste un dernier recours fragile, sensible au moindre changement des pages.

Ici le site est récent, et les annonces sont présentées très simplement, il faudra donc valider proprement les données. Si le site ne fait pas de requêtes AJAX, il existe par contre des flux RSS pour chaque catégorie, où la description est en Markdown au lieu de HTML. Par contre, rechercher une annonce nécessiterait de savoir dans quel flux chercher, donc parser la catégorie dans la page HTML de toute façon. Et puis nous voulons un exemple bien gore pour l’article, donc restons sur le hache-thé-aime-elle.

5. Remplissons les blancs

Nous allons maintenant remplir naïvement le squelette généré avant de réaliser un premier test, en nous appuyant sur la documentation de CapJob et d’autres modules comme adecco (mais il utilise l’ancienne API) ou popolemploi (si, le module existe !). L’exemple reste simple, mais parfois les interactions entre les classes dans certains modules complexes peuvent nécessiter une aspirine. N’hésitez pas à demander aux gens bons sur IRC !

5.1 module.py

La classe LinuxJobsModule définit trois méthodes non implémentées que nous devons donc remplir :

- advanced_search_job, qui effectue une recherche sur des critères configurés au préalable. Le site visé ne proposant pas de recherche multicritère il nous faudrait filtrer les résultats, et gérer aussi la configuration du module. Nous allons donc laisser cette méthode de côté. Mais vous pourrez proposer un patch !

- get_job_advert doit retourner un objet décrivant l’annonce correspondant à l’identifiant unique en paramètre. Le site semble utiliser un nombre croissant monotone dans l’URL pour cet usage. Nous passons simplement la requête au browser comme font les autres modules :

    def get_job_advert(self, _id, advert=None):
returnself.browser.get_job_advert(_id, advert)

- search_job doit itérer sur une recherche par mots-clefs. Ici aussi, c’est le browser qui se chargera de la requête :

    def search_job(self, pattern=None):
for job_advert inself.browser.search_job(pattern):

            yield job_advert

5.2 browser.py

La classe LinuxJobsBrowser est chargée de l’interaction avec le site, et utilise différentes Pages en fonction de l’URL demandée.

Plutôt que Page1 et Page2, nous utiliserons SearchPage et AdvertPage en indiquant les regexp idoines, l’une récupérant l’ID de l’annonce, l’autre indiquant où spécifier la chaîne à rechercher. Corrigeons au passage l’URL de base, par défaut en HTTP et .com :

from .pages import SearchPage, AdvertsPage
importurllib

class LinuxJobsBrowser(PagesBrowser):

    BASEURL = 'https://www.linuxjobs.fr'

    advert_page = URL('/jobs/(?P<id>.+)', AdvertPage)

    search_page = URL('/search/(?P<job>)', SearchPage)

Nous aurions également pu spécifier un PROFILE de navigateur (Weboob se fait passer pour Firefox par défaut), ce qui est utile entre autres pour récupérer des vidéos HLS à la place du Flash sur un site codé avec les pieds. Il est également possible de spécifier un TIMEOUT différent des 10s par défaut.

En lieu et place du get_stuff proposé, nous ajouterons les méthodes que nous appelons depuis LinuxJobsModule :

    def get_job_advert(self, _id, advert):

        self.advert_page.go(id=_id)

        assert self.advert_page.is_here()

        return self.page.get_job_advert(obj=advert)

    def search_job(self, pattern=None):

        if pattern is None:

            return []

        self.search_page.go(job=urllib.quote_plus(pattern.encode('utf-8')))

        assert self.search_page.is_here()

        return self.page.iter_job_adverts()

Dans les deux cas, nous appelons la méthode go() pour envoyer la requête après avoir préparé les paramètres. Si l’id n’a pas vraiment besoin de traitement, la chaîne de recherche doit être encodée en UTF-8 et les espaces remplacés par des signes plus.

Comme suggéré dans get_stuff, un assert s’assure que nous sommes bien passés sur la bonne page, dont une instance sera alors assignée à self.page. Puis nous appelons une méthode de la classe correspondante pour remplir l’objet décrivant l’annonce, ou itérer sur les résultats de la recherche.

5.3 pages.py

C’est le gros du boulot puisque ces classes vont rechercher les éléments utiles dans le contenu de la page. Ce sont celles que l’on doit patcher lorsqu’un webmaster a un accès de créativité. Heureusement, cette tâche s’est considérablement simplifiée depuis Browser2.

class AdvertPage(HTMLPage):

    @method

    class get_job_advert(ItemElement):

        klass = BaseJobAdvert

Alors là, ça ressemble à de la magie pour qui n’est pas encore à l’aise avec les décorateurs de classe en Python. Nous déclarons une classe AdvertPage qui dérive de HTMLPage. Le décorateur @method indique que la classe déclarée en dessous (get_job_advert) sera appelée comme une méthode, et qu’elle sera au final une BaseJobAdvert dont nous remplirons les champs juste après. Les autres modules font ainsi, donc ça devrait fonctionner...

Contentons-nous pour l’instant de récupérer l’id, l’url (que l’on reconstruit à partir de l’id et de la regexp de l’URL) et l’intitulé du poste (title et job_name sont identiques) qui ô joie est le contenu de la balise title :

        obj_id = Env('id')

        obj_url = BrowserURL('advert_page', id=Env('id'))

        obj_title = CleanText('//title')

        obj_job_name = CleanText('//title')

Rassurez-vous, ça se corsera pour les autres champs. Le filtre CleanText permet de nettoyer le texte des tabulations et autres retours à la ligne, et d’être sûr que le résultat est bien de l’Unicode. Nous lui passons en paramètre un sélecteur XPath indiquant la balise title.

Passons à la recherche. Les résultats sont groupés dans un div par catégorie, chacun contenant des balises a vers les annonces. Dans le lien nous trouvons par contre des infos mieux balisées que sur la page de l’annonce elle-même avec des span de classe job-title et job-company...

De plus, les résultats sont retournés sur une page unique, nous n’aurons donc pas à gérer la pagination.

Ici aussi nous utiliserons un peu de magie décorative :

class SearchPage(HTMLPage):

    @method

    class iter_job_adverts(ListElement):

item_xpath = '//a[@class="list-group-item "]'

        class item(ItemElement):

klass = BaseJobAdvert

obj_id = Regexp(Link('.'), '.*fr/jobs/(\d+)/.*')

            obj_title = CleanText('h4/span[@class="job-title"]')

            obj_society_name = CleanText('h4/span[@class="job-company"]')

Là encore c’est une page HTML, mais des classes existent pour gérer le JSON, CSV, XML… et même des fichiers XLS et PDF !

La magie va opérer et sélectionner tous les éléments répondant au sélecteur item_xpath. Au passage, on me dit que l’on peut user de $x('//a...') dans la console de Firefox pour tester les sélecteurs XPath en direct, pratique !

Notez l’espace dans le nom de classe du lien, elle doit être strictement égale. Une recherche dans le contenu aurait pu être demandée par a[contains(@class, "list-group-item")], ou a[has-class("list-group-item")], mais c’est spécifique à Weboob. Incidemment, la correspondance stricte nous filtre des liens « Voir toutes les annonces pour… » qui elles n’ont pas l’espace en bout de chaîne…

Pour chaque item trouvé, un objet BaseJobAdvertest retourné par l’itérateur avec les propriétés récupérées par les filtres déclarés ici. L’id numérique est retrouvé dans l’URL de l’annonce par une Regexp.

Sans oublier les imports nécessaires au début du fichier, pompés sur popolemploi.

6. Ça marche ?

Une petite mise à jour du cache des modules :

$ weboob-config update

Tentons une recherche :

$ handjoob -b linuxjobs search python

Warning: there is currently no configured backend for handjoob

Do you want to configure backends? (Y/n):

Nous n’avons pas encore configuré de backend, c’est-à-dire une instance du module. Répondons-y pour sélectionner celui qui nous intéresse, et relançons la recherche avec l’option de debug et sans limiter le nombre de réponses :

$ handjoob -b linuxjobs --debug -n 0search python
...

2016-10-01 04:24:38,860:DEBUG:backend.linuxjobs.browser:1.2:browsers.py:662:internal_callback Handle https://www.linuxjobs.fr/search/python with SearchPage

566@linuxjobs — DevOps

Society : OXALIDE

573@linuxjobs — H/F Ingénieur support produit

Society : Linagora

Ça marche \o/ Enfin un boulot intéressant ?

$ handjoob -b linuxjobs info 566@linuxjobs
DevOps

url: https://www.linuxjobs.fr/jobs/566

Job name : DevOps

Un peu succinct ! C’est normal, nous n’avons pas encore rempli tous les champs depuis la page de l’annonce. La commande info accepte la notation id@backend, ainsi que l’id seule puisque l’on force le backend avec -b.

7. Plus de champs

C’est maintenant que la facilité d’utilisation du framework va s’exprimer, sur la partie la plus tordue. Ajoutons la lecture de la date (en français et en toutes lettres) à iter_job_adverts :

            obj_publication_date = Date(CleanText('h4/span[@class="badge pull-right"]'), parse_func=parse_french_date)

Nous utilisons le filtre Date sur le texte nettoyé du span choisi, en précisant parse_french_date importé depuis weboob.tools.date pour lire notre date pas très standard. Et ça marche !

Passons à la page d’annonce. Les champs importants sont dans des balises h2, h4 ou même small dans quelques div imbriqués pas vraiment identifiés hormis par leur ordre d’apparition dans la page. Vous êtes dispensés de lire la documentation de XPath, voici les spoilers :

        obj_society_name = CleanText('//div[2]/div[@class="col-md-9"]/h4[1]')
 obj_publication_date = Date(CleanText('//div[2]/div[@class="col-md-9"]/small', replace=[(u'Ajoutée le', '')]), parse_func=parse_french_date) obj_place = Regexp(CleanText('//div[2]/div[@class="col-md-9"]/h4[2]'), '(.*) \(.*\)')

obj_description = CleanHTML('//div[4]/div[@class="col-md-9"]')

La société est en effet dans le premier h4 du lot. La date, elle, est préfixée par un « Ajouté le » sans espace à la fin... Nous précisons donc au filtre CleanText le remplacement à effectuer, puis nous utilisons encore une fois le parseur de date française. Le lieu est contenu dans le second h4, mais nous devons nous débarrasser de la catégorie entre parenthèses... Une Regexp s’en charge très bien. Quant à la description, c’est le 4ème div à droite, 1er div à gauche, on nettoie le HTML bien sûr, et c’est bon !

Note

Les commandes Weboob ne se contentent pas d’afficher le résultat en texte brut. L’option -f permet de spécifier un formater, pour effectuer vos propres traitements. Par exemple :

$ handjoob -b linuxjobs -f json -n 0 search python

[{"id": "566@linuxjobs", "publication_date": "2016-08-22", …
$ handjoob -b linuxjobs -f csv -n 0 search python > jobs.csv && loffice --calc --infilter="csv:59,34,0,1" jobs.csv

8. Un test

Histoire de montrer que l’on n’oublie pas d’écrire le test.py (ben oui, on cherche du boulot...) :

class LinuxJobsTest(BackendTest):

    MODULE = 'linuxjobs'

    def test_linuxjobs_search(self):

        l = list(self.backend.search_job('linux'))

        assert len(l)

        advert = self.backend.get_job_advert(l[0].id, l[0])

        self.assertTrue(advert.url, 'URL for announce "%s" not found: %s' % (advert.id, advert.url))

Ce serait un comble de ne pas trouver d’annonce parlant de Linux ! Vérifions donc le test, ainsi que le style de codage :

$ tools/run_tests.sh linuxjobs

$ tools/pyflakes.py

9. PTP !

C’est le moment de payer, on crée un commit :

$ git add modules/linuxjobs$ git commit -m "Add linuxjobs.fr module"

En vrai le commit-log serait plus long, mentionnant les fonctionnalités manquantes dans un second paragraphe. On en extrait un patch :

$ rm -f *.patch

$ git format-patch -n -s origin

Le canal upstream documenté étant la liste de diffusion [8], il convient tout d’abord de s’y inscrire pour pouvoir répondre, puis laissons git envoyer le patch :

$ git send-email --to=weboob@weboob.org *.patch

Avec un peu de chance, votre code sera accepté du premier coup. Sinon il faudra retenter après les corrections demandées.

Lorsque vous deviendrez un contributeur régulier, vous aurez probablement droit à votre propre dépôt sur le serveur, la pratique habituelle étant de proposer une branche pour fusion sur IRC ou la liste de diffusion.

Conclusion

Nous avons écrit et contribué à notre premier patch sur Weboob, ajoutant un module de recherche d’emploi presque complet, en utilisant les nouvelles possibilités du framework. Pour avoir déjà écrit quelques modules avant Browser2, la concision des filtres et leurs possibilités de composition rendent le code bien plus court et lisible.

Notez que Weboob est utilisé par plusieurs entreprises dans leur logiciel (Budgea, Cozy Cloud, etc.), et qu’il dispose même d’un support professionnel [9].

Enfin, sachez qu'en plus de contribuer au code, vous pouvez également soutenir les développeurs en devenant membre de l'Association Weboob [10] pour la modique somme de cinq euros.

Références

[1] Site officiel de Weboob : http://weboob.org/

[2] « Le job board du Logiciel Libre et de l’open source » : https://www.linuxjobs.fr/

[3] Canal IRC officiel de Weboob : irc://irc.freenode.net/#weboob

[4] Documentation développeurs : http://dev.weboob.org/

[5] Procédures d’installation : http://weboob.org/install

[6] Serveur git (dépôts officiels et personnels) : https://git.symlink.me/

[7] BACHELIER L., « Faster module creation for Weboob » : http://laurent.bachelier.name/2013/02/faster-module-creation-for-weboob/

[8] Liste de diffusion : http://lists.symlink.me/mailman/listinfo/weboob

[9] Support professionnel pour Weboob : http://weboob.com/

[10] Association Weboob : http://association.weboob.org/

Pour aller plus loin

Pour les anglophones voulant apprendre à contribuer sérieusement à des projets libres, il existe l’Upstream University : http://upstream-university.org/