Formation CasperJS

CasperJS

Usages principaux

Scraping

Tests fonctionnels

CasperJS

CasperJS permet de scripter des scenarios automatisés de navigation et d'interaction avec des applications Web utilisant massivement JavaScript.

Confusions fréquentes

Installation

Installation (OSX)

Utiliser Homebrew, un gestionnaire de paquet pour OS X, est la solution la plus simple :

$ ruby -e "$(curl -fsSL https://raw.github.com/mxcl/homebrew/go)"
$ brew update
$ brew install casperjs --devel
$ casperjs --version
1.1-beta2

Note : PhantomJS sera alors installé en tant que dépendance.

Installation (Linux)

PhantomJS s'installe facilement via npm, tandis que CasperJS s'installera via git:

$ npm install -g phantomjs
$ git clone https://github.com/n1k0/casperjs.git
$ cd casperjs
$ ln -sf `pwd`/bin/capserjs /usr/local/bin/casperjs
$ git co 1.1-beta2
$ casperjs --version
1.1-beta2

Installation (Windows)

Vous pouvez désormais utiliser la commande casperjs.exe :

C:> casperjs.exe myscript.js

Notes :

Premier script

Dans un nouveau fichier casper-commits.js :

require("casper").create({pageSettings: {loadImages: false, loadPlugins: false}})
  .start("https://github.com/n1k0", function() {this.clickLabel("casperjs")})
  .waitForSelector(".commits a").thenClick(".commits a")
  .waitForSelector(".commit-group-heading").run(function() {
    this.echo("Latest commits from CasperJS:\n\n" + this.evaluate(function() {
      var nodes = document.querySelectorAll('.commit-title');
      return [].map.call(nodes, function(node) {
        return node.textContent.replace("…", "").trim();
      });
    }).join("\n - ")).exit();
  });

Cela nous donne

$ casperjs casper-commits.js
Latest commits from CasperJS:

- fixed jshint issue
- updated obsolete doclinks in README
- fixes #704 - added --auto-exit=no test runner option
- added missing test file - refs #721
- refs #546 - even better 1.1 strategy for storing successes/failures
- fixes #704 - provide an 'exit' tester event to hook before runner exits
- refs #546 - documented fallback strategies for getPasses/getFailures
- fixes #546 - updated 1.1 upgrade docs regarding getPasses/getFailures
- minor docs fixes for clientutils
- Merge pull request #718 from phillipalexander/fix-readme-typo

Des questions ?

Oui, c'était une blague.

Mais à la fin vous pourrez produire vous-même ce genre de script

(En beaucoup plus beau)

Quelques rappels sur JavaScript

Scope

function hello() {
    var target = "world";
    return "hello, " + target;
}
console.log(hello()) // "hello, world"
console.log(target)  // ReferenceError: target is not defined

Scope

function hello() {
    target = "world";
    return "hello, " + target;
}
console.log(hello()) // "hello, world"
console.log(target)  // "world"

Ici, l'absence d'utilisation du mot-clé var stocke target dans le scope global, c'est à dire attache la propriété target à l'objet window.

Scope

function hello() {
    "use strict";
    target = "world";
    return "hello, " + target;
}
console.log(hello()) // ReferenceError: assignment to undeclared variable target
console.log(target)  // undefined

La directive "use strict" effectue une vérification stricte du contexte dans lequel nous déclarons nos variables. Utilisez-la aussi souvent que possible!

Scope

function hello(target) {
    function capitalize() {
        return target.toUpperCase();
    }
    return "hello, " + capitalize();
}
console.log(hello("chuck")) // "hello, CHUCK"
console.log(target)         // undefined

Un fonction imbriquée accède au scope de sa fonction parente. Les arguments reçus par une fonction ne sont pas exposés hors de son scope.

Scope

function hello(target) {
    function capitalize() {
        target = target.toUpperCase();
    }
    capitalize();
    return "hello, " + target;
}
console.log(hello("chuck")) // "hello, CHUCK"

Une fonction imbriquée peut altérer une variable du scope parent, mais cette pratique est vivement déconseillée, car ce type d'effets de bords sont toujours plus difficiles à contrôler et à tester.

Closures

function add(x) {
    return function(y) {
        return x + y;
    };
}
var add5 = add(5);
console.log(add5(3));   // 8
console.log(add(5)(3)); // 8

Une closure est une fonction qui effectue des opérations sur ou à partir des éléments de son scope parent. Ici, la fonction add prend un argument x et retourne elle-même une autre fonction prenant un paramètre y qui sera ajouté à x.

L'aspirine est dans le petit placard blanc de la salle de bain.

Closures

var functions = [];
for (var i = 0; i < 10; i++) {
    functions.push(function() {
        console.log(i);
    });
}

functions.forEach(function(fn) {
    fn();
});

Cela affiche dix fois le nombre 10. Que s'est-il passé ?

Closures

var functions = [];
for (var i = 0; i < 10; i++) {
    functions.push(function(i) {
        return function() {
            console.log(i);
        };
    }(i));
}

functions.forEach(function(fn) {
    fn();
});

Grace à une closure, nous avons isolé la valeur de i utilisée par chacune des fonctions de notre tableau de fonctions.

IIFE

var functions = [];
for (var i = 0; i < 10; i++) {
    (function(i) {
        functions.push(function() {
            console.log(i);
        });
    })(i);
}

functions.forEach(function(fn) {
    fn();
})

Ici nous utilisons une IIFE — Immediately Invoked Function Expression — qui aboutit au même résultat.

Types

>>> typeof "foo"
???

>>> typeof null
???

>>> typeof NaN
???

>>> typeof new Date()
???

>>> Object.prototype.toString.call(new Date())
???

Types

>>> typeof "foo"
"string"

>>> typeof null
object

>>> typeof NaN
"number"

>>> typeof new Date()
"object"

>>> Object.prototype.toString.call(new Date())
"[object Date]"

Prototype

>>> function Cow(name) {
        this.name = name;
    }

>>> Cow.prototype.say = function(what) {
        console.log(this.name + ' says ' + what);
    };

>>> var cow = new Cow('Geneviève');
>>> cow.say('Meuh.');
// => "Geneviève says Meuh."

Sérialisation

>>> var cow = new Cow('Geneviève');
[Object object]

>>> cow = JSON.parse(JSON.stringify(cow));
[Object object]

>>> cow.says('Halp.');
???

Sérialisation

>>> var cow = new Cow('Geneviève');
[Object object]

>>> cow = JSON.parse(JSON.stringify(cow));
[Object object]

>>> cow.says('Halp.');
TypeError: cow.says is not a function

Un objet cloné de cette façon renverra toujours un objet natif dépossédé de toutes les méthodes que vous y avez ajouté.

Array#forEach

[1, 2, 3].forEach(function(i) {
    console.log(i);
});
// => 1
// => 2
// => 3

Array#map

[1, 2, 3].map(function(x) {
    return x * 2;
});
// [2, 4, 6]

Array#reduce

[1, 2, 3].reduce(function(acc, x) {
    return acc + x;
});
// 6

Array#filter

[1, 2, 3].filter(function(x) {
    return x > 1 && x < 3;
});
// [2]

Arguments

function adder() {
    return [].reduce.call(arguments, function(acc, x) {
        return acc + x;
    });
}

adder(1, 2)    // 3
adder(1, 2, 3) // 6

Function#apply

function adder() {
    return [].reduce.call(arguments, function(acc, x) {
        return acc + x;
    });
}

adder.apply(null, [1, 2])
adder.apply(null, [1, 2, 3])

Function#call

function adder() {
    return this.reduce(function(acc, x) {
        return acc + x;
    });
}

adder.call([1, 2])
adder.call([1, 2, 3])

Function#bind

function test() {
    "use strict";
    console.log(this, JSON.stringify(arguments));
}

>>> test();
undefined '{}'

>>> test.bind("foo")();
foo {}

>>> test.bind("foo", 1, 2)();
foo {"0":1,"1":2}

Rappels sur le DOM

document.querySelector

>>> typeof document.querySelector('body')
???

>>> document.querySelector('body') instanceof HTMLBodyElement
???

>>> document.querySelector('body') instanceof HTMLElement
???

>>> document.querySelector('#nonexistent')
???

>>> typeof document.querySelector('#nonexistent')
???

document.querySelector

>>> typeof document.querySelector('body')
"object"

>>> document.querySelector('body') instanceof HTMLBodyElement
true

>>> document.querySelector('body') instanceof HTMLElement
true

>>> document.querySelector('#nonexistent')
null

>>> typeof document.querySelector('#nonexistent')
"object"

document.querySelectorAll

>>> typeof document.querySelectorAll('a')
???

>>> document.querySelectorAll('a') instanceof HTMLElement
???

>>> document.querySelectorAll('a') instanceof NodeList
???

>>> document.querySelectorAll('a')[0] instanceof HTMLElement
???

>>> document.querySelectorAll('a') instanceof Array
???

document.querySelectorAll

>>> typeof document.querySelectorAll('a')
"object"

>>> document.querySelectorAll('a') instanceof HTMLElement
false

>>> document.querySelectorAll('a') instanceof NodeList
true

>>> document.querySelectorAll('a')[0] instanceof HTMLElement
true

>>> document.querySelectorAll('a') instanceof Array
false

NodeList

>>> document.body.innerHTML = '<ul><li>1</li><li>2</li><li>3</li></ul>'
>>> document.querySelectorAll('li').length
???
>>> document.querySelectorAll('li').map(function(node) {
>>>     return parseInt(node.textContent, 10);
>>> })
???

NodeList

>>> document.body.innerHTML = '<ul><li>1</li><li>2</li><li>3</li></ul>'
>>> document.querySelectorAll('li').length
3
>>> document.querySelectorAll('li').map(function(node) {
>>>     return parseInt(node.textContent, 10);
>>> })
TypeError: document.querySelectorAll(...).map is not a function

NodeList

La solution, utiliser la fonction prototypale Array#map en lui passant le contexte approprié :

>>> Array.prototype.map.call(document.querySelectorAll('li'), function(node) {
>>>     return parseInt(node.textContent, 10);
>>> })
[1, 2, 3]

Ou encore :

>>> [].map.call(document.querySelectorAll('li'), function(node) {
>>>     return parseInt(node.textContent, 10);
>>> })
[1, 2, 3]

Scraping

Scraping

Le Web scraping (parfois appelé Harvesting) est une technique d'extraction du contenu de sites Web, via un script ou un programme, dans le but de le transformer pour permettre son utilisation dans un autre contexte

Wikipedia

Scraping: exemple 1

Extraire tous les liens depuis la page fr.wikipedia.org/wiki/Web_Scraping.

Commençons doucement…

Créer un fichier exercices/1/1.js :

// exercices/1/1.js
var casper = require('casper').create();
casper.start('http://fr.wikipedia.org/wiki/Web_scraping', function() {
    this.debugHTML();
});
casper.run();

Lancer le script :

$ casperjs exercices/1/1.js

Que s'est il passé ?

On crée un object casper :

var casper = require('casper').create();

Notez l'utilisation du pattern module, comme dans node.js

Que s'est il passé ?

On lance le navigateur sur l'url :

casper.start('http://fr.wikipedia.org/wiki/Web_scraping');

Considérez la méthode start() comme l'ouverture d'un nouvel onglet et le chargement d'une url dans un navigateur classique.

Que s'est il passé ?

On définit le comportement à la réception de la réponse HTTP attendue ; ici, on affiche simplement le contenu HTML brut de la page :

casper.start('http://fr.wikipedia.org/wiki/Web_scraping', function() {
    this.debugHTML();
});

La méthode debugHTML() est très utile pour savoir exactement quel contenu HTML est utilisé dans la page courante.

Que s'est il passé ?

Enfin, on lance le traitement de l'ensemble des opérations programmées :

casper.run();

Une erreur fréquente des débutants est d'omettre l'appel à run(), résultant sur un script figé.

Asynchronicité

On pourrait aussi écrire :

var casper = require('casper').create();

casper.start('http://fr.wikipedia.org/wiki/Web_scraping');

casper.then(function() {
    this.debugHTML();
});

casper.run();

Asynchronicité

Ou encore :

require('casper')
    .create()
    .start('http://fr.wikipedia.org/wiki/Web_scraping')
    .then(function() {
        this.debugHTML();
    })
    .run();

Plus une affaire de goûts et de couleurs qu'autre chose…

Extraction des liens

// exercices/1/2.js
var casper = require('casper').create();
var links = [];

casper.start('http://fr.wikipedia.org/wiki/Web_scraping', function() {
    links = this.evaluate(function() {
        var nodes = document.querySelectorAll('a');
        return [].map.call(nodes, function(node) {
            return node.getAttribute('href');
        });
    });
});

casper.run(function() {
    this.echo(links).exit();
});

Hmm, WAT?

Regardons plus en détail ce qui s'est passé…

Que s'est il passé ?

Une fois la page Wikipedia ouverte, nous appelons la méthode evaluate() depuis le context casper afin de récupérer une valeur depuis le DOM de la page en question :

var links = [];

casper.start('http://fr.wikipedia.org/wiki/Web_scraping', function() {
    links = this.evaluate(function() {
        // ce qui sera retourné ici sera affecté à la variable links
    });
});

Que s'est il passé ?

Et que voulons-nous affecter à notre variable links ? La liste des liens hypertextes de la page courrante ; aussi nous commençons par récupérer la liste des éléments DOM correspondants :

        var nodes = document.querySelectorAll('a');

Que nous mappons afin d'en extraire le contenu de l'attribut href :

        return [].map.call(nodes, function(node) {
            return node.getAttribute('href');
        });

Note: Nous ne pouvons pas directement mapper la liste d'éléments car le type NodeList n'implémente pas complètement le prototype Array ; nous appliquons donc directement la méthode Array#map à notre objet NodeList au moyen de Function#call.

Que s'est-il passé ?

Enfin, nous lançons l'exécution des opérations et affichons le contenu de notre variable links :

casper.run(function() {
    this.echo(links).exit();
});

Notes sur Casper#evaluate()

Pour évaluer du code dans le contexte de la page Web que vous scrapez, il faut utiliser la méthode Casper#evaluate().

Par sécurité, cette fonction est sandboxée, il n'y a aucun moyen pour le code qui lui est passé d'accéder aux objets et variables JavaScript en dehors du contexte de la page en question.

Comme pour PhantomJS, l'utilisation du sandboxing implique de ne pouvoir transférer que des objets sérializables de l'environnement JavaScript de la page à l'environnement d'exécution CasperJS.

Astuce : considérez une valeur comme sérializable lorsque l'expression suivante est vraie :

value === JSON.parse(JSON.stringify(value)) // true

Notes sur Casper#evaluate()

Ceci fonctionnera :

var href = casper.evaluate(function() {
    return document.querySelector('a').href; // String
});

Ceci ne fonctionnera pas :

var href = casper.evaluate(function() {
    return document.querySelector('a'); // HTMLElement, non serializable
});

Notes sur Casper#evaluate()

Ce diagramme illustre le principe d'étanchéïté entre les deux environnements :

Notes sur Casper#evaluate()

Une fonction passée à evaluate() n'a pas accès au scope de l'environnement CasperJS ; ceci ne fonctionnera pas :

var toto = 42;
this.echo(this.evaluate(function() {
    return toto; // undefined
}));

On peut cependant passer des arguments inline à evaluate() :

var toto = 42;
this.echo(this.evaluate(function(toto) {
    return toto; // 42
}, toto));

Un peu plus dur…

Vous connaissez sans doute la fonctionnalité de Google Suggest, qui permet de proposer des suggestions pour un terme de recherche particulier :

Allons scraper tout ça pour rigoler un coup (ou pas)

Un peu plus dur…

L'idée est de développer un script permettant, pour un terme de recherche passé, d'afficher la liste des suggestions correspondantes depuis la ligne de commande :

$ casperjs google-suggest.js pourquoi
pourquoi la guerre au mali
pourquoi pas coline
pourquoi je me suis marié
pourquoi le ciel est bleu
pourquoi la france intervient au mali
pourquoi je me suis marié aussi
pourquoi j'ai mangé mon père
pourquoi on baille
pourquoi le pape démissionne

Quelques indices

Certains outils peuvent également s'avérer utiles :

C'est à vous !

Récupérer un argument

Une fois l'objet casper créé, il dispose d'un attribut cli qui permet de récupérer les informations passées en ligne de commande ; ici, nous souhaitons récupérer le premier argument :

var casper = require('casper').create();
var word = casper.cli.get(0);

Remplir un champ

La méthode Casper#sendKeys() permet de simuler la saisie d'un texte sur un champ de formulaire ; Nous allons saisir le mot passé en argument du script dans la zone de recherche google :

casper.start('http://www.google.com/', function() {
    this.sendKeys('input[name=q]', word);
});

Attendre l'autocomplétion

L'affichage des suggestions de recherche Google étant asynchrone, nous utilisons la méthode Casper#waitFor afin d'attendre que le sélecteur correspondant existe et commence par le mot que nous recherchons ; ici, le sélecteur CSS3 correspondant est .gsq_a table span :

casper.waitFor(function() {
  return this.fetchText('.gsq_a table span').indexOf(word) === 0
}, function() {
    // la boite d'autocomplétion est disponible
    // récupération des suggestions
});

Récupérer les suggestions

Une fois la zone affichant les suggestions est visible, nous pouvons procéder à la récupération des textes pour chaque élément de la liste correspondante :

    suggestions = this.evaluate(function() {
        var nodes = document.querySelectorAll('.gsq_a table span');
        return [].map.call(nodes, function(node){
            return node.textContent;
        });
    });

Affichage des suggestions

Enfin, nous affichons la liste des résultats que nous avons stocké :

casper.run(function() {
    this.echo(suggestions.join('\n')).exit();
});

Tests fonctionnels

Tests fonctionnels

Utiles pour :

Le framework de test fonctionnel a été refondu en version 1.1, et propose désormais des fonctionnalités avancées de reporting, les méthodes setUp() et tearDown() et propose une architecture plus découplée.

La documentation de la version de développement est située à l'adresse docs.casperjs.org.

Installation de la version 1.1-DEV

Installer la version 1.1 de développement :

$ git clone https://github.com/n1k0/casperjs.git
$ cd casperjs
$ git co master
$ casperjs --version
1.1.0-DEV

La sous-commande casperjs test

L'utilisation des fonctionnalités de test implique l'utilisation d'une sous-commande particulière, casperjs test :

$ casperjs test mytest.js

Premier script de test

Testons la recherche google :

// exercices/3/1.js
casper.test.begin('Google search tests', 4, function suite(test) {
    casper.start("http://www.google.fr/", function() {
        test.assertTitle("Google", "google homepage title is the one expected");
        test.assertExists('form[action="/search"]', "main form is found");
        this.fill('form[action="/search"]', {
            q: "casperjs"
        }, true);
    });

    casper.waitFor(function() {
        return this.getTitle() === "casperjs - Recherche Google";
    }, function() {
        test.assertUrlMatch(/q=casperjs/, "search term has been submitted");
        test.assertEval(function() {
            return document.querySelectorAll("h3.r").length >= 10;
        }, "google search for \"casperjs\" retrieves 10 or more results");
    });

    casper.run(function() {
        test.done();
    });
});

Execution

Vous devriez obtenir quelque chose d'approchant :

Execution

En faisant volontairement échouer le test :

Que s'est-il passé ?

casper.test.begin('Google search tests', 4, function suite(test) {

Nous démarrons un nouveau test en spécifiant :

  1. son nom, ici Google search tests
  2. le nombre d'assertions attendues, ici 4
  3. une fonction définissant les assertions à executer; notez l'argument test contenant une référence à l'objet Tester.

Que s'est-il passé ?

    casper.start("http://www.google.fr/", function() {
        test.assertTitle("Google", "google homepage title is the one expected");
        test.assertExists('form[action="/search"]', "main form is found");
        this.fill('form[action="/search"]', {
            q: "casperjs"
        }, true);
    });

Nous ouvrons la page d'accueil de Google, puis :

  1. vérifions le titre de la page (le contenu de sa balise <title>)
  2. vérifions que le formulaire de recherche existe, à partir d'un sélecteur CSS3
  3. remplissons le champ de recherche avec le mot-clé casperjs et déclenchons sa soumission

Que s'est-il passé ?

    casper.waitFor(function() {
        return this.getTitle() === "casperjs - Recherche Google";
    }, function() {
        test.assertUrlMatch(/q=casperjs/, "search term has been submitted");
        test.assertEval(function() {
            return document.querySelectorAll("h3.r").length >= 10;
        }, "google search for \"casperjs\" retrieves 10 or more results");
    });

Nous attendons que le titre de la page ait changé, puis nous vérifions :

  1. que l'URL de la page a changé conformément
  2. que le nombre de résultats de recherche est supérieur à 10

Que s'est-il passé ?

    casper.run(function() {
        test.done();
    });

Enfin, nous executons l'ensemble des opérations définies et appelons la méthode Tester#done() afin de signifier que le test est terminé.

Export au format XUnit

L'option --xunit de la commande casper test permet d'exporter le résultat d'une suite de tests au format XUnit :

$ casperjs test exercices/3/1.js --xunit=log.xml

Ces logs dont utiles dans la perspective de la mise en place d'intégration continue avec un serveur comme Jenkins.

Export au format XUnit

Le fichier log.xml contient alors :

<?xml version="1.0" encoding="UTF-8"?>
<testsuites duration="0.94">
  <testsuite errors="0" failures="0" name="Google search tests"
             package="exercices/3/1" tests="4" time="0.94"
             timestamp="2013-04-29T16:12:01.211Z">
    <testcase classname="exercices/3/1"
              name="google homepage title is the one expected" time="0.55"/>
    <testcase classname="exercices/3/1"
              name="main form is found" time="0.001"/>
    <testcase classname="exercices/3/1"
              name="search term has been submitted" time="0.388"/>
    <testcase classname="exercices/3/1"
              name="google search for 'casperjs' retrieves 10 or more results"
              time="0.001"/>
    <system-out/>
  </testsuite>
</testsuites>

Outils & ressources

Merci !

1 / 91

#