421 lines
20 KiB
Markdown
421 lines
20 KiB
Markdown
## 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](/liens-interessants/).
|
||
|
||
J’ai essayé toutes sortes de solutions au fil des années dans divers projets, les plus connues et utilisées étant probablement [wkhtmltoimage](https://wkhtmltopdf.org) au plus bas niveau et ses différents wrappers pour PHP ou plus spécifiquement Laravel (et notamment [Browsershot](https://spatie.be/docs/browsershot/v4/introduction)), et des solutions sous nodejs dont [puppeteer](https://pptr.dev).
|
||
J’ai aussi, beaucoup plus récemment, essayé [Browserless](https://docs.browserless.io).
|
||
|
||
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](https://laravel.com/).
|
||
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](https://laravel.com/docs/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](https://www.selenium.dev).
|
||
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](https://gist.github.com/MogulChris/6f2facf768ac3f280e9ad765e531dd55) 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](https://laravel.com/docs/11.x/dusk).
|
||
En ce qui me concerne, puisque je travaille avec [Sail](https://laravel.com/docs/sail#laravel-dusk), je dois modifier mon `docker-compose.yml` :
|
||
|
||
```yaml
|
||
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 :
|
||
|
||
```yaml
|
||
services:
|
||
laravel.test:
|
||
# [...]
|
||
depends_on:
|
||
- pgsql
|
||
- redis
|
||
- selenium
|
||
```
|
||
|
||
Je relance les services :
|
||
|
||
```shell
|
||
sail down && sail up -d
|
||
```
|
||
|
||
Et j’installe Dusk :
|
||
|
||
```shell
|
||
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 à :
|
||
|
||
```php
|
||
$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 :
|
||
|
||
```php
|
||
$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 :
|
||
|
||
```php
|
||
/**
|
||
* 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.
|
||
|
||
```php
|
||
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](https://github.com/php-webdriver/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` :
|
||
|
||
```ini
|
||
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](https://fr.wikipedia.org/wiki/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*](/termes/artisanat/).
|
||
|
||
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](/pages/contact/) 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. |