[couverture de Linux Magazine 77]

Construire des robots pour le web (2)

Article publié dans Linux Magazine 77, novembre 2005.

Copyright © 2005 - Philippe Bruhat

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

Chapeau

Après avoir décrit notre boîte à outils et quelques robots simples dans Linux Magazine 75, nous allons dans cette deuxième partie automatiser des sites plus complexes. Ceux-ci vont simuler une navigation plus longue et manipuler en profondeur les formulaires envoyés par les serveurs ou les requêtes que nous leur renverrons.

Filtrer les enchérisseurs sur eBay

Un des mongueurs de Lyon est un habitué d'eBay : il vend et achète régulièrement des objets sur ce site de vente aux enchères.

Dans le monde des ventes aux enchères, il y a deux types d'enchérisseurs :

Lorsqu'il est dans le rôle du vendeur, notre mongueur ne souhaite donc pas voir participer ces enchérisseurs au profil négatif ou nul. Pour cela, il peut :

Nous allons bien sûr construire le robot.

Identification sur le site

Pour accéder à ses enchères sur eBay, il faut tout d'abord s'identifier.

[Identification]
Identification

mech-dump nous montre les formulaires de la page d'ouverture de session :

    $ mech-dump https://signin.ebay.fr/ws/eBayISAPI.dll?SignIn
    POST https://scgi.ebay.fr/ws/eBayISAPI.dll?RegisterEnterInfo&siteid=71&co_partnerid=2&UsingSSL=1 [RegisterEnterInfo]
      MfcISAPICommand=RegisterEnterInfo (hidden readonly)
      co_partnerId=2                 (hidden readonly)
      siteid=71                      (hidden readonly)
      ru=                            (hidden readonly)
      bin=-1                         (hidden readonly)
      <NONAME>=S'inscrire >          (submit)

    POST https://signin.ebay.fr/ws/eBayISAPI.dll?co_partnerid=2&siteid=71&UsingSSL=1 [SignInForm]
      MfcISAPICommand=SignInWelcome  (hidden readonly)
      siteid=71                      (hidden readonly)
      co_partnerId=2                 (hidden readonly)
      UsingSSL=1                     (hidden readonly)
      ru=                            (hidden readonly)
      pp=                            (hidden readonly)
      pa1=                           (hidden readonly)
      pa2=                           (hidden readonly)
      pa3=                           (hidden readonly)
      i1=-1                          (hidden readonly)
      pageType=-1                    (hidden readonly)
      rtmData=                       (hidden readonly)
      userid=                        (text)
      pass=                          (password)
      <NONAME>=Ouvrir une session en mode sécurisé >  (submit)
      keepMeSignInOption=<UNDEF>     (checkbox) [*<UNDEF>/off|1/Je souhaite rester connecté sur cet ordinateur ; je fermerai moi-même la session. Astuces sur la sécurité de votre compte Assurez-vous que l'URL affichée ci-dessus commence par https://signin.ebay.fr/]

Le premier formulaire concerne l'inscription initiale, avec ouverture de compte chez eBay. Celui qui nous intéresse est le second qui correspond à l'ouverture d'une session (ainsi que l'atteste la présence des champs userid et pass).

    # récupération du formulaire
    $m->get('https://signin.ebay.fr/ws/eBayISAPI.dll?SignIn');
    die 'Échec de connexion : ' . $m->res->status_line()
      unless $m->success();

    # remplissage et validation du formulaire
    $m->form_number(2);
    $m->set_fields(
        userid => $userid,
        pass   => $pass,
    );
    $m->submit();

    # connexion réussie ?
    die 'Échec de validation du formulaire : ' . $m->res()->status_line()
      unless $m->success();

Nous allons utiliser la technique utilisée avec Skype (dans GLMF 75) pour nous assurer que l'identification est correcte. La ligne suivante nous affichera la liste des cookies reçus par notre robot :

    $m->cookie_jar()->scan( sub { print "$_[1]\n" } );

En cas d'identification réussie, le robot reçoit les cookies dp1, ebay, nonsession, ns1, s et secure_ticket. En cas d'échec, on constate que le cookie secure_ticket n'est pas envoyé. On peut remarquer de plus que c'est le seul des cinq cookies qui est marqué comme secure, c'est-à-dire qu'il ne peut être envoyé au serveur qu'à travers une connexion sécurisée (i.e. en SSL).

Comme pour Skype dans l'article précédent, la vérification de la réussite de notre identification se fait de la façon suivante, en inspectant les cookies :

    {
        my $ok = 0;
        $m->cookie_jar()->scan( sub { $ok++ if $_[1] eq 'secure_ticket' } );
        die "Échec d'identification : informations d'ouverture de session invalides"
          unless $ok;
    }

Notre script sait désormais se connecter et vérifier que l'identification est réussie.

Liste des objets et enchérisseurs

Nous allons maintenant récupérer la liste des objets que nous avons mis en vente, afin de pouvoir ensuite trouver les enchérisseurs dont le profil ne nous revient pas.

Notre robot possédant les bons cookies, nous pouvons nous connecter directement à la page qui nous intéresse :

    # récupération de "mes ventes"
    $m->get( 'http://my.ebay.fr/ws/eBayISAPI.dll?MyeBay&CurrentPage=MyeBaySelling');
    die 'Échec de connexion à "mes ventes" : ' . $m->res()->status_line()
      unless $m->success();
[Formulaire d'envoi des images]
Formulaire d'envoi des images

Les liens vers les pages des objets mis en vente sont reconnaissables à l'URL du lien associé : chacun contient la chaîne ViewItem et l'identifiant de l'objet sous la forme item=numero.

WWW::Mechanize fournit la méthode find_all_links(), qui renvoie la liste des liens correspondant aux paramètres de recherche fournis. Ces paramètres sont les mêmes pour les trois méthodes follow_link() (équivalent de get() à partir d'un lien dans la page), find_link() (qui renvoie un seul lien correspondant aux critères de recherche) et find_all_links() (qui renvoie tous les liens correspondants).

Les critères de recherche décrits ci-après ont tous (sauf le dernier, n) un équivalent de la forme xxx_regex qui accepte une expression régulière comme argument, afin de faire une recherche plus souple. Les paramètres sans le suffixe _regex ne correspondront qu'en cas d'identité stricte.

Ces critères sont :

Nous allons ici utiliser la méthode find_all_links() avec le paramètre url_regex pour trouver les pages associées à tous les objets mis en vente :

    my @ventes = $m->find_all_links( url_regex => qr/\?ViewItem.*&item=\d+/ );

Les objets renvoyés par find_all_links() sont de type WWW::Mechanize::Link. Ils disposent de plusieurs accesseurs comme url(), text(), tag(), etc. qui fournissent tous les informations concernant le lien tel qu'il était dans la page web. Ils peuvent être passés directement à la méthode get() de WWW::Mechanize qui saura les utiliser pour récuperer les pages concernées.

Récupération des profils négatifs ou nuls

En accédant à la page dédiée à chaque objet mis en vente, nous obtenons la liste des enchérisseurs.

[Liste des enchérisseurs]
Liste des enchérisseurs

Soit $item l'élément de @ventes que nous analysons. Nous allons tout d'abord récupérer l'historique des enchères sur cet objet :

    # boucle sur le contenu de @ventes
    my ($id) = ( $item->url() =~ /item=(\d+)/g );
    print "Objet : ", $item->text(), " ($id)\n";    # pour info

    # connexion directe à l'historique des enchères sur cet objet
    $m->get( 'http://offer.ebay.fr/ws/eBayISAPI.dll?ViewBids&item=$id' );
    do {
        warn "Échec de connexion aux enchères de l'objet $id : "
          . $m->res()->status_line();
        $m->back();
        next;
      }
      unless $m->success();

Note : La méthode back() réalise la même opération que le bouton « Précédent » de votre navigateur web. WWW::Mechanize conserve en mémoire l'historique des pages visitées (et s'en sert par exemple pour renseigner le champ d'en-tête Referer:). Il est intéressant de suivre autant que possible la logique de la navigation et donc d'utiliser back() quand votre programme retourne en arrière (par exemple en fin de boucle). Ceci permet d'utiliser les méthodes usuelles de WWW::Mechanize dans presque tous les cas, et d'avoir des en-têtes Referer: cohérents avec l'organisation du site.

Les enchérisseurs sont listés sur cette page, avec un lien pour chaque profil. find_all_links() et une requête un peu plus élaborée que précédemment, vont nous renvoyer la liste des liens vers des profils négatifs ou nuls.

    # détection des enchérisseurs avec profil négatif ou nul
    my @negs = $m->find_all_links(
        url_regex  => qr/\?ViewFeedBack/,    # lien vers les détails du profil
        text_regex => qr/^[-0]/              # profil négatif ou nul
    );

Il nous faut maintenant extraire le pseudonyme de ces enchérisseurs. Le lien ViewFeedBack contient un paramètre userid, ce qui va nous faciliter la chose. Nous allons stocker les identifiants des utilisateurs dans une table de hachage, qui nous garantit par construction que chaque pseudonyme n'apparaîtra qu'une seule fois.

Note : L'utilisation d'un hash pour gérer des listes non ordonnées dont les éléments n'apparaissent qu'une seule fois est un idiome Perl.

Normalement, l'expression régulière /userid=(.+?)(?:&|$)/ devrait suffire (on s'arrête en fin de chaîne ou au premier & rencontré).

    my %negs;
    foreach my $link (@negs) {
        $link->url() =~ /(?:userid|ReturnUserEmail&requested)=(.+?)(?:&|$)/;

        # ajoute la clé correspondant au pseudonyme dans le hash
        $negs{$1} = '';
    }

Lors de nos essais, nous avons constaté que l'identifiant de l'utilisateur apparaît parfois après la chaîne ReturnUserEmail&requested= pour une raison obscure. C'est pourquoi nous utilisons une expression un peu plus compliquée, afin de gérer tous les cas connus. Celle-ci pourra d'ailleurs être étendue si d'autres formes venaient à se présenter.

Annulation des enchères

Nous obtenons finalement un hash dont les clés sont les pseudos de tous les enchérisseurs importuns. Il ne reste plus qu'à accéder à la page d'annulation des enchères pour chacun d'entre eux et à soumettre le formulaire adéquat complété avec le numéro d'objet, le pseudo de l'acheteur et un petit message mentionnant la raison de l'éviction. Nos manières expéditives ne doivent pas nous empêcher de rester polis.

[Annulation d'une enchère]
Annulation d'une enchère
    # suppression des enchères non souhaitées
    foreach my $pseudo (keys %negs) {
        $m->get('http://offer.ebay.fr/ws/eBayISAPI.dll?CancelBidShow');
        $m->submit_form(

            # le formulaire 1 correspond à la boîte de recherche
            # c'est donc le formulaire 2 que nous devons remplir
            form_number => 2,
            fields      => { 
                item        => $item, 
                buyeruserid => $pseudo, 
                info        => "Profil <= 0 ; n'a pas pris contact avant d'enchérir."
            }
        );
    }

Le script complet

Tous les bouts de code présentés précédemment ont été mis ensemble pour produire un script complet qui accepte des options de ligne de commande (--userid, --pass, --verbose, etc.). Il ne reste plus qu'à ajouter la lecture d'un fichier $HOME/.ebayrc et il sera parfait (passer un mot de passe sur la ligne de commande, ce n'est pas beaucoup mieux que de le mettre dans le source du script : l'idéal est bien d'avoir un fichier de configuration à part).

Le script remanié reste d'une longueur raisonnable :

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

    # options par défaut
    my %CONF = (
        verbose => 0,
        base    => 'ebay.fr',
        userid  => '',          # À compléter avec vos identifiant
        pass    => '',          # et mot de passe
    );

    # récupération des paramètres de ligne de commande
    GetOptions( \%CONF, "verbose!", "base=s", "userid=s", "pass=s" )
      or die << 'USAGE';
    Options disponibles :
      --base    <site>      : base site (ebay.fr, ebay.com, etc.)
      --userid  <userid>
      --pass    <password>
      --verbose
    USAGE

    # calcul des URL spécifiques
    my %url = (
        signin => "http://signin.$CONF{base}/ws/eBayISAPI.dll?SignIn",
        sales  =>
          "http://my.$CONF{base}/ws/eBayISAPI.dll?MyeBay&CurrentPage=MyeBaySelling",
        bids      => "http://offer.$CONF{base}/ws/eBayISAPI.dll?ViewBids&item=",
        cancelbid => "http://offer.$CONF{base}/ws/eBayISAPI.dll?CancelBidShow"
    );

    # création du robot
    my $m = WWW::Mechanize->new();

    # récupération du formulaire
    $m->get( $url{signin} );
    die "Échec de connexion à la page de login : " . $m->res()->status_line()
      unless $m->success();

    # remplissage et validation
    $m->form_number(2);
    $m->set_fields(
        userid => $CONF{userid},
        pass   => $CONF{pass},
    );
    $m->submit();
    die "Échec d'envoi du formulaire : " . $m->res()->status_line()
      unless $m->success();

    # identification réussie ?
    {
        my $ok = 0;
        $m->cookie_jar()->scan( sub { $ok++ if $_[1] eq 'secure_ticket' } );
        die "Échec du login : informations d'ouverture de session invalides"
          unless $ok;

        print "Connecté à $CONF{base} en tant que $CONF{userid}\n"
          if $CONF{verbose};
    }

    # récupération de "mes ventes"
    $m->get( $url{sales} );
    die 'Échec de connexion à "mes ventes" : ' . $m->res()->status_line()
      unless $m->success();

    my @ventes = $m->find_all_links( url_regex => qr/\?ViewItem.*&item=\d+/ );

    # traitement de chaque objet en vente
    for my $item (@ventes) {

        my ($id) = ( $item->url() =~ /item=(\d+)/g );
        print "Objet : ", $item->text(), " ($id)\n" if $CONF{verbose};

        # connexion directe à l'historique des enchères sur cet objet
        $m->get( $url{bids} . $id );
        do {
            warn "Échec de connexion aux enchères de l'objet $id : "
              . $m->res()->status_line();
            $m->back();
            next;
          }
          unless $m->success();

        # détection des enchérisseurs avec profil négatif ou nul
        my @negs = $m->find_all_links(
            url_regex  => qr/\?ViewFeedBack/,
            text_regex => qr/^[-0]/,
        );

        my %negs;
        foreach my $link (@negs) {
            $link->url() =~ /(?:userid|ReturnUserEmail&requested)=(.+?)(?:&|$)/;

            # ajoute la clé correspondant au pseudonyme dans le hash
            $negs{$1} = '';
        }

        # suppression des enchères non souhaitées
        foreach my $pseudo ( keys %negs ) {
            $m->get( $url{cancelbid} );
            $m->submit_form(
                form_number => 2,
                fields      => {
                    item        => $id,
                    buyeruserid => $pseudo,
                    info        =>
                      "Profil <= 0 ; n'a pas pris contact avant d'enchérir."
                }
            );
            $m->back();
        }
        $m->back();
    }

Un point important à noter est que, puisque la base d'utilisateurs eBay est mondiale et que tous les sites eBay utilisent le même moteur de gestion de comptes et d'enchères, les divers sites eBay sont complètement interchangeables du point de vue de notre navigation (à la langue du site près, d'où l'intérêt de s'intéresser aux éléments invariants entre les langues). Ainsi, si l'on remplace ebay.fr par ebay.com, ebay.ca, ebay.de, ebay.at, ebay.ie, ebay.it, ebay.nl, ebay.es, ebay.ch, ebay.co.uk, ebay.com.au ou ebay.com.cn, l'identification (et donc très probablement le reste du script) fonctionne parfaitement.

Les sites ebay.se, ebay.com.hk, ebay.ph, ebay.com.sg semblent utiliser des moteurs différents (mais identiques entre eux).

Une exception notable est le site ebay.be qui semble partagé entre flamands et wallons : il y a un niveau d'indirection supplémentaire pour le choix de la langue qui empêche le script d'identification de fonctionner.

Quelques autres sites eBay ne fonctionnent pas si on utilise ce script tel quel. Je n'ai pas cherché à gérer cet aspect dans cet article, me contentant d'une option --base pour éventullement changer de site eBay.

Déposer ses JPEG chez le photographe virtuel

Depuis que j'ai un appareil photo numérique, je fais trop de photos. Le format JPEG n'étant pas toujours adapté pour montrer les photos à la famille, il faut parfois sortir les photos sur papier.

Un certain nombre d'entreprises proposent de développer vos photos numériques très facilement : il suffit d'aller sur leur site web, d'y déposer les photos et d'indiquer dans quel magasin de l'enseigne on veut aller les chercher. Ça marche bien pour une dizaine de photos ; mais c'est moins pratique quand je veux télécharger des centaines de photos de la plus belle nièce du monde... Je préférerais donc lister les photos dans un fichier que je passerais à un script qui se chargerait du reste.

C'est ce que nous allons faire.

J'ai choisi pour mes photos l'entreprise Photo Station (http://www.photostation.fr). Comme d'habitude, nous allons d'abord naviguer un peu sur leur site, afin de nous faire une idée de l'interface du site web et des formulaires que nous allons rencontrer.

Exploration du site

[Choix de l'interface]
Choix de l'interface

Première bonne surprise, le site propose une interface HTML en plus de l'interface ActiveX (réservée à Internet Explorer). C'est évidemment sur celle-ci que nous allons nous appuyer.

La navigation sur le site par l'interface HTML se fait selon la progression suivante :

  1. Sélection de l'interface (nous choisirons toujours l'interface HTML)

  2. Sélection du type de travail (« Formats classiques », « Agrandissements », « Produits Photo Fun »).

  3. Sélection de l'interface (ActiveX ou HTML) ;

  4. Sélection et envoi des fichiers images ;

  5. Sélection du format des photos, ou sélection du format de chaque image (optionnel) ;

  6. Si l'on ne s'était pas identifié jusqu'à présent (le formulaire apparait sur toutes les pages), identification avec son compte utilisateur ;

  7. Sélection du magasin et acceptation des conditions ;

  8. Validation de la commande

À l'issue de cette visite, nous avons déjà constaté (rien qu'en regardant les URL dans le navigateur) qu'hormis la page d'accueil, l'intégralité de l'application d'envoi des photos est gérée par le site photoprintit.de.

Les « Produits Photo Fun » étant très spécifiques, nous allons nous intéresser uniquement aux « Formats classiques » et tirer toutes les photos au même format. Étant donnée une liste de photos, le rôle de notre script sera donc de les envoyer sur le site, de sélectionner le format des tirages, de sélectionner un magasin et enfin de valider la commande. Et bien sûr, ne pas oublier de nous identifier à un moment ou un autre.

Commençons par l'identification justement, ça ne doit pas être bien compliqué.

Identification sur le site

Comme tout bon client, nous allons créer manuellement un compte utilisateur avec notre adresse e-mail et le mot de passe idoine. Ceux-ci nous serviront à passer la commande à la fin.

Le formulaire d'identification est présent dès la page d'accueil de http://www.photostation.fr/. Pourtant, bien que visible sur cette la d'accueil chargée par notre navigateur, le formulaire d'identification en question n'est pas détecté par mech-dump :

    $ mech-dump http://www.photostation.fr/index.php
    POST http://www.photostation.fr/entreprise/newsletter.php [newsletter]
      E_MAIL=Votre e-mail            (text)
      imageField=<UNDEF>             (image)

    POST http://www.photostation.fr/magasin/viamichelin.php [list3]
      intMapType=1                   (hidden readonly)
      productId=50739                (hidden readonly)
      from=1234                      (hidden readonly)
      withCriteria=false             (hidden readonly)
      strCountry=000001424           (hidden readonly)
      strCP=                         (text)
      <NONAME>=<UNDEF>               (image)

Note : en période promotionnelle, on trouve parfois une animation Flash au bout de http://www.photostation.fr/. La « vraie » page principale se trouve alors à http://www.photostation.fr/index.php.

En fait, le formulaire de connexion est fourni dans une balise iframe. Ceci confirme d'ailleurs que PhotoStation utilise les services d'un fournisseur spécialisé pour la récupération des photos : photoprintit.de.

WWW::Mechanize nous permet de suivre des liens à partir de l'indication de la balise qui les contient. Nous allons ainsi récupérer le contenu du cadre en question, et afficher le formulaire :

    $m->get('http://www.photostation.fr/index.php');
    $m->follow_link( tag => "iframe" );
    print $m->current_form()->dump();

Ce qui nous affichera :

    POST http://asp07.photoprintit.de/microsite/customers/1156/live/templates/login_iframe.php [external_login_form]
      PHPSESSID=184c6840f65e07d7057c0171dcbfe886 (hidden readonly)
      external_login_user=Email      (text)
      external_login_pwd=Mot de passe (password)
      external_login[]=<UNDEF>       (image)

Remplir le formulaire est trivial :

    $m->set_fields(
        external_login_user => $email,
        external_login_pwd  => $password,
    );
    $m->submit();

Normalement, la page reçue en retour contient l'adresse email utilisée pour se connecter, confirmant que nous nous sommes correctement identifiés. On peut le vérifier en inspectant le contenu de $m->content(), comme ceci :

    print $m->content =~ /\Q$email\E/ ? "ok" : "nok";

Note : L'utilisation de \Q et \E, qui émulent quotemeta() à l'intérieur de l'expression régulière, permet d'empêcher l'interprétation par l'expression régulière des points et autres métacaractères que l'adresse email pourrait contenir.

Le seul problème, c'est que notre petit script de test affiche nok.

Pour comprendre ce qui se passe, nous allons utiliser notre proxy et tenter de nous identifier avec Firefox :

    POST http://asp09.photoprintit.de/microsite/customers/1156/live/templates/login_iframe.php
        Cookie: PHPSESSID=5b56e6e73c7a90624c0a2755248f250a
        Referer: http://asp07.photoprintit.de/microsite/1156/customers/1156/live/templates/login_iframe.php
        PHPSESSID                      => 5b56e6e73c7a90624c0a2755248f250a
        external_login_user            => *******
        external_login_pwd             => *******
        external_login[].x             => 0
        external_login[].y             => 0
        200 OK
        Content-Type: text/html; charset=utf-8

Et comparer avec notre robot :

    POST http://asp06.photoprintit.de/microsite/customers/1156/live/templates/login_iframe.php
        Cookie: PHPSESSID=bbf50583177dc9c43cc02dc39af4f397
        Cookie2: $Version="1"
        Referer: http://asp.photoprintit.de/1156/customers/1156/live/templates/login_iframe.php
        PHPSESSID                      => bbf50583177dc9c43cc02dc39af4f397
        external_login_user            => *******
        external_login_pwd             => *******
        200 OK
        Content-Type: text/html; charset=utf-8

Outre l'en-tête Cookie2, la différence principale c'est qu'avec le navigateur, nous avons cliqué sur l'image contenue dans le formulaire. Notre robot n'a pas renvoyé les champs external_login[].x et external_login[].y.

Heureusement, WWW::Mechanize permet également de simuler le clic sur une image :

    $m->click('external_login[]');

En utilisant cette commande au lieu de submit(), le robot envoie les champs attendus et notre script de test affiche ok, confirmant que nous avons réussi à nous authentifier sur le site !

Récupération du formulaire d'envoi des photos

Maintenant que nous sommes identifiés, nous allons déposer nos photos. Pour cela, il suffit de sélectionner l'interface « Netscape/Autre » sur la page principale.

Avec la méthode back(), nous allons revenir à la page principale (le robot est actuellement sur la page de réponse au formulaire d'identification).

    # après identification
    $m->back(); # retour au formulaire d'identification
    $m->back(); # retour à la page principale

    # on suit le lien vers l'interface HTML
    $m->follow_link( url_regex => qr/htmlclient/ );

    # affiche tous les formulaires de la page
    print $_->dump(), "\n" for $m->forms();

Voici les formulaires visibles :

    POST http://asp05.photoprintit.de/microsite/htmlclient.php?customerid=1156 [external_login_form]
      PHPSESSID=4a58ab74f9342a384a60e57c591e581d (hidden readonly)
      accountmode=1                  (hidden readonly)
      external_login_user=Email      (text)
      external_login_pwd=Mot de passe (password)
      external_login[]=<UNDEF>       (image)

    POST http://asp05.photoprintit.de/microsite/1156/htmlclient.php
      PHPSESSID=4a58ab74f9342a384a60e57c591e581d (hidden readonly)
      quantity=10                    (option)   [*10|20|30|40|50]
      <NONAME>=<UNDEF>               (image)

    POST http://asp05.photoprintit.de/microsite/1156/receive.php (multipart/form-data) [upload_form]
      PHPSESSID=4a58ab74f9342a384a60e57c591e581d (hidden readonly)
      UPLOAD_METER_ID=85582e6a2b60e80cc5339f7d809ff308 (hidden readonly)
      file0=                         (file)
      file1=                         (file)
      file2=                         (file)
      file3=                         (file)
      file4=                         (file)
      file5=                         (file)
      file6=                         (file)
      file7=                         (file)
      file8=                         (file)
      file9=                         (file)
      upload=<UNDEF>                 (image)

Avant de nous occuper du dernier formulaire, il nous faut remarquer un petit problème : le premier formulaire de la page est un formulaire d'identification, alors que nous sommes censés nous être identifiés précédemment. Que s'est-il passé ?

La réponse à cette question apparaît quand on demande au robot de nous afficher ses cookies :

    print $m->cookie_jar()->as_string();

Ce qui donne le résultat suivant :

    Set-Cookie3: PHPSESSID=4a58ab74f9342a384a60e57c591e581d; path="/"; domain=asp05.photoprintit.de; path_spec; discard; version=0
    Set-Cookie3: PHPSESSID=bbf50583177dc9c43cc02dc39af4f397; path="/"; domain=asp06.photoprintit.de; path_spec; discard; version=0

Nous avons deux cookies, l'un pour asp05.photoprintit.de et l'autre pour asp06.photoprintit.de.

En y regardant d'un peu plus près, on constate qu'effectivement, le formulaire d'identification a été récupéré sur asp06 et le formulaire d'envoi des photos sur asp05. Selon toute vraisemblance, il y a un système d'équilibrage de charge qui nous a redirigé une première fois vers asp06 et la seconde vers asp05. Seul le cookie associé à asp06 nous identifie.

Après vérification, ce problème se pose également avec Firefox quand on s'identifie dans le <iframe> affiché sur la page principale. Quand ensuite on choisit une commande de photos, la boîte d'identification attend encore notre e-mail et notre mot de passe.

La raison est très simple : le site vers lequel pointe la première page a une URL en asp.photoprintit.de. Quand on clique effectivement sur le lien, le site fait une redirection automatique vers l'un des sites asp01 à asp10. Notre proxy espion nous montre comment cela se passe :

    GET http://asp.photoprintit.de/
        302 Found
        Content-Type: text/html; charset=iso-8859-1
        Location: http://asp03.photoprintit.de/

Une fois connecté sur le site, tous les liens sont relatifs et on reste donc sur le même serveur.

Or l'iframe qui se trouve dans la page d'accueil du site PhotoStation pointe également vers le nom générique asp (vers http://asp.photoprintit.de/1156/customers/1156/live/templates/login_iframe.php, pour être précis). Le jeu des redirections fait donc qu'il est fort peu probable qu'une identification réalisée dans le formulaire de l'iframe du début soit valable sur le serveur qui traitera notre demande (1 chance sur 10 très exactement).

Heureusement, dès que l'on commence à aller chercher les pages sur photoprintit.de, le formulaire d'identification fait partie intégrante de page (ce n'est plus un iframe). Il nous suffit donc de remettre les lignes de notre script dans le bon ordre et d'aller d'abord sur le site du prestataire, puis de nous identifier.

    my $m = WWW::Mechanize->new;
    $m->get('http://www.photostation.fr/');

    # d'abord on va chez le fournisseur
    $m->follow_link( url_regex => qr/htmlclient/ );

    # puis on s'identifie
    $m->set_fields(
        external_login_user => $email,
        external_login_pwd  => $password,
    );
    $m->click( 'external_login[]' );

    print $_->dump(), "\n" for $m->forms();
    print $m->cookie_jar()->as_string();

Ceci a l'avantage de simplifier notre script : comme le formulaire d'identification fait partie de la page, il n'y a plus besoin de faire des back() pour revenir à la page qui nous intéresse.

Voici le résultat :

    POST http://asp07.photoprintit.de/microsite/htmlclient.php
      external_logout[]=<UNDEF>      (image)

    POST http://asp07.photoprintit.de/microsite/htmlclient.php
      quantity=10                    (option)   [*10|20|30|40|50]
      <NONAME>=<UNDEF>               (image)

    POST http://asp01.photoprintit.de/microsite/receive.php (multipart/form-data) [upload_form]
      UPLOAD_METER_ID=22a21519eca86d05620e69e709f7945d (hidden readonly)
      file0=                         (file)
      file1=                         (file)
      file2=                         (file)
      file3=                         (file)
      file4=                         (file)
      file5=                         (file)
      file6=                         (file)
      file7=                         (file)
      file8=                         (file)
      file9=                         (file)
      upload=<UNDEF>                 (image)

    Set-Cookie3: PHPSESSID=9d08c5f4bf02846ae8580c6642937e35; path="/"; domain=asp07.photoprintit.de; path_spec; discard; version=0

Le formulaire d'identification nous propose cette fois de nous déconnecter (prouvant ainsi que nous sommes identifiés), un formulaire permet de requérir plus de champs d'ajout de photo, et le dernier formulaire est celui qui permet de déposer les photos. Enfin, nous n'avons cette fois-ci qu'un seul cookie, associé au serveur qui va gérer toute notre transaction.

Il faut noter que le formulaire d'identification/déconnexion sera toujours le premier formulaire des pages à venir. Les formulaires que nous validerons désormais seront toujours le deuxième ou le troisième de la page.

Remplissage et validation du formulaire d'envoi des photos

Par défaut, le formulaire envoyé par le site propose 10 entrées pour des fichiers de photo, et un formulaire permet de sélectionner 20, 30, 40 ou 50 entrées. Si on veut envoyer plus de 10 photos, il faudra donc faire une requête supplémentaire pour charger le formulaire adéquat.

[Formulaire d'envoi des images]
Formulaire d'envoi des images

Ce formulaire est le deuxième de ceux renvoyés par la requête précédente :

    POST http://asp07.photoprintit.de/microsite/htmlclient.php
      quantity=10                    (option)   [*10|20|30|40|50]
      <NONAME>=<UNDEF>               (image)

En le regardant, on imagine sans peine que si on change la valeur du champ quantity pour y mettre une valeur quelconque, le serveur nous renverra une page avec le nombre de champs file demandé.

    $m->form_number(2);    # il s'agit du deuxième formulaire de la page
    $m->field( quantity => 7 );    # valeur non standard

On constate que HTML::Form respecte scrupuleusement les spécifications du formulaire et n'accepte pas de valeur non prévue. Notre script meurt en affichant le message suivant :

    Illegal value '7' for field 'quantity' at /usr/share/perl5/WWW/Mechanize.pm line 1030

Cela vient du fait que le champ quantity est défini comme étant de type <select> dans le source HTML du formulaire. Dans l'objet HTML::Form construit par WWW::Mechanize à partir du source HTML, cela se traduit par un objet de type HTML::Form::ListInput. C'est cet objet qui fait les vérifications et provoque l'erreur ci-dessus.

Qu'à cela ne tienne, nous n'avons qu'à décider qu'il s'agit en fait d'un champ de type text normal ! Pour cela, il suffit de changer l'idée que Perl se fait de cet objet, en changeant sa classe :

    bless $m->current_form()->find_input('quantity'), 'HTML::Form::TextInput';

Le champ quantity acceptera désormais n'importe quelle valeur et l'enverra au serveur lors de la validation du formulaire.

Soit @photos le tableau contenant la liste des fichiers à envoyer. Nous pourrons donc étendre le formulaire si le besoin s'en fait sentir :

    if ( @photos > 10 ) {
        $m->form_number(2);
        bless $m->current_form()->find_input('quantity'),
          'HTML::Form::TextInput';
        $m->field( quantity => scalar @photos );
        $m->click;
    }

Notre script pourra donc envoyer autant d'images que nous le souhaitons via un seul formulaire.

Nous pouvons maintenant remplir ce formulaire sans difficulté :

    # sélectionne le 3ème formulaire de la page
    $m->form_number(3);

    # ajoute les photos au formulaire
    $m->set_fields( "file$_" => $photos[$_] ) for 0 .. @photos - 1;

Indicateur de progression et optimisation de la mémoire

Une photo d'assez bonne qualité pour être imprimée pèse entre 1,5 et 2 mégaoctets. Même pour une dizaine de photos, ça veut dire envoyer pas loin 16 Mo en remontant à travers la liaison ADSL.

[La fenêtre annexe de téléchargement]
La fenêtre annexe de téléchargement

Pendant le téléchargement des photos, le site envoie une popup (associée à un identifiant unique) rafraîchie régulièrement qui donne l'état d'avancement du transfert.

Dans notre script, si nous appellons directement la méthode submit(), le script ne récupèrera la main qu'à la fin du téléchargement c'est-à-dire au bout d'une dizaine de minutes sur une ligne à 30 ko/s. Il nous faut donc donner la possibilité au script de nous indiquer son avancement, sans quoi il nous sera impossible de savoir ce qui se passe et on risque de l'interrompre à tort. De plus, nous ne voulons pas charger 16 Mo de photos en mémoire juste pour les envoyer sur le réseau !

Heureusement, la libraire LWP sait justement aussi produire des requêtes HTTP::Request qui envoient beaucoup de données au serveur sans tout charger en mémoire (merci Gisles !). Et WWW::Mechanize, en bonne sous-classe de LWP::UserAgent, sait les utiliser.

Habituellement, un objet HTTP::Request associé à un POST contient un paramètre accessible par la méthode content(). Ceci permet d'avoir accès (en lecture et en écriture) au contenu du corps de la requête avant de l'envoyer. Pour les requêtes volumineuses, il est prévu que cet attribut puisse être une fonction de rappel qui renvoie les données morceaux par morceaux. Ainsi, la mémoire occupée reste raisonnable.

HTTP::Request::Common, la classe de base de HTTP::Request sait construire automatiquement la fonction en question quand on veut envoyer des fichiers au serveur. Il suffit pour cela de mettre la variable globale HTTP::Request::Common::DYNAMIC_FILE_UPLOAD à une valeur vraie.

C'est la méthode make_request() de HTML::Form qui produit l'objet HTTP::Request attendu.

    my $req;
    {   
        no warnings;
        local $HTTP::Request::Common::DYNAMIC_FILE_UPLOAD = 1;
        $req = $m->current_form()->make_request;
    }

Note : no warnings évite que l'avertissement Name "HTTP::Request::Common::DYNAMIC_FILE_UPLOAD" used only once: possible typo soit affiché.

Ainsi, pour réaliser un petit affichage qui va nous faire patienter pendant le téléchargement, il suffit de remplacer la routine en question par une autre qui réalise un affichage. Cette nouvelle routine devra évidemment renvoyer les données initialement renvoyées par celle générée par HTTP::Request::Common, afin de ne pas modifier la requête.

Nous allons ici nous contenter d'afficher un pourcentage d'avancement. Pour cela, nous utilisons la longueur totale de la requête telle que calculée par HTTP::Request::Common (et accessible depuis l'objet HTTP::Request via la méthode content_length()), et nous calculons la taille des données déjà envoyées grâce à une petite addition. Notre code devient :

    my $req;
    {
        no warnings;
        local HTTP::Request::Common::DYNAMIC_FILE_UPLOAD = 1;
        $req = $m->current_form()->make_request();

        # récupère la routine générée par HTTP::Request::Common
        my $sub   = $req->content();
        my $done  = 0;
        my $total = $req->content_length();
        $req->content(
            sub {
                my $data = $sub->();    # appelle la routine générée
                return unless defined $data;

                $done += length $data;
                printf "\rTransfert: %2d%%", $done / $total * 100;
                return $data;
            }
        );
    }

Note : Le no warnings évite en plus ici l'affichage d'un avertissement Use of uninitialized value in length quand $data vaut undef en fin de lecture des fichiers.

Pour que l'affichage se fasse correctement (nous utilisons \r), il ne faut pas oublier de passer la sortie standard en mode non tamponné (unbuffered). La méthode la plus lisible pour le faire est :

    use IO::Handle;
    STDOUT->autoflush(1);

Il ne reste plus qu'à passer la requête à WWW::Mechanize pour qu'il envoie effectivement les photos et qu'on puisse passer à la suite du traitement.

Sélection du format des photos

Dans le cadre de cet article, nous nous intéressons seulement à la commande d'un lot de photos toutes dans le même format.

La page suivante nous propose donc le formulaire suivant pour sélectionner le format de nos tirages :

    POST http://asp07.photoprintit.de/microsite/1156/formats.php
      orderdata[4]=0                 (text)
      orderdata[5]=1                 (text)
      orderdata[11]=0                (text)
      orderdata[6]=0                 (text)
      album=0                        (hidden readonly)
      album=<UNDEF>                  (checkbox) [*<UNDEF>/off|1/ ]
      order[]=<UNDEF>                (image)
      customcopies=1                 (text)
      customformatid_4[]=4           (image)
      customformatid_5[]=5           (image)
      customformatid_11[]=11         (image)
      customformatid_6[]=6           (image)
      custom[]=<UNDEF>               (image)
[Sélection du format]
Sélection du format

En comparant avec le formulaire visualisé par notre navigateur, nous voyons que pour le cas qui nous occupe (commande express de photos toutes au même format), seuls les quatre premiers champs nous intéressent.

La valeur dans le champ correspond au nombre de tirages de chaque taille qui sera fait pour chaque photo. En ce qui concerne les formats, on vérifie avec le source HTML que le champ orderdata[4] correspond au format 9x11, orderdata[5] au format 10x13, orderdata[11] au format 11x15 et orderdata[6] au 13x17. Pour connaitre le nombre total de photos, il faut donc multiplier la somme des valeurs de ces champs par le nombre total de photos.

Une fois le format sélectionné, on clique sur le bouton « Commande Express » qui correspondant au champ order[] du formulaire.

À noter que même si on prend le choix par défaut, il est préférable de le sélectionner nous-même, pour le cas où le défaut changerait dans le futur (évitons les mauvaises surprises).

    $m->form_number(2);
    $m->set_fields(
        'orderdata[4]'  => 0,    #  9x11
        'orderdata[5]'  => 1,    # 10x13
        'orderdata[11]' => 0,    # 11x15
        'orderdata[6]'  => 0,    # 13x17
    );
    $m->click('order[]');

Choix du mode de livraison

Une fois le format validé, il reste à choisir le mode de livraison. Afin de ne pas nous embarquer dans la gestion de paiement en ligne (et pour nous éviter des frais supplémentaires), nous sélectionnons la « livraison en magasin ».

[Choix du mode de livraison]
Choix du mode de livraison

C'est le formulaire suivant qui nous le propose :

    POST http://asp07.photoprintit.de/microsite/1156/cart.php [cartform]
      transfer_type=FC               (radio)    [FM/  Livraison à domicile (Payant)|*FC/  Livraison en magasin  (Gratuit)]
      ok[]=<UNDEF>                   (image)
      startorder[]=<UNDEF>           (image)
      couponid=                      (text)
      ok=<UNDEF>                     (image)

Nous maîtrisons complètement l'opération, désormais :

    $m->form_number(2);
    $m->field( transfer_type => 'FC' );    # Livraison en magasin
    $m->click('startorder[]');             # bouton "Confirmer ma commande"

Nous sommes presque au bout de nos peines.

Sélection du magasin où les photos seront livrées

Pour pouvoir aller récuperer nos photos une fois développées, nous devons choisir dans quel magasin nous irons. Ce nouvel écran nous propose une recherche du magasin par code postal ou ville, ou la sélection directe par une liste déroulante.

[Formulaire de sélection du magasin validation]
Formulaire de sélection du magasin et validation
    POST https://asp07.photoprintit.de/microsite/confirm.php [payment]
      search_zip=                    (text)
      search_city=                   (text)
      search[]=<UNDEF>               (image)
      loc_id=                        (hidden readonly)
      loc_id=                        (option)   [*/SELECTIONNEZ VOTRE MAGASIN CI-DESSOUS|29733/PHOTO STATION, 18 Rue Du Mal Joffre, 01000 Bourg en Bresse|29734/PHOTO STATION, C.Cial Du Fayet, 02100 Saint-Quentin|...
      accept=<UNDEF>                 (checkbox) [*<UNDEF>/off|on/ J'accepte les conditions générales de vente. ]
      startorder[]=<UNDEF>           (image)

Dans le cas de notre script, nous avons juste besoin de connaître l'identifiant numérique du magasin qui nous intéresse. On peut l'obtenir simplement à partir des données dans le formulaire.

    # trouve le champ de HTML::Form qui contient la liste des magasins
    $m->form_number(2);
    my $input = $m->current_form()->find_input( 'loc_id', 'option' );

    # utilisation d'une tranche de hash
    my %cities;
    @cities{ $input->possible_values } = $input->value_names;

    # affichage de la liste, par ordre des identifiants
    print "$_\t$cities{$_}\n" for sort keys %cities;

Il suffit de sauver la sortie du script dans un fichier. C'est ainsi que je trouve la boutique la plus proche de chez moi dans la liste des 288 magasins :

    ...
    29620   PHOTO STATION, 25 Rue Des Boulangers, 68000 Colmar
    29621   PHOTO STATION, 53 Rue Du Sauvage, 68100 Mulhouse
    29622   PHOTO STATION, 47 Rue Victor Hugo, 69002 Lyon
    29623   PHOTO STATION, 45 Rue De La Republique, 69002 Lyon
    29624   PHOTO STATION, Centre Cial Part Dieu, 69003 Lyon
    ...

Dans le script final présenté en fin d'article, une option a été ajoutée pour aller uniquement chercher la liste des magasins sur le site, en envoyant une petite image et en coupant la transaction avant la confirmation finale.

Validation

Jusqu'à présent quand le script s'arrêtait, la commande n'était pas validée, et les photos restaient donc sur un coin de disque dur sur le serveur (en attendant qu'une tâche planifiée ne les efface).

[La plus belle nièce du monde]
La plus belle nièce du monde

Cette fois-ci, nous sommes identifiés, il y a des photos et si on valide, il faudra aller les chercher chez le marchand. :-) Avant de valider pour nos tests finaux, nous allons donc choisir des photos que nous voulons vraiment développer, et pas juste de petits fichier images pour économiser la bande passante (et accélerer les tests). J'ai choisi pour ce test une photo de la plus belle nièce du monde. ;-)

Arrivé à la page de confirmation, il faut cocher la case « J'accepte les conditions générales de vente. » et cliquer sur le bouton « Confirmer ma commande ».

La case à cocher correspond au champ HTML suivant :

     <td><input type="checkbox" name="accept"></td>

La méthode de WWW::Mechanize pour cocher les cases s'appelle tick() et nécessite de connaître la valeur associée à chaque case à cocher. Ici, comme on peut le voir, il n'y a qu'une case avec aucune valeur associée. Heureusement, le dump du formulaire nous a permis de voir quelle valeur HTML::Form associe par défaut à une balise <input> de type checkbox sans paramètre value :

          accept=<UNDEF>                 (checkbox) [*<UNDEF>/off|on/| J'accepte les conditions générales de vente.| ]

Nous devrons donc cocher la case on.

    $m->field( loc_id => $magasin );
    $m->tick('accept', 'on');
    $m->click('startorder[]');

Avant de cocher automatiquement la case accept, je vous engage au moins à survoler les conditions générales de vente (disponibles à l'adresse http://asp.photoprintit.de/microsite/1156/terms.php). Celles-ci indiquent en effet que vous renoncez à votre droit de rétraction, pour la simple raison que les produits seront déjà fabriqués (et livrés) avant la fin du droit de rétractation. Autrement dit, si vous faites des tests qui vont jusqu'à la confirmation finale, prévoyez comme moi d'aller chercher les photos. :-)

Après validation, nous recevons une page qui contient le texte suivant :

     <td align="right" colspan="3">
          <span class="error">Veuillez s&eacute;lectionner un magasin.</span><br><br>
     </td>
    </tr>
    <tr>
     <td><input type="checkbox" name="accept" checked></td>

Nous pouvons constater que nous avons bien coché la case concernant les conditions générales de vente. En revanche, la sélection du magasin ne s'est pas passée comme prévu.

Nous allons ajouter le code suivant pour détecter et afficher les erreurs, avant de recommencer nos essais :

    if ( $m->content() =~ m!<span class="error">(.*?)</span>! ) {
        die "Erreur lors de la confirmation : $1";
    }

Si on regarde plus attentivement le formulaire, on voit que le champ loc_id est en fait présent de deux façons dans le formulaire : comme un champ texte caché et comme une liste. WWW::Mechanize fournit la méthode select() qui simplifie la gestion des champs de type select/option. Nous allons donc essayer de sélectionner le magasin avec :

    $m->select( loc_id => $magasin );

Cette fois, c'est WWW::Mechanize qui affiche un avertissement Input "loc_id" is not type "select". Et notre script affiche l'erreur Erreur lors de la confirmation : Veuillez s&eacute;lectionner un magasin..

Le problème est le suivant : nous avons deux champs qui portent le même nom. HTML::Form nous laisse remplir le premier champ loc_id (de type hidden) et envoie alors comme corps de la requête search_zip=&search_city=&loc_id=29729&loc_id=. Le serveur à l'autre bout doit probablement écraser la première valeur (29729) avec la seconde (vide). Lors de notre deuxième tentative (avec select()), le problème est cette fois que le premier champ nommé loc_id trouvé par WWW::Mechanize n'est pas de type select mais de type readonly.

Pour envoyer une requête correcte au serveur nous devons donc mettre à jour le second champ loc_id. Nous allons d'abord devoir le sélectionner avec la méthode find_input() de HTML::Form, puis le remplir avec la méthode value() (c'est un objet de type HTML::Form::Input) :

    $m->current_form()->find_input( 'loc_id', 'option' )->value( $CONF{shop} );

Cette fois-ci tout se passe (enfin !) comme prévu, et nous recevons non pas un 302 (que WWW::Mechanize aurait suivi tout seul) mais la page suivante (remise en forme pour des raisons de lisibilité) :

    <html>
    <head>
      <meta http-equiv="refresh" content="0;http://asp07.photoprintit.de/microsite/summary.php" />
      <script type="text/javascript">window.location.replace("http://asp07.photoprintit.de/microsite/summary.php")</script>
    </head>
    <body>
      <a href="http://asp07.photoprintit.de/microsite/summary.php">Klicken Sie hier,
      falls Ihr Browser Sie nicht automatisch weiterleitet</a>
    </body></html>

Babelfish traduit grossièrement le texte en allemand par « cliquetez ici, si votre Browser ne vous transmet pas automatiquement », ce que nous traduirons en Perl par :

    $m->follow_link( url_regex => qr/summary/ );

Une fois le lien suivi, une page résumant la transaction est affichée. Les champs utiles à récupérer sont le numéro de commande et le numéro de client (vous connaissez normalement l'adresse du magasin !), que l'on peut récupérer tous les deux avec une seule expression régulière (à condition d'être bien sûr de l'ordre dans lequel ils apparaissent dans la page).

Le HTML en question ressemble à ceci (la page envoyée est en UTF-8) :

    <p>
     N° de commande:&nbsp;235484</p>
    <p>
       <table border="0" cellpadding="4" cellspacing="0">
        <tr>
              <td>

          <h2>La livraison s'effectuera au magasin :</h2>
         </td>
             </tr>
        <tr>
         <td>
          <b>PHOTO STATION (Numéro de client: 813023)<br>47 Rue Victor Hugo<br>69002 Lyon<br></b>
         </td>

Et l'expression régulière qui nous capture toutes les informations d'un coup est la suivante (en évitant soigneusement les caractères non-ASCII) :

    my ( $commande, $client ) =
      $m->content =~ /(?:commande:\&nbsp;|client: )(\d+)/g;

Vous recevrez de toute façon un email de confirmation avec le numéro de commande et l'adresse du magasin (mais pas le numéro de client) dans votre boîte aux lettres.

Script complet

Le script suivant est fourni avec de multiples options de ligne de commande qui n'ont pas été décrites dans l'article.

Notez en particulier l'option --list qui se contentera d'envoyer une image PNG de 89 octets (soyons gentils avec les disques durs du fournisseur) et d'aller jusqu'au formulaire de sélection du magasin pour nous afficher la liste complète. Sauvez cette liste dans un coin de votre ~ ou au moins le numéro du magasin le plus près de chez vous. Cette technique d'envoi direct d'une image montre qu'on n'a pas besoin de passer par des fichiers sur le disque pour envoyer des « fichiers » à un serveur web.

L'option --find accepte une expression régulière pour trouver une sous-liste de magasins correspondants dans la liste ainsi obtenue.

Note : Dans le code, on utilise qr/(?=)/ comme valeur pour $re afin d'avoir une expression régulière qui correspond avec n'importe quelle chaîne (qr/.*/ fonctionnerait tout aussi bien). L'utilisation de qr// pose en effet problème car il s'agit une écriture particulière (tout comme //) : quand l'expression régulière est une chaîne vide, Perl utilise la dernière expression régulière qui a établi une correspondance avec succès.

Si ni --list ni --find ne sont pas fournies, le script insistera pour voir l'option --shop suivie du numéro d'un magasin. Et ira jusqu'au bout de la commande.

Le hash %CONF est fait pour que vous y mettiez vos valeurs par défaut (en particulier pour email, password et shop).

    #!/usr/bin/perl
    use strict;
    use warnings;
    use Getopt::Long;
    use WWW::Mechanize;
    use IO::Handle;

    # pas de tampon sur STDOUT
    STDOUT->autoflush(1);

    # options par défaut
    my %CONF = (
        email    => '',    # À remplacer par vos identifiant,
        password => '',    # mot de passe
        shop     => '',    # et magasin préféré
        find     => '',
        list     => 0,
    );

    # message d'information
    my $USAGE = << 'USAGE';
    Options disponibles:
      --email    <email>
      --password <password>
      --shop     <numéro magasin>
      --find     <regexp>
      --list
    USAGE

    GetOptions( \%CONF, "email=s", "password=s", "list!", "shop=i", "find=s",
        "help!" )
      or die $USAGE;

    # divers cas d'erreur
    $CONF{$_} or die "$_ requis\n$USAGE" for qw( email password );
    die "Au moins un paramètre --shop ou --find ou --list requis\n$USAGE"
      unless $CONF{shop} || $CONF{find} || $CONF{list};
    die "Au moins une image requise\n$USAGE" if $CONF{shop} && @ARGV == 0;
    die $USAGE                               if $CONF{help};

    # connexion à la page principale
    my $m = WWW::Mechanize->new;
    $m->get('http://www.photostation.fr/index.php');

    # client HTML
    $m->follow_link( url_regex => qr/htmlclient/ );

    # formulaire d'identification
    $m->set_fields(
        external_login_user => $CONF{email},
        external_login_pwd  => $CONF{password},
    );
    $m->click('external_login[]');
    die "Échec de l'identification\n" if $m->content !~ /\Q$CONF{email}\E/;

    # les noms des photos sont dans @ARGV
    if ( $CONF{shop} ) {
        if ( @ARGV > 10 ) {
            $m->form_number(2);
            bless $m->current_form()->find_input('quantity'),
              'HTML::Form::TextInput';
            $m->field( quantity => scalar @ARGV );
            $m->click;
        }

        $m->form_number(3);
        $m->set_fields( "file$_" => $ARGV[$_] ) for 0 .. @ARGV - 1;

        my $req;
        $|++;
        {
            no warnings;
            local $HTTP::Request::Common::DYNAMIC_FILE_UPLOAD = 1;
            $req = $m->current_form()->make_request;
            my $content_sub = $req->content();
            my $done        = 0;
            my $total       = $req->content_length;
            $req->content(
                sub {
                    my $data = $content_sub->();
                    return unless defined $data;

                    $done += length $data;
                    printf "\rTransfert: %2d%% (%d/%d)", $done / $total * 100,
                      $done, $total;
                    return $data;
                }
            );
        }

        # envoi des photos
        $m->request($req);
        print "\n";
    }
    else {

        # envoi d'une image bidon (blanc 10x10)
        $m->form_number(3);
        my $input = $m->current_form()->find_input('file0');
        $input->content(
            pack( 'H*',
                    '89504e470d0a1a0a0000000d494844520000000a0000000a0103000000b7'
                  . 'fc5dfe00000006504c5445000000ffffffa5d99fdd0000000e4944415478'
                  . 'da63f87f8001370200075211774b8b03d80000000049454e44ae426082' )
        );
        $input->filename('blank10x10.png');
        $input->headers( content_type => 'image/png' );
        $m->click;
    }

    die "Échec de l'envoi des images : " . $m->res()->status_line()
      unless $m->success();

    # taille par défaut
    # force le format 10x13
    $m->form_number(2);
    $m->set_fields(
        'orderdata[4]'  => 0,    #  9x11
        'orderdata[5]'  => 1,    # 10x13
        'orderdata[11]' => 0,    # 11x15
        'orderdata[6]'  => 0,    # 13x17
    );
    $m->click('order[]');
    die "Échec de validation du format : " . $m->res()->status_line()
      unless $m->success();

    # choix de la livraison en magasin
    $m->form_number(2);
    $m->field( transfer_type => 'FC' );
    $m->click('startorder[]');
    die "Échec de validation de la livraison : " . $m->res()->status_line()
      unless $m->success();

    # liste des magasins
    $m->form_number(2);

    if ( $CONF{shop} ) {

        # validation finale
        $m->current_form()->find_input( 'loc_id', 'option' )->value( $CONF{shop} );
        $m->tick( 'accept', 'on' );    # accepte les conditions générales de vente
        $m->click('startorder[]');     # validation
        die "Échec de la confirmation finale : " . $m->res()->status_line()
          unless $m->success();

        # détection d'éventuelles erreurs
        if( $m->content() =~ m!<span class="error">(.*?)</span>! ) {
            die "Erreur lors de la confirmation : $1";
        }

        # suit la redirection
        $m->follow_link( url_regex => qr/summary/ );
        die "Échec de récupération du bilan : " . $m->res()->status_line()
          unless $m->success();

        # affichage des informations de commande
        print $m->content();
        my ( $commande, $client ) =
          $m->content() =~ /(?:commande:\&nbsp;|client: )(\d+)/g;

        print "Numéro de client:   $client\n", "Numéro de commande: $commande\n";
    }
    else {
        my %cities;
        my $input = $m->current_form()->find_input( 'loc_id', 'option' );
        @cities{ $input->possible_values() } = $input->value_names();

        # recherche et affichage des éléments qui correspondent
        my $re = $CONF{find} ? qr/$CONF{find}/i : qr/(?=)/;
        print map { "$_\t$cities{$_}\n" }
              grep { $cities{$_} =~ $re }
              keys %cities;
    }

Considération sur la généralisation de ce script

Nous avons vu que le site web d'envoi des photos est en réalité géré par photoprintit.de. Des URL que nous avons manipulées, il n'est pas difficile de déduire que l'identifiant de Photo Station est le 1156.

L'uniligne suivant va nous permettre de décrouvrir la liste des identifiants valides pour les autres clients de photoprintit.de :

    $ perl -MLWP::Simple -le '(get("http://asp.photoprintit.de/microsite/$_/htmlclient.php") ne "Unknown customer id" ) && print "ok $_" for 1 .. 8000'
    ok 1
    ok 5
    ok 8
    ok 10
    ok 13
    ...

Ce script s'appuie sur le fait que la page renvoie Unknown customer id si on essaye de passer un identifiant invalide dans l'URL.

Cette recherche sur les 8000 premiers identifiants en a renvoyé 434 valides. Autrement dit, si nous arrivons à faire un module générique, il pourra servir pour chacun des 434 magasins de photo en ligne (la majeure partie d'entre eux sont des sites en allemand ou en hollandais).

Le code que nous avons produit est déjà très générique : nulle part nous n'avons utilisé autre chose que les noms des champs ou les URL pour nous déplacer de page en page.

Auteur

Philippe "BooK" Bruhat, membre de Paris.pm & Lyon.pm, est l'auteur de HTTP::Proxy et le co-traducteur avec Jean Forget de Perl Best Practices, à paraître chez O'Reilly France.

Merci à David Morel (de Lyon.pm) de m'avoir fourni son script de connexion à eBay, à partir duquel j'ai développé ma propre version décrite dans cet article.

Merci à Michel Grafmeyer et aux mongueurs de Perl pour leur travail de relecture.

Comme pour l'article précédent, les deux scripts (eBay et PhotoStation) sont fournis sur le CD. Ils sont également en ligne aux adresses http://articles.mongueurs.net/magazines/webrobot-02/ebay-zero.pl et http://articles.mongueurs.net/magazines/webrobot-02/photostation.pl.

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