Un site web peut changer sans que rien de ce qu'un visiteur remarque ne change. Un compteur de commentaires mis à jour ou un attribut de tracking modifie le hash du HTML, alors qu'une nouvelle image hero peut laisser le balisage presque intact. Aucun de ces deux cas n'indique à un système de monitoring si un élément important de la landing page a réellement changé.

Pour mesurer ce que voient les visiteurs, nous avons construit un moniteur Python qui capture les pages rendues avec Crawlbase, compare les screenshots avec l'indice de similarité structurelle (SSIM) et ne demande à un modèle de vision de décrire la différence que lorsqu'un seuil basé sur l'image est franchi. Il conserve le HTML du même crawl pour recoupement, écrit des alertes locales et maintient une baseline pour chaque URL et chaque appareil.

Le code se trouve dans ScraperHub/visual-change-detection-with-screenshots-and-vision-models, avec un checkpoint par étape sous steps/ et le moniteur complet sous final/, ainsi que des tests, la configuration et des exemples de planification. Les chiffres ci-dessous proviennent d'un petit test réel sur Hacker News et Wikipedia, pas d'un benchmark synthétique.

En bref
  • Capturez un JPEG pleine page avec la Crawling API et téléchargez-le immédiatement, avec le HTML de la même réponse ; le HTML n'est pas stocké avec un screenshot.
  • Normalisez les deux screenshots à une largeur commune, puis notez-les avec le SSIM et un ratio de zone modifiée. Il suffit que l'un des deux scores franchisse son seuil pour ouvrir la porte à un appel au modèle de vision.
  • Traitez le paragraphe du modèle comme une explication candidate. Les screenshots, la heatmap et le HTML enregistré constituent les preuves.
Une passe par URL et par appareil : capture, enregistrement du JPEG et du HTML, score par rapport à la baseline, et appel au modèle de vision uniquement quand la porte s'ouvre.

Pourquoi les hashes HTML ne disent pas ce qui a changé visuellement

Sur deux crawls desktop de Hacker News, le HTML est passé de 34 626 à 34 508 octets et les préfixes SHA-256 différaient : 45b9d53f52b3 contre efc0c606f53b. L'article Python de Wikipedia a lui aussi changé au niveau du balisage, de 1 309 531 à 1 310 770 octets, avec des hashes différents. Pourtant, le h1 extrait de Wikipedia est resté Python (programming language).

Un hash indique si deux séquences d'octets sont identiques. Il ne distingue pas un nonce renouvelé d'une offre modifiée, et il ne mesure ni la mise en page ni les pixels. À l'inverse, une image peut changer sans modification structurelle évidente du HTML. Pour le monitoring de landing pages, la question utile n'est pas le document a-t-il changé ? mais qu'est-ce qui a changé dans la page rendue, et cela mérite-t-il une analyse ?

Les cibles du test étaient l'index de Hacker News sur desktop et mobile, et l'article Python de Wikipedia sur desktop. Chaque combinaison URL et appareil a sa propre baseline de screenshot.

Capturer des screenshots rendus en JavaScript avec Crawlbase

Le moniteur utilise la Crawling API avec un JavaScript token. Chaque capture demande screenshot=true, store=true et format=json; le scroll et un temps d'attente laissent le contenu en lazy loading apparaître avant la prise du screenshot. L'étape de capture construit les paramètres sous forme de chaînes, de sorte que l'encodage d'URL envoie true plutôt que la valeur Python True:

python
# steps/02-capture/capture.py
def build_options(
    device: str,
    page_wait: int,
    scroll_interval: int,
    css_click_selector: str = "",
) -> dict[str, str]:
    """Query params for one rendered JPEG. Strings, so urlencode sends true rather than True."""
    options = {
        "screenshot": "true",
        "store": "true",
        "format": "json",
        "device": device,
        "scroll": "true",
        "scroll_interval": str(scroll_interval),
        "page_wait": str(page_wait),
    }
    selector = with_body_fallback(css_click_selector)
    if selector:
        options["css_click_selector"] = selector
    return options

Pour ce test, page_wait=2000 ajoutait deux secondes après le chargement. scroll_interval=10 est la plus petite valeur acceptée par l'API, et ce n'est pas une limite de temps : l'API la convertit en trois passes de scroll jusqu'en bas au maximum et, à chaque passe, attend jusqu'à 40 secondes que la page s'agrandisse. Une page qui ne s'agrandit jamais, comme l'index de Hacker News, passe tout de même environ 43 secondes dans l'étape de scroll. Le paramètre optionnel country a été omis, le routage était donc automatique.

Un css_click_selector peut fermer un bandeau avant la capture. Le .mw-cookiebanner-dismiss de la cible Wikipedia est envoyé sous la forme .mw-cookiebanner-dismiss,body, car sans scroll un sélecteur sans correspondance fait échouer la requête avec cb_status 595, et le repli ,body correspond toujours. Avec scroll=true, que ce moniteur envoie systématiquement, l'API clique plutôt à l'intérieur de sa boucle de scroll et ignore simplement un élément absent. Ce chemin accepte une seule liste de sélecteurs : limitez-vous donc à un sélecteur unique plutôt qu'à une chaîne de clics | quand vous scrollez.

Le screenshot=true de la Crawling API renvoie un JPEG pleine page par défaut. Avec mode=viewport, width et height redimensionnent et recadrent cette capture pleine page exactement à ces dimensions, depuis le coin supérieur droit par défaut ; sans mode=viewport ils sont ignorés. La Screenshots API (un produit distinct) n'accepte plus de nouvelles inscriptions : cette implémentation reste donc sur la Crawling API. Le Crawlbase Web MCP Server propose aussi un outil crawl_screenshot pour les captures interactives, mais un moniteur planifié a besoin de sa propre boucle de capture reproductible.

Le crawl stabilise la page avant que le JPEG n'existe : appareil, scroll, clic sur un bandeau et temps d'attente en entrée ; une URL de screenshot temporaire et son ID de requête en sortie.

La première tentative sur Hacker News a expiré avec un timeout client de 120 secondes, et une tentative de scroll supplémentaire a renvoyé un HTTP 500. Les exécutions réussies utilisaient 240 secondes, un réglage côté client qui laisse de la marge et non une demande de rendu de quatre minutes pour chaque page. Le scroll change aussi le coût : une requête avec scroll facture ses 8 premières secondes comme une requête JavaScript, puis chaque tranche de 5 secondes supplémentaire comme une autre, comme le précisent les paramètres de rendu de la documentation. Avec environ 43 secondes de scroll, la capture d'une page qui ne s'agrandit jamais est facturée environ huit requêtes JavaScript : ne scrollez donc que si le contenu que vous comparez se charge sous la ligne de flottaison.

Six captures réussies ont renvoyé cb_status 200, mais leurs dimensions variaient beaucoup :

Capture Taille du screenshot Octets JPEG
Hacker News desktop, baseline 3840×2144 215 392
Hacker News desktop, comparaison 1939×1249 199 382
Hacker News mobile, baseline 375×1777 175 304
Hacker News mobile, comparaison 1080×2324 159 983
Wikipedia desktop, baseline 1680×1050 198 864
Wikipedia desktop, comparaison 2560×1080 215 711

Les requêtes rendues n'utilisent pas une fenêtre de navigateur fixe, si bien que la largeur d'une capture pleine page peut varier d'un crawl à l'autre, même pour le même appareil. Cette variabilité devient la principale limite lorsque nous lisons les différences d'image ci-dessous.

Télécharger le JPEG avant l'expiration de son URL

Avec format=json, la réponse contient un screenshot_url dans le corps JSON (avec le format de réponse HTML, il arrive plutôt sous forme d'en-tête) ; la documentation des réponses couvre les deux cas. Crawlbase documente ce lien comme temporaire, et il contient le token de votre compte : téléchargez donc le JPEG immédiatement et ne journalisez ni ne partagez jamais l'URL elle-même.

Le moniteur télécharge immédiatement le JPEG dans shots/{url_hash}/{device}/{timestamp}.jpg, vérifie la signature JPEG FF D8, puis écrit via un fichier temporaire .part avant de remplacer la destination. Il enregistre aussi le HTML de la même réponse à côté de l'image dans un fichier .html dédié, pour que les preuves restent sur disque. Sur ces réponses, l'ID de requête était disponible dans le paramètre de requête rid de l'URL du screenshot :

python
# steps/02-capture/capture.py
def rid_from_screenshot_url(url: str) -> str:
    if not url:
        return ""
    values = parse_qs(urlparse(url).query).get("rid") or []
    return values[0] if values else ""

Le premier screenshot desktop réussi de Hacker News portait le rid 907337a6a19e932cabde4165, et son URL /storage renvoyait un JPEG. C'est voulu : sur une requête de screenshot, Cloud Storage conserve l'image sous le rid de la requête et ne stocke pas le HTML, avec ou sans store=true. Activer store=true conserve le JPEG comme page stockée et ajoute le coût de stockage ; le moniteur n'en dépend pas, car il lit le HTML enregistré localement.

Normaliser les screenshots, puis calculer le SSIM

Le SSIM compare des régions d'image correspondantes, les deux entrées doivent donc avoir les mêmes dimensions. L'étape de normalisation redimensionne chaque screenshot à 1280 pixels de large avec un rééchantillonnage Lanczos, puis recadre les deux depuis le haut à leur hauteur commune la plus courte :

python
# steps/03-normalize/normalize.py
shared = min(fitted_before.height, fitted_after.height)
if shared < 1:
    raise ValueError("normalized height is empty")
box = (0, 0, width, shared)

Cela rend les tableaux comparables ; cela ne rend pas les mises en page équivalentes. Les captures mobiles de Hacker News, par exemple, sont redimensionnées à environ 1280×6065 et 1280×2754. Le SSIM ne mesure que la partie supérieure commune, tandis que l'écart de hauteur de 3 311 pixels est enregistré séparément. Si un breakpoint responsive a changé entre les captures, le score mélange changements de mise en page et changements de contenu.

L'étape de comparaison convertit les deux images en niveaux de gris et calcule structural_similarity avec data_range=255 et une fenêtre de sept pixels. OpenCV calcule la différence absolue en niveaux de gris, et les pixels qui diffèrent de plus de 24 niveaux d'intensité comptent dans le ratio de zone modifiée. Les contours deviennent des boîtes englobantes (les douze plus grandes, en ignorant celles de moins de 64 pixels carrés), et la heatmap teinte en rouge les pixels modifiés et encadre les boîtes. La passe de comparaison a mesuré :

Cible SSIM Zone modifiée Écart de hauteur normalisé
Hacker News desktop 0.7934 8.27% 110 px
Hacker News mobile 0.5468 24.50% 3 311 px
Wikipedia desktop 0.5694 30.99% 260 px

Une fixture de test identique de 80×80 a obtenu un SSIM d'au moins 0.99 sans aucune zone modifiée. Les trois paires réelles étaient bien plus éloignées, mais les chiffres seuls ne permettent pas de distinguer un hero modifié d'un bloc de tarifs modifié. Les screenshots n'étaient pas cadrés de façon cohérente, ce qui fait de la variabilité de capture un facteur de confusion.

Filtrer le modèle de vision avec des scores d'image peu coûteux

Un modèle de vision n'a pas besoin de lire chaque capture. Le moniteur décide d'abord si la différence SSIM ou le ratio de zone modifiée est assez important pour justifier une analyse, et la porte est volontairement minimale :

python
# steps/05-gate/gate.py
def crosses(ssim: float, changed_area: float, ssim_delta: float, area_delta: float) -> bool:
    return (1.0 - ssim) >= ssim_delta or changed_area >= area_delta

La configuration d'exemple, final/targets.example.toml, définit ssim_delta=0.04 et area_delta=0.03. L'un ou l'autre suffit à ouvrir la porte : une différence structurelle d'au moins 0.04, ou des pixels modifiés couvrant au moins 3% de la zone comparée. Ce sont des seuils de départ issus de l'implémentation et de ses tests, pas des seuils validés sur de nombreuses exécutions calmes en production.

Deux scores peu coûteux et déterministes décident si un appel au modèle a lieu. Il suffit que l'un des deux franchisse son seuil.

Toutes les paires réelles ont franchi les deux seuils, ce qui ne signifie pas que chaque page a changé de façon significative. Un carrousel, une publicité tournante, une horloge ou un saut de largeur de capture peuvent aussi ouvrir la porte. En production, calibrez les seuils sur des captures répétées dans des conditions stables, et masquez les régions dynamiques connues quand c'est possible.

Crawlbase Crawling API

Rendez une page dans un vrai navigateur et récupérez un screenshot pleine page avec le HTML du même crawl, prêt à comparer. Les requêtes échouées ne sont pas facturées. Commencez avec jusqu'à 5 000 requêtes gratuites, sans carte bancaire.

Demander à un modèle de vision ce qui a changé, et vérifier sa réponse

Quand la porte s'ouvre, l'étape d'explication envoie les screenshots avant et après à gpt-4o. Chaque image est réduite à un côté maximal de 1280 pixels, réencodée en JPEG de qualité 70 et soumise avec detail="low". Le prompt demande un paragraphe factuel limité aux différences visibles, plus un label : pricing, hero, banner ou other.

La passe de vérification utilisait --max-vision-calls 1, si bien que seule la comparaison desktop de Hacker News a été commentée ; les paires mobile et Wikipedia ont tout de même produit des scores et des alertes. Le modèle a renvoyé other, mais son paragraphe mentionnait des titres d'articles et une année de batch YC qui n'étaient visibles dans aucun des deux screenshots. C'est un mode d'échec utile à repérer tôt : une explication plausible n'est pas forcément une description fidèle des pixels.

Le parser devait aussi gérer un LABEL: other ajouté à la fin d'une phrase plutôt que sur sa propre ligne. Après le parsing, l'alerte conserve le paragraphe du modèle tel quel, sans le label. Le SSIM, la heatmap et les screenshots bruts restent les preuves principales ; le paragraphe est une interprétation à relire, pas un journal de changements vérifié.

Pour les changements étiquetés pricing, hero ou banner, le moniteur peut extraire du texte d'appui depuis le HTML enregistré lors de la même capture :

python
# steps/08-monitor/monitor.py
if vision and needs_html(label):
    extracted = storage_fn(
        token,
        shot.rid,
        target.extract_selector,
        label,
        timeout=settings.timeout,
        html_path=shot.html_path,
    )

Malgré son nom, storage_fn ne passe pas par le stockage : l'implémentation par défaut _extract_saved lit html_path en local. Un sélecteur configuré (une balise, #id ou .class) peut extraire un élément, comme sur Wikipedia le h1, resté inchangé ; sans sélecteur, l'extraction de prix cherche un montant en devise avant de se rabattre sur le titre. Cela ajoute du contexte, mais une simple extraction ne prouve pas que le modèle a correctement identifié le changement. Dans ce test, le seul label était other, la branche HTML ne s'est donc pas exécutée.

Stocker les alertes, faire tourner les baselines et planifier le moniteur

Chaque franchissement crée une alerte JSON avec l'URL, l'appareil, le SSIM, le ratio de zone modifiée, le rid, le chemin de la heatmap, le label et l'éventuelle explication générée. La heatmap PNG reste sur disque. Si SLACK_WEBHOOK_URL est défini, le notifier publie un résumé texte ; il n'envoie pas le PNG.

Le test a créé trois alertes JSON, une par comparaison, sans webhook Slack configuré. La passe de baseline a pris 173 secondes et la passe de comparaison 391 secondes. SQLite a enregistré six exécutions : trois baselines initiales et trois comparaisons. Seule la comparaison desktop avait un label et un paragraphe générés par le modèle.

Le gestionnaire de baselines SQLite, final/baselines.py, conserve une baseline par URL et par appareil dans la table baselines ; les tables runs et alerts gardent l'historique. Avec rotate_after_n=5, le dernier screenshot devient la baseline après cinq comparaisons, calmes comprises. Acquitter une alerte avec --ack <id> fait immédiatement passer la baseline au screenshot de cette alerte, de sorte qu'un changement relu devient la nouvelle référence.

La baseline ne bouge que volontairement : après une alerte acquittée, ou après le nombre configuré d'exécutions comparées.

Pour l'essayer, clonez le dépôt, installez les dépendances Python, copiez final/.env.example vers final/.env et définissez CRAWLBASE_JS_TOKEN. Définissez OPENAI_API_KEY pour activer les descriptions du modèle, et laissez SLACK_WEBHOOK_URL vide pour n'avoir que des alertes locales. Depuis la racine du dépôt :

bash
python -m pip install -r requirements.txt
python -m pytest final
python final/monitor.py --once

Planifiez-le avec cron, en lançant le moniteur avec --once plutôt qu'en gardant un processus actif. Les chemins dans targets.example.toml sont résolus relativement à ce fichier.

Ce que le test réel enseigne

Le pipeline a capturé six screenshots, produit trois comparaisons et heatmaps, filtré une seule requête de vision et écrit trois alertes locales. Il a aussi révélé deux problèmes à corriger avant d'utiliser le même workflow pour le monitoring de concurrents ou des contrôles de régression visuelle.

D'abord, fixez le rectangle de capture avant d'ajuster les seuils. Les labels d'appareil seuls ne produisent pas des largeurs de screenshot stables, car la fenêtre de rendu varie. mode=viewport avec width et height fixes donne des dimensions de sortie fixes, mais la page reste mise en page à la largeur de fenêtre du rendu : vérifiez donc la mise en page sur des captures répétées et, pour un monitoring pleine page, comparez aussi la largeur, l'échelle et la hauteur du contenu. Gardez des temps d'attente, une locale et un comportement de bandeau cohérents, et envisagez de comparer des régions choisies quand la page entière contient du contenu dynamique sans rapport.

Ensuite, traitez la narration de l'IA comme une explication candidate. Le seul appel de vision a décrit des détails impossibles à confirmer dans les screenshots. Quand une alerte compte, examinez la paire avant/après et la heatmap, puis recoupez les changements de texte ou de prix avec le HTML enregistré. Ne versez pas un paragraphe généré tel quel dans un registre d'audit faisant autorité.

Avec ces garde-fous, le workflow permet le monitoring des landing pages de concurrents, les contrôles de régression visuelle après déploiement et la comparaison d'une même landing page entre pays grâce au routage optionnel country de Crawlbase. Chaque usage gagne à avoir sa propre baseline stable et des seuils calibrés pour la page.

Conclusion

Un moniteur visuel utile sépare la capture, la mesure et l'interprétation. Crawlbase fournit des screenshots rendus sans parc de navigateurs local ; les scores SSIM et de zone modifiée décident quelles captures méritent l'attention ; un modèle de vision ajoute une explication lisible ; et le HTML enregistré, les heatmaps et les ID de requête rendent chaque alerte vérifiable.

L'expérience montre que le pipeline fonctionne, pas que ses premiers seuils sont prêts pour la production. Une géométrie de capture cohérente et des explications étayées par des preuves sont ce qui transforme des différences de screenshots en détection de changements exploitable. Le dépôt contient une implémentation Python complète prête à être adaptée : créez un compte Crawlbase pour exécuter l'étape de screenshot sur vos propres pages publiques, et consultez les tarifs Crawlbase pour les coûts de capture et de rendu.

Foire aux questions (FAQ)

En quoi la détection de changements visuels diffère-t-elle de la comparaison de hashes HTML ?

Un hash HTML signale toute différence de balisage au niveau des octets, y compris invisible. La détection visuelle compare des screenshots rendus, elle fait donc ressortir les changements de mise en page, d'image et de présentation. Combiner les deux relie une différence visible au contenu qui la sous-tend.

Crawlbase peut-il capturer des landing pages riches en JavaScript ?

Oui. La Crawling API rend les pages dans un navigateur quand vous utilisez le JavaScript token (ou le Normal token avec javascript=true), et ses contrôles page_wait, de scroll et de clic préparent le contenu dynamique avant que screenshot=true ne capture la page.

Un score SSIM plus bas prouve-t-il toujours un changement important de la page ?

Non. Le SSIM mesure la similarité structurelle entre des tableaux d'image alignés. Des largeurs de viewport différentes, des mises en page responsive, du contenu tournant et d'autres variations de capture peuvent faire baisser le score même quand l'offre ou le message n'a pas changé.

Comment réduire les faux positifs dans le monitoring par screenshots ?

Gardez l'appareil et les dimensions de capture stables, standardisez les temps d'attente de rendu, fermez les overlays temporaires de la même façon à chaque fois, et excluez les régions dynamiques quand c'est faisable. Fixez les seuils à partir de plusieurs captures calmes de chaque cible plutôt que de vous fier à un score universel.

Pourquoi placer un seuil SSIM avant un modèle de vision ?

La comparaison d'images est peu coûteuse et déterministe comparée à un appel de modèle. La porte écarte les captures de routine qui ne franchissent pas les seuils, ce qui réduit l'usage du modèle tout en lui laissant expliquer les changements potentiellement significatifs. Un franchissement ne garantit pas que le changement soit significatif.

Comment automatiser les alertes et mettre à jour la baseline ?

Exécutez python final/monitor.py --once depuis cron ou un autre planificateur. Le moniteur stocke des alertes JSON locales, publie éventuellement des résumés sur Slack et conserve sa baseline dans SQLite. La baseline tourne après le nombre configuré de comparaisons, ou immédiatement quand une alerte est acquittée.

Commencer à construire

Crawlez n'importe quel site à grande échelle, sans combattre l'infrastructure.

Crawlbase gère les proxies, les empreintes et les CAPTCHA pour que votre équipe livre des pipelines de données au lieu de maintenir l'infrastructure de crawl. Jusqu'à 5 000 requêtes gratuites, sans carte bancaire requise.

En libre-service · Sans appel commercial requis · Volumes de crawl entreprise disponibles