Dans un précédent article je décrivais la conception d'un serveur de capture de page web. Il s'agissait d'un système permettant le rendu d'une page web sous forme d'image avec possibilité de modifier l'association hostname<->IP du site web et donc de faire ce rendu sur des fermes de serveurs web. L'outils PhantomJS permet aussi un tel type de rendu sans utiliser de machine vrituelle ou container, solution retenue dans le précédent article.
L'intérêt de ce type d'outils est toujours le même : vérifier l'affichage, avec sa mise en forme et l'exécution du code javascript embarqué, d'une page web depuis un ou plusieurs serveurs web. Le client est le serveur de rendu qui se fait passer pour un navigateur web et la ou les cibles sont en général les frontaux web privés accessibles à travers un équilibreur de charge.
Nous allons ajouter à ce système l'affichage des temps de chargement de chaque élément de la page HTML. Pour cela nous utiliserons le script d'affichage HTTP Archive Viewer.
Il va de soi qu'il s'agit de vos serveurs, accessible en IP privée.
ATTENTION : aucune sécurité ne sera mise en place ! nous partons du principe que ce système de capture est à usage privé et donc nous ne parlerons pas de sécurité (réseau, scripts PHP...).
Cette version n'utilise plus de container mais PhantomJS qui est un outils de rendu de page web, hors interface graphique (headless) en ligne de commande seulement, avec sortie sous forme d'image (PNG, JPG...). PhantomsJS intégre le code WebKit qui est à la base des navigateurs Safari, Chrome... Il s'agit donc d'un rendu différent des autres navigateurs du marché (Firefox, IE...). L'affichage et l'exécution du Javascript n'est pas donc pas forcément au top de ce qui se fait aujourd'hui mais il est déjà plus que correct et bien plus rapide que l'utilisation d'un navigateur dans une VM ou un container. Et l'installation de la solution est bien plus simple.
Installation de PhantomJS
Pour avoir le support du User-Agent et des Cookies il faut récupérer une version récente des sources de PhantomJS et la compiler.
Pour Debian cela donne :
sudo apt-get update sudo apt-get install build-essential chrpath git-core libssl-dev libfontconfig1-dev libqtwebkit-dev libfreetype-dev git clone git://github.com/ariya/phantomjs.git cd phantomjs git checkout 1.9 ./build.sh
ce qui fourni l'exécutable
./bin/phantomjs
Arborescence du site web de capture
Chemin Contenu /var/www/ DocumentRoot du serveur web. /var/www/phantomjs Binaire de PhantomJS, celui compilé au chapitre précédent (à copier à cet emplacement). /var/www/index.php Page pincipale du système de rendu (voir source plus loin). /var/www/screenshot.php Rendu d'une page web sous forme d'image (voir source plus loin). C'est ce script qui appelle PhantomJS. /var/www/screenshot.js Script de rendu PhantomJS (voir source plus loin) avec extraction des temps de chargement des éléments de la page. /var/www/cache/ Dossier de cache pour les images des sites web Ce dossier doit appartenir à l'utilisateur du serveur web car on va y écrire les images et les logs de rendus : chown www-data.www-data /var/www/cache
/var/www/har Scripts d'affichage des HTTP Archive Logs, fichiers de log des timing de téléchargement des éléments de la page : cd /var/www/har
wget https://harviewer.googlecode.com/files/harviewer-2.0-15.zip
unzip harviewer-2.0-15.zip
rm harviewer-2.0-15.zip
Page web et script de capture
La logique de rendu est la suivante :
Saisie dans la page index.php de : url du site à rendre, liste des IP des serveurs web, éventuelles options (taille, user-agent, cookies)
La page principale index.php est :
<html> <head> <title>Screenshot web page renderer</title> </head> <body> <?php $url = isset($_REQUEST['url']) ? $_REQUEST['url'] : 'http://www.kozodo.com'; $ip = isset($_REQUEST['ip']) ? $_REQUEST['ip'] : '10.0.0.1 10.0.0.2'; $useragent = isset($_REQUEST['useragent']) ? $_REQUEST['useragent'] : ''; $width = isset($_REQUEST['width']) ? $_REQUEST['width'] : ''; $height = isset($_REQUEST['height']) ? $_REQUEST['height'] : ''; $cookies = isset($_REQUEST['cookies']) ? $_REQUEST['cookies'] : ''; $servers = explode("\n", $ip); $timing = rand(); ?> <script> var servers_groups = { '' : '', 'Front LB' : '10.0.0.1\10.0.0.2', 'Front production' : '82.224.254.135', }; </script> <form name=render> <table border=0 valign=top> <tr valign=top> <td>URL :<br> <input type=text size=50 name=url value="<?= $url; ?>"/> <td>Target server(s) IP : <br><textarea rows=6 cols=40 name=ip><?= $ip; ?></textarea> <td><br> <table> <tr> <td>Servers groups : <td> <select onchange="document.forms['render']['ip'].value = groups.options[groups.selectedIndex].value " name=groups> <script> var key; for (key in servers_groups) document.write("<option value='", servers_groups[key], "'>", key, "</option>\n"); </script> </select> <tr> <td>User-Agent : <td><input type=text name="useragent" size=50 value="<?=$useragent?>"> <tr> <td>Taille fixe : <td>L=<input type=text name="width" size=5 value="<?=$width?>"> H=<input type=text name="height" size=5 value="<?=$height?>"> <tr> <td>Cookies : <td><input type=text name="cookies" size=50 value="<?=$cookies?>"> </table> <tr> <td><input type=submit value=Render> </table> </form> <hr> <style> img { height:100%; width: 100%; max-width:100%; max-height:100%; } </style> <?php print "<table border=1 width='100%' height='100%'>\n"; print " <tr height='100%'>\n"; foreach ($servers as $srv) { $srv = trim($srv); if ($srv) { $url_params = parse_url($url); $host = $url_params['host']; $urlencoded = urlencode(unparse_url($url_params, array('host' => $srv))); $filename = htmlentities(str_replace('/', '_', escapeshellcmd("snapshot-$url-$srv-$host-$timing"))); $ua = urlencode($useragent); $ck = urlencode($cookies); $w = round(100 / count($servers)); print " <td align=center valign=top width='$w%' height='100%'>\n"; print " <a href='har?inputUrl=http://www.kozodo.com/cache/$filename.harp'>Page loading graph</a><br>\n"; print " <a href='cache/$filename.png'><img src='screenshot.php?url=$urlencoded&ip=$srv&host=$host&filename=cache/$filename&useragent=$ua&width=$width&height=$height&cookies=$ck'></a> \n\n"; } } print "</table>\n"; function unparse_url($parsed_url, $new_values = '') { if (!empty($new_values)) $parsed_url = array_merge($parsed_url, $new_values); $scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . '://' : ''; $host = isset($parsed_url['host']) ? $parsed_url['host'] : ''; $port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : ''; $user = isset($parsed_url['user']) ? $parsed_url['user'] : ''; $pass = isset($parsed_url['pass']) ? ':' . $parsed_url['pass'] : ''; $pass = ($user || $pass) ? "$pass@" : ''; $path = isset($parsed_url['path']) ? $parsed_url['path'] : ''; $query = isset($parsed_url['query']) ? '?' . $parsed_url['query'] : ''; $fragment = isset($parsed_url['fragment']) ? '#' . $parsed_url['fragment'] : ''; return "$scheme$user$pass$host$port$path$query$fragment"; } ?> </body> </html>
Quelques points à vérifier ou à modifier :
L'URL de base
http://www.kozodo.com/
doit correspondre au DocumentRoot/var/www
. Eventuellement vous pouvez installer l'outils dans un sous-dossier en corrigeant l'URL.A vous de définir la variables
servers_groups
pour aider à la saisie des hostname ou IP des serveurs cibles.Le script
screenshot.php
qui va lancer PhantomJS pour le rendu est :<?php $url = $_REQUEST['url']; $ip = $_REQUEST['ip']; $host = $_REQUEST['host']; $filename = $_REQUEST['filename']; $useragent = isset($_REQUEST['useragent']) ? $_REQUEST['useragent'] : ''; $width = isset($_REQUEST['width']) ? $_REQUEST['width'] : ''; $height = isset($_REQUEST['height']) ? $_REQUEST['height'] : ''; $cookies = isset($_REQUEST['cookies']) ? $_REQUEST['cookies'] : ''; exec("./phantomjs screenshot.js '$url' '$ip' '$host' '$filename' '$useragent' '$width' '$height' '$cookies'", $output, $erreur); if (!$erreur) { header('Content-Type: image/jpeg'); readfile($filename . '.png'); } else { header('Content-Type: text/plain'); print implode($output, "\n"); } ?>
Ce script correspond au HREF de chaque image rendue dans la page principale. Ainsi c'est le navigateur qui provoque le rendu lors du chargement des composants de la page
index.php
.Chaque appel à
screenshot.php
provoque l'appel PhantomJSscreenshot.js
dont le code est :var system = require('system'); var page = require('webpage').create(); var fs = require('fs'); /userfiles/image/ // We render the url web page in a file var url = system.args[1]; // url with the server IP var ip = system.args[2]; // the server IP to search in the url var host = system.args[3]; // the Host header valu to set for the main page var filename = system.args[4]; // the taget image file to create var useragent = system.args[5]; // to overwrite the User-Agent HTTP header var width = system.args[6]; // To fix the width (or nothing for auto width) var height = system.args[7]; // To fix the height (or nothing for auto height) var cookies = system.args[8]; // To set the Cookies HTTP header //-------------------------------------------------------------------------------- // Usefull debug function function print_r(theObj, base, level) { if (theObj.constructor == Array || theObj.constructor == Object) { for (var p in theObj) { if (theObj[p].constructor == Array || theObj[p].constructor == Object) { console.log(base + "["+p+"] => " + typeof(theObj)); print_r(theObj[p], base + "["+p+"]", level + 1); } else { console.log(base + "["+p+"] => " + theObj[p]); } } } }; //-------------------------------------------------------------------------------- // Create HAR file tools if (!Date.prototype.toISOString) { Date.prototype.toISOString = function () { function pad(n) { return n < 10 ? '0' + n : n; } function ms(n) { return n < 10 ? '00'+ n : n < 100 ? '0' + n : n } return this.getFullYear() + '-' + pad(this.getMonth() + 1) + '-' + pad(this.getDate()) + 'T' + pad(this.getHours()) + ':' + pad(this.getMinutes()) + ':' + pad(this.getSeconds()) + '.' + ms(this.getMilliseconds()) + 'Z'; } } function createHAR(address, title, startTime, resources) { var entries = []; resources.forEach(function (resource) {php var request = resource.request, startReply = resource.startReply, endReply = resource.endReply; if (!request || !startReply || !endReply) { return; } // Exclude Data URI from HAR file because // they aren't included in specification if (request.url.match(/(^data:image\/.*)/i)) { return; } entries.push({ startedDateTime: request.time.toISOString(), time: endReply.time - request.time, request: { method: request.method, url: request.url, httpVersion: "HTTP/1.1", cookies: [], headers: request.headers, queryString: [], headersSize: -1, bodySize: -1 }, response: { status: endReply.status, statusText: endReply.statusText, httpVersion: "HTTP/1.1", cookies: [], headers: endReply.headers, redirectURL: "", headersSize: -1, bodySize: startReply.bodySize, content: { size: startReply.bodySize, mimeType: endReply.contentType } }, cache: {},<pre class="brush:jscript;"> timings: { blocked: 0, dns: -1, connect: -1, send: 0, wait: startReply.time - request.time, receive: endReply.time - startReply.time, ssl: -1 }, pageref: address }); }); return { log: { version: '1.2', creator: { name: "PhantomJS", version: phantom.version.major + '.' + phantom.version.minor + '.' + phantom.version.patch }, pages: [{ startedDateTime: startTime.toISOString(), id: address, title: title, pageTimings: { onLoad: page.endTime - page.startTime } }], entries: entries } }; } //-------------------------------------------------------------------------------- // Get HAR information page.onLoadStarted = function () { page.startTime = new Date(); }; page.onResourceRequested = function (req, f) {http://www.kozodo.com/ page.resources[req.id] = { request: req, startReply: null, endReply: null }; // If this is the master website (ip start the url), add a Host header with the right website name // this is the way to override the target server to load the url from console.log("--------------------------"); print_r(req, "", 0); if (req.url.indexOf("http://" + ip) == 0) { f.setHeader("Host", host); console.log("Get it !!!!!!!!!!!! => " + host); } }; page.onResourceReceived = function (res) { if (res.stage === 'start') { page.resources[res.id].startReply = res; } if (res.stage === 'end') { page.resources[res.id].endReply = res; } }; //-------------------------------------------------------------------------------- // Render the full page to a file page.address = url; page.resources = []; if (useragent) page.settings.userAgent = useragent; if (width || height) page.viewportSize = { 'width': width, 'height': height }; if (cookies) { var cookies_array = cookies.split(';'); cookies_array.forEach(function(cooky) { var cook = cooky.split('='); phantom.addCookie({ 'name' : cook[0].trim(), 'value' : cook[1].trim(), 'domain' : ip });php }); }; page.open(page.address, function(status) { // For HAR file creation if (status !== 'success') { console.log('FAIL to load the address'); phantom.exit(1); } else { page.endTime = new Date(); page.title = page.evaluate(function () { return document.title; }); har = createHAR(page.address, page.title, page.startTime, page.resources); fs.write(filename + '.harp', "onInputData(" + JSON.stringify(har, undefined, 4) + ")", 'w'); // Render the page now page.render(filename + '.png'); phantom.exit(); } });
Ce code a deux fonctions : rendre la page sous forme d'image vers un fichier PNG et enregistrer le timing du chargement des éléments de la page vers un fichier HAR. Les deux fichiers sont créée dans le dossier /var/www/cache.
Test de rendu
Voici a quoi reseemble une session de rendu :
Page principale après saisie des paramètres (URL, IP cibles...) et clic sur le bouton Render :
Comme chaque image est un lien vers un cache, un simple clic droit et Ouvrir l'image dans une autre page/onglet nous donne la vue complète de la page rendue :
Encore un clic sur l'image pour l'afficher en taille réelle :
Que l'on peut faire défiler complétement dans le navigateur. Il s'agit d'un rendu PNG donc sans perte.
Notez que PhantomJS fait toujours un rendu complet même si la hauteur demandée est inférieure à la hauteur complète de la page.
Les liens Page loading graph sur la page principale nous affichent les temps de téléchargement avec l'outils HTTP Archive Viewer; ce qui donne :
On a ici un exemple avec déploiement d'un noeud pour avoir plus d'information.
Là aussi le fichier HAR est mis en cache sur le serveur et il est donc facile d'y revenir ou d'en faire un lien dans un email.
A propos du cache, il est judicieux d'ajouter un script de purge en crontab.
Voilà. Ce nouveau système est certainnement moins précis que le précédent (obligatoirement moteur Webkit) mais beaucoup plus simple (pas de VM ou container à installer) et encore plus rapide (pas de gestion de verrou qui limite le nombre de rendus simultanés).
De plus on peut facilement changer le user-agent et envoyer les cookies que l'on veut. De quoi tester le rendu d'une version mobile ou tablette des sites web.
A bientôt.
Antoine