Article publié dans Linux Magazine 75, septembre 2005.
Copyright © 2005 - Philippe Bruhat
À la suite de mes articles présentant la librairie LWP dans Linux Magazine 56 à 58, cette nouvelle série décrit l'utilisation de divers outils Perl et techniques d'analyse des sites pour construire des robots qui vont naviguer sur le web à notre place.
On trouve sur le web de nombreux sites d'information, de recherche ou de vente. Le navigateur web et le protocole HTTP ne sont que l'interface permettant d'obtenir l'information souhaitée.
Dans de nombreux cas, on souhaite effectivement accéder à cette information, mais le navigateur web est un obstacle. Il nécessite la présence d'un utilisateur pour entrer l'URL, remplir les formulaires, cliquer sur les liens. Cette série d'articles va vous présenter, par l'exemple, des techniques d'analyse des sites pour construire de petits programmes (des robots !) qui vont, en fonction de divers paramètres, aller chercher l'information seuls, et même réagir et naviguer plus avant en fonction de l'information récupérée.
Au fur et à mesure de ces articles, nous allons visiter des sites de plus en plus complexes, construire des robots de plus en plus évolués, pour finir par produire des modules qui nous permettront de réutiliser un moteur de récupération d'information.
Pour écrire nos robots, nous avons à notre disposition de nombreux outils. Je vous présente ici la liste de ceux que contient ma boîte à outils personnelle :
WWW::Mechanize
Cette librairie Perl encapsule LWP::UserAgent
pour gérer toute la
partie navigation et gestion des formulaires.
Pour une présentation plus complète de WWW::Mechanize
, je
vous renvoie à l'article LWP, Le Web en Perl (3), paru
dans Linux Magazine 58 et disponible en ligne à l'adresse :
http://articles.mongueurs.net/magazines/linuxmag58.html.
WWW::Mechanize
se comporte comme un client de haut niveau : il
gère par défaut les cookies, l'en-tête Referer
, l'historique des
pages visitées, etc. Cela fait autant de code en moins à écrire.
Ce script fourni avec WWW::Mechanize
, affiche le contenu des
formulaires, la liste des images et des liens d'une page web donnée
(obtenue avec un simple GET
HTTP).
Nous verrons qu'il faut s'y prendre autrement quand la page que l'on veut lister n'est pas accessible directement (par exemple s'il faut une authentification préalable) ou si l'on veut accéder à d'autres informations comme les cookies ou les en-têtes.
HTTP::Proxy
J'utilise un proxy maison, qui s'appuie sur mon module HTTP::Proxy
(disponible sur CPAN),
pour extraire les informations intéressantes d'un échange HTTP.
Très utile pour espionner l'activité d'un bout de JavaScript ou de
Flash.
Disposer d'un navigateur couplé à un proxy espion permet de comparer l'activité d'un « vrai » utilisateur avec celle du script en cours d'élaboration.
L'extension Web Developer de Firefox permet d'avoir accès aux en-têtes envoyés par le serveur, ainsi que de mettre en valeur certaines parties du HTML. La simple commande View Source associée à l'outil de recherche peut s'avérer très utile.
LiveHTTPheaders est une autre extension utile pour l'analyse des échanges HTTP : elle permet, dans un onglet de Firefox, de voir les en-têtes des requêtes et réponses voulues (grâce à des expressions régulières de filtrage et d'exclusion) et même de rejouer les requêtes de son choix.
HTML::Parser
et HTML::TreeBuilder
La navigation sur certains sites nécessite parfois une analyse poussée du HTML.
L'outil de base pour cette analyse est HTML::Parser
, un analyseur de
HTML par événements. À chaque événement (ouverture/fermeture de balise, etc.)
on peut associer un gestionnaire d'événement (handler) qui va effectuer
un traitement particulier.
Il est souvent plus simple de considérer le document HTML comme un arbre
et de naviguer dans les branches de l'arbre. HTML::TreeBuilder
produit
un tel arbre à l'aide de HTML::Parser
et fournit de nombreuses méthodes
de recherche, de navigation et d'élagage dans l'arbre.
Parfois, il faut redescendre au plus bas niveau. Dans ces cas-là, ngrep (network grep) est vraiment l'outil le plus adapté.
Mon seul outil personnel dans la liste présentée précédemment est le
proxy basé sur HTTP::Proxy
. Il est disponible dans la distribution
de HTTP::Proxy, sous le nom eg/logger.pl. Le code du proxy est également
fourni sur le CD, avec le code des trois scripts de ce mois.
Le code de ce proxy n'est pas expliqué en détail ici, car il sort du cadre de cet article. Il s'agit d'un outil d'aide à l'analyse des interactions client/serveur HTTP, dont nous expliquons ci-après l'utilisation.
Pour chaque couple requête/réponse, nous verrons :
La requête (GET
, POST
, etc.), et les en-têtes intéressants (par défaut
Cookie
, Cookie2
, Referer
, Referrer
, Authorization
),
La ligne de statut de la réponse (200 OK
, 302 Found
, etc.) et les
en-têtes intéressants (par défaut Content-Type
, Set-Cookie
,
Set-Cookie2
, Location
, WWW-Authenticate
).
Les couples clé/valeur passés dans le corps d'une requête POST
.
Le proxy accepte comme paramètres en plus des paramètres standards de
HTTP::Proxy
les paramètres suivants :
Expression régulière pour déterminer le site à espionner ;
En-tête supplémentaire à afficher (dans la requête et la réponse).
Chaque paramètre peut être répété autant que nécessaire. Si aucun paramètre
peek
n'est donné, le proxy espionnera tous les sites visités.
À titre d'exemple, voici le résultat de la demande de http://www.google.com/ par mon client web :
$ ./logger.pl peek 'google\.\w+$' GET http://www.google.com/ 302 Found Content-Type: text/html Set-Cookie: PREF=ID=50559ac18bae0f57:CR=1:TM=1119132978:LM=1119132978:S=Px8CAVLCC5FoR1NK; expires=Sun, 17-Jan-2038 19:14:07 GMT; path=/; domain=.google.com Location: http://www.google.fr/cxfer?c=PREF%3D:TM%3D1119132978:S%3Dwpjw70CuTrboKsrd&prev=/ GET http://www.google.fr/cxfer?c=PREF%3D:TM%3D1119132978:S%3Dwpjw70CuTrboKsrd&prev=/ 302 Found Content-Type: text/html Set-Cookie: PREF=ID=e2b4582bd0c2849e:LD=fr:TM=1119132978:LM=1119132978:S=keTI_KO9ZyhHypD3; expires=Sun, 17-Jan-2038 19:14:07 GMT; path=/; domain=.google.fr Location: http://www.google.fr/ GET http://www.google.fr/ Cookie: PREF=ID=e2b4582bd0c2849e:LD=fr:TM=1119132978:LM=1119132978:S=keTI_KO9ZyhHypD3 200 OK Content-Type: text/html
Voici le résumé de ce que nous observons :
La requête vers http://www.google.com/ provoque l'envoi d'un cookie
par le site et une redirection (302
) vers http://www.google.fr/
(mon IP m'a trahi et a permis à Google de deviner que je me connectais
depuis la France).
On remarque que le cookie, qui est associé au domaine google.com
est
également passé en paramètre à la requête vers http://www.google.fr/.
google.fr
renvoie le même cookie que google.com
, cette fois associé
au domaine google.fr
. Google s'assure ainsi qu'un utilisateur aura
le même cookie sur tous ses sites (au moins dans le cas de ce type de
redirection géographique).
Il y a une nouvelle redirection, cette fois vers http://www.google.fr/, sans paramètres cachés, puisque le cookie est en place.
Ce proxy nous sera très utile lors de la création de nos robots.
Tout au long de cette série d'articles, je vais construire des robots pour automatiser la consultation de divers sites que j'utilise couramment. À chaque fois, j'ai choisi des sites qui correspondent à mon utilisation du web, et à mes fournisseurs.
Il ne s'agit pas de publicité gratuite, juste de la réalité de mon activité sur le web. D'ailleurs certains n'apprécieraient peut-être pas de savoir que je navigue sur leur site sans voir les pubs de leurs partenaires, ou que les cookies qu'ils se donnent tant de mal à m'envoyer sont perdus dès que les scripts se terminent...
Le but de cette série d'articles est de vous permettre, en utilisant les mêmes techniques et outils, de pouvoir à votre tour automatiser votre navigation sur le web selon vos besoins. Nous nous confronterons à des sites de plus en plus complexes au fur et à mesure de notre progression.
Maintenant que nous avons nos outils en main, nous allons pouvoir commencer...
Sur IRC, il est assez mal vu de coller sur le canal de larges extraits de code : on inonde le canal pour rien (ce qui est mal en soi), et pour peu que la discussion soit animée, le code va défiler rapidement rendant sa lecture impossible par d'éventuelles bonnes âmes.
La solution a été d'utiliser le web comme presse-papier mondial : il existe un certain nombre de sites de paste (collage) où un newbie qui a besoin d'aide peut coller son code (certains sites font même un peu de coloriage de code) et donner l'URL à ceux qui souhaitent l'aider. Une seule ligne est postée sur le canal, et ceux qui veulent donner un coup de main peuvent lire tranquillement le code sur leur navigateur ou le copier/coller (sans les pseudos et l'horodatage de leur client IRC) pour le tester localement.
Il existe même des « paste-bots » qui informent un canal choisi de l'ajout de nouvelles entrées sur le site.
Voici quelques sites de paste :
La faiblesse de ces sites, c'est qu'il faut passer par un navigateur web pour publier ces petits bouts de textes, ce qui ralentit énormément le processus. Par exemple, si j'ai un problème avec un script et que je veux montrer un bout de code sur un canal IRC, je vais devoir ouvrir mon éditeur, copier le contenu du fichier, ouvrir mon navigateur, coller le code (éventuellement en plusieurs fois selon mon éditeur), valider le formulaire, copier l'URL de la page renvoyée en retour et enfin coller cette URL dans le canal IRC.
Un script en ligne de commande prendrait en paramètre le fichier à coller, et afficherait l'URL à coller dans le client IRC, tout simplement.
J'ai choisi d'automatiser le site http://rafb.net/paste/, car c'est celui que j'utilise le plus souvent.
Le site propose un formulaire avec cinq champs : Language, Nickname, Description, Convert tabs et bien sûr une zone de texte pour coller son code. mech-dump va nous donner le nom des champs du formulaire HTML en un tournemain :
$ mech-dump http://rafb.net/paste/ POST http://rafb.net/paste/paste.php lang=C89 (option) [*C89/C (C89)|C/C (C99)|C++|C#|Java|Pascal|Perl|PHP|PL/I|Python|Ruby|SQL|VB/Visual Basic|Plain Text] nick= (text) desc= (text) cvt_tabs=No (option) [*No|2|3|4|5|6|8] text= (textarea) <NONAME>=Paste (submit)
Notre script va commencer par charger les modules requis, puis définir les valeurs par défaut et récupérer les options sur la ligne de commande :
#!/usr/bin/perl -w use strict; use WWW::Mechanize; use Getopt::Long; my %CONF = ( lang => 'Plain Text', nick => 'A. Nonyme', desc => '', cvt_tabs => 'No', text => '', ); GetOptions( \%CONF, "lang=s", "nick=s", "desc=s", "cvt_tabs|tabs=i", "text=s" ) or die "Bad options";
Nous créons ensuite l'objet WWW::Mechanize
.
Note : Tout au long de cet article, notre robot sera contenu dans
la variable $m
(pour mech, le surnom donné à WWW::Mechanize par son
auteur Andy Lester).
Si la description n'est
pas fournie dans les options de ligne de commande, et qu'un nom de fichier a
été passé sur la ligne de commande, c'est le nom du fichier qui sera
utilisé. Le texte à coller est récupéré grâce à <>
qui fait
exactement ce qu'on attend : récupérer le contenu des fichiers dont
on a passé le nom sur la ligne de commande ou, si rien n'a été
passé, il lit le contenu de STDIN
(soit la sortie d'un tube, soit
ce que l'utilisateur tape sur le terminal).
my $m = WWW::Mechanize->new; $m->get("http://rafb.net/paste/"); die $m->res->status_line unless $m->success; unless ( $CONF{text} ) { $CONF{desc} ||= $ARGV[0]; $CONF{text} = join "", <>; }
Notez que le script meurt en cas de problème de connexion.
L'objet HTTP::Response
correspondant à la dernière réponse reçue
est accessible via la méthode response()
ou son alias res()
.
Ici, on affiche la ligne de statut en cas d'échec de la requête,
c'est-à-dire quand le code de la réponse n'est pas un 2xx
ou
un 3xx
.
D'une manière
générale, il est préférable de détecter les problèmes aussi tôt que possible,
afin d'éviter que le script « parte en vrille » dès que quelque chose
ne se passe pas comme prévu. Pour ce script-ci, cela n'a aucune incidence,
mais un script qui automatise des achats ou des transactions importantes
a plutôt intérêt à s'arrêter avant de vous endetter pour trois
générations ! ;-)
(Ceci dit, avec des systèmes comme le 1-Click
d'Amazon, il n'y a plus besoin de donner son numéro de carte bleue pour
acheter quelque chose... Autant éviter que notre robot clique n'importe
où !)
Il ne reste plus qu'à remplir les champs (avec la méthode set_fields()
)
et valider le formulaire, avant de renvoyer l'URL de la page obtenue.
$m->set_fields(%CONF); $m->submit; die $m->res->status_line unless $m->success; print $m->response->request->uri->as_string, "\n";
Cette dernière ligne de code mérite quelques explications, car elle est un peu complexe.
Voici ce qu'indique mon proxy personnel au sujet du dernier échange :
POST http://rafb.net/paste/paste.php Referer: http://rafb.net/paste/ lang => Plain Text nick => A. Nonyme desc => test cvt_tabs => No text => test 302 Found Content-Type: text/html; charset=ISO-8859-1 Set-Cookie: uid=A.+Nonyme; expires=Sat, 25-Jun-05 22:48:16 GMT Location: /paste/results/g9oRiw95.html GET http://rafb.net/paste/results/g9oRiw95.html Cookie: uid=A.+Nonyme Cookie2: $Version="1" Referer: http://rafb.net/paste/ 200 OK Content-Type: text/html; charset=ISO-8859-1
Lorsque l'on soumet le formulaire, le robot fait un POST
sur l'URL
http://rafb.net/paste/paste.php. Le script CGI répond par une
réponse 302 Found
, qui indique que le résultat de la requête se
trouve temporairement accessible à une autre URL. WWW::Mechanize
suit
automatiquement la redirection. L'URL qui nous intéresse, celle qui mène
à la page contenant les données collées, est donc l'URL de la requête
faite automatiquement par WWW::Mechanize
, c'est-à-dire
l'URL de la requête associée à la dernière réponse reçue.
(L'URL de la redirection annoncée dans la première réponse.)
On remarque au passage que le site note notre nick dans un cookie, afin probablement de remplir automatiquement ce champ du formulaire la prochaine fois que nous aurons un paste à faire avec le même navigateur.
En mettant bout à bout les trois blocs de code listés précédemment, nous disposons d'un robot qui sait gérer un site de paste, et peut s'utiliser de manière créative :
# montrer sa configuration disque $ df -h | paste-rafb --desc 'df -h' http://rafb.net/paste/results/YYsdKD11.html # et son /etc/fstab $ paste-rafb /etc/fstab http://rafb.net/paste/results/EFTOEA69.html # envoyer le code source du script à ses amis (avec coloration du code) $ paste-rafb --lang perl `which paste-rafb` http://rafb.net/paste/results/Nk9csX57.html
Comme ce script fonctionne comme un filtre, on peut même l'utiliser depuis
vim pour coller du code ou du texte directement depuis son éditeur, avec
des commandes comme :addrw !paste-rafb
. addr est une spécification
de lignes à traiter, comme par exemple %
(tout le fichier), 3,12
(lignes 3 à 12), ,+10
(la ligne courante et les 10 suivantes),
etc. Attention, il doit y avoir un espace entre le w
et le !
, pour
que vim comprenne bien que le !
ne fait pas partie du nom de fichier.
Ce script est particulièrement utile : je m'en sers quasiment tous les jours.
Il existe sur CPAN un module qui s'appelle WebServices::Paste
.
Celui-ci ne me plaît pas trop, car
le script fourni doit être modifié à la main pour pointer vers le
bon serveur et il n'est pas conçu pour pouvoir être étendu au
support d'autres sites de collage.
En revanche, un aspect intéressant de ce module, c'est qu'il utilise
un autre module nommé Clipboard
(sur lequel je n'ai pas eu le
temps de me faire une opinion) qui permet de coller le contenu du
presse-papier. Les inconvénients dépassant largement le maigre
avantage que cela représente, j'ai préféré passer 10 minutes à
écrire mon propre script.
Il serait assez simple de produire un module WWW::Paste
(par exemple)
qui sache gérer les diverses options des multiples sites de collage qui
existent (certains ont une option pour qu'un robot annonce le collage sur
un canal particulier), et reprendre notre script pour qu'il fonctionne
comme un point d'entrée unique pour tous ces sites (avec un fichier
~/.pasterc, etc.).
Je suis connecté en ADSL avec Télé2, qui signale sur son site web les problèmes réseau rencontrés. En général, quand je m'inquiète de l'état du réseau, c'est qu'il est déjà coupé pour moi, et je ne peux donc accéder à cette page d'information. L'idéal serait qu'un robot aille récupérer et sauvegarder périodiquement l'information contenue dans cette page.
Le portail http://www.tele2internet.fr/ a un lien « État du réseau »
qui ouvre une popup donnant l'état du réseau. Le lien est le suivant :
javascript:popup('http://editorial.tele2internet.fr/?page=ETAT_RESEAU&popup=1','helpPopupWindow',650,450,'scrollbars=1')
.
Donnons la partie utile de ce lien à notre robot :
use WWW::Mechanize; my $m = WWW::Mechanize->new; $m->get('http://editorial.tele2internet.fr/?page=ETAT_RESEAU&popup=1'); print $m->content;
En examinant le contenu du HTML qui nous est renvoyé, nous constatons que le serveur nous répond dédaigneusement :
Ce site Web nécessite Netscape 4.x ou une version ultérieure.
De plus, si on regarde la ligne de statut de la réponse HTTP :
$m->response->status_line;
nous voyons qu'il emploie les grands moyens :
400 Bad Request
Qu'à cela ne tienne, faisons-nous passer pour Mozilla grâce à la commande suivante :
$m->agent_alias("Linux Mozilla");
Cette fois-ci, le serveur nous rejette encore avec une réponse
400 Bad Request
et cette phrase :
Cette page nécessite Java script.
Nous allons continuer notre analyse avec un vrai navigateur. Dans une fenêtre normale, chargeons la page http://www.editorial.tele2internet.fr/?page=ETAT_RESEAU&popup=1 en ayant pris soin de supprimer les cookies existants (l'onglet Privacy dans la fenêtre de Préférences de Firefox permet de le faire simplement).
Notre proxy espion détecte trois chargements de pages :
GET http://www.editorial.tele2internet.fr/?page=ETAT_RESEAU&popup=1 User-Agent: Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.7.8) Gecko/20050517 Firefox/1.0.4 (Debian package 1.0.4-2) Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5 Accept-Language: en-us,en;q=0.5 400 Bad Request Content-Type: text/html Set-Cookie: ApacheEE=fwAAAUK1S3nqeN78Me4h; path=/; domain=.tele2internet.fr Set-Cookie: ETRACK=249904881; expires=Fri, 16-Dec-05 10:39:53 GMT; path=/; domain=.tele2internet.fr
On voit que le serveur répond aussi par un 400
à Firefox, puis lui
donne deux cookies.
GET http://www.editorial.tele2internet.fr/?page=ETAT_RESEAU&popup=1&1119177593 Cookie: ApacheEE=fwAAAUK1S3nqeN78Me4h; ETRACK=249904881; BrowserDetect=passed Referer: http://www.editorial.tele2internet.fr/?page=ETAT_RESEAU&popup=1 User-Agent: Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.7.8) Gecko/20050517 Firefox/1.0.4 (Debian package 1.0.4-2) Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5 Accept-Language: en-us,en;q=0.5 200 OK Content-Type: text/html Set-Cookie: ETRACK=249904881; expires=Fri, 16-Dec-05 10:39:54 GMT; path=/; domain=.tele2internet.fr
Le JavaScript inclus dans la page a probablement demandé un rechargement. Le numéro passé à la fin de la chaîne de requête (query string) correspond à la date au format Unix classique (nombre de secondes depuis janvier 1970).
GET http://www.editorial.tele2internet.fr/favicon.ico Cookie: ApacheEE=fwAAAUK1S3nqeN78Me4h; ETRACK=249904881; BrowserDetect=passed User-Agent: Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.7.8) Gecko/20050517 Firefox/1.0.4 (Debian package 1.0.4-2) Accept: image/png,*/*;q=0.5 Accept-Language: en-us,en;q=0.5 200 OK Content-Type: text/html Set-Cookie: ETRACK=249904881; expires=Fri, 16-Dec-05 10:39:55 GMT; path=/; domain=.tele2internet.fr
On remarque qu'à partir de l'étape 2, le client transmet également un
cookie BrowserDetect
. Comme aucun des en-têtes Set-Cookie
envoyés
par le serveur ne correspond, c'est probablement le code JavaScript
inclus dans la page qui a fait cet ajout.
Effectivement, le code JavaScript de la deuxième page renvoyée contient la ligne :
document.cookie = "BrowserDetect=passed;";
Essayons à nouveau en ajoutant simplement le cookie attendu :
use WWW::Mechanize; my $m = WWW::Mechanize->new; $m->agent_alias("Linux Mozilla"); $m->add_header( Cookie => 'BrowserDetect=passed' ); $m->get('http://editorial.tele2internet.fr/?page=ETAT_RESEAU&popup=1'); print $m->content;
Pour ce cas particulier, nous ajoutons le cookie avec la méthode
add_header()
de WWW::Mechanize
. Attention cependant, cette méthode
ajoute l'en-tête en question à chacune des requêtes qui sera effectuée
par le robot (l'en-tête en question est stocké dans la liste des
« en-têtes spéciaux » du robot). Si l'en-tête ne devait être ajouté
que pour cette requête-ci, nous pourrions le supprimer ensuite avec
delete_header()
.
En implantant nous-mêmes ce cookie, nous avons réussi à faire croire au serveur de Télé2 que le code JavaScript a reconnu le navigateur attendu. Et nous capturons ainsi la page d'information.
Il reste plus qu'à en extraire les informations utiles. Voici un extrait de la page en question :
<!--début de la zone à copier-coller --> <span class="apptextBold"><font color="#CC0000"> <!--début titre : bien modifier date et heure--> 14/06/05, 16:30 : PROBLEMES DE CONNEXION POUR LES CLIENTS EN ACCES MODEM RTC 56K DU SUD ET DE L'OUEST DE LA FRANCE<!--fin titre --> </font></span> <span class="apptextPlain"><br> <!--début texte --> Actuellement nous rencontrons des problèmes de connexion sur les connexions modem RTC 56K affectant uniquement les clients du sud et de l'ouest de la France. Cela est du à un équipement isolé du réseau.<!--fin texte --> </span><BR><BR> <!--fin de la zone à copier-coller -->
Outre les commentaires destinés aux opérateurs mettant les informations à
jour, on voit du HTML qui sert à structurer le texte dans des blocs
<span>
selon les classes
apptextBold
pour les titres et apptextPlain
pour les explications
associées. Nous allons utiliser HTML::TreeBuilder
pour récupérer le
texte en question.
my $tree = HTML::TreeBuilder->new_from_content( $m->content ); print $_->as_trimmed_text, $_->attr('class') eq 'apptextBold' ? "\n" : "\n\n" for $tree->look_down( _tag => 'span', class => qr/apptext/ );
La première ligne crée un objet HTML::Tree
à partir du contenu de
la réponse téléchargée par notre objet WWW::Mechanize
. La méthode
look_down()
renvoie la liste des branches de l'arbre HTML correspondant
à la requête. Dans notre cas, nous voulons les nœuds ayant pour
balise <span>
et pour attribut class
une valeur qui corresponde
à l'expression régulière qr/apptext/
.
On affiche ensuite le texte des éléments avec la méthode as_trimmed_text()
de chacun des objets HTML::Element
renvoyés. Celle-ci se comporte comme
as_text()
, en supprimant les blancs en début et fin de ligne. On saute
une ligne après le titre, et deux lignes après le contenu.
La visualisation du résultat montre que les opérateurs de Télé2 ne savent
pas fermer correctement leurs balises <span>
. Nous ne pouvons donc
même pas compter sur les styles pour faire un affichage correct !
Heureusement, le texte contient des puces (caractère \x95
) avant chaque
nouveau titre. Il est probable que les opérateurs n'oublieront pas celui-ci.
Nous allons donc nous appuyer dessus pour faire notre découpage :
my $tree = HTML::TreeBuilder->new_from_content( $m->content ); print map { s/\n*\x95/\n\n*/g; $_ } map { $_->as_trimmed_text . "\n" } $tree->look_down( _tag => 'span', class => qr/apptext/ );
Cette fois-ci, nous obtenons le résultat souhaité :
* 14/06/05, 16:30 : PROBLEMES DE CONNEXION POUR LES CLIENTS EN ACCES MODEM RTC 56K DU SUD ET DE L'OUEST DE LA FRANCE Actuellement nous rencontrons des problèmes de connexion sur les connexions modem RTC 56K affectant uniquement les clients du sud et de l'ouest de la France. Cela est du à un équipement isolé du réseau. * 09/06/05, 22:00 : FIN DU PROBLEME DE CONNEXION POUR LES CLIENTS DEGROUPES DANS LA REGION DE SOISY (95)
J'ai donc mis ce script dans ma crontab :
# infos Telé2 */10 * * * * ~/bin/tele2 > ~/tele2
À la coupure suivante de ma connexion, j'ai pu constater mon erreur : quand le réseau est indisponible, le script ne renvoie rien mais écrase quand même le fichier d'information... Autrement dit, je perds l'information en cas de coupure du réseau, exactement quand j'en ai besoin !
La première chose à faire, c'est de sortir du programme en cas d'erreur :
# juste après le $m->get(...); exit unless $m->success;
La méthode success()
renvoie une valeur booléenne indiquant si la
réponse est un succès (code 2xx
). Cette méthode encapsule un appel
à $m->response->is_success()
.
Ce qui pose problème en réalité, c'est la redirection de la sortie
standard par le shell, qui écrase le fichier destination même si le
script ne produit aucune sortie. On ne peut pas se contenter d'ajouter
en fin de fichier (append) avec le >>
du shell, car on
ajouterait l'intégralité de la page d'information à notre fichier
à chaque lancement du script !
C'est donc à notre script lui-même de gérer la sauvegarde dans un fichier.
J'ai choisi le comportement suivant : le premier paramètre sur la ligne
de commande sera le nom du fichier dans lequel sauvegarder l'information,
sinon la sortie se fait sur STDOUT
comme précédemment.
La sélection de la sortie se fait facilement, en réouvrant STDOUT
# sélection de la sortie if (@ARGV) { open STDOUT, ">", $ARGV[0] or warn "Impossible de rediriger STDOUT sur $ARGV[0]: $!\n"; }
C'est-à-dire que nous faisons l'équivalent de > fichier
, non pas
au démarrage du script, mais quand nous avons effectivement des données
à sauvegarder dans ce fichier. S'il est impossible d'ouvrir le fichier
voulu, la sortie se fera sur la sortie standard habituelle, après
affichage d'un avertissement.
La ligne dans la crontab sera désormais (notez la subtile différence) :
# infos Telé2 */10 * * * * ~/bin/tele2 ~/tele2
Après tous ces efforts, nous pouvons constater que le script lui-même est resté assez court :
#!/usr/bin/perl use HTML::TreeBuilder; use WWW::Mechanize; my $m = WWW::Mechanize->new; # récupération des informations $m->agent_alias("Linux Mozilla"); $m->add_header( Cookie => 'BrowserDetect=passed' ); $m->get('http://editorial.tele2internet.fr/?page=ETAT_RESEAU&popup=1'); exit unless $m->success; # sélection de la sortie if (@ARGV) { open STDOUT, ">", $ARGV[0] or warn "Impossible de rediriger STDOUT sur $ARGV[0]: $!\n"; } # affichage des informations my $tree = HTML::TreeBuilder->new_from_content( $m->content ); print map { s/\n*\x95/\n\n*/g; $_ } map { $_->as_trimmed_text . "\n" } $tree->look_down( _tag => 'span', class => qr/apptext/ );
En pratique, à chaque fois que j'ai été déconnecté depuis que je fais tourner ce script, je n'ai jamais trouvé d'explication dans les messages d'état du réseau ! Ça doit être le modem qui chauffe...
J'utilise depuis peu le logiciel de téléphonie en ligne Skype. Si Skype est gratuit, certains services sont payants (comme SkypeOut, qui permet de téléphoner vers des téléphones classiques). En me connectant par hasard sur mon profil récemment, j'ai vu un message parlant de SkypeOut Gift Day. Il s'agit apparemment d'une offre promotionnelle permettant justement de gagner des « crédits » d'appel pour SkypeOut. Tous les détails sont sur la page http://www.skype.com/campaigns/freeskypedays/.
Il suffit de se connecter sur sa page personnelle Skype le bon jour, et de cliquer au bon endroit. J'aimerais bien gagner ces crédits, mais j'ai plus intéressant à faire que de me connecter sur ma page Skype tous les jours...
La page personnelle de Skype (en anglais) contient le message suivant : Today is not SkypeOut Gift Day, but perhaps it's coming up soon? (Sur la version française du site : « Aujourd'hui n'est pas une Journée cadeau SkypeOut... mais il y en aura une très bientôt ! »)
Je vais donc programmer un robot qui va vérifier une ou deux fois par jour la page en question, et me prévenir le jour où ce message n'apparaît plus. Ce jour-là, je ferai l'effort de me connecter moi-même et de voir ce qu'il y a à faire pour gagner ce crédit de temps.
Pour accéder à ma page personnelle, il me faut tout d'abord m'identifier. Un peu de recherche me dirige vers le lien https://secure.skype.com/store/member/login.html qui contient le formulaire de connexion. C'est notre premier formulaire d'identification depuis le début de cet article !
Appelons mech-dump à la rescousse :
$ mech-dump https://secure.skype.com/store/member/login.html POST https://secure.skype.com/store/member/dologin.html username= (text) password= (password) login=Sign me in (submit)
Voilà un formulaire simple, comme je les aime. :-)
En quelques lignes, nous pouvons produire un script de connexion et de recherche du message.
# formulaire de login $m->get('https://secure.skype.com/store/member/login.html'); die $m->res->status_line unless $m->success; # remplissage et validation $m->set_fields( username => 'monlogin', # entrez vos identifiants de connexion ici password => 'monpasse', ); $m->click; die $m->res->status_line unless $m->success; # un cadeau ? print localtime() . " - Connecte-toi à ta page Skype !\n" if $m->content !~ /Today is not SkypeOut Gift Day/;
On ne devrait pas avoir de problèmes à cause de la langue, car la sélection
de celle-ci se fait par un cookie language
.
Lors de mes tests, j'ai pu constater qu'au moins une fois l'identification a échoué, et que la page contenait le message d'erreur suivant : New account creation is temporarily disabled. Please try again after a few minutes.
L'ajout de la ligne :
# fait passer notre robot pour Mozilla, grâce à l'en-tête User-Agent: $m->agent_alias('Linux Mozilla');
a eu l'air de satisfaire le serveur Skype. Peu de temps après une
nouvelle tentative de connexion sans cette ligne a réussi. Ce n'est
donc pas l'en-tête User-Agent:
qui est en cause. (Ceci dit, il peut
être parfois utile de se faire passer pour un navigateur « standard »
pour déjouer certaines tentatives de détection du navigateur.)
Mais alors, comment vérifier que l'identification est correcte ?
On pourrait supposer que Skype utilise la réponse HTTP standard
403 Forbidden
en cas de problème d'identification. Il est facile de
vérifier que dans tous les cas (mot de passe valide ou non) la réponse
est un magnifique 200 OK
qui ne nous apporte donc aucune information
(du moins en ce qui concerne l'identification : en cas de problème
réseau, on aura une erreur 500
par exemple, qui sera détectée par la
ligne utilisant $m->success()
).
Nous pourrions analyser la page reçue en réponse pour trouver des informations permettant de distinguer le succès de l'échec de connexion. Il y a beaucoup plus simple. La ligne suivante permet d'afficher les cookies reçus par notre robot :
print $m->cookie_jar->as_string;
Nous voyons les cookies au format interne de HTTP::Cookies
(c'est également
sous cette forme qu'ils sont sauvés par la méthode save()
) :
Set-Cookie3: loggedin=1; path="/"; domain=.skype.com; path_spec; expires="2005-07-27 15:02:40Z"; version=0 Set-Cookie3: username=monlogin; path="/"; domain=.skype.com; path_spec; expires="2005-07-27 15:02:40Z"; version=0 Set-Cookie3: skype_store2=70b6ea6caeff30ab42d7f60f05d20dfd; path="/"; domain=secure.skype.com; path_spec; discard; version=0
Le nom du cookie loggedin
suffit à trahir son rôle (je me demande
s'il n'y a pas un problème de sécurité, d'ailleurs). On vérifie facilement
que les cookies username
et loggedin
ne sont pas envoyés par le
serveur en cas d'erreur lors de l'identification.
Nous allons réaliser l'analyse des cookies reçus à l'aide de la
méthode scan()
de HTTP::Cookies
(la class de libwww-perl
qui gère
les cookies). Cette méthode permet d'appliquer une fonction de rappel à
tous les cookies stockés dans un objet cookie_jar (boîte à gâteaux,
en anglais). La fonction de rappel attend les 10 paramètres suivants
(dans l'ordre) : version
, key
, val
, path
, domain
,
port
, path_spec
, secure
, expires
, discard
et hash
.
(Ce dernier paramètre, hash
, existe à des fins d'extensiblité et
permet de recevoir des paramètres non standards du cookie).
La vérification de la réussite de notre identification se fait de la façon suivante :
# vérification que l'identification est réussie { my $ok = 0; $m->cookie_jar->scan( sub { $ok++ if $_[1] eq 'loggedin' } ); die "Echec d'identification : mauvais Pseudo Skype ou mot de passe ?" unless $ok; }
En ajoutant ce bout de code juste avant la ligne print localtime() ...
,
notre script saura se connecter à Skype et vérifier que l'identification
a réussi. Nous aurons donc deux messages différents selon que le site
Skype a refusé notre connexion (comme dans le cas New account creation
is temporarily disabled...) ou que la page personnelle a changé.
Après installation dans la crontab, il n'y a plus qu'à attendre
d'être prévenu par un mail de Cron Daemon que mon cadeau Skype
m'attend ! :-)
Quatre jours après avoir écrit les lignes ci-dessus, j'ai reçu un email laconique :
Sat Jul 30 15:25:12 2005 - Connecte-toi à ta page Skype !
Et voici ce qui m'attendait :
Au point où nous en sommes, nous allons essayer d'automatiser aussi cette partie-là, histoire de ne plus rien avoir à faire du tout ! Cette partie ne nous laisse pas le droit à l'erreur, car on ne peut valider le formulaire qu'une seule fois.
Nous pouvons commencer par demander à notre script d'afficher tous les formulaires de la page avec :
print $_->dump for $m->forms;
La page ne contient qu'un seul formulaire :
POST https://secure.skype.com/store/myaccount/givepromotion.html promotion=4 (hidden readonly) <NONAME>=Get your free Skype Gift (submit)
C'est du tout cuit ! Notre script va donc regarder si la page contient
un formulaire dont l'action est givepromotion.html
. Si le formulaire
existe, il va le valider et nous informer du résultat ; dans le cas
contraire, nous serons quand même informés qu'il y a quelque chose à faire.
Le HTML de Skype est très propre et s'appuye énormément sur les CSS.
En inspectant le HTML de la page contenant le formulaire, j'ai constaté
que le formulaire était dans un bloc <div class="freeskypeday">
.
Avant de valider le formulaire, nous allons donc afficher le message
d'information à l'aide de HTML::TreeBuilder
:
print map { $_->as_text } HTML::TreeBuilder ->new_from_content( $m->content ) ->look_down( _tag => 'div', class => 'freeskypeday' );
(Comme l'objet HTML::Tree
n'est utilisé qu'une fois pour afficher
un message, on peut se passer de variable intermédiaire.)
Ensuite, si la page contient un formulaire qui correspond à celui du cadeau, on le valide :
if ( $m->current_form && $m->current_form->action =~ /givepromotion\.html$/ ) { $m->submit; die $m->res->status_line unless $m->success; }
Une petite remarque : ne jamais oublier d'ajouter une ligne
print $m->content
quand on récupère une page pendant le
développement d'un script. Ça permet de conserver une copie de la page
reçue pour analyse. C'est ainsi qu'après validation, j'ai constaté qu'un
message d'information était affiché entre balises <h1>
. J'ai donc
ajouté a posteriori la commande print()
adéquate pour voir ce message
la prochaine fois.
Il reste un dernier point à régler : le message change une fois qu'on a reçu le cadeau. On lit désormais : Today is SkypeOut Gift Day, but it seems that you already received your gift (sur la version française : « Aujourd'hui est un jour cadeau pour SkypeOut, mais il semble que vous ayiez déjà reçu votre cadeau. »). Afin de ne pas être avertis inutilement une fois que le cadeau a été retiré, nous allons également ignorer ce message.
En collant tous ces morceaux bout à bout et en rajoutant de quoi passer le login et le mot de passe sur la ligne de commande, nous obtenons le script suivant :
#!/usr/bin/perl use strict; use warnings; use WWW::Mechanize; use HTML::TreeBuilder; use Getopt::Long; no warnings 'utf8'; my %CONF; GetOptions( \%CONF, "username=s", "password=s" ) or die << 'USAGE'; Options disponibles : --username <username> --password <password> USAGE my $m = WWW::Mechanize->new; # formulaire de login $m->get('https://secure.skype.com/store/member/login.html'); die $m->res->status_line unless $m->success; # remplissage et validation $m->set_fields( username => $CONF{username}, password => $CONF{password}, ); $m->click; die $m->res->status_line unless $m->success; # vérification que l'identification est réussie { my $ok = 0; $m->cookie_jar->scan( sub { $ok++ if $_[1] eq 'loggedin' } ); die "Echec d'identification : mauvais Pseudo Skype ou mot de passe ?\n" unless $ok; } # y a-t-il un cadeau pas encore récupéré ? if ( $m->content !~ /Today is not SkypeOut Gift Day/ && $m->content !~ /you already received your gift/ ) { # affiche le message print localtime() . " - Connecte-toi à ta page Skype !\n\n"; print map { $_->as_text } HTML::TreeBuilder ->new_from_content( $m->content ) ->look_down( _tag => 'div', class => 'freeskypeday' ); # valide le formulaire if ( $m->current_form && $m->current_form->action =~ /givepromotion\.html$/ ) { $m->submit; die $m->res->status_line unless $m->success; # affiche les détails print map { $_->as_text } HTML::TreeBuilder ->new_from_content( $m->content ) ->look_down( _tag => 'h1' ); } }
Tout ce travail pour un cadeau de 20 centimes d'euro !
Épilogue : Un peu plus d'une semaine après que j'ai récupéré mon cadeau, la page principale du compte Skype ne contient plus aucune information au sujet des cadeaux SkypeOut. Évidemment, mon script m'a prévenu que la page avait changé. J'ai ainsi pu constater qu'il était devenu probablement inutile... jusqu'à la prochaine campagne publicitaire de Skype.
Philippe "BooK" Bruhat, book@mongueurs.net
.
Merci aux Mongueurs de Perl et à Michel Grafmeyer pour leur relecture attentive.
Le code des trois scripts présentés dans cet article, ainsi que celui du proxy espion sont présents sur le CD. Ils sont également téléchargeables ici :
Copyright © Les Mongueurs de Perl, 2001-2011
pour le site.
Les auteurs conservent le copyright de leurs articles.