[couverture de Linux Magazine 58]

LWP, Le Web en Perl (3)

Article publié dans Linux Magazine 58, février 2004.

Copyright © 2004 - Philippe Bruhat.

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

Chapeau de l'article

Cette dernière partie est presque tout entière consacrée à WWW::Mechanize. Comme vous allez le constater, ce module simplifie encore la création de scripts pour la navigation web.

Simplifier l'utilisation de LWP::UserAgent

Malgré son respect des RFC, sa modularité et son exhaustivité, LWP::UserAgent reste un module complexe, qui nécessite de connaître un minimum de choses sur le protocole HTTP (et parfois sur le fonctionnement interne de LWP). Dans un script d'automatisation, on ne cherche pas tant à utiliser HTTP, qu'à seulement récupérer une page web et éventuellement construire une nouvelle requête à partir d'élements de cette page.

Il existe plusieurs modules qui encapsulent les interfaces de LWP::UserAgent dans une interface simplifiée ou dédiée à l'automatisation.

LWP::Simple

Nous avons déjà vu LWP::Simple dans le premier article de cette série.

Il est souvent assez fastidieux de créer un script complet avec LWP. C'est un peu toujours la même chose. N'oubliez donc pas l'existence de LWP::Simple, qui fournit des interfaces très simples. Il vous évite en particulier de créer l'objet HTTP::Request et de décortiquer l'objet HTTP::Response.

En revanche, on est limité aux méthodes GET et HEAD. Et il n'y a pas de moyen de traiter complètement les erreurs. Cet inconvénient est contre-balancé par la rapidité de cette librairie pour certaines opérations.

Gisle Aas a en effet optimisé la méthode get() de LWP::Simple pour qu'elle soit aussi rapide que possible pour les cas simples. Du coup, il est plus performant d'utiliser celle-ci dans un script plutôt que LWP::UserAgent pour faire la même chose. C'est d'ailleurs ce que nous avons constaté à la fin du premier article de cette série, dans Linux Magazine 56.

En général, LWP::Simple permet de récupérer de l'information sur une page et la traiter directement ensuite.

WWW::Mechanize

WWW::Mechanize (surnommé Mech) est en fait une classe qui hérite de LWP::UserAgent. Ce module est activement développé par Andy 'petdance' Lester sur sourceforge. À l'origine, WWW::Mechanize est la reprise de WWW::Automate, de Kirrily 'Skud' Roberts, module s'inspirant lui-même de WWW::Chat, un système de macros écrit initialement par Gisle Aas et lui-même repris ensuite par Simon Wistow. WWW::Chat nécessite un préprocesseur (webchatpp), tandis que WWW::Automate (qui n'est plus maintenu) et WWW::Mechanize sont des modules qui offrent une interface orientée objet.

WWW::Mechanize, comme son nom l'indique, a pour but principal d'écrire des robots (ou automates) pour la navigation sur le web.

L'exemple perlmonks avec WWW::Mechanize

Vous pouvez vous reporter au premier article de cette série (Linux Mag 56) pour vous remettre en mémoire le script perlmonks utilisant LWP::UserAgent. Voici le même exemple, écrit cette fois avec WWW::Mechanize :

    #!/usr/bin/perl -w
    use strict;
    use WWW::Mechanize;
    use HTTP::Cookies;
    use Getopt::Std;

    my $base = 'http://www.perlmonks.org/';
    my %conf = ( f => "$ENV{HOME}/.perlmonksrc" );

    # -l login:pass
    # -f config_file
    getopts( 'l:f:', \%conf ) or die "Bad parameters";

    # initialisation de l'agent
    my $bot = WWW::Mechanize->new(
        agent      => 'Mozilla/4.73 [en] (X11; I; Linux 2.2.16 i686; Nav)',
        cookie_jar => HTTP::Cookies->new(
            file           => $conf{f},
            autosave       => 1,
            ignore_discard => 1,        # le cookie devrait être effacé à la fin
        )
    );

    # nouvel utilisateur ou pas ?
    if ( $conf{l} ) {
        my ( $user, $pass ) = split ':', $conf{l}, 2;

        # exécute la requête et reçoit la réponse
        $bot->get("${base}index.pl?node=login");
        die $bot->res->status_line if not $bot->success;

        # sélectionne le formulaire de login (second formulaire de la page)
        $bot->form_number(2);

        # remplit les champs
        $bot->set_fields( user => $user, passwd => $pass );

        # valide le formulaire
        $bot->submit;
    }

    # sinon on établit la connexion (avec le cookie s'il existe)
    else {
        $bot->get("${base}index.pl?node=login");
    }

Les différences sont assez minimes : comme WWW::Mechanize dérive de LWP::UserAgent, on l'initialise de la même façon, en lui passant un objet HTTP::Cookies (cookie jar) si nécessaire.

L'intérêt principal de WWW::Mechanize réside dans sa particularité : un agent WWW::Mechanize conserve et prépare tout un contexte autour des URI et formulaires visités. Ces informations, couplées avec des méthodes d'accès simplifiées, prennent tout leur intérêt quand on veut réaliser une session complète constituée d'un ensemble de requêtes web liées entre elles : tout d'abord il n'y a pas à préparer les requêtes (il suffit d'indiquer l'URI de départ) et ensuite il suffit de sélectionner les formulaires, mettre à jour les champs et valider. On répète ainsi l'opération autant de fois qu'il est nécessaire.

WWW::Mechanize

Le project WWW::Mechanize est hébergé sur SourceForge (http://sourceforge.net/projects/www-mechanize/) et deux listes de diffusion existent : www-mechanize-cvs, qui envoie un courriel automatique pour chaque commit dans le CVS et www-mechanize-development où les développeurs du module font part de leurs projets et discutent des évolutions possibles du module. Le trafic est peu volumineux. Pour vous abonner, consultez la page du projet sur SourceForge.

WWW::Mechanize est activement développé : 9 nouvelles versions sont sorties ces trois derniers mois. La version de WWW::Mechanize utilisée pour cet article est la 0.70, qui requiert également la toute dernière version de LWP, la 5.76.

L'API de WWW::Mechanize

Comme pour tout module orienté objet, on commence par créer une instance de la classe, c'est-à-dire un objet WWW::Mechanize.

    my $bot = WWW::Mechanize->new();

Puisque WWW::Mechanize dérive de LWP::UserAgent, il accepte les arguments du constructeur de LWP::UserAgent, en plus de ses propres paramètres d'initialisation (qui concernent principalement les avertissements et erreurs.

Notez que WWW::Mechanize utilise toujours les proxies définis par les variables d'environnement. Par défaut, il stocke également les cookies en mémoire. Si vous ne voulez pas que votre robot accepte les cookies, vous pouvez l'initialiser comme suit :

    WWW::Mechanize->new( cookie_jar => undef );

Si au contraire vous voulez qu'il stocke les cookies dans un fichier, il vous faudra alors créer votre propre objet HTTP::Cookies et lui passer, comme nous l'avons fait dans l'exemple précédent sur Perlmonks.

Une fois votre robot créé, vous pouvez commencer à surfer :

    my $response = $bot->get( 'http://www.example.com/' );

La méthode get() retourne un objet HTTP::Response. Vous n'aurez en fait que rarement besoin de l'utiliser, puisqu'il est également stocké par WWW::Mechanize. get() reproduit le fonctionnement de la méthode get() de LWP::UserAgent et accepte les mêmes paramètres.

Comme je n'ai pas parlé de la méthode get() de LWP::UserAgent dans les articles précédents, je vais rapidement vous la présenter. Le premier paramètre est une URI. Les suivants permettent de mettre à jour simplement les en-têtes de la requête, passés comme d'habitude comme des couples clé/valeur :

    $ua->get( "http://www.example.com", Referer => "http://www.mongueurs.net/");

Certains paramètres spéciaux, précédés par des : ne sont pas des en-têtes, mais des paramètres pour la méthode request() de LWP::UserAgent. Nous n'avons pas non plus vu ces paramètres dans les articles précédents. Pour résumer, la méthode request() de LWP::UserAgent accepte comme paramètres un nom de fichier (pour sauvegarder le corps de la réponse) ou une référence à une routine (callback) qui sera appelée au fur et à mesure de la réception de la réponse. Le paramètre read_size_hint sert à donner à LWP::UserAgent un indice concernant la taille des morceaux de réponse attendus (ce paramètre n'est qu'indicatif).

En bref, ces paramètres sont :

    :content_file   => $filename
    :content_cb     => \&callback
    :read_size_hint => $bytes

Mais revenons à WWW::Mechanize. get() est en général la première vraie commande d'un script d'automatisation. Ensuite, la session de navigation consiste principalement à suivre des liens et remplir des formulaires.

La méthode follow_link() permet suivre un lien. Elle accepte plusieurs types de critères pour séléctionner le lien à suivre :

Si plusieurs critères sont fournis, seuls les liens qui correspondent à tous les critères seront sélectionnés. n permettra de garder le nième parmi ceux-ci.

La plupart des pages web contiennent plusieurs formulaires. Avant de commencer à remplir le formulaire choisi, il faut donc le sélectionner. Ceci se fait à l'aide des méthodes form_number() et form_name() pour sélectionner un formulaire en fonction de son numéro d'ordre (indexé à partir de 1) ou de son nom.

Pour le remplissage du formulaire, la méthode field() est utilisée :

    $bot->form_number(2);
    $bot->field( 'user', 'BooK' );
    # ou en utilisant les propriétés de =>
    $bot->field( passwd => 's3kr3t' );

Si plusieurs champs portent le même nom (par exemple un champ de type option), un troisième champ est utilisé pour donner le numéro d'ordre de ce champ (à partir de 1).

La méthode set_fields() permet de remplir plusieurs champs d'un coup :

    $bot->form_number(2);
    $bot->set_fields( user => 'BooK', passwd => 's3kr3t' );

Si plusieurs champs du même nom existent, il faudra alors passer un tableau anonyme contenant la valeur et le numéro d'ordre du champ. Pour notre exemple, nous utilisont le formulaire du premier article de cette série :

    $bot->set_fields( FRUITS => [ 'poire', 2 ],
                      FRUITS => [ 'banane', 3 ] );

Enfin, la méthode tick() et untick() permettent de traiter le cas des cases à cocher :

    $bot->tick( 'RGB', 'rouge' );
    $bot->untick( RGB => 'vert' );

L'envoi du formulaire se fait avec les méthodes click() ou submit(). click() est appelée avec le nom du bouton à cliquer, tandis que submit() valide le formulaire sans cliquer sur aucun bouton. La méthode submit_form() permet de combiner la sélection, le remplissage et l'envoi du formulaire en une seule méthode (consultez la documentation pour plus de détails).

Enfin, il existe un certain nombre de méthodes pour obtenir des informations à partir de la réponse à la requête HTTP : response(), content(), links(), etc. Là encore, la documentation vous en donnera la liste complète. Sachez enfin que comme WWW::Mechanize dérive de LWP::UserAgent, toutes les méthodes de LWP::UserAgent sont également disponibles.

Autres utilisations

Andy Lester est un grand fan des tests. C'est pourquoi il utilise Test::More et WWW::Mechanize pour tester le comportement de ses sites web.

    use Test::More;
    like( $bot->content(), qr/$expected/, "" );

Un script de test élaboré va rejouer complètement une session utilisateur, en vérifiant à chaque étape le contenu de la page renvoyée.

Un exemple pas à pas : le formulaire de Mailman

Puisque nous parlons d'exemples utiles, je vous propose de réaliser comme le mois dernier un script d'automatisation utilisant WWW::Mechanize en suivant pas à pas les étapes de sa création.

Mailman (http://www.list.org/) est un outil de gestion de listes de diffusion dont l'interface d'administration est accessible par le web. En particulier, les courriels envoyés depuis des adresses non inscrites à la liste sont bloqués dans une file d'attente afin d'être traités manuellement par un administrateur. Il y a donc un formulaire web qui propose les choix suivants : Defer (attendre), Approve (accepter), Reject (rejeter) et Discard (détruire).

Je suis co-administrateur d'une liste gérée par Mailman ; la fois où j'ai vu le formulaire m'annoncer 39 mails en attente, j'ai craqué. Rien que de penser que j'allais devoir tirer sur l'ascenseur, cliquer sur Discard, passer au suivant, recommencer, je n'ai plus eu qu'une idée en tête, automatiser tout. Ne plus jamais utiliser Mozilla pour administrer cette liste.

Point de départ

Le site d'administration de Mailman se trouve en général au bout d'une URI de la forme http://www.example.com/mailmain/admin. De là vous pouvez choisir la liste à administrer. Ensuite, dans Other Administrative Activities / Tend to pending moderator requests, vous trouverez l'URI qui donne accès au formulaire de gestion des courriels en attente.

Ce programme a été écrit avec Mailman 2.0.13, mais avec une petite modification dans l'URL de départ, j'ai pu facilement le rendre compatible avec la version 2.1.3, qui a été installée récemment sur le serveur de ma liste de distribution.

Pour la version 2.0.13, la page donnant les messages bloqués était http://www.example.com/mailman/admindb/maliste. Pour obtenir le même formulaire avec la version 2.1.3, il faut demander la page http://www.example.com/mailman/admindb/maliste?details=all. Selon la version de Mailman utilisée, il faudra donc sélectionner la bonne URL de départ.

Authentification

L'accès à la page d'admin commence par un formulaire d'authentification. Nous allons donc charger la page, trouver le formulaire, le remplir et le valider.

    #!/usr/bin/perl -w
    use strict;
    use WWW::Mechanize;
    use Data::Dumper;

    my $url = 'http://www.example.com/mailman/admindb/maliste?details=all';
    my $pwd = 's3kr3t';

    my $bot = WWW::Mechanize->new;
    $bot->get($url);
    $bot->form_number(1);
    print Dumper $bot->current_form;

    # print Dumper +( $bot->forms )[0];    # autre formulation

WWW::Mechanize fournit des interfaces de haut niveau. Plus besoin de créer la requête à partir d'une URL, un simple get() suffit.

La méthode form_number() permet de sélectionner le formulaire qui sera le formulaire courant, c'est-à-dire celui sur lequel agiront les méthodes d'accès aux champs. forms() quant à elle, renvoie la liste des formulaires sur la page. Attention, les formulaires sont indexés à partir de 1.

Nous affichons ainsi le premier (et seul) formulaire de la page :

    $VAR1 = bless( {
                     'inputs' => [
                                   bless( {
                                            'size' => '30',
                                            'type' => 'password',
                                            'name' => 'adminpw'
                                          }, 'HTML::Form::TextInput' ),
                                   bless( {
                                            'value' => 'Let me in...',
                                            'type' => 'submit',
                                            'name' => 'request_login'
                                          }, 'HTML::Form::SubmitInput' )
                                 ],
                     'enctype' => 'application/x-www-form-urlencoded',
                     'method' => 'POST',
                     'attr' => {
                                 'method' => 'POST'
                               },
                     'action' => bless( do{\(my $o = 'http://www.example.com/mailman/admindb/maliste?details=all')}, 'URI::http' )
                   }, 'HTML::Form' );

Pour les formulaires très gros, il est plus simple d'utiliser mech-dump (voir plus bas la section Outils), qui rend un affichage plus lisible :

    $ mech-dump --forms http://www.example.com/mailman/admindb/maliste?details=all
    POST http://www.example.com/mailman/admindb/maliste
      adminpw=                        (password) 
      request_login=Let me in...      (submit)

Le champ à remplir avec le mot de passe s'appelle donc adminpw.

Note : dans les exemples de code qui suivent, je n'indiquerai plus la liste des modules utilisés. C'est seulement quand un nouveau module sera utilisé que je le préciserai.

    my $pwd = 's3cr3t';

    # page de login principale
    $bot->get( $url );
    $bot->form_number(1);
    $bot->field( adminpw => $pwd );
    $bot->click();

Pour récupérer une référence à un formulaire, il faut utiliser soit la méthode current_form() (qui renvoie le formulaire actif), soit aller le chercher directement dans le résultat de forms() (qui renvoie la liste de tous les formulaires de la page).

La méthode click() valide le formulaire. Elle prend en paramètre le nom du bouton sur lequel il faut "cliquer". S'il n'y a qu'un seul bouton dans le formulaire, il n'est pas nécessaire de donner son nom. Nous aurions également pu utiliser submit().

Une fois qu'on s'est authentifié, on reçoit une page avec un formulaire contenant une description de tous les messages à modérer. Pour se faire une première idée de ces données, on utilise le code suivant :

    # la page de spam
    $bot->form_number(1);
    print Dumper $bot->current_form;

L'authentification par cookies

Comme WWW::Mechanize descend de LWP::UserAgent, il peut comme lui gérer les cookies. Nous ouvrirons donc à notre robot la boîte à cookies :

    use HTTP::Cookies;

    $bot->cookie_jar(
        HTTP::Cookies->new(
            file           => "$ENV{HOME}/.mailmanrc",
            autosave       => 1,
            ignore_discard => 1,
        )
    );

Les cookies seront donc sauvegardés automatiquement dans le fichier .mailmanrc dans notre répertoire personnel. Comme dans le cas précédent, le cookie est un cookie de session, qui doit être effacé à la fin de l'exécution du programme.

Relançons encore une fois le script et voyons le résultat dans ~/.mailmanrc :

    #LWP-Cookies-1.0
    Set-Cookie3: maliste:admin=2832333333693203d90e702833333308020165050609070903660100036665096105000907656464010060050562626107656208000903; path="/mailman/"; domain=www.example.com; path_spec; discard; version=1

Ça marche, nous avons capturé le cookie renvoyé par Mailman.

Nous avons un petit problème, maintenant : puisque nous lisons le cookie dans notre fichier, la première page qui nous sera renvoyée est directement celle contenant le formulaire avec tous les messages à valider. L'étape de login devient inutile, mais reste nécessaire lors du premier lancement du programme.

Ici encore, la solution est simple : si le premier formulaire reçu est le formulaire de login, on le remplit et on valide, sinon on passe directement à la suite. Voici comment coder cela :

    # charge le premier formulaire
    $bot->get($url);
    $bot->form_number(1);

    # ne se logge que si nécessaire
    if ( $bot->current_form->find_input('adminpw') ) {
        warn "Rechargement du cookie\n";
        $bot->field( adminpw => $pwd );
        $bot->click();
        die "Pas de message en attente\n" unless scalar @{$bot->forms};
        $bot->form_number(1);
    }

La méthode find_input() de HTML::Form permet de retrouver un champ du formulaire par son nom, son type ou son numéro d'ordre. Ici nous cherchons simplement s'il y a un champ nommé adminpw. Si oui, c'est la page de login, si non, nous sommes déjà arrivés sur la page qui nous intéresse depuis le début.

Suppression des spams

Une fois chargée la page contenant le formulaire d'administration, il ne nous reste plus qu'à trouver les bonnes cases à cocher et valider le formulaire.

Tout d'abord, voyons un peu le contenu du formulaire :

    print $_->name for $bot->current_form->inputs;

Ceci nous affiche les noms des différents champs :

    submit
    2085
    preserve-2085
    forward-2085
    forward-addr-2085
    comment-2085
    headers-2085
    fulltext-2085
    2095
    preserve-2095
    forward-2095
    forward-addr-2095
    comment-2095
    headers-2095
    fulltext-2095
    submit

On retrouve donc les deux boutons Submit et un certain nombre de champs par message. Avec Data::Dumper, nous pouvons observer rapidement chaque objet HTML::Form::Input.

Par exemple, le champ 2085 correspond à l'objet suivant :

    $VAR1 = bless( {  
                     'seen' => [
                                 1,
                                 0,
                                 0,
                                 0
                               ],
                     'menu' => [
                                 '0',
                                 '1',
                                 '2',
                                 '3'
                               ],
                     'current' => 0,
                     'type' => 'radio',
                     'name' => '2085'
                   }, 'HTML::Form::ListInput' )
                 );

En regardant le source HTML de la page (qu'on peut obtenir par la méthode $bot->content), on découvre rapidement notre formulaire :

          <table CELLPADDING="0" CELLSPACING="5">
            <tr>
              <td>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Defer&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</td>
              <td>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Approve&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</td>
              <td>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Reject&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</td>
              <td>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Discard&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</td>
            </tr>
            <tr>
              <td><center><INPUT name="2085" type="RADIO" value="0" CHECKED ></center></td>
              <td><center><INPUT name="2085" type="RADIO" value="1" ></center></td>
              <td><center><INPUT name="2085" type="RADIO" value="2" ></center></td>
              <td><center><INPUT name="2085" type="RADIO" value="3" ></center></td>
            </tr>
          </table>

Et on peut ainsi faire la correspondance :

    Defer   => 0
    Approve => 1
    Reject  => 2
    Discard => 3

Le code de suppression des messages est alors rapide à écrire.

    # traitement des différents champs du formulaire
    my $msg = 0;
    for ( $bot->current_form->inputs ) {

        # affiche certains en-têtes
        if ( $_->name =~ /^headers-(\d+)$/ ) {
            print "$1 ", join "$1 ",
              $_->value =~ m!^((?:From|Subject):.*?\n)!img;
            print $/;
            next;
        }

        # coche l'effacement des messages
        $_->value(3), $msg++, next if $_->name =~ /^\d+$/;
    }

    # valide le formulaire
    $bot->click;

Le script affiche de plus le sujet et l'auteur du message :

    7220 From: "Celia Langley" <7azynsvcrj@concentric.com>
    7220 Subject: Fwd: Best Source V|@gra, Valï(u)m, X(a)n@x Diet Pills Any Meds bfdrsubre ti
    
    7221 From: "PosMortgage" <quotes@arthriaway.com>
    7221 Subject: One simple inquiry, multiple great quotes
    
    7222 From: "Judson Culver" <ofzhywirrgldjj@lycos.com>
    7222 Subject: plutonium classificatory buildup
    
    7223 From: "Avis Rosado" <ferreroacquistapace3_342@china139.com>
    7223 Subject: The penís pill is amazing you have to try it

On recharge la page à la main, pour constater que ça a marché ! Plus de messages en attente... Oups. Et s'il y avait des messages légitimes parmi ceux que nous venons d'effacer ?

Ce qui reste à faire

Évidemment, tous les mails envoyés sur ma liste et bloqués pour cause de Post by non-member to a members-only list ne sont pas des spams. Il n'est donc pas envisageable de supprimer tous les messages d'un coup.

Pour s'assurer que la liste des messages ne contiendrait pas un message légitime, on peut remplacer la dernière ligne du script par les suivantes :

    if (@ARGV) {
        $bot->click;
        warn "Removed $msg messages\n";
    }
    else { warn "$msg messages on hold\n"; }

Ainsi, appeler le script sans paramètre se contenterait d'afficher le sujet et l'expéditeur du message, tandis qu'avec un paramètre (quelconque), tous les messages seraient supprimés. On peut alors vérifier rapidement qu'il n'y a pas de message légitime dans la liste avant de tout balancer aux ordures.

On pourrait égalament faire un affichage des messages bloqués les un après les autres, pour sélectionner l'action à engager, le choix par défaut étant Discard.

De plus, si Mailman est configuré pour demander l'approbation d'un modérateur pour chaque demande d'inscription, de nouveau champs vont apparaître dans notre formulaire (celui-ci s'appelle après tout Administrative requests et pas Blocked emails...).

Bref, il y a encore de quoi s'occuper avec Mailman et WWW::Mechanize !

Bien que je n'ai pas eu le temps (ou le courage) de faire un script complet pour gérer l'interface fastidieuse de Mailman (écrit en Python) avec Perl, nous avons tout de même pu voir les avantages à utiliser WWW::Mechanize :

Le temps le plus long est pris par les délais de transfert : le formulaire validé est quasiment identique au formulaire reçu et celui-ci contient plusieurs kilo-octets de données par message.

Outils d'aide au développement de scripts web

Pour terminer cette série d'articles, voici plusieurs modules, scripts ou programmes qui peuvent vous aider lors de la création d'un script de navigation automatisée.

lwp-request

Nous avons déjà rencontré lwp-request dans le précédent article. Ce programme vous permettra de récupérer rapidement le contenu d'une page à traiter.

Il est également utile à mon avis quand il est utilisé avec les options -des, ce qui permet de se faire une idée rapide des en-têtes renvoyés par le serveur.

La méthode TRACE peut également être utile pour détecter un problème sur le chemin entre vous et le serveur.

wget

wget, comme lwp-request va vous permettre de récupérer dans un fichier le contenu d'une page web donnée. C'est un programme GNU avec de très nombreuses options. Il peut chercher une simple page ou peut faire la copie récursive de tout ou partie d'un site si les liens ne sont pas cachés dans du javascript. Notons aussi l'option -S qui permet d'afficher les en-têtes de la réponse du serveur.

Utilisation pour télécharger une seule page :

    $ wget http://www.perlmonks.org/
    --22:44:13--  http://www.perlmonks.org/
               => `index.html'
    Résolution de www.perlmonks.org... 209.197.123.153, 66.39.54.27
    Connexion vers www.perlmonks.org[209.197.123.153]:80...connecté.
    requête HTTP transmise, en attente de la réponse...200 OK
    Longueur: non spécifié [text/html]
    
    [        <=>                          ] 63,219        33.45K/s             
    
    22:44:19 (33.33 KB/s) - « index.html » sauvegardé [63219]

mech-dump

mech-dump est un script fourni avec WWW::Mechanize qui permet d'obtenir rapidement les informations utiles concernant une page web. Il fournit la liste des formulaires, liens et images d'une page en une seule commande :

    $ mech-dump --forms http://www.google.fr/
    GET http://www.google.fr/search
      q=
      ie=ISO-8859-1                   (hidden)  
      hl=fr                           (hidden)  
      btnG=Recherche Google           (submit)  
      btnI=J'ai de la chance          (submit)  
      meta=                           (radio)   [*|lr=lang_fr|cr=countryFR]

L'option --forms affiche la liste des formulaires, --links affiche la liste des liens (absolus avec l'option --absolute) et --images la liste des images. --all vous donnera évidemment tout ce qui précède.

HTTP::Proxy

HTTP::Proxy est un proxy web écrit en Perl pur. Il permet d'appliquer des filtres sur les données qui le traversent. Ces filtres permettent de lire et de modifier les en-têtes et le corps des requêtes et des réponses HTTP.

HTTP::Proxy peut donc vous être utile pour voir les requêtes envoyées par votre navigateur. Cependant, bien qu'il ait été écrit spécifiquement avec pour objectif de générer automatiquement des scripts de connexion au web à partir d'une session de surf, je ne pense pas qu'HTTP::Proxy puisse en l'état (version 0.12) servir seul à construire un tel script.

Si vous l'utilisez, ce sera à vous d'écrire les filtres pour extraire les informations qui vous intéressent. Voici un exemple simple pour obtenir les informations d'une requête POST :

    #!/usr/bin/perl -w
    use strict;
    use HTTP::Proxy qw( :log );
    use HTTP::Proxy::BodyFilter::simple;
    use CGI::Util qw( unescape );

    # création du proxy et du filtre
    my $proxy  = HTTP::Proxy->new;
    my $filter = HTTP::Proxy::BodyFilter::simple->new(
        sub {
            my ( $self, $dataref, $message, $protocol, $buffer ) = @_;
            print STDOUT $message->method, " ", $message->uri, "\n";

            # ce code provient de CGI.pm, méthode parse_params()
            my (@pairs) = split ( /[&;]/, $$dataref );
            for (@pairs) {
                my ( $param, $value ) = split ( '=', $_, 2 );
                $param = unescape($param);
                $value = unescape($value);
                printf STDOUT "    %-30s => %s\n", $param, $value;
            }
        }
    );

    # le filtre ne s'applique qu'aux requêtes POST
    $proxy->push_filter( method => 'POST', request => $filter );

    # démarrage du proxy
    $proxy->start;

Il ne reste plus qu'à indiquer à votre client d'utiliser comme proxy HTTP localhost, port 8080 (le port par défaut de HTTP::Proxy).

Vous verrez alors le contenu des requêtes POST :

    POST http://www.pagesjaunes.fr/pb.cgi
        faire                          => decode_input_image
        DEFAULT_ACTION                 => bf_inscriptions_req
        SESSION_ID                     => GB-78B5F09-2B186
        VID                            => GB-78B5F09-2B186
        lang                           => FR
        pays                           => FR
        srv                            => PB
        TYPE_RECHERCHE                 => ZZZ
        input_image                    => 
        FRM_NOM                        => martin
        FRM_PRENOM                     => jacques
        FRM_ADRESSE                    => 
        FRM_LOCALITE                   => paris
        FRM_DEPARTEMENT                => 
        BF_INSCRIPTIONS_REQ.x          => 60
        BF_INSCRIPTIONS_REQ.y          => 16

Voici un exemple plus complet, qui permet de voir l'ensemble des requêtes GET et POST, les cookies envoyés par le server (Set-Cookie) et le client (Cookie) et les paramètres des requêtes POST. Il nécessite au moins la version 0.12 de HTTP::Proxy.

    #!/usr/bin/perl -w
    use strict;
    use HTTP::Proxy qw( :log );
    use HTTP::Proxy::HeaderFilter::simple;
    use HTTP::Proxy::BodyFilter::simple;
    use CGI::Util qw( unescape );

    my $post_filter = HTTP::Proxy::BodyFilter::simple->new(
        sub {
            my ( $self, $dataref, $message, $protocol, $buffer ) = @_;
            print STDOUT $message->method, " ", $message->uri, "\n";
            print_headers( $message, qw( Cookie Cookie2 ));

            # this is from CGI.pm, method parse_params
            my (@pairs) = split ( /[&;]/, $$dataref );
            for (@pairs) {
                my ( $param, $value ) = split ( '=', $_, 2 );
                $param = unescape($param);
                $value = unescape($value);
                printf STDOUT "    %-30s => %s\n", $param, $value;
            }
        }
    );

    my $get_filter = HTTP::Proxy::HeaderFilter::simple->new(
        sub {
            my ( $self, $headers, $message ) = @_;
            my $req = $message->request;
            if( $req->method ne 'POST' ) {
                print STDOUT $req->method, " ", $req->uri, "\n";
                print_headers( $req, qw( Cookie Cookie2 ));
            }
            print_headers( $message, qw( Content-Type Set-Cookie Set-Cookie2 ));
        }
    );

    sub print_headers {
        my $message = shift;
        for my $h (@_) {
            if( $message->header($h) ) {
                print STDOUT "    $h: $_\n" for ( $message->header($h) );
            }
        }
    }

    my $proxy = HTTP::Proxy->new;
    $proxy->logmask( shift || NONE );
    $proxy->push_filter( method => 'POST', request => $post_filter );
    $proxy->push_filter( response => $get_filter );
    $proxy->start;

Ce code (disponible dans le fichier eg/logger.pl de la distribution HTTP::Proxy) donnera des résultats plus complets que le précedent pour une session :

    GET http://www.pagesjaunes.fr/
        Content-Type: text/html
        Set-Cookie: VisitorID=GB-78C7D2C-36ECC; expires=Monday, 05-Jan-2014 17:53:16 GMT; domain=.pagesjaunes.fr; path=/
    GET http://ads.wanadooregie.com/html.ng/sn=pagesjaunes.vig&pn=accueil_popup.pj&sz=1x1&ord=9695738522?
        Cookie: NGUserID=a010828-24721-1073324900-6
        Content-Type: text/html
    POST http://www.pagesjaunes.fr/pj.cgi
        Cookie: VisitorID=GB-78C7D2C-36ECC; e=P-mkj6wWAWgAAG6Jg8A
        faire                          => decode_input_image
        DEFAULT_ACTION                 => jf_inscriptions_req
        SESSION_ID                     => GB-78C7D2C-36ECC
        VID                            => GB-78C7D2C-36ECC
        lang                           => FR
        pays                           => FR
        srv                            => PJ
        TYPE_RECHERCHE                 => ZZZ
        input_image                    =>
        FRM_ACTIVITE                   => plombier
        FRM_NOM                        =>
        FRM_ADRESSE                    =>
        FRM_LOCALITE                   => paris
        FRM_DEPARTEMENT                =>
        JF_INSCRIPTIONS_REQ.x          => 0
        JF_INSCRIPTIONS_REQ.y          => 0
        Content-Type: text/html

Il manque probablement quelques champs, mais ce n'est pas mal pour commencer. Le code des filtres reste cependant un peu compliqué à écrire.

HTTP::Recorder

HTTP::Recorder est un module très récent, écrit par Linda Julien. Il s'utilise avec HTTP::Proxy en remplaçant l'agent par défaut par un objet HTTP::Recorder, qui enregistre dans un fichier une suite de commandes pour WWW::Mechanize construite à partir des requêtes que l'agent a dû réaliser.

Le module en est pour le moment à l'état de « preuve de concept » (version 0.01 sur CPAN), mais donne déjà des résultats intéressants. Voici par exemple le fruit d'une recherche sur les pages blanches effectué avec le script donné en exemple dans la documentation :

    $agent->get("http://www.pageblanches.fr/");
    $agent->get("http://www.pagesjaunes.fr/pb.cgi?");
    $agent->field("srv", "PB");
    $agent->field("BF_INSCRIPTIONS_REQ.y", "6");
    $agent->field("VID", "FA-70033A4-33F4C");
    $agent->field("FRM_LOCALITE", "paris");
    $agent->field("FRM_NOM", "martin");
    $agent->field("DEFAULT_ACTION", "bf_inscriptions_req");
    $agent->field("faire", "decode_input_image");
    $agent->field("FRM_PRENOM", "jacques");
    $agent->field("SESSION_ID", "FB-70033C8-3B40A");
    $agent->field("lang", "FR");
    $agent->field("TYPE_RECHERCHE", "ZZZ");
    $agent->field("pays", "FR");
    $agent->field("BF_INSCRIPTIONS_REQ.x", "50");
    $agent->submit_form(form_number => "1");

Il ne reste plus qu'à supprimer les champs qui ont gardé leurs valeurs par défaut et surtout le plus difficile : extraire l'information utile de la page retournée par la dernière commande. Comme vous pourrez le constater, c'est souvent la partie la plus difficile, surtout quand les pages sont truffées de JavaScript.

Notez que si vous voulez réaliser automatiquement des recherches sur les services Pages Jaunes et Pages Blanches, ce n'est pas la peine de vous fatiguer à analyser le site web de France Télécom. En effet, il existe déjà un script Perl qui fait cela : le script pagesjaunes fourni avec le module WWW::Search::Pagesjaunes de Briac Pilpré (de Paris.pm) peut répondre à toutes vos questions plus rapidement que Mozilla :

    $ pagesjaunes -n martin -p jacques -t paris
    Martin Jacques - 5 r Banquier 75013 PARIS - 01 43 36 07 12
    Martin Jacques - 159 r Charenton 75012 PARIS - 01 43 44 25 59
    Martin Jacques - 9 r Comète 75007 PARIS - 01 45 55 55 68
    Martin Jacques - 30 bd Exelmans 75016 PARIS - 01 42 24 43 07
    ...

Pour accéder aux Pages Jaunes, il suffit de préciser l'activité :

    $ pagesjaunes -t paris -act plombier
    Bourdeaux Alain (EURL) - 11 r Emile Dubois 75014 Paris - .01 45 65 09 40
    Bourdeaux Alain (EURL) - 11 r Emile Dubois 75014 Paris - .01 45 65 09 40 fax : .  01 45 81 53 52
    Boutillon Stéphane - 26 r George Sand 75016 Paris - .01 42 24 90 40   
    Bruel et Terrage - 6 r Jeanne d'Arc 75013 PARIS - .01 45 83 99 01
    ...

pagesjaunes est un excellent exemple de ce qu'on peut faire sur le web sans son navigateur, à l'aide de LWP::UserAgent ou de WWW::Mechanize.

WWW::Mechanize::Shell

Ce module, que j'ai découvert lors de la rédaction de cet article, semble très prometteur. Écrit par Max "Corion" Maischein, il fournit un genre de shell au dessus de WWW::Mechanize, pour vous permettre de surfer de façon interactive, en ligne de commande !

Reprenons encore une fois notre exemple avec perlmonks. Dans le shell de WWW::Mechanize::Shell, l'invite de commande contient l'URL courante suivie du caractère > :

    $ perl -MWWW::Mechanize::Shell -e shell
    Module File::Modified not found. Automatic reloading disabled.
    >get http://www.perlmonks.org/index.pl?node=login
    Retrieving http://www.perlmonks.org/index.pl?node=login(200)
    http://www.perlmonks.org/index.pl?node=login>eval $self->agent->form_number(2)
    HTML::Form=HASH(0x876bd40)
    http://www.perlmonks.org/index.pl?node=login>dump
    POST http://www.perlmonks.org/?
      op=login                        (hidden)  
      node_id=109                     (hidden)  
      user=
      passwd=                         (password) 
      expires=<UNDEF>                 (checkbox) [*<UNDEF>/off|+10y/save me a permanent cookie, cowboy!]
      sexisgood=submit                (submit)  
      .cgifields=expires              (hidden)  
    http://www.perlmonks.org/index.pl?node=login>fillout
    (text)user> [] BooK
    (password)passwd> [] s3kr3t
    |+10y
    (checkbox)expires> [] 
    http://www.perlmonks.org/index.pl?node=login>submit
    200
    http://www.perlmonks.org/?>script

La commande script permet d'obtenir le code WWW::Mechanize correspondant aux commandes entrées tout au long de la session. Le script résultant est le suivant :

    #!/usr/bin/perl -w
    use strict;
    use WWW::Mechanize;
    use WWW::Mechanize::FormFiller;
    use URI::URL;

    my $agent = WWW::Mechanize->new( autocheck => 1 );
    my $formfiller = WWW::Mechanize::FormFiller->new();
    $agent->env_proxy();

      $agent->get('http://www.perlmonks.org/index.pl?node=login');
        $agent->form(1) if $agent->forms and scalar @{$agent->forms};
       print( do { $agent->form_number(2) },"\n" );
      $formfiller->add_filler( 'user' => Fixed => 'BooK' );
      $formfiller->add_filler( 'passwd' => Fixed => 's3kr3t' );
      $formfiller->add_filler( 'expires' => Fixed => '' );$formfiller->fill_form($agent->current_form);
      $agent->submit();

Je vous invite à consulter la documentation de ce module pour plus de détails, car je ne fais moi-même que découvrir ses possibilités.

ngrep

Un peu plus bas dans les couches réseau, ngrep (pour network grep) permet de voir le contenu des paquets qui circulent sur une interface réseau. Il est tout de même assez rare d'en avoir besoin pour un script web.

Exemple :

    # ngrep -d ppp0 -n 2 port 80
    interface: ppp0 (213.102.24.231/255.255.255.255)
    filter: ip and ( port 80 )
    #####
    T 213.102.24.231:3341 -> 193.252.242.142:80 [AP]
      GET / HTTP/1.1..TE: deflate,gzip;q=0.3..Keep-Alive: 300..Connection: Keep-A
      live, TE..Cache-Control: max-age=0..Via: 1.1 rose (HTTP::Proxy/0.12)..Accep
      t: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plai
      n;q=0.8,video/x-mng,image/png,image/jpeg,image/gif;q=0.2,text/css,*/*;q=0.1
      ..Accept-Charset: ISO-8859-1, utf-8;q=0.66, *;q=0.66..Accept-Language: fr, 
      en;q=0.50..Host: www.pagesjaunes.fr..User-Agent: Mozilla/5.0 (X11; U; Linux
       i686; en-US; rv:1.0.0) Gecko/20020623 Debian/1.0.0-0.woody.1....          
    #
    T 193.252.242.142:80 -> 213.102.24.231:3341 [AP]
      HTTP/1.1 200 OK..Server: Netscape-Enterprise/4.1..Date: Sun, 04 Jan 2004 21
      :42:44 GMT..Content-type: text/html..P3p: Policyref="http://www.pagesjaunes
      .fr/w3c/p3p.xml", CP="NON DSP COR LAW CUR ADMa DEVa HISa OUR LEG UNI COM NA
      V INT CNT STA"..Set-cookie: VisitorID=GD-78B6174-436CE; expires=Sunday, 04-
      Jan-2014 21:42:44 GMT; domain=.pagesjaunes.fr; path=/..Content-length: 2163
      1....                                                                      
    ##exit
    8 received, 0 dropped

C'est généralement beaucoup plus d'information que vous n'en avez réellement besoin pour la rédaction de votre script, mais cela peut être utile pour savoir ce qui se passe réellement.

Conclusion de la série d'articles

Au cours de ces trois articles, je vous ai fait découvrir LWP::UserAgent, le principal module utilisé pour se connecter au web en Perl, puis les techniques courantes d'authentification et de récupération d'informations, avant de vous présenter un ensemble de scripts et de modules qui simplifient encore la rédaction de robots du web. Sans compter les multiples exemples concrets de code.

Il y a encore un certain nombre de modules et de techniques qui restent à présenter, mais avec ce que vous avez appris et le secours de la documentation, il y a peu de sites web qui devraient vous resister longtemps.

Références

L'auteur

Philippe 'BooK' Bruhat, <book@mongueurs.net>.

Philippe Bruhat est vice-président de l'association les Mongueurs de Perl et membre du groupe Paris.pm. Il est consultant spécialisé en sécurité et l'auteur des modules Log::Procmail, HTTP::Proxy et Regexp::Log, disponibles sur CPAN.

Merci aux membres du groupe de travail « articles » des Mongueurs de Perl pour leur relecture attentive malgré la date tardive de remise de cet article.

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