Automatiser les captures d'écran de sites avec Python pour la surveillance

Capturez une page en PNG en une seule requête, écrivez les octets directement dans un fichier, et distinguez les cas via le content-type plutôt que de deviner quand une capture échoue. Testé en août 2026.

Un test de régression visuelle a besoin de voir si une page a changé. Une status page a besoin d’une miniature à jour de chaque site qu’elle surveille. Un rapport hebdomadaire a besoin de montrer à quoi une page ressemblait vraiment, pas juste d’y renvoyer par un lien. Ces trois cas ont besoin d’une image d’une URL, pas d’une description, et /v1/screenshot prend une URL et renvoie une image. Ce qui piège les gens la première fois, c’est que la réponse n’est pas du tout du JSON, ce qui compte parce que tous les autres endpoints FastAF le sont.

La réponse, ce sont des octets, pas du JSON

/v1/screenshot renvoie des données d’image brutes avec un content-type de image/png. Il n’y a pas d’enveloppe, pas de clé results, rien sur quoi appeler .json(). Écrivez le corps de la réponse directement dans un fichier :

import requests

API_KEY = "faf_live_..."  # votre clé FastAF

resp = requests.get(
    "https://api.fastaf.co/v1/screenshot",
    params={"url": "https://example.com"},
    headers={"Authorization": f"Bearer {API_KEY}"},
    timeout=60,
)

if resp.headers.get("content-type", "").startswith("image/"):
    with open("screenshot.png", "wb") as f:
        f.write(resp.content)
else:
    print("capture failed:", resp.json()["error"])

Ça coûte 5 crédits. Une capture par défaut de https://example.com est revenue en PNG 1920x1080, 22 318 octets. Appelez resp.json() par habitude et requests lève une erreur de décodage, parce qu’elle essaie de parser le corps comme du texte JSON et que quelques milliers d’octets de binaire PNG ne sont pas du JSON valide. resp.content est ce que vous voulez, les octets bruts ; resp.text vous redonnerait juste les mêmes octets décodés en une chaîne charabia, ce qui n’est pas ce que vous voulez écrire dans un fichier .png.

Détecter un échec correctement

Une requête échouée ne revient pas comme une image cassée, elle revient comme du JSON avec un objet error. C’est exactement pour ça que le code ci-dessus vérifie content-type avant de faire quoi que ce soit d’autre, plutôt que de supposer un succès et de découvrir trois fonctions plus tard que screenshot.png est en fait 40 octets de JSON avec une extension .png. Le content-type est le seul signal qui vous dise de façon fiable quelle forme vous avez récupérée, donc testez-le en premier, à chaque fois, plutôt que d’essayer de détecter une mauvaise image après coup en, disons, vérifiant le nombre d’octets ou en ouvrant le fichier avec une bibliothèque d’images et en attrapant l’exception que ça lève.

Pour un dashboard de surveillance, ça compte plus qu’il n’y paraît : le cas d’échec est exactement le moment où vous avez le plus besoin d’un signal propre, puisque ça veut généralement dire que la page cible est en panne ou en erreur, ce qui est justement ce que vous essayiez de détecter au départ. Un pipeline qui enregistre silencieusement un blob d’erreur JSON comme si c’était un PNG vous montrera une miniature cassée sans aucune explication au lieu du vrai message d’erreur qui était pourtant là, dans la réponse.

Taille et format

Au-delà de url, l’endpoint prend : fullPage (true pour capturer toute la page défilable au lieu d’un seul viewport), width et height pour régler le viewport avant la capture, wait_until (load, domcontentloaded, ou networkidle, pour les pages qui finissent de se rendre après l’événement de chargement initial), format (png, jpg, ou webp), et quality (0 à 100, pour les formats avec perte). wait_until est celui qui compte le plus pour tout ce qui est construit avec un framework frontend moderne : une page peut déclencher son événement load avant que le JavaScript côté client ait fini de peindre le contenu que vous voulez vraiment dans l’image, donc un dashboard ou une single-page app vaut généralement le coup d’être capturé avec une condition d’attente plus tardive que celle par défaut, au prix d’une requête un peu plus lente. Aucun de ces paramètres ne change le fait que vous récupérez des octets, seulement ce qu’il y a dedans :

resp = requests.get(
    "https://api.fastaf.co/v1/screenshot",
    params={
        "url": "https://example.com",
        "fullPage": "true",
        "format": "jpg",
        "quality": 80,
    },
    headers={"Authorization": f"Bearer {API_KEY}"},
    timeout=60,
)

Pour la régression visuelle, gardez width, height et format fixes d’une exécution à l’autre. Une diff entre deux captures n’a de sens que si les deux images ont été prises à la même taille ; changer le viewport entre deux exécutions vous montrera une différence qui n’a rien à voir avec la page.

Construire une boucle de surveillance

Une tâche de miniatures pour une status page ou un rapport, c’est une boucle sur des URL, qui enregistre chaque réponse sous un nom que vous pourrez retrouver plus tard et journalise celles qui ont échoué au lieu de les laisser tomber silencieusement :

import os

def capture_all(urls, out_dir="captures"):
    os.makedirs(out_dir, exist_ok=True)
    for i, url in enumerate(urls):
        resp = requests.get(
            "https://api.fastaf.co/v1/screenshot",
            params={"url": url, "width": 1280, "height": 800},
            headers={"Authorization": f"Bearer {API_KEY}"},
            timeout=60,
        )
        if resp.headers.get("content-type", "").startswith("image/"):
            with open(f"{out_dir}/{i}.png", "wb") as f:
                f.write(resp.content)
        else:
            print(url, "failed:", resp.json()["error"])

Dans nos mesures, la réponse médiane était de 3,9 secondes. C’est très bien pour une poignée de tuiles de dashboard sur un planning ; pour quelques centaines de pages, vous voudrez que les requêtes tournent en concurrence plutôt que les unes après les autres, puisqu’un passage séquentiel sur 500 URL à cette médiane prendrait à lui seul environ une demi-heure.

Ce que ça coûte

Volume Crédits (30 jours) Pack qui le couvre Coût
100 pages, vérifiées quotidiennement 15 000 Growth, 29 $ ~5,80 $ du pack
500 pages, vérifiées quotidiennement 75 000 Growth, 29 $ tout le pack
500 pages, vérifiées toutes les 6 heures 300 000 Scale, 99 $ tout le pack

Les requêtes échouées ne sont jamais facturées : les crédits sont réservés quand une requête démarre et rendus si elle ne revient pas avec un résultat, donc une cible cassée ne vide pas votre solde en silence. L’offre gratuite est de 500 crédits sans carte, 100 captures d’écran pour vérifier les temps de réponse et la taille de sortie sur vos propres pages avant de construire un planning autour de ça.