Capturer des pages web avec Laravel Dusk

Illustration par DALL·E

  1. Introduction
  2. Présentation de Laravel Dusk
  3. Comment j’ai procédé
    1. Installation de Dusk
    2. Exploration du code
    3. Screenshoteuse
      1. name() et dataname()
      2. driver()
      3. take()
    4. Avantages
    5. Inconvénients
  4. Conclusion

Introduction

Depuis trois semaines, je travaille sur ce que j’appelle un “Big Fucking Refactoring” de mon moteur de site statique, avec lequel je génère mon blog. Dans le cadre de ce refactoring, il y a une fonctionnalité en particulier que je voulais améliorer : la capture d’écran pour les liens intéressants.

J’ai essayé toutes sortes de solutions au fil des années dans divers projets, les plus connues et utilisées étant probablement wkhtmltoimage au plus bas niveau et ses différents wrappers pour PHP ou plus spécifiquement Laravel (et notamment Browsershot), et des solutions sous nodejs dont puppeteer. J’ai aussi, beaucoup plus récemment, essayé Browserless.

Ces solutions m’ont rendu de fiers services, mais elles présentent toutes un fort désavantage : je dois modifier le Dockerfile de Laravel pour inclure tout un tas de librairies et de binaires qui alourdissent le container et font planer la menace d’incompatibilités lors de mises à jour, quand je ne dois pas télécharger la moitié d’Internet pour une méthode (je ne me lasse pas de la faire celle-là, désolé les nodeux 😙).

Je note également pas mal de limitations, et un manque certain de souplesse. Et, sauf dans le cas de Browsershot, évidemment, un manque d’intégration à l’ecosystème Laravel. Par exemple, puisque j’exécute Docker sur un Mac mini M2 avec Rosetta, j’ai ramé pour faire tourner puppeteer avec une version adéquate de Chrome : en cause, des binaires amd64 essayant de tourner sur une architecture arm64. Vous voyez le genre de problèmes auxquels je me suis frotté.

Alors, ce que j’ai toujours vraiment voulu faire, c’était mettre en œuvre Laravel Dusk. Ce BFR était l’opportunité rêvée pour m’y mettre.

Présentation de Laravel Dusk

Dusk est un package first-party pour Laravel permettant d’intégrer des tests frontend via Selenium. Selenium fourni une instance de navigateur que l’on peut piloter par le code. Parmi ses innombrables possibilités, il y en a donc une en particulier qui suscite mon intérêt : les captures d’écran.

Mais, étant donné que Dusk est un framework pour produire des tests, il n’y a pas de solution “simple” pour s’en servir en dehors des tests. En cherchant sur GitHub, on pourra trouver quelques essais ici ou là, parfois datés, plus maintenus et pas maintenables.

Je veux étendre l’usage de Dusk au-delà des tests. Et, je ne suis pas peu fier du résultat…

Comment j’ai procédé

Étant donné qu’il y a peu de ressources disponibles sur le web, et ça s’est traduit par des tentatives désespérées de ChatGPT pour m’aider, j’ai simplement dû faire un peu de rétro-ingénierie. Les spécialistes du thème considéreront l’usage de ce terme comme inapproprié, considérant la simplicité avec laquelle j’ai abouti à un résultat convaincant, et surtout, parce que rien n’est vraiment caché dans Laravel.

Installation de Dusk

On commence donc par installer Dusk, en suivant scrupuleusement la documentation fournie par Laravel. En ce qui me concerne, puisque je travaille avec Sail, je dois modifier mon docker-compose.yml :

    selenium:
        image: 'seleniarm/standalone-chromium'
        extra_hosts:
            - 'host.docker.internal:host-gateway'
        volumes:
            - '/dev/shm:/dev/shm'
        networks:
            - sail

Et je rajoute la dépendance à selenium dans le depends_on de mon container principal :

services:
    laravel.test:
        # [...]
        depends_on:
            - pgsql
            - redis
            - selenium

Je relance les services :

sail down && sail up -d

Et j’installe Dusk :

sail composer require laravel/dusk --dev
sail artisan dusk:install

Exploration du code

Laravel a alors créé un dossier tests/Browser, dans lequel on trouve une partie de ce qui va nous intéresser. Notamment, le fichier ExampleTest.php, fort simple à comprendre, et dont la principale caractéristique est d’hériter de DuskTestCase. On peut en profiter pour voir qu’instancier un navigateur se résume à :

$this->browse(function (Browser $browser) {
    $browser->visit('https://google.com')
        ->assertSee('Google');
});

Pour prendre une capture d’écran du site de Google, il suffirait de changer ce code de cette façon :

$this->browse(function (Browser $browser) {
    $browser->visit('https://google.com')
        ->screenshot('screenshot');
});

Ce qui :

  1. Nous oblige à lancer un test pour prendre la capture.
  2. Sauvegarde la capture dans le dossier tests/Browser/screenshots.

On doit donc chercher comment accéder à la méthode browse() depuis n’importe quelle classe, c’est-à-dire, une classe que nous allons créer ultérieurement.

La classe DuskTestCase contient une méthode importante :

/**
 * Create the RemoteWebDriver instance.
 */
protected function driver(): RemoteWebDriver
{
    $options = (new ChromeOptions)->addArguments(collect([
        $this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080',
    ])->unless($this->hasHeadlessDisabled(), function (Collection $items) {
        return $items->merge([
            '--disable-gpu',
            '--headless=new',
        ]);
    })->all());

    return RemoteWebDriver::create(
        $_ENV['DUSK_DRIVER_URL'] ?? 'http://localhost:9515',
        DesiredCapabilities::chrome()->setCapability(
            ChromeOptions::CAPABILITY, $options
        )
    );
}

Elle permet d’instancier un driver : c’est ce qui va nous permettre de communiquer avec Selenium et de le piloter. On va donc se garder ça sous le coude, et on va continuer d’explorer le code fourni par Laravel. La classe hérite de BaseTestCase, c’est-à-dire de la classe Laravel\Dusk\TestCase. Et, là, les choses deviennent intéressantes.

Cette classe implémente deux traits : ProvidesBrowser, qui nous fourni la méthode browse() que l’on veut obtenir, et SupportsChrome, qui permet spécifiquement de piloter Chrome, vous l’aurez deviné. J’aurais préféré éviter Chrome, mais, bon, tout est là, alors on va s’en servir.

Nous sommes prêts à commencer l’écriture de notre classe à screenshot.

Screenshoteuse

On va partir du cahier des charges suivant : la classe doit prendre un URL en entrée, et sortir une ressource que l’on pourra stocker ou modifier à loisir. On ne peut pas faire plus simple ! Pourtant, nous allons voir que nous avons besoin d’un peu plus de code que ce que l’on pourrait penser…

Voici l’intégralité de la classe, nous détaillerons ensuite.

namespace App\Classes;

use Exception;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Exception\Internal\UnexpectedResponseException;
use Facebook\WebDriver\Exception\UnknownErrorException;
use Facebook\WebDriver\Exception\UnrecognizedExceptionException;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Illuminate\Support\Str;
use Laravel\Dusk\Browser;
use Laravel\Dusk\Chrome\SupportsChrome;
use Laravel\Dusk\Concerns\ProvidesBrowser;

class Screenshot
{
    use ProvidesBrowser;
    use SupportsChrome;

    private $driver;

    private $optionsSets = [
        'normal' => [
            '--start-maximized',
            '--force-dark-mode',
            '--no-sandbox',
            '--disable-dev-shm-usage',
            '--ignore-certificate-errors',
            '--allow-insecure-localhost',
            '--window-size=1920,1080',
        ],
        'no-gpu' => [
            '--disable-gpu',
            '--headless',
            '--start-maximized',
            '--force-dark-mode',
            '--no-sandbox',
            '--disable-dev-shm-usage',
            '--ignore-certificate-errors',
            '--allow-insecure-localhost',
            '--window-size=1920,1080',
        ],
    ];

    private $tries = 0;

    /**
     * Create the RemoteWebDriver instance.
     */
    protected function driver(): RemoteWebDriver
    {
        if (!isset($this->driver)) {
            $options = $this->getDriverOptions();

            $this->driver = RemoteWebDriver::create(
                $_ENV['DUSK_DRIVER_URL'],
                DesiredCapabilities::chrome()->setCapability(
                    ChromeOptions::CAPABILITY, $options
                )
            );
        }

        return $this->driver;
    }

    protected function getDriverOptions()
    {
        $options = (new ChromeOptions)
            ->addArguments(
                collect($this->optionsSets[$this->optionsSet])
                    ->all()
            );

        return $options;
    }

    public function __construct(public string $url, public ?string $optionsSet = 'normal')
    {

    }

    public function name()
    {
        return Str::slug($this->url);
    }

    public function dataname()
    {
        return Str::slug('data_' . $this->url);
    }

    public function take()
    {
        Browser::$baseUrl            = $this->url;
        Browser::$storeScreenshotsAt = base_path('tests/Browser/screenshots');
        Browser::$storeConsoleLogAt  = base_path('tests/Browser/console');
        Browser::$storeSourceAt      = base_path('tests/Browser/source');

        $shouldRetry = false;

        $this->closeAll();

        try {
            $this->browse(function (Browser $browser) {
                $browser->resize(1920, 1080)
                    ->visit($this->url)
                    ->pause(1000)
                    ->screenshot('screenshot')
                    ->quit();
            });
        } catch (UnknownErrorException $ex) {
            // This could happen when SSL certificat is expired
            dump($ex->getMessage());
        } catch (UnexpectedResponseException $ex) {
            dump($ex->getMessage());
        } catch (UnrecognizedExceptionException $ex) {
            // This could happen on - some - YouTube links. Using the "no-gpu"
            // preset fixes the problem
            dump($ex->getMessage());

            $shouldRetry = true;

            $this->optionsSet = 'no-gpu';

            dump(sprintf('Retrying with options set %s', $this->optionsSet));
        } finally {
            $this->closeAll();
        }

        $this->tries++;

        if ($shouldRetry) {
            if ($this->tries === 3) {
                throw new Exception(sprintf('Maximum tries reach for url %s', $this->url));
            }

            unset($this->driver);

            return $this->take();
        }

        $imagePath = Browser::$storeScreenshotsAt . '/screenshot.png';

        if (!file_exists($imagePath)) {
            return null;
        }

        $imageContent = file_get_contents($imagePath);

        unlink($imagePath);

        return $imageContent;
    }
}

Oh my god. Me voilà embarqué dans l’utilisation de Chrome avec du code fourni par facebook, je vais cramer…

Je crois que j’ai pas loin de 150 liens intéressants dans ma rubrique dédiée, je pense donc avoir un “large” panel de sites à tester. Et, j’ai dû faire beaucoup de tests pour aboutir à ce résultat, parce que le code fourni par facebook est vraiment très, très académique.

Bien, donc, nous avons notre classe, et elle implémente les deux traits vus précédemment.

Ces traits nous obligent à définir quelques méthodes.

name() et dataname()

J’ignore ce que ces méthodes doivent retourner avec précision. Je suppose que cela a quelque chose à voir avec les sessions créées dans Selenium à chaque instanciation des navigateurs afin d’éviter les confusions, je me contente donc de retourner le slug de l’URL passé en argument du constructeur.

driver()

Cette méthode doit fournir une instance de… driver, ici un RemoteWebDriver mais il est théoriquement possible de créer un driver personnalisé. Ce n’est pas le but de ce que je suis en train de faire, donc je ne m’y suis pas intéressé plus que ça, mais ça pourrait être fun d’explorer la librairie php-webdriver.

Pour l’heure, on se contente d’un copier-coller de ce que la classe DuskTestCase nous propose, juste un tout petit peu remanié.

Deux choses sont particulièrement importantes ici :

  • on a créé une méthode getDriverOptions() qui permet de retourner un jeu d’options spécifiques (un sous-array de $this->optionsSets dont on cherche la clé fournie par $this->optionsSet)
  • on utilise exclusivement $_ENV['DUSK_DRIVER_URL'] pour obtenir l’URL de notre serveur Selenium

L’adresse du serveur par défaut dans les exemples de tests fournis par Laravel est http://localhost:9515, mais d’une part nous avons installé Selenium dans un container lié à notre projet, et d’autre part le port d’écoute est 4444.

On doit donc rajouter une ligne à notre .env :

DUSK_DRIVER_URL=http://selenium:4444

C’est une excellente chose : cette simple ligne de configuration va nous permettre une certaine mobilité de notre service, si, un jour, on veut utiliser un serveur externe.

On peut ensuite voir ce que l’on fait dans notre méthode custom.

take()

C’est ici que le plus important se passe. On commence par définir quelques options, presque exactement comme dans la classe TestCase : ce sont les appels à Browser::. J’ai laissé la plupart des valeurs par défaut, à l’exception de $baseUrl. Ça ne nous servira pas pour effectuer une capture d’écran, mais psychologiquement, je préfère spécifier l’URL que l’on s’apprête à screenshoter.

On commence par définir la variable $shouldRetry à false, parce que l’on aura parfois besoin de deux captures avec des réglages différents. On ferme ensuite tous les autres navigateurs éventuellement ouverts dans Selenium. Sans ça, souvent, on lèvera des exceptions de type UnexpectedResponseException, qui nous obligeront (ou en tout cas, qui m’ont obligé) à relancer tout le projet avec sail down && sail up -d.

Dans ces situations, on a beau envoyer des requêtes à Selenium, on ne verra rien dans les logs, et l’application continuera de tourner dans le vent.

On peut enfin appeler notre méthode browse() et obtenir une instance de navigateur. Pour mes besoins personnels, j’ai défini une taille d’écran de 1920x1080, et j’ai ajouté un temps de pause de 1000ms afin de laisser le temps aux ressources de se charger, avant d’effectuer la capture (qui enregistrera un fichier nommé screenshot dans le dossier prédéfini dans Browser::$storeScreenshotsAt), et de quitter le navigateur. Attention : c’est du PNG qui sort et, a priori, si l’on veut autre chose, on devra se débrouiller pour faire la conversion.

Sauf que, loi de Murphy oblige, la méthode quit() n’est pas toute-puissante, et il lui arrive de ne pas faire ce qu’elle est censée faire. J’ai dit que le code venait de facebook ? 😬

C’est d’ailleurs principalement pour cette raison que tout le code est englobé dans un try {} catch {}, avec plusieurs cas prédéfinis. Ce sont les cas que j’ai rencontré jusqu’à présent, il y en a beaucoup d’autres potentiels dans le namespace Facebook\WebDriver\Exception.

Attardons-nous simplement sur UnrecognizedExceptionException, qui semble survenir lorsque les options ne conviennent pas au site visité. Comme je l’ai mis en commentaire, j’ai notamment constaté que YouTube n’aime pas quand on n’a pas activé le headless et donc disable-gpu, options que vous pouvez voir tout en haut de la classe, dans private $optionsSets, avec la clé no-gpu.

Malheureusement, un coup ça marche comme ça, un coup ça ne marche pas comme ça… C’est étonnamment peu stable pour des outils de tests, mais vous m’objecterez, avec raison, que justement, nous sommes en train de détourner ces outils de leur objectif initial, alors…

Donc, si jamais l’exception UnrecognizedExceptionException fini par être levée (et elle le sera), on affecte true à $shouldRetry, on applique un nouveau set d’options (no-gpu, sous-entendant que jusqu’à maintenant, on utilisait le set normal), ce qui nous permet plus loin de recommencer le processus. Mais d’abord, on finally closeAll. Juste pour éviter d’énerver Selenium.

Sortant de cette boucle, on commence par voir si l’on doit recommencer le processus. Arrivé à trois essais, on abandonne, on a tout tenté ou presque, il n’y a plus rien à faire, et surtout, on arrête de s’acharner sur Selenium, parce qu’à ce stade, il est tout simplement possible que l’on essaye de capturer un site… hors-ligne.

Enfin, on récupère le contenu de l’image générée, on supprime le fichier intermédiaire, et l’on renvoie le résultat. En guise d’exercice pour mes lecteurs, trouvez le moyen de retourner directement la ressource sans avoir enregistré le fichier en premier. Hint : il faut passer directement par le driver.

Avantages

Je veux juste faire des screenshots des liens intéressants, et pourquoi pas, de mon propre site à travers le temps. Rien de très excitant ou même de réellement utile. Et bien, même pour faire ça, je veux le faire le mieux possible, et je ne ferai pas mieux qu’avec Laravel Dusk à mes côtés.

L’API risque de peu changer, donc je pense que ma classe est assez solide. Je peux paramétrer les options assez facilement (d’ailleurs, au prochain refactoring, elles vont bouger dans le dossier config, c’est obligé), le driver principal est interchangeable, l’URL de Selenium est adaptable à tout moment, bref, elle risque de moins bouger que toutes mes tentatives précédentes.

Le code est assez propre (on peut toujours mieux faire, surtout en résolvant l’exercice proposé ci-dessus), un peu plus long que ce que j’espérais, mais, plus court que ce que je craignais, donc je suis content.

En outre, je pense qu’il n’est même pas requis d’installer ou utiliser Selenium : l’URL par défaut étant http://localhost:9515 me laisse penser qu’il suffit d’avoir un Chrome disponible quelque part, paramétré pour le contrôle à distance. Ça, c’est bien pour ceux à qui ça ne met pas la rate au court-bouillon, pas comme moi…

Inconvénients

Si, par malheur, une exception non gérée devait être levée, il faut redémarrer la stack, ou en tout cas Selenium. Peut-être que l’on pourrait l’éviter en interagissant directement avec le driver, mais à quoi bon utiliser un framework si c’est pour utiliser des outils de bas niveau…

Conclusion

Je suis assez fier d’être arrivé à ce résultat du fait que rien sur Internet n’est réellement exploitable. Ces tentatives sont au mieux mignonnes, au pire inutilisables. Ici, on a une classe bien construite, simple, qui repose sur les fondations de Laravel. On n’a rien hérité, rien transformé, seulement adapté. Et on a pu le faire parce que Laravel est un framework d’artisans.

Je tiens à remercier ChatGPT qui s’est montré particulièrement penaud face à mes incursions dans l’inconnu. Il m’a toujours soutenu en me disant ”Oui, c’est possible de faire ça”, ”Je suis content que vous ayez réussi”, etc. Et il s’est montré compatissant quand il pédalait dans la semoule… 😁

J’espère que vous aurez trouvé cet article utile et intéressant. N’hésitez pas à me contacter pour me dire si vous avez réussi l’exercice !

Maintenant que j’ai de “beaux” screenshot dans la section, le prochain boulot va consister à coder un détecteur de liens morts. Mais, ça sera pour une prochaine fois.