[couverture de Linux Magazine 56]

LWP, Le Web en Perl (1)

Article publié dans Linux Magazine 56, décembre 2003.

Copyright © 2003 - Philippe Bruhat.

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

Chapeau

L'acquisition de données et surtout la gestion d'applications à travers le web sont généralement une occupation fastidieuse et répétitive. De nombreux outils disposent uniquement d'une interface web et on se rend souvent compte que l'on irait plus vite avec un petit script en ligne de commande. Cette série d'articles va vous apprendre comment automatiser la navigation HTTP en Perl.

Automatiser le surf

Le problème principal du web, c'est que les navigateurs ne sont pas très pratiques à utiliser. D'une certaine façon, il faut bien reconnaître qu'au niveau interface utilisateur, ce n'est souvent pas terrible et que les formulaires HTML montrent rapidement leurs limites en matière d'ergonomie (« Oh, encore un formulaire où il faut cliquer 397 fois avant de pouvoir valider ! »).

Dans de nombreux cas on préférerait pouvoir se passer de son navigateur, en particulier pour toutes les situations répétitives : récupération d'une information régulièrement mise à jour, vérification qu'une page web a été modifiée ou non, ou tout simplement obtention d'une information texte simple à partir d'un site à la présentation alambiquée.

Après tout, puisque HTTP n'est qu'un protocole réseau, il doit donc être possible d'utiliser directement le protocole pour parler avec le serveur et obtenir de lui ce que l'on souhaite. Voici une première tentative :

    #!/usr/bin/perl -w
    use strict;
    use Socket;
    my ( $iaddr, $paddr, $proto );

    # ouvre la connexion
    $iaddr = inet_aton('paris.mongueurs.net') or die "Le serveur n'existe pas";
    $paddr = sockaddr_in( 80, $iaddr );
    $proto = getprotobyname('tcp');
    socket( SOCK, PF_INET, SOCK_STREAM, $proto ) or die "socket: $!";
    connect( SOCK, $paddr ) or die "connect: $!";

    # force le vidage automatique du tampon
    select( ( select(SOCK), $| = 1 )[0] );

    # envoie la requête
    # ("\r\n" n'est pas portable, d'où l'utilisation de "\015\012")
    print SOCK "GET / HTTP/1.0\015\012Host: paris.mongueurs.net\015\012\015\012";

    # récupère la réponse
    my $page = do { local $/; <SOCK> };

    # ferme la connexion
    close(SOCK) or die "close: $!";

Rappel : Mettre $/ à undef permet de passer en mode slurp (aspiration), et de capturer ainsi toute la page d'un seul coup. Il ne reste alors plus qu'à traiter le contenu de $page.

Note : Dans tous les scripts de cet article, j'utilise l'option -w et la pragma strict, afin de détecter rapidement les fautes d'inattention, les typos, ainsi que les problèmes liés à ma lecture trop rapide des documentations. En général, cela permet de détecter plus rapidement les erreurs et de les corriger derechef. Tous ces scripts ont été testés et utilisés par moi avant d'être inclus dans l'article.

Il est tout de même un peu fastidieux d'ouvrir ses connexions à la main. Heureusement, Perl dispose de classes réseau d'un peu plus haut niveau, comme IO::Socket::INET, qui encapsulent les fonctions complexes et permettent d'ouvrir facilement des connexions vers tout serveur sur Internet.

    #!/usr/bin/perl -w
    use strict;
    use IO::Socket::INET;

    # ouvre une connexion sur le serveur
    my $sock = IO::Socket::INET->new(
        PeerAddr => 'paris.mongueurs.net',
        PeerPort => 'http(80)',
        Proto    => 'tcp'
    ) or die "Impossible de se connecter";

    # envoie la requête
    print $sock "GET / HTTP/1.0\015\012Host: paris.mongueurs.net\015\012\015\012";

    # récupère la réponse
    my $page = do { local $/; <$sock> };

    # ferme la connexion
    close $sock or die "close: $!";

Dans les deux exemples précédents, $page contient toute la réponse : la ligne de statut (200 OK), les en-têtes et le corps du message. Il vous reste encore tout le travail d'analyse (certes pas très compliqué) à faire pour séparer ces trois éléments. Et le véritable traitement n'a pas encore commencé !

Sans compter le fait que, si vous passez par un serveur mandataire (ou proxy), il vous faudra modifier la connexion (on se connecte au proxy, et non plus directement au serveur final) et la requête (l'URI doit être absolue et non relative).

Bref, si vous utilisez Socket ou IO::Socket::INET pour gérer vos connexions manuellement, votre petit script sera obèse avant d'être utile.

Enfin, nous avons utilisé ici le protocole HTTP/1.0, car le support de HTTP/1.1 (connexions persistantes, chunked encoding, etc.) est beaucoup plus compliqué qu'une simple ouverture de connexion. En réalité, HTTP est un protocole complexe, pour lequel on compte pas moins de 25 RFC.

Il est donc préférable d'utiliser une librairie qui respecte au mieux les protocoles, plutôt que d'en faire sa propre implémentation bancale. Perl dispose d'une telle librairie : libwwww-perl, plus connue sous l'abréviation LWP.

La librairie libwww-perl

Pour accéder au web en Perl, il est heureusement inutile d'ouvrir vos connexions vous-mêmes et d'envoyer des GET / HTTP/1.0 dessus. Ce serait ré-inventer une roue particulièrement ronde : la librairie libwww-perl ou LWP. Il s'agit d'un ensemble de modules Perl pour accéder au web (et plus), dont la création et le développement ont été coordonnés par Gisle Aas depuis 1995, à partir de la librairie libwww-perl écrite pour Perl 4 par Roy Fielding (l'un des auteurs du RFC concernant HTTP). Comme vous allez le voir dans la suite de cet article, LWP est l'outil indispensable pour l'écriture de clients HTTP en Perl.

Présentation de LWP

LWP est une librairie orientée objet qui utilise beaucoup les notions d'héritage et d'encapsulation et s'appuie sur le modèle de communication HTTP. C'est-à-dire que la communication se déroule toujours de la même façon : une requête (un objet HTTP::Request) est construite, puis envoyée au serveur qui renvoie une réponse (un objet HTTP::Response), qui contient les informations requises. HTTP est un protocole sans mémoire, ce qui signifie que chaque requête est traitée indépendamment des autres. LWP s'appuie sur ce modèle de communication pour réaliser non seulement des requêtes HTTP, mais aussi ftp, gopher, SMTP, des accès au système de fichier local, etc.

Cette partie va introduire quelques-uns des modules principaux de la librairie LWP. Ne vous inquiétez pas si tout ne vous paraît pas clair au début, cette série d'articles se poursuivra par des exemples largement commentés. En ce qui concerne l'API complète (liste des méthodes et attributs), je vous renvoie à la documentation en ligne.

Retour à notre exemple

Voici donc comment récupérer la page principale du site de Paris.pm avec LWP::Simple :

    #!/usr/bin/perl -w
    use strict;
    use LWP::Simple;

    # récupère le corps de la réponse
    my $content = get('http://paris.mongueurs.net/');

Difficile de faire plus simple ! Sa simplicité est en fait la principale limitation de LWP::Simple : il ne donne pas accès aux en-têtes, et la méthode get() retourne un simple undef en cas d'erreur. Il est donc impossible de savoir si on a affaire à une erreur 404 ou à un dépassement du délai de garde de la connexion (timeout).

Pour accéder à toutes les informations concernant votre connexion HTTP, il vous faudra utiliser LWP::UserAgent et les objets qu'il emploie :

    #!/usr/bin/perl -w
    use strict;
    use LWP::UserAgent;

    # crée un agent et une requête
    my $ua  = LWP::UserAgent->new();
    my $req = HTTP::Request->new( GET => 'http://paris.mongueurs.net/' );

    # récupère la réponse
    my $res = $ua->request($req);

L'objet $res retourné par la méthode request() est de type HTTP::Response. Vous pouvez obtenir l'objet HTTP::Headers qui représente les en-têtes de cette réponse avec la méthode headers() et le corps du message avec la méthode content().

Exemple d'automatisation : perlmonks

Nous allons utiliser LWP::UserAgent et les librairies de LWP pour nous connecter au site Perlmonks (accessible à http://www.perlmonks.org/). En effet, le site récompense les utilisateurs fidèles en leur donnant des point d'expérience (XP) de façon aléatoire à la première connexion de la journée. Si un utilisateur se connecte tous les jours, son expérience augmente un peu plus vite.

Les points d'expérience sont également attribués en de nombreuses autres occasions (votes sur les messages postés par soi-même ou les autres utilisateurs). Utiliser un script de connexion automatique permet de s'assurer que l'on touchera quelques XP même quand on n'a pas le temps matériel de se connecter sur le site.

Un script de connexion simple

Notre objectif est donc de nous connecter au moins une fois par jour, avec cron et perl. Voici le script qui nous permet de le faire :

    #!/usr/bin/perl -w
    use strict;
    use LWP::UserAgent;
    use HTML::Form;

    # les paramètres personnels
    my ( $user, $pass ) = qw( BooK S3kr3t );
    my $base = 'http://www.perlmonks.org/';

    # initialisation de l'agent
    my $ua =
      LWP::UserAgent->new(
        agent => 'Mozilla/4.73 [en] (X11; I; Linux 2.2.16 i686; Nav)' );

    # création de la requête
    my $req = HTTP::Request->new( GET => "${base}index.pl?node=login" );

    # exécute la requête et reçoit la réponse
    my $res = $ua->request($req);
    die $res->status_line if not $res->is_success;

    # le formulaire de login est le second formulaire de la page
    my $form = ( HTML::Form->parse( $res->content, $base ) )[1];

    # remplit les champs
    $form->find_input('user')->value($user);
    $form->find_input('passwd')->value($pass);

    # valide et renvoie le formulaire
    $ua->request( $form->click );

L'initialisation des variables jusqu'à l'appel de $ua->request() ne pose pas de problème. On essaie de passer inaperçu en indiquant un navigateur « standard ». ;-) La méthode is_success() de HTTP::Response nous permet de vérifier que le serveur nous a bien répondu par un code 2xx (en général 200 OK).

Si la réponse est correcte, nous allons alors remplir le formulaire de connexion qu'elle contient. La classe HTML::Form permet de traiter un formulaire HTML comme un objet. HTML::Form->parse() retourne une liste d'objets HTML::Form à partir d'un formulaire en HTML et de l'URI de base ($base dans notre exemple). Nous extrayons donc le second formulaire (indice 1 dans la liste) et le stockons dans $form. Le premier formulaire est celui du moteur de recherche du site perlmonks.

La méthode find_input() de HTML::Form retourne un objet HTML::Form::Input à partir de son nom. La méthode value() de HTML::Form::Input permet de modifier la valeur du champ correspondant du formulaire. C'est ainsi que nous pouvons entrer nos login et mot de passe dans le formulaire.

La méthode click() retourne directement l'objet HTTP::Request qui pourra être utilisé par LWP::UserAgent pour aller chercher la page correspondant à la validation du formulaire.

Comme la réponse ne nous intéresse pas (nous voulons juste que perlmonks note notre connexion du jour, et nous attribue éventuellement un XP), le programme est fini.

Notez bien que nous faisons plusieurs tests d'erreur, afin de nous assurer que tout fonctionne. Sans un minimum de traitement d'erreur, tout script peut planter horriblement dès qu'un petit quelque chose ne se déroule pas comme prévu.

Utilisation de cookies

Le site perlmonks utilise des cookies pour conserver l'authentification des utilisateurs pendant leur navigation d'une page à l'autre. Le script précédent charge deux pages (récupération du formulaire, puis validation de celui-ci), alors que si nous connaissions le cookie utilisé, une seule requête suffirait : nous montrerions le biscuit en chargeant n'importe quelle page du site (si possible une page pas trop grosse).

HTTP::Cookies est capable de lire et de renvoyer les cookies reçus depuis un serveur, et de les stocker dans un format qui lui est propre. Pour cela, il suffit d'ajouter un attribut cookie_jar à votre objet LWP::UserAgent :

    $ua->cookie_jar( HTTP::Cookies->new() );

L'objet client sera ainsi capable de traiter les cookies reçus, de les renvoyer avec les prochaines requêtes sur les sites correspondants, de les sauver dans un fichier, etc.

Le problème principal avec les cookies, c'est que la méthode set_cookie() de HTTP::Cookie n'a pas une signature très simple :

    $cookie_jar->set_cookie(
        $version,   $key,    $val,    $path,    $domain, $port,
        $path_spec, $secure, $maxage, $discard, \%rest
      );

Plutôt que de capturer le cookie et de mettre à jour l'objet manuellement, il est préférable (la paresse est une vertu, souvenez-vous) de laisser LWP faire le travail à votre place.

Reprenons l'initialisation de notre objet LWP::UserAgent :

    my $ua = LWP::UserAgent->new(
        agent      => 'Mozilla/4.73 [en] (X11; I; Linux 2.2.16 i686; Nav)',
        cookie_jar =>
          HTTP::Cookies->new( file => 'cookies.txt', autosave => 1 )
    );

Nous ajoutons ainsi à notre agent une « boîte à cookies » où il stockera ceux qu'il reçoit. À la destruction de l'objet HTTP::Cookies, la liste des cookies sera sauvée dans le fichier cookies.txt.

Relançons notre script, et voyons le contenu du fichier cookies.txt ainsi généré :

    #LWP-Cookies-1.0

Il n'y a aucun cookie. Pourtant, mon navigateur habituel me demande si j'accepte le cookie de perlmonks à chaque fois que je me connecte ! Un bref coup d'œil à la documentation de HTTP::Cookies nous donne la réponse :

    $cookie_jar = HTTP::Cookies->new;
        The constructor takes hash style parameters.  The following parameters
        are recognized:

          file:            name of the file to restore cookies from and save cookies to
          autosave:        save during destruction (bool)
          ignore_discard:  save even cookies that are requested to be discarded (bool)
          hide_cookie2:    do not add Cookie2 header to requests

Le paramètre ignore_discard semble correspondre à notre problème. En effet, le cookie renvoyé par perlmonks est certainement un cookie de session qui n'est pas sauvegardé automatiquement par HTTP::Cookie. Vous remarquerez au passage que la librairie LWP respecte le RFC 2965 en ne conservant pas ce cookie après la fin de l'exécution du script (le comportement par défaut en l'absence du paramètre Max-Age dans le cookie est de le détruire quand le client s'arrête).

Nous modifions donc notre code comme suit :

    my $ua = LWP::UserAgent->new(
        agent      => 'Mozilla/4.73 [en] (X11; I; Linux 2.2.16 i686; Nav)',
        cookie_jar => HTTP::Cookies->new(
            file           => 'cookies.txt',
            autosave       => 1,
            ignore_discard => 1
        )
    );

Après une nouvelle exécution de notre script, le fichier contient bien le cookie au format LWP-Cookies :

    #LWP-Cookies-1.0
    Set-Cookie3: userpass=BooK%257CBoO5RNugWibsc%257C; path="/"; domain=www.perlmonks.org; path_spec; discard; version=0

(Inutile d'essayer ce cookie pour me voler mon compte sur perlmonks : j'ai changé mon mot de passe le temps des tests. ;-))

Nous allons donc pouvoir nous servir de ce cookie pour économiser un peu les ressources réseau. Nous utilisons également Getopt::Std pour gérer des options de ligne de commande :

    #!/usr/bin/perl -w
    use strict;
    use LWP::UserAgent;
    use HTML::Form;
    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 "Mauvais arguments";

    # initialisation de l'agent
    my $ua = LWP::UserAgent->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
        )
    );

    my $req = HTTP::Request->new( GET => "${base}index.pl?node=login" );

    # nouvel utilisateur ou pas ?
    # l'option -l permet de forcer cette séquence,
    # qui capture et stocke le cookie
    if ( $conf{l} ) {
        my ( $user, $pass ) = split ':', $conf{l}, 2;

        # exécute la requête et reçoit la réponse
        my $res = $ua->request($req);
        die $res->status_line if not $res->is_success;

        # le formulaire de login est le second formulaire de la page
        my $form = ( HTML::Form->parse( $res->content, $base ) )[1];

        # on remplit les champs
        $form->find_input('user')->value($user);
        $form->find_input('passwd')->value($pass);

        $ua->request( $form->click );
    }

    # sinon on établit la connexion (avec le cookie s'il existe)
    else {
        $ua->request($req);
    }

À propos de ce script, il faut savoir qu'il n'affichera pas d'erreur si le mot de passe est mauvais ou si le cookie n'existe pas. Mais en quelques lignes de code, il rend déjà bien service.

Utilisation avancée de LWP

Importation de cookies

Encore une dernière remarque au sujet des cookies : votre navigateur enregistre lui aussi des cookies sur le disque. Pourquoi ne pas aller chercher celui qui vous intéresse directement dans le fichier correspondant ?

Vous trouverez, dans la distribution standard de LWP ou sur CPAN, des modules qui se chargent de lire les cookies stockés au format propriétaire de votre navigateur et de les ajouter à un objet dérivé de HTTP::Cookies, celui-ci pouvant être utilisé par votre agent. Ceci pourra par exemple vous permettre de surfer avec votre navigateur habituel, puis de convertir les cookies qu'il aura récupérés au format HTTP::Cookies, afin de les utiliser dans vos scripts. Et vous n'aurez pas besoin de faire vous même la conversion entre le format natif de votre navigateur et le format interne HTTP::Cookies avant utilisation.

Les modules existants à ce jour sont : HTTP::Cookies::Netscape, HTTP::Cookies::Mozilla, HTTP::Cookies::Microsoft, HTTP::Cookies::iCab, HTTP::Cookies::Safari et HTTP::Cookies::Omniweb.

Voici un exemple de script de conversion des cookies Mozilla en cookies LWP. Il traduit le contenu du fichier Mozilla-cookies.txt (format Mozilla) et le sauve dans un fichier cookies.txt utilisable directement par le module HTTP::Cookies :

    #!/usr/bin/perl -w
    use strict;
    use HTTP::Cookies::Mozilla;

    my $cookies = HTTP::Cookies::Mozilla->new( file => 'Mozilla-cookies.txt' );
    HTTP::Cookies::save( $cookies, 'LWP-cookies.txt' );

La dernière ligne force l'objet $cookies à être sauvegardé avec la méthode save() de HTTP::Cookies, et non celle de HTTP::Cookies::Mozilla (qui sauve les cookies au format Mozilla). Pour adapter ce script à votre navigateur, il suffit simplement d'utiliser le module correspondant.

Utilisation d'un proxy

LWP::UserAgent supporte l'utilisation d'un serveur mandataire (proxy). C'est la méthode proxy() qui permet de le définir :

    $ua->proxy( 'http', 'http://proxy.example.com:3128/');

Si le proxy supporte plusieurs protocoles, il faut utiliser une référence à un tableau :

    $ua->proxy(['http', 'ftp'], 'http://proxy.example.com:8080/');

Si un mot de passe est nécessaire pour utiliser le proxy, la chaîne de description correspondante sera : http://user:pass@proxy.example.com:8080/.

Il est également possible d'utiliser une variable d'environnement pour définir le proxy à utiliser.

Pour utiliser les variables d'environnement, il faut initialiser l'agent de la façon suivante :

    my $ua = LWP::UserAgent->new( env_proxy => 1 );

La variable d'environnement utilisée par LWP::UserAgent est HTTP_PROXY (FTP_PROXY pour le proxy ftp, etc.). Notez que LWP::UserAgent ne tient pas compte de la casse : il retrouvera son proxy que la variable d'environnement s'appelle http_proxy, HTTP_PROXY ou encore HtTp_PrOxY.

L'avantage d'utiliser une variable d'environnement est qu'il est alors possible d'écrire un script très général, qu'un utilisateur devant utiliser un proxy pourra utiliser sans le modifier. Mais on peut tout aussi bien ajouter une option --proxy à son script (au sujet des options, voir l'article de Jérôme Quelin dans Linux Magazine 49).

Gestion de formulaires complexes

HTML::Form est assez simple à utiliser avec un formulaire de recherche classique, où il s'agit en général de remplir le champ texte correspondant à la requête.

Pour une simple requête Google, par exemple,

    #!/usr/bin/perl -w
    use strict;
    use LWP::UserAgent;
    use HTML::Form;

    my $ua = LWP::UserAgent->new( env_proxy => 1, agent => "Mozilla/5.0" );
    my $base = 'http://www.google.fr/';
    my $res  = $ua->request( HTTP::Request->new( GET => $base ) );
    my $form = HTML::Form->parse( $res->content );
    $form->value( q => shift );
    $res = $ua->request( $form->click );

Et $res contient la réponse HTTP faite par Google à votre requête.

Note : Google s'appuie sur l'en-tête User-Agent pour refuser les requêtes automatiques ; c'est pourquoi nous avons utilisé l'attribut agent. De toute façon, si vous voulez faire des requêtes sur Google de façon automatique, les conditions d'utilisation de Google (http://www.google.com/terms_of_service.html) l'interdisent. Pour cela, il est recommandé d'utiliser les API que Google met à la disposition des développeurs.

Dans la suite de cette partie, nous allons voir comment valider les champs ayant un nombre limité de valeurs possibles. Ils sont de type radio, checkbox ou select ; les objets correspondants sont tous de la classe HTML::Form::ListInput. Ces champs portent tous le même nom, ce qui peut poser problème, puisque la fonction value() (utilisée dans l'exemple $form->value( q => "test" ) cité précédemment) ne permet que de modifier le premier champ de même nom.

Nous allons donc voir comment remplir un formulaire à partir d'un petit exemple de source HTML :

    <form method="POST" action="/sondage.pl">
    <p>Quelles sont vos couleurs préférées&nbsp;?</p>
    <p>
    <input type="checkbox" name="RGB" value="rouge" /> Rouge
    <input type="checkbox" name="RGB" value="vert" /> Vert
    <input type="checkbox" name="RGB" value="bleu" /> Bleu
    </p><p>
    <input type="radio" name="CYMK" value="cyan" checked /> Cyan
    <input type="radio" name="CYMK" value="jaune" /> Jaune
    <input type="radio" name="CYMK" value="magenta" /> Magenta
    </p>
    <p>Et vos fruits et légumes préférés&nbsp;?</p>
    <select name="FRUITS" multiple>
    <option value="pommes">pommes
    <option value="poires">poires
    <option value="bananes">bananes
    <option value="oranges">oranges
    </select>
    <select name="LEGUMES">
    <option value="brocolis">brocolis
    <option value="carottes">carottes
    <option value="courgettes">courgettes
    </select>
    <p><input type="submit" value="Valider" /></p>
    </form>

Voici ce qu'un navigateur affichera en le recevant :

Une fois l'objet $form créé avec HTML::Form->parse(), on peut l'afficher avec sa méthode dump() :

    POST http://example.com/sondage.pl
      RGB=<UNDEF>                     (checkbox) [*<UNDEF>|rouge]
      RGB=<UNDEF>                     (checkbox) [*<UNDEF>|vert]
      RGB=<UNDEF>                     (checkbox) [*<UNDEF>|bleu]
      CYMK=<UNDEF>                    (radio)   [cyan|jaune|magenta]
      FRUITS=<UNDEF>                  (option)  [*<UNDEF>|pommes]
      FRUITS=<UNDEF>                  (option)  [*<UNDEF>|poires]
      FRUITS=<UNDEF>                  (option)  [*<UNDEF>|bananes]
      FRUITS=<UNDEF>                  (option)  [*<UNDEF>|oranges]
      LEGUMES=brocolis                (option)  [*brocolis|carottes|courgettes]
      <NONAME>=Valider                (submit)

Les * correspondent aux valeurs « déjà vues » pour ce champ par l'objet HTML::Form (explications ci-dessous).

Tout d'abord, il convient de noter que les champs checkbox et select/multiple sont traités différement des champs radio et select sans l'option multiple. Cela vient du fait que les premiers permettant de faire une selection multiple, ils peuvent apparaître plusieurs fois dans la requête générée (par exemple RGB=rouge&RGB=bleu&FRUITS=poires&FRUITS=oranges&LEGUMES=brocolis) et que le CGI recevant cette requête sait traiter une telle requête.

Nous pouvons commencer à essayer de modifier les valeurs des champs. Nous testerons ce qui sera renvoyé en analysant la requête générée à partir du formulaire modifié.

    # valide le formulaire sans modification
    print $form->click()->content();

Ceci affiche :

    LEGUMES=brocolis

Première remarque, les champs de type select sans l'option multiple ayant nécessairement une valeur sélectionnée, celle-ci est toujours renvoyée, ce qui n'est pas le cas pour les champs de type checkbox ou les select avec l'option multiple.

Commençons par tester la boîte de sélection simple et les boutons radio :

    # radio
    $form->value( CYMK => 'jaune' );
    $form->value( CYMK => 'cyan' );
    # select sans l'option multiple
    $form->value( LEGUMES => 'carottes' );
    $form->value( LEGUMES => 'courgettes' );
    # le résultat
    print $form->click()->content();

Le corps de la requête envoyée sera :

    CYMK=cyan&LEGUMES=courgettes

Pour ces deux types de champs, comme il se doit, seule la dernière valeur sélectionnée est passée lors de l'envoi du formulaire.

Si on tente de sélectionner une valeur non proposée, cela provoquera une erreur (la méthode value() meurt en faisant appel à Carp::croak()) :

    # valeur illégale
    $form->value( CYMK => 'blanc' );
    Illegal value 'blanc' at exemple.pl line 13

Note : Pour éviter que votre programme ne meure brutalement, vous pouvez bien sûr utiliser le mécanisme d'exceptions associé à eval { } et traiter le message d'erreur contenu dans la variable standard $@ (pour la gestion d'exceptions en Perl, je vous renvoie à mon article de Linux Magazine 52, Les variables standard de Perl).

Voyons maintenant les champs avec des sélections multiples (checkbox et select avec l'option multiple) :

    # checkbox
    $form->value( RGB => 'rouge');
    $form->value( RGB => 'bleu');

Ceci donne l'erreur Illegal value 'bleu'. La valeur rouge ne provoque pas d'erreur, mais la valeur bleu en provoque une ? Souvenez-vous que la méthode find_input() (utilisée par value()) ne trouve que le premier de tous les champs de même nom. Or dans le cas des checkbox (comme celui des select/multiple), HTML::Form crée un objet de type HTML::Form::Input par valeur possible, ainsi que nous l'avons vu dans le résultat de dump(). Le premier champ trouvé portant le nom RGB n'a que deux valeurs possibles : UNDEF et rouge. On peut le vérifier en utilisant la méthode possible_values() dans l'exemple suivant :

    use Data::Dumper;
    # valeurs possibles de la première checkbox nommée RGB
    my $input = $form->find_input( 'RGB' );
    print Dumper [ $input->possible_values() ];

Ce qui affiche :

    $VAR1 = [
              undef,
              'rouge'
            ];

L'erreur rencontrée précédemment s'explique ainsi facilement. Il faudra donc sélectionner les cases à cocher d'une autre manière.

La méthode find_input() accepte heureusement d'autres arguments que le simple nom du champ. Le deuxième argument impose le type du champ à trouver (au cas où plusieurs champs différents porteraient le même nom) et le troisième permet d'imposer le numéro d'ordre du champ à trouver (en comptant à partir de 1). On peut ainsi sélectionner les cases voulues, pourvu qu'on connaisse leur ordre d'apparition dans le formulaire :

    # checkbox
    $form->find_input( 'RGB', undef, 1 )->value(undef);
    $form->find_input( 'RGB', undef, 2 )->value('vert');
    $form->find_input( 'RGB', undef, 3 )->value('bleu');

Ce qui coche les cases vert et bleu, tout en décochant la case rouge :

    RGB=vert&RGB=bleu&LEGUMES=brocolis

Une autre possibilité est d'obtenir la liste des champs à l'aide de inputs() et de boucler dessus pour les modifier. Nous allons également utiliser la méthode possible_values(), qui (comme son nom l'indique) liste l'ensemble des valeurs possibles pour ce champ. Si le champ n'a pas un nombre limité de valeurs possibles, cette méthode retourne une liste vide.

Voici un exemple permettant de sélectionner seulement les fruits poires et bananes :

    # récupère les champs nommés FRUITS
    for my $input
        ( grep { defined $_->name && $_->name eq 'FRUITS' } $form->inputs )
    {
        # valide ceux qui acceptent 'poires' ou 'bananes' comme valeur
        $input->value($_)
          for grep { defined && /poires|bananes/ } $input->possible_values();
    }

En général, utiliser find_input() avec trois paramètres est un peu plus compréhensible. Cela nécessite juste d'être certain de la présentation du formulaire, puisque le troisième paramètre dépend de l'ordre dans lequel les balises <input> sont insérées dans le code HTML.

À titre d'information, sachez qu'il existe également une méthode other_possible_values() qui liste l'ensemble des valeurs non encore utilisées pour un champ. Il y a toujours au moins une valeur par défaut qui a été prise, ainsi qu'on l'a vu plus haut avec le résultat de dump() (les valeurs marquées par *).

    # trouve le premier champ FRUITS
    my $input = $form->find_input('FRUITS');
    print "Possibles : ", map( {"<$_>"} $input->possible_values() ),       "\n";
    print "Restantes : ", map( {"<$_>"} $input->other_possible_values() ), "\n";
    # sélectionne une valeur
    $input->value('pommes');
    print "Restantes : ", map( {"<$_>"} $input->other_possible_values() ), "\n";

L'exemple ci-dessus affichera (avec un message d'avertissement à cause de l'utilisation d'undef dans le premier map) :

    Possibles : <><pommes>
    Restantes : <pommes>
    Restantes :

Rien n'est affiché à la fin, car toutes les valeurs possibles (undef et pommes) ont été sélectionnées au moins une fois.

Je ne présente pas la méthode try_others, car je ne lui ai pas encore trouvé d'utilité.

Et la performance ?

Au début de cet article, quatre méthodes vous ont été successivement présentées pour télécharger une page depuis un site web. Nous avons analysé le code écrit, mais pas la performance des requêtes successives.

Un test, réalisé avec le module Benchmark, en faisant 500 requêtes successives sur http://www.yahoo.fr/ a donné les résultats suivants :

                Rate       lwp lwpsimple  iosocket    socket
    lwp       30.7/s        --      -73%      -91%      -96%
    lwpsimple  113/s      269%        --      -65%      -86%
    iosocket   327/s      965%      189%        --      -58%
    socket     781/s     2447%      591%      139%        --

LWP n'est donc clairement pas un bon outil de déni de service. ;-) Attention cependant, les chiffres mesurés s'appuient sur la notion de temps CPU consommé. Les performances réseau ne sont donc pas directement prises en compte.

La lenteur de LWP est principalement due à son interface objet, qui rajoute un certain nombre de couches et d'appels de fonction au-dessus de la partie purement réseau. Le gain recherché quand on utilise LWP, ce n'est pas la performance réseau brute, mais la simplicité de programmation et la capacité d'automatisation. De toute façon, on est rarement à quelques secondes près quand on remplit un formulaire sur le web.

La méthode get() de LWP::Simple a été écrite spécialement pour être plus rapide ; c'est d'ailleurs ce qu'indique Gisle Aas lui-même dans le message posté sur la liste de diffusion libwww@perl.org :

Only thing wrong was that it was too heavy and I wanted LWP::Simple::get() to be really lightweight and fast to load for the common http case.

Les différences entre les performances des modules Socket et IO::Socket ont exactement les mêmes origines. La différence est celle qu'il y a entre une connexion directe faisant appel aux fonction natives C et une connexion à travers une surcouche objet.

Au final, tout a un coût ; on paye en performance ce que l'on gagne en facilité et en rapidité de développement. LWP fait gagner énormément du point de vue du développement.

Il existe cependant une méthode pour améliorer les performances réseau de LWP : l'utilisation de HTTP/1.1. En effet, cette version du protocole permet de faire passer plusieurs requêtes HTTP dans la même connexion TCP, en utilisant le chunked encoding (encodage par morceaux).

LWP peut ainsi utiliser un « cache » de connexions vers différents serveurs pour mettre à profit le gain offert par HTTP/1.1. Pour cela, il suffit de déclarer votre objet LWP::UserAgent avec l'attribut keep_alive :

    # conservera trois connexions TCP ouvertes en même temps
    my $ua = LWP::UserAgent->new( keep_alive => 3 );

Sur une série de requêtes vers le même serveur (condition nécessaire), j'ai pu observer un gain d'environ 20% en temps. En effet, avec ce système, il y a beaucoup moins de connexions TCP à établir pour faire le même nombre de requêtes HTTP.

Conclusion

L'évolution du réseau fait qu'HTTP est en passe de devenir le TCP/IP du XXIe siècle : utilisé partout et pour tout, y compris quand ce n'est pas le protocole le plus adapté. LWP est le navigateur qui vous évitera de cliquer pour accéder tout de même aux masses de données disponibles. Les meilleurs modules de la hiérarchie WWW sur CPAN vous donnent une interface simple vers des bases de données souvent accessibles uniquement via HTTP.

LWP est vraiment une superbe librairie, qui m'a permis par exemple d'écrire un script nommé connect-tunnel (disponible sur CPAN), qui gère des tunnels CONNECT à travers un proxy HTTP. Comme LWP supporte la méthode CONNECT et l'authentification HTTP, la toute première version de ce script a été codée en moins d'une heure.

De son côté, mon module HTTP::Proxy repose lourdement sur les modules LWP::UserAgent et HTTP::Daemon.

L'idée que j'ai voulu développer dans cette série d'articles est qu'il existe de nombreuses applications où l'on veut nous imposer une interface web pour interagir avec le système, alors que ce n'est pas l'interface la plus adaptée. Nous le verrons encore mieux dans la suite de cette série, où vous découvrirez comment envoyer des fichiers à un site web par la méthode POST, comment vous connecter à des sites par HTTPS, des exemples d'automatisation expliqués pas à pas et surtout le module WWW::Mechanize, qui simplifie encore l'écriture de scripts d'automatisation du web.

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. Son module WWW::Gazetteer::HeavensAbove utilise LWP pour palier aux limites de l'interface de recherche du site http://www.heavens-above.com/countries.asp lors de la recherche d'informations topographiques sur les villes du monde.

Merci à Estelle et aux membres du groupe de travail « articles » des Mongueurs de Perl pour leur relecture attentive.

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