[couverture de Linux Magazine 75]

Construire des robots pour le web

Article publié dans Linux Magazine 75, septembre 2005.

Copyright © 2005 - Philippe Bruhat

[+ del.icio.us] [+ Developers Zone] [+ Bookmarks.fr] [Digg this] [+ My Yahoo!]

Chapeau

À la suite de mes articles présentant la librairie LWP dans Linux Magazine 56 à 58, cette nouvelle série décrit l'utilisation de divers outils Perl et techniques d'analyse des sites pour construire des robots qui vont naviguer sur le web à notre place.

Des robots ?

On trouve sur le web de nombreux sites d'information, de recherche ou de vente. Le navigateur web et le protocole HTTP ne sont que l'interface permettant d'obtenir l'information souhaitée.

Dans de nombreux cas, on souhaite effectivement accéder à cette information, mais le navigateur web est un obstacle. Il nécessite la présence d'un utilisateur pour entrer l'URL, remplir les formulaires, cliquer sur les liens. Cette série d'articles va vous présenter, par l'exemple, des techniques d'analyse des sites pour construire de petits programmes (des robots !) qui vont, en fonction de divers paramètres, aller chercher l'information seuls, et même réagir et naviguer plus avant en fonction de l'information récupérée.

Au fur et à mesure de ces articles, nous allons visiter des sites de plus en plus complexes, construire des robots de plus en plus évolués, pour finir par produire des modules qui nous permettront de réutiliser un moteur de récupération d'information.

Ma boîte à outils pour le web

Présentation des outils utilisés

Pour écrire nos robots, nous avons à notre disposition de nombreux outils. Je vous présente ici la liste de ceux que contient ma boîte à outils personnelle :

Détails du proxy espion

Mon seul outil personnel dans la liste présentée précédemment est le proxy basé sur HTTP::Proxy. Il est disponible dans la distribution de HTTP::Proxy, sous le nom eg/logger.pl. Le code du proxy est également fourni sur le CD, avec le code des trois scripts de ce mois.

Le code de ce proxy n'est pas expliqué en détail ici, car il sort du cadre de cet article. Il s'agit d'un outil d'aide à l'analyse des interactions client/serveur HTTP, dont nous expliquons ci-après l'utilisation.

Pour chaque couple requête/réponse, nous verrons :

Le proxy accepte comme paramètres en plus des paramètres standards de HTTP::Proxy les paramètres suivants :

Chaque paramètre peut être répété autant que nécessaire. Si aucun paramètre peek n'est donné, le proxy espionnera tous les sites visités.

À titre d'exemple, voici le résultat de la demande de http://www.google.com/ par mon client web :

    $ ./logger.pl peek 'google\.\w+$'

    GET http://www.google.com/
    302 Found
        Content-Type: text/html
        Set-Cookie: PREF=ID=50559ac18bae0f57:CR=1:TM=1119132978:LM=1119132978:S=Px8CAVLCC5FoR1NK; expires=Sun, 17-Jan-2038 19:14:07 GMT; path=/; domain=.google.com
        Location: http://www.google.fr/cxfer?c=PREF%3D:TM%3D1119132978:S%3Dwpjw70CuTrboKsrd&prev=/

    GET http://www.google.fr/cxfer?c=PREF%3D:TM%3D1119132978:S%3Dwpjw70CuTrboKsrd&prev=/
    302 Found
        Content-Type: text/html
        Set-Cookie: PREF=ID=e2b4582bd0c2849e:LD=fr:TM=1119132978:LM=1119132978:S=keTI_KO9ZyhHypD3; expires=Sun, 17-Jan-2038 19:14:07 GMT; path=/; domain=.google.fr
        Location: http://www.google.fr/

    GET http://www.google.fr/
        Cookie: PREF=ID=e2b4582bd0c2849e:LD=fr:TM=1119132978:LM=1119132978:S=keTI_KO9ZyhHypD3
    200 OK
        Content-Type: text/html

Voici le résumé de ce que nous observons :

Ce proxy nous sera très utile lors de la création de nos robots.

Remarque sur les sites visités

Tout au long de cette série d'articles, je vais construire des robots pour automatiser la consultation de divers sites que j'utilise couramment. À chaque fois, j'ai choisi des sites qui correspondent à mon utilisation du web, et à mes fournisseurs.

Il ne s'agit pas de publicité gratuite, juste de la réalité de mon activité sur le web. D'ailleurs certains n'apprécieraient peut-être pas de savoir que je navigue sur leur site sans voir les pubs de leurs partenaires, ou que les cookies qu'ils se donnent tant de mal à m'envoyer sont perdus dès que les scripts se terminent...

Le but de cette série d'articles est de vous permettre, en utilisant les mêmes techniques et outils, de pouvoir à votre tour automatiser votre navigation sur le web selon vos besoins. Nous nous confronterons à des sites de plus en plus complexes au fur et à mesure de notre progression.

Maintenant que nous avons nos outils en main, nous allons pouvoir commencer...

Un script de « copier-coller » (paste)

Sur IRC, il est assez mal vu de coller sur le canal de larges extraits de code : on inonde le canal pour rien (ce qui est mal en soi), et pour peu que la discussion soit animée, le code va défiler rapidement rendant sa lecture impossible par d'éventuelles bonnes âmes.

La solution a été d'utiliser le web comme presse-papier mondial : il existe un certain nombre de sites de paste (collage) où un newbie qui a besoin d'aide peut coller son code (certains sites font même un peu de coloriage de code) et donner l'URL à ceux qui souhaitent l'aider. Une seule ligne est postée sur le canal, et ceux qui veulent donner un coup de main peuvent lire tranquillement le code sur leur navigateur ou le copier/coller (sans les pseudos et l'horodatage de leur client IRC) pour le tester localement.

Il existe même des « paste-bots » qui informent un canal choisi de l'ajout de nouvelles entrées sur le site.

Voici quelques sites de paste :

La faiblesse de ces sites, c'est qu'il faut passer par un navigateur web pour publier ces petits bouts de textes, ce qui ralentit énormément le processus. Par exemple, si j'ai un problème avec un script et que je veux montrer un bout de code sur un canal IRC, je vais devoir ouvrir mon éditeur, copier le contenu du fichier, ouvrir mon navigateur, coller le code (éventuellement en plusieurs fois selon mon éditeur), valider le formulaire, copier l'URL de la page renvoyée en retour et enfin coller cette URL dans le canal IRC.

Un script en ligne de commande prendrait en paramètre le fichier à coller, et afficherait l'URL à coller dans le client IRC, tout simplement.

J'ai choisi d'automatiser le site http://rafb.net/paste/, car c'est celui que j'utilise le plus souvent.

Le site propose un formulaire avec cinq champs : Language, Nickname, Description, Convert tabs et bien sûr une zone de texte pour coller son code. mech-dump va nous donner le nom des champs du formulaire HTML en un tournemain :

    $ mech-dump http://rafb.net/paste/
    POST http://rafb.net/paste/paste.php
      lang=C89                       (option)   [*C89/C (C89)|C/C (C99)|C++|C#|Java|Pascal|Perl|PHP|PL/I|Python|Ruby|SQL|VB/Visual Basic|Plain Text]
      nick=                          (text)
      desc=                          (text)
      cvt_tabs=No                    (option)   [*No|2|3|4|5|6|8]
      text=                          (textarea)
      <NONAME>=Paste                 (submit)

Notre script va commencer par charger les modules requis, puis définir les valeurs par défaut et récupérer les options sur la ligne de commande :

    #!/usr/bin/perl -w
    use strict;
    use WWW::Mechanize;
    use Getopt::Long;

    my %CONF = (
        lang     => 'Plain Text',
        nick     => 'A. Nonyme',
        desc     => '',
        cvt_tabs => 'No',
        text     => '',
    );

    GetOptions( \%CONF, "lang=s", "nick=s", "desc=s", "cvt_tabs|tabs=i", "text=s" )
      or die "Bad options";

Nous créons ensuite l'objet WWW::Mechanize. Note : Tout au long de cet article, notre robot sera contenu dans la variable $m (pour mech, le surnom donné à WWW::Mechanize par son auteur Andy Lester).

Si la description n'est pas fournie dans les options de ligne de commande, et qu'un nom de fichier a été passé sur la ligne de commande, c'est le nom du fichier qui sera utilisé. Le texte à coller est récupéré grâce à <> qui fait exactement ce qu'on attend : récupérer le contenu des fichiers dont on a passé le nom sur la ligne de commande ou, si rien n'a été passé, il lit le contenu de STDIN (soit la sortie d'un tube, soit ce que l'utilisateur tape sur le terminal).

    my $m = WWW::Mechanize->new;
    $m->get("http://rafb.net/paste/");
    die $m->res->status_line unless $m->success;

    unless ( $CONF{text} ) {
        $CONF{desc} ||= $ARGV[0];
        $CONF{text} = join "", <>;
    }

Notez que le script meurt en cas de problème de connexion. L'objet HTTP::Response correspondant à la dernière réponse reçue est accessible via la méthode response() ou son alias res(). Ici, on affiche la ligne de statut en cas d'échec de la requête, c'est-à-dire quand le code de la réponse n'est pas un 2xx ou un 3xx.

D'une manière générale, il est préférable de détecter les problèmes aussi tôt que possible, afin d'éviter que le script « parte en vrille » dès que quelque chose ne se passe pas comme prévu. Pour ce script-ci, cela n'a aucune incidence, mais un script qui automatise des achats ou des transactions importantes a plutôt intérêt à s'arrêter avant de vous endetter pour trois générations ! ;-) (Ceci dit, avec des systèmes comme le 1-Click d'Amazon, il n'y a plus besoin de donner son numéro de carte bleue pour acheter quelque chose... Autant éviter que notre robot clique n'importe où !)

Il ne reste plus qu'à remplir les champs (avec la méthode set_fields()) et valider le formulaire, avant de renvoyer l'URL de la page obtenue.

    $m->set_fields(%CONF);
    $m->submit;
    die $m->res->status_line unless $m->success;

    print $m->response->request->uri->as_string, "\n";

Cette dernière ligne de code mérite quelques explications, car elle est un peu complexe.

Voici ce qu'indique mon proxy personnel au sujet du dernier échange :

    POST http://rafb.net/paste/paste.php
        Referer: http://rafb.net/paste/
        lang                 => Plain Text
        nick                 => A. Nonyme
        desc                 => test
        cvt_tabs             => No
        text                 => test
    302 Found
        Content-Type: text/html; charset=ISO-8859-1
        Set-Cookie: uid=A.+Nonyme; expires=Sat, 25-Jun-05 22:48:16 GMT
        Location: /paste/results/g9oRiw95.html

    GET http://rafb.net/paste/results/g9oRiw95.html
        Cookie: uid=A.+Nonyme
        Cookie2: $Version="1"
        Referer: http://rafb.net/paste/
    200 OK
        Content-Type: text/html; charset=ISO-8859-1

Lorsque l'on soumet le formulaire, le robot fait un POST sur l'URL http://rafb.net/paste/paste.php. Le script CGI répond par une réponse 302 Found, qui indique que le résultat de la requête se trouve temporairement accessible à une autre URL. WWW::Mechanize suit automatiquement la redirection. L'URL qui nous intéresse, celle qui mène à la page contenant les données collées, est donc l'URL de la requête faite automatiquement par WWW::Mechanize, c'est-à-dire l'URL de la requête associée à la dernière réponse reçue. (L'URL de la redirection annoncée dans la première réponse.)

On remarque au passage que le site note notre nick dans un cookie, afin probablement de remplir automatiquement ce champ du formulaire la prochaine fois que nous aurons un paste à faire avec le même navigateur.

En mettant bout à bout les trois blocs de code listés précédemment, nous disposons d'un robot qui sait gérer un site de paste, et peut s'utiliser de manière créative :

    # montrer sa configuration disque
    $ df -h | paste-rafb --desc 'df -h'
    http://rafb.net/paste/results/YYsdKD11.html

    # et son /etc/fstab
    $ paste-rafb /etc/fstab
    http://rafb.net/paste/results/EFTOEA69.html

    # envoyer le code source du script à ses amis (avec coloration du code)
    $ paste-rafb --lang perl `which paste-rafb`
    http://rafb.net/paste/results/Nk9csX57.html

Comme ce script fonctionne comme un filtre, on peut même l'utiliser depuis vim pour coller du code ou du texte directement depuis son éditeur, avec des commandes comme :addrw !paste-rafb. addr est une spécification de lignes à traiter, comme par exemple % (tout le fichier), 3,12 (lignes 3 à 12), ,+10 (la ligne courante et les 10 suivantes), etc. Attention, il doit y avoir un espace entre le w et le !, pour que vim comprenne bien que le ! ne fait pas partie du nom de fichier.

Ce script est particulièrement utile : je m'en sers quasiment tous les jours.

D'autres outils de paste

Il existe sur CPAN un module qui s'appelle WebServices::Paste. Celui-ci ne me plaît pas trop, car le script fourni doit être modifié à la main pour pointer vers le bon serveur et il n'est pas conçu pour pouvoir être étendu au support d'autres sites de collage.

En revanche, un aspect intéressant de ce module, c'est qu'il utilise un autre module nommé Clipboard (sur lequel je n'ai pas eu le temps de me faire une opinion) qui permet de coller le contenu du presse-papier. Les inconvénients dépassant largement le maigre avantage que cela représente, j'ai préféré passer 10 minutes à écrire mon propre script.

Il serait assez simple de produire un module WWW::Paste (par exemple) qui sache gérer les diverses options des multiples sites de collage qui existent (certains ont une option pour qu'un robot annonce le collage sur un canal particulier), et reprendre notre script pour qu'il fonctionne comme un point d'entrée unique pour tous ces sites (avec un fichier ~/.pasterc, etc.).

Récupérer une « simple » page d'information

Je suis connecté en ADSL avec Télé2, qui signale sur son site web les problèmes réseau rencontrés. En général, quand je m'inquiète de l'état du réseau, c'est qu'il est déjà coupé pour moi, et je ne peux donc accéder à cette page d'information. L'idéal serait qu'un robot aille récupérer et sauvegarder périodiquement l'information contenue dans cette page.

État du réseau Télé2
État du réseau Télé2

Le portail http://www.tele2internet.fr/ a un lien « État du réseau » qui ouvre une popup donnant l'état du réseau. Le lien est le suivant : javascript:popup('http://editorial.tele2internet.fr/?page=ETAT_RESEAU&popup=1','helpPopupWindow',650,450,'scrollbars=1').

Donnons la partie utile de ce lien à notre robot :

    use WWW::Mechanize;
    my $m = WWW::Mechanize->new;
    $m->get('http://editorial.tele2internet.fr/?page=ETAT_RESEAU&popup=1');
    print $m->content;

En examinant le contenu du HTML qui nous est renvoyé, nous constatons que le serveur nous répond dédaigneusement :

    Ce site Web nécessite Netscape 4.x ou une version ultérieure.

De plus, si on regarde la ligne de statut de la réponse HTTP :

    $m->response->status_line;

nous voyons qu'il emploie les grands moyens :

    400 Bad Request

Qu'à cela ne tienne, faisons-nous passer pour Mozilla grâce à la commande suivante :

    $m->agent_alias("Linux Mozilla");

Cette fois-ci, le serveur nous rejette encore avec une réponse 400 Bad Request et cette phrase :

    Cette page nécessite Java script.

Nous allons continuer notre analyse avec un vrai navigateur. Dans une fenêtre normale, chargeons la page http://www.editorial.tele2internet.fr/?page=ETAT_RESEAU&popup=1 en ayant pris soin de supprimer les cookies existants (l'onglet Privacy dans la fenêtre de Préférences de Firefox permet de le faire simplement).

Notre proxy espion détecte trois chargements de pages :

  1.     GET http://www.editorial.tele2internet.fr/?page=ETAT_RESEAU&popup=1
            User-Agent: Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.7.8) Gecko/20050517 Firefox/1.0.4 (Debian package 1.0.4-2)
            Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5
            Accept-Language: en-us,en;q=0.5
        400 Bad Request
            Content-Type: text/html
            Set-Cookie: ApacheEE=fwAAAUK1S3nqeN78Me4h; path=/; domain=.tele2internet.fr
            Set-Cookie: ETRACK=249904881; expires=Fri, 16-Dec-05 10:39:53 GMT; path=/; domain=.tele2internet.fr

    On voit que le serveur répond aussi par un 400 à Firefox, puis lui donne deux cookies.

  2.     GET http://www.editorial.tele2internet.fr/?page=ETAT_RESEAU&popup=1&1119177593
            Cookie: ApacheEE=fwAAAUK1S3nqeN78Me4h; ETRACK=249904881; BrowserDetect=passed
            Referer: http://www.editorial.tele2internet.fr/?page=ETAT_RESEAU&popup=1
            User-Agent: Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.7.8) Gecko/20050517 Firefox/1.0.4 (Debian package 1.0.4-2)
            Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5
            Accept-Language: en-us,en;q=0.5
        200 OK
            Content-Type: text/html
            Set-Cookie: ETRACK=249904881; expires=Fri, 16-Dec-05 10:39:54 GMT; path=/; domain=.tele2internet.fr

    Le JavaScript inclus dans la page a probablement demandé un rechargement. Le numéro passé à la fin de la chaîne de requête (query string) correspond à la date au format Unix classique (nombre de secondes depuis janvier 1970).

  3.     GET http://www.editorial.tele2internet.fr/favicon.ico
            Cookie: ApacheEE=fwAAAUK1S3nqeN78Me4h; ETRACK=249904881; BrowserDetect=passed
            User-Agent: Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.7.8) Gecko/20050517 Firefox/1.0.4 (Debian package 1.0.4-2)
            Accept: image/png,*/*;q=0.5
            Accept-Language: en-us,en;q=0.5
        200 OK
            Content-Type: text/html
            Set-Cookie: ETRACK=249904881; expires=Fri, 16-Dec-05 10:39:55 GMT; path=/; domain=.tele2internet.fr

    On remarque qu'à partir de l'étape 2, le client transmet également un cookie BrowserDetect. Comme aucun des en-têtes Set-Cookie envoyés par le serveur ne correspond, c'est probablement le code JavaScript inclus dans la page qui a fait cet ajout.

    Effectivement, le code JavaScript de la deuxième page renvoyée contient la ligne :

        document.cookie = "BrowserDetect=passed;";

Essayons à nouveau en ajoutant simplement le cookie attendu :

    use WWW::Mechanize;
    my $m = WWW::Mechanize->new;
    $m->agent_alias("Linux Mozilla");
    $m->add_header( Cookie => 'BrowserDetect=passed' );
    $m->get('http://editorial.tele2internet.fr/?page=ETAT_RESEAU&popup=1');
    print $m->content;

Pour ce cas particulier, nous ajoutons le cookie avec la méthode add_header() de WWW::Mechanize. Attention cependant, cette méthode ajoute l'en-tête en question à chacune des requêtes qui sera effectuée par le robot (l'en-tête en question est stocké dans la liste des « en-têtes spéciaux » du robot). Si l'en-tête ne devait être ajouté que pour cette requête-ci, nous pourrions le supprimer ensuite avec delete_header().

En implantant nous-mêmes ce cookie, nous avons réussi à faire croire au serveur de Télé2 que le code JavaScript a reconnu le navigateur attendu. Et nous capturons ainsi la page d'information.

Il reste plus qu'à en extraire les informations utiles. Voici un extrait de la page en question :

    <!--début de la zone à copier-coller -->

    <span class="apptextBold"><font color="#CC0000">

    <!--début titre : bien modifier date et heure-->

     14/06/05, 16:30 : PROBLEMES DE CONNEXION POUR LES CLIENTS EN ACCES MODEM RTC 56K DU SUD ET DE L'OUEST DE LA FRANCE<!--fin titre -->

    </font></span>

    <span class="apptextPlain"><br>

    <!--début texte -->

    Actuellement nous rencontrons des problèmes de connexion sur les connexions modem RTC 56K affectant uniquement les clients du sud et de l'ouest de la France. Cela est du à un équipement isolé du réseau.<!--fin texte -->

    </span><BR><BR>

    <!--fin de la zone à copier-coller -->

Outre les commentaires destinés aux opérateurs mettant les informations à jour, on voit du HTML qui sert à structurer le texte dans des blocs <span> selon les classes apptextBold pour les titres et apptextPlain pour les explications associées. Nous allons utiliser HTML::TreeBuilder pour récupérer le texte en question.

    my $tree = 
      HTML::TreeBuilder->new_from_content( $m->content );
    print
      $_->as_trimmed_text,
      $_->attr('class') eq 'apptextBold' ? "\n" : "\n\n"
      for $tree->look_down( _tag => 'span', class => qr/apptext/ );

La première ligne crée un objet HTML::Tree à partir du contenu de la réponse téléchargée par notre objet WWW::Mechanize. La méthode look_down() renvoie la liste des branches de l'arbre HTML correspondant à la requête. Dans notre cas, nous voulons les nœuds ayant pour balise <span> et pour attribut class une valeur qui corresponde à l'expression régulière qr/apptext/.

On affiche ensuite le texte des éléments avec la méthode as_trimmed_text() de chacun des objets HTML::Element renvoyés. Celle-ci se comporte comme as_text(), en supprimant les blancs en début et fin de ligne. On saute une ligne après le titre, et deux lignes après le contenu.

La visualisation du résultat montre que les opérateurs de Télé2 ne savent pas fermer correctement leurs balises <span>. Nous ne pouvons donc même pas compter sur les styles pour faire un affichage correct !

Heureusement, le texte contient des puces (caractère \x95) avant chaque nouveau titre. Il est probable que les opérateurs n'oublieront pas celui-ci. Nous allons donc nous appuyer dessus pour faire notre découpage :

    my $tree = HTML::TreeBuilder->new_from_content( $m->content );
    print
      map { s/\n*\x95/\n\n*/g; $_ }
      map { $_->as_trimmed_text . "\n" }
      $tree->look_down( _tag => 'span', class => qr/apptext/ );

Cette fois-ci, nous obtenons le résultat souhaité :

    * 14/06/05, 16:30 : PROBLEMES DE CONNEXION POUR LES CLIENTS EN ACCES MODEM RTC 56K DU SUD ET DE L'OUEST DE LA FRANCE
    Actuellement nous rencontrons des problèmes de connexion sur les connexions modem RTC 56K affectant uniquement les clients du sud et de l'ouest de la France. Cela est du à un équipement isolé du réseau.


    * 09/06/05, 22:00 : FIN DU PROBLEME DE CONNEXION POUR LES CLIENTS DEGROUPES DANS LA REGION DE SOISY (95)

J'ai donc mis ce script dans ma crontab :

    # infos Telé2
    */10 * * * * ~/bin/tele2 > ~/tele2

À la coupure suivante de ma connexion, j'ai pu constater mon erreur : quand le réseau est indisponible, le script ne renvoie rien mais écrase quand même le fichier d'information... Autrement dit, je perds l'information en cas de coupure du réseau, exactement quand j'en ai besoin !

La première chose à faire, c'est de sortir du programme en cas d'erreur :

    # juste après le $m->get(...);
    exit unless $m->success;

La méthode success() renvoie une valeur booléenne indiquant si la réponse est un succès (code 2xx). Cette méthode encapsule un appel à $m->response->is_success().

Ce qui pose problème en réalité, c'est la redirection de la sortie standard par le shell, qui écrase le fichier destination même si le script ne produit aucune sortie. On ne peut pas se contenter d'ajouter en fin de fichier (append) avec le >> du shell, car on ajouterait l'intégralité de la page d'information à notre fichier à chaque lancement du script !

C'est donc à notre script lui-même de gérer la sauvegarde dans un fichier. J'ai choisi le comportement suivant : le premier paramètre sur la ligne de commande sera le nom du fichier dans lequel sauvegarder l'information, sinon la sortie se fait sur STDOUT comme précédemment.

La sélection de la sortie se fait facilement, en réouvrant STDOUT

    # sélection de la sortie
    if (@ARGV) {
        open STDOUT, ">", $ARGV[0]
          or warn "Impossible de rediriger STDOUT sur $ARGV[0]: $!\n";
    }

C'est-à-dire que nous faisons l'équivalent de > fichier, non pas au démarrage du script, mais quand nous avons effectivement des données à sauvegarder dans ce fichier. S'il est impossible d'ouvrir le fichier voulu, la sortie se fera sur la sortie standard habituelle, après affichage d'un avertissement.

La ligne dans la crontab sera désormais (notez la subtile différence) :

    # infos Telé2
    */10 * * * * ~/bin/tele2 ~/tele2

Après tous ces efforts, nous pouvons constater que le script lui-même est resté assez court :

    #!/usr/bin/perl
    use HTML::TreeBuilder;
    use WWW::Mechanize;

    my $m = WWW::Mechanize->new;

    # récupération des informations
    $m->agent_alias("Linux Mozilla");
    $m->add_header( Cookie => 'BrowserDetect=passed' );
    $m->get('http://editorial.tele2internet.fr/?page=ETAT_RESEAU&popup=1');
    exit unless $m->success;

    # sélection de la sortie
    if (@ARGV) {
        open STDOUT, ">", $ARGV[0]
          or warn "Impossible de rediriger STDOUT sur $ARGV[0]: $!\n";
    }

    # affichage des informations
    my $tree = HTML::TreeBuilder->new_from_content( $m->content );
    print
      map { s/\n*\x95/\n\n*/g; $_ }
      map { $_->as_trimmed_text . "\n" }
      $tree->look_down( _tag => 'span', class => qr/apptext/ );

En pratique, à chaque fois que j'ai été déconnecté depuis que je fais tourner ce script, je n'ai jamais trouvé d'explication dans les messages d'état du réseau ! Ça doit être le modem qui chauffe...

Surveiller une page web

J'utilise depuis peu le logiciel de téléphonie en ligne Skype. Si Skype est gratuit, certains services sont payants (comme SkypeOut, qui permet de téléphoner vers des téléphones classiques). En me connectant par hasard sur mon profil récemment, j'ai vu un message parlant de SkypeOut Gift Day. Il s'agit apparemment d'une offre promotionnelle permettant justement de gagner des « crédits » d'appel pour SkypeOut. Tous les détails sont sur la page http://www.skype.com/campaigns/freeskypedays/.

Il suffit de se connecter sur sa page personnelle Skype le bon jour, et de cliquer au bon endroit. J'aimerais bien gagner ces crédits, mais j'ai plus intéressant à faire que de me connecter sur ma page Skype tous les jours...

La page personnelle de Skype (en anglais) contient le message suivant : Today is not SkypeOut Gift Day, but perhaps it's coming up soon? (Sur la version française du site : « Aujourd'hui n'est pas une Journée cadeau SkypeOut... mais il y en aura une très bientôt ! »)

Je vais donc programmer un robot qui va vérifier une ou deux fois par jour la page en question, et me prévenir le jour où ce message n'apparaît plus. Ce jour-là, je ferai l'effort de me connecter moi-même et de voir ce qu'il y a à faire pour gagner ce crédit de temps.

Pour accéder à ma page personnelle, il me faut tout d'abord m'identifier. Un peu de recherche me dirige vers le lien https://secure.skype.com/store/member/login.html qui contient le formulaire de connexion. C'est notre premier formulaire d'identification depuis le début de cet article !

Le formulaire de login de Skype
Le formulaire de login de Skype

Appelons mech-dump à la rescousse :

    $ mech-dump https://secure.skype.com/store/member/login.html
    POST https://secure.skype.com/store/member/dologin.html
      username=                      (text)
      password=                      (password)
      login=Sign me in               (submit)

Voilà un formulaire simple, comme je les aime. :-)

En quelques lignes, nous pouvons produire un script de connexion et de recherche du message.

    # formulaire de login
    $m->get('https://secure.skype.com/store/member/login.html');
    die $m->res->status_line unless $m->success;

    # remplissage et validation
    $m->set_fields(
        username => 'monlogin', # entrez vos identifiants de connexion ici
        password => 'monpasse',
    );
    $m->click;
    die $m->res->status_line unless $m->success;

    # un cadeau ?
    print localtime() . " - Connecte-toi à ta page Skype !\n"
      if $m->content !~ /Today is not SkypeOut Gift Day/;

On ne devrait pas avoir de problèmes à cause de la langue, car la sélection de celle-ci se fait par un cookie language.

Lors de mes tests, j'ai pu constater qu'au moins une fois l'identification a échoué, et que la page contenait le message d'erreur suivant : New account creation is temporarily disabled. Please try again after a few minutes.

L'ajout de la ligne :

    # fait passer notre robot pour Mozilla, grâce à l'en-tête User-Agent:
    $m->agent_alias('Linux Mozilla');

a eu l'air de satisfaire le serveur Skype. Peu de temps après une nouvelle tentative de connexion sans cette ligne a réussi. Ce n'est donc pas l'en-tête User-Agent: qui est en cause. (Ceci dit, il peut être parfois utile de se faire passer pour un navigateur « standard » pour déjouer certaines tentatives de détection du navigateur.)

Mais alors, comment vérifier que l'identification est correcte ? On pourrait supposer que Skype utilise la réponse HTTP standard 403 Forbidden en cas de problème d'identification. Il est facile de vérifier que dans tous les cas (mot de passe valide ou non) la réponse est un magnifique 200 OK qui ne nous apporte donc aucune information (du moins en ce qui concerne l'identification : en cas de problème réseau, on aura une erreur 500 par exemple, qui sera détectée par la ligne utilisant $m->success()).

Nous pourrions analyser la page reçue en réponse pour trouver des informations permettant de distinguer le succès de l'échec de connexion. Il y a beaucoup plus simple. La ligne suivante permet d'afficher les cookies reçus par notre robot :

    print $m->cookie_jar->as_string;

Nous voyons les cookies au format interne de HTTP::Cookies (c'est également sous cette forme qu'ils sont sauvés par la méthode save()) :

    Set-Cookie3: loggedin=1; path="/"; domain=.skype.com; path_spec; expires="2005-07-27 15:02:40Z"; version=0
    Set-Cookie3: username=monlogin; path="/"; domain=.skype.com; path_spec; expires="2005-07-27 15:02:40Z"; version=0
    Set-Cookie3: skype_store2=70b6ea6caeff30ab42d7f60f05d20dfd; path="/"; domain=secure.skype.com; path_spec; discard; version=0

Le nom du cookie loggedin suffit à trahir son rôle (je me demande s'il n'y a pas un problème de sécurité, d'ailleurs). On vérifie facilement que les cookies username et loggedin ne sont pas envoyés par le serveur en cas d'erreur lors de l'identification.

Nous allons réaliser l'analyse des cookies reçus à l'aide de la méthode scan() de HTTP::Cookies (la class de libwww-perl qui gère les cookies). Cette méthode permet d'appliquer une fonction de rappel à tous les cookies stockés dans un objet cookie_jar (boîte à gâteaux, en anglais). La fonction de rappel attend les 10 paramètres suivants (dans l'ordre) : version, key, val, path, domain, port, path_spec, secure, expires, discard et hash. (Ce dernier paramètre, hash, existe à des fins d'extensiblité et permet de recevoir des paramètres non standards du cookie).

La vérification de la réussite de notre identification se fait de la façon suivante :

    # vérification que l'identification est réussie
    {
        my $ok = 0;
        $m->cookie_jar->scan( sub { $ok++ if $_[1] eq 'loggedin' } );
        die "Echec d'identification : mauvais Pseudo Skype ou mot de passe ?"
          unless $ok;
    }

En ajoutant ce bout de code juste avant la ligne print localtime() ..., notre script saura se connecter à Skype et vérifier que l'identification a réussi. Nous aurons donc deux messages différents selon que le site Skype a refusé notre connexion (comme dans le cas New account creation is temporarily disabled...) ou que la page personnelle a changé.

Après installation dans la crontab, il n'y a plus qu'à attendre d'être prévenu par un mail de Cron Daemon que mon cadeau Skype m'attend ! :-)

À moi les cadeaux !

Quatre jours après avoir écrit les lignes ci-dessus, j'ai reçu un email laconique :

    Sat Jul 30 15:25:12 2005 - Connecte-toi à ta page Skype !

Et voici ce qui m'attendait :

L'annonce du cadeau de Skype
L'annonce du cadeau de Skype

Au point où nous en sommes, nous allons essayer d'automatiser aussi cette partie-là, histoire de ne plus rien avoir à faire du tout ! Cette partie ne nous laisse pas le droit à l'erreur, car on ne peut valider le formulaire qu'une seule fois.

Nous pouvons commencer par demander à notre script d'afficher tous les formulaires de la page avec :

    print $_->dump for $m->forms;

La page ne contient qu'un seul formulaire :

    POST https://secure.skype.com/store/myaccount/givepromotion.html
      promotion=4                    (hidden readonly)
      <NONAME>=Get your free Skype Gift (submit)

C'est du tout cuit ! Notre script va donc regarder si la page contient un formulaire dont l'action est givepromotion.html. Si le formulaire existe, il va le valider et nous informer du résultat ; dans le cas contraire, nous serons quand même informés qu'il y a quelque chose à faire.

Le HTML de Skype est très propre et s'appuye énormément sur les CSS. En inspectant le HTML de la page contenant le formulaire, j'ai constaté que le formulaire était dans un bloc <div class="freeskypeday">. Avant de valider le formulaire, nous allons donc afficher le message d'information à l'aide de HTML::TreeBuilder :

    print map { $_->as_text }
      HTML::TreeBuilder
        ->new_from_content( $m->content )
        ->look_down( _tag => 'div', class => 'freeskypeday' );

(Comme l'objet HTML::Tree n'est utilisé qu'une fois pour afficher un message, on peut se passer de variable intermédiaire.)

Ensuite, si la page contient un formulaire qui correspond à celui du cadeau, on le valide :

    if (   $m->current_form
        && $m->current_form->action =~ /givepromotion\.html$/ )
    {
        $m->submit;
        die $m->res->status_line unless $m->success;
    }

Une petite remarque : ne jamais oublier d'ajouter une ligne print $m->content quand on récupère une page pendant le développement d'un script. Ça permet de conserver une copie de la page reçue pour analyse. C'est ainsi qu'après validation, j'ai constaté qu'un message d'information était affiché entre balises <h1>. J'ai donc ajouté a posteriori la commande print() adéquate pour voir ce message la prochaine fois.

Il reste un dernier point à régler : le message change une fois qu'on a reçu le cadeau. On lit désormais : Today is SkypeOut Gift Day, but it seems that you already received your gift (sur la version française : « Aujourd'hui est un jour cadeau pour SkypeOut, mais il semble que vous ayiez déjà reçu votre cadeau. »). Afin de ne pas être avertis inutilement une fois que le cadeau a été retiré, nous allons également ignorer ce message.

En collant tous ces morceaux bout à bout et en rajoutant de quoi passer le login et le mot de passe sur la ligne de commande, nous obtenons le script suivant :

    #!/usr/bin/perl
    use strict;
    use warnings;
    use WWW::Mechanize;
    use HTML::TreeBuilder;
    use Getopt::Long;
    no warnings 'utf8';

    my %CONF;
    GetOptions( \%CONF, "username=s", "password=s" ) or die << 'USAGE';
    Options disponibles :
      --username <username>
      --password <password>
    USAGE

    my $m = WWW::Mechanize->new;

    # formulaire de login
    $m->get('https://secure.skype.com/store/member/login.html');
    die $m->res->status_line unless $m->success;

    # remplissage et validation
    $m->set_fields(
        username => $CONF{username},
        password => $CONF{password},
    );
    $m->click;
    die $m->res->status_line unless $m->success;

    # vérification que l'identification est réussie
    {
        my $ok = 0;
        $m->cookie_jar->scan( sub { $ok++ if $_[1] eq 'loggedin' } );
        die "Echec d'identification : mauvais Pseudo Skype ou mot de passe ?\n"
          unless $ok;
    }

    # y a-t-il un cadeau pas encore récupéré ?
    if (   $m->content !~ /Today is not SkypeOut Gift Day/
        && $m->content !~ /you already received your gift/ )
    {

        # affiche le message
        print localtime() . " - Connecte-toi à ta page Skype !\n\n";
        print map { $_->as_text }
          HTML::TreeBuilder
          ->new_from_content( $m->content )
          ->look_down( _tag => 'div', class => 'freeskypeday' );

        # valide le formulaire
        if (   $m->current_form
            && $m->current_form->action =~ /givepromotion\.html$/ )
        {
            $m->submit;
            die $m->res->status_line unless $m->success;

            # affiche les détails
            print map { $_->as_text }
              HTML::TreeBuilder
              ->new_from_content( $m->content )
              ->look_down( _tag => 'h1' );
        }
    }

Tout ce travail pour un cadeau de 20 centimes d'euro !

Épilogue : Un peu plus d'une semaine après que j'ai récupéré mon cadeau, la page principale du compte Skype ne contient plus aucune information au sujet des cadeaux SkypeOut. Évidemment, mon script m'a prévenu que la page avait changé. J'ai ainsi pu constater qu'il était devenu probablement inutile... jusqu'à la prochaine campagne publicitaire de Skype.

Auteur

Philippe "BooK" Bruhat, book@mongueurs.net.

Merci aux Mongueurs de Perl et à Michel Grafmeyer pour leur relecture attentive.

Le code des trois scripts présentés dans cet article, ainsi que celui du proxy espion sont présents sur le CD. Ils sont également téléchargeables ici :

[IE7, par Dean Edwards] [Validation du HTML] [Validation du CSS]