1
0
Fork 0
contenu/blog/2024/04/13/capturer-des-pages-web-avec.../index.md

20 KiB
Raw Blame History

Introduction

Depuis trois semaines, je travaille sur ce que jappelle 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.

Jai 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. Jai aussi, beaucoup plus récemment, essayé Browserless.

Ces solutions mont 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 dincompatibilités lors de mises à jour, quand je ne dois pas télécharger la moitié dInternet 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 dintégration à lecosystème Laravel. Par exemple, puisque jexécute Docker sur un Mac mini M2 avec Rosetta, jai 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 jai toujours vraiment voulu faire, cétait mettre en œuvre Laravel Dusk. Ce BFR était lopportunité rêvée pour my mettre.

Présentation de Laravel Dusk

Dusk est un package first-party pour Laravel permettant dintégrer des tests frontend via Selenium. Selenium fourni une instance de navigateur que lon 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 ny a pas de solution “simple” pour sen 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 lusage de Dusk au-delà des tests. Et, je ne suis pas peu fier du résultat…

Comment jai procédé

Étant donné quil y a peu de ressources disponibles sur le web, et ça sest traduit par des tentatives désespérées de ChatGPT pour maider, jai simplement dû faire un peu de rétro-ingénierie. Les spécialistes du thème considéreront lusage de ce terme comme inapproprié, considérant la simplicité avec laquelle jai abouti à un résultat convaincant, et surtout, parce que rien nest 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 jinstalle 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 dhériter de DuskTestCase. On peut en profiter pour voir quinstancier 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 nimporte quelle classe, cest-à-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 dinstancier un driver : cest 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 dexplorer le code fourni par Laravel. La classe hérite de BaseTestCase, cest-à-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 lon veut obtenir, et SupportsChrome, qui permet spécifiquement de piloter Chrome, vous laurez deviné. Jaurais préféré éviter Chrome, mais, bon, tout est là, alors on va sen 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 lon pourra stocker ou modifier à loisir. On ne peut pas faire plus simple ! Pourtant, nous allons voir que nous avons besoin dun peu plus de code que ce que lon pourrait penser…

Voici linté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 lutilisation de Chrome avec du code fourni par facebook, je vais cramer…

Je crois que jai pas loin de 150 liens intéressants dans ma rubrique dédiée, je pense donc avoir un “large” panel de sites à tester. Et, jai 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()

Jignore 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 lURL 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 nest pas le but de ce que je suis en train de faire, donc je ne my suis pas intéressé plus que ça, mais ça pourrait être fun dexplorer la librairie php-webdriver.

Pour lheure, on se contente dun 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 doptions 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 lURL de notre serveur Selenium

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

On doit donc rajouter une ligne à notre .env :

DUSK_DRIVER_URL=http://selenium:4444

Cest 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 lon fait dans notre méthode custom.

take()

Cest 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::. Jai laissé la plupart des valeurs par défaut, à lexception de $baseUrl. Ça ne nous servira pas pour effectuer une capture décran, mais psychologiquement, je préfère spécifier lURL que lon sapprête à screenshoter.

On commence par définir la variable $shouldRetry à false, parce que lon 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 mont 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 lapplication continuera de tourner dans le vent.

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

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

Cest dailleurs 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 jai rencontré jusquà présent, il y en a beaucoup dautres 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 lai mis en commentaire, jai notamment constaté que YouTube naime pas quand on na 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… Cest étonnamment peu stable pour des outils de tests, mais vous mobjecterez, avec raison, que justement, nous sommes en train de détourner ces outils de leur objectif initial, alors…

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

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

Enfin, on récupère le contenu de limage générée, on supprime le fichier intermédiaire, et lon renvoie le résultat. En guise dexercice 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 quavec Laravel Dusk à mes côtés.

LAPI risque de peu changer, donc je pense que ma classe est assez solide. Je peux paramétrer les options assez facilement (dailleurs, au prochain refactoring, elles vont bouger dans le dossier config, cest obligé), le driver principal est interchangeable, lURL 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 lexercice proposé ci-dessus), un peu plus long que ce que jespérais, mais, plus court que ce que je craignais, donc je suis content.

En outre, je pense quil nest même pas requis dinstaller ou utiliser Selenium : lURL par défaut étant http://localhost:9515 me laisse penser quil suffit davoir un Chrome disponible quelque part, paramétré pour le contrôle à distance. Ça, cest 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 lon pourrait léviter en interagissant directement avec le driver, mais à quoi bon utiliser un framework si cest pour utiliser des outils de bas niveau…

Conclusion

Je suis assez fier dêtre arrivé à ce résultat du fait que rien sur Internet nest 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 na rien hérité, rien transformé, seulement adapté. Et on a pu le faire parce que Laravel est un framework dartisans.

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

Jespère que vous aurez trouvé cet article utile et intéressant. Nhésitez pas à me contacter pour me dire si vous avez réussi lexercice !

Maintenant que jai 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.