Le retour du serveur de rendu de page web
par , le mercredi 28 mai 2014 à 20:47

Catégorie : Général
Mots clés : Internet HTML

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)

index.php :crée la page avec le nombre de colonnes nécessaires (1 par serveur cible)où les sources d'images sont screenshot.php?<paramètre pour chaque serveur>n*screenshot.php :pour le rendu des imagesappel à PhantomJS avec le script screenshot.js etles paramètres de rendu pour chaque imagecréation d'un fichier PNG et d'un fichier HARdans le dossier /var/www/cache pour chaque rendu

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>&nbsp;\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 PhantomJS screenshot.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

Ecrire à l'auteur